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:
co-authored by
Claude Opus 5
parent
e4bd794025
commit
e581d9b3d7
@@ -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` 不需要登录)。训练跑起来后另开一个终端:
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]})
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user