深度学习优化器算法巧思速览
要说深度学习里哪个环节最容易被新手当成“玄学”,优化器绝对排得上号。很多人辛辛苦苦把网络搭好、数据喂进去,结果loss曲线像心电图一样乱跳,或者从头到尾纹丝不动,第一反应就是改网络结构、调数据增强。但按我这些年实际调模型的经验来说,十次里有七八次,问题根源都在优化器上——要么选错了,要么参数不合适,要么压根没理解它在干什么。
这篇东西不想做成那种罗列一堆数学公式然后就不管了的科普,而是想把优化器算法背后的“巧思”拆开来讲。适合刚入门深度学习、想弄清楚Adam为什么比普通梯度下降好用的人;也适合已经跑过不少模型、但总觉得loss曲线的脾气拿不准、想系统排查一波的人。看完你应该能回答三个问题:优化器到底在解什么难题,主流优化器各自用什么思路解题,实际训练里到底该选谁、怎么调。
1. 先把一个核心问题问清楚:优化器到底在忙什么
1.1 网络结构决定“山长什么样”,优化器决定“怎么下山”
深度学习的训练过程可以简化成一个比喻:你要在连绵起伏的山脉里找到最低的那个山谷,网络结构决定了这座山的形状——也就是损失函数长什么样,而优化器就是你下山时迈步子的策略。
为什么同一个网络用不同优化器,训练结果能差出一个量级?因为梯度只在当前这一小片区域告诉了你最陡的下坡方向,它不知道这座山整体是什么样的。如果你每一步都严格沿着最陡方向冲,在这个地方看似聪明,放到全局就是典型的“局部最优解思维”,很容易在狭窄的沟壑里来回震荡。优化器要解决的本质问题,是如何利用有限的信息、在不确定的地形里走得又快又稳。
这个问题比看上去复杂得多。真实的高维损失面不是平滑的碗,而是充满了鞍点、平坦区域、陡峭峭壁和噪声。鞍点附近所有方向梯度都接近零,朴素梯度下降会被困在那里;陡峭的区域里梯度的模可能突然爆炸,一个步长就能把参数踢到天涯海角。优化器的历史,实际上就是在跟这三种地形搏斗。
1.2 选错优化器的典型症状
我在带新人时习惯先问一句:你觉得当前模型的loss不下降,是网络问题还是优化器问题?多数人下意识回答网络。这恰恰是误区。优化器选错以后的表现其实很有辨识度:
- 用固定学习率的SGD去训较深的CNN或Transformer,loss曲线会在一个平台附近来回抖动,怎么等都下不去。
- 在NLP任务上默认用Adam,但学习率设得很随意,结果训练前期loss骤降、后期验证集指标反而退步。
- 学习率设成0.1跑Transformer,没几步就loss变成NaN,训练直接崩掉。
- 换了模型结构以后,原来“好用”的优化器参数突然全部失灵。
这些都不是网络结构的锅,而是优化器的更新方式跟当前地形不匹配。我先给一个总览式的结论:没有哪个优化器在所有任务里都是最优解,但不同优化器背后的“巧思”是可以通用借鉴的——动量、自适应学习率、梯度估计修正,这些思想被组合出了无数种变体。下面按时间线把几代优化器的设计思路展开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 朴素梯度下降的困境与动量法的破局
2.1 为什么“每一步都走最陡方向”反而很慢
一开始大家用的都是朴素SGD(随机梯度下降):每轮迭代里,用当前batch算出的梯度g直接更新参数,也就是公式里的 w = w - lr * g。逻辑很简单:梯度指向loss上升最快的方向,那朝着反方向走就能下降。
但实际一跑就发现问题了。假设损失面是个倾斜的狭长山谷,长轴方向平缓、短轴方向陡峭。在这种地形下,梯度在短轴方向分量很大、在长轴方向分量很小。朴素SGD每一步几乎都在沿着短轴来回横跳,等于一边往山谷深处挪动、一边在做无谓的左右震荡,导致收敛速度肉眼可见的慢。这就是常说的zigzag现象。要是此时学习率调得偏大,震荡幅度甚至会越来越大,直接发散;调小了,长轴方向推进得又太慢,半天到不了最低点。
还有一个更隐蔽的问题:SGD每一步只参考当前这一个batch的梯度,而batch是随机采样的,所以梯度本身就带有噪声。噪声在极小值附近特别明显,参数会在真值周围颤抖,无法精确收敛。说到底,朴素SGD把高维地形里的每一步都当成孤立事件处理,丢掉了太多可用的信息。
2.2 动量法的巧思:让历史梯度变成“惯性”
动量法的改进思路特别像物理里的惯性。想象你推一个实心铁球下坡,如果每推一下都停下来重新定位,球会走得很慢;但如果你顺着球的运动方向持续推,即使某个时刻突然没推力了,球也会因为惯性继续往前滚。动量法做的就是这件事:把历史梯度的信息积攒成一个“速度项”v,更新时用它代替当前梯度。
公式是:
- v = mu * v_prev + g
- w = w - lr * v
这里mu通常取0.9,含义是保留之前速度的90%,再加上当前梯度作为加速度。这个设计的精妙之处在于:如果历史梯度和当前梯度方向一致(比如在长轴方向持续向下),速度会越来越大,相当于加速通过平缓区域;如果方向不一致(比如在短轴方向来回震荡),速度会相互抵消,震荡幅度自然被压住。
我实际用下来,动量法最直观的感受就是训练曲线变得顺滑了。原来每次batch带来的梯度起伏被历史速度“垫”了一下,不会因为局部噪声大幅跑偏。这也是为什么在CV任务上,SGD加动量(常写成SGD with Momentum)至今仍是很多经典网络的标配优化器——它够稳定、够通用,而且好调。
pytorch里的调用也很简单:
python复制optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
2.3 Nesterov动量:先往前方看一眼再迈步
动量法还有一个更聪明的改进版本,叫Nesterov动量(在PyTorch里对应SGD的nesterov=True参数)。它的巧思在于:既然v是累积下来的历史速度,那参数下一步本来就会移动到 w - lr * mu * v 附近,为什么不等参数先“虚拟地”移动到那个位置,再去算那个位置的梯度呢?
Nesterov的更新逻辑是:先根据已有速度往前探一步,在那个前瞻位置计算梯度,然后用这个前瞻梯度来修正速度方向。好处是它具备一定的“预判”能力,能在快要冲过头时提前减速,特别适合地形变化快、容易overshoot的情况。
在PyTorch里用起来就是:
python复制optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, nesterov=True)
我个人的经验是:Nesterov在收敛稳定性和最终精度上经常比普通动量稍好一点,代价几乎可以忽略不计。所以如果选了SGD路线,建议直接开nesterov=True。
3. 自适应学习率的演进:从AdaGrad到RMSProp再到Adam
3.1 一个关键痛点:不同参数需要的步长其实不一样
动量法解决了方向问题,但还有个问题没解决:不同参数的学习率是不是应该一样?理论上,如果某个参数的历史梯度一直很小,说明它在的空间比较平缓,应该给它大一点的步长快速推进;如果某个参数的历史梯度一直很大,说明它在地形陡峭的地方,步长太大容易震荡甚至爆炸,应该给它小一点的步长。
对每一个参数单独算一个合适的学习率,这就是自适应学习率方法的出发点。而它实现起来又有一个巧妙的思路:不需要直接去估计什么是最优学习率,只需要记录每个参数“历史梯度的模有多大”,然后让有效的学习率跟这个模呈反比就行。
3.2 AdaGrad与RMSProp:从“全部累加”到“滑动平均”
AdaGrad是最早提出的自适应学习率方法之一。它对每个参数维护一个历史梯度平方的累积量G,更新时把学习率除以sqrt(G):
- G = G + g^2
- w = w - lr * g / (sqrt(G) + eps)
好处是陡峭维度上的有效步长会自动变小,平缓维度上的步长相对变大,非常适合处理稀疏特征。但它的致命伤也很明显:G是单调递增的累积量,随着训练推进,分母越来越大,有效学习率会一路衰减到接近零。相当于训练到后半程时,参数已经基本不动了。这在非凸的深度学习损失面上就是灾难——你经常需要在训练中途还有大把调整空间,结果AdaGrad提前“锁死”了参数。
RMSProp的改进看起来小,实际却是决定性的:把“历史全部梯度平方之和”改成“历史梯度平方的指数滑动平均”:
- E = beta * E_prev + (1 - beta) * g^2
- w = w - lr * g / (sqrt(E) + eps)
这样历史信息会随时间逐渐“遗忘”,最近的梯度平方占主导,有效学习率不会一直衰减。beta一般取0.9或0.99,相当于只看最近大约1/(1-beta)步的梯度幅度。RMSProp训练RNN这类非平稳目标时表现很好,可以说它是Adam的前身。
3.3 Adam的巧思:把动量法和RMSProp缝在一起
2015年的Adam论文之所以影响力巨大,是因为它把两个此前被证明有效的思路简单直接地结合了起来:一阶矩(也就是动量)管方向,二阶矩管每个参数的自适应步长。整个算法维护两个滑动平均:
- m_t = beta1 * m_{t-1} + (1 - beta1) * g_t
- v_t = beta2 * v_{t-1} + (1 - beta2) * g_t^2
由于m和v的初始值是0,训练初期它们会偏小,所以Adam加了一个偏差修正步骤,把估计值拉回来:
- m_hat = m_t / (1 - beta1^t)
- v_hat = v_t / (1 - beta2^t)
最终更新为:
- w = w - lr * m_hat / (sqrt(v_hat) + eps)
默认参数是beta1=0.9、beta2=0.999、eps=1e-8。这个组合的实用价值非常大:它几乎不需要手工对每个参数调学习率,对稀疏梯度、非平稳目标都很稳健,在NLP、强化学习、GAN训练里几乎成了默认选择。
我在这必须提醒一下:很多人只记住了Adam“自适应”的好处,却忽略了它同样需要学习率调度和合适的初始学习率。Adam虽然比SGD鲁棒,但绝不等于设个lr=0.1还能好好训练。实际经验里Transformer类模型的初始学习率通常要低到1e-4甚至1e-5,CNN任务用Adam的话初始lr从1e-3开始调比较稳。
4. 现代优化器的进一步改良:AdamW、NAdam、LAMB与元优化思路
4.1 AdamW的巧思:把权重衰减从梯度里解耦出来
用Adam做L2正则的时候,很多版本实现里是在loss上加上weight * weight那项,然后让优化器对“加了正则项的总梯度”做更新。这就带来一个Bug:Adam的自适应机制会把权重衰减的部分也按梯度的尺度去缩放,导致不同参数的衰减强度完全不同,正则效果被扭曲。
AdamW的论文点破了这一点:权重衰减应该直接做在参数上,而不是混进梯度里。更新规则变成:
- w = w - lr * (m_hat / (sqrt(v_hat) + eps) + weight_decay * w)
这样做的好处是正则强度与梯度大小解耦,每个参数收到一致的衰减比例。现在在预训练Transformer、BERT、GPT这类模型里,AdamW基本是标配,PyTorch里实现时直接设weight_decay参数即可。实际调参时我一般会先固定weight_decay在0.01到0.1之间,再调学习率,效果比较可控。
4.2 NAdam与RAdam:纠偏Adam的两种思路
NAdam的想法直白:既然Nesterov动量在SGD上表现好,那把它融进Adam会怎样?做法是把计算m_hat那一步改成Nesterov式的“前瞻”形式。在很多任务上,NAdam能比标准Adam收敛得更快,代价几乎为零。PyTorch里可以这样创建优化器:
python复制optimizer = torch.optim.NAdam(model.parameters(), lr=1e-3)
RAdam的思路则更敏锐:Adam在训练刚开始时,v_t的估计方差很大,因为只看了很少几步的梯度,二阶矩的估计极不准确。这时候正常更新会步子大小忽大忽小,容易跳进坏的区域。传统解决方式是warmup——前几千步小步走,等二阶矩估计稳定了再说。RAdam则是用数学方法算出一个“整流项”,自动修正早期方差,让Adam在没有warmup时也能稳住。我自己的体会是,在训练步数比较少的场景里(比如微调),RAdam的稳定性确实比纯Adam好一截。
4.3 LAMB:大batch训练里的逐层自适应
当数据规模变大,你需要用几百甚至几千块GPU做分布式训练时,batch size会从几百涨到几千、几万。这么大的batch下,每层的梯度尺度差异非常大,统一用一个全局学习率很容易出问题。LAMB的巧思是按层做自适应:计算每个参数的更新量并除以该层参数更新的二阶范数,再乘以一个全局缩放因子。这样每一层都能有自己合适的更新尺度,即使batch size从2k涨到64k也能保持稳定。
LAMB在谷歌预训练BERT时被验证过,是大型分布式训练里一个比较实用的方案。如果只是在单卡上训练中小模型,LAMB的收益不会明显,反而增加复杂度,这时选AdamW就完全够用。
4.4 更前沿的一类思路:把优化器当成可学习或更精细的对象
近几年还出现了一些更具巧思的优化器设计。比如Lookahead的思想是维护一组“慢权重”和一组“快权重”,快权重用普通优化器快速探索,慢权重每隔K步向快权重方向靠近一点,相当于从全局视角做平滑,能在一定程度上缓解不同batch之间的分布偏移。
Sophia则是从二阶优化的角度切入:它使用Hessian矩阵的对角线估计来替代梯度平方,让更新方向更贴合真实曲率,在部分大模型预训练任务上可以用更少的步数达到目标loss。
还有一些研究在探索设计元优化器,也就是用神经网络输出另一个神经网络的更新参数。这类方法还比较学术派,但在小规模模型上已经能看出效果。我的建议是不用全都追新,理解清楚哪些优化器解决了什么问题、代价是什么,比收集各种版本更重要。
5. 优化器之外的配套操作,别忽略这三个
5.1 warmup与学习率调度
优化器决定每一步怎么迈,但“一步迈多大”还受学习率调度器控制。warmup的直觉来自RAdam想解决的同一个问题:训练刚开始时,梯度估计极不稳定,如果一开始就用大学习率,容易把参数推到坏区域。所以先让学习率从很小的值线性升到目标值,等优化器的动量/矩估计稳定后再进入正常训练。
常见的做法是warmup + cosine decay:先上升,再按余弦曲线衰减到接近零。这个组合在Transformer类模型上尤其好用。具体实现可以用PyTorch的LambdaLR或HuggingFace的get_scheduler:
python复制from transformers import get_cosine_schedule_with_warmup
scheduler = get_cosine_schedule_with_warmup(
optimizer,
num_warmup_steps=2000,
num_training_steps=total_steps
)
warmup步数一般取总训练步数的5%到10%,如果数据噪声大、batch小,可以适当加大比例。
5.2 梯度裁剪:防爆炸的保险丝
LSTM、Transformer这类模型在训练时经常遇到梯度爆炸,尤其是在长序列上。一个简单有效的办法是梯度裁剪:如果梯度的整体范数超过阈值,就把它按比例缩小到阈值以内。PyTorch里有现成接口:
python复制torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
这个max_norm取值依任务变化,我一般从1.0开始试,如果loss仍然震荡就降到0.5,如果模型收敛很慢且没有梯度爆炸风险,也可以适当放宽。别忘了裁剪操作要在optimizer.step()之前调用,顺序写错等于没裁。
5.3 不同任务下的优化器选择清单
虽然常有“无免费午餐”的说法,但不同领域的实践经验已经形成了相对可靠的默认组合。我按自己的项目经验总结成一张表:
| 任务场景 | 常用优化器 | 初始学习率建议 | 配套设置 |
|---|---|---|---|
| 图像分类(CNN) | SGD + Momentum / Nesterov | 0.01~0.1(需配合lr调度) | weight_decay 1e-4,训练周期较长时用cosine退火 |
| 目标检测/分割 | SGD(老牌)或AdamW(方便) | SGD: 0.02,AdamW: 1e-4 | 大batch时优先考虑AdamW |
| Transformer NLP(预训练/微调) | AdamW | 预训练3e-4~1e-3,微调1e-5~5e-5 | warmup + 线性或cosine衰减 |
| 强化学习 | Adam | 3e-4(PPO常用) | 梯度裁剪很重要 |
| GAN系列 | Adam | 2e-4(经典DCGAN默认值) | beta1建议0.5,beta2默认0.999 |
| 大规模分布式训练 | LAMB | 按batch size缩放 | layerwise自适应,配套warmup |
这张表不是教条,但至少能帮你少走弯路。初学者最容易踩的坑是一上来就无脑用默认lr跑Transformer,结果直接发散,其实那多半是学习率超了。
5.4 一个实用的工作流
如果实在没有头绪,我通常按这个流程走:先用AdamW配一个偏小的学习率(比如1e-4)加warmup,快速把整个流程跑通,确认数据、loss、网络前向反向都没问题。然后看loss曲线形态,如果收敛速度太慢就加大lr,如果震荡明显就减小lr或增大batch。等模型到了平台期,再切回SGD+Nesterov从头精调,或继续用AdamW但配合cosine衰减。别小看“先快速验证再精调”的思路,很多项目卡住并不是因为缺高级优化器,而是流程有问题。
6. 常见问题与排查技巧实录
6.1 loss完全不动,但梯度似乎还在算
这个情况我碰到过很多次,尤其是自己写的训练循环里。排查顺序是:先打印每个batch的loss值,确认loss本身真的一直不变,而不是因为日志打印间隔太大看不出变化。如果loss数值确实恒定,先别怀疑优化器,很可能是损失函数写错了——比如某个位置把detach()掉,或者label是常量,导致loss不随参数变化。
排除掉这些之后,再检查优化器:看每一步更新后参数是否真的有变化。可以打印某一层参数的范数,连续打几个step。如果参数确实在动但loss不动,那就是loss表达有问题;如果参数都不动,那可能是优化器没有接到正确的参数列表,或者你把requires_grad设成了False。
6.2 loss变成NaN,第一反应不该是改网络
NaN是训练里最让人头疼的现象之一。最常见的元凶是学习率过大,尤其在Transformer类模型里。另一个常见原因是梯度里混入了NaN,比如输入数据存在NaN、或者某个除零操作没有加eps。我的排查习惯是:先加上梯度裁剪,把max_norm设成1.0跑几步;如果还是NaN,就调小学习率到原来的十分之一跑几步;再用一个固定随机种子逐层打印梯度,定位NaN出现在哪一层。很多时候,问题出在数据预处理阶段——一个未被处理的空值会通过loss反向传播扩散到整个网络。
6.3 训练曲线震荡剧烈,验证集指标忽高忽低
这往往是学习率偏大、batch size偏小或者beta2设置偏高共同导致的。Adam里beta2决定二阶矩估计看多长的历史,如果beta2=0.999,有效学习率的变化会比较平缓;如果震荡严重,可以试试调小学习率、增大batch size,或者检查数据增强强度是否过大。还有一个容易被忽视的点:如果训练集和测试集分布差异过大,验证集指标抖动不一定代表优化器有问题,反而是过拟合或数据采样的信号。
6.4 Adam训练集收敛不错,但泛化比SGD差
这属于Adam的已知痛点。Adam倾向于收敛到损失面的尖锐极小值附近,而SGD加动量更容易找到平坦的极小值,后者通常泛化更好。解决办法不是立刻放弃Adam,而是先试AdamW,配合权重衰减和EMA(指数移动平均),把参数取历史平均而不是直接用最后一版权重。EMA在多分类任务上效果尤为稳定,我自己常用decay=0.999,训练结束后用EMA权重做推理。
6.5 一张速查表解决80%的优化器调试问题
| 现象 | 可能原因 | 排查/解决手段 |
|---|---|---|
| loss完全不下降 | 学习率过低、loss计算断开、优化器参数没接对 | 打印参数梯度,逐步调大lr,检查requires_grad |
| loss发散或NaN | 学习率过高、数据含NaN、梯度爆炸 | 开启梯度裁剪,lr降为1/10,检查输入数据 |
| loss震荡严重 | lr偏大、batch过小、数据噪声大 | 降低lr,增大batch,调节beta2 |
| 收敛太慢 | lr偏小、warmup太长、优化器不合适 | 按训练曲线逐步调大lr,缩短warmup |
| 训练好但验证差 | 过拟合 / Adam到尖锐极小值 | 增大weight_decay,开EMA,尝试SGD+Nesterov |
| 参数更新后loss无变化 | loss与参数无关 | 检查是否带了detach,确认label是否有变化 |
这个表基本覆盖了我过去调试优化器遇到的绝大多数问题。你要真能把这六类现象对上号、按流程排查一遍,调模型的速度会提升一个量级。
7. 一点个人总结和最后的建议
优化器的设计史,本质上是一部“如何在信息不完备的情况下做出好的决策”的历史。从朴素的梯度下降补上动量,用历史梯度的累积来缓冲噪声;再补上自适应,让每个参数都能按自己的节奏走;再补上偏差修正和权重衰减解耦,解决稳定性与泛化的问题——每一步都是在上一代方案暴露的缺陷上做针对性修复。这种迭代思路本身就很值得学习:当你面对一个模型效果不理想时,学着辨别瓶颈到底在地形的哪个部分,而不是盲目换一个新花样的优化器。
我个人实际用下来的体会是:建议大家在正式训练前,一定要养成打印梯度统计量、参数更新量、loss分布这三项信息的习惯。看到loss曲线异常时,第一时间不是去改网络结构,而是观察优化器相关的信号。另外,不要盲目照搬论文里的优化器配置,论文的batch size、数据分布和你的差距可能很大。如果你训练的是小数据,batch size只有几十,那用大batch训练出来的默认lr往往偏大,记得要等比缩小。先在小规模上验证流程,再放大资源去跑,是几乎所有项目通用的稳妥路线。
希望这篇速览能帮你把优化器从“调参玄学”变成“有章可循”的常识。下次再遇到loss不收敛,先把注意力放到优化器上——你可能很快就找到那个被忽略的真相。
