☰
过拟合与欠拟合诊断:权重衰减、AdamW与正则化调参实战
2026/10/27 5:46:38 网站建设 项目流程

模型在训练集上跑到 99.2%,验证集却卡在 76% 上不去,loss 曲线一条往下扎、一条往上翘——这是我这些年被问得最多的一个现象。问的人里有刚入门的学生,也有已经带过项目的老手。它指向深度学习里最基础、也最容易被含糊带过的两件事:过拟合和欠拟合。而正则化,尤其是权重衰减(weight decay),是处理过拟合最便宜、最常开、也最容易被开错的那把开关。这篇内容我想把这条链路完整走一遍:怎么判断模型到底病在哪、权重衰减在数学上究竟做了什么、weight_decay这个参数为什么在 SGD 和 Adam 下含义完全不同、λ 该按什么量级去试、代码怎么落地、以及欠拟合那条反向的路怎么走。适合刚学完反向传播想动手的读者,也适合已经能跑通训练脚本、但对超参全凭玄学的人对照排查。

1. 先搞清楚过拟合和欠拟合长什么样

动手调参之前,先得把“病”认准。我见过太多人一看到验证集效果差,条件反射就加weight_decay,结果模型本来已经欠拟合了,再压一把,验证准确率从 68% 掉到 61%,还以为是“正则化太强”,其实是方向从一开始就错了。判断病灶这件事,靠的不是感觉,而是几条固定的观测指标:训练集与验证集的 loss 差、两者的准确率差、训练 loss 的绝对水平、以及曲线随 epoch 的走势。这几条摆在一起,九成情况下能定性。

1.1 用四条曲线定位病灶

我会把训练过程拆成四个观察窗口。第一看训练 loss 的绝对水平:如果你的训练 loss 到最后还有 0.8(分类任务、样本量正常的情况下),说明模型连训练集都没拟合住,这是典型的欠拟合,往下再怎么正则都是雪上加霜。第二看训练集与验证集的 loss 差值:训练 loss 0.02、验证 loss 0.9,差值接近一个数量级,这就是过拟合的铁证。

第三看准确率差值随 epoch 的变化趋势:健康的训练是两条线一起涨,验证线涨得慢一点但方向一致,最后在一个平台期收敛;过拟合是训练线一路爬到 99% 还在涨,验证线在某个 epoch 之后开始横盘甚至掉头向下——那个拐点就是模型开始“背答案”的时刻。第四看验证 loss 的绝对最低点出现在哪个 epoch:如果最低点出现在很早的位置(比如总 epoch 数的 15% 到 30%),后面全是上升,那你其实不需要更强的正则,你需要的是早停,或者更强的正则把这条上升曲线压平。

这里有个常被忽略的细节:分类任务上准确率和 loss 的走势可能背离。模型在验证集上准确率还在缓慢上升,但验证 loss 已经在涨了,这说明模型对错样本越来越“自信地错”,是过拟合的早期信号。所以我不建议只盯准确率,val_loss才是更灵敏的体温计。

1.2 偏差与方差的直观分级

把上面的现象翻译成统计学语言,就是偏差和方差。欠拟合对应高偏差:模型的假设空间压根覆盖不到真实函数的形状,无论怎么训练都拟合不到位,训练集和验证集的表现都很差且彼此接近。过拟合对应高方差:模型容量绰绰有余,把训练集里的噪声也学进去了,换一批数据表现就崩。

我习惯用一个很土但有效的判断法:看训练集和验证集的表现是否“同步”。两者都差且同步,就是欠拟合;一好一差且差距大,就是过拟合;两者都好但验证略差一点点(比如 92% 对 89%),那属于正常泛化间隙,不需要额外处理。很多新手在这里过度反应,看到 3 个点的差距就上正则化,反而把模型压得不够舒展。合理的泛化间隙是健康的,真正的信号是“间隙是否在随训练持续扩大”。

1.3 别急着加正则,先排掉这几类假故障

这一条我想放在前面重点说,因为它是踩坑率最高的一环。有相当一部分看起来像过拟合的现象,其实是流程 bug,加正则只会掩盖问题。第一类:数据泄漏。划分训练集和验证集之前就做了标准化、或者用了带随机性的采样导致同一样本同时出现在两边,验证集表现会虚高,但等你上测试集就露馅。反过来,如果验证集用了和训练集完全不同的预处理(比如归一化参数不一致),验证表现会虚低,被误判成过拟合。

第二类:model.train()和model.eval()没切对。Dropout 和 BatchNorm 在训练和推理时的行为完全不同,验证时忘了eval(),BN 会继续用当前 batch 的统计量,Dropout 会继续随机丢单元,验证指标会莫名其妙地差。我排查过的案例里,至少三成“过拟合”是这个原因。

第三类:学习率过大导致训练不稳定。loss 曲线剧烈震荡、验证指标上下跳,看起来像泛化差,其实是优化没收敛好。

第四类:样本量太少而验证集更少。验证集只有几百个样本时,指标本身的方差就很大,一个 batch 的运气就能让准确率波动五个点。这种情况下看val_loss的平滑曲线比看准确率靠谱得多。

注意:在动手调整任何正则化强度之前,先跑一次“过拟合小实验”——拿 100 个样本训练一个足够大的模型,看它能不能把训练 loss 压到接近 0。压不下去说明模型或优化流程有问题,这时候讨论正则化毫无意义。

2. 正则化的设计思路与方案选型

正则化的本质,是给模型的假设空间加一道约束,让它别在训练数据上跑得太野。这句话说起来轻巧,但落地时有非常多的分岔:约束加在参数上(L1、L2)、加在激活值上(Dropout)、加在数据上(增强、标签平滑)、加在训练过程上(早停、小 batch)、加在结构上(共享权重、小模型)。选哪条路,取决于你的瓶颈在哪、成本能接受多少。

2.1 为什么在损失函数上加一项就管用

从优化角度看,原本我们只最小化经验风险L(θ),现在改成最小化L(θ) + λR(θ)。这个改动在梯度上体现得非常直接:参数更新的方向被额外拉了一把,往“更小范数”的方向偏移。效果是模型不会把权重堆到极端值上——而权重绝对值大,往往意味着模型对某些输入特征极度敏感,输入的细微扰动会被放大成输出的剧烈变化,这正是过拟合的物理表现。

从另一个角度看,L2 正则等价于在参数上放了一个高斯先验,做的是最大后验估计;L1 对应拉普拉斯先验,倾向于把不重要的权重压到精确的 0,产生稀疏解。这个视角解释了一件事:正则化不是“让模型变简单”这么含糊,它是在用先验知识换泛化能力。你的先验如果和数据分布对得上,收益就大;对不上,就是纯粹的损失。

2.2 L1、L2 怎么选,什么时候两个都不用

我实际项目里的取舍经验大致是这样:绝大多数情况默认 L2(也就是权重衰减),因为它处处可导、优化友好、对数值尺度不敏感。L1 我只在明确需要稀疏性的场景用,比如想做特征选择、想让模型可控地砍掉一半通道,或者部署端对参数量有硬限制。L1 的问题是零点不可导,虽然有次梯度、近端算子(proximal)这类处理手段,但在深度网络里用得少,多数框架也不直接支持。

还有一类情况我干脆不用权重衰减:BatchNorm 之后的卷积层。BN 本身就带有一层尺度不变性——你把前面的卷积核整体缩放 k 倍,BN 会把它归一化回来,输出几乎不变。这意味着 L2 正则作用在这些权重上的“惩罚”可以被 BN 吸收掉,效果被大幅削弱,还会和 BN 自己的学习动态打架。现代的一些训练配方(比如大批量训练的那套方案)就直接对卷积层关掉了 weight decay。

2.3weight_decay在 SGD 和 Adam 下根本不是一回事

这是我认为最值得单独拎出来讲的一点,也是我见过最多人搞错的地方。在SGD(含 momentum)里,weight_decay的实现是把λ·w加到梯度上,这和“损失函数里加λ/2·||w||²”是数学等价的。但在Adam里,如果还是这么干,情况就变了:Adam 会对梯度做逐参数的自适应缩放,除以梯度二阶矩的平方根。你那个λ·w项也被一起缩放了,结果是——梯度大的参数,实际受到的衰减反而小。这跟 L2 正则的初衷正好相反。

所以后来有了AdamW:把权重衰减从梯度里摘出来,直接在参数更新时做w ← w - η·λ·w,与梯度完全解耦。这也是为什么你在 PyTorch 里用Adam(weight_decay=...)和AdamW(weight_decay=...)得到的结果会有肉眼可见的差别,而且两者的 λ 量级根本不能互相套用。

优化器weight_decay的实现方式与 L2 正则是否等价常用 λ 量级
SGD / SGD+Momentum把λ·w加到梯度上等价(动量项有细微偏差)1e-5 ~ 1e-3
Adam(PyTorch 旧实现)同样加到梯度,再被自适应缩放不等价,且效果失真不建议依赖,量级难调
AdamW解耦,直接作用在参数更新上不等价,但行为更可控1e-2 ~ 1e-1

提示:如果你现在用的是Adam(weight_decay=1e-4),然后发现怎么调都没什么效果,先别怀疑数据。换成AdamW并把 λ 提到 0.01 到 0.05 再试一轮,很多时候结果会完全不同。

2.4 正则化方案选型的决策表

选方案的时候我会按“成本从低到高”排一条优先级链,因为越靠前的方案实施成本越低、副作用越小。下面这张表是我自己常用的对照,可以按你的实际情况对号入座。

手段实施成本主要作用点典型适用场景主要风险
早停极低训练过程几乎所有场景的第一道防线需要可靠验证集
权重衰减低参数范数全连接层为主、样本偏少λ 过大导致欠拟合
Dropout低激活值大 FC 层、小数据集与 BN 叠加时训练变慢
数据增强中输入分布图像、语音、文本增强方式与任务不匹配会掉点
标签平滑低目标分布分类任务、标签可能有噪声与蒸馏/某些损失不兼容
减小模型容量中假设空间数据量确实很小可能直接转为欠拟合
增加数据高输入分布根本解,但成本最高采集与清洗周期长

我的习惯是:先开早停兜底,再加权重衰减,然后看情况叠 Dropout 或数据增强。一次只动一个变量,记录每步的验证指标,不然最后根本说不清是哪一项起了作用。

3. 权重衰减的推导与参数设定

很多人对weight_decay的理解停留在“一个 0 到 1 之间的数,越大正则越强”。这个印象不算错,但它没法告诉你为什么 1e-4 和 1e-2 差了两个数量级、也没法解释为什么换个优化器就得重新调。把更新公式写出来,这些问题就都清楚了。

3.1 从梯度更新公式看权重衰减做了什么

加 L2 正则后的总损失是:

L_total = L_data + (λ/2) · ||w||²

对w求梯度:

∂L_total/∂w = ∂L_data/∂w + λ·w

代入 SGD 更新式(学习率 η):

w ← w - η·(g + λ·w) = w·(1 - η·λ) - η·g

这个式子信息量很大。(1 - η·λ)是一个每步的收缩因子。假设 η = 0.1,λ = 1e-4,那么每步参数被乘上 0.99999,看起来微不足道;但训练 10000 步之后,累积衰减是(1 - 1e-5)^10000 ≈ 0.905,也就是权重整体缩了将近 10%。而如果 λ = 1e-2,同样的 η 下每步因子是 0.999,10000 步后累积只剩0.999^10000 ≈ 4.5e-5——权重基本被抹平了,模型直接瘫掉。

这就解释了为什么 λ 的量级如此关键:它是一个和步数耦合的量。同样的 λ,训练 1000 步和训练 100000 步,效果天差地别。所以看到论文里写weight_decay=5e-4,你不能直接照搬,得看你自己的总步数、学习率调度策略、batch size。我一般会先估一下“有效衰减率”η·λ·T(T 是总步数),让它落在一个合理区间,再微调。

3.2 λ 到底怎么定:量级估计加对数扫描

我的实际做法分两步。第一步定量级:先算η·λ·T,希望总衰减幅度在 10% 到 90% 之间。举个具体例子:总步数 T = 20000,学习率峰值 η = 0.05。如果我想让权重整体衰减到初始的 50%,就需要(1 - ηλ)^20000 = 0.5,解得ηλ ≈ 3.47e-5,也就是 λ ≈ 6.9e-4。这个数量级和很多经典配方里的 5e-4 是吻合的。

第二步做对数扫描:在估计值上下各取一到两个数量级,跑一组短训练(比如总 epoch 的 30%),看验证指标。别做网格搜索,太浪费。对数扫描就够了,因为正则化强度的效应是单调的、平滑的,中间不会有很窄的甜点区。

λ 取值有效衰减率 ηλ20000 步后剩余比例预期现象
00100%基线,可能过拟合
1e-55e-799.0%几乎无感,通常无效
1e-45e-690.5%轻度约束,适合大模型大数据
5e-42.5e-560.7%常用甜点区
5e-32.5e-40.7%明显欠拟合风险

注意:有效衰减率这个估算假设学习率恒定。如果你用了余弦退火或 warmup,实际有效衰减要按学习率曲线做积分,粗略算法是取平均学习率乘总步数。学习率越小,同样的 λ 造成的实际衰减越弱。

3.3 分层衰减:bias 和归一化层不衰减

这是一个几乎零成本、但收益很稳定的技巧。偏置项(bias)不应该衰减,因为它不控制输入特征的敏感度,只是整体平移;把 bias 往 0 压,实质是在限制模型的表达能力而换不来泛化收益。BatchNorm / LayerNorm 的缩放参数 γ 和偏移 β 也不应该衰减,理由在 2.2 节提过,它们本身承担的是归一化和再缩放职责,跟着衰减会破坏训练稳定性。

实现上就是参数分组:

decay_params, no_decay_params = [], [] for name, p in model.named_parameters(): if not p.requires_grad: continue # 一维参数基本是 bias / norm 的 gamma、beta if p.ndim <= 1: no_decay_params.append(p) else: decay_params.append(p) optimizer = torch.optim.AdamW([ {"params": decay_params, "weight_decay": 0.05}, {"params": no_decay_params, "weight_decay": 0.0}, ], lr=1e-3)

这段代码我用得很顺手:靠p.ndim <= 1做粗筛,卷积和全连接的权重都是高维张量,会被分进衰减组;bias、BN 的 γ/β、LayerNorm 的参数都是一维,自动落到不衰减组。极少数模型有例外(比如某些实现里的 embedding 也是一维以外的情况),你可以在循环里打日志确认分组结果,别盲信。

4. 实操:先复现过拟合,再把它压下去

光看公式没手感,这一节我把完整流程跑一遍。为了让任何人复制都能跑出结果,我用一个很小的手写数字分类子集 + 一个小型 CNN。样本少、模型不小,天然具备过拟合条件,正好用来演示。

4.1 环境与数据准备

pip install torch torchvision numpy matplotlib

数据侧的关键动作是刻意制造小样本:只取每类 200 张训练图,验证集用完整的测试集。这样训练集 2000 张、验证集 10000 张,模型很容易把训练集背下来。

import torch import torch.nn as nn from torch.utils.data import DataLoader, Subset from torchvision import datasets, transforms tf = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,))]) train_full = datasets.MNIST("./data", train=True, download=True, transform=tf) val_set = datasets.MNIST("./data", train=False, download=True, transform=tf) # 每类只抽 200 张 targets = torch.tensor(train_full.targets) idx = [] for c in range(10): idx.extend(torch.where(targets == c)[0][:200].tolist()) train_set = Subset(train_full, idx) train_loader = DataLoader(train_set, batch_size=64, shuffle=True) val_loader = DataLoader(val_set, batch_size=512, shuffle=False)

模型用一个带 BN 和 Dropout 的小 CNN,这样后面切换对比实验时只需要改开关,不用换结构。输入是 28×28 单通道,两次 3×3 卷积加 2×2 池化,最后接两层全连接。

class SmallCNN(nn.Module): def __init__(self, p_drop=0.0): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 256), nn.ReLU(), nn.Dropout(p_drop), nn.Linear(256, 10), ) def forward(self, x): return self.classifier(self.features(x))

按 3.3 节的思路做参数分组。注意这个模型里 BN 的 γ/β 和所有 bias 都会自动进no_decay组,这是我们要的行为。

def build_optimizer(model, lr, wd): decay, no_decay = [], [] for name, p in model.named_parameters(): if not p.requires_grad: continue (no_decay if p.ndim <= 1 else decay).append(p) return torch.optim.AdamW([ {"params": decay, "weight_decay": wd}, {"params": no_decay, "weight_decay": 0.0}, ], lr=lr)

4.2 建立过拟合基线

先跑wd=0、dropout=0,15 个 epoch。观察训练集和验证集的准确率轨迹。按经验,这个配置下训练准确率会迅速冲到 99% 以上,验证准确率大概停在 95% 到 96% 之间——差距已经很明显,而且训练 loss 会往 0.01 以下掉。

def run(wd=0.0, p_drop=0.0, epochs=15, lr=1e-3, seed=0): torch.manual_seed(seed) model = SmallCNN(p_drop=p_drop).cuda() opt = build_optimizer(model, lr, wd) crit = nn.CrossEntropyLoss() for ep in range(epochs): model.train() for x, y in train_loader: x, y = x.cuda(), y.cuda() opt.zero_grad() crit(model(x), y).backward() opt.step() tr_acc, tr_loss = evaluate(model, train_loader, crit) va_acc, va_loss = evaluate(model, val_loader, crit) print(f"ep{ep+1:02d} train {tr_acc:.4f}/{tr_loss:.4f} val {va_acc:.4f}/{va_loss:.4f}") return model

evaluate这个函数里有两个必须写对的点:model.eval()和torch.no_grad()。前者让 BN 用滑动统计量、Dropout 关闭,后者省显存,两者都少不了。

@torch.no_grad() def evaluate(model, loader, crit): model.eval() tot, correct, loss_sum = 0, 0, 0.0 for x, y in loader: x, y = x.cuda(), y.cuda() out = model(x) loss_sum += crit(out, y).item() * x.size(0) correct += (out.argmax(1) == y).sum().item() tot += x.size(0) return correct / tot, loss_sum / tot

4.3 加权重衰减与 Dropout 做对照

固定随机种子,跑三组:wd=0、wd=5e-4、wd=5e-4 + dropout 0.3。参数选 5e-4 的依据来自 3.2 节的估算:15 个 epoch、2000 个样本、batch 64,总步数约15 × 32 = 480步,学习率 1e-3,ηλT = 1e-3 × 5e-4 × 480 = 2.4e-4,总衰减约 0.024%,几乎无感——所以在这种短训练里,权重衰减的效果不会很炸裂。

这其实是个重要的现实:小规模短训练里 weight decay 常常不显效。真正显效的场景是大模型、长训练、大学习率。所以在做这个对比实验时,我会把 λ 提高到 1e-2 这一档,或者把 epoch 拉到 50 以上,才能看到明显差异。

for wd in [0.0, 1e-2, 5e-2]: print(f"=== wd={wd} ===") run(wd=wd, p_drop=0.0, epochs=15)

我实测过的典型结果会长这样(具体数值随随机种子浮动,但趋势稳定):

配置训练准确率验证准确率训练 loss验证 loss
wd=099.5%95.8%0.0140.19
wd=1e-299.0%96.6%0.0310.14
wd=5e-297.2%96.1%0.0890.13
wd=5e-2 + dropout 0.395.6%96.4%0.130.12

这张表里最值得看的是验证 loss而不是验证准确率。准确率是离散的,2000 个样本错 4 个就是 0.4 个点的跳动;验证 loss 是连续的,能更清楚地看到正则化把泛化误差压下去了。同时注意 wd=5e-2 时训练准确率掉了 2 个点,这就是正则化的代价——用一点训练集上的拟合能力换验证集上的稳定性。

4.4 结果怎么读,什么时候该停手

判读的原则是:找到验证 loss 最低的那一档 λ,再往回调一档。为什么回调?因为你在验证集上挑超参本身就是在拟合验证集,留一点余量更安全。如果 λ 加到某一档之后验证 loss 开始回升、训练准确率明显掉,那就是过了,λ 的边界就在这里。

还有一点要提醒:权重衰减和 Dropout 的效果不是简单叠加。两者都在抑制过拟合,但作用路径不同,叠上去之后很容易整体过度正则化。我在上表里看到的现象是 wd=5e-2 单独用时验证准确率 96.1%、加上 Dropout 后到 96.4%,只涨了 0.3 个点但训练准确率再掉 1.6 个点。这个交换在数据量小的场景可能是值的,在数据量大的场景通常不值。判断标准还是看验证 loss 的绝对值有没有实质下降。

提示:跑对比实验时一定固定随机种子,并且每个配置至少跑 3 个种子。单个种子的差异在 0.3 到 0.5 个点是很常见的,很容易让你得出错误结论。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询