Files
ModelTrainEval/tests/test_config.py
T
Shaun ZhouandClaude Opus 5 86c86b105c refactor: 按实际的五步工作流裁掉无关代码
实际工作流是:simclr 训练 -> 聚类 -> 人工归类 -> 分类训练 -> GradCAM/SHAP 生成
热力图与 box。据此移除不参与其中的检测/定位链路,共 31 个文件、约 2600 行:

- 模型:defect_net、postprocess、necks/、heads/center_head
- 数据:film_defect(YOLO 读取端)、target_encoder
- 引擎:trainer、evaluator、losses
- 入口:train.py、eval.py
- 脚本:convert_annotations、split_dataset、export_onnx、profile_inference
- 配置:configs/{train,eval,model,dataset}
- utils/labels(多类/单类标签模式,仅检测分类头使用)

共享模块按实际用量瘦身:engine/metrics 只留 AverageMeter(检测指标随之移除);
datasets/yolo 只留写出侧(IMAGE_SUFFIXES 与 write_data_yaml)——第 5 步产出的
YOLO 标签是给外部检测训练用的交换格式,读取端不再需要。

裁剪过程中修掉一个真 bug:generate_boxes 的 --stage 默认 6(layer3)对 tiny_conv
这类非 nn.Sequential 骨架直接抛异常。现在自动退回骨架最终输出并警告,
另加 --stage -1 显式选择最终输出。

五步工作流已端到端实跑验证(simclr 1 轮 -> 聚类 -> 导出 -> 分类 1 轮 ->
gradcam 与 shap 两种方法各生成一遍)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:27:20 +08:00

186 lines
7.2 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():
"""非严格模式下仍可新建嵌套节点;严格模式(load_config 默认)会拦下未知键。"""
cfg = apply_overrides({}, ["a.b.c=1"], strict=False)
assert cfg == {"a": {"b": {"c": 1}}}
with pytest.raises(KeyError, match="不存在"):
apply_overrides({}, ["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():
cluster = load_config("configs/cluster/film.yaml")
assert cluster["dataset"]["name"] == "unlabeled"
assert cluster["num_clusters"] > 0
classify = load_config("configs/classify/film.yaml")
assert classify["model"]["name"] == "classifier"
assert classify["train"]["monitor"] == "macro_f1"
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):
"""仓库里每个配置都必须能真的构建出组件。
回归测试:派生配置曾经无法使用——它同时继承两份骨架配置,
合并后 backbone 混了两边的字段(tiny_conv 的 widths 混进 torchvision),构建时直接报错。
"""
pytest.importorskip("torch")
from models.registry import BACKBONES
cfg = load_config(config_path)
# 聚类配置:pretrained 用顶层 backbone,simclr 用 simclr.backbone
for backbone_cfg in (cfg.get("backbone"), cfg.get_path("simclr.backbone")):
if not backbone_cfg:
continue
backbone_cfg = dict(backbone_cfg)
if backbone_cfg.get("name") == "torchvision":
backbone_cfg["pretrained"] = False # 单测里不下载 ImageNet 权重
backbone = BACKBONES.build(backbone_cfg)
assert backbone.out_channels > 0
# 分类配置
model_cfg = cfg.get("model")
if model_cfg:
import torch
from models import build_model
model_cfg = dict(model_cfg)
model_cfg.setdefault("num_classes", 3)
model_cfg["init_from"] = None
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():
assert model(torch.randn(1, 3, 64, 64)).shape[1] == model_cfg["num_classes"]
@pytest.mark.parametrize(
"override,expected_hint",
[
("simclr.optmizer.weight_decay=0.0", "optimizer"), # 少了个 i
("simclr.optimizer.lrr=1e-3", "lr"), # 多了个 r
("num_clusers=4", "num_clusters"), # 少了个 t
],
)
def test_set_rejects_typos_with_suggestion(override, expected_hint):
"""--set 打错字母会凭空造出没人读的键,训练照跑但改动无效——必须拦下并给出建议。"""
with pytest.raises(KeyError) as excinfo:
load_config("configs/cluster/film_simclr_resnet18.yaml", [override])
assert expected_hint in str(excinfo.value)
def test_set_accepts_existing_keys():
cfg = load_config(
"configs/cluster/film_simclr_resnet18.yaml",
["simclr.optimizer.lr=0.001", "simclr.optimizer.weight_decay=0.0", "num_clusters=7"],
)
assert cfg["simclr"]["optimizer"]["lr"] == 0.001
assert cfg["simclr"]["optimizer"]["weight_decay"] == 0.0
assert cfg["num_clusters"] == 7
def test_apply_overrides_can_add_keys_when_not_strict():
from utils.config import apply_overrides
assert apply_overrides({"a": {"b": 1}}, ["a.c=2"], strict=False) == {"a": {"b": 1, "c": 2}}