From 506fde029013b13a7bdeffd5e274680e8a133d82 Mon Sep 17 00:00:00 2001 From: Shaun Zhou Date: Mon, 7 Sep 2026 19:11:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(cluster):=20=E9=85=8D=E7=BD=AE=E5=B1=82?= =?UTF-8?q?=E7=BA=A7=E5=86=99=E9=94=99=E4=B8=8D=E5=86=8D=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E5=BF=BD=E7=95=A5=EF=BC=8C=E5=B9=B6=E5=A7=8B=E7=BB=88=E6=89=93?= =?UTF-8?q?=E5=8D=B0=E7=94=9F=E6=95=88=E7=9A=84=E8=B6=85=E5=8F=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 聚类配置里 optimizer/epochs 等键都在 `simclr:` 段下。写到顶层会被完全忽略、 不报任何错——用户改了 lr 却毫无反应,极难排查。同样的坑还有:改了基类 configs/cluster/film.yaml 的 simclr.optimizer,但运行的是子配置 film_simclr_resnet18.yaml,子配置里的同名键会盖掉基类的修改。 - 顶层出现 optimizer / scheduler / epochs / temperature / projection / save_interval / resume / checkpoint / log_interval 时警告并给出正确写法 - method != simclr 却写了 simclr: 段时提示该段不会生效 - SimCLRTrainer 初始化时无条件打印实际生效的 epochs / temperature / optimizer / scheduler,不再只在续训时才打印 - 修掉一处假差异:旧存档没有 scheduler_cfg 字段时会误报 "scheduler None -> cosine", 那是"不知道"而非"当时没有调度器" 已验证 --set 覆盖、直接改 yaml、旧格式存档三条路径下 lr/weight_decay 均正常生效。 Co-Authored-By: Claude Opus 5 --- engine/simclr.py | 11 +++++++- scripts/cluster_unlabeled.py | 29 ++++++++++++++++++++ tests/test_simclr.py | 51 ++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/engine/simclr.py b/engine/simclr.py index 357a959..561ce5e 100644 --- a/engine/simclr.py +++ b/engine/simclr.py @@ -92,6 +92,14 @@ class SimCLRTrainer: self.global_step = 0 self.start_epoch = 0 + # 无论是否续训都打印一次实际生效的超参:配置写错层级时最难发现的就是"改了没反应" + logger.info( + "SimCLR 超参:epochs=%d temperature=%.3g optimizer=%s scheduler=%s", + self.epochs, + self.temperature, + dict(self.cfg.get("optimizer", {})) or "(默认 adamw lr=1e-3)", + (dict(self.cfg.get("scheduler") or {})).get("name") or "(无)", + ) if resume: self.load_state(resume) @@ -297,7 +305,8 @@ class SimCLRTrainer: changes.append(f"optimizer.{key} {archived} -> {current}") archived_scheduler = (state.get("scheduler_cfg") or {}).get("name") current_scheduler = (dict(self.cfg.get("scheduler") or {})).get("name") - if archived_scheduler != current_scheduler: + # 旧存档没有 scheduler_cfg 字段,那是"不知道"而不是"当时没有调度器",不能报成差异 + if "scheduler_cfg" in state and archived_scheduler != current_scheduler: changes.append(f"scheduler {archived_scheduler} -> {current_scheduler}") if changes: diff --git a/scripts/cluster_unlabeled.py b/scripts/cluster_unlabeled.py index 2cf6f81..974da41 100644 --- a/scripts/cluster_unlabeled.py +++ b/scripts/cluster_unlabeled.py @@ -353,6 +353,33 @@ def save_preview(path: Path, metas: list[dict], indices: list[int], cell: int = canvas.save(path) +#: 这些键只在 `simclr:` 段下有意义,写到顶层会被静默忽略 +SIMCLR_ONLY_KEYS = ( + "optimizer", + "scheduler", + "epochs", + "temperature", + "projection", + "save_interval", + "resume", + "checkpoint", + "log_interval", +) + + +def warn_misplaced_keys(cfg, method: str) -> None: + """顶层出现本该放在 simclr: 下的键时警告——否则改了配置却毫无反应,极难排查。""" + misplaced = [key for key in SIMCLR_ONLY_KEYS if key in cfg] + if misplaced: + logger.warning( + "配置顶层出现了只在 `simclr:` 段下生效的键 %s,它们会被忽略。" + "把它们缩进到 simclr: 下面,例如 `simclr:\n optimizer: {lr: 0.0005}`", + misplaced, + ) + if method != "simclr" and cfg.get("simclr"): + logger.info("当前 method=%s,`simclr:` 段下的配置不会生效", method) + + def sweep_clusters(embeddings, spec: str, cluster_kwargs: dict) -> None: """扫一遍簇数,同时看轮廓系数和换种子的稳定性。 @@ -431,6 +458,8 @@ def main() -> int: logger.error("--train-only 与 --sweep-k 不能同时用:前者不聚类,后者要靠聚类才能扫") return 1 + warn_misplaced_keys(cfg, method) + dataset = build_dataset(dict(cfg["dataset"])) logger.info( "扫描到 %d 张图,来自 %d 个目录;簇数 %d,method=%s,设备 %s", diff --git a/tests/test_simclr.py b/tests/test_simclr.py index 05b5a8a..57927f2 100644 --- a/tests/test_simclr.py +++ b/tests/test_simclr.py @@ -489,6 +489,57 @@ def _cluster_argv(work_dir, image_dir, *extra): ] +def test_warns_when_simclr_keys_are_at_top_level(tiny_image_dir, caplog): + """顶层写 optimizer 会被静默忽略,必须警告——否则"改了配置没反应"极难排查。""" + import logging + + sys.path.insert(0, "scripts") + import cluster_unlabeled as cu + + cfg = { + "dataset": {"name": "unlabeled", "root": str(tiny_image_dir)}, + "optimizer": {"name": "adamw", "lr": 0.9}, # ← 应该放在 simclr: 下 + "epochs": 100, # ← 同上 + "simclr": {"epochs": 2}, + } + with caplog.at_level(logging.WARNING, logger="mte"): + cu.warn_misplaced_keys(cfg, "simclr") + + assert "optimizer" in caplog.text and "epochs" in caplog.text + assert "simclr" in caplog.text + + +def test_no_warning_when_keys_are_correctly_nested(tiny_image_dir, caplog): + import logging + + sys.path.insert(0, "scripts") + import cluster_unlabeled as cu + + cfg = {"dataset": {"name": "unlabeled", "root": str(tiny_image_dir)}, + "simclr": {"optimizer": {"lr": 0.9}, "epochs": 2}} + with caplog.at_level(logging.WARNING, logger="mte"): + cu.warn_misplaced_keys(cfg, "simclr") + assert "会被忽略" not in caplog.text + + +def test_effective_hyperparams_are_logged_on_every_run(tiny_image_dir, tmp_path, caplog): + """不只是续训时——每次训练开始都要打印实际生效的超参。""" + import logging + + trainer = _make_trainer( + tiny_image_dir, tmp_path / "run", epochs=1, + optimizer={"name": "adamw", "lr": 0.00042, "weight_decay": 0.007}, + ) + del trainer + with caplog.at_level(logging.INFO, logger="mte"): + _make_trainer( + tiny_image_dir, tmp_path / "run2", epochs=1, + optimizer={"name": "adamw", "lr": 0.00042, "weight_decay": 0.007}, + ) + assert "SimCLR 超参" in caplog.text + assert "0.00042" in caplog.text + + def test_train_only_stops_before_clustering(tiny_image_dir, tmp_path, monkeypatch): """--train-only 只训练存权重,不抽特征也不聚类。""" sys.path.insert(0, "scripts")