但凡用PyTorch训练过模型的人,一定见过这两行代码:optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)和optimizer.zero_grad()。但真正问一句“这个Model-Optimizer到底在优化什么”,可能不少人都卡住了。优化器是训练循环里的那个“方向盘”——它决定了模型参数往哪个方向调整、每一步调多大,同样一个模型结构,换上不同的优化器或学习率策略,收敛速度和最终精度能差出一大截。
这篇文章就把Model-Optimizer这件事彻底讲透。我会从优化器的底层机制谈起,把SGD和Adam这两大阵营的来龙去脉、关键参数、调参经验全部过一遍,再给出我实际踩坑之后的配置模板和排查思路。适合刚入门想搞懂原理的读者,也适合已经跑过不少模型但总觉得训练不太可控、想系统梳理一遍的老手。
1. 整体设计与思路拆解:优化器到底在优化什么
1.1 一次参数更新的完整路径
要理解Model-Optimizer,最好的方式是从一次完整的参数更新流程入手。一个典型的训练循环内部其实只做了四件事:前向计算损失、反向传播梯度、优化器更新参数、梯度清零。前三步的代码你可能闭着眼都能写出来,但很多人没有意识到,optimizer和loss.backward()之间是通过“梯度”这个中间量联系起来的。
反向传播结束后,模型的每一层参数上都会挂着一个.grad属性,里面对应的是损失对该参数的偏导数。然后优化器拿到这个偏导值,结合自己内部的动量状态、学习率、权重衰减等设置,计算出真正的参数增量,执行param -= delta。所以优化器的本质并没有那么玄乎——它就是一个“给定梯度,算出参数应该走多远”的决策器。SGD和Adam的区别,本质上是这个决策器用的信息多少、处理方式不同而已。
一个常被忽略的细节是:优化器并不直接修改损失函数,也不直接参与前向计算。它只服务于“参数更新”这一件事。这也就是为什么你在PyTorch里可以任意换优化器,模型代码一行都不动,训练流程依然能跑通。但这种松耦合的好处也带来一个问题——很多人把优化器当成“黑盒参数提交按钮”,选好名字设个学习率就不管了。真正想控制训练过程,恰恰要钻进这个黑盒里看它每一步到底拿梯度做了什么。
1.2 优化器设计中的核心矛盾
所有优化器设计都在解决一对核心矛盾:稳定性和速度。更新步长太小,收敛很慢,训练时间不可接受;步长太大,loss容易出现震荡,甚至直接发散到NaN。经典的SGD(随机梯度下降)只用当前梯度乘学习率来更新,实现最简单,但有两个明显短板:梯度方向波动大时,更新路径会“左右横跳”,收敛效率低;遇到峡谷状或鞍点形态的损失曲面,前进速度会非常拖沓。
这就是动量(momentum)被引入的原因。动量机制借鉴了物理中物体惯性的概念——参数更新不再只看当前这一瞬间的梯度,而是把过去所有梯度的“指数衰减平均值”也纳入考量。如果过去几次梯度的方向基本一致,动量就会叠加出一个更大的实际步长,加速前进;如果最近梯度方向剧烈摇摆,动量会让更新方向变得平滑,过滤掉高频抖动。
Adam的出现则是另一个思路:不仅给梯度做平滑(一阶动量),还把梯度平方的历史信息也记下来(二阶动量),用二阶动量来归一化每个参数的学习率。这样不同参数的更新步长就可以适应各自的梯度尺度——梯度变化剧烈的参数自动缩小步长,梯度平缓的参数自动放大步长。这是Adam“自适应学习率”说法的来源,也是它在落地场景中往往比SGD更好上手的原因。理解这两条技术路线的分岔点,后面配置优化器时就不会再盲目了。
2. 核心细节解析与实操要点
2.1 主流优化器的原理和适用场景
先把我实际使用过的优化器整理成一张对照表,方便你看完后面的详细分析后快速回顾:
| 优化器 | 核心思想 | 关键超参数 | 最擅长场景 | 主要短板 |
|---|---|---|---|---|
| SGD | 当前梯度直接更新 | lr, momentum | CV分类、微调预训练模型 | 收敛慢,对lr敏感 |
| SGD+Momentum | 梯度指数滑动平均 | lr, momentum, nesterov | 大批量训练、稳定收敛 | 需要精心调lr策略 |
| Adam | 一阶+二阶动量 | lr, betas, eps | NLP、多模态、通用训练 | 可能错过尖锐极小值 |
| AdamW | Adam + 解耦权重衰减 | lr, betas, weight_decay | Transformer、GPT类模型 | 同样有泛化争议 |
| RMSProp | 梯度平方滑动平均 | lr, alpha, eps | 非平稳目标、RNN训练 | 对alpha敏感 |
| Adagrad | 历史梯度平方累积 | lr, eps | 稀疏特征场景 | 学习率单调递减,易停滞 |
SGD在深度学习初期是绝对主力,PyTorch里它的实现支持momentum和Nesterov加速。我个人的经验是:SGD搭配余弦退火学习率调度器,在很多图像分类任务上的最终精度往往优于Adam,尤其是微调ImageNet预训练权重时,SGD的泛化表现更稳定。原因在于SGD的更新规则简单,不存在自适应机制带来的隐式正则变化,参数更倾向于收敛到平坦的最低点,而这种平坦极小值在测试集上通常表现更好。
Adam则把训练的“下限”拉高了——默认配置下,哪怕你完全不调参,它也能让模型在大部分任务上收敛到一个可接受的水平。这也是为什么NLP社区和各类比赛选手几乎默认用Adam。但Adam有个公认的问题:它使用的二阶动量相当于对每个参数的学习率做归一化,这使得它在梯度稀疏、变化剧烈的场景下非常高效,却也容易让模型收敛到尖锐的局部极小值,泛化能力偶有打折。AdamW则是在原来Adam的基础上把权重衰减从L2正则改成了直接对参数做解耦衰减,这一步修正对Transformer类模型效果尤其明显。
2.2 关键参数背后的数学含义
优化器的参数并不多,但每个都值得仔细推敲。学习率lr决定了参数更新的基本步长,这是训练中优先级最高的超参数。SGD的时间复杂度很低,但它的有效步长完全靠lr撑着,lr太大直接让loss震荡甚至爆掉,lr太小又会让训练进度肉眼不可见。我一般会用“日志里前几百步loss下降速度”来判断学习率是否量级正确:如果前100步loss纹丝不动,说明学习率大概率偏小;如果loss快速飙升或出现NaN,那基本是学习率过大。
momentum是SGD家族独有的参数,默认值为0.9。它的效果相当于让更新带上“惯性”:如果过去一段时间梯度方向一致,实际步长会放大到lr/(1-momentum)左右。换个角度理解,momentum=0.9意味着近似的历史窗口覆盖大约10个batch的梯度方向,momentum=0.99则拉到约100个batch,惯性越大,更新越平滑,但反应到新方向上就越迟钝。这个参数一般不需要频繁改动,0.9是久经考验的默认值。
Adam的betas是(beta1, beta2)元组,常见默认是(0.9, 0.999)。beta1控制一阶动量(梯度均值)的衰减速度,beta2控制二阶动量(梯度平方均值)的衰减速度。二阶动量衰减得越慢,它对历史梯度信息的记忆越长,学习率归一化越稳定。修改betas的场景很少,但训练GAN或某些自监督模型时,把beta1调到0.5可以加快初期对新梯度的响应速度。
weight_decay是容易被误解的参数。在PyTorch的SGD实现里,weight_decay是标准的L2正则化,即每一步都在原梯度上额外加一个weight_decay * param的惩罚项。而AdamW里的weight_decay则是直接把参数缩小一个固定比例,再把更新后的梯度加上去——这是两个完全不同的操作,虽然同名。实际使用时,Transformer类模型的weight_decay一般设成0.01到0.1之间,CNN微调那类场景则常设成5e-4到1e-4。默认设0不丢人,但多数任务加上一点权重衰减都有助于抑制过拟合。
2.3 参数组:一个优化器实例管理多个学习率
PyTorch的优化器支持参数组(param_groups)设计,这是很多人都没充分利用的特性。所谓参数组,就是把模型的不同部分塞进同一个优化器,但它们各自使用不同的学习率、动量和权重衰减。应用场景非常明确:迁移学习中,主干网络(backbone)应该用较小的学习率缓慢微调,新加的分类头则用较大的学习率快速收敛;BERT微调时,embedding层和顶层分类器的学习率需求也完全不同;有些模型里的bias和BatchNorm参数根本不需要权重衰减,也应该单独拎出来。
实现方式非常直接,就是向优化器传入一个字典列表,而不是一个参数迭代器。下面是一段典型的分组配置代码:
backbone_params = [] head_params = [] for name, param in model.named_parameters(): if param.requires_grad is False: continue if "backbone" in name: backbone_params.append(param) else: head_params.append(param) optimizer = torch.optim.AdamW([ {"params": backbone_params, "lr": 1e-5, "weight_decay": 0.0}, {"params": head_params, "lr": 1e-3, "weight_decay": 0.05}, ], lr=1e-3)这里有两个细节值得注意。第一,字典里只写了需要覆盖的参数,其他属性会fallback到优化器全局默认值,所以全局的lr必须写在第二位置,作为未覆盖项的兜底。第二,named_parameters()返回的参数名是包含模块路径的完整名称,如果模型里使用了nn.Sequential,名字的层级关系必须心里有数,否则分组规则很容易匹配不中——到时候模型静默跑了几百步,某些层的学习率根本没有按预期生效,这种隐性bug是最难排查的。
3. 实操过程与核心环节实现
3.1 从零配置一个训练优化器
下面用一个实际可运行的模板,演示从构建优化器到接入训练循环的完整过程。这个模板面向的是典型的BERT分类微调场景,使用的优化器是AdamW,配合线性warmup和余弦退火学习率调度器——这套组合在NLP任务上我反复使用,效果非常稳定。
import math import torch from torch.optim import AdamW from torch.optim.lr_scheduler import LambdaLR def get_optimizer_and_scheduler(model, train_steps, warmup_ratio=0.1): # 分组:分类头和预训练主干使用不同学习率 no_decay = ["bias", "LayerNorm.weight"] grouped_params = [ { "params": [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)], "weight_decay": 0.01, }, { "params": [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)], "weight_decay": 0.0, }, ] optimizer = AdamW(grouped_params, lr=2e-5, betas=(0.9, 0.999), eps=1e-8) def lr_lambda(current_step): if current_step < warmup_steps: return float(current_step) / float(max(1, warmup_steps)) progress = float(current_step - warmup_steps) / float(max(1, train_steps - warmup_steps)) return max(0.0, 0.5 * (1.0 + math.cos(math.pi * progress))) warmup_steps = int(train_steps * warmup_ratio) scheduler = LambdaLR(optimizer, lr_lambda) return optimizer, scheduler这段代码里有几个操作值得逐一说清楚。no_decay列表把bias和LayerNorm.weight排除在权重衰减之外——这是Transformer微调的标准操作,原因是bias和LayerNorm参数本身就承担着归一化和偏置的功能,强加L2惩罚反而会破坏训练稳定性。warmup_ratio设为0.1,意思是前10%的步数里学习率从0线性升到目标值,避免训练一开始就用较大的学习率冲向一个不合理的参数区域。后面的余弦退火让学习率在剩余训练步数内按余弦曲线平滑下降,所有优化器最终都能用到相对精细的步长来完成收敛。
3.2 学习率策略的接入方法
PyTorch的scheduler.step()需要和训练步数严格对齐。每跑完一个batch,你依次执行loss.backward()、optimizer.step()、optimizer.zero_grad(),然后调用scheduler.step()。有一点和直觉不同——学习率调度器更新的是optimizer内部的学习率,而不是某个独立的变量。也就是说,调度器和优化器是“嵌套关系”,前者通过修改optimizer.param_groups中的lr值来影响后续更新。想确认当前实际学习率,可以打印optimizer.param_groups[0]["lr"]。
这里有一个极易踩坑的点:如果你同时用了梯度累积,调度器的步数对齐方式也要跟着调整。梯度累积(gradient accumulation)的意思是每accumulation_steps个batch才做一次真正的参数更新,此时optimizer.step()的执行频率降低了,但PyTorch的scheduler.step()默认是按“调用次数”前进的,不是按“真实参数更新次数”前进。如果你想严格按真实更新次数调整学习率,就得把scheduler.step()放到optimizer.step()同一个条件分支里去:
for i, batch in enumerate(loader): loss = model(batch) loss = loss / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() scheduler.step() optimizer.zero_grad()loss除以accumulation_steps是为了保证等效batch size变大后梯度均值稳定,否则梯度累积后总梯度会成倍放大,学习率必须同步缩小,这点经常被忽略。整体来看,学习率策略的选取原则非常简单:训练步数长(比如几十万步),用余弦或带warmup的线性衰减;训练步数短或做少量epoch微调,直接用固定学习率就够,没必要引入额外的复杂度。
3.3 梯度裁剪和极端数值处理
训练中一个非常实用的保护机制是梯度裁剪(gradient clipping)。它做的是:在optimizer.step()之前检查所有参数的梯度范数,如果范数超过预设阈值,就把整个梯度张量等比例缩放,让范数重新降到阈值以内。这么做能防止单batch的异常梯度“引爆”参数更新。
PyTorch里最常用的是clip_grad_norm_,它接受整个模型参数的可迭代对象和一个max_norm阈值:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step()阈值的选择需要看训练数据的梯度范数分布。我一般会在第一轮训练时每隔一定步数打印total_norm的数值,观察它大致落在什么范围:如果普遍在0.1到1之间,那max_norm=1.0是合理的安全线;如果偶尔飙到50以上,阈值该设置在5左右,只拦截极端尖峰,不要误伤正常更新。梯度裁剪对RNN、Transformer这类深层序列模型尤其有效,因为长序列的反向传播链路很容易造成梯度爆炸,不裁剪的话训练一晚上可能local loss已经飞了。
还有一个容易忽视的细节:max_norm(绝对阈值)和clip_grad_value_(逐参数值阈值)看起来相似,但机制完全不同。clip_grad_value_是把每个梯度的值直接截断到[-clip_value, clip_value]范围内,它不会考虑梯度向量整体的范数,而是逐元素处理。使用clip_grad_value_时阈值一般要设得比较小(比如0.5~2.0),它更多用于稳定训练早期阶段,而clip_grad_norm_更适合做整体保护。两者都常见,但别在同一个训练循环里混用。
4. 常见问题与排查技巧实录
4.1 优化器选型导致的问题
loss长时间不下降。如果你设好AdamW跑了几百步,loss曲线像心电图一样平稳成一条直线,优先检查学习率是否被调度器压得过低,或者warmup阶段还没结束——warmup前10%的步数里学习率是从0缓慢爬升的,如果总训练步数很大,前一小段loss几乎不下降是正常现象。排除掉这个原因之后,再去确认优化器是不是真的把参数接住了:训练几个step后打印[p.grad is not None for p in model.parameters()],看看是不是某些层因为被requires_grad=False冻结、或者没有参与前向计算而根本没有梯度。
loss快速冲到NaN。这是优化器训练里最常见的灾难现场。排查顺序:先把学习率降低一个数量级测试,很多NaN其实是学习率过大导致的;再检查数据中是否含有NaN或极大值,某些预处理环节会漏掉异常样本;最后看模型输出是否出现过exp溢出或log(0)的情况,如果损失函数里用了对数项,数值稳定性就需要加eps。优化器方面,Adam的eps默认是1e-8,如果数据量级较小,可以尝试调整到1e-7,给梯度二阶动量计算增加一层保护。
模型收敛了,但测试集上表现很差。训练集上loss能压到很小,但验证集表现不佳,这是典型的过拟合信号。此时优先考虑加大weight_decay,然后检查是不是用了过长的训练周期——余弦退火配合合适的早停(early stopping)机制往往能改善泛化。有些时候还需要审视优化器本身:如果任务里有大量稀疏特征(推荐系统常见),Adagrad类优化器对稀疏参数的更新更友好;如果模型本身是Transformer,那AdamW基本是最合理的选择,不用犹豫。
4.2 参数配置中的隐性坑
参数组的lr覆盖顺序没搞对。之前提到向优化器传入字典列表时,如果某个字典里没有指定某个参数,它会回退到全局默认值。有个反直觉的坑:全局lr是在优化器的所有参数组之后设定的,所有没有在分组字典里显式写出lr的参数都会使用这个全局值。如果你希望某个组走全局学习率,这个组的字典里就不要写lr;一旦写了,就按组里的值执行。看起来是小事,但分组多了之后非常容易混乱,建议把每个组用到的超参数都完整显式写上,虽然啰嗦,但不容易出错。
权重衰减作用在错误的目标上。PyTorch的SGD里weight_decay作用于所有参与更新的参数,包括bias和归一化层的scale参数。但实践共识是,bias和BatchNorm里的gamma、beta不应该做权重衰减,因为它们的主要作用是完成特征变换和归一化,惩罚它们反而会限制模型的表达空间。普通CNN微调场景中,建议把weight_decay设置为0的参数集合从所有参数直接去掉bias和norm层。
梯度累积配合调度器的步数错位。梯度累积和scheduler.step()的错位问题前面提过一次,但值得再次强调。很多框架的scheduler默认按epoch更新,如果你把data loader的batch size调小,同样的epoch数下实际更新次数变多,如果用按epoch更新、按step衰减的策略,学习率下降速度会比预期更快,最终出现“训练还没结束学习率已经逼近0”的情况。解决方式只有一条:统一用真实参数更新步数来驱动scheduler。
4.3 优化器状态与检查点恢复
训练到中途断电或者想从checkpoint恢复,很多人只恢复了模型权重,忘了优化器状态,这就是个隐藏的坑。优化器内部不只是简单保存了参数副本,它还有动量缓冲、二阶动量缓冲、以及当前每个参数组的学习率。如果不恢复这些状态,恢复训练后前几步的更新方向就会错乱,相当于训练“重启”,之前积累的动量信息全部丢失。
PyTorch里保存优化器状态的标准做法是把model.state_dict()和optimizer.state_dict()一起打包进同一个checkpoint文件:
checkpoint = { "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(), "epoch": epoch, } torch.save(checkpoint, "latest.pth")恢复时按相反顺序加载,先实例化一个同样配置的优化器,再optimizer.load_state_dict(checkpoint["optimizer"])。这里对优化器实例的初始状态并没有严格要求,因为加载操作会完全覆盖它的内部状态,只需要确保超参数配置一致即可。很多人会漏掉scheduler的恢复,但scheduler其实也有内部步数记录,不恢复的话学习率会从头开始计算,效果同样会打折扣。
4.4 从每步日志反推优化器状态
训练时单看loss: 2.31这种打印其实信息量很有限。我习惯在日志里加一项梯度范数和当前的learning rate:
total_norm = 0.0 for p in model.parameters(): if p.grad is not None: param_norm = p.grad.detach().norm(2) total_norm += param_norm.item() ** 2 total_norm = total_norm ** 0.5 print(f"step={step} loss={loss.item():.4f} lr={scheduler.get_last_lr()[0]:.2e} grad_norm={total_norm:.2f}")这行日志能帮你判断很多问题:loss在降但梯度范数越来越小,说明模型快收敛了,可以准备早停;loss还在高位但梯度范数从1跳到50,说明数据里可能有异常样本,需要用梯度裁剪兜底;梯度范数持续在很小的量级但loss完全不降,可能是模型进入了鞍点区域,单纯靠当前优化器很难走出来,此时可以考虑调大momentum、临时提高学习率,或者用sam这类在参数空间内额外做一步探索的优化器。
最后分享一个我屡试不爽的实用套路
兜兜转转写了这么多,压箱底的经验也就一句话:给你的每次训练预设一个“优化器配置模板”,而不是每次都从头拍脑子。我自己维护了一套固定的起点配置——CV任务用SGD(lr=0.02,momentum=0.9,weight_decay=5e-4,余弦退火);Transformer系列用AdamW(lr=2e-5到5e-5,weight_decay=0.01,带10%步数的线性warmup和余弦退火);RNN或强化学习这类时序任务用RMSProp或Adam,但务必开启梯度裁剪(max_norm从1.0起步)。这个起点并不一定最优,但它足够稳定,能让你在同样的基准下快速判断模型结构和数据本身的问题,而不是在优化器调参的泥潭里打转。
在这套模板的基础上,每次需要调整时只动一个变量,记录下它的影响,慢慢就会形成属于你自己的工作记忆——哪些任务吃大学习率,哪些任务必须有warmup,哪些任务换掉weight_decay立刻见效。优化器这件事,本质上不是选一个“最好的”,而是理解它之后把它变成你训练流水线上最可控的那个部件。希望这篇文章能帮你少走我已经走过的那些弯路。