fix(config): --set 拦下不存在的键,并支持续训时重开学习率曲线

用户反馈"聚类配置里改不了 lr/weight_decay",实测是两件事:

1. --set 的键打错字母会凭空造出一个谁也不读的键,训练照跑但改动无效。
   实例:simclr.optmizer.weight_decay=0.0(optimizer 少个 i)被静默接受。
   现在 apply_overrides 默认只允许覆盖已存在的键,报错时给出同级可用键
   与最接近的拼写("是不是想写 'optimizer'?")。需要新增键时可 strict=False。

2. lr 其实生效了,但从 2229/2500 续训时 cosine 只剩满值的 3%,
   新设的 1e-3 落到 2.87e-5。新增 restart_schedule 开关:按剩余轮数重开曲线,
   新 lr 从满值开始;SimCLR 与检测训练都支持。续训日志补上调度进度,
   并在 lr 不足 initial_lr 的 10% 时警告并给出两种解法。

顺带:严格校验立刻暴露出 configs/cluster/film.yaml 里根本没有 scatter 段
(代码 cfg.get("scatter", {}) 兜底走默认值,所以功能正常但配置项形同虚设),已补上。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Shaun Zhou
2026-09-07 19:30:17 +08:00
co-authored by Claude Opus 5
parent 506fde0290
commit 92c88b785c
8 changed files with 165 additions and 7 deletions
+13
View File
@@ -355,6 +355,19 @@ uv run python scripts/cluster_unlabeled.py --config configs/cluster/film.yaml \
> optimizer.lr 0.001 -> 0.0005`),不会出现"改了没生效"。学习率调度按新的总轮数重建
> 并快进到当前 epoch,不会沿用存档里的 T_max。
**续训到调度末段时想调大 lr**:cosine 是 epoch 的函数,在 2229/2500 处只剩满值的 3%,
所以新设的 `lr=1e-3` 实际只有 `2.9e-5`。日志会警告这一点。要让新 lr 真正生效:
```bash
# 按剩余轮数重开一条 cosine 曲线,新 lr 从满值开始
--set simclr.restart_schedule=true
# 或者拉长整条曲线(总轮数变大,当前位置的系数就回升)
--set simclr.epochs=5000
```
`train.py` 的 `train.restart_schedule` 同理。
续训时各项配置的行为:
| 配置项 | 续训时 |
+14
View File
@@ -99,6 +99,9 @@ simclr:
temperature: 0.2 # 越小越关注难负样本
amp: auto # CUDA 上开、MPS 上关
save_interval: 5 # 每 5 个 epoch 落一次盘,中途中断也能拿到权重
# 续训时若调度已接近末尾,cosine 会把新设的 lr 乘上接近 0 的系数。
# 打开这个开关:按剩余轮数重开一条曲线,新 lr 从满值开始。
restart_schedule: false
# 训练可视化,字段与 train.swanlab 相同。默认 local:不需要登录,
# 日志写到 <work_dir>/swanlog,另开终端 `uv run swanlab watch runs/simclr/swanlog` 看板。
@@ -122,3 +125,14 @@ simclr:
# 输出:每簇软链多少张图 / 预览拼图放多少张
link_limit: 300
preview_count: 100
# 簇分布图 cluster_scatter.png(与 preview 拼图同级):
# 左边是聚类空间的 PCA 二维投影(散点按簇着色,X 是质心),右边是质心两两距离矩阵。
# 用 PCA 而不是 t-SNE/UMAP——后两者为了把簇拉开会扭曲簇间距离,而这张图就是要看距离。
scatter:
enabled: true
max_points: 5000 # 点太多会糊成一团且文件很大,超过就随机抽样
point_size: 6
alpha: 0.35
figsize: [14, 6]
dpi: 150
+1
View File
@@ -13,6 +13,7 @@ train:
clip_grad: 5.0
log_interval: 20
monitor: f1 # 用于挑选 best.pth 的验证指标
restart_schedule: false # 续训时按剩余轮数重开学习率曲线(调度已到末尾、想调大 lr 时用)
optimizer:
name: adamw
+29 -4
View File
@@ -287,6 +287,21 @@ class SimCLRTrainer:
# 否则从 0 出发只会一路留在 0。
for group in self.optimizer.param_groups:
group["lr"] = group.get("initial_lr", group["lr"])
if self.cfg.get("restart_schedule"):
# 续训到调度末段时,cosine 会把新设的 lr 乘上一个接近 0 的系数
# (2229/2500 处只剩 2.9%),"调大学习率接着练"就落空了。
# 打开这个开关:按剩余轮数重开一条曲线,新 lr 从满值开始。
remaining = max(1, self.epochs - self.start_epoch)
self.scheduler = build_scheduler(self.optimizer, self.cfg.get("scheduler"), remaining)
logger.warning(
"restart_schedule=true:学习率调度从 epoch %d 重新开始,覆盖剩余 %d 轮,"
"起始 lr=%.2e",
self.start_epoch,
remaining,
self.optimizer.param_groups[0]["lr"],
)
return
self.scheduler = build_scheduler(self.optimizer, self.cfg.get("scheduler"), self.epochs)
with warnings.catch_warnings():
warnings.simplefilter("ignore") # 快进阶段的 "step before optimizer.step()" 属预期
@@ -311,13 +326,23 @@ class SimCLRTrainer:
if changes:
logger.warning("配置与存档不一致,按**当前配置**执行:%s", ";".join(changes))
group = self.optimizer.param_groups[0]
initial = group.get("initial_lr", float("nan"))
logger.info(
"续训超参:lr=%.2e(initial_lr=%.2e)weight_decay=%s,共 %d 轮",
self.optimizer.param_groups[0]["lr"],
self.optimizer.param_groups[0].get("initial_lr", float("nan")),
[g["weight_decay"] for g in self.optimizer.param_groups],
"续训超参:lr=%.2e(initial_lr=%.2e,调度进度 %d/%d)weight_decay=%s",
group["lr"],
initial,
self.start_epoch,
self.epochs,
[g["weight_decay"] for g in self.optimizer.param_groups],
)
if initial and group["lr"] < initial * 0.1 and not self.cfg.get("restart_schedule"):
logger.warning(
"当前 lr 只有 initial_lr 的 %.1f%%——调度已接近末尾,此时调大 lr 收效有限。"
"想让新 lr 真正生效可加 --set simclr.restart_schedule=true(按剩余轮数重开曲线),"
"或调大 simclr.epochs 拉长整条曲线。",
group["lr"] / initial * 100,
)
def _checkpoint(self, epoch: int) -> None:
"""同时落两份:给下游用的骨架,和给续训用的完整状态。"""
+13
View File
@@ -200,6 +200,19 @@ class Trainer:
# 否则从检查点带来的 lr 出发会算出一条错的曲线。
for group in self.optimizer.param_groups:
group["lr"] = group.get("initial_lr", group["lr"])
if self.cfg.get("restart_schedule"):
# 续训到调度末段时 cosine 会把新设的 lr 乘上一个接近 0 的系数,
# "调大学习率接着练"就落空了。打开这个开关按剩余轮数重开一条曲线。
remaining = max(1, self.epochs - self.start_epoch)
self.scheduler = build_scheduler(self.optimizer, self.cfg.get("scheduler"), remaining)
logger.warning(
"restart_schedule=true:学习率调度从 epoch %d 重新开始,覆盖剩余 %d 轮,起始 lr=%.2e",
self.start_epoch,
remaining,
self.optimizer.param_groups[0]["lr"],
)
return
self.scheduler = build_scheduler(self.optimizer, self.cfg.get("scheduler"), self.epochs)
with warnings.catch_warnings():
warnings.simplefilter("ignore") # 快进阶段的 "step before optimizer.step()" 属预期
+36 -1
View File
@@ -37,9 +37,13 @@ def test_parse_override_accepts_yaml_style_scalars():
def test_apply_overrides_creates_missing_nodes():
cfg = apply_overrides({}, ["a.b.c=1"])
"""非严格模式下仍可新建嵌套节点;严格模式(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")
@@ -152,3 +156,34 @@ def test_eval_config_matches_its_training_backbone():
("configs/train/resnet18.yaml", "configs/eval/resnet18.yaml"),
]:
assert load_config(train_cfg)["model"] == load_config(eval_cfg)["model"], eval_cfg
@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_resnet50.yaml", [override])
assert expected_hint in str(excinfo.value)
def test_set_accepts_existing_keys():
cfg = load_config(
"configs/cluster/film_simclr_resnet50.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}}
+33
View File
@@ -292,6 +292,39 @@ def test_resume_with_a_longer_schedule_rebuilds_the_lr_curve(tiny_image_dir, tmp
assert lr == pytest.approx(1e-3 * (1 + math.cos(math.pi * 10 / 30)) / 2, rel=1e-3)
def test_restart_schedule_gives_the_new_lr_full_value(tiny_image_dir, tmp_path):
"""续训到调度末段时,cosine 会把新 lr 乘上接近 0 的系数;
restart_schedule 按剩余轮数重开曲线,让新 lr 从满值开始。"""
import math
work_dir = tmp_path / "run"
_make_trainer(
tiny_image_dir, work_dir, epochs=9,
optimizer={"name": "adamw", "lr": 1e-3},
).fit()
def resumed(restart):
return SimCLRTrainer(
BACKBONES.build({"name": "tiny_conv", "widths": [8, 16], "depths": [1, 1]}),
_make_trainer(tiny_image_dir, work_dir, epochs=1).dataloader,
{"epochs": 10, "amp": False, "log_interval": 999, "save_interval": 1,
"scheduler": {"name": "cosine"}, "optimizer": {"name": "adamw", "lr": 1e-3},
"projection": {"hidden_channels": 16, "out_channels": 8},
"swanlab": {"enabled": False}, "restart_schedule": restart},
torch.device("cpu"),
work_dir,
resume=work_dir / "simclr_last.pth",
)
# 默认:沿用整条曲线,9/10 处只剩很小的系数
default_lr = resumed(False).optimizer.param_groups[0]["lr"]
assert default_lr == pytest.approx(1e-3 * (1 + math.cos(math.pi * 9 / 10)) / 2)
assert default_lr < 1e-3 * 0.1
# 打开开关:新 lr 从满值开始
assert resumed(True).optimizer.param_groups[0]["lr"] == pytest.approx(1e-3)
def test_resume_takes_hyperparams_from_config_not_checkpoint(tiny_image_dir, tmp_path):
"""续训时 lr / weight_decay / 总轮数一律以 yaml 为准,存档只提供进度。
+26 -2
View File
@@ -134,21 +134,45 @@ def parse_override(item: str) -> tuple[str, Any]:
return key.strip(), parsed if isinstance(parsed, (bool, int, float, list, type(None))) else raw
def apply_overrides(cfg: dict, overrides: Iterable[str]) -> dict:
def apply_overrides(cfg: dict, overrides: Iterable[str], strict: bool = True) -> dict:
"""把 `a.b.c=value` 写进配置。
`strict` 时只允许覆盖**已存在**的键:`--set` 打错一个字母(optimizer -> optmizer)
会凭空造出一个谁也不读的键,训练照跑但改动毫无效果,事后极难发现。
"""
for item in overrides:
key, value = parse_override(item)
parts = key.split(".")
node = cfg
for part in parts[:-1]:
for depth, part in enumerate(parts[:-1]):
nxt = node.get(part)
if not isinstance(nxt, dict):
if strict and part not in node:
raise KeyError(_unknown_key_message(key, ".".join(parts[:depth]), node, part))
nxt = {}
node[part] = nxt
node = nxt
if strict and parts[-1] not in node:
raise KeyError(_unknown_key_message(key, ".".join(parts[:-1]), node, parts[-1]))
node[parts[-1]] = value
return cfg
def _unknown_key_message(full_key: str, prefix: str, node: Mapping, missing: str) -> str:
"""报错时给出同级可用键与最接近的拼写,方便一眼看出打错在哪。"""
import difflib
available = sorted(k for k in node if not k.startswith("_"))
close = difflib.get_close_matches(missing, available, n=3, cutoff=0.6)
where = f"`{prefix}` 下" if prefix else "配置顶层"
hint = f",是不是想写 {close[0]!r}?" if close else ""
return (
f"--set 里的 {full_key!r} 在配置中不存在:{where}没有 {missing!r}{hint} "
f"同级可用的键有 {available}。"
"(只允许覆盖已存在的键;确实要新增请先写进 yaml)"
)
def load_config(path: str | Path, overrides: Iterable[str] = ()) -> Config:
cfg = _load_raw(Path(path), set())
cfg = apply_overrides(cfg, overrides)