资讯动态

过拟合与欠拟合诊断:权重衰减、AdamW与正则化调参实战

发布时间:2026/10/27 5:46:42 来源:尧图企业网站定制
模型在训练集上跑到 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 / SGDMomentum把λ·w加到梯度上等价动量项有细微偏差1e-5 ~ 1e-3AdamPyTorch 旧实现同样加到梯度再被自适应缩放不等价且效果失真不建议依赖量级难调AdamW解耦直接作用在参数更新上不等价但行为更可控1e-2 ~ 1e-1提示如果你现在用的是Adam(weight_decay1e-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.99910000 步后累积只剩0.999^10000 ≈ 4.5e-5——权重基本被抹平了模型直接瘫掉。这就解释了为什么 λ 的量级如此关键它是一个和步数耦合的量。同样的 λ训练 1000 步和训练 100000 步效果天差地别。所以看到论文里写weight_decay5e-4你不能直接照搬得看你自己的总步数、学习率调度策略、batch size。我一般会先估一下“有效衰减率”η·λ·TT 是总步数让它落在一个合理区间再微调。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}, ], lr1e-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, trainTrue, downloadTrue, transformtf) val_set datasets.MNIST(./data, trainFalse, downloadTrue, transformtf) # 每类只抽 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_size64, shuffleTrue) val_loader DataLoader(val_set, batch_size512, shuffleFalse)模型用一个带 BN 和 Dropout 的小 CNN这样后面切换对比实验时只需要改开关不用换结构。输入是 28×28 单通道两次 3×3 卷积加 2×2 池化最后接两层全连接。class SmallCNN(nn.Module): def __init__(self, p_drop0.0): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), 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}, ], lrlr)4.2 建立过拟合基线先跑wd0、dropout015 个 epoch。观察训练集和验证集的准确率轨迹。按经验这个配置下训练准确率会迅速冲到 99% 以上验证准确率大概停在 95% 到 96% 之间——差距已经很明显而且训练 loss 会往 0.01 以下掉。def run(wd0.0, p_drop0.0, epochs15, lr1e-3, seed0): torch.manual_seed(seed) model SmallCNN(p_dropp_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(fep{ep1:02d} train {tr_acc:.4f}/{tr_loss:.4f} val {va_acc:.4f}/{va_loss:.4f}) return modelevaluate这个函数里有两个必须写对的点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 / tot4.3 加权重衰减与 Dropout 做对照固定随机种子跑三组wd0、wd5e-4、wd5e-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(wdwd, p_drop0.0, epochs15)我实测过的典型结果会长这样具体数值随随机种子浮动但趋势稳定配置训练准确率验证准确率训练 loss验证 losswd099.5%95.8%0.0140.19wd1e-299.0%96.6%0.0310.14wd5e-297.2%96.1%0.0890.13wd5e-2 dropout 0.395.6%96.4%0.130.12这张表里最值得看的是验证 loss而不是验证准确率。准确率是离散的2000 个样本错 4 个就是 0.4 个点的跳动验证 loss 是连续的能更清楚地看到正则化把泛化误差压下去了。同时注意 wd5e-2 时训练准确率掉了 2 个点这就是正则化的代价——用一点训练集上的拟合能力换验证集上的稳定性。4.4 结果怎么读什么时候该停手判读的原则是找到验证 loss 最低的那一档 λ再往回调一档。为什么回调因为你在验证集上挑超参本身就是在拟合验证集留一点余量更安全。如果 λ 加到某一档之后验证 loss 开始回升、训练准确率明显掉那就是过了λ 的边界就在这里。还有一点要提醒权重衰减和 Dropout 的效果不是简单叠加。两者都在抑制过拟合但作用路径不同叠上去之后很容易整体过度正则化。我在上表里看到的现象是 wd5e-2 单独用时验证准确率 96.1%、加上 Dropout 后到 96.4%只涨了 0.3 个点但训练准确率再掉 1.6 个点。这个交换在数据量小的场景可能是值的在数据量大的场景通常不值。判断标准还是看验证 loss 的绝对值有没有实质下降。提示跑对比实验时一定固定随机种子并且每个配置至少跑 3 个种子。单个种子的差异在 0.3 到 0.5 个点是很常见的很容易让你得出错误结论。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑