- configs/train/resnet18.yaml 此前直接报错:它同时继承 tiny_conv 与 resnet18 的模型
配置,深度合并后 backbone 混了两边字段。在 configs/model/resnet18.yaml 的 backbone
上加 _replace_: true 整块替换
- _replace_ 标记此前不生效:_load_raw 对每个 base 都会先 deep_merge({}, data),
标记在参与替换判断之前就被剥掉了。改为合并期间保留、load_config 末尾统一剥除
- 新增 configs/eval/resnet18.yaml:评估必须与训练用同一套模型配置,否则构建出的
骨架与权重对不上,而此前只有 baseline 一个评估配置
- 新增参数化测试:仓库里每个配置都要能真的构建出组件(片段自动跳过),
且训练/评估配置对的 model 段必须一致
- README 补充骨架与配置对的对应表
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
137 lines
5.3 KiB
Python
137 lines
5.3 KiB
Python
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")
|
|
|
|
|
|
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
|