Newer
Older
TIASJudgeModel / tests / test_color_config.py
"""色特徴分類器の設定(`color` 節)のテスト.

仕様(実装サマリーの設計を仕様として扱う):

- `ExperimentConfig.color` は省略可(既存の CNN 設定はそのまま読め,`color` は None)
- classifier は `logistic` / `hist_gb`.`max_side` は既定 512,`null` で縮小なし
- 候補(`c_values`・`learning_rates`・`max_depths`)の空・重複・非正は拒否.`max_depths` は null 可
- 未知のキーは拒否
- `configs/color/*.yaml`(18 本)と `configs/color_synthetic.yaml` がすべて読める
- 色特徴の設定は,対応する CNN の設定と分割(data・split・inner_val_ratio)が同一
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest
from pydantic import ValidationError

from tias_judge_model.schema import ExperimentConfig, load_config

CONFIGS_DIR = Path(__file__).resolve().parents[1] / "configs"
COLOR_CONFIGS = sorted((CONFIGS_DIR / "color").glob("*.yaml"))
# configs/seg/ はセグメンテーションの評価(別スキーマ `load_seg_eval_config`)のため除く.
SEG_CONFIGS_DIR = CONFIGS_DIR / "seg"
ALL_CONFIGS = sorted(
    p
    for p in CONFIGS_DIR.rglob("*.yaml")
    if p.name != "remote.yaml" and SEG_CONFIGS_DIR not in p.parents
)
EXPORT_CONFIGS = sorted((CONFIGS_DIR / "export").glob("*.yaml"))
NO_ENV: dict[str, str] = {}


def _with_color(color: dict | None = None, **extra) -> dict:
    raw: dict = {"data": {"root": "somewhere"}, **extra}
    if color is not None:
        raw["color"] = color
    return raw


# --- 既定値・省略 ---


def test_color_is_optional_and_none_by_default() -> None:
    assert ExperimentConfig.model_validate(_with_color()).color is None


def test_color_defaults() -> None:
    c = ExperimentConfig.model_validate(_with_color({})).color
    assert c is not None
    assert c.classifier == "logistic"
    assert c.max_side == 512
    assert len(c.logistic.c_values) >= 1
    assert c.logistic.max_iter >= 1
    assert len(c.hist_gb.learning_rates) >= 1
    assert len(c.hist_gb.max_depths) >= 1


def test_max_side_null_disables_downscale() -> None:
    c = ExperimentConfig.model_validate(_with_color({"max_side": None})).color
    assert c is not None and c.max_side is None


def test_hist_gb_accepts_null_max_depth() -> None:
    c = ExperimentConfig.model_validate(
        _with_color(
            {
                "classifier": "hist_gb",
                "hist_gb": {"learning_rates": [0.1], "max_depths": [2, None]},
            }
        )
    ).color
    assert c is not None
    assert c.classifier == "hist_gb"
    assert tuple(c.hist_gb.max_depths) == (2, None)


@pytest.mark.parametrize(
    "color",
    [
        {"classifier": "svm"},
        {"classifier": ""},
        {"max_side": 0},
        {"max_side": -1},
        {"logistic": {"c_values": []}},
        {"logistic": {"c_values": [1.0, 1.0]}},
        {"logistic": {"c_values": [0.0]}},
        {"logistic": {"c_values": [-1.0, 1.0]}},
        {"logistic": {"max_iter": 0}},
        {"hist_gb": {"learning_rates": []}},
        {"hist_gb": {"learning_rates": [0.1, 0.1]}},
        {"hist_gb": {"learning_rates": [0.0]}},
        {"hist_gb": {"learning_rates": [-0.1]}},
        {"hist_gb": {"max_depths": []}},
        {"hist_gb": {"max_depths": [3, 3]}},
        {"hist_gb": {"max_depths": [None, None]}},
        {"hist_gb": {"max_depths": [0]}},
        {"hist_gb": {"max_depths": [-2]}},
        {"hist_gb": {"max_iter": 0}},
        {"unknown": 1},
        {"logistic": {"C": [1.0]}},
        {"hist_gb": {"lr": [0.1]}},
    ],
    ids=str,
)
def test_invalid_color_settings_are_rejected(color: dict) -> None:
    with pytest.raises(ValidationError):
        ExperimentConfig.model_validate(_with_color(color))


def test_existing_cnn_configs_still_load_without_color() -> None:
    cnn = [p for p in ALL_CONFIGS if "color" not in p.as_posix()]
    assert cnn
    for path in cnn:
        assert load_config(path, environ=NO_ENV).color is None, path.name


# --- リポジトリの設定ファイル ---


def test_color_configs_count() -> None:
    assert len(COLOR_CONFIGS) == 18


@pytest.mark.parametrize(
    "path", [*COLOR_CONFIGS, CONFIGS_DIR / "color_synthetic.yaml"], ids=lambda p: p.name
)
def test_every_color_config_loads_with_color_section(path: Path) -> None:
    c = load_config(path, environ=NO_ENV)
    assert c.color is not None
    # バランス・サンプリングは色特徴分類器では未対応
    assert c.train.sampler == "random"


@pytest.mark.parametrize("path", COLOR_CONFIGS, ids=lambda p: p.name)
def test_color_config_classifier_matches_file_name(path: Path) -> None:
    c = load_config(path, environ=NO_ENV)
    assert c.color is not None
    expected = "hist_gb" if "_hist_gb_" in path.stem else "logistic"
    assert c.color.classifier == expected
    expected_weighting = "inverse" if path.stem.endswith("_cwinv") else "none"
    assert c.train.class_weighting == expected_weighting


def _cnn_counterpart(path: Path) -> Path:
    # cv_color_<分類器>_<条件>[_sNN][_cwinv] → CNN の cv_<条件>.yaml・repeat/cv_<条件>_sNN.yaml
    m = re.fullmatch(
        r"cv_color_(?:logistic|hist_gb)_(with_mild|without_mild)(_s\d+)?(_cwinv)?", path.stem
    )
    assert m, path.name
    cond, seed_suffix = m.group(1), m.group(2)
    if seed_suffix:
        return CONFIGS_DIR / "repeat" / f"cv_{cond}{seed_suffix}.yaml"
    return CONFIGS_DIR / f"cv_{cond}.yaml"


@pytest.mark.parametrize("path", COLOR_CONFIGS, ids=lambda p: p.name)
def test_color_config_split_matches_cnn_config(path: Path) -> None:
    # 同じ seed の CNN と分割が一致するには,data・split・inner_val_ratio が同一である必要がある
    base = _cnn_counterpart(path)
    assert base.is_file(), base
    c = load_config(path, environ=NO_ENV)
    b = load_config(base, environ=NO_ENV)
    assert c.data == b.data
    assert c.split == b.split
    assert c.train.inner_val_ratio == b.train.inner_val_ratio


def test_color_synthetic_split_matches_cv_synthetic() -> None:
    c = load_config(CONFIGS_DIR / "color_synthetic.yaml", environ=NO_ENV)
    b = load_config(CONFIGS_DIR / "cv_synthetic.yaml", environ=NO_ENV)
    assert c.data == b.data
    assert c.split == b.split
    assert c.train.inner_val_ratio == b.train.inner_val_ratio


# --- 判定モデルの書き出し(configs/export/)---


def test_export_configs_exist() -> None:
    names = {p.stem for p in EXPORT_CONFIGS}
    assert {
        "color_logistic_with_mild",
        "color_logistic_without_mild",
        "color_synthetic",
    } <= names


@pytest.mark.parametrize("path", EXPORT_CONFIGS, ids=lambda p: p.name)
def test_every_export_config_loads_as_logistic(path: Path) -> None:
    # color-export は ExperimentConfig で読み,logistic のみ対応する
    c = load_config(path, environ=NO_ENV)
    assert c.color is not None
    assert c.color.classifier == "logistic"
    assert c.train.sampler == "random"


def test_seg_configs_are_excluded_from_experiment_configs() -> None:
    assert not [p for p in ALL_CONFIGS if SEG_CONFIGS_DIR in p.parents]