Files
ModelTrainEval/engine/simclr.py
T
Shaun ZhouandClaude Opus 5 92c88b785c 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>
2026-09-07 19:30:17 +08:00

391 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SimCLR 自监督预训练:在无标注的薄膜图上学一套特征,供聚类使用。
骨架仍然是项目里的纯卷积 backbone(`tiny_conv` 等),预训练完可以直接拿去
初始化检测模型。支持 cuda / mps / cpu,用 `device: auto` 自动挑最快的后端。
"""
from __future__ import annotations
import time
import warnings
from collections.abc import Mapping
from pathlib import Path
from typing import Any
import torch
from torch import nn
from torch.nn import functional as F
from torch.utils.data import DataLoader
from engine.metrics import AverageMeter
from engine.optim import build_optimizer, build_scheduler
from engine.visualizer import SwanLabLogger
from models.heads.projection import ProjectionHead
from utils.device import PeakMemory, resolve_amp, synchronize
from utils.logging import get_logger
logger = get_logger("engine.simclr")
def nt_xent_loss(z1: torch.Tensor, z2: torch.Tensor, temperature: float = 0.2) -> torch.Tensor:
"""NT-Xent:同一张图的两个视图互为正样本,batch 内其余 2N-2 个都是负样本。"""
batch_size = z1.shape[0]
z = F.normalize(torch.cat([z1, z2]), dim=1)
similarity = z @ z.t() / temperature
similarity.fill_diagonal_(float("-inf")) # 排除自己和自己
targets = torch.cat(
[
torch.arange(batch_size, 2 * batch_size, device=z.device),
torch.arange(0, batch_size, device=z.device),
]
)
return F.cross_entropy(similarity, targets)
class SimCLRTrainer:
"""Args:
backbone: 输出特征图的卷积骨架(需带 `out_channels`)。
dataloader: 每个样本产出 2 个视图的 DataLoader(`UnlabeledImageDataset(views=2)`)。
cfg: `epochs / temperature / optimizer / scheduler / amp / log_interval / save_interval`
以及 `swanlab`(可视化,字段同 `train.swanlab`)。
full_config: 记录到 SwanLab 的超参,一般直接传完整 yaml 配置。
resume: 训练状态文件(`simclr_last.pth`)路径,从上次中断处接着练。
与 `simclr.checkpoint` 不同:checkpoint 是"有权重就跳过训练直接聚类",
resume 是"载入骨架 + 投影头 + 优化器 + 调度器 + 起始 epoch 继续训练"。
"""
def __init__(
self,
backbone: nn.Module,
dataloader: DataLoader,
cfg: Mapping[str, Any],
device: torch.device,
work_dir: str | Path,
full_config: dict | None = None,
resume: str | Path | None = None,
) -> None:
self.backbone = backbone
self.dataloader = dataloader
self.cfg = dict(cfg)
self.device = device
self.work_dir = Path(work_dir)
self.work_dir.mkdir(parents=True, exist_ok=True)
self.epochs = int(self.cfg.get("epochs", 50))
self.temperature = float(self.cfg.get("temperature", 0.2))
self.log_interval = int(self.cfg.get("log_interval", 20))
# 每隔若干 epoch 落一次盘:自监督动辄跑几十分钟,中途被打断不该把训练成果全丢掉
self.save_interval = int(self.cfg.get("save_interval", 5))
self.projector = ProjectionHead(
backbone.out_channels, **dict(self.cfg.get("projection", {}))
).to(device)
self.model = nn.ModuleDict({"backbone": backbone, "projector": self.projector})
self.optimizer = build_optimizer(
self.model, self.cfg.get("optimizer", {"name": "adamw", "lr": 1e-3, "weight_decay": 1e-4})
)
self.scheduler = build_scheduler(self.optimizer, self.cfg.get("scheduler"), self.epochs)
self.amp = resolve_amp(self.cfg.get("amp", "auto"), device)
self.scaler = torch.amp.GradScaler(device.type, enabled=self.amp)
self.peak_memory = PeakMemory(device)
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)
self.visualizer = SwanLabLogger(
work_dir=self.work_dir,
config=full_config,
**dict(self.cfg.get("swanlab", {})),
)
def train_one_epoch(self, epoch: int) -> float:
self.model.train()
meter = AverageMeter()
start = time.time()
for step, batch in enumerate(self.dataloader):
view1, view2 = (view.to(self.device, non_blocking=True) for view in batch["views"][:2])
with torch.amp.autocast(self.device.type, enabled=self.amp):
z1 = self.projector(self.backbone(view1))
z2 = self.projector(self.backbone(view2))
loss = nt_xent_loss(z1, z2, self.temperature)
self.optimizer.zero_grad(set_to_none=True)
self.scaler.scale(loss).backward()
self.scaler.step(self.optimizer)
self.scaler.update()
self.peak_memory.update()
meter.update(float(loss.detach()), view1.shape[0])
self.global_step += 1
if step % self.log_interval == 0:
self.visualizer.log(
{
"loss": float(loss.detach()),
"loss_avg": meter.avg,
"lr": self.optimizer.param_groups[0]["lr"],
},
step=self.global_step,
prefix="simclr/",
)
logger.info(
"simclr epoch %d [%d/%d] loss=%.4f lr=%.2e",
epoch,
step,
len(self.dataloader),
meter.avg,
self.optimizer.param_groups[0]["lr"],
)
synchronize(self.device)
peak = self.peak_memory.peak_mb
elapsed = time.time() - start
self.visualizer.log(
{
"epoch": float(epoch),
"epoch_loss": meter.avg,
"epoch_seconds": elapsed,
"gpu_mem_peak_mb": peak,
},
step=self.global_step,
prefix="simclr/",
)
logger.info(
"simclr epoch %d 完成 loss=%.4f 耗时 %.1fs 显存峰值 %.0f MB%s",
epoch,
meter.avg,
elapsed,
peak,
"" if self.peak_memory.exact else "(MPS 采样下界)",
)
return meter.avg
def save_backbone(self, epoch: int) -> Path:
"""只存骨架权重,不含投影头——投影头训练成对增强不变,会丢掉下游有用的信息。
这是给下游用的产物(聚类、初始化 DefectNet);要接着训练请用 `simclr_last.pth`。
"""
path = self.work_dir / "simclr_backbone.pth"
torch.save({"model": self.backbone.state_dict(), "epochs": epoch + 1}, path)
return path
def save_state(self, epoch: int) -> Path:
"""完整训练状态,供 `resume` 断点续训。
比 `simclr_backbone.pth` 多了投影头、优化器、调度器与 scaler——少了这些,
续训等于把动量和学习率进度清零,损失会先反弹一段。
同时记下当时的优化器/调度器配置,续训时若与新配置不同会打日志说明按新的来。
"""
path = self.work_dir / "simclr_last.pth"
torch.save(
{
"backbone": self.backbone.state_dict(),
"projector": self.projector.state_dict(),
"optimizer": self.optimizer.state_dict(),
"scheduler": self.scheduler.state_dict() if self.scheduler else None,
"scaler": self.scaler.state_dict(),
"epoch": epoch,
"global_step": self.global_step,
"epochs_planned": self.epochs,
"optimizer_cfg": dict(self.cfg.get("optimizer", {})),
"scheduler_cfg": dict(self.cfg.get("scheduler") or {}),
},
path,
)
return path
def load_state(self, path: str | Path) -> None:
"""载入 `save_state` 写出的完整状态,从下一个 epoch 接着练。
**配置文件是唯一真相源**:存档只提供"进度"——模型权重、优化器动量缓冲、
已训到第几个 epoch、global_step;而学习率、weight_decay、总轮数、调度器
一律以当前 yaml 为准。否则改了配置再续训,改动会被存档静默覆盖掉。
"""
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"续训状态文件不存在: {path}")
state = torch.load(path, map_location=self.device, weights_only=False)
if "projector" not in state:
raise ValueError(
f"{path} 只含骨架权重,无法续训。续训要用 simclr_last.pth;"
"若只是想跳过训练直接聚类,请改用 simclr.checkpoint。"
)
self.backbone.load_state_dict(state["backbone"])
self.projector.load_state_dict(state["projector"])
# optimizer.load_state_dict 会整体覆盖 param_groups,把 lr/weight_decay 等超参
# 一并还原成存档时的值。先把配置构造出来的超参抄下来,加载完再按组写回去,
# 这样只留下动量缓冲(真正的"进度"),超参仍以 yaml 为准。
# 按组写回也保住了 build_optimizer 的分组结构(BN 与 bias 那组 weight_decay=0)。
archived_optimizer = (state.get("optimizer_cfg") or {}).get("name")
current_optimizer = dict(self.cfg.get("optimizer", {})).get("name")
if archived_optimizer and current_optimizer and archived_optimizer != current_optimizer:
# 换了优化器种类,动量缓冲的含义完全不同(AdamW 的 exp_avg 对 SGD 毫无意义),
# 硬塞进去只会白占内存。直接不加载,从零开始积累。
logger.warning(
"优化器由 %s 换成了 %s,动量缓冲无法迁移、将从零积累(模型权重与训练进度不受影响)",
archived_optimizer,
current_optimizer,
)
else:
intended = [
{key: value for key, value in group.items() if key != "params"}
for group in self.optimizer.param_groups
]
self.optimizer.load_state_dict(state["optimizer"])
for group, wanted in zip(self.optimizer.param_groups, intended, strict=True):
group.update(wanted)
if state.get("scaler"):
self.scaler.load_state_dict(state["scaler"])
self.start_epoch = int(state["epoch"]) + 1
self.global_step = int(state.get("global_step", 0))
self._restore_scheduler(state)
self._log_config_changes(state)
logger.info(
"已载入训练状态 %s:从 epoch %d 接着练到 %d", path, self.start_epoch, self.epochs
)
if self.start_epoch >= self.epochs:
logger.warning(
"状态里已训到 epoch %d,不小于计划的 %d 轮——不会再训练。"
"要继续请调大 simclr.epochs。",
self.start_epoch,
self.epochs,
)
def _restore_scheduler(self, state: dict) -> None:
"""按**当前配置**重建学习率调度,再快进到已训到的 epoch。
不恢复存档里的调度器状态:它带着当时的 T_max 和 base_lrs,一旦 yaml 里改了
总轮数或学习率就会被静默覆盖。原计划 10 轮的 cosine 跑完后 lr 已是 0,
这时把 epochs 改成 30 接着练,沿用旧 T_max 的话 lr 仍是 0——一步都学不动。
cosine / step / multistep 都是 epoch 的确定性函数,快进与恢复状态等价,
所以统一走重建这一条路,没有第二种行为。
"""
del state # 存档里的 scheduler 状态刻意不用
if self.scheduler is None:
return
# cosine 的链式实现基于当前 lr 递推,必须先复位回 initial_lr,
# 否则从 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()" 属预期
for _ in range(self.start_epoch):
self.scheduler.step()
def _log_config_changes(self, state: dict) -> None:
"""存档配置与当前配置不一致时说清楚用的是哪一份,避免"改了没生效"的困惑。"""
changes = []
planned = int(state.get("epochs_planned", self.epochs))
if planned != self.epochs:
changes.append(f"总轮数 {planned} -> {self.epochs}")
for key, archived in (state.get("optimizer_cfg") or {}).items():
current = dict(self.cfg.get("optimizer", {})).get(key)
if current is not None and current != archived:
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")
# 旧存档没有 scheduler_cfg 字段,那是"不知道"而不是"当时没有调度器",不能报成差异
if "scheduler_cfg" in state and archived_scheduler != current_scheduler:
changes.append(f"scheduler {archived_scheduler} -> {current_scheduler}")
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,调度进度 %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:
"""同时落两份:给下游用的骨架,和给续训用的完整状态。"""
self.save_backbone(epoch)
self.save_state(epoch)
def fit(self) -> Path:
"""训练并保存骨架权重(不含投影头),返回骨架路径。
每 `save_interval` 个 epoch 落一次盘(Ctrl-C 也会先落盘),
同时写 `simclr_backbone.pth`(下游用)与 `simclr_last.pth`(续训用)。
"""
if len(self.dataloader) < 2:
raise ValueError("SimCLR 需要多个 batch,请调小 batch_size 或增加样本")
path = self.work_dir / "simclr_backbone.pth"
if self.start_epoch >= self.epochs:
self.save_backbone(self.epochs - 1)
self.visualizer.finish()
return path
epoch = self.start_epoch
try:
for epoch in range(self.start_epoch, self.epochs):
self.train_one_epoch(epoch)
if self.scheduler is not None:
self.scheduler.step()
if self.save_interval and (epoch + 1) % self.save_interval == 0:
self._checkpoint(epoch)
logger.info("已保存中间权重(epoch %d): %s", epoch, path)
except KeyboardInterrupt:
self._checkpoint(epoch)
logger.warning(
"训练被中断,已保存 epoch %d 的权重: %s;续训用 --set simclr.resume=%s",
epoch,
path,
self.work_dir / "simclr_last.pth",
)
self.visualizer.finish()
return path
self._checkpoint(self.epochs - 1)
logger.info("自监督骨架已保存: %s", path)
self.visualizer.finish()
return path