Newer
Older
TIASjudge / tests / test_data_csv.py
"""同梱学習データ `data/data.csv` の検証(SPEC_02「データファイル形式」).

SPEC_02 の記述は以下.

- 1 行目: ヘッダ(`L,a,b,紫,暗赤,紅,淡紅,淡白,…` の繰り返し)
- 2 行目以降: 84 サンプル.列 1〜3 が L\\*a\\*b\\*,
  列 4 以降が 医師 × 5 色の 0/1(医師ごとに one-hot)
- 文字コードは UTF-8 に統一する(ENV_01「現行実装からの移行対応」)

※ 医師数(SPEC_02 は 38 名)は PLAN_02 ステップ 2 で旧実装の読込コードと突き合わせて確定するため,
  本ファイルでは実データの構造から導ける不変条件を検証する.
"""

from pathlib import Path

import pytest

COLOR_LABELS = ["紫", "暗赤", "紅", "淡紅", "淡白"]
EXPECTED_SAMPLE_COUNT = 84
LAB_COLUMN_COUNT = 3


class TestEncoding:
    """文字コード(ENV_01: Shift-JIS → UTF-8 に統一)."""

    def test_is_valid_utf8(self, data_csv_path: Path) -> None:
        data_csv_path.read_bytes().decode("utf-8")

    def test_has_no_bom(self, data_csv_path: Path) -> None:
        assert not data_csv_path.read_bytes().startswith(b"\xef\xbb\xbf")

    def test_color_labels_are_readable_as_utf8(self, data_csv_rows: list[list[str]]) -> None:
        """UTF-8 として読んだとき色名が文字化けせず取得できる."""
        header = data_csv_rows[0]
        assert header[LAB_COLUMN_COUNT : LAB_COLUMN_COUNT + 5] == COLOR_LABELS


class TestHeader:
    """ヘッダ行の構造."""

    def test_first_three_columns_are_lab(self, data_csv_rows: list[list[str]]) -> None:
        assert data_csv_rows[0][:LAB_COLUMN_COUNT] == ["L", "a", "b"]

    def test_evaluation_columns_are_multiple_of_five(self, data_csv_rows: list[list[str]]) -> None:
        """評価列は 5 色 1 組(医師 1 名分)の繰り返しである."""
        evaluation_column_count = len(data_csv_rows[0]) - LAB_COLUMN_COUNT
        assert evaluation_column_count % len(COLOR_LABELS) == 0
        assert evaluation_column_count // len(COLOR_LABELS) >= 38

    def test_color_labels_repeat_in_fixed_order(self, data_csv_rows: list[list[str]]) -> None:
        header = data_csv_rows[0]
        evaluation = header[LAB_COLUMN_COUNT:]
        doctor_count = len(evaluation) // len(COLOR_LABELS)
        for index in range(doctor_count):
            block = evaluation[index * 5 : index * 5 + 5]
            assert block == COLOR_LABELS, f"医師 {index} 番目のブロックが {block}"


class TestShape:
    """行数・列数."""

    def test_sample_count(self, data_csv_rows: list[list[str]]) -> None:
        """ヘッダを除く 84 サンプル(SPEC_02)."""
        assert len(data_csv_rows) - 1 == EXPECTED_SAMPLE_COUNT

    def test_all_rows_have_same_column_count(self, data_csv_rows: list[list[str]]) -> None:
        column_counts = {len(row) for row in data_csv_rows}
        assert len(column_counts) == 1, f"列数が不揃い: {sorted(column_counts)}"

    def test_no_empty_cells(self, data_csv_rows: list[list[str]]) -> None:
        empty = [
            (row_index, column_index)
            for row_index, row in enumerate(data_csv_rows)
            for column_index, cell in enumerate(row)
            if cell.strip() == ""
        ]
        assert empty == []


class TestLabColumns:
    """L\\*a\\*b\\* 列(SVM の入力 3 次元)."""

    def test_all_lab_values_parse_as_float(self, data_csv_rows: list[list[str]]) -> None:
        for row_index, row in enumerate(data_csv_rows[1:], start=2):
            for column_index in range(LAB_COLUMN_COUNT):
                cell = row[column_index]
                try:
                    float(cell)
                except ValueError:  # pragma: no cover - 失敗時のみ
                    pytest.fail(f"{row_index} 行 {column_index + 1} 列が数値でない: {cell!r}")

    def test_lightness_is_within_lab_range(self, data_csv_rows: list[list[str]]) -> None:
        """L\\* は 0〜100 の範囲に収まる(Lab 色空間の定義域)."""
        values = [float(row[0]) for row in data_csv_rows[1:]]
        assert all(0.0 <= value <= 100.0 for value in values), (min(values), max(values))

    @pytest.mark.parametrize("column_index", [1, 2])
    def test_chromatic_axes_are_within_practical_range(
        self, data_csv_rows: list[list[str]], column_index: int
    ) -> None:
        """a\\*・b\\* は舌色として現実的な範囲(|値| <= 128)に収まる."""
        values = [float(row[column_index]) for row in data_csv_rows[1:]]
        assert all(-128.0 <= value <= 128.0 for value in values), (min(values), max(values))

    def test_samples_are_not_all_identical(self, data_csv_rows: list[list[str]]) -> None:
        """学習データとして意味を持つよう Lab がばらついている."""
        lab_values = {tuple(row[:LAB_COLUMN_COUNT]) for row in data_csv_rows[1:]}
        assert len(lab_values) > 1


class TestEvaluationColumns:
    """医師評価列(SVM の教師データ)."""

    @staticmethod
    def _doctor_blocks(row: list[str]) -> list[list[str]]:
        evaluation = row[LAB_COLUMN_COUNT:]
        return [evaluation[i : i + 5] for i in range(0, len(evaluation), 5)]

    def test_values_are_binary(self, data_csv_rows: list[list[str]]) -> None:
        values = {cell for row in data_csv_rows[1:] for cell in row[LAB_COLUMN_COUNT:]}
        assert values <= {"0", "1"}, f"0/1 以外の値: {sorted(values - {'0', '1'})}"

    def test_each_doctor_block_is_one_hot_or_all_zero(self, data_csv_rows: list[list[str]]) -> None:
        """各医師 5 列は one-hot(未評価の医師は全 0)."""
        for row_index, row in enumerate(data_csv_rows[1:], start=2):
            for doctor_index, block in enumerate(self._doctor_blocks(row)):
                total = sum(int(cell) for cell in block)
                assert total in (0, 1), f"{row_index} 行 医師 {doctor_index} が {block}"

    def test_at_least_38_doctors_evaluated_every_sample(
        self, data_csv_rows: list[list[str]]
    ) -> None:
        """SPEC_02 が想定する 38 名分以上の評価が全サンプルに存在する."""
        for row_index, row in enumerate(data_csv_rows[1:], start=2):
            evaluated = sum(
                1 for block in self._doctor_blocks(row) if sum(int(cell) for cell in block) == 1
            )
            assert evaluated >= 38, f"{row_index} 行の有効評価が {evaluated} 名分"

    def test_every_color_appears_as_positive_example(self, data_csv_rows: list[list[str]]) -> None:
        """5 色すべてに正例が存在する(SVM 学習が成立する前提)."""
        positives = dict.fromkeys(COLOR_LABELS, 0)
        for row in data_csv_rows[1:]:
            for block in self._doctor_blocks(row):
                for label, cell in zip(COLOR_LABELS, block, strict=True):
                    positives[label] += int(cell)
        assert all(count > 0 for count in positives.values()), positives

    def test_trailing_all_zero_doctor_blocks_are_documented(
        self, data_csv_rows: list[list[str]]
    ) -> None:
        """全サンプルで全 0 の医師ブロック(=実質未使用の列)の数を記録する.

        SPEC_02 は 医師 38 名(193 列)と記すが,実ファイルは 198 列ある.
        差分が「全サンプル全 0 の末尾ブロック」であることを固定し,
        PLAN_02 ステップ 2 で旧実装の読込列数を確定する際の根拠とする.
        """
        blocks = [self._doctor_blocks(row) for row in data_csv_rows[1:]]
        doctor_count = len(blocks[0])
        all_zero = [
            index
            for index in range(doctor_count)
            if all(sum(int(cell) for cell in row[index]) == 0 for row in blocks)
        ]
        effective_doctor_count = doctor_count - len(all_zero)
        assert effective_doctor_count == 38, (
            f"有効な医師数 {effective_doctor_count}(全 0 ブロック: {all_zero})"
        )
        assert all_zero == list(range(38, doctor_count)), (
            f"全 0 ブロックが末尾以外にある: {all_zero}"
        )


class TestFileIntegrity:
    """旧実装ファイルからの変換忠実性(ENV_04「同梱データ」)."""

    def test_numeric_content_matches_original_when_available(
        self, repo_root: Path, data_csv_rows: list[list[str]]
    ) -> None:
        """`org/src` がある環境では,数値が旧 data.csv と完全一致する."""
        original_path = repo_root / "org" / "src" / "tonguediagnosisMFC2" / "data.csv"
        if not original_path.is_file():
            pytest.skip("旧実装 org/src が無い環境のためスキップ")
        original_text = original_path.read_bytes().decode("cp932")
        original_rows = [line.split(",") for line in original_text.splitlines()]
        assert len(original_rows) == len(data_csv_rows)
        for converted, original in zip(data_csv_rows[1:], original_rows[1:], strict=True):
            assert converted == original