"""tias-train cv の結合テスト.
仕様: SPEC_01「CLI」「実験の出力」「回収ホワイトリスト」・SPEC_02「出力」.
- runs/<実験ID>/ に SPEC_02「出力」の表のファイルが揃う
- metrics.json に部分集合ごとの指標・mild の内訳・所要時間・実行環境がある
- history.csv にエポックの所要秒数の列がある
- 実験 ID は `YYYYmmdd-HHMMSS_<設定ファイル名>_<git短縮ハッシュ>`.標準出力の最終行が実験 ID
- 回収対象の出力(ログを含む)に患者 ID・ファイル名が出ない
- split.json・predictions.csv・checkpoints は回収ホワイトリストの対象外(checkpoints は明示時のみ)
- 「ややあり除外」でも held_out は全程度で評価される
合成データ(scripts/make_synthetic_dataset.py)のファイル名を目立つ文字列に付け替えて使う.
"""
from __future__ import annotations
import contextlib
import csv
import importlib.util
import io
import json
import logging
import re
import sys
from pathlib import Path
from types import ModuleType
import numpy as np
import pytest
import yaml
import tias_judge_model.cli.train as cli
from tias_judge_model.data.manifest import build_manifest_records, write_manifest
from tias_judge_model.remote.whitelist import is_pull_allowed
pytestmark = pytest.mark.slow
SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "make_synthetic_dataset.py"
SECRET = "SECRETPT"
GIT = "abc1234"
N_FOLDS = 5
PER_DIR = 6
CONFIG_NAME = "cv_tiny_nomild"
RECOVERABLE = ["config.yaml", "metrics.json", "history.csv", "confusion_matrix.csv", "log.txt"]
def _load_script() -> ModuleType:
spec = importlib.util.spec_from_file_location("make_synthetic_dataset_train", 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
@pytest.fixture(scope="module")
def run(tmp_path_factory: pytest.TempPathFactory) -> dict:
tmp = tmp_path_factory.mktemp("train_cli")
data_root = tmp / "dataset"
_load_script().generate(data_root, per_dir=PER_DIR, seed=0)
for n, png in enumerate(sorted((data_root / "images").rglob("*.png"))):
png.rename(png.with_name(f"{SECRET}{n:04d}.png"))
write_manifest(build_manifest_records(data_root / "images"), data_root / "manifest.csv")
config = tmp / f"{CONFIG_NAME}.yaml"
config.write_text(
yaml.safe_dump(
{
"data": {"root": data_root.as_posix(), "severities": ["strong", "normal"]},
"split": {"seed": 3, "n_folds": N_FOLDS},
"model": {"name": "resnet18", "pretrained": False},
"train": {
"image_size": 32,
"batch_size": 8,
"max_epochs": 2,
"warmup_epochs": 1,
"inner_val_ratio": 0.2,
"num_workers": 0,
"amp": False,
},
}
),
encoding="utf-8",
)
out_root = tmp / "runs"
mp = pytest.MonkeyPatch()
mp.delenv("TIAS_DATA_ROOT", raising=False)
mp.setenv("TIAS_GIT_COMMIT", GIT)
root_logger = logging.getLogger()
old_level = root_logger.level
root_logger.setLevel(logging.INFO)
out, err = io.StringIO(), io.StringIO()
stderr_handler = logging.StreamHandler(err)
root_logger.addHandler(stderr_handler)
try:
with contextlib.redirect_stdout(out):
rc = cli.main(["cv", "--config", str(config), "--out-root", str(out_root)])
finally:
root_logger.removeHandler(stderr_handler)
root_logger.setLevel(old_level)
mp.undo()
run_dirs = [p for p in out_root.iterdir() if p.is_dir()]
return {
"rc": rc,
"stdout": out.getvalue(),
"stderr": err.getvalue(),
"run_dirs": run_dirs,
"run_dir": run_dirs[0] if run_dirs else None,
"n_images": 9 * PER_DIR,
}
def test_exit_code_and_single_run_dir(run: dict) -> None:
assert run["rc"] == 0
assert len(run["run_dirs"]) == 1
def test_run_id_format_and_last_stdout_line(run: dict) -> None:
run_id = run["run_dir"].name
assert re.fullmatch(rf"\d{{8}}-\d{{6}}_{CONFIG_NAME}_{GIT}", run_id)
lines = [ln for ln in run["stdout"].splitlines() if ln.strip()]
assert lines[-1] == run_id
def test_output_files_exist(run: dict) -> None:
d = run["run_dir"]
for name in [*RECOVERABLE, "split.json", "predictions.csv"]:
assert (d / name).is_file(), name
pngs = list((d / "figures").glob("*.png"))
assert pngs
names = " ".join(p.name for p in pngs)
assert "confusion" in names # 混同行列
assert len(pngs) >= 2 # 学習曲線 + 混同行列
ckpts = sorted(p.name for p in (d / "checkpoints").glob("*.pt"))
assert ckpts == [f"fold{k}.pt" for k in range(N_FOLDS)]
assert (d / "log.txt").stat().st_size > 0
def test_config_yaml_records_git_and_seed(run: dict) -> None:
doc = yaml.safe_load((run["run_dir"] / "config.yaml").read_text(encoding="utf-8"))
text = json.dumps(doc, ensure_ascii=False)
assert GIT in text
# 解決済み設定(model・train を含む)と seed が記録される
assert doc["config"]["split"]["seed"] == 3
assert doc["config"]["model"]["name"] == "resnet18"
assert doc["config"]["train"]["image_size"] == 32
assert doc["seeds"]["split"] == 3
def _metrics(run: dict) -> dict:
return json.loads((run["run_dir"] / "metrics.json").read_text(encoding="utf-8"))
def test_metrics_has_subsets_per_fold_and_mean_std(run: dict) -> None:
m = _metrics(run)
assert len(m["folds"]) == N_FOLDS
for fold in m["folds"]:
assert set(fold["subsets"]) >= {"all", "common", "mild"}
assert fold["best_epoch"] >= 1
for subset in ("all", "common", "mild"):
s = fold["subsets"][subset]
assert {"macro_f1", "accuracy", "per_class", "confusion_matrix"} <= set(s)
for subset in ("all", "common", "mild"):
ms = m["mean_std"][subset]
assert {"mean", "std"} <= set(ms["macro_f1"])
assert {"mean", "std"} <= set(ms["accuracy"])
def test_held_out_evaluated_on_all_severities(run: dict) -> None:
# severities=[strong, normal] でも held_out は全程度(合計 = manifest 全件)
m = _metrics(run)
n_all = sum(f["subsets"]["all"]["n"] for f in m["folds"])
n_mild = sum(f["subsets"]["mild"]["n"] for f in m["folds"])
assert n_all == run["n_images"]
assert n_mild == 4 * PER_DIR # mild は淡紅以外の 4 クラス
def test_confusion_matrix_sum(run: dict) -> None:
m = _metrics(run)
all_cm = np.array(m["confusion_matrix_sum"]["all"])
common_cm = np.array(m["confusion_matrix_sum"]["common"])
assert all_cm.shape == (5, 5)
assert all_cm.sum() == run["n_images"]
assert common_cm.sum() == run["n_images"] - 4 * PER_DIR
# 行(正解)の合計はクラスごとの枚数(淡紅は normal のみ,他は strong + mild)
assert all_cm.sum(axis=1).tolist() == [
2 * PER_DIR,
PER_DIR,
2 * PER_DIR,
2 * PER_DIR,
2 * PER_DIR,
]
with (run["run_dir"] / "confusion_matrix.csv").open(encoding="utf-8") as f:
rows = list(csv.DictReader(f))
subsets = {r["subset"] for r in rows}
assert subsets == {"common", "all"}
all_rows = [r for r in rows if r["subset"] == "all"]
assert [r["true"] for r in all_rows] == ["pale", "light_red", "red", "dark_red", "purple"]
csv_matrix = [[int(v) for k, v in r.items() if k.startswith("pred_")] for r in all_rows]
assert csv_matrix == all_cm.tolist()
def test_mild_prediction_breakdown_present(run: dict) -> None:
m = _metrics(run)
breakdown = m["pooled"]["mild"]["prediction_breakdown"]
overall = breakdown["overall"]
assert overall["n"] == 4 * PER_DIR
total = overall["light_red"] + overall["original"] + overall["other"]
assert total == pytest.approx(1.0)
assert set(breakdown["per_class"]) == {"pale", "red", "dark_red", "purple"}
for fold in m["folds"]:
assert "prediction_breakdown" in fold["subsets"]["mild"]
def test_metrics_has_timing_and_environment(run: dict) -> None:
m = _metrics(run)
t = m["timing"]
assert t["total_seconds"] > 0
assert t["data_loading_seconds"] >= 0
assert t["total_seconds"] >= t["data_loading_seconds"]
for fold in m["folds"]:
ft = fold["timing"]
assert ft["train_seconds"] >= 0
assert ft["eval_seconds"] >= 0
assert 1 <= ft["epochs_run"] <= 2
assert ft["mean_epoch_seconds"] >= 0
env = m["environment"]
assert env["device"]
assert env["model_name"] == "resnet18"
assert env["num_parameters"] > 0
def test_history_has_epoch_seconds(run: dict) -> None:
with (run["run_dir"] / "history.csv").open(encoding="utf-8") as f:
rows = list(csv.DictReader(f))
assert rows
cols = set(rows[0])
assert {"fold", "epoch", "train_loss", "val_loss", "val_macro_f1", "lr"} <= cols
seconds_cols = [c for c in cols if "second" in c]
assert seconds_cols
assert all(float(r[seconds_cols[0]]) >= 0 for r in rows)
assert {int(r["fold"]) for r in rows} == set(range(N_FOLDS))
def test_recoverable_outputs_have_no_patient_ids(run: dict) -> None:
d = run["run_dir"]
for name in RECOVERABLE:
assert SECRET not in (d / name).read_text(encoding="utf-8"), name
for png in (d / "figures").glob("*.png"):
assert SECRET.encode() not in png.read_bytes(), png.name
assert SECRET not in run["stdout"]
assert SECRET not in run["stderr"]
def test_split_and_predictions_contain_ids_and_are_not_pulled(run: dict) -> None:
d = run["run_dir"]
# 個票(患者 ID を含む)はサーバ上にだけ置き,回収しない
assert SECRET in (d / "split.json").read_text(encoding="utf-8")
assert SECRET in (d / "predictions.csv").read_text(encoding="utf-8")
for name in ("split.json", "predictions.csv"):
assert not is_pull_allowed(name, with_checkpoints=False)
assert not is_pull_allowed(name, with_checkpoints=True)
def test_predictions_cover_all_severities(run: dict) -> None:
with (run["run_dir"] / "predictions.csv").open(encoding="utf-8") as f:
rows = list(csv.DictReader(f))
assert len(rows) == run["n_images"]
assert {r["severity"] for r in rows} == {"strong", "mild", "normal"}
def test_every_output_matches_pull_whitelist_as_spec(run: dict) -> None:
d = run["run_dir"]
recoverable = set(RECOVERABLE)
for path in d.rglob("*"):
if not path.is_file():
continue
rel = path.relative_to(d).as_posix()
if rel in recoverable or (rel.startswith("figures/") and rel.endswith(".png")):
assert is_pull_allowed(rel, with_checkpoints=False), rel
elif rel.startswith("checkpoints/") and rel.endswith(".pt"):
assert not is_pull_allowed(rel, with_checkpoints=False), rel
assert is_pull_allowed(rel, with_checkpoints=True), rel
else:
assert not is_pull_allowed(rel, with_checkpoints=True), rel
assert rel in {"split.json", "predictions.csv"}, f"想定外の出力: {rel}"