feat(cluster): 新增 resnet18 自监督聚类配置,并修复 --set 的 YAML 标量解析
- 新增 configs/cluster/film_simclr_resnet18.yaml:用 resnet18 做域内自监督后聚类。 backbone 块带 _replace_,避免 base 里 tiny_conv 的 widths/depths 残留 - parse_override 此前只用 ast.literal_eval,不认 YAML 风格的 null/true/false: `--set xxx.enabled=false` 会得到字符串 "false",而非空字符串是真值,开关反被打开。 改为先试 Python 字面量(保住 1e-3 这类),再退回 YAML 标量解析 - feature_stages 默认由 null 改为 [5, 6]:实测 1 epoch 自监督后 layer2+3 的轮廓 0.243/稳定性 0.997,明显优于 layer4 的 0.156/0.528。原注释里"自监督后 layer4 已是域内特征"的推断被数据推翻 - README 补充三种方案的实测对比与成本 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
47fb0f1125
commit
6f00074199
@@ -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 | 说明 |
|
||||
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
+20
-4
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user