从SGD到AdamW:深度学习优化器选型与PyTorch实战指南
训练模型这些年被问得最多的问题之一就是优化器到底该选哪个。新手往往上来就AdamW一把梭老手有时候反而退回去用SGDmomentum中间还夹着一堆RMSprop、Adagrad、Adam的历史包袱。这个问题看似简单实际上牵扯到梯度更新的数学本质、不同任务对收敛行为的偏好、以及正则化和泛化之间那点微妙的平衡。我打算把从SGD到AdamW这条演化线索完整捋一遍每个优化器为什么被发明出来、解决了前一代的什么痛点、又引入了什么新问题最后落到PyTorch里怎么用、参数怎么调、什么场景选什么。不管你是刚接触深度学习不久、还在被loss曲线折磨的新手还是已经调过不少模型、想搞清楚背后逻辑的从业者这篇应该都能让你有点收获。1. 为什么优化器值得单独拎出来讲很多人觉得优化器就是个torch.optim里的一个类换个名字而已loss不降就换一个试试。这种盲试的做法在小模型上还能凑合一旦模型规模上去、训练成本变高盲目换优化器浪费的时间和算力是相当可观的。理解每个优化器的更新规则你才能预判它在你的任务上大概会表现成什么样。1.1 优化器到底在做什么把训练过程想象成下山。损失函数是一片崎岖的地形参数是你当前站的位置目标是走到山谷最低点。梯度告诉你脚下哪个方向最陡优化器则决定你每一步迈多大、往哪个方向迈、要不要借助之前的惯性。数学上几乎所有优化器都在做同一件事给定当前参数 $\theta_t$ 和梯度 $g_t \nabla_\theta L(\theta_t)$计算一个更新量 $\Delta\theta_t$然后 $\theta_{t1} \theta_t - \Delta\theta_t$。区别全在 $\Delta\theta_t$ 怎么算。SGD直接用梯度动量法把历史梯度累积起来自适应方法则给每个参数维度单独缩放学习率。这个怎么算的差异在深层网络、稀疏梯度、不同参数尺度这些场景下会被急剧放大。1.2 一个容易被忽略的事实优化器不是孤立起作用的我见过不少人把优化器当成万能旋钮loss不降就换优化器结果换了一圈发现还是不行。实际上优化器的表现高度依赖几个配套因素学习率调度策略、batch size、权重初始化、网络结构里的归一化层。同一个Adam配上不同的weight decay实现方式泛化表现能差出一截——这也是后来AdamW被提出来的直接原因。所以后面讲每个优化器的时候我会尽量把它和配套的超参、使用场景绑在一起说而不是孤立地列公式。单纯背更新公式意义不大知道什么时候它会出问题才是关键。2. 从最朴素的SGD说起SGDStochastic Gradient Descent是所有优化器的老祖宗理解它是理解后面一切的前提。它的更新规则简单到不能再简单用当前batch算出的梯度乘上学习率直接减掉。2.1 原始SGD的更新逻辑与它的两个硬伤公式写出来就一行$$\theta_{t1} \theta_t - \eta \cdot g_t$$其中 $\eta$ 是学习率$g_t$ 是当前batch的梯度。PyTorch里对应torch.optim.SGD(model.parameters(), lr0.01)不带动量的话就是这样。它的问题很直观。第一对学习率极其敏感。学习率大一点loss震荡甚至发散小一点收敛慢得让人怀疑人生。第二在损失曲面呈狭长山谷形状时这在深层网络里非常常见梯度方向会在山谷两壁之间来回横跳前进方向上的分量很小导致收敛极慢。你可以理解为下山时山谷很窄你每一步都被两侧的坡推来推去实际往谷底走的速度很慢。2.2 动量给梯度加上惯性动量Momentum的思路非常符合直觉既然来回横跳浪费步数那就把历史梯度累积起来让方向一致的维度越走越快方向反复变化的维度相互抵消。更新规则变成$$v_t \mu \cdot v_{t-1} g_t$$ $$\theta_{t1} \theta_t - \eta \cdot v_t$$$\mu$ 是动量系数PyTorch里默认0.9。物理类比就是一个有质量的小球滚下山不会因为一个小坑就立刻改变方向惯性帮它冲过去。实践中最直接的感受是loss曲线更平滑了收敛速度明显快于纯SGD而且对学习率没那么敏感了。PyTorch里就是加一个参数optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9)提示动量系数0.9是个很稳的默认值但如果你发现训练早期loss下降太慢可以试试0.5起步等稳定后再调回0.9。反过来如果loss出现周期性震荡适当降低动量系数往往比降学习率更有效。2.3 Nesterov动量往前多看一步Nesterov动量NAG是对普通动量的一个精巧改进。普通动量是先算当前梯度再和惯性叠加Nesterov的想法是既然我知道惯性会把我往某个方向推那我干脆先按惯性走一步在那个预判位置上算梯度。这样在接近谷底时预判位置已经越过谷底了梯度会指回来相当于提前刹车减少过冲。PyTorch里通过nesterovTrue开启但注意它要求momentum必须大于0optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, nesterovTrue)实测下来Nesterov在凸问题上理论收敛率更好在深度网络里表现通常略优于普通动量但差距不算大。我的经验是如果你已经在用动量SGD且训练稳定加上Nesterov几乎没成本值得开如果训练本身就不太稳先把学习率和动量调好再说。2.4 SGD在什么场景下依然是首选这里要说一个反直觉的结论在图像分类这类任务上精心调参的SGDmomentum泛化表现经常优于Adam系。这不是玄学背后有解释——SGD的噪声特性每个batch梯度估计的随机性被认为有助于找到更平坦的极小值而平坦极小值通常泛化更好。Adam因为自适应缩放早期收敛快但容易收敛到尖锐的极小值。所以如果你在做CV任务、有充足的调参预算、追求最终的泛化精度SGDmomentum学习率衰减比如cosine schedule依然是很多经典方案的选择。代价是调参确实更费劲学习率的搜索范围比较窄。3. 自适应学习率的登场AdaGrad与RMSpropSGD系的所有变体本质上还是所有参数共用一个学习率或者一个全局调度。但深层网络里不同层的参数梯度尺度差异巨大有的层梯度一直很大有的层梯度很小共用一个学习率显然不合理。自适应方法的核心思想就是给每个参数维度单独维护一个学习率。3.1 AdaGrad梯度大的维度自动减速AdaGrad的做法是累积每个维度历史梯度的平方和然后用它来缩放学习率$$G_t G_{t-1} g_t^2$$ $$\theta_{t1} \theta_t - \frac{\eta}{\sqrt{G_t \epsilon}} \cdot g_t$$直觉上很好理解某个维度历史上梯度一直很大$G_t$就大分母大这个维度的有效学习率就小梯度一直很小的维度有效学习率就大。这天然适合稀疏梯度场景比如NLP里词嵌入层低频词的梯度很稀疏AdaGrad能给它们更大的更新步长。但它有个致命问题$G_t$是单调递增的随着训练进行分母越来越大有效学习率单调衰减到接近零训练后期几乎停滞。这个学习率只减不增的特性让AdaGrad在长训练任务里表现很差。optimizer torch.optim.Adagrad(model.parameters(), lr0.01)3.2 RMSprop用滑动平均修掉AdaGrad的停滞问题RMSprop的思路很直接既然AdaGrad的问题是历史梯度平方无限累积那就别全累积改用指数滑动平均让久远的历史逐渐被遗忘$$E[g^2]t \beta \cdot E[g^2]{t-1} (1-\beta) \cdot g_t^2$$ $$\theta_{t1} \theta_t - \frac{\eta}{\sqrt{E[g^2]_t \epsilon}} \cdot g_t$$$\beta$ 通常取0.9。这样有效学习率不会单调衰减到零能适应非平稳的训练过程。RMSprop在RNN类模型上曾经是标配因为它能缓解循环网络里梯度尺度剧烈变化的问题。optimizer torch.optim.RMSprop(model.parameters(), lr0.01, alpha0.99)注意PyTorch的RMSprop里alpha就是上面公式的$\beta$默认0.99和原始论文的0.9略有出入。这个默认值差异在实际使用中影响不小如果你从别的框架迁移过来记得核对一下。RMSprop的问题在于它没有动量机制更新方向完全由当前缩放后的梯度决定在损失曲面复杂时方向可能不够稳定。这就引出了下一步——把动量和自适应结合起来。4. Adam动量与自适应的合体以及它的隐患AdamAdaptive Moment Estimation基本上就是把动量一阶矩和RMSprop二阶矩合到一起同时维护梯度的一阶矩估计和二阶矩估计并做了偏差修正。它在2015年前后提出后迅速成为默认选择因为开箱即用的体验太好了——大多数任务上不调参就能跑出不错的结果。4.1 Adam的完整更新规则拆解Adam维护两个量$$m_t \beta_1 m_{t-1} (1-\beta_1) g_t \quad \text{一阶矩类似动量}$$ $$v_t \beta_2 v_{t-1} (1-\beta_2) g_t^2 \quad \text{二阶矩类似RMSprop}$$因为$m_t$和$v_t$初始化为0训练初期它们会偏向0所以要做偏差修正$$\hat{m}_t \frac{m_t}{1-\beta_1^t}, \quad \hat{v}_t \frac{v_t}{1-\beta_2^t}$$最终更新$$\theta_{t1} \theta_t - \eta \cdot \frac{\hat{m}_t}{\sqrt{\hat{v}_t} \epsilon}$$默认超参$\beta_10.9$$\beta_20.999$$\epsilon10^{-8}$。偏差修正是Adam早期收敛快的关键——没有它前几步的更新会异常小。optimizer torch.optim.Adam(model.parameters(), lr1e-3, betas(0.9, 0.999), eps1e-8)4.2 为什么Adam的weight decay一直有问题这是理解AdamW的关键。在SGD里L2正则化和weight decay是等价的在梯度里加上$\lambda\theta$等价于每次更新时把参数往零缩一点。但在Adam里这个等价关系被打破了。原因在于Adam会对梯度做自适应缩放。如果你把weight decay写进梯度即L2正则那么这个正则项也会被$\sqrt{\hat{v}_t}$缩放。结果是梯度大的参数其weight decay效果被削弱梯度小的参数weight decay效果被放大。这导致正则化的力度和参数的梯度尺度耦合在一起完全失控。换句话说你以为设了weight_decay0.01就是在做0.01强度的正则实际上每个参数受到的正则强度都不一样。这就是为什么早期用Adam时weight decay经常看起来设了但没起作用或者设了反而掉点。4.3 Adam的泛化 gap一个被反复讨论的现象实践中还有一个常被提及的现象Adam在训练集上收敛快、loss低但验证集表现有时不如调好的SGD。这个泛化gap在很多任务上被观察到。可能的解释包括前面提到的尖锐极小值问题以及自适应学习率在训练后期仍然保持较大步长、导致在极小值附近震荡。应对手段通常是用学习率衰减后期把学习率降下来让它稳定收敛、或者用warmup避免早期不稳定。这些在Transformer类模型训练里几乎是标配。5. AdamW把weight decay从梯度里解耦出来AdamW的核心改动就一句话weight decay不再加进梯度而是直接作用在参数上和自适应梯度更新分开。这个改动看似微小实际效果在大量任务上相当明显。5.1 解耦weight decay的具体做法对比一下两者的更新。Adam带L2是先把$\lambda\theta$加进梯度再走自适应流程AdamW则是先做纯粹的自适应更新然后单独对参数做衰减$$\theta_{t1} \theta_t - \eta \cdot \frac{\hat{m}_t}{\sqrt{\hat{v}_t} \epsilon} - \eta \cdot \lambda \cdot \theta_t$$这样weight decay的强度$\lambda$就是实打实的、对所有参数一致的不再被二阶矩缩放。PyTorch里用AdamWoptimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay0.01)提示AdamW的weight_decay默认值是0.01而Adam的默认是0。这个差异经常让从Adam迁移过来的人困惑——如果你直接换类但没注意weight_decay行为会变。迁移时建议显式写出weight_decay别依赖默认值。5.2 实际迁移中的参数对照从Adam换到AdamW除了类名最需要关注的是weight_decay的取值。因为解耦后正则强度更实同样的数值在AdamW里效果通常比在Adam的L2里更强。经验上Transformer类模型常用0.01到0.1之间CNN任务上0.01起步比较稳。参数Adam默认AdamW默认迁移建议lr1e-31e-3保持一致或略降betas(0.9,0.999)(0.9,0.999)一般不动eps1e-81e-8一般不动weight_decay00.01显式设置从0.01试起我自己的习惯是新项目直接上AdamWweight_decay从0.01开始如果发现欠拟合就降到0.001过拟合就往上加。比在Adam里调L2系数直观得多因为数值和实际正则强度是对应的。5.3 为什么现在新项目基本默认AdamW原因很实际它把Adam最好用的部分自适应、开箱即用保留了同时修掉了weight decay这个长期存在的坑。在Transformer、大语言模型、扩散模型这些当前主流方向上AdamW几乎是默认配置。你去看主流开源模型的训练脚本优化器那一行基本都是AdamW。不过这不意味着AdamW在所有场景都最优。在小数据集、需要强正则的任务上SGDmomentum配合精细的学习率调度仍然可能给出更好的泛化。选型这事没有银弹得看任务。6. PyTorch实操一套可复用的对比实验框架光看公式容易飘实际跑一遍对比才有体感。我搭一个最小可复现的框架在同一模型、同一数据、同一初始化下对比几个优化器观察loss曲线和最终精度。这样你能直观看到差异而不是只记结论。6.1 统一的实验脚手架关键是控制变量模型结构、初始化种子、数据划分、batch顺序都要一致只变优化器。下面是一个可复用的骨架import torch import torch.nn as nn import copy def build_model(): return nn.Sequential( nn.Linear(784, 256), nn.ReLU(), nn.Linear(256, 128), nn.ReLU(), nn.Linear(128, 10) ) def make_optimizer(name, model): if name sgd: return torch.optim.SGD(model.parameters(), lr0.01, momentum0.9) elif name sgd_nesterov: return torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, nesterovTrue) elif name adagrad: return torch.optim.Adagrad(model.parameters(), lr0.01) elif name rmsprop: return torch.optim.RMSprop(model.parameters(), lr0.001) elif name adam: return torch.optim.Adam(model.parameters(), lr0.001) elif name adamw: return torch.optim.AdamW(model.parameters(), lr0.001, weight_decay0.01) else: raise ValueError(name) def run_one(name, train_loader, val_loader, epochs10, seed42): torch.manual_seed(seed) model build_model() init_state copy.deepcopy(model.state_dict()) opt make_optimizer(name, model) criterion nn.CrossEntropyLoss() history {train_loss: [], val_acc: []} for ep in range(epochs): model.train() total_loss 0.0 for x, y in train_loader: x x.view(x.size(0), -1) opt.zero_grad() loss criterion(model(x), y) loss.backward() opt.step() total_loss loss.item() * x.size(0) history[train_loss].append(total_loss / len(train_loader.dataset)) model.eval() correct 0 with torch.no_grad(): for x, y in val_loader: x x.view(x.size(0), -1) pred model(x).argmax(dim1) correct (pred y).sum().item() history[val_acc].append(correct / len(val_loader.dataset)) return history这个骨架里有个细节值得说torch.manual_seed(seed)必须在建模型之前调用而且每个优化器跑之前都要重新设种子、重新加载初始权重。否则不同优化器拿到的初始点不一样对比就没意义了。我见过有人忘了重置种子结果把初始化差异误当成优化器差异结论完全跑偏。6.2 结果解读别只看最终精度跑完你会得到每个优化器的loss曲线和验证精度曲线。解读时有几个要点第一看早期收敛速度。Adam系通常前几个epoch loss掉得最快SGD系慢一些但曲线更平滑。这符合预期因为自适应方法在初期步长更激进。第二看后期稳定性。SGD系后期loss曲线往往更平稳Adam系可能出现小幅震荡尤其是学习率没衰减的时候。这也是为什么Adam常配学习率衰减。第三看验证精度和训练loss的gap。如果某个优化器训练loss很低但验证精度上不去说明过拟合或泛化差这时候weight decay的设置就值得检查——AdamW和Adam在这个指标上的差异往往能看出来。注意单次实验的随机性很大尤其是小数据集。要下结论至少跑3到5个不同种子取平均否则你看到的差异可能只是噪声。这一点在对比优化器时特别重要因为不同优化器对初始化的敏感度本身就不一样。6.3 学习率这个变量怎么控制对比优化器时最容易犯的错是所有优化器用同一个学习率。这其实不公平因为不同优化器的合适学习率区间差别很大。SGD常用0.01到0.1Adam常用1e-3到1e-4直接拉平对比等于给某些优化器判了死刑。更严谨的做法是给每个优化器做一个小范围的学习率扫描比如各试3个值取各自最好的结果再对比。虽然费点时间但结论可靠得多。如果嫌麻烦至少要在报告结果时注明学习率未针对各优化器单独调优让读者知道这个对比的局限。7. 选型决策什么任务配什么优化器讲完原理和实验落到最实际的问题拿到一个新任务优化器怎么选。我给一个基于经验的决策思路不是硬规则但能帮你少走弯路。7.1 按任务类型和模型规模的快速判断场景推荐起点理由Transformer/大模型AdamW自适应解耦正则社区默认CNN图像分类追求精度SGDmomentumNesterov泛化通常更好配合cosine调度CNN图像分类快速原型AdamW少调参就能跑起来RNN/序列模型AdamW或RMSprop梯度尺度变化大自适应更稳稀疏特征推荐、NLP嵌入AdaGrad系或Adam自适应对稀疏梯度友好小数据集强正则需求SGDmomentum配合weight decay控制过拟合微调预训练模型AdamW小学习率避免破坏预训练权重这张表是起点不是终点。实际选型还要看你的调参预算预算充足就往SGD方向靠追求泛化上限预算紧张就用AdamW快速拿到可用结果。7.2 学习率调度和优化器的搭配关系优化器选完调度策略得跟上。几个常见组合SGDmomentum配cosine annealing或step decaywarmup可选。SGD对调度比较敏感好的调度能显著提升最终精度。AdamW配linear warmupcosine decay是Transformer标配CNN任务上简单的step decay也够用。warmup对Adam系尤其重要能避免早期二阶矩估计不准导致的更新异常。RMSprop配简单的指数衰减即可它本身对调度不那么敏感。一个常被忽略的点AdamW的weight_decay在配合学习率调度时衰减强度是跟着学习率走的因为更新式里$\eta$和$\lambda$相乘。所以学习率降下来后正则强度也会降。如果你希望正则强度恒定得用别的方式实现但对大多数任务来说这个联动是可以接受的。7.3 几个我踩过的坑第一个坑Adam的eps在混合精度训练下要调大。默认1e-8在fp16下可能因为数值下溢出问题通常调到1e-6甚至1e-4。这个在AMP自动混合精度训练里特别常见loss突然变NaN往往就是它。第二个坑weight_decay不要加在bias和归一化层参数上。这是很多论文和开源代码的惯例做法——只对权重矩阵做衰减bias和LayerNorm/BatchNorm的参数不衰减。实现上就是把参数分组给不同的weight_decay。这个细节对最终精度有可感知的影响尤其是大模型。decay, no_decay [], [] for n, p in model.named_parameters(): if p.ndim 2 or bias in n or norm in n.lower(): no_decay.append(p) else: decay.append(p) optimizer torch.optim.AdamW([ {params: decay, weight_decay: 0.01}, {params: no_decay, weight_decay: 0.0}, ], lr1e-3)第三个坑换优化器时忘了重置学习率调度器。调度器的step计数和优化器状态是绑定的中途换优化器如果不重建调度器学习率会从一个奇怪的位置继续训练直接崩。换优化器就老老实实从头配一套。8. 一些不那么显然的补充最后补几个容易被忽略但实际有用的点算是这些年攒下来的零碎经验。关于二阶优化器像L-BFGS、K-FAC这些理论上收敛更快但在深度学习里因为参数量太大、Hessian近似成本高实际很少用于大规模训练。小规模问题上可以试试但工程价值有限这里不展开。关于优化器状态的内存开销Adam/AdamW每个参数要额外存一阶矩和二阶矩内存占用是参数量的2倍fp32下。大模型训练时这部分开销很可观这也是为什么有AdaFactor这类省内存的变体——它用低秩分解近似二阶矩把内存降到接近SGD的水平代价是精度略降。如果你显存吃紧值得了解一下。关于优化器的可复现性PyTorch的优化器状态动量、二阶矩是可以保存和加载的做断点续训时一定要把optimizer.state_dict()一起存。只存模型权重不存优化器状态续训后loss曲线会出现明显跳变因为动量和自适应统计量都丢了相当于重新开始累积。关于学习率finder如果你不确定学习率从哪开始可以跑一个LR range test——从很小的学习率逐步增大记录loss找loss下降最快又没发散的那个区间。这个方法对SGD和Adam都适用能省掉不少盲试的时间。不过要注意finder给出的建议值偏激进实际用的时候往往要取比它小一点的值。这些东西单看都是小点但组合起来就是为什么别人调得快、你调得慢的差距所在。优化器本身不难难的是把它和整个训练流程的其它环节配合好。我个人的体会是先把一个优化器推荐AdamW用熟、把配套的调度和正则调明白比频繁换优化器有效得多。等你对某个任务的收敛行为有了手感再去尝试SGD系追求泛化上限那时候你才知道该往哪个方向调。