Newer
Older
TIASJudgeModel / tests / training / test_cross_validation.py
"""交差検証の手順のテスト(SPEC_02「交差検証の実行」「程度(ややあり)の扱い」).

最重要:
- 分割は manifest 全体で作り,`data.severities` に関わらず同一(同じ seed なら同じ分割)
- 各 fold で held_out・学習・検証の患者が互いに重ならず,held_out は fold 間で manifest を分割する
- `data.severities` は学習用と検証用にだけ適用し,held_out は全程度のまま評価する

合成データ(scripts/make_synthetic_dataset.py)・極小設定(resnet18・pretrained なし・1 エポック)で
実際に学習を回す(`@pytest.mark.slow`).
"""

from __future__ import annotations

import importlib.util
import sys
from pathlib import Path
from types import ModuleType

import numpy as np
import pytest

from tias_judge_model.data.manifest import read_manifest
from tias_judge_model.schema import ExperimentConfig
from tias_judge_model.training.cross_validation import CrossValidationResult, run_cross_validation

SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "make_synthetic_dataset.py"
N_FOLDS = 5


def _load_script() -> ModuleType:
    spec = importlib.util.spec_from_file_location("make_synthetic_dataset_cv", SCRIPT_PATH)
    assert spec is not None and spec.loader is not None
    module = importlib.util.module_from_spec(spec)
    sys.modules[spec.name] = module
    spec.loader.exec_module(module)
    return module


def _config(root: Path, severities: list[str]) -> ExperimentConfig:
    return ExperimentConfig.model_validate(
        {
            "data": {"root": str(root), "severities": severities},
            "split": {"seed": 7, "n_folds": N_FOLDS},
            "model": {"name": "resnet18", "pretrained": False},
            "train": {
                "image_size": 32,
                "batch_size": 8,
                "max_epochs": 1,
                "warmup_epochs": 0,
                "inner_val_ratio": 0.2,
                "num_workers": 0,
                "amp": False,
            },
        }
    )


@pytest.fixture(scope="module")
def cv_runs(
    tmp_path_factory: pytest.TempPathFactory,
) -> tuple[list, CrossValidationResult, CrossValidationResult]:
    root = tmp_path_factory.mktemp("cv") / "dataset"
    _load_script().generate(root, per_dir=6, seed=0)
    records = read_manifest(root / "manifest.csv")
    with_mild = run_cross_validation(
        _config(root, ["strong", "mild", "normal"]), records, root.parent / "ck_with"
    )
    without_mild = run_cross_validation(
        _config(root, ["strong", "normal"]), records, root.parent / "ck_without"
    )
    return records, with_mild, without_mild


pytestmark = pytest.mark.slow


def test_runs_all_folds(cv_runs) -> None:
    _, with_mild, without_mild = cv_runs
    assert [f.index for f in with_mild.folds] == list(range(N_FOLDS))
    assert len(without_mild.folds) == N_FOLDS


def test_patients_disjoint_within_each_fold(cv_runs) -> None:
    records, with_mild, without_mild = cv_runs
    all_patients = {r.patient_id for r in records}
    for result in (with_mild, without_mild):
        for f in result.folds:
            train, val, held = set(f.train_patients), set(f.val_patients), set(f.held_out_patients)
            assert train and val and held
            assert train.isdisjoint(val)
            assert train.isdisjoint(held)
            assert val.isdisjoint(held)
            assert train | val | held == all_patients


def test_held_out_partitions_manifest_across_folds(cv_runs) -> None:
    records, with_mild, _ = cv_runs
    held_all = [p for f in with_mild.folds for p in f.held_out_patients]
    assert len(held_all) == len(set(held_all))
    assert set(held_all) == {r.patient_id for r in records}
    indices = np.concatenate([f.held_out_indices for f in with_mild.folds])
    assert sorted(indices.tolist()) == list(range(len(records)))


def test_split_is_same_regardless_of_severities(cv_runs) -> None:
    _, with_mild, without_mild = cv_runs
    for a, b in zip(with_mild.folds, without_mild.folds, strict=True):
        assert a.held_out_patients == b.held_out_patients
        assert a.train_patients == b.train_patients
        assert a.val_patients == b.val_patients
        np.testing.assert_array_equal(a.held_out_indices, b.held_out_indices)


def test_held_out_keeps_mild_even_when_excluded_from_training(cv_runs) -> None:
    records, _, without_mild = cv_runs
    severities = np.array([r.severity for r in records])
    held_sev = np.concatenate([severities[f.held_out_indices] for f in without_mild.folds])
    assert (held_sev == "mild").sum() == (severities == "mild").sum()
    for f in without_mild.folds:
        held_ids = set(f.held_out_patients)
        expected = {i for i, r in enumerate(records) if r.patient_id in held_ids}
        assert set(f.held_out_indices.tolist()) == expected


def test_severities_applied_only_to_train_and_val(cv_runs) -> None:
    _, with_mild, without_mild = cv_runs
    for f in without_mild.folds:
        for split in ("train", "val"):
            mild_counts = {g: n for g, n in f.group_counts[split].items() if g.endswith("_mild")}
            assert sum(mild_counts.values()) == 0, split
        assert sum(n for g, n in f.group_counts["held_out"].items() if g.endswith("_mild")) > 0
    # ややあり込みでは学習用に mild が入る
    assert any(
        sum(n for g, n in f.group_counts["train"].items() if g.endswith("_mild")) > 0
        for f in with_mild.folds
    )


def test_held_out_probabilities_shape(cv_runs) -> None:
    _, with_mild, _ = cv_runs
    for f in with_mild.folds:
        assert f.held_out_probs.shape == (len(f.held_out_indices), 5)
        np.testing.assert_allclose(f.held_out_probs.sum(axis=1), 1.0, atol=1e-4)
        assert 1 <= f.best_epoch <= len(f.history)


def test_checkpoints_per_fold(cv_runs, tmp_path_factory) -> None:
    records, _, _ = cv_runs
    # fixture と同じ tmp の下に fold<k>.pt が書かれている
    base = tmp_path_factory.getbasetemp()
    found = sorted(p.name for p in base.rglob("ck_with/fold*.pt"))
    assert found == [f"fold{k}.pt" for k in range(N_FOLDS)]