训练深度学习模型,最让人头大的问题通常不是网络结构搭不出来,而是Loss怎么都不降、降得太慢、或者降着降着突然变成NaN。我接触PyTorch深度学习这几年,发现绝大多数这类问题都出在优化器身上。PyTorch对优化器的封装已经足够友好,几行代码就能跑起来,但也正因为太友好,很长一段时间里我只是在“换优化器试运气”,并没有真正理解它在做什么。这篇学习笔记是PyTorch优化器系列的第一篇,我会从最朴素的SGD讲起,一路讲到当前最常用的Adam,把每个优化器的原理、代码细节、以及实操里容易踩的坑都梳理一遍,希望能帮刚入门的朋友少走弯路。
1. 优化器到底在优化什么:先建立直觉
1.1 从损失函数到参数更新的完整链路
深度学习训练的本质,是在一个巨大的参数空间里寻找一组能让损失函数尽量小的参数。整个流程可以拆成四步:前向传播算预测值、计算损失、反向传播求梯度、优化器更新参数。前两步好理解,第三步的梯度告诉我们“当前参数应该往哪个方向调整”,而第四步——也就是优化器的工作——决定了“每一步到底调多少、怎么调”。
这里有个点很多人没意识到:网络结构决定了你能表达的函数空间有多大,但优化器决定你最终能不能在这个空间里找到一个好位置。同一个模型,用不同的优化器和学习率,最终精度可能差好几个点。原因是梯度方向只是损失下降最快的局部方向,而不是全局指向最低点的方向。损失函数表面往往崎岖不平,有峡谷、有鞍点、有平台,沿着梯度方向走很容易在峡谷两侧来回震荡,或者在鞍点附近磨蹭半天。优化器的设计目标,就是尽可能高效、稳定地在这种复杂地形里往低处走。
以前我总觉得“只要梯度算对了,参数更新就是减一下学习率乘梯度这么简单”。但后来看到一个直观的例子:同样一个二次型损失函数,在两个方向上的曲率差异很大时,固定学习率的梯度下降会在陡峭方向震荡、在平缓方向龟速前进,怎么调学习率都不合适。这时候才理解为什么需要动量、为什么需要自适应学习率——它们本质上都是为了让更新步伐更匹配损失表面的几何形状。
1.2 优化器共有的“三大旋钮”:学习率、动量与权重衰减
无论多复杂的优化器,核心超参都逃不开三个:学习率、动量和权重衰减。
学习率是最直接的步长控制,决定每一步参数移动多远。太大容易发散,太小收敛极慢,它在绝大多数任务里都是“最值得调的超参”。动量解决的是方向稳定性问题,它让参数更新方向带上历史梯度的“惯性”,从而抵消峡谷两侧的来回震荡、加速平坦区域的移动。权重衰减很多人都知道是L2正则化,但理解角度可以更直观:它在每次更新时把参数往零的方向拉一点,相当于限制参数空间的范围,抑制过拟合。
这三个旋钮在不同优化器里呈现形式不一样。SGD里动量是显式的momentum参数,Adam里动量被拆成了一阶矩和二阶矩,权重衰减在AdamW里还专门做了解耦处理。搞清楚这些旋钮各自在做什么,再看任何优化器都能一眼抓住要领。后面的章节我会逐个展开,从最简单的SGD开始。
2. PyTorch优化器的通用使用流程与底层逻辑
2.1 一个最基础的使用模板
PyTorch里所有优化器都在torch.optim下面,用法高度统一。先看最基础的一段代码:
import torch import torch.nn as nn model = nn.Linear(10, 1) # 随便定义一个线性层 optimizer = torch.optim.SGD(model.parameters(), lr=0.01) for x, y in dataloader: pred = model(x) loss = nn.functional.mse_loss(pred, y) optimizer.zero_grad() # 第一步:清空梯度 loss.backward() # 第二步:反向传播计算梯度 optimizer.step() # 第三步:更新参数这段代码看起来平平无奇,但每个方法背后都有值得注意的细节。zero_grad()清空的是参数的梯度缓存。PyTorch的梯度是有“累加”机制的,如果不手动清零,下一次backward()会把新梯度加到旧梯度上。这个机制有时反而有用,比如显存不够时可以通过多次小batch累积梯度来模拟大batch;但正常训练时忘了清零,梯度就会越叠越大,loss曲线表现得非常诡异。
backward()计算完梯度后,梯度是挂在param.grad上的,优化器并没有直接读这个梯度,而是在step()时统一从所有注册的参数上取梯度做更新。这也是为什么优化器必须在创建时传入model.parameters()——它需要提前拿到参数引用,才能在step时逐个更新。
2.2 用param_groups做精细化配置
优化器创建时传入的params参数,不一定只能传model.parameters(),还可以传入一个参数分组列表,即param_groups。每个组可以有自己的学习率、动量配置。典型应用场景是:骨干网络用较小学习率,分类头用较大学习率,这在迁移学习里很常见。
optimizer = torch.optim.SGD([ {'params': model.backbone.parameters(), 'lr': 1e-4}, {'params': model.head.parameters(), 'lr': 1e-3}, ], lr=1e-3, momentum=0.9)这里有个坑:创建优化器时如果没有给某个分组指定lr,它会用默认的lr;但如果给了分组自己的lr,后续你手动改optimizer.param_groups[0]['lr']时,改的也是分组自己的值,不会影响另一个分组。很多人调试时明明打印出optimizer.param_groups里两个组的lr不同,训练曲线却不符合预期,就是因为对“默认值”和“分组覆盖值”的关系没搞清楚。
2.3 状态保存与恢复:别让你的训练白跑
优化器内部是有状态的。最典型的是动量缓冲,SGD里的momentum_buffer、Adam里的exp_avg和exp_avg_sq,都会随训练过程更新。如果训练到一半中断了,只保存模型权重、不保存优化器状态,恢复训练后动量信息会全部丢失,等效于换了一组初始条件,loss曲线会出现明显波动,严重时甚至要重新收敛很久。
正确的保存方式是同时保存模型和优化器:
checkpoint = { 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'epoch': epoch, 'loss': loss, } torch.save(checkpoint, 'checkpoint.pth') # 恢复时 checkpoint = torch.load('checkpoint.pth') model.load_state_dict(checkpoint['model_state_dict']) optimizer.load_state_dict(checkpoint['optimizer_state_dict'])optimizer.state_dict()里存的是param_groups的配置和每个参数对应的状态张量。恢复后如果想微调学习率,可以直接改optimizer.param_groups里的lr,再load_state_dict会把param_groups覆盖掉,所以正确顺序是先加载状态再调整lr。这个顺序问题我踩过不止一次。
3. 从朴素SGD到Momentum与Nesterov
3.1 朴素SGD:最纯粹也最“脆”的优化器
朴素SGD的更新规则一句话就能说完:新参数 = 当前参数 - 学习率 × 梯度。
写成代码就是:
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)这里的“SGD”名字里有Stochastic,指每次用一个batch的样本来估计整体梯度,而不是用全量数据算精确梯度。由于batch的采样噪声加上损失表面本身的崎岖地形,朴素SGD在收敛过程中的轨迹很不稳定,典型表现就是峡谷地形里来回震荡。之前提过的那个二次型例子,陡峭方向梯度大、参数被推过头,平缓方向梯度小、参数走不动,最后导致loss收敛特别慢。
我个人的体会是,朴素SGD除非任务非常简单,否则几乎不适合直接拿来训练深度网络。它对学习率极其敏感,lr稍大一点就能看到loss发散,lr小了又龟速下降,而且没有任何机制来应对不同参数尺度差异。它更大的意义是作为理解更复杂优化器的起点。你在对比实验里看到所谓“基线SGD”,通常也都是加了动量的版本,而不是真正朴素的SGD。
3.2 Momentum:给梯度一个“惯性”
Momentum的思路非常直观:不要让这次更新只看当前梯度,而是把历史梯度也加权进来。更新公式可以理解为速度的累积:
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)物理上可以想象成一个球从山坡滚下:普通SGD是每一步都根据脚下坡度重新决定方向,而带动量的SGD相当于球本身有向下的速度,即便某一步脚下坡度反转,速度也会让球继续往前冲,从而压制峡谷两侧的震荡。
动量系数momentum通常取0.9或0.99。0.9表示当前更新方向里有90%来自历史速度、10%来自当前梯度,历史窗口大约十步;0.99的窗口更长,但也会带来更强的“惯性”,如果学习率偏大,惯性过大容易在收敛点附近刹不住车,表现为loss在低处反复弹跳。我调参时一般先在0.9起步,如果loss下降稳定但接近收敛时还在震,再考虑把lr降低而不是一上来就调高动量。
3.3 Nesterov:先迈一步再看梯度
Nesterov加速梯度(NAG)是Momentum的一个改良版本。普通Momentum是先根据当前梯度更新速度,再带着历史速度走一步;NAG则是先沿着历史速度预演一步,看看预演位置的梯度,再综合决定更新方向。这个“向前看”的动作让它能更早感知到地形变化,在接近局部极小值时刹车更快,收敛方向也更稳。
PyTorch里使用NAG只需要在SGD上打开一个开关:
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, nesterov=True)注意:nesterov=True时必须同时设置momentum非零,否则会报错。实际效果上,NAG在部分任务里比普通Momentum收敛快、精度高一点点,但差距通常不大。如果训练CV模型,我倾向于默认打开nesterov,因为它几乎不增加额外开销,却能带来略微更好的稳定性。这个“白蹭”的改进在调参成本不变的情况下值得占。
4. 自适应学习率:AdaGrad与RMSProp
4.1 AdaGrad:让每个参数都有自己的学习率
固定学习率的一个天然问题是:不同参数在训练过程中被更新频率差异很大。有些参数(比如稀疏特征对应的参数)很少被激活,每次激活时梯度信息量很大;有些参数则几乎每一步都在更新。对所有参数用同一个学习率,显然不合理。
AdaGrad的思路是给每个参数维护一个累积梯度平方和,更新时让学习率除以这个累积值的平方根。梯度大的参数累积值大、学习率被压小;梯度小的参数累积值小、学习率相对保持较大。用数学表达就是:每个参数的有效学习率 = 全局lr / sqrt(累积平方梯度和 + eps)。
PyTorch用法:
optimizer = torch.optim.Adagrad(model.parameters(), lr=0.01)AdaGrad最大的问题在于“累积”两个字:平方梯度从训练开始一直累加,数值只增不减,导致有效学习率会随着时间推移单调递减,训练后期所有参数的有效学习率都趋近于零,模型基本学不动了。这个缺陷在深度网络训练里特别致命,因为深度网络动辄训练几十上百个epoch,AdaGrad往往坚持不到收敛就“僵住”。它的适用场景被局限在特征非常稀疏的模型上,例如某些推荐场景或文本词向量模型。
4.2 RMSProp:把“累积”改成“滑动平均”
RMSProp针对AdaGrad的问题做了关键改进:不再累加全部历史平方梯度,而是用指数滑动平均来估计近期的梯度平方尺度。这样有效学习率既能自适应调整,又不会随着训练时间无限缩小。
PyTorch用法:
optimizer = torch.optim.RMSprop(model.parameters(), lr=0.01, alpha=0.99, eps=1e-08)alpha就是滑动平均系数,0.99表示当前估计里保留99%的历史信息、纳入1%的新信息。这个参数控制的是“记忆长度”:alpha越大,对历史梯度尺度的估计越平滑,学习率变化越稳定;alpha越小,响应越快,但也越容易受到单个batch噪声干扰。
RMSProp在非凸问题上的表现比AdaGrad稳定得多,适合处理RNN这类序列模型,以及损失表面比较复杂的情况。不过我实际使用中,RMSProp的调试成本比Adam高,因为它的有效学习率会随着梯度尺度动态变化,不容易凭直觉设置lr。它还带一个momentum参数,可以把RMSProp和动量机制结合起来,开了之后更新公式会多一项动量项,效果接近SGD加动量的感觉。
4.3 实战中的选择:什么时候值得手动选AdaGrad或RMSProp
现在默认任务很少会主动选AdaGrad,但它依然有自己的生态位。特征非常稀疏、且希望低频参数得到较大有效更新的场景,AdaGrad的“累积压制”反而成了优点:高频参数被压得很低,低频参数灵活更新。RMSProp则适合那些对自适应调节有需求、但Adam又表现得太“激进”的任务。
我自己在某个序列标注任务上做过对比,RMSProp+适当学习率的收敛曲线比Adam更平滑,最后精度也略高。这种差异没有普适规律,更多取决于任务性质,所以做实验时我会把它们都放进对比列表,而不是只凭“大家都说Adam好”就放弃其他选择。对比时别忘了给每个优化器各配一组调过的学习率,直接用相同lr比较很容易得出错误结论。
5. Adam:默认不犯错的选择
5.1 Adam的更新规则与偏差校正
Adam可以说是目前深度学习里最常用的默认优化器,因为它把Momentum和RMSProp的思想融合在一起:既维护一阶动量(梯度的指数加权平均,代表更新方向),又维护二阶动量(梯度平方的指数加权平均,代表每个参数的尺度),再用二阶动量对一阶动量做归一化。这样每个参数都拥有自适应学习率,同时方向更新又平滑稳定。
PyTorch默认的Adam参数:
optimizer = torch.optim.Adam(model.parameters(), lr=0.001, betas=(0.9, 0.999), eps=1e-08)这里的betas对应两个动量项的指数衰减系数,常用的(0.9, 0.999)可以理解为:一阶动量看最近约10步的梯度方向,二阶动量看最近约1000步的梯度平方尺度。这个配置在Transformer类任务里几乎是标配,从0开始调参的时候直接把Adam加lr=3e-4起步,一般都能稳定跑起来。
Adam在公式里还有一个容易忽略但很重要的细节:偏差校正。因为梯度的指数平均是从零开始累积的,训练初期估计值会严重偏向零,如果不做校正,前几步的更新会非常小。偏差校正在迭代早期除以系数,快速把估计值拉回正常水平。这也是为什么Adam哪怕不仔细调参,也能在几千步内表现出不错的效果。
5.2 PyTorch中的Adam参数细读
Adam的eps参数是二阶动量分母里的一个极小值,主要目的是防止除以零。默认1e-8在大多数场景没问题,但在混合精度训练里我吃过亏:某些梯度特别小的情况下,分母里sqrt(v_hat)接近零,加上1e-8仍然不够稳,loss直接跳成NaN。后来把eps调到1e-6,问题就消失了。经验上,如果训练时遇到不明原因的NaN,且确认不是学习率过大,检查一下eps是个便宜又有效的排查方向。
weight_decay在Adam里也存在,但它的实现方式和SGD不太一样。SGD的weight_decay等价于把L2正则项合并到梯度里,所有参数都会受到一致的额外衰减;而Adam在更新时已经对梯度做了归一化,如果直接把weight_decay算进梯度,对大尺度参数和稀疏参数的惩罚会被扭曲。这个问题催生了AdamW,专门把权重衰减从自适应学习率的计算里解耦出来。如果你在训练大模型或预训练模型,看到代码里用AdamW而不是Adam,不用惊讶,它本质上是Adam的一个修正式变体。
5.3 Adam的“缺点”与什么时候换回SGD
Adam收敛快,这是它的优点,但也带来一个业内讨论很多的现象:部分任务里Adam的最终精度可能略逊于精细调过的SGD+动量。原因有多种解释,包括Adam的梯度归一化行为相当于隐式地调整了参数更新的方差,可能让模型走到的最终点位不够“锐利”,而某些锐利的极小值点泛化性能更好。当然这个规律不是绝对的,数据集、模型结构、训练时长都会影响结果。
我个人的经验是:把Adam当第一选择用来快速验证模型能不能收敛,一旦确认训练流程没问题、需要追求最终精度,我会额外开一组SGD+momentum(0.9)+nesterov+warmup,让两组对比跑完。有些任务里SGD能比Adam高零点几个甚至一两个点,尤其是在ImageNet这类大规模图像分类任务上,这个差距值得花时间去调。
6. 优化器调试实录:怎么选、怎么调、怎么排错
6.1 不同任务的默认优化器选型
优化器选型没有绝对正确答案,但确实有高效起点。这里给一份我常用的选型参考:
| 任务类型 | 常用优化器 | 常用初始lr | 备注 |
|---|---|---|---|
| 中小规模图像分类 | SGD + Momentum(0.9) / Adam | SGD: 0.01~0.1,Adam: 1e-3 | SGD+nesterov追求精度 |
| 大规模图像分类 | SGD + Momentum + Nesterov | 0.05~0.1(配合warmup) | 常规工程首选 |
| Transformer / NLP | Adam / AdamW | 3e-4 ~ 1e-4 | betas默认即可 |
| GAN | Adam | 1e-4 ~ 2e-4 | 生成器和判别器可分开配lr |
| 稀疏特征场景 | AdaGrad / Adam | 1e-2 / 1e-3 | 高频参数自适压步调 |
| 强化学习策略网络 | RMSProp / Adam | 3e-4 | 稳定性优先 |
注意这张表只是起点。任何任务最终都要根据验证集曲线做调整,但至少可以让你在还没开始调参时不用先纠结“该用哪个”。
6.2 学习率与优化器的配合套路
优化器再强,也离不开学习率策略配合。我的实测经验是,warmup在Adam上尤其重要。训练初期梯度估计噪声大、二阶动量还没积累起来,直接上大学习率很容易导致前期loss剧烈震荡。warmup让学习率从很小的值线性涨到目标值,前几百步相当于让模型“热身”,等动量估计稳定后再全力加速。
一个朴素的warmup+cosine退火实现思路:
def lr_lambda(step, warmup_steps=500, total_steps=10000): if step < warmup_steps: return step / warmup_steps progress = (step - warmup_steps) / (total_steps - warmup_steps) return 0.5 * (1 + math.cos(math.pi * progress)) scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)这段代码会在训练步数超过预先设定后把学习率按余弦曲线降到接近零。配合Adam时,前期warmup防止震荡,后期低学习率帮助收敛到更优位置。很多开源项目里的默认配置基本就是这个模式,差别只在warmup步数和总步数的设定。
6.3 常见问题速查表
调试优化器过程中,我把遇到过的问题整理成了速查表,遇到类似现象可以直接定位:
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| Loss直接变成NaN | 学习率过大;eps过小;梯度爆炸 | 先降到1e-5验证;混合精度下调大eps到1e-6 |
| Loss反复震荡不收敛 | 学习率偏大;batch_size偏小 | 降低lr一个数量级;检查是否忘了zero_grad |
| 收敛太慢 | 学习率过小;动量/自适应机制未生效 | 增大lr;确认优化器参数是否被param_groups覆盖 |
| 损失下降然后停止 | 陷入平台区;学习率衰减过快 | 加warmup或换余弦退火;检查是否到达过拟合 |
| 恢复训练后loss突变 | 只加载模型没加载优化器状态 | 保存并加载optimizer.state_dict |
| 不同batch结果波动大 | 动量/betas不合适;lr过高 | 增大momentum到0.99,或调整Adam的betas |
排查时有一个我常用的笨办法:把学习率调到极小(比如1e-5),如果loss还发散,那大概率不是优化器问题,而是数据或模型的bug;如果不发散但学得很慢,再逐步增大lr,找到临界点。这样能把“优化器配置”和“其他bug”分开,省很多冤枉时间。
6.4 一次实际对比实验的记录
我在一个公开的10类图像分类任务上做过一次简单对比实验,模型是一个18层残差卷积网络,输入是64x64的图片,总共训练25个epoch。四组配置分别是:朴素SGD(lr=0.01)、SGD+动量(0.9)+nesterov(lr=0.01)、RMSProp(lr=0.001)、Adam(lr=0.001)。实验环境固定,数据增强和随机种子保持一致。
第一轮结果很有意思:普通SGD的loss下降非常慢,前5个epoch曲线几乎是平的;RMSProp前10个epoch收敛很快,但到第15个epoch后开始小幅度震荡;Adam从第3个epoch开始就稳步上升,最终验证准确率最高;SGD+动量前期速度不比Adam慢太多,到后半段反超RMSProp,最终准确率比Adam低了不到0.5个百分点。
第二轮我做了个小改动:给SGD配了5个epoch的warmup,并把学习率按余弦退火降到最低,Adam那组也加了同样的warmup。结果最终SGD组的验证准确率反超了Adam大约0.8个百分点。这说明什么?优化器本身没有绝对强弱,学习率策略和训练时长都会显著影响最终排名。这也正是我一直强调“别迷信单一优化器”的原因。
写这份笔记的时候,我特意翻出了很久以前的训练日志,发现当时踩过的很多坑其实都集中在优化器的“默认行为”上:以为SGD默认就是好用的、以为Adam用了就能躺平、以为训练中断后只保存权重就行。这篇优化器(一)算是把这些最基础的概念和实验彻底理清了。如果你刚接触PyTorch,我的建议很简单:先用Adam把模型跑通,确认数据流和模型没问题后,再回头试SGD+动量。比起一开始就在优化器选择上纠结,先让Loss降下来永远是更重要的事。