AI 工程基础体系 · 第 61/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。

深度学习损失与正则化:目标设计、Dropout、权重衰减和早停

1. 先明确:模型究竟在优化什么

训练一个模型,通常不是直接“让预测正确”,而是在最小化一个由数据损失和正则项组成的目标:

J(θ)=1Ni=1N(fθ(xi),yi)+λΩ(θ)J(\theta) = \frac{1}{N}\sum_{i=1}^{N} \ell(f_\theta(x_i), y_i) + \lambda \Omega(\theta)

其中:

  • xix_i 是输入;
  • yiy_i 是目标;
  • fθf_\theta 是参数为 θ\theta 的模型;
  • \ell 是单样本损失;
  • NN 是训练样本数量;
  • Ω(θ)\Omega(\theta) 是正则化项;
  • λ\lambda 控制正则化强度。

第一项回答“模型在训练数据上错了多少”,第二项回答“在预测能力相近时,更偏好哪一类参数或函数”。

这两个部分解决的是不同问题:

  • 损失函数定义任务目标,例如分类错误、回归误差、下一个 token 的负对数似然;
  • 正则化限制模型利用数据的方式,降低对训练样本偶然模式的依赖;
  • 优化器决定如何寻找较小的目标值;
  • 评测指标决定如何报告结果,不一定等于训练损失。

因此,准确率高并不意味着损失设计正确,训练损失低也不意味着泛化能力好。一个模型可能优化了错误的目标,也可能在正确目标上过拟合。


2. 损失函数:把任务目标写成可优化的数学量

2.1 经验风险与总体风险

机器学习真正关心的是未知数据分布上的期望风险:

R(θ)=E(x,y)D[(fθ(x),y)]R(\theta) = \mathbb{E}_{(x,y)\sim \mathcal{D}} [ \ell(f_\theta(x), y) ]

但训练时只能看到有限样本,因此使用经验风险:

R^(θ)=1Ni=1N(fθ(xi),yi)\hat R(\theta) = \frac{1}{N} \sum_{i=1}^{N} \ell(f_\theta(x_i), y_i)

经验风险最小不保证总体风险最小。训练集越小、模型越大、数据噪声越高,这种差异越明显。正则化、数据增强、Dropout 和早停,都是在有限样本条件下限制这种差异的手段,但它们改变风险的方式不同。


2.2 分类中的交叉熵:从最大似然推导

假设有 KK 个类别,模型输出 logits:

z=fθ(x)RKz=f_\theta(x)\in\mathbb{R}^K

通过 softmax 得到类别概率:

pθ(y=kx)=exp(zk)j=1Kexp(zj)p_\theta(y=k\mid x) = \frac{\exp(z_k)} {\sum_{j=1}^{K}\exp(z_j)}

对于真实类别 yy,负对数似然为:

CE=logpθ(yx)\ell_{\text{CE}} = -\log p_\theta(y\mid x)

如果真实标签写成 one-hot 向量 qq,则交叉熵是:

(q,p)=k=1Kqklogpk\ell(q,p) = -\sum_{k=1}^{K}q_k\log p_k

因为 one-hot 标签只有真实类别位置为 1,所以两种写法相同。

交叉熵并不是任意选择。它来自最大似然估计:假设每个样本的标签服从模型给出的 categorical 分布,那么最大化所有真实标签的联合概率,等价于最小化负对数似然。

对于单个样本,logit 的梯度具有简单形式:

zk=pk1[k=y]\frac{\partial \ell}{\partial z_k} = p_k-\mathbf{1}[k=y]

这解释了交叉熵的一个重要性质:模型越自信地预测错误类别,梯度越大;模型即使预测正确,只要概率还不够接近 1,仍然会继续获得梯度。

PyTorch 形式

import torch
import torch.nn as nn

logits = torch.tensor([
    [2.0, 0.5, -1.0],
    [0.2, 1.1,  0.3],
])
target = torch.tensor([0, 2])

loss_fn = nn.CrossEntropyLoss()
loss = loss_fn(logits, target)

print(loss.item())

这里:

  • logits 的形状是 [batch_size, num_classes]
  • target 的形状是 [batch_size]
  • target 中保存类别索引,而不是已经 softmax 后的概率;
  • CrossEntropyLoss 内部等价于 log_softmax 加负对数似然。

常见错误是先手动调用 softmax,再把结果传给 CrossEntropyLoss。这会破坏数值稳定性,并且通常是不必要的。训练时应直接传 logits。

如果使用:

loss_fn = nn.CrossEntropyLoss(ignore_index=-100)

则目标值为 -100 的位置不参与损失。语言模型中通常用它忽略 padding 或不需要预测的 token,但必须确认这些位置确实不应影响训练,否则模型可能学不到本应学习的内容。


2.3 回归中的 MSE、MAE 与 Huber 损失

对于连续目标,均方误差为:

MSE=1dy^y22\ell_{\text{MSE}} = \frac{1}{d} \|\hat y-y\|_2^2

它对应一个常见统计假设:目标噪声近似服从均值为 0、方差固定的高斯分布。最小化 MSE 等价于最大化该高斯似然。

MSE 对大误差平方放大,因此对离群点敏感。例如误差分别为 111010 时,平方误差分别为 11100100

平均绝对误差为:

MAE=1dy^y1\ell_{\text{MAE}} = \frac{1}{d} \|\hat y-y\|_1

它对应拉普拉斯噪声假设,对离群点更稳健,但在误差为零处不可导。实际框架会使用次梯度或等价处理。

Huber 损失在小误差处像 MSE,在大误差处像 MAE:

Lδ(e)={12e2,eδδ(e12δ),e>δL_\delta(e)= \begin{cases} \frac{1}{2}e^2, & |e|\le \delta\\ \delta(|e|-\frac{1}{2}\delta), & |e|>\delta \end{cases}

它提供了平滑性和抗离群点能力之间的折中。选择哪种损失,应该由错误代价和噪声分布决定,而不是只看训练曲线是否下降。


2.4 类别不平衡:加权损失不是“免费修复”

二分类交叉熵可以写为:

=ylogp(1y)log(1p)\ell = -y\log p-(1-y)\log(1-p)

当正样本很少时,模型即使总是预测负类,也可能获得较高准确率。可以使用类别权重:

=w+ylogpw(1y)log(1p)\ell = -w_+y\log p -w_-(1-y)\log(1-p)

但加权改变了优化目标。它通常提高少数类的关注度,却可能使输出概率不再是原始数据分布下的校准概率。

因此需要区分:

  • 如果目标是提高召回率或优化业务代价,加权可能合理;
  • 如果目标是输出可解释概率,训练后可能需要重新校准;
  • 如果只是评测集类别比例失真,应该先检查采样和评测设计,而不是盲目加权。

还要注意 reduction="mean" 的含义。对于带权损失,框架常按有效权重归一化,而不是简单按样本数除法。若手工实现损失,必须明确分母,否则不同 batch 的损失尺度可能不一致。


2.5 生成式 AI 中的损失:token 级负对数似然

自回归语言模型将序列概率分解为:

pθ(x1:T)=t=1Tpθ(xtx<t)p_\theta(x_{1:T}) = \prod_{t=1}^{T} p_\theta(x_t\mid x_{<t})

负对数似然为:

=t=1Tlogpθ(xtx<t)\ell = -\sum_{t=1}^{T} \log p_\theta(x_t\mid x_{<t})

训练时通常使用 teacher forcing:在预测第 tt 个 token 时,把真实的历史 token x<tx_{<t} 作为输入,而不是模型上一步生成的 token。

实现上,输入和目标错开一位:

import torch
import torch.nn as nn

# 假设 logits.shape == [batch, sequence_length, vocab_size]
logits = torch.randn(2, 5, 1000)
input_ids = torch.randint(0, 1000, (2, 5))

shift_logits = logits[:, :-1, :].contiguous()
shift_labels = input_ids[:, 1:].contiguous()

loss_fn = nn.CrossEntropyLoss(ignore_index=-100)
loss = loss_fn(
    shift_logits.view(-1, shift_logits.size(-1)),
    shift_labels.view(-1),
)

print(loss.item())

每一步的含义是:

  1. 第 0 个位置的输出预测第 1 个 token;
  2. 最后一个位置没有下一个 token,因此被丢弃;
  3. [batch, time] 展平成样本维度;
  4. 每个有效 token 参与一次分类交叉熵;
  5. padding 位置可以设置为 -100,由 ignore_index 排除。

这里的平均损失通常是“有效 token 的平均负对数似然”,不是“每条序列先平均、再对序列平均”。两者在序列长度不同时结果不同:

1Bb1Tbtb,tbtb,tbTb\frac{1}{B}\sum_b \frac{1}{T_b}\sum_t \ell_{b,t} \neq \frac{\sum_b\sum_t \ell_{b,t}} {\sum_b T_b}

生产评测必须固定分母定义,否则不同长度分布的数据集之间无法直接比较。

困惑度(perplexity)通常定义为:

PPL=exp(平均 token 负对数似然)\operatorname{PPL}=\exp(\text{平均 token 负对数似然})

它只有在 tokenization、忽略规则和平均方式一致时才适合比较。不同 tokenizer 的 token 数不同,困惑度不能简单横向比较。


3. 目标设计中的几个关键边界

3.1 训练损失、业务指标与评测指标不是同一个对象

例如推荐系统可能使用二元交叉熵训练,却以 NDCG、点击率或收益评估;目标检测可能使用分类损失、边界框损失和 IoU 指标;对话模型可能使用 token 级负对数似然,但最终还要评估事实性、安全性和人工偏好。

指标不直接可微时,常见做法是:

  1. 选择一个与最终目标相关、可微且稳定的代理损失;
  2. 在独立验证集上监控最终业务指标;
  3. 检查代理损失下降是否真的带来业务指标改善;
  4. 发现不一致时,重新设计采样、权重或训练目标。

如果把验证集业务指标反复用于调参,验证集就逐渐成为训练过程的一部分,必须保留最终测试集或时间外测试集进行一次性确认。


3.2 多任务损失的权重会改变任务优先级

多任务目标常写成:

J=λ1L1+λ2L2++λmLmJ = \lambda_1 L_1+\lambda_2 L_2+\cdots+\lambda_m L_m

即使 L1L_1L2L_2 都是“平均损失”,它们的数值尺度和梯度尺度也可能完全不同。令 λ2\lambda_2 增大,不只是让任务二“更稳定”,而是在改变模型的资源分配。

例如共享编码器同时进行分类和回归:

J=Lclassification+0.1LregressionJ = L_{\text{classification}} + 0.1L_{\text{regression}}

如果回归损失的梯度本身比分类大 100 倍,那么 0.1 仍可能让回归主导训练。应监控各任务的损失、梯度范数和独立指标,而不是只看加权总和。


4. 正则化:限制模型的有效复杂度

正则化并不只等于“在损失后面加一项”。更准确地说,正则化是任何能够限制模型有效复杂度、缩小可选解空间或引入先验偏好的方法。

常见形式包括:

  • 参数正则化:L1、L2、权重衰减;
  • 随机正则化:Dropout、随机深度、数据增强;
  • 训练过程正则化:早停;
  • 数据和结构约束:共享参数、低秩、稀疏、冻结层;
  • 目标分布正则化:标签平滑、知识蒸馏、KL 惩罚。

“正则化能防止过拟合”只是结果层面的概括。具体机制必须说明它改变了什么:参数大小、函数噪声、训练时间、标签分布,还是可行解空间。


5. Dropout:随机删除激活,而不是删除数据

5.1 定义与训练时公式

设某层激活为 hh,Dropout 概率为 pp,保留概率为:

q=1pq=1-p

训练时采样伯努利掩码:

mjBernoulli(q)m_j\sim \operatorname{Bernoulli}(q)

使用 inverted dropout:

h~j=mjqhj\tilde h_j=\frac{m_j}{q}h_j

因为:

E[h~j]=E[mj]qhj=hj\mathbb{E}[\tilde h_j] = \frac{\mathbb{E}[m_j]}{q}h_j = h_j

所以训练时激活的期望与不使用 Dropout 时一致。推理时直接使用 hh,不再随机屏蔽,也不需要额外乘 qq

这正是 PyTorch nn.Dropout(p) 的常见行为:p 表示丢弃概率,训练模式随机屏蔽并缩放;评估模式返回原值。

import torch
import torch.nn as nn

dropout = nn.Dropout(p=0.5)
x = torch.ones(10000)

dropout.train()
train_mean = dropout(x).mean().item()

dropout.eval()
eval_mean = dropout(x).mean().item()

print(train_mean, eval_mean)

在足够大的样本上,两个均值都应接近 1。单次输出中,训练模式约有一半元素为 0,未被屏蔽的元素约为 2。


5.2 Dropout 为什么能正则化

对于线性层:

z=wh~=jwjmjqhjz=w^\top \tilde h = \sum_j w_j\frac{m_j}{q}h_j

每次更新看到的都是一个随机子网络。模型不能稳定依赖某一个特征或某一条激活路径,必须学习在多种子网络下都有效的表示。

对单个激活,其方差为:

Var(h~j)=pqhj2\operatorname{Var}(\tilde h_j) = \frac{p}{q}h_j^2

因此 Dropout 引入了与激活大小相关的乘性噪声。pp 越大,噪声越强;在数据少、模型大时可能有帮助,但在本来就不稳定或数据量充足的模型中可能造成欠拟合。

Dropout 也常被解释为近似训练大量共享参数的子网络并进行集成,但这是一种直觉解释,不应把它当作严格等价于显式训练并平均所有子网络。


5.3 Dropout 的正确生命周期

model.train()
for x, y in train_loader:
    optimizer.zero_grad(set_to_none=True)
    pred = model(x)
    loss = loss_fn(pred, y)
    loss.backward()
    optimizer.step()

model.eval()
with torch.no_grad():
    for x, y in valid_loader:
        pred = model(x)

model.train()model.eval() 会递归切换子模块的训练状态。Dropout 在两种状态下行为不同,BatchNorm 也不同。

torch.no_grad() 只关闭梯度记录,不会自动关闭 Dropout。下面的写法仍然可能得到随机输出:

model.train()
with torch.no_grad():
    output = model(x)

验证和推理必须同时使用 model.eval() 与适当的推理上下文。若忘记 eval(),验证损失会产生额外随机波动,早停判断也可能错误。


5.4 Dropout 的边界与反例

Dropout 并非越大越好:

  • 对最后的 logits 层直接使用较大 Dropout,可能破坏类别边界;
  • 对已有强正则化、强数据增强的大模型继续叠加高 Dropout,可能导致训练和验证都变差;
  • 在某些 Transformer 结构中,Dropout 的位置很重要,注意力权重、残差分支和前馈层的 Dropout 并不等价;
  • 生成时通常关闭 Dropout,否则同一个输入可能因内部随机掩码产生额外不确定性。

在 Transformer 预训练和大规模生成模型中,数据规模、优化器、学习率、权重衰减和架构本身可能已经提供大量正则化。不能仅因为小模型曾经受益,就默认增大 Dropout 一定改善泛化。


6. 权重衰减:参数更新规则与 L2 正则并不总是相同

6.1 SGD 中的等价关系

考虑目标:

J(θ)=L(θ)+λ2θ22J(\theta) = L(\theta)+\frac{\lambda}{2}\|\theta\|_2^2

其梯度为:

J(θ)=L(θ)+λθ\nabla J(\theta) = \nabla L(\theta)+\lambda\theta

使用学习率 η\eta 的 SGD:

θt+1=θtη(L(θt)+λθt)\theta_{t+1} = \theta_t-\eta \left( \nabla L(\theta_t)+\lambda\theta_t \right)

整理得:

θt+1=(1ηλ)θtηL(θt)\theta_{t+1} = (1-\eta\lambda)\theta_t -\eta\nabla L(\theta_t)

这说明在普通 SGD 中,L2 正则项表现为每一步都把参数向零收缩,因此常被称为 weight decay。

如果写成 λθ22\lambda\|\theta\|_2^2 而不是 λ2θ22\frac{\lambda}{2}\|\theta\|_2^2,梯度会多出一个 2。工程中比较超参数时必须确认定义,不能只比较名称。


6.2 Adam 中的区别

Adam 对梯度做逐参数的自适应缩放。若把 λθ\lambda\theta 加入梯度,Adam 的一阶、二阶矩估计也会受到影响:

gt=L(θt)+λθtg_t = \nabla L(\theta_t)+\lambda\theta_t

因此这不再等价于简单的、独立于梯度自适应项的参数收缩。

AdamW 使用 decoupled weight decay,将衰减直接放到参数更新中:

θt+1=θtηm^tv^t+ϵηλθt\theta_{t+1} = \theta_t - \eta\frac{\hat m_t}{\sqrt{\hat v_t}+\epsilon} - \eta\lambda\theta_t

这里权重衰减项不进入 Adam 的矩估计。通常所说的“Adam + weight decay”如果要表达这种解耦形式,应使用 AdamW,而不是把 L2 项手工加到 loss 中。

optimizer = torch.optim.AdamW(
    model.parameters(),
    lr=1e-3,
    weight_decay=1e-4,
)

这不是说 AdamW 在任何任务上都一定优于 Adam,而是两者的正则化机制不同,超参数不能直接照搬。


6.3 哪些参数通常不应衰减

权重衰减的对象不应机械地包括所有参数。常见分组是:

  • 对线性层、卷积层的权重衰减;
  • 对 bias 不衰减;
  • 对 LayerNorm、BatchNorm 的缩放和偏置不衰减。

示例:

import torch
import torch.nn as nn

model = nn.Sequential(
    nn.Linear(10, 32),
    nn.LayerNorm(32),
    nn.GELU(),
    nn.Linear(32, 2),
)

decay, no_decay = [], []

for name, param in model.named_parameters():
    if not param.requires_grad:
        continue

    # 这是按名称和参数维度进行的常见分组策略,
    # 实际项目应结合模块类型审查。
    if name.endswith("bias") or param.ndim == 1:
        no_decay.append(param)
    else:
        decay.append(param)

optimizer = torch.optim.AdamW(
    [
        {"params": decay, "weight_decay": 1e-4},
        {"params": no_decay, "weight_decay": 0.0},
    ],
    lr=1e-3,
)

param.ndim == 1 是常见启发式,因为许多归一化参数是一维向量,但它不是普适规范。自定义模块可能有一维权重却需要衰减,也可能有特殊参数应排除。生产代码应根据模块语义而非只依赖名称。


6.4 权重衰减的反例

权重衰减限制的是参数表示的大小,不直接限制函数复杂度。由于神经网络可能存在参数缩放对函数影响不对称的情况,同一个函数可以由大小不同的参数表示。

例如 ReLU 网络满足:

ReLU(cx)=cReLU(x),c>0\operatorname{ReLU}(c x)=c\operatorname{ReLU}(x),\quad c>0

可以把前一层权重乘以 cc,再把后一层权重除以 cc,在一定条件下保持函数近似不变,但 L2 参数范数发生变化。因此“参数更小”不严格等价于“函数更简单”。

此外,权重衰减过大时会造成:

  • 训练损失下降缓慢;
  • 训练集和验证集都表现不佳;
  • 参数范数快速收缩;
  • logits 绝对值偏小,分类预测变得不自信。

这类表现通常是欠拟合,不应继续增加 Dropout 或缩短训练时间。


7. 早停:把训练过程本身作为正则化器

7.1 训练误差为何会先降后升

高容量模型通常先学习稳定、易泛化的模式,随后逐渐拟合训练集中的噪声。于是可能出现:

  • 训练损失持续下降;
  • 验证损失先下降后上升;
  • 验证指标在某个时间点达到最佳。

早停在验证集表现最好时保存模型,而不是使用最后一个 epoch 的参数。它限制了优化轨迹长度,因此属于一种过程正则化。

需要注意,早停不是“验证损失上升一次就立即停止”。验证指标有噪声,通常需要 patience 个评估周期没有改善后再停止。


7.2 一个完整的早停状态机

设监控指标为验证损失,目标是越小越好。需要维护:

  • best_value:历史最佳验证损失;
  • best_state:最佳模型参数快照;
  • bad_epochs:连续没有改善的次数;
  • patience:允许的连续无改善次数;
  • min_delta:至少改善多少才算改善。

伪代码如下:

best_value = +infinity
bad_epochs = 0

for epoch in epochs:
    train_one_epoch()

    model.eval()
    valid_value = evaluate()

    if valid_value < best_value - min_delta:
        best_value = valid_value
        best_state = copy(model.state_dict())
        bad_epochs = 0
    else:
        bad_epochs += 1

    if bad_epochs >= patience:
        break

model.load_state_dict(best_state)

关键点是 best_state 必须是独立副本。如果只保存对 state_dict() 的引用,后续训练可能继续修改底层张量,最终加载的并非历史最佳状态。

import copy

best_state = copy.deepcopy(model.state_dict())

如果使用 AdamW、学习率调度器或混合精度训练,要区分两种恢复目标:

  • 只恢复推理模型:保存模型权重通常足够;
  • 从最佳点继续训练:还必须保存优化器状态、学习率调度器状态、AMP scaler、当前 epoch 和随机状态。

否则模型参数虽然回到了最佳点,但动量和自适应统计量仍来自更晚的训练阶段,恢复后的轨迹并不等价。


7.3 可运行的训练示例

下面示例使用合成二分类数据,展示 Dropout、AdamW 和早停的生命周期。输出中的具体数值会因随机种子、PyTorch 版本和硬件而略有差异,但应能观察到验证损失被保存并恢复到最佳 epoch。

import copy
import random
import numpy as np
import torch
from torch import nn
from torch.utils.data import TensorDataset, DataLoader

def set_seed(seed=7):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)

set_seed()

# 生成一个带噪声的二分类数据集
n = 1200
x = torch.randn(n, 20)
signal = 2.0 * x[:, 0] - 1.2 * x[:, 1] + 0.5 * x[:, 2]
y = (signal + 0.8 * torch.randn(n) > 0).long()

perm = torch.randperm(n)
train_idx, valid_idx = perm[:900], perm[900:]

train_ds = TensorDataset(x[train_idx], y[train_idx])
valid_ds = TensorDataset(x[valid_idx], y[valid_idx])

train_loader = DataLoader(train_ds, batch_size=64, shuffle=True)
valid_loader = DataLoader(valid_ds, batch_size=256, shuffle=False)

model = nn.Sequential(
    nn.Linear(20, 128),
    nn.ReLU(),
    nn.Dropout(p=0.3),
    nn.Linear(128, 2),
)

loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(
    model.parameters(),
    lr=1e-3,
    weight_decay=1e-4,
)

best_valid_loss = float("inf")
best_state = None
bad_epochs = 0
patience = 5
min_delta = 1e-4

for epoch in range(100):
    model.train()
    train_loss_sum = 0.0
    train_count = 0

    for xb, yb in train_loader:
        optimizer.zero_grad(set_to_none=True)

        logits = model(xb)
        loss = loss_fn(logits, yb)
        loss.backward()
        optimizer.step()

        batch_size = xb.size(0)
        train_loss_sum += loss.item() * batch_size
        train_count += batch_size

    model.eval()
    valid_loss_sum = 0.0
    valid_correct = 0
    valid_count = 0

    with torch.no_grad():
        for xb, yb in valid_loader:
            logits = model(xb)
            loss = loss_fn(logits, yb)

            batch_size = xb.size(0)
            valid_loss_sum += loss.item() * batch_size
            valid_correct += (logits.argmax(dim=1) == yb).sum().item()
            valid_count += batch_size

    train_loss = train_loss_sum / train_count
    valid_loss = valid_loss_sum / valid_count
    valid_acc = valid_correct / valid_count

    improved = valid_loss < best_valid_loss - min_delta
    if improved:
        best_valid_loss = valid_loss
        best_state = copy.deepcopy(model.state_dict())
        bad_epochs = 0
    else:
        bad_epochs += 1

    print(
        f"epoch={epoch + 1:03d} "
        f"train_loss={train_loss:.4f} "
        f"valid_loss={valid_loss:.4f} "
        f"valid_acc={valid_acc:.3f} "
        f"bad_epochs={bad_epochs}"
    )

    if bad_epochs >= patience:
        print("early stopping")
        break

if best_state is None:
    raise RuntimeError("没有保存到任何有效模型状态")

model.load_state_dict(best_state)
torch.save(
    {
        "model": model.state_dict(),
        "best_valid_loss": best_valid_loss,
    },
    "best_model.pt",
)

这个示例中:

  1. 训练阶段 model.train() 使 Dropout 生效;
  2. 每个 batch 清空梯度、前向计算、反向传播、更新参数;
  3. 验证阶段 model.eval() 关闭 Dropout;
  4. torch.no_grad() 节省验证阶段的内存和计算;
  5. 只有验证损失达到新低时才保存快照;
  6. 连续 patience 次没有达到 min_delta 的改善后停止;
  7. 停止后重新加载历史最佳模型,而不是保留最后一次参数。

训练集和验证集必须在训练开始前分离。若每个 epoch 都根据测试集决定是否停止,测试集就不再是独立评测集。


8. 早停与学习率调度器的交互

学习率下降后,验证损失可能重新改善。因此早停不能简单地把“短期没有改善”理解成“训练已经无效”。

典型顺序是:

  1. 训练一个 epoch;
  2. 在验证集上评估;
  3. 更新最佳模型;
  4. 调用 ReduceLROnPlateau 等依赖验证指标的调度器;
  5. 更新早停计数;
  6. 满足条件后停止。

调度器和早停应使用不同的耐心周期。例如:

  • 学习率调度器等待 2 个评估周期;
  • 早停等待 6 个评估周期。

否则学习率还没来得及下降,早停就已经终止训练。

如果监控的是 accuracy,改善方向是越大越好;如果监控的是 loss,方向是越小越好。把方向设置反会导致模型在最差点保存,或者始终不触发早停。


9. Dropout、权重衰减和早停不是同一种正则化

三者都可能改善验证集表现,但改变机制不同:

方法 主要作用对象 训练时行为 推理时行为
Dropout 激活和网络路径 引入随机屏蔽噪声 通常关闭
权重衰减 参数更新 每步收缩参数 无额外随机行为
早停 优化时间和轨迹 根据验证表现停止 使用最佳快照

它们可以同时使用,但组合并不自动更好。一个常见失败组合是:

  • 数据量很小;
  • Dropout 很高;
  • weight decay 很大;
  • patience 很短。

此时模型可能尚未学到稳定模式就被停止,表现为训练损失和验证损失都偏高。诊断顺序应是:

  1. 先确认训练模式和评估模式正确;
  2. 确认损失、标签、mask 和 reduction 正确;
  3. 用无正则化的小模型检查数据管道能否过拟合少量样本;
  4. 再逐个加入 Dropout、权重衰减和早停;
  5. 观察每个改动对训练损失、验证损失和业务指标的影响。

逐个加入比一次同时修改三个超参数更容易建立因果关系。


10. 常见误解与失败表现

10.1 “训练损失越低,模型越好”

反例是标签含噪的高容量模型。它可以继续记忆训练集中的错误标签,使训练交叉熵不断下降,但验证损失上升。此时应该保存验证表现最佳的 checkpoint,而不是最后一个 checkpoint。

10.2 “L2 正则和 AdamW 完全一样”

在 SGD 中,两者可以通过更新公式建立等价关系;在 Adam 中,把 L2 项加进梯度会影响 Adam 的自适应统计量,而 AdamW 将衰减项解耦。它们的有效行为和超参数含义不同。

10.3 “Dropout 只要在模型定义里出现就会生效”

如果模型一直处于 eval(),Dropout 不会随机屏蔽;如果验证时仍处于 train(),验证结果会随机波动。模块状态是运行时状态,不是定义文件中的静态标记。

10.4 “验证集可以无限调参”

反复根据同一个验证集选择损失权重、Dropout、weight decay、训练轮数和数据处理方式,会使验证集逐渐被过拟合。最终应使用未参与选择的测试集、时间切分集或跨域集确认结果。

10.5 “把所有参数都做权重衰减最安全”

归一化层的缩放参数和偏置通常不做衰减,是因为它们的作用不是普通的特征提取权重。这个规则是常见实现和经验选择,不是所有架构的数学必然;自定义模块需要单独审查。

10.6 “损失下降就说明数据没有问题”

损失可能因为标签泄漏、重复样本、错误的 padding mask 或训练验证分布泄漏而下降。生产诊断至少应检查:

  • 样本是否按用户、会话或时间正确切分;
  • 验证样本是否出现在训练数据中;
  • 标签是否被错误地输入模型;
  • padding、ignore index 和 attention mask 是否一致;
  • 加权损失的分母是否稳定;
  • 训练和推理预处理是否相同。

11. 生产系统中的目标、数据、评测与成本

正则化不能脱离生产约束单独讨论。模型训练目标应与数据、权限、评测和成本一起设计。

数据

重复样本、近重复文本和错误标签会改变最优正则化强度。数据泄漏会让早停看起来有效,却无法预测上线表现。时间序列、用户行为和对话数据通常需要按实体或时间切分,而不是随机逐行切分。

评测

必须固定:

  • 监控哪个指标;
  • 指标越大越好还是越小越好;
  • token 或样本如何归一化;
  • padding 和无效标签如何处理;
  • 是否按用户、文档或序列进行聚合。

否则早停保存的“最佳模型”可能只是分母变化或采样波动造成的假改善。

权限

训练数据、checkpoint、优化器状态和评测输出可能包含敏感信息。checkpoint 不只是模型结构,还可能包含:

  • 优化器动量;
  • 学习率调度器状态;
  • AMP scaler;
  • 随机数状态;
  • 数据迭代位置。

这些文件应按敏感级别控制读取和下载权限。对生成式模型,还要审查训练数据是否包含不应被模型记忆和复现的内容。

成本

早停减少训练计算,但验证本身也有成本,尤其是大语言模型的长序列评测。可以降低验证频率、使用固定小型监控集或分层评测,但必须确认监控集仍能代表真实目标。

保存 checkpoint 也有存储成本。只保留最佳权重适合部署;需要断点续训时则必须保留优化器等状态。两种保存策略应明确区分,不能为了省空间删除恢复训练所需的信息。


12. 一套可验证的排查顺序

当模型过拟合或训练异常时,可以按以下因果顺序排查:

  1. 确认目标正确:类别索引、回归尺度、序列 shift、mask 和 reduction 是否正确;
  2. 确认数据切分独立:没有重复样本、未来信息或用户级泄漏;
  3. 尝试小样本过拟合:若连几十个样本都无法把训练损失降下来,问题通常在模型、标签、优化器或数据管道;
  4. 确认模式切换:训练用 train(),验证和推理用 eval()
  5. 建立无正则化基线:先观察模型是否有足够拟合能力;
  6. 逐一加入正则化:先调 weight decay 或 Dropout,再调早停;
  7. 检查验证噪声:扩大验证集、固定随机种子或增加评估次数;
  8. 用独立测试集确认:避免把验证集选择结果误认为泛化能力。

一个有意义的实验应至少记录:

  • 训练损失;
  • 验证损失;
  • 主要业务指标;
  • 参数范数或梯度异常;
  • 学习率;
  • 当前 checkpoint;
  • 数据版本和代码版本。

这样才能判断是目标设计、优化过程、正则化强度还是数据分布导致了结果变化。


13. 结语:先确定目标,再选择正则化机制

损失函数决定模型被要求学习什么,正则化决定模型被允许以多复杂的方式学习,早停则限制它在当前数据上继续拟合的时间。

可以用三个问题建立清晰边界:

  1. 损失是否准确表达了任务和错误代价?
  2. 正则化是否针对当前的过拟合机制,而不是盲目叠加?
  3. 验证流程是否独立、可复现,并且与上线目标一致?

交叉熵、MSE、Dropout、AdamW 和早停都只是工具。真正可靠的训练系统,必须把目标定义、参数更新、模式状态、checkpoint、数据切分和独立评测连接成一个可验证的闭环。


系列导航与关联阅读

官方资料

本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。