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`),不会出现"改了没生效"。学习率调度按新的总轮数重建
|
> optimizer.lr 0.001 -> 0.0005`),不会出现"改了没生效"。学习率调度按新的总轮数重建
|
||||||
> 并快进到当前 epoch,不会沿用存档里的 T_max。
|
> 并快进到当前 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 # 越小越关注难负样本
|
temperature: 0.2 # 越小越关注难负样本
|
||||||
amp: auto # CUDA 上开、MPS 上关
|
amp: auto # CUDA 上开、MPS 上关
|
||||||
save_interval: 5 # 每 5 个 epoch 落一次盘,中途中断也能拿到权重
|
save_interval: 5 # 每 5 个 epoch 落一次盘,中途中断也能拿到权重
|
||||||
|
# 续训时若调度已接近末尾,cosine 会把新设的 lr 乘上接近 0 的系数。
|
||||||
|
# 打开这个开关:按剩余轮数重开一条曲线,新 lr 从满值开始。
|
||||||
|
restart_schedule: false
|
||||||
|
|
||||||
# 训练可视化,字段与 train.swanlab 相同。默认 local:不需要登录,
|
# 训练可视化,字段与 train.swanlab 相同。默认 local:不需要登录,
|
||||||
# 日志写到 <work_dir>/swanlog,另开终端 `uv run swanlab watch runs/simclr/swanlog` 看板。
|
# 日志写到 <work_dir>/swanlog,另开终端 `uv run swanlab watch runs/simclr/swanlog` 看板。
|
||||||
@@ -122,3 +125,14 @@ simclr:
|
|||||||
# 输出:每簇软链多少张图 / 预览拼图放多少张
|
# 输出:每簇软链多少张图 / 预览拼图放多少张
|
||||||
link_limit: 300
|
link_limit: 300
|
||||||
preview_count: 100
|
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
|
clip_grad: 5.0
|
||||||
log_interval: 20
|
log_interval: 20
|
||||||
monitor: f1 # 用于挑选 best.pth 的验证指标
|
monitor: f1 # 用于挑选 best.pth 的验证指标
|
||||||
|
restart_schedule: false # 续训时按剩余轮数重开学习率曲线(调度已到末尾、想调大 lr 时用)
|
||||||
|
|
||||||
optimizer:
|
optimizer:
|
||||||
name: adamw
|
name: adamw
|
||||||
|
|||||||
+29
-4
@@ -287,6 +287,21 @@ class SimCLRTrainer:
|
|||||||
# 否则从 0 出发只会一路留在 0。
|
# 否则从 0 出发只会一路留在 0。
|
||||||
for group in self.optimizer.param_groups:
|
for group in self.optimizer.param_groups:
|
||||||
group["lr"] = group.get("initial_lr", group["lr"])
|
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)
|
self.scheduler = build_scheduler(self.optimizer, self.cfg.get("scheduler"), self.epochs)
|
||||||
with warnings.catch_warnings():
|
with warnings.catch_warnings():
|
||||||
warnings.simplefilter("ignore") # 快进阶段的 "step before optimizer.step()" 属预期
|
warnings.simplefilter("ignore") # 快进阶段的 "step before optimizer.step()" 属预期
|
||||||
@@ -311,13 +326,23 @@ class SimCLRTrainer:
|
|||||||
|
|
||||||
if changes:
|
if changes:
|
||||||
logger.warning("配置与存档不一致,按**当前配置**执行:%s", ";".join(changes))
|
logger.warning("配置与存档不一致,按**当前配置**执行:%s", ";".join(changes))
|
||||||
|
group = self.optimizer.param_groups[0]
|
||||||
|
initial = group.get("initial_lr", float("nan"))
|
||||||
logger.info(
|
logger.info(
|
||||||
"续训超参:lr=%.2e(initial_lr=%.2e)weight_decay=%s,共 %d 轮",
|
"续训超参:lr=%.2e(initial_lr=%.2e,调度进度 %d/%d)weight_decay=%s",
|
||||||
self.optimizer.param_groups[0]["lr"],
|
group["lr"],
|
||||||
self.optimizer.param_groups[0].get("initial_lr", float("nan")),
|
initial,
|
||||||
[g["weight_decay"] for g in self.optimizer.param_groups],
|
self.start_epoch,
|
||||||
self.epochs,
|
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:
|
def _checkpoint(self, epoch: int) -> None:
|
||||||
"""同时落两份:给下游用的骨架,和给续训用的完整状态。"""
|
"""同时落两份:给下游用的骨架,和给续训用的完整状态。"""
|
||||||
|
|||||||
@@ -200,6 +200,19 @@ class Trainer:
|
|||||||
# 否则从检查点带来的 lr 出发会算出一条错的曲线。
|
# 否则从检查点带来的 lr 出发会算出一条错的曲线。
|
||||||
for group in self.optimizer.param_groups:
|
for group in self.optimizer.param_groups:
|
||||||
group["lr"] = group.get("initial_lr", group["lr"])
|
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)
|
self.scheduler = build_scheduler(self.optimizer, self.cfg.get("scheduler"), self.epochs)
|
||||||
with warnings.catch_warnings():
|
with warnings.catch_warnings():
|
||||||
warnings.simplefilter("ignore") # 快进阶段的 "step before optimizer.step()" 属预期
|
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():
|
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}}}
|
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):
|
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")
|
(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"),
|
("configs/train/resnet18.yaml", "configs/eval/resnet18.yaml"),
|
||||||
]:
|
]:
|
||||||
assert load_config(train_cfg)["model"] == load_config(eval_cfg)["model"], eval_cfg
|
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)
|
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):
|
def test_resume_takes_hyperparams_from_config_not_checkpoint(tiny_image_dir, tmp_path):
|
||||||
"""续训时 lr / weight_decay / 总轮数一律以 yaml 为准,存档只提供进度。
|
"""续训时 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
|
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:
|
for item in overrides:
|
||||||
key, value = parse_override(item)
|
key, value = parse_override(item)
|
||||||
parts = key.split(".")
|
parts = key.split(".")
|
||||||
node = cfg
|
node = cfg
|
||||||
for part in parts[:-1]:
|
for depth, part in enumerate(parts[:-1]):
|
||||||
nxt = node.get(part)
|
nxt = node.get(part)
|
||||||
if not isinstance(nxt, dict):
|
if not isinstance(nxt, dict):
|
||||||
|
if strict and part not in node:
|
||||||
|
raise KeyError(_unknown_key_message(key, ".".join(parts[:depth]), node, part))
|
||||||
nxt = {}
|
nxt = {}
|
||||||
node[part] = nxt
|
node[part] = nxt
|
||||||
node = 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
|
node[parts[-1]] = value
|
||||||
return cfg
|
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:
|
def load_config(path: str | Path, overrides: Iterable[str] = ()) -> Config:
|
||||||
cfg = _load_raw(Path(path), set())
|
cfg = _load_raw(Path(path), set())
|
||||||
cfg = apply_overrides(cfg, overrides)
|
cfg = apply_overrides(cfg, overrides)
|
||||||
|
|||||||
Reference in New Issue
Block a user