fix(trainer): 检测训练续训以配置为准,并恢复学习率调度进度
train.py --resume 走的是与 SimCLR 不同的一套代码,同样的问题在这里更严重: - load_checkpoint 直接 optimizer.load_state_dict,把 lr/weight_decay 连同动量缓冲 一起还原成存档值:yaml 写 lr=5e-4,实际仍按检查点的 1e-3 训 - save_checkpoint 根本没保存调度器状态,续训时 scheduler.last_epoch 从 0 开始, 而 optimizer 的 lr 又来自检查点,两者互相矛盾,cosine 曲线完全错乱 改为与 SimCLR 一致的「配置文件是唯一真相源」: - save_checkpoint 补上 scheduler 状态、global_step 与当时的 train 配置快照 - 新增 Trainer.resume():加载优化器状态后按组写回配置里的超参(只留动量缓冲, 并保住 BN/bias 那组 weight_decay=0 的结构),调度器按当前配置重建再快进到已训 epoch - 配置与检查点不一致时逐项打日志说明按当前配置执行 - train.py 改用 trainer.resume(),不再自己拼恢复逻辑 实测续训到 10 轮时 lr=3.273e-04,与 cosine 在 4/10 处的理论值精确一致; weight_decay、T_max、调度进度、动量缓冲均正确。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
01cfff9b02
commit
105cb38831
@@ -109,6 +109,19 @@ uv run python scripts/export_onnx.py --config configs/train/resnet18.yaml \
|
|||||||
|
|
||||||
产物写在 `work_dir`(默认 `runs/<name>/`):`train.log`、`config.yaml`、`last.pth`、`best.pth`、`swanlog/`。
|
产物写在 `work_dir`(默认 `runs/<name>/`):`train.log`、`config.yaml`、`last.pth`、`best.pth`、`swanlog/`。
|
||||||
|
|
||||||
|
**续训以配置为准。** `--resume` 只从检查点取"进度"——模型权重、优化器动量缓冲、
|
||||||
|
已训到第几轮、`best_metric`、`global_step`;`epochs`、`lr`、`weight_decay`、调度器
|
||||||
|
一律以当前 yaml 为准,所以可以顺手调超参:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python train.py --config configs/train/baseline.yaml --resume runs/baseline/last.pth \
|
||||||
|
--set train.epochs=200 train.optimizer.lr=5e-4
|
||||||
|
```
|
||||||
|
|
||||||
|
与检查点不一致时日志会逐项列出(`配置与检查点不一致,按当前配置执行:总轮数 100 -> 200;
|
||||||
|
optimizer.lr 0.001 -> 0.0005`)。学习率调度按新的总轮数重建并快进到当前 epoch。
|
||||||
|
`epochs` 是**总轮数**不是剩余轮数。SimCLR 自监督那边是同样的语义。
|
||||||
|
|
||||||
## 多类 / 单类分类
|
## 多类 / 单类分类
|
||||||
|
|
||||||
图像级分类的形式由 yaml 的 `label_mode` 决定,模型的分类头、分类损失、分类指标都跟着切换
|
图像级分类的形式由 yaml 的 `label_mode` 决定,模型的分类头、分类损失、分类指标都跟着切换
|
||||||
|
|||||||
@@ -16,16 +16,27 @@ def save_checkpoint(
|
|||||||
epoch: int = 0,
|
epoch: int = 0,
|
||||||
best_metric: float | None = None,
|
best_metric: float | None = None,
|
||||||
config: dict | None = None,
|
config: dict | None = None,
|
||||||
|
scheduler: Any = None,
|
||||||
|
global_step: int = 0,
|
||||||
|
train_cfg: dict | None = None,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
|
"""保存检查点。
|
||||||
|
|
||||||
|
除模型与优化器外还记下调度器状态、global_step 和当时的 train 配置:
|
||||||
|
少了这些,续训时学习率调度会从头开始,而且无法判断 yaml 里的超参有没有被改过。
|
||||||
|
"""
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
torch.save(
|
torch.save(
|
||||||
{
|
{
|
||||||
"model": model.state_dict(),
|
"model": model.state_dict(),
|
||||||
"optimizer": optimizer.state_dict() if optimizer else None,
|
"optimizer": optimizer.state_dict() if optimizer else None,
|
||||||
|
"scheduler": scheduler.state_dict() if scheduler else None,
|
||||||
"epoch": epoch,
|
"epoch": epoch,
|
||||||
|
"global_step": global_step,
|
||||||
"best_metric": best_metric,
|
"best_metric": best_metric,
|
||||||
"config": config,
|
"config": config,
|
||||||
|
"train_cfg": train_cfg,
|
||||||
},
|
},
|
||||||
path,
|
path,
|
||||||
)
|
)
|
||||||
|
|||||||
+89
-1
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
|
import warnings
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -12,7 +13,7 @@ import torch
|
|||||||
from torch import nn
|
from torch import nn
|
||||||
from torch.utils.data import DataLoader
|
from torch.utils.data import DataLoader
|
||||||
|
|
||||||
from engine.checkpoint import save_checkpoint
|
from engine.checkpoint import load_checkpoint, save_checkpoint
|
||||||
from engine.evaluator import Evaluator, _to_device
|
from engine.evaluator import Evaluator, _to_device
|
||||||
from engine.losses import DetectionLoss
|
from engine.losses import DetectionLoss
|
||||||
from engine.metrics import AverageMeter
|
from engine.metrics import AverageMeter
|
||||||
@@ -149,6 +150,87 @@ class Trainer:
|
|||||||
self.peak_memory.reset()
|
self.peak_memory.reset()
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
def resume(self, path: str | Path) -> dict:
|
||||||
|
"""从检查点续训。
|
||||||
|
|
||||||
|
**配置文件是唯一真相源**:检查点只提供"进度"——模型权重、优化器动量缓冲、
|
||||||
|
已训到第几个 epoch、global_step、best_metric;而 lr、weight_decay、总轮数、
|
||||||
|
调度器一律以当前 yaml 为准。否则改了配置再续训,改动会被检查点静默覆盖。
|
||||||
|
"""
|
||||||
|
state = load_checkpoint(path, self.model, map_location=self.device)
|
||||||
|
|
||||||
|
if state.get("optimizer"):
|
||||||
|
# optimizer.load_state_dict 会整体覆盖 param_groups,把 lr/weight_decay
|
||||||
|
# 一并还原成存档时的值。先抄下配置构造出的超参,加载完按组写回,
|
||||||
|
# 只留下动量缓冲。按组写回也保住了 BN 与 bias 那组 weight_decay=0 的结构。
|
||||||
|
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)
|
||||||
|
|
||||||
|
self.start_epoch = int(state.get("epoch", -1)) + 1
|
||||||
|
self.global_step = int(state.get("global_step", 0))
|
||||||
|
best = state.get("best_metric")
|
||||||
|
self.best_metric = float(best) if best is not None and math.isfinite(float(best)) else float("-inf")
|
||||||
|
|
||||||
|
self._restore_scheduler()
|
||||||
|
self._log_config_changes(state)
|
||||||
|
logger.info(
|
||||||
|
"已从 %s 恢复:起始 epoch=%d,best_%s=%.4f",
|
||||||
|
path,
|
||||||
|
self.start_epoch,
|
||||||
|
self.monitor,
|
||||||
|
self.best_metric,
|
||||||
|
)
|
||||||
|
return state
|
||||||
|
|
||||||
|
def _restore_scheduler(self) -> None:
|
||||||
|
"""按**当前配置**重建学习率调度,再快进到已训到的 epoch。
|
||||||
|
|
||||||
|
不恢复检查点里的调度器状态:它带着当时的 T_max 与 base_lrs,一旦 yaml 里改了
|
||||||
|
总轮数或学习率就会被静默覆盖。cosine / step / multistep 都是 epoch 的确定性函数,
|
||||||
|
快进与恢复状态等价,所以统一走重建这一条路。
|
||||||
|
"""
|
||||||
|
if self.scheduler is None:
|
||||||
|
return
|
||||||
|
# cosine 的链式实现基于当前 lr 递推,必须先复位回 initial_lr,
|
||||||
|
# 否则从检查点带来的 lr 出发会算出一条错的曲线。
|
||||||
|
for group in self.optimizer.param_groups:
|
||||||
|
group["lr"] = group.get("initial_lr", group["lr"])
|
||||||
|
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:
|
||||||
|
"""检查点里的配置与当前配置不一致时说清楚用的是哪一份。"""
|
||||||
|
archived = state.get("train_cfg") or {}
|
||||||
|
changes = []
|
||||||
|
if archived.get("epochs") not in (None, self.epochs):
|
||||||
|
changes.append(f"总轮数 {archived['epochs']} -> {self.epochs}")
|
||||||
|
for key, value in (archived.get("optimizer") or {}).items():
|
||||||
|
current = dict(self.cfg.get("optimizer", {})).get(key)
|
||||||
|
if current is not None and current != value:
|
||||||
|
changes.append(f"optimizer.{key} {value} -> {current}")
|
||||||
|
archived_scheduler = (archived.get("scheduler") or {}).get("name")
|
||||||
|
current_scheduler = (dict(self.cfg.get("scheduler") or {})).get("name")
|
||||||
|
if archived_scheduler and archived_scheduler != current_scheduler:
|
||||||
|
changes.append(f"scheduler {archived_scheduler} -> {current_scheduler}")
|
||||||
|
|
||||||
|
if changes:
|
||||||
|
logger.warning("配置与检查点不一致,按**当前配置**执行:%s", ";".join(changes))
|
||||||
|
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],
|
||||||
|
self.epochs,
|
||||||
|
)
|
||||||
|
|
||||||
def fit(self) -> float:
|
def fit(self) -> float:
|
||||||
logger.info("开始训练:共 %d 个 epoch,输出目录 %s", self.epochs, self.work_dir)
|
logger.info("开始训练:共 %d 个 epoch,输出目录 %s", self.epochs, self.work_dir)
|
||||||
for epoch in range(self.start_epoch, self.epochs):
|
for epoch in range(self.start_epoch, self.epochs):
|
||||||
@@ -182,6 +264,9 @@ class Trainer:
|
|||||||
epoch=epoch,
|
epoch=epoch,
|
||||||
best_metric=self.best_metric,
|
best_metric=self.best_metric,
|
||||||
config=self.full_config,
|
config=self.full_config,
|
||||||
|
scheduler=self.scheduler,
|
||||||
|
global_step=self.global_step,
|
||||||
|
train_cfg=dict(self.cfg),
|
||||||
)
|
)
|
||||||
score = metrics.get(self.monitor)
|
score = metrics.get(self.monitor)
|
||||||
if score is not None and score > self.best_metric:
|
if score is not None and score > self.best_metric:
|
||||||
@@ -193,6 +278,9 @@ class Trainer:
|
|||||||
epoch=epoch,
|
epoch=epoch,
|
||||||
best_metric=self.best_metric,
|
best_metric=self.best_metric,
|
||||||
config=self.full_config,
|
config=self.full_config,
|
||||||
|
scheduler=self.scheduler,
|
||||||
|
global_step=self.global_step,
|
||||||
|
train_cfg=dict(self.cfg),
|
||||||
)
|
)
|
||||||
logger.info("epoch %d 刷新最佳 %s=%.4f,已保存 best.pth", epoch, self.monitor, score)
|
logger.info("epoch %d 刷新最佳 %s=%.4f,已保存 best.pth", epoch, self.monitor, score)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pytest.importorskip("yaml")
|
|||||||
pytest.importorskip("PIL")
|
pytest.importorskip("PIL")
|
||||||
|
|
||||||
import numpy as np # noqa: E402
|
import numpy as np # noqa: E402
|
||||||
|
import torch # noqa: E402
|
||||||
import yaml # noqa: E402
|
import yaml # noqa: E402
|
||||||
from PIL import Image # noqa: E402
|
from PIL import Image # noqa: E402
|
||||||
|
|
||||||
@@ -150,3 +151,67 @@ def test_device_flag_overrides_config(project, monkeypatch, tmp_path):
|
|||||||
|
|
||||||
def config_path_of(project):
|
def config_path_of(project):
|
||||||
return project[0]
|
return project[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_takes_hyperparams_from_config(project, monkeypatch, tmp_path):
|
||||||
|
"""检测训练续训:lr / weight_decay / 总轮数以 yaml 为准,检查点只提供进度。
|
||||||
|
|
||||||
|
回归测试:load_checkpoint 直接 optimizer.load_state_dict,把超参连同动量一起还原,
|
||||||
|
改了配置再续训会被静默覆盖;而且此前根本没保存调度器状态,学习率调度会从头开始。
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
|
||||||
|
import yaml as yaml_mod
|
||||||
|
|
||||||
|
config_path, root = project
|
||||||
|
cfg = yaml_mod.safe_load(config_path.read_text(encoding="utf-8"))
|
||||||
|
cfg["train"].update({"epochs": 4, "optimizer": {"name": "adamw", "lr": 1e-3, "weight_decay": 1e-4},
|
||||||
|
"scheduler": {"name": "cosine"}})
|
||||||
|
config_path.write_text(yaml_mod.safe_dump(cfg, allow_unicode=True), encoding="utf-8")
|
||||||
|
|
||||||
|
work_dir = tmp_path / "runs"
|
||||||
|
assert run(train_mod, ["train.py", "--config", str(config_path), "--work-dir", str(work_dir)],
|
||||||
|
monkeypatch) == 0
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
real_fit = train_mod.Trainer.fit
|
||||||
|
|
||||||
|
def probe(self):
|
||||||
|
captured["groups"] = [dict(g) for g in self.optimizer.param_groups]
|
||||||
|
captured["scheduler"] = self.scheduler
|
||||||
|
captured["start_epoch"] = self.start_epoch
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
monkeypatch.setattr(train_mod.Trainer, "fit", probe)
|
||||||
|
assert run(
|
||||||
|
train_mod,
|
||||||
|
["train.py", "--config", str(config_path), "--work-dir", str(work_dir),
|
||||||
|
"--resume", str(work_dir / "last.pth"), "--set",
|
||||||
|
"train.epochs=10", "train.optimizer.lr=5e-4", "train.optimizer.weight_decay=0.05"],
|
||||||
|
monkeypatch,
|
||||||
|
) == 0
|
||||||
|
monkeypatch.setattr(train_mod.Trainer, "fit", real_fit)
|
||||||
|
|
||||||
|
groups = captured["groups"]
|
||||||
|
assert groups[0]["initial_lr"] == pytest.approx(5e-4) # 不是检查点里的 1e-3
|
||||||
|
assert groups[0]["weight_decay"] == pytest.approx(0.05) # 不是检查点里的 1e-4
|
||||||
|
assert groups[1]["weight_decay"] == 0.0 # BN/bias 分组结构没被冲掉
|
||||||
|
|
||||||
|
scheduler = captured["scheduler"]
|
||||||
|
assert scheduler.T_max == 10
|
||||||
|
assert scheduler.last_epoch == captured["start_epoch"] == 4 # 调度进度已快进,不是从 0 开始
|
||||||
|
assert groups[0]["lr"] == pytest.approx(5e-4 * (1 + math.cos(math.pi * 4 / 10)) / 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkpoint_carries_scheduler_and_progress(project, monkeypatch, tmp_path):
|
||||||
|
"""检查点要带上调度器状态与 global_step,否则续训无从恢复调度进度。"""
|
||||||
|
config_path, _ = project
|
||||||
|
work_dir = tmp_path / "runs"
|
||||||
|
assert run(train_mod, ["train.py", "--config", str(config_path), "--work-dir", str(work_dir)],
|
||||||
|
monkeypatch) == 0
|
||||||
|
|
||||||
|
state = torch.load(work_dir / "last.pth", map_location="cpu", weights_only=False)
|
||||||
|
expected = {"model", "optimizer", "scheduler", "epoch", "global_step", "best_metric", "train_cfg"}
|
||||||
|
assert expected <= set(state)
|
||||||
|
assert state["global_step"] > 0
|
||||||
|
assert state["train_cfg"]["optimizer"]["lr"] is not None
|
||||||
|
|||||||
@@ -87,12 +87,7 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if args.resume:
|
if args.resume:
|
||||||
from engine import load_checkpoint
|
trainer.resume(args.resume)
|
||||||
|
|
||||||
state = load_checkpoint(args.resume, model, trainer.optimizer, map_location=device)
|
|
||||||
trainer.start_epoch = int(state.get("epoch", 0)) + 1
|
|
||||||
trainer.best_metric = float(state.get("best_metric") or float("-inf"))
|
|
||||||
logger.info("已从 %s 恢复,起始 epoch=%d", args.resume, trainer.start_epoch)
|
|
||||||
|
|
||||||
cfg.dump(work_dir / "config.yaml")
|
cfg.dump(work_dir / "config.yaml")
|
||||||
best = trainer.fit()
|
best = trainer.fit()
|
||||||
|
|||||||
Reference in New Issue
Block a user