"""色特徴分類器の設定(`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"))
ALL_CONFIGS = sorted(p for p in CONFIGS_DIR.rglob("*.yaml") if p.name != "remote.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