diff --git a/CLAUDE.md b/CLAUDE.md index 47bbc96..e0cc92d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: PLAN_02 ステップ 1(プロジェクト骨格)完了.次はステップ 2(csv_io) +最新: PLAN_02 ステップ 2(core: CSV 読込)完了.次はステップ 3(color) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) diff --git "a/docs/04_SPEC/SPEC_02_\350\250\272\346\226\255\343\203\255\343\202\270\343\203\203\343\202\257\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_02_\350\250\272\346\226\255\343\203\255\343\202\270\343\203\203\343\202\257\344\273\225\346\247\230.md" index 63aa6aa..543a2e7 100644 --- "a/docs/04_SPEC/SPEC_02_\350\250\272\346\226\255\343\203\255\343\202\270\343\203\203\343\202\257\344\273\225\346\247\230.md" +++ "b/docs/04_SPEC/SPEC_02_\350\250\272\346\226\255\343\203\255\343\202\270\343\203\203\343\202\257\344\273\225\346\247\230.md" @@ -62,4 +62,5 @@ - 1 行目: ヘッダ(`L,a,b,紫,暗赤,紅,淡紅,淡白,…` の繰り返し) - 2 行目以降: 84 サンプル.列 1〜3 が L\*a\*b\*,列 4〜193 が医師 38 名 × 5 色の 0/1(各医師 5 列の one-hot) -- ※ 実ファイルは 198 列(医師ブロック 39 個分)あるが,末尾の 39 番目のブロック 5 列は全サンプルで全 0 の未使用列である(有効な評価データは上記の 38 名分=193 列).全 0 列は「正例が 2 未満の列は学習せずスコア 0」の規則により学習対象外となるため診断結果には影響しない.旧実装の読込列数(193 か 198 か)はステップ 2(csv_io)で `org/src` の読込コードと突き合わせて確定すること +- ※ 実ファイルは 198 列(医師ブロック 39 個分)あるが,末尾の 39 番目のブロック 5 列は全サンプルで全 0 の未使用列である(有効な評価データは上記の 38 名分=193 列) +- ※ 旧実装の読込列数は **193 列で確定**(ステップ 2 で `org/src` を確認済み: `EXP_OUTPUT_DIM = 190` = 38 名 × 5 色で,`CsvRead` は cols=193 で読み 194 列目以降にアクセスしない.ヘッダの「医師の数39」コメントは誤記).新実装(`core/csv_io.py`)も先頭 193 列のみを読む diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 1b1fb47..0d5d82b 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -23,3 +23,8 @@ - 設計判断: opencv-python は無指定だと 5.0.x が入るため,ENV_01 の「OpenCV 4.x」と `cv2.ml.SVM` の挙動同一性を優先して `>=4.10,<5` に固定 - 発見: `data.csv` の実体は 198 列(医師 39 ブロック)だが末尾ブロックは全 0 の未使用列で,有効データは SPEC_02 どおり 38 名分=193 列(SPEC_02 に注記済み).旧実装の読込列数はステップ 2 で `org/src` と突き合わせて確定する - 未実装・後段: `__main__.py` はプレースホルダ(ステップ 6 で GUI 起動に差し替え).pytest-cov はステップ 2 以降で導入検討 +- [x] **PLAN_02 ステップ 2 完了(core: CSV 読込)**: `core/csv_io.py`(変換行列 17×3・data.csv の読込と形式検証,専用例外 3 種)を実装(無人運転) + - 確定事項: 旧実装の data.csv 読込は **193 列**(`EXP_OUTPUT_DIM=190`=38 名 × 5 色,`CsvRead` cols=193).実ファイル末尾 5 列は旧実装でも未読.SPEC_02 の注記を確定版に更新済み + - 設計判断: dtype は旧実装(`CV_32F`・int キャスト)に合わせ lab=float32 / evaluations=int32.変換行列は計算精度優先で float64(旧実装も double で計算) + - レビューポイント(仕様に規定なし・挙動は現状維持のまま起床後の判断待ち): ① 変換行列の nan/inf セルを受理する(有限値チェック追加の要否)/ ② 評価列 `1.0` 表記を 1 と解釈する / ③ UTF-8 失敗時の cp932 フォールバックの要否(ENV_01 の UTF-8 統一方針との整合)/ ④ 変換行列は 17 行ちょうどを要求(旧実装は 18 行以上でも先頭 17 行で通す) + - 旧実装の潜在バグ発見: `Readfile()` の係数初期化ループが `i < 16` までで `a[16]` 等が未初期化(正常な 17 行ファイルなら影響なし.新実装には持ち込まない) diff --git a/src/tiasjudge/core/csv_io.py b/src/tiasjudge/core/csv_io.py new file mode 100644 index 0000000..86952a0 --- /dev/null +++ b/src/tiasjudge/core/csv_io.py @@ -0,0 +1,231 @@ +"""CSV 読込(変換行列・学習データ). + +SPEC_02「データファイル形式」に対応する読込と形式検証をまとめる. +旧実装(`org/src` の `Readfile()` / `CsvRead()`)と同じ実効データになるよう, +読み込む行数・列数と dtype(float32)を踏襲する. +""" + +from __future__ import annotations + +import csv +import logging +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +from numpy.typing import NDArray + +logger = logging.getLogger(__name__) + +# --- 形式に関する定数(SPEC_02「データファイル形式」)--- + +#: 変換行列 CSV の行数(多項式 17 項.SPEC_02 (1)) +TRANSFORM_COEFFICIENT_COUNT = 17 +#: 変換行列 CSV の列数(a[i], b[i], c[i] の 3 列) +TRANSFORM_COLUMN_COUNT = 3 + +#: data.csv の学習サンプル数(旧実装 EXP_DATA_ROW = 84) +SAMPLE_COUNT = 84 +#: SVM 入力の次元(L*a*b*.旧実装 EXP_INPUT_DIM = 3) +LAB_DIMENSION = 3 +#: 舌色の分類数(紫・暗赤・紅・淡紅・淡白) +COLOR_COUNT = 5 +#: 医師の人数(旧実装 EXP_OUTPUT_DIM 190 = 38 名 × 5 色) +DOCTOR_COUNT = 38 +#: 医師評価列の数(旧実装 EXP_OUTPUT_DIM = 190) +EVALUATION_COLUMN_COUNT = DOCTOR_COUNT * COLOR_COUNT +#: data.csv から読み込む列数(旧実装は 3 + 190 = 193 列だけを読み,以降の列は無視する) +TRAINING_COLUMN_COUNT = LAB_DIMENSION + EVALUATION_COLUMN_COUNT +#: data.csv のヘッダ行数 +HEADER_ROW_COUNT = 1 + +# 読込に使う文字コードの候補(同梱データは UTF-8.旧実装由来のファイルは Shift-JIS) +_ENCODINGS = ("utf-8", "cp932") + + +class CsvIoError(Exception): + """CSV 読込に関するエラーの基底(`core` の専用例外).""" + + +class DataFileNotFoundError(CsvIoError): + """CSV ファイルが存在しない,または読み取れない.""" + + +class DataFormatError(CsvIoError): + """CSV の形式が仕様と異なる(行数・列数不一致,数値でないセル等).""" + + +@dataclass(frozen=True) +class TrainingData: + """`data.csv` の学習データ(SPEC_02 (3) の SVM 入力). + + Attributes: + lab: 形状 (84, 3)・dtype float32.各行が 1 サンプルの L*a*b*. + evaluations: 形状 (84, 190)・dtype int32.医師 38 名 × 5 色の 0/1 評価. + 列 `doctor_index * 5 + color_index` が医師ごとの one-hot に対応する. + """ + + lab: NDArray[np.float32] + evaluations: NDArray[np.int32] + + @property + def sample_count(self) -> int: + """学習サンプル数(= 84).""" + return int(self.lab.shape[0]) + + @property + def output_dimension(self) -> int: + """SVM の出力次元(= 医師数 × 5 色 = 190).""" + return int(self.evaluations.shape[1]) + + @property + def doctor_count(self) -> int: + """医師の人数(= 38).""" + return self.output_dimension // COLOR_COUNT + + def color_columns(self, color_index: int) -> NDArray[np.int32]: + """指定した色(0〜4)に対応する全医師分の評価列を返す(形状 (84, 医師数)).""" + if not 0 <= color_index < COLOR_COUNT: + raise ValueError(f"color_index は 0〜{COLOR_COUNT - 1} の範囲: {color_index}") + return self.evaluations[:, color_index::COLOR_COUNT] + + +def load_transform_matrix(path: Path | str) -> NDArray[np.float64]: + """変換行列 CSV(17 行 × 3 列・ヘッダなし)を読み込む. + + Args: + path: 変換行列 CSV のパス(ユーザーが診断時に選択するファイル). + + Returns: + 形状 (17, 3)・dtype float64 の配列.列 0 が a[i](X 用), + 列 1 が b[i](Y 用),列 2 が c[i](Z 用)の係数(SPEC_02 (1)). + + Raises: + DataFileNotFoundError: ファイルが存在しない・読み取れない場合. + DataFormatError: 行数・列数が仕様と異なる,または数値でないセルがある場合. + """ + csv_path = Path(path) + rows = _read_rows(csv_path) + + if len(rows) != TRANSFORM_COEFFICIENT_COUNT: + raise DataFormatError( + f"変換行列 CSV は {TRANSFORM_COEFFICIENT_COUNT} 行必要ですが " + f"{len(rows)} 行です: {csv_path}" + ) + + matrix = np.empty((TRANSFORM_COEFFICIENT_COUNT, TRANSFORM_COLUMN_COUNT), dtype=np.float64) + for row_index, row in enumerate(rows): + if len(row) != TRANSFORM_COLUMN_COUNT: + raise DataFormatError( + f"変換行列 CSV の {row_index + 1} 行目は " + f"{TRANSFORM_COLUMN_COUNT} 列必要ですが {len(row)} 列です: {csv_path}" + ) + for column_index, cell in enumerate(row): + matrix[row_index, column_index] = _parse_float( + cell, csv_path, row_index + 1, column_index + 1 + ) + + logger.info("変換行列を読み込みました: %s", csv_path) + return matrix + + +def load_training_data(path: Path | str) -> TrainingData: + """学習データ `data.csv`(ヘッダ 1 行 + 84 サンプル)を読み込む. + + 列 1〜3 が L*a*b*,列 4〜193 が医師 38 名 × 5 色の 0/1 評価である. + 旧実装(`CsvRead(..., 84, 3 + 190, 2, 1)`)と同じく先頭 193 列のみを読み, + 194 列目以降(全サンプルで全 0 の未使用ブロック)は無視する. + + Args: + path: `data.csv` のパス(同梱データ.`core` はパスを固定しない). + + Returns: + Lab と医師評価を保持する `TrainingData`. + + Raises: + DataFileNotFoundError: ファイルが存在しない・読み取れない場合. + DataFormatError: 行数・列数が仕様と異なる,数値でないセル, + または評価列に 0/1 以外の値がある場合. + """ + csv_path = Path(path) + rows = _read_rows(csv_path) + + expected_row_count = HEADER_ROW_COUNT + SAMPLE_COUNT + if len(rows) != expected_row_count: + raise DataFormatError( + f"data.csv はヘッダ 1 行 + {SAMPLE_COUNT} サンプルの " + f"{expected_row_count} 行必要ですが {len(rows)} 行です: {csv_path}" + ) + + lab = np.empty((SAMPLE_COUNT, LAB_DIMENSION), dtype=np.float32) + evaluations = np.empty((SAMPLE_COUNT, EVALUATION_COLUMN_COUNT), dtype=np.int32) + + for sample_index, row in enumerate(rows[HEADER_ROW_COUNT:]): + line_number = sample_index + HEADER_ROW_COUNT + 1 + if len(row) < TRAINING_COLUMN_COUNT: + raise DataFormatError( + f"data.csv の {line_number} 行目は {TRAINING_COLUMN_COUNT} 列以上必要ですが " + f"{len(row)} 列です: {csv_path}" + ) + for lab_index, cell in enumerate(row[:LAB_DIMENSION]): + lab[sample_index, lab_index] = _parse_float(cell, csv_path, line_number, lab_index + 1) + for offset, cell in enumerate(row[LAB_DIMENSION:TRAINING_COLUMN_COUNT]): + evaluations[sample_index, offset] = _parse_binary( + cell, csv_path, line_number, LAB_DIMENSION + offset + 1 + ) + + logger.info( + "学習データを読み込みました: %s(%d サンプル × %d 列)", + csv_path, + SAMPLE_COUNT, + TRAINING_COLUMN_COUNT, + ) + return TrainingData(lab=lab, evaluations=evaluations) + + +def _read_rows(csv_path: Path) -> list[list[str]]: + """CSV を読み,空行を除いた行のリスト(各行はセルのリスト)を返す.""" + text = _read_text(csv_path) + rows = list(csv.reader(text.splitlines())) + return [row for row in rows if any(cell.strip() for cell in row)] + + +def _read_text(csv_path: Path) -> str: + """CSV をテキストとして読む(UTF-8 優先,失敗時は Shift-JIS を試す).""" + try: + data = csv_path.read_bytes() + except OSError as error: + raise DataFileNotFoundError(f"CSV ファイルを読み込めません: {csv_path}") from error + + for encoding in _ENCODINGS: + try: + return data.decode(encoding) + except UnicodeDecodeError: + continue + raise DataFormatError(f"CSV の文字コードを判別できません(UTF-8・Shift-JIS 以外): {csv_path}") + + +def _cell_position(csv_path: Path, line_number: int, column_number: int) -> str: + """エラーメッセージ用にセル位置を表す文字列を組み立てる.""" + return f"{csv_path} の {line_number} 行 {column_number} 列" + + +def _parse_float(cell: str, csv_path: Path, line_number: int, column_number: int) -> float: + """セルを実数として解釈する.数値でなければ `DataFormatError`.""" + try: + return float(cell.strip()) + except ValueError as error: + raise DataFormatError( + f"{_cell_position(csv_path, line_number, column_number)}が数値ではありません: {cell!r}" + ) from error + + +def _parse_binary(cell: str, csv_path: Path, line_number: int, column_number: int) -> int: + """医師評価のセル(0 または 1)を解釈する.それ以外は `DataFormatError`.""" + value = _parse_float(cell, csv_path, line_number, column_number) + if value not in (0.0, 1.0): + raise DataFormatError( + f"{_cell_position(csv_path, line_number, column_number)}は " + f"0 か 1 である必要があります: {cell!r}" + ) + return int(value) diff --git a/tests/core/test_csv_io.py b/tests/core/test_csv_io.py new file mode 100644 index 0000000..5a8ef4a --- /dev/null +++ b/tests/core/test_csv_io.py @@ -0,0 +1,431 @@ +"""`core/csv_io.py` の検証(SPEC_02「データファイル形式」・PLAN_02 ステップ 2). + +仕様上の要件は以下. + +- 変換行列 CSV: 17 行 × 3 列(ヘッダなし).各行が `a[i], b[i], c[i]`(X, Y, Z 用係数) +- `data.csv`: 1 行目ヘッダ,2 行目以降 84 サンプル. + 列 1〜3 が L\\*a\\*b\\*,列 4〜193 が医師 38 名 × 5 色の 0/1(各医師 5 列の one-hot). + 実ファイルの 194 列目以降(全 0 の未使用ブロック)は診断に使わない +- 不正形式・欠損ファイルでは `core` の専用例外を送出する(error-handling.md,PLAN_02 完了条件) +""" + +from pathlib import Path + +import numpy as np +import pytest + +from tiasjudge.core.csv_io import ( + COLOR_COUNT, + DOCTOR_COUNT, + EVALUATION_COLUMN_COUNT, + LAB_DIMENSION, + SAMPLE_COUNT, + TRAINING_COLUMN_COUNT, + TRANSFORM_COEFFICIENT_COUNT, + CsvIoError, + DataFileNotFoundError, + DataFormatError, + TrainingData, + load_training_data, + load_transform_matrix, +) + +COLOR_LABELS = ["紫", "暗赤", "紅", "淡紅", "淡白"] +TRANSFORM_COLUMN_COUNT = 3 +TOTAL_TRAINING_ROW_COUNT = SAMPLE_COUNT + 1 # ヘッダ 1 行 + 84 サンプル + + +# --- テスト用 CSV の生成ヘルパー --- + + +def write_csv(path: Path, rows: list[list[str]], encoding: str = "utf-8") -> Path: + """行のリストを CSV として書き出す.""" + text = "\n".join(",".join(row) for row in rows) + "\n" + path.write_bytes(text.encode(encoding)) + return path + + +def transform_rows( + row_count: int = TRANSFORM_COEFFICIENT_COUNT, column_count: int = TRANSFORM_COLUMN_COUNT +) -> list[list[str]]: + """変換行列 CSV の行(値は `行番号.列番号` で一意になるようにする).""" + return [ + [f"{row_index}.{column_index}" for column_index in range(column_count)] + for row_index in range(row_count) + ] + + +def training_header(doctor_count: int = DOCTOR_COUNT) -> list[str]: + return ["L", "a", "b"] + COLOR_LABELS * doctor_count + + +def training_sample_row( + sample_index: int, doctor_count: int = DOCTOR_COUNT, evaluation_columns: int | None = None +) -> list[str]: + """1 サンプル分の行を作る.医師 d の評価色は `(sample_index + d) % 5` とする.""" + lab = [f"{50 + sample_index}", f"{sample_index}.5", f"{-sample_index}.25"] + evaluations: list[str] = [] + for doctor_index in range(doctor_count): + color_index = (sample_index + doctor_index) % COLOR_COUNT + evaluations += ["1" if index == color_index else "0" for index in range(COLOR_COUNT)] + if evaluation_columns is not None: + evaluations = (evaluations + ["0"] * evaluation_columns)[:evaluation_columns] + return lab + evaluations + + +def training_rows( + sample_count: int = SAMPLE_COUNT, + doctor_count: int = DOCTOR_COUNT, + evaluation_columns: int | None = None, +) -> list[list[str]]: + rows = [training_header(doctor_count)] + rows += [ + training_sample_row(index, doctor_count, evaluation_columns) + for index in range(sample_count) + ] + return rows + + +@pytest.fixture +def valid_training_csv(tmp_path: Path) -> Path: + return write_csv(tmp_path / "data.csv", training_rows()) + + +@pytest.fixture(scope="module") +def real_data(data_csv_path: Path) -> TrainingData: + """同梱 `data/data.csv` を読み込んだ結果.""" + return load_training_data(data_csv_path) + + +class TestLoadTransformMatrixNormal: + """変換行列 CSV の正常系(SPEC_02「変換行列 CSV」).""" + + def test_returns_17_by_3_matrix(self, tmp_path: Path) -> None: + matrix = load_transform_matrix(write_csv(tmp_path / "matrix.csv", transform_rows())) + assert matrix.shape == (TRANSFORM_COEFFICIENT_COUNT, TRANSFORM_COLUMN_COUNT) + + def test_values_are_read_in_row_major_order(self, tmp_path: Path) -> None: + """各行が `a[i], b[i], c[i]`.列 0=a(X 用),列 1=b(Y 用),列 2=c(Z 用).""" + matrix = load_transform_matrix(write_csv(tmp_path / "matrix.csv", transform_rows())) + for row_index in range(TRANSFORM_COEFFICIENT_COUNT): + for column_index in range(TRANSFORM_COLUMN_COUNT): + expected = float(f"{row_index}.{column_index}") + assert matrix[row_index, column_index] == pytest.approx(expected) + + def test_is_floating_point(self, tmp_path: Path) -> None: + matrix = load_transform_matrix(write_csv(tmp_path / "matrix.csv", transform_rows())) + assert np.issubdtype(matrix.dtype, np.floating) + + def test_accepts_negative_and_scientific_notation(self, tmp_path: Path) -> None: + """キャリブレーション結果は負値・指数表記を含みうる.""" + rows = [["-1.5", "2.5e-3", "+0.25"] for _ in range(TRANSFORM_COEFFICIENT_COUNT)] + matrix = load_transform_matrix(write_csv(tmp_path / "matrix.csv", rows)) + assert matrix[0].tolist() == pytest.approx([-1.5, 0.0025, 0.25]) + + def test_accepts_string_path(self, tmp_path: Path) -> None: + """`core` はパスを引数で受け取る(str でも Path でも良い).""" + path = write_csv(tmp_path / "matrix.csv", transform_rows()) + assert load_transform_matrix(str(path)).shape == (TRANSFORM_COEFFICIENT_COUNT, 3) + + def test_precision_is_not_truncated(self, tmp_path: Path) -> None: + """係数は多項式変換に直接使うため丸めずに保持する.""" + rows = [["0.123456789012345", "1", "2"] for _ in range(TRANSFORM_COEFFICIENT_COUNT)] + matrix = load_transform_matrix(write_csv(tmp_path / "matrix.csv", rows)) + assert matrix[0, 0] == pytest.approx(0.123456789012345, abs=1e-15) + + +class TestLoadTransformMatrixErrors: + """変換行列 CSV の異常系(不正形式で専用例外).""" + + @pytest.mark.parametrize("row_count", [0, 1, 16, 18, 34]) + def test_row_count_other_than_17_is_rejected(self, tmp_path: Path, row_count: int) -> None: + path = write_csv(tmp_path / "matrix.csv", transform_rows(row_count=row_count) or [[""]]) + with pytest.raises(DataFormatError): + load_transform_matrix(path) + + @pytest.mark.parametrize("column_count", [1, 2, 4]) + def test_column_count_other_than_3_is_rejected(self, tmp_path: Path, column_count: int) -> None: + path = write_csv(tmp_path / "matrix.csv", transform_rows(column_count=column_count)) + with pytest.raises(DataFormatError): + load_transform_matrix(path) + + def test_ragged_row_is_rejected(self, tmp_path: Path) -> None: + rows = transform_rows() + rows[5] = ["1", "2"] + with pytest.raises(DataFormatError): + load_transform_matrix(write_csv(tmp_path / "matrix.csv", rows)) + + def test_non_numeric_cell_is_rejected(self, tmp_path: Path) -> None: + rows = transform_rows() + rows[3][1] = "abc" + with pytest.raises(DataFormatError): + load_transform_matrix(write_csv(tmp_path / "matrix.csv", rows)) + + def test_empty_cell_is_rejected(self, tmp_path: Path) -> None: + rows = transform_rows() + rows[0][2] = "" + with pytest.raises(DataFormatError): + load_transform_matrix(write_csv(tmp_path / "matrix.csv", rows)) + + def test_header_row_is_rejected(self, tmp_path: Path) -> None: + """変換行列 CSV はヘッダなしのため,ヘッダ付きは形式エラー.""" + rows = [["a", "b", "c"], *transform_rows()] + with pytest.raises(DataFormatError): + load_transform_matrix(write_csv(tmp_path / "matrix.csv", rows)) + + def test_missing_file_raises_not_found(self, tmp_path: Path) -> None: + with pytest.raises(DataFileNotFoundError): + load_transform_matrix(tmp_path / "missing.csv") + + def test_directory_raises_not_found(self, tmp_path: Path) -> None: + directory = tmp_path / "dir.csv" + directory.mkdir() + with pytest.raises(DataFileNotFoundError): + load_transform_matrix(directory) + + def test_undecodable_bytes_raise_format_error(self, tmp_path: Path) -> None: + path = tmp_path / "matrix.csv" + path.write_bytes(b"\x80\x81\n" * TRANSFORM_COEFFICIENT_COUNT) + with pytest.raises(DataFormatError): + load_transform_matrix(path) + + def test_errors_share_a_common_base(self, tmp_path: Path) -> None: + """GUI 側が 1 つの基底例外で捕捉できる(error-handling.md).""" + assert issubclass(DataFileNotFoundError, CsvIoError) + assert issubclass(DataFormatError, CsvIoError) + with pytest.raises(CsvIoError): + load_transform_matrix(tmp_path / "missing.csv") + + +class TestLoadTrainingDataNormal: + """`data.csv` の正常系(生成したデータ).""" + + def test_shapes_follow_spec(self, valid_training_csv: Path) -> None: + data = load_training_data(valid_training_csv) + assert data.lab.shape == (SAMPLE_COUNT, LAB_DIMENSION) + assert data.evaluations.shape == (SAMPLE_COUNT, EVALUATION_COLUMN_COUNT) + + def test_counts_follow_spec(self, valid_training_csv: Path) -> None: + data = load_training_data(valid_training_csv) + assert data.sample_count == SAMPLE_COUNT + assert data.output_dimension == EVALUATION_COLUMN_COUNT == 190 + assert data.doctor_count == DOCTOR_COUNT == 38 + + def test_lab_values_come_from_first_three_columns(self, valid_training_csv: Path) -> None: + data = load_training_data(valid_training_csv) + for index in range(SAMPLE_COUNT): + expected = [float(cell) for cell in training_sample_row(index)[:LAB_DIMENSION]] + assert data.lab[index].tolist() == pytest.approx(expected) + + def test_evaluation_columns_come_from_columns_4_to_193(self, valid_training_csv: Path) -> None: + data = load_training_data(valid_training_csv) + for index in range(SAMPLE_COUNT): + expected = [int(cell) for cell in training_sample_row(index)[LAB_DIMENSION:]] + assert data.evaluations[index].tolist() == expected + + def test_evaluation_layout_is_doctor_major(self, valid_training_csv: Path) -> None: + """列 `doctor_index * 5 + color_index` が医師ごとの one-hot に対応する.""" + data = load_training_data(valid_training_csv) + for sample_index in range(SAMPLE_COUNT): + for doctor_index in range(DOCTOR_COUNT): + block = data.evaluations[ + sample_index, + doctor_index * COLOR_COUNT : (doctor_index + 1) * COLOR_COUNT, + ] + assert block.sum() == 1 + expected_color = (sample_index + doctor_index) % COLOR_COUNT + assert int(block.argmax()) == expected_color + + def test_evaluations_are_binary(self, valid_training_csv: Path) -> None: + data = load_training_data(valid_training_csv) + assert set(np.unique(data.evaluations).tolist()) <= {0, 1} + + def test_color_columns_returns_all_doctors_for_one_color( + self, valid_training_csv: Path + ) -> None: + data = load_training_data(valid_training_csv) + for color_index in range(COLOR_COUNT): + columns = data.color_columns(color_index) + assert columns.shape == (SAMPLE_COUNT, DOCTOR_COUNT) + expected = data.evaluations[:, color_index::COLOR_COUNT] + assert np.array_equal(columns, expected) + + def test_color_columns_matches_generated_one_hot(self, valid_training_csv: Path) -> None: + data = load_training_data(valid_training_csv) + for color_index in range(COLOR_COUNT): + columns = data.color_columns(color_index) + for sample_index in range(SAMPLE_COUNT): + for doctor_index in range(DOCTOR_COUNT): + expected = int((sample_index + doctor_index) % COLOR_COUNT == color_index) + assert columns[sample_index, doctor_index] == expected + + @pytest.mark.parametrize("color_index", [-1, COLOR_COUNT, 100]) + def test_color_columns_rejects_out_of_range( + self, valid_training_csv: Path, color_index: int + ) -> None: + data = load_training_data(valid_training_csv) + with pytest.raises(ValueError): + data.color_columns(color_index) + + def test_extra_trailing_columns_are_ignored(self, tmp_path: Path) -> None: + """実ファイルの 194 列目以降(未使用ブロック)は読み飛ばす(SPEC_02 注記).""" + rows = training_rows(doctor_count=39) + path = write_csv(tmp_path / "data.csv", rows) + data = load_training_data(path) + assert data.evaluations.shape == (SAMPLE_COUNT, EVALUATION_COLUMN_COUNT) + expected = [int(cell) for cell in training_sample_row(0, doctor_count=39)[LAB_DIMENSION:]] + assert data.evaluations[0].tolist() == expected[:EVALUATION_COLUMN_COUNT] + + def test_accepts_cp932_encoded_file(self, tmp_path: Path) -> None: + """旧実装由来のファイル(Shift-JIS ヘッダ)も読める.""" + path = write_csv(tmp_path / "data_sjis.csv", training_rows(), encoding="cp932") + data = load_training_data(path) + assert data.lab.shape == (SAMPLE_COUNT, LAB_DIMENSION) + + def test_result_is_immutable_container(self, valid_training_csv: Path) -> None: + data = load_training_data(valid_training_csv) + assert isinstance(data, TrainingData) + with pytest.raises(Exception): # noqa: B017 - frozen dataclass の再代入禁止 + data.lab = np.zeros((SAMPLE_COUNT, LAB_DIMENSION), dtype=np.float32) # type: ignore[misc] + + +class TestLoadTrainingDataErrors: + """`data.csv` の異常系.""" + + @pytest.mark.parametrize("sample_count", [0, 1, 83, 85]) + def test_sample_count_other_than_84_is_rejected( + self, tmp_path: Path, sample_count: int + ) -> None: + path = write_csv(tmp_path / "data.csv", training_rows(sample_count=sample_count)) + with pytest.raises(DataFormatError): + load_training_data(path) + + def test_missing_header_is_rejected(self, tmp_path: Path) -> None: + """ヘッダ 1 行 + 84 サンプルの計 85 行を要求する.""" + rows = training_rows()[1:] + with pytest.raises(DataFormatError): + load_training_data(write_csv(tmp_path / "data.csv", rows)) + + @pytest.mark.parametrize("doctor_count", [0, 1, 37]) + def test_insufficient_evaluation_columns_are_rejected( + self, tmp_path: Path, doctor_count: int + ) -> None: + path = write_csv(tmp_path / "data.csv", training_rows(doctor_count=doctor_count)) + with pytest.raises(DataFormatError): + load_training_data(path) + + def test_column_short_by_one_is_rejected(self, tmp_path: Path) -> None: + rows = training_rows(evaluation_columns=EVALUATION_COLUMN_COUNT - 1) + with pytest.raises(DataFormatError): + load_training_data(write_csv(tmp_path / "data.csv", rows)) + + def test_single_ragged_row_is_rejected(self, tmp_path: Path) -> None: + rows = training_rows() + rows[42] = rows[42][: TRAINING_COLUMN_COUNT - 1] + with pytest.raises(DataFormatError): + load_training_data(write_csv(tmp_path / "data.csv", rows)) + + def test_non_numeric_lab_cell_is_rejected(self, tmp_path: Path) -> None: + rows = training_rows() + rows[10][1] = "N/A" + with pytest.raises(DataFormatError): + load_training_data(write_csv(tmp_path / "data.csv", rows)) + + def test_empty_lab_cell_is_rejected(self, tmp_path: Path) -> None: + rows = training_rows() + rows[7][0] = "" + with pytest.raises(DataFormatError): + load_training_data(write_csv(tmp_path / "data.csv", rows)) + + @pytest.mark.parametrize("value", ["2", "-1", "0.5", "x"]) + def test_evaluation_cell_other_than_0_or_1_is_rejected( + self, tmp_path: Path, value: str + ) -> None: + rows = training_rows() + rows[20][LAB_DIMENSION + 3] = value + with pytest.raises(DataFormatError): + load_training_data(write_csv(tmp_path / "data.csv", rows)) + + def test_evaluation_error_in_last_valid_column_is_detected(self, tmp_path: Path) -> None: + """193 列目(最後の有効列)も検証対象である.""" + rows = training_rows() + rows[1][TRAINING_COLUMN_COUNT - 1] = "9" + with pytest.raises(DataFormatError): + load_training_data(write_csv(tmp_path / "data.csv", rows)) + + def test_missing_file_raises_not_found(self, tmp_path: Path) -> None: + with pytest.raises(DataFileNotFoundError): + load_training_data(tmp_path / "missing.csv") + + def test_directory_raises_not_found(self, tmp_path: Path) -> None: + directory = tmp_path / "data_dir" + directory.mkdir() + with pytest.raises(DataFileNotFoundError): + load_training_data(directory) + + def test_undecodable_bytes_raise_format_error(self, tmp_path: Path) -> None: + path = tmp_path / "data.csv" + path.write_bytes(b"\x80\x81,\x80\x81\n" * TOTAL_TRAINING_ROW_COUNT) + with pytest.raises(DataFormatError): + load_training_data(path) + + def test_error_message_points_to_the_file(self, tmp_path: Path) -> None: + """GUI がそのまま提示できるよう,メッセージにファイル名を含める.""" + rows = training_rows() + rows[3][0] = "abc" + path = write_csv(tmp_path / "broken.csv", rows) + with pytest.raises(DataFormatError) as error: + load_training_data(path) + assert "broken.csv" in str(error.value) + + +class TestLoadRealDataCsv: + """同梱 `data/data.csv` の読込(PLAN_02 ステップ 2 完了条件「実データを読める」).""" + + def test_shapes(self, real_data: TrainingData) -> None: + assert real_data.lab.shape == (SAMPLE_COUNT, LAB_DIMENSION) + assert real_data.evaluations.shape == (SAMPLE_COUNT, EVALUATION_COLUMN_COUNT) + assert real_data.doctor_count == DOCTOR_COUNT + + def test_lab_matches_file_content( + self, real_data: TrainingData, data_csv_rows: list[list[str]] + ) -> None: + for index, row in enumerate(data_csv_rows[1:]): + expected = [float(cell) for cell in row[:LAB_DIMENSION]] + assert real_data.lab[index].tolist() == pytest.approx(expected, rel=1e-6) + + def test_evaluations_match_first_190_evaluation_columns( + self, real_data: TrainingData, data_csv_rows: list[list[str]] + ) -> None: + for index, row in enumerate(data_csv_rows[1:]): + expected = [int(cell) for cell in row[LAB_DIMENSION:TRAINING_COLUMN_COUNT]] + assert real_data.evaluations[index].tolist() == expected + + def test_evaluations_are_binary(self, real_data: TrainingData) -> None: + assert set(np.unique(real_data.evaluations).tolist()) <= {0, 1} + + def test_each_doctor_block_is_one_hot_or_all_zero(self, real_data: TrainingData) -> None: + blocks = real_data.evaluations.reshape(SAMPLE_COUNT, DOCTOR_COUNT, COLOR_COUNT) + assert set(blocks.sum(axis=2).ravel().tolist()) <= {0, 1} + + def test_every_color_has_positive_examples(self, real_data: TrainingData) -> None: + """5 色すべてに正例がある(SPEC_02 (3) の学習成立の前提).""" + for color_index in range(COLOR_COUNT): + assert real_data.color_columns(color_index).sum() > 0 + + def test_lab_is_within_valid_range(self, real_data: TrainingData) -> None: + assert real_data.lab[:, 0].min() >= 0.0 + assert real_data.lab[:, 0].max() <= 100.0 + assert np.abs(real_data.lab[:, 1:]).max() <= 128.0 + + def test_unused_trailing_block_is_excluded(self, data_csv_rows: list[list[str]]) -> None: + """194 列目以降(全 0 の未使用ブロック)が読み込み対象外であること.""" + assert len(data_csv_rows[0]) > TRAINING_COLUMN_COUNT, "実ファイルは 193 列より多い前提" + trailing = [int(cell) for row in data_csv_rows[1:] for cell in row[TRAINING_COLUMN_COUNT:]] + assert set(trailing) == {0}, "無視する末尾列に非 0 の評価が含まれる" + + def test_repeated_load_is_deterministic(self, data_csv_path: Path) -> None: + first = load_training_data(data_csv_path) + second = load_training_data(data_csv_path) + assert np.array_equal(first.lab, second.lab) + assert np.array_equal(first.evaluations, second.evaluations)