fix(backbones): 修复 shufflenet 无法构建,并让换骨架不再踩配置合并的坑

- shufflenet_v2_x1_0 列在支持清单里却直接 AttributeError:ShuffleNetV2 既不是
  扁平 children 也没有 .features,需要按 conv1/maxpool/stage2..4/conv5 自己拼。
  该 bug 同时影响 SimCLR 与检测模型
- 新增参数化测试覆盖清单里的每个 arch:能前向、声明的 out_channels 与实际一致
- 配置支持 `_replace_: true`:子字典整块替换而不与 base 合并。换骨架时
  tiny_conv 的 widths/depths 会残留并传给新骨架,此前无论用 --set 还是 _base_
  都绕不开,只能改原文件
- Registry.build 提前校验字段,报错直接指出是哪些字段不被接受、该怎么办,
  取代裸 TypeError
- README 补充可选骨架表与三种改法(含不该用 --set 改 name 的说明)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Shaun Zhou
2026-09-04 15:28:49 +08:00
co-authored by Claude Opus 5
parent e4bd794025
commit e581d9b3d7
7 changed files with 204 additions and 8 deletions
+45
View File
@@ -353,6 +353,51 @@ view_transforms: # 生成两个视图的随机增强
> `RandomResizedCrop` 的 `scale` 下限别低于 0.4:裁太狠会让两个视图都只剩背景,
> "背景 vs 背景"被当成正样本对,学出来的特征会退化。
#### 换一个骨架
`simclr.backbone` 用的是和检测模型同一套注册表,可选:
| `name` | `arch` | out_channels | 说明 |
| --- | --- | --- | --- |
| `tiny_conv` | — | 256(可调) | 自研深度可分离卷积,默认,最轻 |
| `torchvision` | `resnet18` / `resnet34` | 512 | 通用基线 |
| `torchvision` | `resnet50` | 2048 | 最强也最重,MPS 上注意 batch |
| `torchvision` | `mobilenet_v3_small` / `_large` | 576 / 960 | 面向实时部署 |
| `torchvision` | `shufflenet_v2_x1_0` | 1024 | 同上 |
全是纯卷积(ViT/Swin 不在支持范围,本项目要部署在实时设备上)。改法是**整块替换**
`simclr.backbone`,因为两种骨架的字段完全不同(`widths/depths/block` vs `arch/pretrained`):
```yaml
# configs/cluster/film.yaml
simclr:
backbone:
name: torchvision
arch: resnet18
pretrained: true # 先用 ImageNet 权重起步,再在本批数据上自监督微调
in_channels: 3
```
如果不想改原文件,用 `_base_` 继承时要加 `_replace_: true`——子字典默认是**合并**而不是
替换,不加的话 base 里 `tiny_conv` 的 `widths/depths` 会残留下来传给新骨架:
```yaml
_base_: [configs/cluster/film.yaml]
simclr:
backbone:
_replace_: true # 整块换掉,不与 base 合并
name: torchvision
arch: resnet18
pretrained: true
```
> **不要用 `--set simclr.backbone.name=torchvision`**:它只改名字,旧骨架的专有字段仍在,
> 会报 `构建 'torchvision' 时收到它不接受的字段 ['block', 'depths', 'widths']`。
> 换组件请改配置文件。
注意 `simclr.feature_stages`:`torchvision` 系列的 `features` 是 `nn.Sequential`,可以填
`[5, 6]` 这类中间层下标;`tiny_conv` 不是,必须保持 `null`(取最终输出)。
SimCLR 训练同样接了 SwanLab(配置在 `simclr.swanlab`,字段与 `train.swanlab` 相同,
默认 `mode: local` 不需要登录)。训练跑起来后另开一个终端:
+1
View File
@@ -743,6 +743,7 @@ $k=4$ 稳定性略低于 ImageNet(0.83 vs 0.98),符合"数据的主结构
| NT-Xent 损失 | `engine/simclr.py::nt_xent_loss` |
| SimCLR 训练循环 | `engine/simclr.py::SimCLRTrainer` |
| 断点续训(完整状态存取) | `engine/simclr.py::save_state` / `load_state` |
| 配置整块替换 `_replace_` | `utils/config.py::deep_merge` |
| 投影头 $g_\varphi$ | `models/heads/projection.py::ProjectionHead` |
| 设备解析(cuda/mps/cpu,auto 优先级) | `utils/device.py::resolve_device` |
| AMP 随后端的默认值 | `utils/device.py::resolve_amp` / `default_amp` |
+20 -4
View File
@@ -45,10 +45,7 @@ class TorchvisionBackbone(nn.Module):
weights = "DEFAULT" if pretrained else None
net = getattr(torchvision.models, arch)(weights=weights)
if arch.startswith("resnet"):
self.features = nn.Sequential(*list(net.children())[:-2])
else:
self.features = net.features
self.features = self._extract_features(net, arch)
if in_channels != 3:
self._patch_first_conv(in_channels)
@@ -60,6 +57,25 @@ class TorchvisionBackbone(nn.Module):
for param in self.parameters():
param.requires_grad_(False)
@staticmethod
def _extract_features(net: nn.Module, arch: str) -> nn.Sequential:
"""取到最后一个卷积 stage 为止,丢掉分类头。
不同系列的 torchvision 模型结构不一样:resnet 是扁平的 children,
mobilenet 有现成的 `.features`,而 ShuffleNetV2 两者都不是——它把层
散放在 conv1/maxpool/stage2..4/conv5 上,得按顺序自己拼。
"""
if arch.startswith("resnet"):
return nn.Sequential(*list(net.children())[:-2])
if arch.startswith("shufflenet"):
return nn.Sequential(
net.conv1, net.maxpool, net.stage2, net.stage3, net.stage4, net.conv5
)
features = getattr(net, "features", None)
if features is None:
raise ValueError(f"不知道如何从 {arch} 取特征,请在 _extract_features 里补一条分支")
return features
def _patch_first_conv(self, in_channels: int) -> None:
for module in self.features.modules():
if isinstance(module, nn.Conv2d):
+37
View File
@@ -46,3 +46,40 @@ def test_project_configs_are_loadable():
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]})
+55
View File
@@ -3,6 +3,7 @@ import pytest
torch = pytest.importorskip("torch")
from models import build_model # noqa: E402
from models.backbones.resnet import _SUPPORTED as _SUPPORTED_ARCHS # noqa: E402
from models.postprocess import decode_heatmap # noqa: E402
@@ -90,3 +91,57 @@ def test_losses_decrease_on_a_single_batch(model_cfg):
last = total
assert last < first
@pytest.mark.parametrize("arch", sorted(_SUPPORTED_ARCHS))
def test_every_supported_torchvision_arch_actually_works(arch):
"""清单里列出的每个骨架都必须真能前向,且声明的 out_channels 与实际一致。
回归测试:shufflenet_v2_x1_0 曾被列为支持,但它没有 `.features` 属性,
构造时直接 AttributeError。
"""
from models.registry import BACKBONES
backbone = BACKBONES.build({"name": "torchvision", "arch": arch, "pretrained": False}).eval()
with torch.no_grad():
feat = backbone(torch.randn(1, 3, 64, 64))
assert feat.dim() == 4
assert feat.shape[1] == backbone.out_channels == _SUPPORTED_ARCHS[arch]
assert backbone.out_stride == 32
def test_unsupported_arch_is_rejected():
from models.registry import BACKBONES
with pytest.raises(ValueError, match="不支持的骨架"):
BACKBONES.build({"name": "torchvision", "arch": "vit_b_16"})
@pytest.mark.parametrize("arch", ["resnet18", "shufflenet_v2_x1_0", "mobilenet_v3_small"])
def test_alternative_backbones_drive_simclr(arch):
"""换骨架后 SimCLR 的投影头要能接上(out_channels 必须准)。"""
from models.heads.projection import ProjectionHead
from models.registry import BACKBONES
backbone = BACKBONES.build({"name": "torchvision", "arch": arch, "pretrained": False}).eval()
head = ProjectionHead(backbone.out_channels, 32, 16)
with torch.no_grad():
assert head(backbone(torch.randn(2, 3, 64, 64))).shape == (2, 16)
def test_alternative_backbone_drives_defect_net():
"""检测模型换骨架同样要能跑通(neck 的 in_channels 由 backbone 决定)。"""
model = build_model(
{
"name": "defect_net",
"num_classes": 3,
"backbone": {"name": "torchvision", "arch": "shufflenet_v2_x1_0", "pretrained": False},
"neck": {"name": "upsample", "channels": 16, "num_upsamples": 3},
"head": {"hidden_channels": 16},
}
).eval()
assert model.out_stride == 4
with torch.no_grad():
out = model(torch.randn(1, 3, 64, 64))
assert out["heatmap"].shape == (1, 3, 16, 16)
+21 -3
View File
@@ -10,6 +10,7 @@ from typing import Any
import yaml
BASE_KEY = "_base_"
REPLACE_KEY = "_replace_"
class Config(dict):
@@ -53,16 +54,33 @@ class Config(dict):
def deep_merge(base: Mapping, override: Mapping) -> dict:
"""递归合并;override 中的标量/列表直接覆盖 base。"""
"""递归合并;override 中的标量/列表直接覆盖 base。
子字典默认是**合并**而不是替换,这在换组件时会出问题:把 backbone 从
`tiny_conv` 换成 `torchvision`,base 里 tiny_conv 专有的 widths/depths
会残留下来,传给新组件直接报错。这时在该字典里写 `_replace_: true`,
表示整块替换掉 base 的同名字典。
"""
merged = dict(base)
for key, value in override.items():
if key in merged and isinstance(merged[key], Mapping) and isinstance(value, Mapping):
if isinstance(value, Mapping) and value.get(REPLACE_KEY):
merged[key] = _strip_replace_key(value)
elif key in merged and isinstance(merged[key], Mapping) and isinstance(value, Mapping):
merged[key] = deep_merge(merged[key], value)
else:
merged[key] = value
merged[key] = _strip_replace_key(value) if isinstance(value, Mapping) else value
return merged
def _strip_replace_key(value: Mapping) -> dict:
"""去掉 `_replace_` 标记本身,避免它漏进组件的构造参数。"""
return {
k: (_strip_replace_key(v) if isinstance(v, Mapping) else v)
for k, v in value.items()
if k != REPLACE_KEY
}
def _load_raw(path: Path, seen: set[Path]) -> dict:
path = path.resolve()
if path in seen:
+25 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import inspect
from collections.abc import Callable, Iterator
from typing import Any, TypeVar
@@ -38,7 +39,30 @@ class Registry:
name = cfg.pop("name", None)
if name is None:
raise KeyError(f"构建 {self.name} 需要 'name' 字段: {cfg}")
return self.get(name)(**{**cfg, **kwargs})
target = self.get(name)
params = {**cfg, **kwargs}
self._check_params(name, target, params)
return target(**params)
@staticmethod
def _check_params(name: str, target: Callable[..., Any], params: dict) -> None:
"""提前挡下不认识的字段,给出比裸 TypeError 更可操作的提示。
最常见的触发方式:用 `--set xxx.name=其他骨架` 只改了名字,
yaml 里原骨架的专有字段(如 tiny_conv 的 widths/depths)仍然留在配置里。
"""
signature = inspect.signature(target)
if any(p.kind is p.VAR_KEYWORD for p in signature.parameters.values()):
return
unknown = sorted(set(params) - set(signature.parameters))
if not unknown:
return
accepted = sorted(p for p in signature.parameters if p != "self")
raise TypeError(
f"构建 '{name}' 时收到它不接受的字段 {unknown}。它接受的是 {accepted}。"
"如果你是用 --set 改了 name,注意 yaml 里原来那个组件的专有字段仍会保留——"
"换组件请直接编辑配置文件、整块替换。"
)
def __contains__(self, name: object) -> bool:
return name in self._entries