衔言渡意第二轮训练(15.5M)的末期,val loss 从 2.461 一路降到 2.422,模型还在学。但同一段时间里 BLEU 的表现是这样的:

19.95 → 17.91 → 18.96 → 16.56 → 18.14 → 18.30 → 18.51

上蹿下跳,epoch 间跳两三分是常态。当时我用 BLEU 做早停判据,best 定格在 19.95,之后 patience 耗尽,训练被杀。

模型还在变好。是判断标准在撒谎。

这件事之后我把早停判据从 BLEU 换成了 val loss,但换完之后发现,val loss 也有它撒谎的方式——不是像 BLEU 那样剧烈跳动,而是在收敛末期用微小的波动骗过固定阈值,把真实的改善吞掉。

这篇文章记录的是我把训练控制里的判断——什么时候调 dropout、改善多少算改善、什么时候该停——从经验值和肉眼判断,逐步换成统计检验的过程。

用错了尺

BLEU 作为评估指标没有问题,作为早停判据有问题。

翻译质量的评估需要 BLEU——它衡量的是生成文本和参考译文之间的 n-gram 重合度,比 loss 更接近“翻译得好不好”这个问题。但早停需要的不是“好不好”,而是“还在不在变好”。这两个问题对指标的要求不一样。

“还在不在变好”需要信号稳定。val loss 是连续值,每个 epoch 在整个验证集上平均,波动小。BLEU 是离散的采样评估——从验证集里抽一批句子,生成翻译,和参考对比。生成过程本身有随机性(beam search 的候选排序、长度惩罚的边界效应),采样集的组成也影响结果。同一个模型、同一个 epoch,换一批句子 BLEU 可能差两分。

前十个 epoch 的数据能看到这个问题的早期形态:

bleu: [3.92, 7.54, 8.47, 12.28, 12.31, 11.09, 14.47, 16.29, 16.55, 16.57]
val:  [4.73, 3.97, 3.51, 3.21, 3.01, 2.89, 2.78, 2.71, 2.66, 2.61]

val loss 单调下降,BLEU 在 epoch 5 回撤到 11.09(前一个 epoch 是 12.31),然后又跳回 14.47。早期 BLEU 基数小,绝对波动看着不严重。但到了收敛末期,改善幅度变小,波动幅度不变,信噪比就崩了。

换 val loss 做早停判据之后,训练稳定了——不再出现“模型在学但被杀”的情况。BLEU 仍然每个 epoch 算,但只用来看翻译质量的大趋势,不参与任何自动化决策。

从单调到趋势

早停解决了,dropout 的问题接着暴露出来。

严格单调的脆弱

我的 dropout 自适应逻辑原本是这样判断过拟合的:取最近 N 个 epoch 的 val loss,如果严格单调递增——all(v[i] < v[i+1] for i in range(len(v)-1))——就认为在过拟合,上调 dropout。

训练前期这个判断没问题。val loss 要么明确在降,要么明确在升,信号清晰。但到了收敛末期,val loss 在 2.39x 附近震荡:

2.395, 2.401, 2.393, 2.400, 2.400, 2.395, 2.402

这种级别的波动不是过拟合,是噪声。但只要窗口内恰好出现一段单调上升的片段——比如 2.393 → 2.400 → 2.400 → 2.402——严格单调判断就触发了。

dropout 上调之后,模型的表达能力被压缩,loss 可能会进一步上升,又触发下一次上调。这个过程是不可逆的——没有对应的“过拟合消退则 dropout 下调”机制,因为判断标准本身就是错的,它会把 dropout 上调后的正常表现继续判定为过拟合。

问题不在 dropout 该不该自适应,在于判断过拟合的方式——把噪声当趋势了。

线性回归 + t 检验

需要的是区分“随机波动”和“系统性上升”。这正好是统计学里假设检验干的事。

做法:取窗口内 5 个 epoch 的 val loss,做线性回归拟合一条直线,得到斜率 ​k 和残差。斜率除以标准误差(标准误差 = 残差标准差 / ​\sqrt{\sum(x_i - \bar{x})^2})得到 t 统计量。​|t| > 2 才判定趋势显著。

触发条件变成:train loss 斜率显著为负(在学) val loss 斜率显著为正(在过拟合),两个条件同时满足才上调 dropout。

收敛末期的效果:val loss 在 2.39x 附近的波动,斜率接近零,残差和斜率量级差不多,t 统计量远小于 2,不触发。真正过拟合时 val loss 持续稳定上升,残差小、斜率大,t 轻松过线。

从最终训练数据可以验证——dropout 全程保持 0.2,没有被误触发过一次。

参数解耦

原来的单调判断只有一个参数:窗口大小 N。但 N 实际上承担了两个角色——它既决定“看多远的历史”,又通过窗口长度隐式地影响灵敏度(窗口越长,严格单调越难满足,越不敏感)。一个参数管两件事,调哪个都会影响另一个。

换成线性回归后,两个参数各管各的:

  • window = 5:决定看多近的历史。5 是 t 检验的实用下界——样本量再小,自由度不够,t 分布太宽,什么都不显著。这个值由统计方法本身决定,不是经验值。
  • significance = 2:趋势多显著才算真趋势。2 对应大约 95% 的置信度(双侧检验),来自统计学的标准阈值。

两个参数解耦了。window 管“看多近”,significance 管“多确定才行动”,互不影响。

阈值吞掉了改善

dropout 的判断统计化了,但早停的判断还在用老办法。

固定比例的盲区

衔言渡意的早停逻辑是这样的:

improvement = self.best_loss - val_loss
if improvement / self.best_loss > self.config.improvement_threshold:  # 5e-3
    # 算改善,更新 best_loss,重置 patience

improvement_threshold = 5e-3,意思是 val loss 相对 best 改善超过 0.5% 才算有进步。

训练前期,loss 从 4.9 降到 3.0,每个 epoch 改善幅度在 0.1-0.3,0.5% 的门槛(约 0.015-0.025)轻松跨过,没有问题。

但到了收敛末期,best loss 在 2.395 附近,0.5% 对应 0.012。而 epoch 间的真实改善可能只有 0.001-0.002。模型确实在变好——val loss 从 2.395 降到了 2.393——但改善幅度过不了 0.012 的门槛,被判定为“没有改善”,patience 开始倒计时。

更有意思的是,同一个训练循环里还有另一套阈值在运行。ReduceLROnPlateauthreshold 我没有改过,PyTorch 默认是 1e-4threshold_mode='rel')。也就是说,lr 调度器认为改善超过 0.01% 就算有进步,而早停认为要超过 0.5% 才算。

两套标准差了 50 倍。

实际效果:lr 调度器几乎总是认为“还在改善”(val loss 微降 0.001 就够了),所以 lr 衰减来得很晚——前 39 个 epoch lr 一直是 3e-4,epoch 40 才第一次降到 2.7e-4。而 early stopping 在收敛末期频繁触发 patience 计数。

这不一定是 bug——lr 调度器和早停确实可以有不同的灵敏度。但如果没有意识到这个差异的存在,就容易误读训练状态。

对量级不敏感的判定

固定比例阈值的根本问题是:它假设“0.5% 的改善”在 loss 的任何量级上都意味着同样的事情。但 loss 从 4.9 降到 4.87(0.5%)和从 2.395 降到 2.393(0.08%)对模型来说可能是同等重要的改善——前者是训练早期的一个普通 epoch,后者是收敛末期挤出来的最后一点进步。

一种修补办法是让阈值随 loss 量级缩小——比如用 ​\log 空间的改善,或者让比例本身衰减。但这只是把一个经验参数换成了另一个经验参数。

更干净的做法是:不用阈值了。

best_loss 的更新改成纯最小值追踪——val_loss < best_loss 就更新。不再问“改善够不够大”,因为“够不够大”这个判断已经交给了下一节的趋势检测。

def _is_best(self, val_loss: float, epoch: int) -> bool:
    if val_loss < self.best_loss:
        self.best_loss = val_loss
        self.best_epoch = epoch
        return True
    return False

improvement_threshold 这个参数直接删掉。它原本承担了两个职责——更新 best 记录和判断是否该停——现在拆开了:best 记录只追踪最小值,停止条件交给统计检验。

什么时候该停

patience 的局限

删掉阈值之后,早停不能再靠 patience 计数器了。原来的逻辑是“连续 N 个 epoch 没有超过阈值的改善就停”,阈值没了,patience 也就失去了意义。

且,patience 本身也有问题:它只数“连续没改善的次数”,不看趋势。如果 val loss 的序列是 2.400, 2.398, 2.401, 2.399, 2.400——整体平了,但每隔一个 epoch 就比 best 低一点点,patience 会反复重置,永远不触发早停。模型其实已经不再学了,但计数器被微小的波动骗过了。

这又是一种撒谎:patience 说“还有改善”,趋势说“已经平了”。

趋势平坦的检测

已经有现成的工具了——dropout 触发用的线性回归 + t 检验。

dropout 触发的判定条件是:train loss 显著下降 val loss 显著上升。

早停的判定条件更简单:val loss 不再显著下降

具体做法:对最近 N 个 epoch 的 val loss 做线性回归,计算斜率的 t 统计量。如果 ​|t| < 2(斜率不显著),说明 val loss 既没有在降也没有在升——趋势平了,模型不再学了,可以停。

def _should_stop(self, val_losses: list[float]) -> bool:
    """val loss 是否不再有显著下降趋势"""
    if len(val_losses) < self.config.dropout_window:
        return False

    window = val_losses[-self.config.dropout_window:]
    slope, t_stat = self._linear_regression_t(window)

    # 斜率不显著(|t| < significance)→ 没有趋势 → 该停了
    # 或者斜率显著为正 → val loss 在升 → 更该停了
    return abs(t_stat) < self.config.dropout_significance or slope > 0

和 patience 的区别:patience 问“最近有没有刷新纪录”,趋势检测问“最近这段时间的方向是什么”。前者容易被单个好 epoch 重置,后者看的是整体走势。

和 dropout 触发用的是同一个 linear_regression_t 函数、同一个 window 参数、同一个 significance 参数。三个判断——dropout 触发、best 更新、训练停止——共享一套统计框架,但各自的判定条件不同:

判断 方法 条件
dropout 触发 线性回归 + t 检验 train 显著下降val 显著上升
best 更新 最小值追踪 val_loss < best_loss
训练停止 线性回归 + t 检验 val 斜率不显著显著上升

超参数的统计根基

前面四节在做的事情是把训练过程中的判断统计化。但训练开始之前还有一组决策:初始学习率、优化器参数、正则化强度。这些值不像 dropout 触发那样可以在训练中动态判断——它们是训练的起点,必须在开始前选定。

这些数字有没有依据?有些有,有些没有。

学习率与调度

衔言渡意用 lr = 3e-4。这个值在小模型领域几乎是默认起点——Karpathy 的 nanoGPT 用 6e-4,BERT 微调推荐 2e-55e-5,从头训练的小 Transformer 通常在 1e-41e-3 之间。

背后的逻辑是:学习率的量级和模型规模、batch size 有关。模型越大,loss landscape 越复杂,需要越小的步长避免震荡;batch size 越大,梯度估计越稳定,可以承受越大的步长。3e-4 对于 50M 级别、batch size 40 的模型是一个安全的起点——不会太大导致发散,不会太小导致训练太慢。

但这仍然是经验共识,不是推导出来的。“大约 3e-4”比“必须 3e-4”更准确。如果换了一个显著不同的架构或数据分布,这个值可能需要调。判断信号:train loss 在前几个 epoch 不降或震荡 → lr 可能太大;train loss 下降但非常慢且 val loss 始终紧跟 → lr 可能太小。

调度器方面,衔言渡意用的是 ReduceLROnPlateau(factor=0.9, patience=3)。从训练曲线可以看到它的行为:前 39 个 epoch lr 保持 3e-4,epoch 40 降到 2.7e-4,epoch 46 再降到 2.43e-4。每次 lr 下降后,val loss 有一段台阶式的下降——从 2.45x 降到 2.43x,再从 2.43x 降到 2.39x——然后又平了。

这说明收敛末期 lr 确实偏大了,降 lr 能让模型继续学一点。但也说明 factor=0.9 很保守——每次只降 10%,需要等 patience 触发才降。更激进的做法是用 cosine schedule 或 warmup + linear decay,让 lr 在训练后期平滑地降下来,而不是靠 plateau 检测被动触发。这是下一个项目可以尝试的。

AdamW 的 betas 与 weight_decay

betas = (0.9, 0.98)weight_decay = 0.01——这三个值每个都有来源。

beta1 = 0.9:一阶矩的指数移动平均系数,控制梯度方向的动量。0.9 意味着当前梯度占 10%,历史占 90%。这个值从 SGD with momentum 时代就是标准选择,Adam 继承了它,几乎没有人改。偶尔有工作用 0.95(更重的动量),但 0.9 在绝大多数场景下不需要调。

beta2 = 0.98:二阶矩的指数移动平均系数,控制对梯度方差的估计。PyTorch 默认是 0.999,但 Transformer 原论文《Attention Is All You Need》用了 0.98。区别在于“记忆窗口”——0.999 的有效窗口约 1000 步,0.98 约 50 步。更短的窗口意味着对近期梯度方差变化更敏感。

Transformer 选 0.98 有道理:多头注意力的梯度分布在训练过程中变化比较大(不同的头在不同阶段学到不同的模式),需要优化器更快地适应。翻译任务里不同语言方向的梯度特征也不同,三语六向混合训练让分布变化更频繁。0.98 比 0.999 更适合这种场景。

什么时候考虑调 beta2?如果训练出现 loss spike(突然飙升再回落),可能是 beta2 太大,优化器对梯度方差的估计过于依赖历史,对突变反应迟钝。降低 beta2 可以缓解。反过来,如果 loss 曲线非常嘈杂、震荡严重,可能是 beta2 太小,对噪声过度敏感,可以尝试增大。

weight_decay = 0.01:这个值来自 Loshchilov & Hutter 在 2019 年提出 AdamW 时使用的设置。AdamW 存在的原因是:在 Adam 优化器中,传统的 L2 正则化(在 loss 中加 ​\lambda |w|^2)会被自适应学习率缩放。具体来说,Adam 给每个参数一个独立的学习率(基于该参数的梯度历史),L2 正则化产生的梯度也被这个自适应学习率缩放了——导致梯度大的参数反而被正则化得更轻(因为 Adam 给它分配了更小的有效学习率)。这和 L2 的初衷相反。

AdamW 的做法是把权重衰减从梯度更新中分离出来,直接在参数更新后乘以 ​(1 - \text{lr} \times \text{weight\_decay})。这样权重衰减的强度对所有参数一视同仁,不受自适应学习率的影响。

0.01 是 AdamW 论文的默认值。在实践中,0.01 到 0.1 都常见,取决于模型大小和数据量。模型越大、数据越少,越需要更强的正则化。衔言渡意 51.7M 参数、180 万样本,0.01 属于偏轻的正则化,搭配 dropout 0.2 使用。从训练结果看,train-val gap 稳定在 0.22,没有过拟合,说明整体正则化强度是合适的。