diff --git a/README.md b/README.md index d34d9a6..59be879 100644 --- a/README.md +++ b/README.md @@ -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` 同理。 + 续训时各项配置的行为: | 配置项 | 续训时 | diff --git a/configs/cluster/film.yaml b/configs/cluster/film.yaml index 5a1bef4..9773ed2 100644 --- a/configs/cluster/film.yaml +++ b/configs/cluster/film.yaml @@ -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:不需要登录, # 日志写到 /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 diff --git a/configs/train/baseline.yaml b/configs/train/baseline.yaml index b06557b..d366433 100644 --- a/configs/train/baseline.yaml +++ b/configs/train/baseline.yaml @@ -13,6 +13,7 @@ train: clip_grad: 5.0 log_interval: 20 monitor: f1 # 用于挑选 best.pth 的验证指标 + restart_schedule: false # 续训时按剩余轮数重开学习率曲线(调度已到末尾、想调大 lr 时用) optimizer: name: adamw diff --git a/engine/simclr.py b/engine/simclr.py index 561ce5e..e95f026 100644 --- a/engine/simclr.py +++ b/engine/simclr.py @@ -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: """同时落两份:给下游用的骨架,和给续训用的完整状态。""" diff --git a/engine/trainer.py b/engine/trainer.py index 8767812..4f27661 100644 --- a/engine/trainer.py +++ b/engine/trainer.py @@ -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()" 属预期 diff --git a/tests/test_config.py b/tests/test_config.py index 329b2ff..34a51e9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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}} diff --git a/tests/test_simclr.py b/tests/test_simclr.py index 57927f2..22c27a7 100644 --- a/tests/test_simclr.py +++ b/tests/test_simclr.py @@ -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 为准,存档只提供进度。 diff --git a/utils/config.py b/utils/config.py index 731afa3..aa3d926 100644 --- a/utils/config.py +++ b/utils/config.py @@ -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)