Files
ModelTrainEval/tests/test_config.py
T
Shaun ZhouandClaude Opus 5 6f00074199 feat(cluster): 新增 resnet18 自监督聚类配置,并修复 --set 的 YAML 标量解析
- 新增 configs/cluster/film_simclr_resnet18.yaml:用 resnet18 做域内自监督后聚类。
  backbone 块带 _replace_,避免 base 里 tiny_conv 的 widths/depths 残留
- parse_override 此前只用 ast.literal_eval,不认 YAML 风格的 null/true/false:
  `--set xxx.enabled=false` 会得到字符串 "false",而非空字符串是真值,开关反被打开。
  改为先试 Python 字面量(保住 1e-3 这类),再退回 YAML 标量解析
- feature_stages 默认由 null 改为 [5, 6]:实测 1 epoch 自监督后 layer2+3 的轮廓
  0.243/稳定性 0.997,明显优于 layer4 的 0.156/0.528。原注释里"自监督后 layer4
  已是域内特征"的推断被数据推翻
- README 补充三种方案的实测对比与成本

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:51:28 +08:00

155 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from pathlib import Path
import pytest
pytest.importorskip("yaml")
from utils.config import Config, apply_overrides, deep_merge, load_config, parse_override # noqa: E402
def test_deep_merge_is_recursive():
merged = deep_merge({"a": {"b": 1, "c": 2}}, {"a": {"c": 3}, "d": 4})
assert merged == {"a": {"b": 1, "c": 3}, "d": 4}
def test_parse_override_literals():
assert parse_override("train.lr=1e-3") == ("train.lr", 1e-3)
assert parse_override("train.amp=False") == ("train.amp", False)
assert parse_override("model.backbone.name=tiny_conv") == ("model.backbone.name", "tiny_conv")
assert parse_override("simclr.feature_stages=[5, 6]") == ("simclr.feature_stages", [5, 6])
def test_parse_override_accepts_yaml_style_scalars():
"""配置文件里写 null/true/false,命令行上也得能这么写。
回归测试:只用 ast.literal_eval 时 `false` 会变成字符串 "false",
而非空字符串是真值——`--set xxx.enabled=false` 反而把开关打开了。
"""
assert parse_override("a=null") == ("a", None)
assert parse_override("a=~") == ("a", None)
assert parse_override("a=true") == ("a", True)
assert parse_override("a=false") == ("a", False)
assert parse_override("a=false")[1] is False # 不是字符串
# 路径、模型名这类仍应保持字符串
assert parse_override("a=resnet18") == ("a", "resnet18")
assert parse_override("a=runs/x/best.pth") == ("a", "runs/x/best.pth")
def test_apply_overrides_creates_missing_nodes():
cfg = apply_overrides({}, ["a.b.c=1"])
assert cfg == {"a": {"b": {"c": 1}}}
def test_load_config_with_base(tmp_path):
(tmp_path / "base.yaml").write_text("train:\n lr: 0.1\n epochs: 10\n", encoding="utf-8")
(tmp_path / "child.yaml").write_text(
"_base_: ./base.yaml\ntrain:\n lr: 0.01\nwork_dir: runs/x\n", encoding="utf-8"
)
cfg = load_config(tmp_path / "child.yaml", ["train.epochs=2"])
assert isinstance(cfg, Config)
assert cfg.train.lr == 0.01
assert cfg.train.epochs == 2
assert cfg.get_path("work_dir") == "runs/x"
def test_load_config_detects_cycle(tmp_path):
(tmp_path / "a.yaml").write_text("_base_: ./b.yaml\n", encoding="utf-8")
(tmp_path / "b.yaml").write_text("_base_: ./a.yaml\n", encoding="utf-8")
with pytest.raises(ValueError, match="循环引用"):
load_config(tmp_path / "a.yaml")
def test_project_configs_are_loadable():
cfg = load_config("configs/train/baseline.yaml")
assert cfg["model"]["name"] == "defect_net"
assert cfg["data"]["train"]["dataset"]["name"] == "film_defect"
assert len(cfg["classes"]) > 0
def test_replace_key_swaps_a_whole_subdict(tmp_path):
"""换组件时要能整块替换,否则旧组件的专有字段会残留下来传给新组件。"""
(tmp_path / "base.yaml").write_text(
"backbone:\n name: tiny_conv\n widths: [32, 64]\n depths: [1, 2]\n block: dw\n",
encoding="utf-8",
)
(tmp_path / "child.yaml").write_text(
"_base_: ./base.yaml\nbackbone:\n _replace_: true\n name: torchvision\n arch: resnet18\n",
encoding="utf-8",
)
cfg = load_config(tmp_path / "child.yaml")
assert cfg["backbone"] == {"name": "torchvision", "arch": "resnet18"}
assert "_replace_" not in cfg["backbone"] # 标记本身不能漏进构造参数
def test_without_replace_key_subdicts_still_merge(tmp_path):
(tmp_path / "base.yaml").write_text("backbone:\n name: tiny_conv\n block: dw\n", encoding="utf-8")
(tmp_path / "child.yaml").write_text(
"_base_: ./base.yaml\nbackbone:\n block: res\n", encoding="utf-8"
)
cfg = load_config(tmp_path / "child.yaml")
assert cfg["backbone"] == {"name": "tiny_conv", "block": "res"}
def test_registry_reports_incompatible_fields_actionably():
"""--set 只改 name 时旧字段会残留,报错要说清楚是哪些字段、该怎么办。"""
import pytest
from models.registry import BACKBONES
with pytest.raises(TypeError, match=r"不接受的字段.*widths"):
BACKBONES.build({"name": "torchvision", "arch": "resnet18", "widths": [32]})
@pytest.mark.parametrize(
"config_path",
sorted(str(p) for p in Path("configs").rglob("*.yaml")),
)
def test_every_shipped_config_builds_its_components(config_path):
"""仓库里每个配置都必须能真的构建出组件。
回归测试:configs/train/resnet18.yaml 曾经无法使用——它同时继承 tiny_conv 与
resnet18 的模型配置,合并后 backbone 混了两边的字段,构建时直接报错。
"""
torch = pytest.importorskip("torch")
from datasets.yolo import load_classes
from models import build_model
from models.registry import BACKBONES
cfg = load_config(config_path)
if "model" in cfg:
if "classes" not in cfg:
# configs/model/*.yaml 是片段,只能被 _base_ 组合,单独用没有 classes
pytest.skip(f"{config_path} 是配置片段,不含 classes")
model_cfg = dict(cfg["model"])
model_cfg.setdefault("num_classes", len(load_classes(cfg["classes"])))
# 单测里不下载 ImageNet 权重
if model_cfg.get("backbone", {}).get("name") == "torchvision":
model_cfg["backbone"] = {**model_cfg["backbone"], "pretrained": False}
model = build_model(model_cfg)
with torch.no_grad():
out = model(torch.randn(1, 3, 64, 64))
assert "heatmap" in out
simclr_backbone = cfg.get_path("simclr.backbone")
if simclr_backbone:
backbone_cfg = dict(simclr_backbone)
if backbone_cfg.get("name") == "torchvision":
backbone_cfg["pretrained"] = False
backbone = BACKBONES.build(backbone_cfg)
assert backbone.out_channels > 0
def test_eval_config_matches_its_training_backbone():
"""评估配置构建出的骨架必须与训练配置一致,否则权重根本加载不上。"""
for train_cfg, eval_cfg in [
("configs/train/baseline.yaml", "configs/eval/baseline.yaml"),
("configs/train/resnet18.yaml", "configs/eval/resnet18.yaml"),
]:
assert load_config(train_cfg)["model"] == load_config(eval_cfg)["model"], eval_cfg