Newer
Older
TIASJudgeModel / tests / test_training_config.py
"""学習の設定(`model`・`train` 節)のテスト(SPEC_02「学習の設定」・SPEC_01「設定ファイル」).

- 既定値が SPEC_02 の表どおり
- `model`・`train` を省略した既存の設定(`tias-dataset` 用)も読める
- 未知のキー・不正な値は拒否する
- リポジトリの `configs/cv_*.yaml` がすべて読め,モデル比較用の設定はモデル名以外が元の設定と同一
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest
import yaml
from pydantic import ValidationError

from tias_judge_model.schema import ExperimentConfig, load_config

CONFIGS_DIR = Path(__file__).resolve().parents[1] / "configs"
CV_CONFIGS = sorted(CONFIGS_DIR.glob("cv_*.yaml"))
NO_ENV: dict[str, str] = {}


def _minimal() -> dict:
    return {"data": {"root": "somewhere"}}


def test_model_defaults_follow_spec() -> None:
    c = ExperimentConfig.model_validate(_minimal())
    assert c.model.name == "convnext_tiny"
    assert c.model.pretrained is True


def test_train_defaults_follow_spec() -> None:
    t = ExperimentConfig.model_validate(_minimal()).train
    assert t.image_size == 224
    assert t.batch_size == 32
    assert t.max_epochs == 40
    assert t.patience == 8
    assert t.lr == pytest.approx(1e-4)
    assert t.weight_decay == pytest.approx(0.05)
    assert t.warmup_epochs == 2
    assert t.inner_val_ratio == pytest.approx(0.15)
    assert t.num_workers == 4
    assert t.amp is True


@pytest.mark.parametrize("name", ["synthetic.yaml", "newton.yaml"])
def test_existing_configs_without_model_train_still_load(name: str) -> None:
    c = load_config(CONFIGS_DIR / name, environ=NO_ENV)
    assert c.model.name == "convnext_tiny"
    assert c.train.image_size == 224


@pytest.mark.parametrize(
    "raw",
    [
        {"model": {"nme": "resnet18"}},
        {"train": {"epochs": 3}},
        {"train": {"batch_size": 0}},
        {"train": {"inner_val_ratio": 0.0}},
        {"train": {"inner_val_ratio": 1.0}},
        {"train": {"lr": 0}},
        {"model": {"name": ""}},
    ],
)
def test_invalid_training_settings_are_rejected(raw: dict) -> None:
    with pytest.raises(ValidationError):
        ExperimentConfig.model_validate({**_minimal(), **raw})


def test_cv_configs_exist() -> None:
    names = {p.name for p in CV_CONFIGS}
    assert {"cv_with_mild.yaml", "cv_without_mild.yaml", "cv_synthetic.yaml"} <= names


@pytest.mark.parametrize("path", CV_CONFIGS, ids=lambda p: p.name)
def test_every_cv_config_loads(path: Path) -> None:
    load_config(path, environ=NO_ENV)


def test_with_and_without_mild_differ_only_in_severities() -> None:
    w = load_config(CONFIGS_DIR / "cv_with_mild.yaml", environ=NO_ENV)
    wo = load_config(CONFIGS_DIR / "cv_without_mild.yaml", environ=NO_ENV)
    assert w.data.severities == ("strong", "mild", "normal")
    assert wo.data.severities == ("strong", "normal")
    dw = w.model_dump()
    dwo = wo.model_dump()
    dw["data"].pop("severities")
    dwo["data"].pop("severities")
    assert dw == dwo


def test_production_cv_configs_use_spec_defaults() -> None:
    c = load_config(CONFIGS_DIR / "cv_with_mild.yaml", environ=NO_ENV)
    assert c.model == ExperimentConfig.model_validate(_minimal()).model
    assert c.train == ExperimentConfig.model_validate(_minimal()).train
    assert c.split.n_folds == 5


def test_synthetic_cv_config_does_not_download_weights() -> None:
    c = load_config(CONFIGS_DIR / "cv_synthetic.yaml", environ=NO_ENV)
    assert c.model.pretrained is False


def _model_comparison_pairs() -> list[tuple[Path, Path]]:
    pairs = []
    for p in CV_CONFIGS:
        if p.name in {"cv_with_mild.yaml", "cv_without_mild.yaml", "cv_synthetic.yaml"}:
            continue
        suffix = "with_mild" if p.stem.endswith("_with_mild") else "without_mild"
        pairs.append((p, CONFIGS_DIR / f"cv_{suffix}.yaml"))
    return pairs


@pytest.mark.parametrize(("variant", "base"), _model_comparison_pairs(), ids=lambda p: Path(p).name)
def test_model_comparison_config_differs_only_in_model_name(variant: Path, base: Path) -> None:
    v = load_config(variant, environ=NO_ENV).model_dump()
    b = load_config(base, environ=NO_ENV).model_dump()
    assert v["model"]["name"] != b["model"]["name"]
    v["model"].pop("name")
    b["model"].pop("name")
    assert v == b


def test_model_comparison_configs_are_present() -> None:
    assert len(_model_comparison_pairs()) >= 2


# --- クラス不均衡の対策(SPEC_02「クラス不均衡」→「対策の設定」) ---

IMBALANCE_CONFIGS = sorted((CONFIGS_DIR / "imbalance").glob("*.yaml"))
REPO_ROOT = CONFIGS_DIR.parent
# 設定ファイル名の末尾と,対策のキー・値の対応
IMBALANCE_SUFFIXES = {
    "_cwinv": ("class_weighting", "inverse"),
    "_cwsqrt": ("class_weighting", "sqrt_inverse"),
    "_balanced": ("sampler", "balanced"),
}


def test_imbalance_defaults_are_no_countermeasure() -> None:
    t = ExperimentConfig.model_validate(_minimal()).train
    assert t.class_weighting == "none"
    assert t.sampler == "random"


@pytest.mark.parametrize(
    "train",
    [
        {"class_weighting": "none"},
        {"class_weighting": "inverse"},
        {"class_weighting": "sqrt_inverse"},
        {"sampler": "random"},
        {"sampler": "balanced"},
        {"class_weighting": "inverse", "sampler": "balanced"},
    ],
    ids=str,
)
def test_valid_imbalance_settings_are_accepted(train: dict) -> None:
    t = ExperimentConfig.model_validate({**_minimal(), "train": train}).train
    for key, value in train.items():
        assert getattr(t, key) == value


@pytest.mark.parametrize(
    "train",
    [
        {"class_weighting": "balanced"},
        {"class_weighting": "Inverse"},
        {"class_weighting": "focal"},
        {"class_weighting": ""},
        {"class_weighting": None},
        {"class_weighting": True},
        {"sampler": "weighted"},
        {"sampler": "inverse"},
        {"sampler": "Balanced"},
        {"sampler": None},
        {"class_weights": "inverse"},
    ],
    ids=str,
)
def test_invalid_imbalance_settings_are_rejected(train: dict) -> None:
    with pytest.raises(ValidationError):
        ExperimentConfig.model_validate({**_minimal(), "train": train})


def _imbalance_base(path: Path) -> tuple[str, Path]:
    # 先頭行のコメントの最後の「(<キー> 以外は <元設定> と同一」が,この設定の元
    first = path.read_text(encoding="utf-8").splitlines()[0]
    found = re.findall(r"((\w+) 以外は (configs/[\w./-]+\.yaml) と同一", first)
    assert found, f"元設定の記載が無い: {path.name}"
    key, base = found[-1]
    return key, REPO_ROOT / base


def test_imbalance_configs_count() -> None:
    assert len(IMBALANCE_CONFIGS) == 36


@pytest.mark.parametrize("path", IMBALANCE_CONFIGS, ids=lambda p: p.name)
def test_imbalance_config_differs_from_base_only_in_countermeasure(path: Path) -> None:
    suffix = next(s for s in IMBALANCE_SUFFIXES if path.stem.endswith(s))
    key, value = IMBALANCE_SUFFIXES[suffix]
    comment_key, base = _imbalance_base(path)
    assert comment_key == key
    assert base.is_file(), base
    v = load_config(path, environ=NO_ENV).model_dump()
    b = load_config(base, environ=NO_ENV).model_dump()
    assert v["train"][key] == value
    # 元設定は対策なし
    assert b["train"]["class_weighting"] == "none"
    assert b["train"]["sampler"] == "random"
    v["train"].pop(key)
    b["train"].pop(key)
    assert v == b
    # 生の YAML でも,元設定との違いは対策のキー 1 つだけ
    raw_v = yaml.safe_load(path.read_text(encoding="utf-8"))
    raw_b = yaml.safe_load(base.read_text(encoding="utf-8"))
    assert raw_v["train"].pop(key) == value
    assert raw_v == raw_b


def test_imbalance_configs_cover_each_base_with_three_countermeasures() -> None:
    by_base: dict[Path, set[str]] = {}
    for path in IMBALANCE_CONFIGS:
        suffix = next(s for s in IMBALANCE_SUFFIXES if path.stem.endswith(s))
        _, base = _imbalance_base(path)
        by_base.setdefault(base.resolve(), set()).add(suffix)
        # 設定名は「元設定名 + 対策の接尾辞」
        assert path.stem == base.stem + suffix
    assert len(by_base) == 12
    assert all(s == set(IMBALANCE_SUFFIXES) for s in by_base.values())