1. 优化器到底是什么,为什么每个模型训练都绕不开它
这两年做大模型和各类深度学习项目的人越来越多,但很多新手第一次接触训练流程时,往往会"卡"在一个问题上:Model-Optimizer到底在做什么?
有人觉得优化器只是"更新参数的工具",随便选个 Adam 就完事;有人则把它当成调参玄学,靠感觉改学习率。实际上,优化器是整个模型训练里最贴近"数学本质"的一个环节——它决定了你的模型能不能收敛、收敛得快不快、最终落在什么质量的位置上,甚至直接决定了一次实验是小时级还是天级。
我用一个生活化的类比来解释:训练模型就像在连绵起伏的山脉里找最低点(损失函数的最小值)。模型本身是一张地图,梯度像指南针告诉你哪边是下坡,而优化器,就是你的"走路策略"。你可以每一步都老老实实顺着最陡的地方走(SGD),也可以给自己加个惯性冲过小坑(Momentum),还可以在平坦的地方自动迈大步伐(Adam)。步子迈多大、什么时候该收力、要不要带点阻尼、怎么绕过那些"看起来很低但其实不是最低"的陷阱——这些全是优化器管的。
这篇内容面向的对象很明确:正在用 PyTorch、TensorFlow 或 PaddlePaddle 训练模型的人,不管你是刚开始写第一个 CNN,还是已经在跑大模型微调,只要涉及训练脚本里的optimizer = ...这一行,都值得把优化器的原理、选型和调参逻辑搞清楚。
先说结论:优化器选型没有绝对的标准答案,不同任务、不同模型结构、不同显存条件下,最优选择完全不同。但如果你掌握了它的底层层逻辑,任何项目里你都能在十分钟内做出一个"至少不差"的选择,并且知道问题出现时该往哪个方向排查。
2. 主流优化器一网打尽:原理、特性与选型建议
聊优化器之前,得先统一一个认知——所谓"优化器",本质上是梯度下降算法的一种工程化实现。它的核心公式可以概括成一句话:
[ \theta_{t+1} = \theta_t - \eta \cdot g_t ]
其中 (\theta) 是参数,(\eta) 是学习率,(g_t) 是第 (t) 步的梯度(或梯度修正量)。所有优化器,无论叫 SGD 还是叫 Lion,本质上都在回答三个问题:用什么梯度、怎么修正梯度、学习率怎么变。
2.1 经典派:SGD 与 Momentum
SGD(随机梯度下降)是最朴素的优化器。它每次从训练集里抽一个 batch 计算梯度,然后直接沿着负梯度方向更新参数。优点极突出:计算开销小、显存占用低、在收敛后期往往能到达比自适应优化器更"锐利"的最优点。缺点是:收敛慢、容易在鞍点和局部极小值附近徘徊、学习率敏感。
我在实际项目中观察到,很多提升模型精度的论文,最终实验阶段都会切回 SGD 加 Momentum,就是因为它能帮模型收敛到更"平滑"的平坦区域,泛化能力更好。
Momentum(动量)则是对 SGD 的一个经典改良。它的思想很朴素:给梯度更新加一个"惯性",让参数更新方向不仅参考当前梯度,还参考历史梯度的累积方向。公式变成了:
[ v_t = \beta v_{t-1} + g_t ] [ \theta_{t+1} = \theta_t - \eta \cdot v_t ]
这里的 (\beta)(通常取 0.9)就是动量系数。在峡谷型损失曲面(一个方向陡、另一个方向平缓)上,SGD 会来回震荡导致前进缓慢,而 Momentum 会在震荡方向上互相抵消、在一致方向上加速,效果立竿见影。
我个人的经验是:如果做图像分类、目标检测这类 CV 任务,SGD+Momentum 往往比 Adam 更稳,尤其是 fine-tune 预训练模型的时候。训练精度上限更高,损失曲线也更光滑。代价是需要多花时间调初始学习率。
2.2 自适应家族:AdaGrad、RMSProp 与 Adam
SGD 家族的痛点在于学习率是一个全局固定标量,而实际模型中每个参数的梯度尺度差异很大。有的参数梯度大而稀疏,有的梯度小而密集。用同一个学习率去更新所有参数,显然不够聪明。
AdaGrad 做了第一波尝试:给每个参数单独维护一个历史梯度平方和,学习率按这个累积值的倒数缩放。梯度大的参数自动变小步长,梯度小的参数自动变大步长。效果是收敛更稳定,但累积平方和不断增大,学习率会趋向于零,导致训练提前停滞——这也是它后来没被广泛使用的原因。
RMSProp 改进了这一点,用滑动平均替换了累积平方和,这样学习率不会单调衰减到零,而是能持续保持自适应能力。RMSProp 在 RNN 训练上表现很好,也是当年的标配。
真正让自适应优化器走入千家万户的是Adam(Adaptive Moment Estimation)。它把 Momentum 的一次矩估计和 RMSProp 的二次矩估计结合在一起,并且加入了偏差修正机制,解决了训练初期的估计偏差问题。PyTorch 里一行代码就能用上:
import torch.optim as optim optimizer = optim.Adam(model.parameters(), lr=1e-3, betas=(0.9, 0.999), eps=1e-8)Adam 的优点不用多说:收敛快、对学习率不敏感(默认 1e-3 通常就能跑起来)、适应各种任务。如果做 Transformer、BERT 这类 NLP 模型,Adam 几乎是无脑选择。但我也得提醒一句:Adam 在部分任务上最终的泛化性能会略逊于精心调过的 SGD+Momentum,这一点在很多研究里都被反复验证过。
2.3 改良先锋:AdamW 与 LAMB,以及那些被低估的细节
如果说 Adam 是优化器界的大众情人,那AdamW就是现在的"正统西装"。AdamW 的核心改动是把权重衰减(L2 正则)从损失函数项中剥离出来,直接作用于参数更新。原文里 Adam 的权重衰减实现方式,会导致大梯度参数被过度惩罚,AdamW 修正了这个耦合问题。
这一改动对大规模预训练模型特别重要。现在主流的大模型训练基本都用 AdamW,配合权重衰减系数 5e-2 到 1e-2 左右。
optimizer = optim.AdamW(model.parameters(), lr=1e-4, weight_decay=0.01)再往后就是面向超大规模训练的LAMB(Layer-wise Adaptive Moments)。LAMB 的思路是给每一层计算独立的 Adam 更新量,再按层的尺度做归一化。它的意义在于:当 batch size 达到几千甚至几万时,梯度噪声和局部最优陷阱对全局学习率的敏感性急剧增加,LAMB 通过逐层适配解决了"大 batch 训练不稳定"的问题。BERT 预训练用 LAMB 可以把 batch size 从 256 提到 65536 而保持精度不显著下降。
另外还有两个近期值得关注的优化器:Lion和Sophia。Lion 只用符号方向(sign)更新,显存占用更小,在大模型训练中能省接近一半的优化器状态显存;Sophia 通过引入二阶信息做裁剪,在大模型上可以达到 AdamW 的 1.5—2 倍迭代效率。这些新优化器在特定场景下很强,但生态和稳定性还在验证中,现阶段主力项目我依然推荐 AdamW 或 SGD。
2.4 选型速查表
| 任务类型 | 推荐优化器 | 优势 | 注意事项 |
|---|---|---|---|
| 图像分类 / 检测 | SGD + Momentum | 泛化精度高、易 fine-tune | 对学习率敏感,需要 warmup 和衰减 |
| NLP 任务 / Transformer | Adam / AdamW | 收敛快、鲁棒性好 | 显存占用比 SGD 高约 2 倍 |
| 大模型预训练 / 微调 | AdamW / LAMB | 稳定支持大规模并行 | 关注 weight decay 与 lr 配合 |
| 强化学习 | Adam | 能处理非平稳目标 | 建议调低学习率 1e-4 左右 |
| 小数据集 / 简单模型 | SGD / AdaGrad | 简单直接、显存友好 | 小心学习率设置过大导致发散 |
选型之外还有个常被忽略的点:优化器状态显存开销。SGD 只需要保存参数和梯度,Momentum 多一份动量,Adam 需要保存一阶矩和二阶矩——这意味着一份模型参数要另外占用两份同等大小的显存。以 7B 参数的模型为例,用 Adam 仅仅优化器状态就是 56GB 以上的显存开销,因此大模型项目通常会用 AdamW 的分片版本(比如 ZeRO 或 FSDP),或者直接换用 Lion、Sophia 这类低状态优化器。
3. 实操环节:一次模型训练中优化器的完整配置
理论说再多,不如把一次真实的训练脚本拆开看。下面我以 PyTorch 为例,写一个图像分类模型微调的完整优化器配置流程,边写边解释每个参数的含义和取值依据。
3.1 配置前的几个关键参数理解
优化器构造看起来只有一行代码,但里面每一个参数都值得拆解清楚。最核心的包括:
- lr(学习率):决定了每一步参数更新的幅度。过大直接发散,过小收敛缓慢。合理的初始学习率通常需要根据优化器类型和任务数据量来确定,SGD 常见 1e-2 到 1e-1,Adam 常见 1e-4 到 1e-3。
- betas:Adam 的动量衰减系数。第一个值是梯度一阶矩的衰减率,第二个是梯度二阶矩的衰减率。默认值 (0.9, 0.999) 覆盖绝大多数场景,但在稀疏梯度占比较高的任务中,可以适当下调到 (0.85, 0.95)。
- weight_decay(权重衰减):对参数做 L2 惩罚,防止模型过拟合。对 AdamW 而言,这个参数直接影响最终模型的泛化性能,1e-2 是一个很好的起点。
- eps(数值稳定项):防止除零操作的极小值。默认 1e-8 就够用,但混合精度训练时建议提高到 1e-6,否则可能遇到数值不稳。
一个完整的例子如下:
import torch import torchvision.models as models import torch.optim as optim model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) optimizer = optim.SGD( model.parameters(), lr=0.01, # 初始学习率:微调通常比从头训练小一个量级 momentum=0.9, # 动量系数 weight_decay=5e-4, # L2 正则强度 nesterov=True # Nesterov 加速版本,略优于普通动量 )如果选择 Adam 系,则对应:
optimizer = optim.AdamW( model.parameters(), lr=1e-4, # 微调用 1e-4,从头训练可以尝试 1e-3 betas=(0.9, 0.999), weight_decay=0.01 )重点提醒一个常见误区:从头训练和微调的学习率必须区分对待。预训练模型已经落在损失曲面的一个"还不错"的位置,学习率太大会一步跨出这个优质区域,导致灾难性遗忘。我见过不少同学拿 Adam 的默认 lr=1e-3 去微调 ImageNet 预训练模型,结果损失不降反升,这就是典型的学习率过量问题。
3.2 训练过程中的监控与判断
配置好优化器之后,训练不是丢进去跑就完事。我习惯每 50 步打一次日志,实时监控三样东西:loss 数值、学习率当前值、梯度范数(grad norm)。
梯度范数是一个被很多人忽略的重要指标。如果梯度范数突然变成 NaN 或者线性膨胀到 1e+10,基本可以判定训练已经爆炸,大概率是学习率过高、数据有 NaN、或者混合精度下的 eps 太小。
在代码层面可以这样加钩子:
def log_grad_norm(model, logger, step): total_norm = 0.0 for p in model.parameters(): if p.grad is not None: param_norm = p.grad.data.norm(2) total_norm += param_norm.item() ** 2 total_norm = total_norm ** 0.5 logger.info(f"step {step}, grad_norm = {total_norm:.4f}")如果训练前期 loss 一直没有明显下降,可以先用一个很小的学习率(比如 1e-6)跑 20 步,确认 loss 能降——这一步的目的是排除数据流程和模型 forward/backward 的 bug。确认通了再逐步调学习率。这个"小步快跑"的思路能帮你节省大量排查时间。
3.3 学习率调度配合优化器的节奏控制
优化器负责"怎么走",学习率调度器(scheduler)负责"什么时候走多大步"。两者必须配合,常见的搭配方案有:
- 线性 Warmup + 线性 Decay:大模型预训练的标配。前 1%—3% 的 step 里学习率从 0 线性升到目标值,之后再线性降到最小学习率。Warmup 的作用是避免训练初期参数剧烈变动导致不稳定。
- Cosine Decay:学习率按余弦曲线从高到低变化。它能让训练后期以小步幅精细寻优,比 Step Decay(阶梯式下降)更平滑,目前在视觉模型微调里用得最多。
- ReduceLROnPlateau:当 loss 长时间不下降时自动把学习率乘以一个因子(比如 0.5)。对于小实验、快速验证场景非常实用,但大规模训练不推荐,因为不可预知性太强。
一个完整的 PyTorch 搭配如下:
from torch.optim.lr_scheduler import LinearLR, CosineAnnealingLR, SequentialLR total_steps = 10000 warmup_steps = int(total_steps * 0.03) # 前 300 步线性 warmup,后 9700 步余弦衰减 scheduler_1 = LinearLR(optimizer, start_factor=0.01, end_factor=1.0, total_iters=warmup_steps) scheduler_2 = CosineAnnealingLR(optimizer, T_max=total_steps - warmup_steps) scheduler = SequentialLR(optimizer, schedulers=[scheduler_1, scheduler_2], milestones=[warmup_steps]) for step in range(total_steps): loss = train_one_step() optimizer.step() scheduler.step()有个容易出错的小细节:PyTorch 里scheduler.step()调用时机要放在optimizer.step()之后。如果是多个 epoch 的循环,还要注意 scheduler 的 step 单位是 epoch 还是 iteration,一旦搞混学习率就会跳变,训练曲线会出现奇怪的突刺。
4. 训练调优的真实经验:常见问题与排查技巧
优化器相关的坑,我前前后后踩了不少。有些问题一眼能看出来,有些则隐藏得很深。这一部分我按真实场景整理了最高频的四类问题,以及对应的排查思路和解决手段。
4.1 损失不下降的排查思路
模型训练到第 500 步,loss 纹丝不动,这是新手最容易慌的情况。先别急着换优化器,按顺序查这几项:
- 确认数据是正确的:输入标签是否对齐、标签有没有错位。我曾经在一个文本分类任务里发现 label 做了一个 unseen 的映射,导致模型没有梯度可学,loss 退化成常数丢了好几天时间。
- 确认模型在学习:打印前几层参数的梯度范数,如果梯度全是 0,大概率是模型某一层输出把梯度阻断了(比如 ReLU 全死区、或者有 zero 初始化层)。
- 小幅调高学习率:降低正则强度、把 weight decay 暂时归零,看 loss 是否能动。如果还是不动,需要回到模型和数据流程排查。
- 用一个可控子集做 sanity check:选取 10—20 条样本,数据量小到模型可以完全过拟合,如果 loss 能够降到接近 0,说明数据和模型没问题,问题在优化器或更大范围的数据分布上。
用 SGD 系优化器时,我还特别推荐一个经典 trick:先随便把某个层的参数手动改成常数(比如全零或全一),跑一次 forward 对比预期输出和实际输出,可以快速定位是网络结构问题还是传播问题。
4.2 训练震荡与不收敛
训练曲线上下乱跳、看似在下降但始终不收敛,这个现象几乎人人都会遇到。出现这类问题,我第一个想到的就是学习率过大或者 batch size 过小。
学习率过大导致参数在最优解附近反复横跳无法收敛,解决手段很简单:把学习率除以 10 再继续观察。batch size 过小导致梯度估计噪声大,可以尝试把 batch size 加倍,同时按比例放大学习率——注意,线性缩放规则不是在所有场景下都成立,batch 翻了倍学习率并不建议同样翻倍,一般乘 1.2—1.5 就够。
如果用了 Adam 还有震荡,可以关注一下betas 参数。默认 betas=(0.9, 0.999) 在一阶矩上衰减偏快,导致更新方向容易受近期梯度影响。处理长尾数据分布的任务时,把一阶矩衰减系数调大到 0.95 甚至 0.99,往往能有效降低震荡。
另外一个隐蔽原因是数据顺序。如果训练集没有做充分 shuffle,模型会学到样本顺序带来的伪相关性,loss 曲线周期性波动。训练前把数据 loader 的 shuffle 打开、并且设置drop_last=True(当最后一个 batch 过小时)都能缓解。
4.3 分布式训练下的优化器行为
多卡训练时优化器的行为和大家单卡经验完全不同,这是很多人容易翻车的第二道坎。
数据并行(DP/DDP)下,每张卡各自前向反向得到梯度,DDP 会对梯度做 all-reduce 平均,然后统一更新。此时优化器的行为在数学上等效于放大 batch size 后的单卡训练,只要学习率随之调整,收敛行为基本一致。问题通常出在梯度不同步:某一张卡的数据出了问题(比如空数据、NaN 样本),它的梯度会污染全局。
梯度累积(Gradient Accumulation)也是常踩的坑:模拟大 batch 时,如果忘了在累积到预设步数后清零梯度,梯度就会一直叠加,优化器行为完全失控。正确写法是:
scaler.scale(loss).backward() if (step + 1) % accum_steps == 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()FSDP / ZeRO 分片优化器状态下,优化器状态被分到不同卡上,每张卡只维护自己分片参数的优化器状态。这会带来一个微妙的问题:不同卡上的更新步骤必须完全同步,如果数据不均匀导致某张卡提前optimizer.step(),整个训练就会开始发散。所以使用 FSDP 时务必保证所有卡的 step 数量一致。
4.4 常见问题速查表
| 问题现象 | 最可能原因 | 快速处理方案 |
|---|---|---|
| loss 为 NaN | 学习率过大 / 数据含 NaN / 混合精度 eps 过小 | 降低 lr、清洗数据、eps 调至 1e-6 |
| loss 几乎不变 | 数据或标签有问题 / 梯度阻断 | 跑 sanity batch,检查梯度流动 |
| loss 震荡不收敛 | 学习率大 / batch 过小 / shuffle 未开 | lr 降 10 倍,batch 加倍,开启 shuffle |
| 收敛速度极慢 | 学习率过小 / 优化器选型不合适 | 换 Adam 从 1e-3 起跑,或用 warmup |
| 收敛值不够低 | 过拟合 / 模型容量不足 / 用了不合适的优化器 | 试试 SGD+Momentum 做后期微调 |
| 多卡训练曲线异常 | 梯度不同步 / scheduler step 次数不一致 | 检查 DDP 同步逻辑,统一 step 计数 |
4.5 换优化器的正确姿势
项目中期想从 Adam 换到 SGD,或者从 SGD 切到 AdamW,直接改一行代码往往会让训练立即崩掉。原因是两种优化器的有效更新尺度完全不同:Adam 的更新量约等于学习率本身,SGD 的更新量等于学习率乘梯度范数,梯度范数量级大时,同样 lr 下 SGD 的实际步长远大于 Adam。
正确的过渡方式是:加载已有的模型权重,但把优化器状态丢空、重新构造,同时把学习率调整到新优化器的经验值范围。常见做法是从 AdamW 转 SGD 时 lr 从 1e-3 降到 1e-2 附近,具体值用 5 步小规模试跑来确定。这里还有一个更稳妥的方案:用余弦退火把原优化器跑到低谷,再用新优化器从低谷继续,避免两种优化器在中间直接切换引入的脉冲式更新。
5. 关于 Model-Optimizer 的几点个人体会
最后聊点我在项目里反复验证过的实战经验。
第一,优化器不是越高级越好。我见过不少团队跟风换新优化器(Lion、Sophia),有的确实省显存、有的在特定任务上迭代速度翻倍,但一旦换回 AdamW,之前的训练技巧、调度器配置、学习率经验全部作废。如果团队已经很熟悉 AdamW 的调参手感,没有强力的显存或速度诉求,不要轻易动优化器的根子。
第二,学习率始终是远比优化器更值得调的超参数。同一优化器下,老手和新手对学习率的把控可以造成 5 个点以上的精度差异。梯度范数的实时监控 + warmup + cosine decay 这套组合,在绝大多数任务中都能稳定逼近最优效果。
第三,optimizer 的二进制兼容性值得注意。PyTorch 升级小版本时,Adam 的 eps 和偏置修正实现有细微变化,会导致复现实验时 loss 曲线有微小偏移。如果你的项目需要精确复现,建议锁定 PyTorch 版本,并保存优化器状态字典用于继续训练(torch.save(optimizer.state_dict(), ...))。
第四,记录实验时,除了记录 lr 和 optimizer 类型,我还习惯把 betas、weight_decay、eps、warmup steps、scheduler 类型都记到训练配置里。一次训练实验要想复现,必须保证优化器的所有参数、调度器的所有参数和模型结构、数据 pipeline 一起锁定,少了任何一环都会得到"看起来差不多但完全对不上"的结果。
做模型训练这些年,优化器给我的感觉就是:它既是数学,也是手艺活。理解公式能让你快速定位方向,积累手感能让你少走弯路。希望这篇梳理能帮你把优化器这扇门推开,少踩一些我当年踩过的坑。