但凡用PyTorch训练过模型的人一定见过这两行代码optimizer torch.optim.Adam(model.parameters(), lr1e-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, momentumCV分类、微调预训练模型收敛慢对lr敏感SGDMomentum梯度指数滑动平均lr, momentum, nesterov大批量训练、稳定收敛需要精心调lr策略Adam一阶二阶动量lr, betas, epsNLP、多模态、通用训练可能错过尖锐极小值AdamWAdam 解耦权重衰减lr, betas, weight_decayTransformer、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)左右。换个角度理解momentum0.9意味着近似的历史窗口覆盖大约10个batch的梯度方向momentum0.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}, ], lr1e-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_ratio0.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, lr2e-5, betas(0.9, 0.999), eps1e-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_norm1.0) optimizer.step()阈值的选择需要看训练数据的梯度范数分布。我一般会在第一轮训练时每隔一定步数打印total_norm的数值观察它大致落在什么范围如果普遍在0.1到1之间那max_norm1.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_gradFalse冻结、或者没有参与前向计算而根本没有梯度。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 ratetotal_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(fstep{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任务用SGDlr0.02momentum0.9weight_decay5e-4余弦退火Transformer系列用AdamWlr2e-5到5e-5weight_decay0.01带10%步数的线性warmup和余弦退火RNN或强化学习这类时序任务用RMSProp或Adam但务必开启梯度裁剪max_norm从1.0起步。这个起点并不一定最优但它足够稳定能让你在同样的基准下快速判断模型结构和数据本身的问题而不是在优化器调参的泥潭里打转。在这套模板的基础上每次需要调整时只动一个变量记录下它的影响慢慢就会形成属于你自己的工作记忆——哪些任务吃大学习率哪些任务必须有warmup哪些任务换掉weight_decay立刻见效。优化器这件事本质上不是选一个“最好的”而是理解它之后把它变成你训练流水线上最可控的那个部件。希望这篇文章能帮你少走我已经走过的那些弯路。