本节摘要:训练不是点火就走,监理环节决定微调的最终成色。本节讲三件监理工具:曲线判读(训练与验证曲线的四种病态形态)、验证集的正确用法(分层切分、绝不参与训练决策之外的任何计算)、早停与检查点(耐心参数怎么设、存什么、何时恢复)。末尾给出一张曲线形态诊断卡,第 6 章的事故排查将直接复用它。
微调过程中每一轮记录训练损失、训练精度、验证损失、验证精度四条曲线。健康形态是:训练损失单调下降、验证损失同步下降并在某轮触底、验证精度在验证损失最低点附近达峰。病态有四种典型形态:
import numpy as np # 四种形态的合成曲线与自动判读 rng = np.random.default_rng(5) epochs = np.arange(1, 21) def noise(scale=0.01): return rng.normal(0, scale, 20) healthy = {"train": 0.5*np.exp(-0.2*epochs)+0.1+noise(), "val": 0.55*np.exp(-0.2*epochs)+0.15+noise()} overfit = {"train": 0.5*np.exp(-0.3*epochs)+0.08+noise(), "val": np.maximum(0.3, 0.6*np.exp(-0.25*epochs))+0.02*epochs+noise(0.02)} plateau = {"train": 0.6 - 0.001*epochs + noise(), "val": 0.62 - 0.0008*epochs + noise()} for name, d in [("健康", healthy), ("过拟合", overfit), ("平台", plateau)]: gap = d["train"][-1] - d["val"][-1] val_best = d["val"].min() print(f"{name}: 末期训练-验证差 {gap:+.3f}, 验证最优 {val_best:.3f}") # 典型输出: # 健康: 末期训练-验证差 -0.049, 验证最优 0.161 # 过拟合: 末期训练-验证差 -0.522, 验证最优 0.305 <- 验证回升即过拟合信号 # 平台: 末期训练-验证差 -0.018, 验证最优 0.604 <- 双双高位即欠拟合信号
监理的合法性取决于验证集的质量。工程纪律四条:
import numpy as np def stratified_split(labels, val_ratio=0.2, seed=7): """按类别分层切分,保证验证集类别比例与整体一致""" rng = np.random.default_rng(seed) train_idx, val_idx = [], [] for cls in np.unique(labels): idx = np.where(labels == cls)[0] rng.shuffle(idx) n_val = max(1, int(len(idx) * val_ratio)) val_idx.extend(idx[:n_val]); train_idx.extend(idx[n_val:]) return np.array(sorted(train_idx)), np.array(sorted(val_idx)) labels = np.repeat([0, 1, 2, 3], [50, 30, 15, 5]) # 不均衡的四类 tr, va = stratified_split(labels) print(f"训练 {len(tr)} 条 / 验证 {len(va)} 条") # 输出: 训练 81 条 / 验证 19 条 for cls in range(4): print(f"类别 {cls}: 验证占比 {np.mean(labels[va]==cls):.2%} / 整体占比 {np.mean(labels==cls):.2%}") # 输出: # 类别 0: 验证占比 50.00% / 整体占比 50.00% # 类别 1: 验证占比 30.00% / 整体占比 30.00% # 类别 2: 验证占比 10.53% / 整体占比 15.00% # 类别 3: 验证占比 5.26% / 整体占比 5.00% # 分层切分下稀有类在验证集里也保住了代表权
早停的逻辑:验证指标连续若干轮(耐心值)不创新优就停止训练,并恢复到历史最优的那一步。配套的检查点策略决定"恢复"是否可行。
import copy class EarlyStopper: """监理的核心器件:耐心计数 + 最优检查点保管""" def __init__(self, patience=3, min_delta=0.0): self.patience = patience # 容忍几轮不提升 self.min_delta = min_delta # 多少算“提升” self.best = -float("inf") self.wait = 0 self.best_state = None def step(self, metric, model) -> bool: """每轮验证后调用;返回 True 表示应停止训练""" if metric > self.best + self.min_delta: self.best, self.wait = metric, 0 self.best_state = copy.deepcopy(model.state_dict()) # 保管最优快照 return False self.wait += 1 return self.wait >= self.patience import torch.nn as nn model = nn.Linear(10, 2) stopper = EarlyStopper(patience=3) for epoch, acc in enumerate([0.70, 0.78, 0.75, 0.74, 0.76, 0.73]): if stopper.step(acc, model): print(f"epoch {epoch}: {stopper.wait} 轮无提升,早停触发,最优 {stopper.best:.2f}") break # 输出: epoch 5: 3 轮无提升,早停触发,最优 0.78 # model.load_state_dict(stopper.best_state) 即可回到最优状态
耐心值的经验:微调轮数本来就少,耐心设二到四;耐心过大会把后面的过拟合轮次也跑完,白白浪费时间与最优点附近的机会。检查点除了模型权重,还应记录轮次、优化器状态(恢复训练用)、验证指标与超参配置——第 6 章排查事故时要靠这些复盘。
⚠️ 常见坑:用验证损失最低点还是验证精度最高点做早停标准,前后不一致。损失对置信度敏感、精度对决策边界敏感,两者最优轮次常常错开一至两轮——开工前定死用哪个,全程不换。
💡 关键直觉:监理的本质是"用不会参与训练的数据,给训练过程提供一个没有利益冲突的旁观者视角"。任何让旁观者沾上训练的事(泄漏、反复试探)都会让监理失明。
| 曲线形态 | 判定 | 第一处方 | 深入章节 |
|---|---|---|---|
| 训练降、验证先降后升 | 过拟合 | 早停回最优、加正则 | 本节与 4.5 |
| 双曲线高位平台 | 欠拟合 | 提学习率、解冻更深 | 4.3 与 4.4 |
| 双曲线震荡 | 步长过大或批过小 | 降学习率、裁剪、加批次 | 4.4 与 4.5 |
| 训练完美、验证全程差 | 数据问题 | 查泄漏与分布对齐 | 6.3 |