训练集准确率 99.2%,验证集 76.5%,两条 loss 曲线在第 20 个 epoch 之后开始分叉——这种图见过太多次。第一反应基本都是“过拟合了,加正则化”,然后 dropout 从 0.2 调到 0.5,weight decay 补上 1e-3,数据增强开到最大,结果模型精度掉了两个点,训练时间翻倍,最后连是哪一步起的作用都说不清。过拟合和正则化这对概念在教科书里讲得很干净,真到工程里全是脏活:你得先分清是真过拟合,还是数据泄漏伪装成过拟合;得看懂 weight decay 在 Adam 下面已经变形;得知道 dropout 和批归一化叠在一起会把推理时的统计量搞乱。这一章把“改进神经网络学习方式”里抑制过拟合这条线拆开讲,从归因、到每种正则手段的真实作用机制、再到参数怎么定、叠加顺序怎么排,最后给一条验证集反复横跳时的排查链路。适合已经能跑通训练脚本、但对“为什么加了正则化反而更差”感到困惑的人。
1. 别急着加 dropout:先把“掉点”归因清楚
1.1 三条曲线的相对位置,比绝对数值有信息量
很多人判断过拟合只看一句话——“训练集比验证集高”。这个判据太粗,几乎覆盖了所有训练异常。真正有用的信息在三条曲线的相对走势:训练 loss 是否单调下行、验证 loss 是先降后升还是同步高位、两者分叉发生在第几个 epoch、分叉前验证 loss 的最低点离训练 loss 有多远。
我按实际遇到的频率整理了一张表,对照着看能省掉大量无效调参:
| 形态 | 训练 loss | 验证 loss | 常见误判 | 实际原因 |
|---|---|---|---|---|
| A 经典过拟合 | 持续下行接近 0 | 先降后升,拐点明显 | 无 | 容量过剩、样本不足、噪声拟合 |
| B 双双停滞 | 降不下去,停在中等水平 | 同步高位,与训练集同量级 | 以为过拟合 | 欠拟合、学习率过大或过小、特征表达力不足 |
| C 剧烈抖动 | 抖动幅度大 | 抖动且与训练集不同步 | 以为过拟合 | 学习率过大、batch 过小、BN 统计量不稳 |
| D 验证优于训练 | 偏高的训练 loss | 验证 loss 反而更低 | 以为泛化特别好 | 训练时开着 dropout 和强增强,测量口径不一致 |
形态 D 特别坑人。训练阶段开着 dropout、随机裁剪、颜色抖动,测出来的训练 loss 天然偏高;验证阶段这些全部关掉,模型处在“最强状态”,验证 loss 当然低。这时候有人开心地宣布“泛化很好”,其实只是拿两种不同配置在比。同理,形态 D 反过来也能骗人:如果训练 loss 用的是训练模式测的,验证 loss 用评估模式测的,那个“泛化间隙”本身就是假的,可能比真实值大好几倍。
1.2 量化泛化间隙之前,先把测量口径对齐
标准做法是写一个统一的评估函数,训练集和验证集都走同一条路径,都在eval模式下测。下面这段代码是我基本每个项目都会复制一遍的模板:
import torch @torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() # 关键:关掉 dropout / BN 用 running stats total_loss, total_correct, total_num = 0.0, 0, 0 for x, y in loader: x, y = x.to(device), y.to(device) logits = model(x) loss = criterion(logits, y) total_loss += loss.item() * y.size(0) total_correct += (logits.argmax(1) == y).sum().item() total_num += y.size(0) return total_loss / total_num, total_correct / total_num有了这个函数,训练集和验证集的 loss 就是在同一口径下算出来的,gap = val_loss - train_loss才有意义。注意model.eval()要写在循环外面,别写在循环里面——虽然结果一样,但每次迭代切换模式会拖慢速度,而且在有 BatchNorm 的模型里反复切换容易让人看代码时产生误解。
还有一个容易被忽略的点:训练过程中记录的训练 loss 通常是每个 batch 的滑动平均,而验证 loss 是整个 epoch 的平均。两者的统计窗口不同,直接相减会有偏差。比较严谨的做法是每个 epoch 结束后用上面的函数把训练集也完整跑一遍,代价是多花一点时间。数据量不大时我建议就这么干,数据量大时可以每 5 个 epoch 测一次,或者从训练集里切一小块固定子集专门用来测。
1.3 三种伪装成过拟合的数据泄漏
比调参更常见的问题是:你看到的“过拟合”根本不是过拟合,是数据划分出了问题。下面三种情况我都真实遇到过,症状和过拟合高度相似,但处理方式完全相反。
第一种是全量数据做预处理。标准化时用了整个数据集(训练+验证)的均值和方差,PCA 在合并数据上拟合,文本的 TF-IDF 词表在全量语料上统计。这些操作让验证集的信息提前渗进了训练流程,模型在验证集上的表现会虚高,而这个虚高随着训练推进会慢慢“被追平”,曲线看起来就像过拟合后的回落。
第二种是时间序列的随机划分。用随机打乱的方式划分时序数据,训练集里会混进未来的样本,模型学到了“未来信息”。典型症状是验证集指标异常高,上线后断崖式下跌,或者验证集指标先高后低,跟着训练一起漂。
第三种是同一主体的样本跨集合。医疗影像里同一个病人的多张片子被分到训练和验证两侧,推荐系统里同一个用户的行为被切开,说话人识别里同一段录音被切成多个片段。模型记住的是“这个人/这个用户”的特征,不是任务本身的规律。这种情况的验证集指标通常会高得不太合常理,比如 98%、99%,而且换个随机种子波动很大。
判断依据很简单:如果一个模型的验证集指标好得超出你对这个任务的直觉预期,先怀疑数据,别怀疑模型。
2. 权重衰减:L2 的数学动机与 AdamW 的工程修正
2.1 从“惩罚大权重”到“每步都缩一点”
L2 正则的推导很短,但值得写一遍,因为后面 AdamW 的坑就藏在这个推导里。原始损失加上权重的平方范数:
L = L_data + (λ/2) * ||w||²对 w 求偏导会多出一项λw,梯度下降的更新式变成:
w ← w - η(∂L_data/∂w + λw) = (1 - ηλ)w - η * ∂L_data/∂w前面那个(1 - ηλ)就是名字的由来:每一步更新之前,权重先乘上一个略小于 1 的系数,往零的方向缩一点。所以 L2 正则和 weight decay 在纯 SGD 下是同一件事。
为什么大权重意味着过拟合?直观解释是:输入的一个小扰动,经过权重放大后会显著改变输出。权重越大,函数在样本点附近的局部曲率越大,模型就越倾向于把每个训练样本“钉”在它自己的位置上,形成高曲率的插值曲面。限制权重范数,等价于给函数加了一个平滑性约束。
2.2 哪些参数该衰减,哪些不该
这是个实践惯例问题,没有严格理论,但偏离这个惯例通常会掉点。我的默认配置是:
- 参与衰减:卷积层、线性层的
weight - 不参与衰减:所有
bias、BatchNorm/LayerNorm 的weight(也就是 γ)和bias(β) - 视情况:Embedding 层通常参与衰减;如果词表很大且低频词多,衰减可能有害,建议单独给一个小系数或者不衰减
理由分别说一下。bias 不放大输入的差异,它只是给激活值加一个平移量,对函数曲率没有贡献,衰减它只会让模型更难拟合数据的基线水平。BN 的 γ 是归一化之后的尺度参数,衰减 γ 相当于限制归一化层的表达能力,而 BN 的输出本来就会被后续层重新缩放,这个限制意义不大,反而可能让训练初期收敛变慢。
用 PyTorch 写就是分组:
decay_params, no_decay_params = [], [] for name, p in model.named_parameters(): if not p.requires_grad: continue if p.ndim <= 1: # bias、BN 的 γ/β 基本都是一维 no_decay_params.append(p) else: decay_params.append(p) optimizer = torch.optim.AdamW([ {"params": decay_params, "weight_decay": 1e-4}, {"params": no_decay_params, "weight_decay": 0.0}, ], lr=3e-4)用p.ndim <= 1做判断是个偷懒但足够好用的启发式。它会把一维的 bias 和归一化参数都归到不衰减那一组,同时把二维的权重矩阵归到衰减组。个别结构(比如一维卷积的 weight)会被误判,但影响很小,真在意的话就按参数名精确匹配。
2.3 Adam 下面的 L2 已经不是 L2 了
这是本节最值得记住的一点。Adam 的更新式是:
m ← β1*m + (1-β1)*g v ← β2*v + (1-β2)*g² w ← w - η * m̂ / (√v̂ + ε)如果把 L2 的梯度λw直接加进g里,这一项会同时进入一阶矩和二阶矩,然后被√v̂除。结果是:梯度本来就很小的参数,衰减被放大;梯度本来就大的参数,衰减被压缩。名义上你设了 λ=1e-4,实际对每个参数起到的衰减强度完全不同,而且和梯度尺度耦合在一起。这不是你想要的“所有参数统一往零缩”。
AdamW 的做法是把衰减从梯度里拿出来,直接在参数上做:
w ← w - η * m̂/(√v̂ + ε) - η*λ*w两件事彻底解耦。这也是为什么现在默认都用 AdamW 而不是 Adam + weight_decay。做个对照:
| 优化器写法 | 衰减位置 | 实际效果 | 建议 |
|---|---|---|---|
Adam(weight_decay=λ) | 混入梯度 | 与自适应学习率耦合,强度不可控 | 不推荐 |
AdamW(weight_decay=λ) | 直接作用在参数 | 等价于解耦的权重衰减 | 默认选择 |
SGD(weight_decay=λ) | 混入梯度 | 与 L2 严格等价 | 可用 |
如果你还在用Adam(weight_decay=...),换成AdamW之后同样的 λ 通常会显得“更弱”,需要适当调大一点,这是我换优化器时踩过的坑。
2.4 λ 的量级怎么定
经验区间是1e-4到1e-2,视觉任务偏小,语言模型偏大。搜索方式建议按数量级走:1e-4, 3e-4, 1e-3, 3e-3, 1e-2,五个点基本能覆盖。别做细粒度的线性搜索,λ 的作用是对数尺度的,从 1e-3 调到 1.2e-3 基本看不出差别。
还有一个和 batch size 相关的细节容易被忽略:如果你的损失函数用的是reduction='sum'而不是'mean',那么梯度会随 batch size 线性放大,此时 λ 的等效强度会随 batch 变化。用'mean'就没有这个问题。我一般只在需要精确复现某个配方时才用 sum,其余情况一律 mean。
3. Dropout 的两套状态:训练随机丢、推理确定放
3.1 inverted dropout 为什么成了默认实现
最早的 dropout 是训练时以概率 p 把神经元置零、不做任何缩放,推理时把所有权重乘以 (1-p)。问题有两个:推理阶段必须记得改权重,而且训练和推理两套代码路径容易出现不一致。
现在框架里的实现统一是 inverted 版本:训练时把保留下来的单元除以 (1-p),推理时原样输出。这样训练阶段的期望输出和推理阶段一致,推理代码不需要做任何改动。手写一遍有助于理解:
class InvertedDropout: def __init__(self, p): assert 0 <= p < 1 self.p = p def __call__(self, x, training): if not training: return x keep = 1.0 - self.p mask = (torch.rand_like(x) > self.p).float() return x * mask / keepmask / keep这一步是关键。假设某个神经元的输入是 10,p=0.5,训练时大约一半的批次它会被置零,另一半保持 20(10/0.5)。期望值仍然是 10,和推理时一致。如果不除以 keep,训练时的期望是 5,推理时是 10,分布就错位了。
3.2 p 取多少:全连接层和卷积层不是一回事
经典的 0.5 是给全连接层用的,尤其是参数量很大的分类头。卷积层上用 0.5 通常会掉点,因为卷积特征图在空间上有很强的相关性,随机丢掉单个元素,信息会立刻被邻居补上,正则效果大打折扣,而丢弃带来的信息损失却实打实地存在。
卷积层上的经验取值是 0.1 到 0.3。如果想要更强的正则,应该用结构化丢弃:
SpatialDropout2D:按通道整体丢弃,让模型不能依赖某几个通道DropBlock:按连续的空间块丢弃,比逐元素丢弃更难被邻居补偿DropPath/ Stochastic Depth:在残差网络里整条分支丢弃,对深网络的训练稳定性提升明显
DropPath 这几年在视觉 Transformer 里几乎是标配,从 0.1 线性递增到 0.2 之类的调度很常见。它的好处是浅层几乎不丢,深层丢得多,符合“深层更容易过拟合”的直觉。
3.3 dropout 和 BN 的顺序,是最容易埋雷的地方
这一点单独拿出来讲,因为它造成的问题经常被误判成过拟合。BatchNorm 在训练阶段用当前 batch 的均值和方差做归一化,同时维护一份全局的running_mean/running_var供推理使用。如果 dropout 在 BN 之前,每个 batch 输入给 BN 的分布会因为随机丢弃而剧烈变化,running_mean和running_var一直在漂移,收敛时也没稳定下来。推理时 dropout 关闭,输入分布变了,但 BN 用的还是那份漂移的统计量,输出直接偏掉。
外在表现是:训练集指标一路走高,验证集指标在某几个 epoch 之后明显下滑,看起来就是过拟合。有人据此加大 dropout,结果更糟。
处理方式按优先级排:
- 把 BN 放在 dropout 之前,先归一化再丢弃,BN 看到的分布是稳定的
- 不要在同一层后叠加 BN 和 dropout,二选一
- 在卷积主干里用 DropBlock 替代逐元素 dropout,它对 BN 的统计量干扰小得多
- 如果模型很深,把 dropout 集中放在最后的全连接头,主干里不开
3.4 推理忘了 eval() 是什么症状
同一个 checkpoint,训练脚本里最后报的验证精度 76%,单独写个推理脚本加载后跑出来 62%,而且每次跑结果都不一样。基本可以确定是忘了model.eval()。dropout 开着的时候推理是随机的,每次输出都不同;BN 用的是当前 batch 的统计量而不是全局的,batch 小的时候波动尤其大。
我的做法是在模型封装里加一个断言,训练循环里显式切换,别依赖默认状态:
def train_one_epoch(model, loader, optimizer, criterion, device): model.train() ... @torch.no_grad() def validate(model, loader, criterion, device): model.eval() ...model.train()和model.eval()一定要写全,不要只写一半。有不少自定义模块的train()方法里做了额外的事情(比如冻结某些子模块),漏写就会出问题。
4. 数据增强与标签平滑:把先验写进样本而不是写进损失
4.1 数据增强的本质是注入先验
权重衰减和 dropout 是在损失和结构层面施加约束,数据增强走的是另一条路:直接从数据分布上告诉模型“这些变换不改变类别”。水平翻转不改变“猫”的类别,随机裁剪不改变“狗”的类别,颜色抖动不改变“车”的类别。每一条增强规则,都是在把一个不变性先验写进训练过程。
常用增强和它们的适用边界:
| 增强方式 | 适用场景 | 不适用场景 | 典型强度 |
|---|---|---|---|
| 随机裁剪 + 翻转 | 自然图像分类 | 字符识别、医学影像(翻转有语义) | 裁剪比例 0.08~1.0 |
| 颜色抖动 | 光照变化大的场景 | 颜色是判别特征的任务 | 亮度/对比度 ±0.4 |
| RandAugment | 通用视觉 | 小数据集(可能过强) | N=2, M=9 |
| MixUp | 分类、半监督 | 检测、分割(标签无法线性混合) | α=0.2 |
| CutMix | 分类、检测 | 需要完整目标的场景 | α=1.0 |
| 随机擦除 | 遮挡鲁棒性 | 目标本身很小的任务 | 面积 2%~20% |
MixUp 值得单独说,因为它的正则效果和标签平滑有相通之处。它的做法是从 Beta 分布采样一个 λ,把两张图按 λ 线性混合,标签也按同样比例混合:
def mixup(x, y, alpha=0.2, num_classes=10): lam = np.random.beta(alpha, alpha) idx = torch.randperm(x.size(0), device=x.device) x_mix = lam * x + (1 - lam) * x[idx] y_onehot = torch.zeros(x.size(0), num_classes, device=x.device) y_onehot.scatter_(1, y.view(-1, 1), 1.0) y_mix = lam * y_onehot + (1 - lam) * y_onehot[idx] return x_mix, y_mix注意 γ 的选取:α 越小,λ 越集中在 0 和 1 附近,混合程度越弱。α=0.2 是分类任务的常见起点,α=1.0 混合得很激进,小数据集上容易欠拟合。
增强强度和数据量成反比,这条经验很实用。一万张图以下,增强要保守,RandAugment 的 M 值调低;百万级数据可以开满。判断标准是看训练 loss 能不能降下去:如果加了增强之后训练 loss 卡在一个较高位置不再下降,说明增强过强,模型连训练集都拟合不了了。
4.2 标签平滑:把 one-hot 拉平一点
标准交叉熵配合 one-hot 标签,会让模型把正确类的 logit 推到无穷大,因为损失只有在 logit 差值趋于无穷时才趋于零。这意味着模型对自己的预测越来越自信,而这种自信在训练集之外没有任何依据。
标签平滑把目标分布改成:
q_i = 1 - ε + ε/K (i 是正确类) q_i = ε/K (其他类)K 是类别数,ε 通常取 0.1。用交叉熵去拟合这个软化后的分布,正确类的 logit 不需要无限大,只要比其他类高出大约log((1-ε+ε/K)/(ε/K))就够了。对 K=10、ε=0.1 来说,这个差值大约是 2.2,模型不再追求极端置信度。
PyTorch 从 1.10 开始内置了支持:
criterion = torch.nn.CrossEntropyLoss(label_smoothing=0.1)几个适用边界要记清楚:
- 知识蒸馏时不要用。学生模型要学的是教师输出的软分布,标签平滑会把教师的软信息压平,蒸馏效果变差。
- 需要概率校准的场景慎用。标签平滑会改变输出的置信度分布,虽然通常让校准更好,但如果下游有固定阈值,需要重新标定。
- 类别数极大时效果很弱。K=1000 时 ε/K 只有 1e-4,几乎不影响。这时候应该用分组平滑或者直接靠其他正则手段。
- 与 MixUp 叠加时降低强度。两者都在软化标签,一起用容易过度平滑,训练 loss 下降变慢。
4.3 增强管线里最容易埋的雷:训练和验证不一致
验证集不能做随机增强,只能做确定性的预处理:resize、center crop、normalize。normalize 用的均值和方差必须来自训练集的统计,不能来自全量数据,也不能随手填 0.5。
我习惯把两条管线写在一起,用参数区分,减少复制粘贴出错的机会:
from torchvision import transforms mean, std = [0.485, 0.456, 0.406], [0.229, 0.224, 0.225] train_tf = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.6, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.3, 0.3, 0.3), transforms.ToTensor(), transforms.Normalize(mean, std), ]) val_tf = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean, std), ])RandomResizedCrop的scale下界不要设得太低,0.08 是 ImageNet 那种量级的数据才扛得住的强度,中小数据集上设 0.6 到 0.8 更稳。
5. 早停、批归一化与权重约束:不动网络结构的三种收紧手段
5.1 早停的三个参数:耐心、最小改善、恢复最佳
早停是最省事的正则手段——不改变模型、不增加计算,只是在对的时机停下来。但它有三个参数需要认真对待,随手填默认值经常停得太早或太晚。
class EarlyStopping: def __init__(self, patience=10, min_delta=1e-4, mode="min"): self.patience = patience self.min_delta = min_delta self.mode = mode self.best = float("inf") if mode == "min" else -float("inf") self.counter = 0 self.best_state = None self.should_stop = False def step(self, metric, model): improved = (metric < self.best - self.min_delta) if self.mode == "min" \ else (metric > self.best + self.min_delta) if improved: self.best = metric self.counter = 0 self.best_state = {k: v.detach().clone() for k, v in model.state_dict().items()} else: self.counter += 1 if self.counter >= self.patience: self.should_stop = True return self.should_stop def restore(self, model): if self.best_state is not None: model.load_state_dict(self.best_state)patience的取值和数据规模、学习率调度有关。数据小、学习率大的时候曲线抖得厉害,patience 设 5 容易误停,我一般设 10 到 20。min_delta用来过滤掉那些幅度极小、没有实际意义的“改善”,1e-4 是个稳妥的起点。
restore这一步很多人会漏。早停触发时模型已经训练了 patience 个额外的 epoch,参数早就不是最佳点了,不恢复的话前面存的最佳权重白存。恢复之后如果还要继续训练,优化器状态也要一并恢复,否则动量项和最佳权重不匹配。
5.2 批归一化的隐式正则和小 batch 的边界
BN 在训练时用 batch 统计量归一化,这个统计量本身带噪声,每个 batch 的归一化结果都略有不同,相当于给激活值加了扰动。这种扰动有轻微的正则效果,也是 BN 有时候能替代 dropout 的原因之一。
但这个隐式正则依赖 batch 足够大。batch 小于 16 的时候,统计量的噪声过大,训练会变得不稳定,收敛变慢,验证指标波动明显。这时候应该换 GroupNorm 或 LayerNorm,它们不依赖 batch 维度,统计量在单个样本内部计算,小 batch 下更稳。
另一个副作用是 BN 会掩盖过拟合信号。有 BN 的模型,训练 loss 往往下降得很平缓,验证 loss 也不容易出现陡峭的上升,两条曲线看起来一直贴在一起,让人误以为没有过拟合。这时候要看验证集准确率而不是 loss,准确率通常在 loss 还在缓降的时候就已经见顶了。
5.3 权重约束与梯度裁剪
权重约束(max-norm)是另一个不常被提起但很有用的手段。做法很简单:每次更新后检查权重的 L2 范数,超过阈值就等比缩回来:
@torch.no_grad() def max_norm_constraint(model, max_value=3.0): for name, p in model.named_parameters(): if p.ndim <= 1: continue norm = p.norm(2) if norm > max_value: p.mul_(max_value / (norm + 1e-6))它对训练稳定性的帮助大于对泛化的帮助,在一些对权重尺度敏感的模型里(比如没有 BN 的小网络)比较有用。阈值一般设 3 到 4。
梯度裁剪解决的是另一个问题——梯度爆炸。循环网络、深层 Transformer 的早期训练、混合精度训练里都可能遇到。标准做法是:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)max_norm在 RNN 里常取 1.0 到 5.0,Transformer 一般取 1.0。裁剪要放在loss.backward()之后、optimizer.step()之前,顺序不能错。用混合精度训练时必须先scaler.unscale_(optimizer)再裁剪,否则裁的是缩放过的梯度,尺度不对。
6. 正则化手段的叠加顺序与一套可复现的实测流程
6.1 按性价比排序,别一次全上
面对一个过拟合的模型,最忌讳的做法是把所有手段一次性加满。出了问题不知道是谁的锅,调参也没有方向。我的默认顺序是按性价比从高到低:
| 优先级 | 手段 | 代价 | 典型收益 | 前置条件 |
|---|---|---|---|---|
| 1 | 数据增强 | 训练变慢 | 大 | 增强不能破坏标签语义 |
| 2 | 权重衰减 | 几乎为零 | 中 | 用 AdamW,排除 bias 和 BN 参数 |
| 3 | 早停 | 几乎为零 | 中 | 有可靠的验证集 |
| 4 | 标签平滑 | 几乎为零 | 小到中 | 非蒸馏、非概率校准场景 |
| 5 | dropout | 训练变慢 | 中 | 注意与 BN 的顺序 |
| 6 | 权重约束 | 几乎为零 | 小 | 无 BN 的小网络更有效 |
数据增强排第一是因为它同时改善泛化和鲁棒性,而且不改变模型结构。权重衰减和早停排前面是因为零成本。dropout 排后面不是因为它没用,而是因为它和 BN 的交互复杂,容易引入新问题,放在最后加更安全。
6.2 一次只改一个变量,记录三组数
每改一次配置,至少记录训练 loss、验证 loss、验证准确率三个数,最好再记下最佳 epoch 和实际训练轮数。这些数据积累下来,你对“这个数据集上什么强度的正则合适”会形成直觉,比任何调参攻略都管用。
我的实验记录表大概长这样:
| 实验 | 增强 | wd | dropout | 平滑 | 最佳 epoch | train loss | val loss | val acc |
|---|---|---|---|---|---|---|---|---|
| baseline | 无 | 0 | 0 | 0 | 18 | 0.02 | 1.35 | 76.5 |
| +wd | 无 | 1e-3 | 0 | 0 | 26 | 0.15 | 0.92 | 80.1 |
| +aug | 中 | 1e-3 | 0 | 0 | 41 | 0.48 | 0.71 | 84.3 |
| +do | 中 | 1e-3 | 0.3 | 0 | 38 | 0.55 | 0.78 | 83.6 |
| +ls | 中 | 1e-3 | 0 | 0.1 | 43 | 0.52 | 0.68 | 85.0 |
从这张表能看出两件事:加 dropout 之后验证准确率反而降了,说明在已经有强增强的情况下 dropout 是多余的;标签平滑带来的提升是实打实的。这类结论只能靠逐个变量对比得到。
6.3 一个完整的训练循环骨架
把前面几节的东西拼起来,大概是这样:
import torch, numpy as np from torch import nn def build_param_groups(model, wd): decay, no_decay = [], [] for n, p in model.named_parameters(): if not p.requires_grad: continue (no_decay if p.ndim <= 1 else decay).append(p) return [ {"params": decay, "weight_decay": wd}, {"params": no_decay, "weight_decay": 0.0}, ] def train(model, train_loader, val_loader, device, epochs=60, lr=3e-4, wd=1e-3, smooth=0.1, patience=12): model.to(device) criterion = nn.CrossEntropyLoss(label_smoothing=smooth) optimizer = torch.optim.AdamW(build_param_groups(model, wd), lr=lr) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs) stopper = EarlyStopping(patience=patience, mode="min") for ep in range(epochs): model.train() for x, y in train_loader: x, y = x.to(device), y.to(device) optimizer.zero_grad(set_to_none=True) loss = criterion(model(x), y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() val_loss, val_acc = evaluate(model, val_loader, criterion, device) print(f"ep {ep:02d} val_loss {val_loss:.4f} val_acc {val_acc:.4f}") if stopper.step(val_loss, model): print(f"early stop at epoch {ep}") break stopper.restore(model) return model这段代码里几个细节值得注意:zero_grad(set_to_none=True)比默认的置零略快;裁剪放在step之前;早停监控的是验证 loss 而不是准确率,因为 loss 更平滑,不容易被单个 batch 的偶然正确误导。
6.4 “加了正则化反而更差”的四个常见原因
这个现象太常见了,单独列一下排查方向:
- 正则太强。λ=1e-2 配小数据集,dropout=0.5 配浅网络,都会让模型连训练集都拟合不了。看训练 loss 卡在什么位置就知道。
- BN 和 dropout 冲突。前面讲过,顺序不对会让验证指标莫名其妙地下滑。
- 早停太早。patience 设成 3,曲线还在波动阶段就停了,最佳点根本没到。
- 学习率没跟着调。加了权重衰减之后,有效学习率是
η和ηλ的叠加,原学习率可能已经偏大。我一般会把 lr 降一档再试。
7. 验证集指标反复横跳时的排查链路
7.1 第一步永远是测量口径
指标不稳定的时候,先别怀疑模型,先检查测量过程。具体查这几项:验证时有没有model.eval();验证集的 transform 里有没有混进随机增强;验证集的 batch size 是不是太小导致 BN 统计量跳变(虽然 eval 模式用 running stats,但如果用了 SyncBN 或者自定义归一化,仍可能受影响);DataLoader 有没有开shuffle=True(验证集不该开)。
这几项都在十秒内能查完,却能解释掉相当一部分“过拟合”现象。
7.2 再排除数据和标签
把验证集里模型预测错误最大的那批样本捞出来,人工看十几张。常见问题包括标签写错、图片损坏、类别定义有歧义(比如“哈士奇”被标成“狼”)、预处理管线和训练集不一致。标签噪声会直接表现为“模型在训练集上学得很好、验证集上莫名其妙”,和过拟合的症状几乎一样。
同时检查划分是否真的互斥,有没有样本在两个集合里同时出现。做一次 MD5 去重是很便宜的保险。
7.3 然后才是正则强度和优化配置
确认前两步没问题之后,再回到调参。我的调整顺序是:先调学习率(它影响的幅度最大)、再看权重衰减、最后才动 dropout。每次只改一个,跑完整的训练周期,不要在中途改配置。
如果所有正则手段都试过、验证指标依然卡在一个明显低于训练指标的水平,那可能不是过拟合的问题,而是数据量本身不够、或者任务本身的难度就在那里。这时候加正则不会有用,该考虑的是补充数据、换更强的预训练权重、或者重新审视任务的可行性。
一个判断经验:如果训练 loss 降到 0.01 以下而验证 loss 在 0.7 以上反复震荡,那是真过拟合,加重正则;如果训练 loss 一直在 0.5 附近降不下去,验证 loss 也是 0.6 左右,那不是过拟合,是欠拟合或者优化没跑起来,加正则只会更糟。
7.4 一个把过拟合处理干净的顺序清单
把整章的思路收在一起,遇到验证集掉点时我实际的执行顺序是:
- 统一测量口径,训练集和验证集都用
eval模式跑一遍,拿到真实的泛化间隙 - 捞错误样本,确认不是标签噪声或数据泄漏
- 建立无正则的基线,记录最佳 epoch 和对应的三组指标
- 按性价比顺序叠加:数据增强 → 权重衰减(AdamW、排除 bias/BN)→ 早停 → 标签平滑 → dropout
- 每加一样,跑完整训练,对比最佳 epoch 的验证指标
- 出现变差的情况,优先怀疑强度过大和 BN/dropout 冲突,而不是手段本身无效
- 所有手段试完仍然不理想,回到数据层面找原因
这套流程跑下来通常要花掉大半天到两三天,比无头苍蝇式的调参快得多。我个人的体会是,大部分所谓的“过拟合难题”,最后都落在第 2 步和第 6 步——要么数据有问题,要么正则强度和叠放方式不对,真正需要换模型架构的情况比想象中少。