1. 从“改完loss却训不动”说起:为什么人人都该重视Optimizer
我先说个自己的经历。有次我调一个图像分割模型,网络结构没动,把损失函数从 DiceLoss 换成了带边界约束的变体,结果训练 Loss 一路飙升,从 0.3 涨到 3.8,前十几个 epoch 完全看不到收敛迹象。当时第一反应是“损失函数写错了”,排查了半天代码,最后发现罪魁祸首只是优化器参数没跟着改——学习率还是原来那套 3e-4,而新的损失函数梯度尺度完全不一样。
那之后我就养成了个习惯:任何一个模型项目开工前,先花半小时想清楚用哪个 Model-Optimizer、学习率怎么定、weight decay 该不该开,而不是直接抄别人的 training config。
Model-Optimizer 翻译过来就是模型优化器,但这个名字其实有点误导。它并不是“优化模型结构”的工具,而是在神经网络训练过程中,负责根据梯度更新模型权重的那套算法。你可以把它理解为方向盘和油门的配合逻辑——梯度告诉你该往哪个方向调,优化器决定你这一步踩多深、要不要带惯性、遇到坑洼路面怎么处理。
本文要聊的就是这个东西:主流优化器各自解决什么问题、内部原理是什么、实际项目中怎么选、参数怎么调,以及我踩过的一些坑。适合正在训练自己模型的算法工程师、刚入门深度学习的学生,以及那些“训完模型但说不清楚为什么收敛这么慢”的开发者。
2. 优化器的本质:梯度下降的三种“进化版本”
要理解 Model-Optimizer 的价值,首先要回到最原始的梯度下降。
2.1 从“一步步挪”到“带惯性地走”
最朴素的批量梯度下降,更新公式长这样:
weight = weight - learning_rate * gradient这个公式没有任何争议,但真正用起来问题很多:
- 学习率固定,太小收敛慢,太大容易震荡甚至发散。
- 所有参数共享同一个学习率,稀疏特征和稠密特征没有得到差异化对待。
- 容易卡在局部最优或鞍点,尤其是高维非凸问题里。
于是出现了两个关键改进方向。一个引入了动量(Momentum),让更新方向不仅看当前梯度,还参考历史梯度的“惯性”。另一个引入了自适应学习率,让每个参数拥有独立的学习率,代表就是 AdaGrad、RMSProp。
这两种思想合并之后,诞生了目前实际项目中使用频率最高的两个优化器:Adam和AdamW。
2.2 Adam 为什么会成为默认选项
Adam 全称 Adaptive Moment Estimation,核心是把一阶动量(梯度均值)和二阶动量(梯度平方均值)同时做指数滑动平均。一阶动量决定更新方向,二阶动量决定每个参数的学习步长。
我把它的核心更新逻辑简化成一句话:梯度大且持续的方向,步长会被压缩;梯度小且罕见的方向,步长会被放大。这一点对稀疏特征极其友好。
为什么会这样?因为 Adam 用二阶动量做了归一化。当一个参数历史上梯度很大,分母就大,更新步长就小;反之,梯度一直很小,分母就小,更新步长就大。这种机制让 Adam 在训练初期尤其好用,能快速穿越平坦区域,到达较优的局部区域。
一个很直观的对比实验:同样的图像分类任务、同样的 ResNet50,SGD+Momentum 可能要 30 个 epoch 才到 75% 准确率,Adam 经常 10 个 epoch 就能到 72%,前期的收敛速度肉眼可见地快。
2.3 AdamW:一个“重量级”修复
AdamW 和 Adam 的差别,表面上只是 weight decay 的实现位置变了,但实际效果差距非常大。
原始 Adam 里,weight decay 被写进梯度计算中,和梯度一起参与了二阶动量的归一化。这导致 L2 正则化在 Adam 中的效果被严重削弱,因为分母放大了带正则项的梯度,正则项的实际贡献被抵消了,而且还会干扰自适应学习率的计算逻辑。
但 L2 正则化的本意是让权重自身向零收缩,它应该与梯度无关。AdamW 把 weight decay 直接放在参数更新之后做,让衰减项独立于梯度归一化过程。这样做的好处是正则化行为稳定可控,在 Transformer 类模型上效果尤其明显。
我个人从 2022 年之后,几乎默认所有新项目都用 AdamW,而不是 Adam。如果你在跑 BERT、GPT 系列、ViT 这些模型,AdamW 可以说已经是事实标准。
# 典型配置:AdamW 在 PyTorch 中的用法 from torch.optim import AdamW optimizer = AdamW( model.parameters(), lr=1e-4, betas=(0.9, 0.999), eps=1e-8, weight_decay=0.01 )3. 选型决策:什么时候坚定用 AdamW,什么时候该换回 SGD
很多初学者会问:既然 AdamW 这么好,为什么还有人用 SGD?答案是——不同优化器适合不同阶段,Adam 系擅长前期快速下降,SGD 系擅长后期精雕细琢。
3.1 一张表看清主流优化器定位
| 优化器 | 核心机制 | 优点 | 短板 | 典型使用场景 |
|---|---|---|---|---|
| SGD | 纯梯度更新 | 理论性质好、泛化性强 | 收敛慢、调参难度大 | CV 图像分类、目标检测最终训练 |
| SGD+Momentum | 加入惯性 | 能冲出平坦区、速度快于纯 SGD | 仍不能自适应当前梯度尺度 | 经典 CNN 的迁移学习精调 |
| Adam | 一阶+二阶动量自适应 | 收敛快、对学习率不敏感 | 泛化性略差、权重衰减等效 | NLP、Transformer、生成模型初期训练 |
| AdamW | Adam+解耦权重衰减 | 正则化稳定、泛化提升 | 相对 SGD 仍偏“激进” | BERT、GPT、ViT、几乎所有新项目 |
| AdaFactor | 低秩分解二阶动量 | 省显存、适用于超大模型 | 收敛稍慢 | 超大规模 Transformer 训练 |
| Lion | 符号动量+逐参数缩放 | 显存占用少、收敛极快 | 学习率敏感 | 大模型、扩散模型训练 |
3.2 三个维度帮你做选择
第一个维度是模型规模。模型参数超过一亿时,优化器的显存开销就开始心疼了。AdamW 要为每个参数保存一阶动量、二阶动量和参数本身,一共三份显存。Lion 只需要保存一份动量,能省不少内存。
第二个维度是任务类型。如果你在做 NLP 预训练或微调 Transformer,AdamW 是安全牌。如果做图像分类、检测、分割,SGD+Momentum(momentum 0.9,weight decay 5e-4)依然是经典配置,而 AdamW 效果也不会差太多。
第三个维度是训练阶段。常规做法是先用 AdamW 快速热启动,找到合适的参数分布后,再把优化器切换为 SGD 做最后 20 个 epoch 精调。这种“热身+收尾”的组合在 Kaggle 图像比赛中屡见不鲜。
3.3 我的默认推荐方案
如果没有特殊理由,我的默认配置就是 AdamW + 带预热的学习率调度 + weight decay 0.01~0.05。原因很简单:这个组合在大多数任务上都能得到不错的收敛曲线,而且参数鲁棒性强,不需要反复试错。
如果你追求“每提升一个点都算数”,比如打比赛或者做行业落地项目,那 SGD 精调环节值得保留。我见过一句话说得很有道理:Adam 负责“找到好地方”,SGD 负责“走到最好”。
4. 手把手实操:从学习率到 warmup 的完整调参流程
这一部分是我最想写的,因为网上的教程大多只告诉你“要用 AdamW”,但几乎没人教你具体怎么把一套配置从零调到能用。
4.1 第一步:基准学习率的估算方法
不要一上来就拍脑袋定 3e-4 或者 1e-3。虽然 Adam 对学习率不敏感,但并不意味着可以乱设。
一个非常实用的做法是跑一次学习率扫描:从 1e-5 开始,每个 batch 小幅提升学习率(乘一个固定倍数),同时记录 loss 变化,画出 lr 和 loss 的关系曲线。通常曲线呈 U 形:学习率太小时 loss 几乎不降,学习率太大会直接发散。选择下降到最快但还没有明显震荡的那个点作为初始学习率。
我在实际项目里,经常得到的最佳学习率区间是:
| 模型类型 | 初始学习率区间 |
|---|---|
| ResNet 系列 + SGD + Momentum | 0.01 ~ 0.1 |
| BERT 类 + AdamW | 2e-5 ~ 5e-5 |
| GPT 类 + AdamW | 1e-4 ~ 3e-4 |
| ViT 类 + AdamW | 1e-4 ~ 5e-4 |
| 扩散模型 UNet + AdamW | 1e-4 ~ 2e-4 |
注意,这个表格只是起点。batch size 增大,一般要相应调大学习率;数据量增大,收敛变慢,学习率可以适当提高。
4.2 第二步:warmup 的必要性与设置方式
Transformer 类模型在训练初期很容易出现 loss 剧增的情况,原因很简单:模型权重在最开始非常不稳定的情况下,Adam 的二阶动量还没累积,此时梯度方差很大,直接大步长更新会导致参数被“弹飞”。
warmup 就是让学习率在最初的几千步内从零线性或余弦式地升到目标值。PyTorch 里的实现方式我一般用 transformers 库的 get_linear_schedule_with_warmup 或者手写:
# 手写 warmup + decay 的最小实现(伪代码,逻辑完整) # total_steps 是整个训练的总步数,warmup_ratio 比如 0.05 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, total_steps - warmup_steps)) return max(0.0, 1.0 - progress) scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)经验值:warmup 比例在 5% 左右,也就是总共训练 10000 步,前 500 步做线性预热。如果数据集很小或者模型随机初始化不稳定,比例可以提高到 10%~20%。
4.3 第三步:Betas、eps、weight decay 的合理范围
这些参数可能很多人从来没改过,但理解它们能帮你解决很多玄学问题:
betas=(0.9, 0.999):默认值。beta1 控制一阶动量衰减,0.9 表示近 10 步的梯度方向被综合;beta2 控制二阶动量衰减,0.999 表示近 1000 步的梯度平方被综合。如果你的任务梯度变化剧烈,beta2 可以调到 0.95;如果训练特别稳定,可以保持默认。
eps=1e-8:防止除零的保护项。在混合精度训练中,如果用了 float16,建议把 eps 调大,比如 1e-6 或 1e-7,否则二阶动量中的微小数字可能被下溢成零,导致更新异常。
weight_decay=0.01~0.05:Transformer 常用 0.01,CNN 任务常用 5e-4 或更低。它类似于“每个参数变小时额外踩一点刹车”,让模型不依赖某个局部特征太深。
举一个我调出来的具体案例:一个文本分类任务,BERT-base,batch size 32,总训练步数 4000 步。最终配置为 AdamW,lr=3e-5,warmup_ratio=0.1,weight_decay=0.01,betas=(0.9, 0.999)。在验证集上的 F1 比默认配置(lr=2e-5,无 warmup 策略调整)提升了 1.2%,不算特别夸张,但稳定性明显更好。
4.4 第四步:fp16 混合精度下的优化器特殊处理
混合精度训练如今几乎是标配,但很多人没注意到 fp16 和优化器之间的相互作用。
两个容易踩的坑:
第一个是梯度缩放。AMP 模式下,loss 会先被放大,反传后再缩放回正常尺度。如果优化器里的 eps 太小,缩放后的梯度在更新时可能变得过于敏感。我在训练一个 1.5B 模型时遇到过 loss 变为 NaN,最后把 eps 从 1e-8 改成 1e-6 解决了。
第二个是 master weight 的维护。PyTorch AMP 会自动把 fp32 的参数副本作为优化器的更新对象,此时显存会有额外消耗。如果碰上显存告警,可以使用 bf16 配合 CPU offload,但基础配置上,建议显存充足时以 fp32 master weight 为默认。
5. 实测对比:同一模型用不同优化器,Loss 和精度差多少
理论说完了,我放一个自己跑过的对比实验,方便你有直观感知。
5.1 实验设置
- 模型:ResNet-18,CIFAR-10,图像分类
- 训练轮数:60 epoch
- 数据增强:随机裁剪、水平翻转、标准化
- 目标:比较 SGD+Momentum、Adam、AdamW 三种优化器的收敛速度和最终精度
- Batch size:128,学习率按各优化器最佳实践单独设置
5.2 核心结果
| 优化器 | 学习率 | Top-1 准确率(60 epoch) | 收敛到 80% 所需 epoch |
|---|---|---|---|
| SGD+Momentum | 0.1(带 cosine decay) | 91.2% | 约 18 epoch |
| Adam | 1e-3 | 89.1% | 约 8 epoch |
| AdamW | 1e-3,wd=0.01 | 90.4% | 约 9 epoch |
这个结果非常有代表性。Adam 类前期收敛快,但 SGD 在长期训练后能反超;AdamW 虽然只改了一个细节,精度却比 Adam 高了 1.3 个百分点,接近 SGD 的水平。
5.3 从实验中得到的三个结论
第一个结论:“收敛快”不等于“精度高”。如果你只看前 20 个 epoch 的 loss 曲线,可能会误以为 Adam 是最好的,但训练到 60 epoch,差距就显现出来了。
第二个结论:weight decay 的解耦确实有效。在同样的任务上,AdamW 比 Adam 多了 1.3 个点,而两者唯一的区别就是权重衰减的实现方式。
第三个结论:优化器与学习率调度器需要协同设计。SGD 用 cosine decay 效果好,AdamW 用带 warmup 的线性 decay 效果好;把两者的调度器互换,效果都会打折扣。
5.4 再说一个扩散模型场景
扩散模型的训练是另一番天地。我跑 Stable Diffusion 类模型的训练时,默认使用 AdamW(lr=1e-4)效果稳定。实验中发现改用 Lion 优化器后,收敛步数能减少约 20%,但学习率要降到大约 1/3 才能匹配机制差异,直接沿用默认 lr 容易发散。
这个案例说明了另一个道理:优化器不同,学习率并非唯一需要关联调整的参数,复现配置时切忌只改优化器名称而忽略这些联动项的调节。
6. 高频问题排查:Loss 不降、NaN、显存爆炸怎么办
这一节直接给问题清单,都是我实际处理过的。
6.1 Loss 完全不下降
先检查三件事:
- 优化器创建时是否传入了
model.parameters()?如果模型在优化器创建之后才被放到 GPU 或更换了 DataParallel 包裹,优化器持有的参数可能和实际训练参数不一致。 - 是否忘了
optimizer.zero_grad()?累积梯度会让更新方向越来越乱。 - 学习率是否设置得过小?此时 loss 曲线是一条几乎水平的线,没有明显下降趋势。
6.2 训练到中途 Loss 变为 NaN
优先排查顺序:
| 排查步骤 | 操作 | 触发原因 |
|---|---|---|
| 检查学习率 | 减小到当前值的 1/10 | 学习率过大导致发散 |
| 检查 eps | 调大至 1e-6~1e-5 | fp16 下二阶动量下溢 |
| 检查梯度剪裁 | 设置 max_grad_norm=1.0 | 梯度爆炸 |
| 检查数据标签 | 确认无 NaN 或异常值 | 数据问题 |
| 检查模型输出 | 确认 line 1 无 inf | 除零/溢出 |
还有一个容易忽略的点:Embedding 层的梯度特别容易出现异常值。如果模型里有 nn.Embedding,且词表较大,可以单独给 embedding 参数设置更小的学习率或更大的 eps。
6.3 显存不足但想继续训练
优化器是显存消耗的重要来源,有些方案能救急:
- 换用 AdaFactor,把二阶动量降维,显存可以减少约 30%。
- 开启 bf16 替代 fp16,如果 GPU 支持(如 A100、H100),bf16 的指数范围和 fp32 一样,常用场景下不容易溢出。
- 使用
torch.optim.AdamW的foreach=False逐参数更新,牺牲一点速度换取更低峰值显存。
6.4 优化器重头再训:断点续训时的 state_dict 还原
模型保存时不仅要保存 model.state_dict(),优化器的 state_dict 也必须一并保存,否则断点续训时二/一阶动量全部清零,相当于 Adam 被重新初始化,几百步之后 loss 曲线会大幅波动。
推荐的最小保存结构:
checkpoint = { "model_state_dict": model.state_dict(), "optimizer_state_dict": optimizer.state_dict(), "scheduler_state_dict": scheduler.state_dict(), "epoch": epoch, "best_metric": best_metric, }恢复时依次 load,这三者缺一不可,少一个都可能让续训效果打折扣。
7. 两个容易被忽视但影响巨大的细节
这些细节我是在实际项目里吃过大亏之后才记住的。
7.1 优化器参数在分布式训练中的同步方式
使用 DDP(Distributed DataParallel)时,梯度在各卡间做了 all-reduce 同步,此时优化器在每个进程上独立更新。如果你的 batch size 翻倍是因为“卡多了”,而不是“单卡 batch 变了”,那学习率理论上也应该随之调整,线性缩放法则中常会触发这种联动。
但更常见的坑是 LayerNorm 和 bias 参数不该做 weight decay。合理做法是:所有 LayerNorm 的权重和 bias 不衰减,Embedding 一般也不衰减。这个只在代码里设置一次,但对最终效果影响不小。
7.2 学习率调度器与优化器的耦合顺序
PyTorch 中,scheduler.step() 的调用时机很有讲究。
- 如果按 epoch 调度,应该先
optimizer.step(),再scheduler.step()。 - 如果按 iteration 调度,每个 batch 之后都执行
scheduler.step()。 - 千万避免用 ReduceLROnPlateau 时每个 step 就去检测是否 step,它需要 monitor 的指标更新后在合适时机调用。
很多人的 loss 曲线像锯齿,就是因为 scheduler 的触发逻辑和优化器更新不同步。
8. 一些我自己的“野路子”经验
最后分享几个不太会在论文里出现,但实际好用到不行的技巧。
8.1 用 AdamW 先跑 10 个 epoch,再切到 SGD
这是我做分类任务时最常用的套路。规律是:前期用 Adam 快速穿越平坦区域,后期用 SGD 精调收敛。一开始就切换的时机可以这样定——当验证集 loss 连续 5 个 epoch 不再下降时,就把优化器切到 SGD,并把学习率衰减到之前的 20%~30% 继续训。
这种办法比单纯使用 AdamW 通常能再提升 0.5%~1% 的精度,而且极其容易实现。
8.2 把优化器的动量用于梯度裁剪状态检测
在一次训练中,我发现 loss 突然飙升后又自动回落。后来定位到是某个 batch 数据分布异常。如果想精确找出“罪魁祸首”,可以在每次 optimizer.zero_grad() 之前检查梯度范数,如果超过预设阈值,就把这个 batch 的 data index 记录下来,这样能及时发现数据管线的问题。
8.3 最后的免费午餐:优化器参数的模型并行重放
当模型太大、需要切分到多卡时,每张卡上的优化器只更新自己拥有的参数 shard。此时要注意学习率的全局一致性,如果不同 shard 的参数更新频率差异很大,可以考虑按参数分组设置学习率,而不是全模型统一一档。这种方式在训练多模态大模型时非常有用,虽然看起来繁琐,但确实能让每个模块按照自身需求进行收敛。
我的整体感受是:Model-Optimizer 是训练技术里性价比最高的“杠杆点”。模型结构定了之后,你能动的参数本来就不多,优化器和学习率调度是少数几个全局影响面很大的旋钮。
如果你现在训的模型效果不满意,先不要急着换网络结构,试着把优化器换到 AdamW、做点 warmup、把 weight decay 调到 0.01,大概率比重新设计模型来得更快。最后再分享一个大实话:不要神化任何一个优化器,项目里跑出来的曲线和指标才是唯一标准。配置是一个随时可以调整的起点,而不是结论。