diff --git a/README.md b/README.md index b68ba77..32bb143 100644 --- a/README.md +++ b/README.md @@ -373,6 +373,25 @@ view_transforms: # 生成两个视图的随机增强 #### 换一个骨架 +**仓库自带 resnet18 的现成配置**,直接用: + +```bash +uv run python scripts/cluster_unlabeled.py --config configs/cluster/film_simclr_resnet18.yaml \ + --work-dir runs/simclr_r18 --set simclr.epochs=40 +``` + +注意默认的 `method: pretrained` 用的**也是 resnet18**(ImageNet 权重,不训练,约 51 秒出结果); +上面这个配置是在 `data/film` 上自监督训练它。两者实测对比(14,579 张,k=4): + +| 方案 | 轮廓 | 稳定性 | 成本 | +| --- | --- | --- | --- | +| pretrained resnet18(ImageNet,不训练) | **0.326** | **0.964** | 约 51 秒 | +| SimCLR tiny_conv(1 epoch) | 0.334 | 0.873 | 65 秒/epoch | +| SimCLR resnet18(1 epoch) | 0.243 | 0.997 | 93 秒/epoch,显存 315 MB | + +resnet18 参数量是 tiny_conv 的 37 倍(11.2M vs 0.3M),1 个 epoch 远不够收敛—— +上表只是**下界**,要用它就得训几十轮。想快速出结果,默认的 pretrained 或 tiny_conv 更划算。 + `simclr.backbone` 用的是和检测模型同一套注册表,可选: | `name` | `arch` | out_channels | 说明 | diff --git a/configs/cluster/film_simclr_resnet18.yaml b/configs/cluster/film_simclr_resnet18.yaml new file mode 100644 index 0000000..21a049f --- /dev/null +++ b/configs/cluster/film_simclr_resnet18.yaml @@ -0,0 +1,35 @@ +# 用 resnet18 做域内自监督(SimCLR)再聚类。 +# +# 与默认配置的区别只有骨架:configs/cluster/film.yaml 的 method=pretrained 用的也是 +# resnet18,但那是 ImageNet 权重、不训练;这里是在 data/film 上自监督训练它。 +# +# 用法: +# uv run python scripts/cluster_unlabeled.py --config configs/cluster/film_simclr_resnet18.yaml \ +# --work-dir runs/simclr_r18 --set simclr.epochs=40 +_base_: + - ./film.yaml + +method: simclr + +simclr: + backbone: + # _replace_ 必须保留:不加的话 base 里 tiny_conv 的 widths/depths/block 会残留, + # 传给 TorchvisionBackbone 直接报错。见 utils/config.py::deep_merge + _replace_: true + name: torchvision + arch: resnet18 + pretrained: true # 从 ImageNet 权重起步再自监督微调;改 false 则从随机初始化开始 + in_channels: 3 + + # torchvision 的 features 是 nn.Sequential,可以取中间层(4~7 = layer1~4)。 + # 实测(1 epoch 自监督后,14,579 张,k=4): + # [5, 6] layer2+3 轮廓 0.243 稳定性 0.997 <- 默认 + # null layer4 轮廓 0.156 稳定性 0.528 + # [5, 6, 7] 再加 layer4 轮廓 0.155 稳定性 0.553 + # 原以为自监督之后 layer4 已是域内特征、可以直接用,实测并非如此: + # 缺陷差异主要在纹理层面,中间层依然更合适。 + feature_stages: [5, 6] + + # resnet18 比 tiny_conv 重得多(11.2M vs 0.3M),显存和耗时都要留余量 + batch_size: 128 + epochs: 40 diff --git a/tests/test_config.py b/tests/test_config.py index ecb3666..329b2ff 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -16,6 +16,24 @@ 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(): diff --git a/utils/config.py b/utils/config.py index 3f453e6..731afa3 100644 --- a/utils/config.py +++ b/utils/config.py @@ -107,15 +107,31 @@ def _load_raw(path: Path, seen: set[Path]) -> dict: def parse_override(item: str) -> tuple[str, Any]: - """解析 `train.lr=1e-3`,值按 Python 字面量尝试转换,失败则保留字符串。""" + """解析 `train.lr=1e-3`,把值转成合适的类型。 + + 先按 Python 字面量解析(`1e-3`、`[5, 6]`、`None`、`False` 都能认), + 再退回 YAML 标量解析,好让配置文件里的写法在命令行上同样可用 + (`null`、`true`、`false`)。两者都失败才保留字符串。 + + 这一步不能只用 `ast.literal_eval`:YAML 风格的 `false` 会原样变成字符串, + 而非空字符串是**真值**——`--set xxx.enabled=false` 反而把开关打开了。 + 也不能只用 YAML:PyYAML 把 `1e-3` 当字符串(YAML 1.1 要写成 `1.0e-3`)。 + """ if "=" not in item: raise ValueError(f"覆盖项格式应为 key=value,收到: {item}") key, raw = item.split("=", 1) + raw = raw.strip() + try: - value = ast.literal_eval(raw) + return key.strip(), ast.literal_eval(raw) except (ValueError, SyntaxError): - value = raw - return key.strip(), value + pass + try: + parsed = yaml.safe_load(raw) + except yaml.YAMLError: + return key.strip(), raw + # YAML 会把 "abc: def" 之类解析成 dict,命令行上应当按字符串处理 + return key.strip(), parsed if isinstance(parsed, (bool, int, float, list, type(None))) else raw def apply_overrides(cfg: dict, overrides: Iterable[str]) -> dict: