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:
co-authored by
Claude Opus 5
parent
506fde0290
commit
92c88b785c
@@ -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` 同理。
|
||||
|
||||
续训时各项配置的行为:
|
||||
|
||||
| 配置项 | 续训时 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
"""同时落两份:给下游用的骨架,和给续训用的完整状态。"""
|
||||
|
||||
@@ -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
@@ -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}}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user