上周有个同事拿着训练日志来找我,说换了三个优化器,loss 都稳稳停在 0.4 附近下不去。我让他先别动优化器,把训练数据抽出来看两眼。十分钟后他开始怀疑人生——训练集里某个类别有接近一半的样本标签是错的。
“Model-Optimizer”这个词最近在社区里讨论热度不低,但很多人把它理解成了“换一个 optimizer”,这是我对这个主题最大的误读点。模型优化从来不是单点操作,而是一条完整的链路:数据质量、训练策略、模型结构、推理部署,每一个环节都可能成为瓶颈。这篇文章把我实际跑过的优化流程和踩过的坑完整捋一遍,适合正在调模型的新手,也适合想系统梳理优化方法的工程同学。
从训练侧的优化器选型、超参组合,到推理侧的量化、剪枝、蒸馏,我都会给具体的判断依据和实操参数,而不是只丢一个结论。
1. 先算清楚账:训练慢或不收敛,先别急着换优化器
很多人一遇到 loss 不降就怀疑是优化器选错了,实际上优化器大多数时候是背锅的。我现在的排查顺序固定成三步:先查数据,再查代码逻辑,最后才动优化器。
1.1 第一步永远是拿小数据做“过拟合冒烟测试”
这个方法我几乎在每个项目里都会做,成本极低,收益极高。具体操作:从训练集里抽 50 到 200 条样本,关掉数据增强、关掉 Dropout、关掉 weight decay,用一个足够大的模型去跑。如果这种情况下 loss 都不能降到接近 0,说明模型结构、优化器配置或者反向传播链路本身有基础问题;如果 loss 能顺利降下来,那问题基本可以锁定在数据或正则设置上。
拿图像分类举例,100 张图训练几十个 step,loss 应该能明显下降。如果你看到 loss 在 2.3(多分类交叉熵的初始值)附近磨蹭不动,先别怪 Adam 不够聪明,大概率是标签有噪声或者模型和损失函数没接对。
这个测试的原理很简单:小批量下模型有足够容量去记忆样本,如果连记忆都做不到,那参数更新环节一定出了故障。
1.2 数据问题比优化器问题常见得多
我见过太多人花一周时间调学习率,最后发现是数据 pipeline 的问题。常见的数据坑包括:
- 标签噪声:标注错误、漏标、错位,尤其是在 OCR、检测这类需要对齐坐标的任务里。
- 特征单位不一致:比如把数值特征直接丢给神经网络,没有做归一化,导致某个特征的值域过大,梯度更新被它主导。
- 归一化方式错误:图像归一化用的 mean/std 和训练时不一致,模型会学得很别扭。
- 数据增强过强:CutOut、MixUp、随机裁剪叠加太狠,训练集上 loss 也会降不下来。
- 序列任务里 padding 的 attention mask 没传对,模型偷偷学到 padding 位置的无意义信息。
我做过一个 OCR 检测项目,模型 loss 怎么都压不下去。查了三天才发现,训练数据里有一批空标注的图被当成背景,标签和图像内容根本没对齐。修完数据之后,同一套优化器配置,loss 立刻恢复正常。
1.3 梯度监控是区分责任归属的关键手段
数据查完之后,你还需要一个客观指标来判断到底是不是优化器的问题。最简单有效的办法是监控梯度范数。
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 print(f"grad norm: {total_norm:.4f}")如果梯度范数在正常训练中波动很大,比如从 0.1 突然跳到 100,那确实需要调整学习率或者加梯度裁剪;如果梯度一直是 0,说明网络存在死区,或者损失函数根本没有连到某些参数上;如果梯度正常但 loss 不降,那要考虑数据质量或者优化器超参是否过于保守。
只有到了这一步,才值得把优化器本身拿出来审视。
2. 优化器选型不是玄学:SGD、Adam、AdamW、LAMB 的性格差异
优化器选型本质上是在选择“梯度更新策略”。不同策略有各自的偏好和代价,理解它们的更新逻辑,比背一堆默认参数有用得多。
2.1 从更新公式看四种优化器的底层逻辑
SGD with Momentum 的核心是动量惯性。它的更新可以理解为下山时保留前几步的速度方向,即使当前位置梯度为 0,也会因为惯性继续向前,从而穿过局部极小点。它的优点是行为可预测,配合合适的学习率和调度,泛化性通常很好。
Adam 在 SGD 的基础上,为每个参数维护一阶矩 m 和二阶矩 v。二阶矩相当于在每个参数维度上装了一个“测速仪”——哪边地形陡,自动缩小步长;哪边平缓,步长自动放大。这让 Adam 对学习率不那么敏感,起步快,适合稀疏梯度和不确定性较高的实验。
AdamW 和 Adam 的区别在于 weight decay 的处理方式。Adam 里的 L2 惩罚会先加到梯度里,再被二阶矩归一化,导致大权重和小权重的衰减力度不一致;AdamW 把权重衰减从梯度中解耦,直接在参数更新时乘以衰减系数,行为更干净。这也是为什么 Transformer 类模型几乎清一色用 AdamW。
LAMB 在 AdamW 的基础上对每一层单独计算更新量缩放,允许在超大批量下也能保持稳定更新,常用于大规模预训练场景。类似的还有给 CV 大规模训练用的 LARS。
| 优化器 | 核心机制 | 常用场景 | 典型配置 |
|---|---|---|---|
| SGD+Momentum | 动量惯性 | 图像分类、精细调优 | lr=0.01~0.1, momentum=0.9 |
| Adam | 一阶+二阶动量自适应 | 稀疏特征、快速出效果 | lr=1e-3, betas=(0.9,0.999) |
| AdamW | 解耦权重衰减 | Transformer、NLP、ViT | lr=1e-4~3e-4, wd=0.01 |
| LAMB | Layer-wise 自适应 + 大 batch | 超大规模预训练 | lr=1e-3~3e-3, wd=0.01 |
2.2 我的选型经验
NLP 和 Transformer 系列,我无脑默认 AdamW,beta2 经常设成 0.98,weight decay 设成 0.01。用 SGD 去训 BERT 不是不行,是收敛速度会让人怀疑人生。
CV 分类或检测任务,如果只是快速出一个效果,Adam 一天内就能跑出一个像样结果;如果追求最终精度,我通常先用 Adam 把流程跑通,确定 loss 和指标正常后,再切到 SGD+Momentum+cosine 精调。这种组合在分类任务上的泛化表现经常比 Adam 好一截。
推荐系统和稀疏特征场景,Adam 几乎是最稳的选择。稀疏梯度下,自适应步长能避免稀疏参数更新过慢,同时不会因为某些高频特征把步长压得太小。
超大规模分布式训练,batch size 到几千甚至上万后,普通 Adam 很容易不稳定,这时候 LAMB 或 LARS 更合适。它们的核心思想是让每个 layer 的更新尺度保持一致,不会因为 batch 变大导致整体梯度范数爆炸。
3. 比优化器更关键的是超参组合:LR、warmup、weight decay 的联动
优化器选对了只是第一步,真正拉开差距的是超参组合。我见过同一个 AdamW,有人 lr 设 3e-5,有人设 1e-3,效果天差地别。下面是我调参时比较固定的思路。
3.1 学习率策略:warmup 和 cosine decay 是怎么起作用的
自适应优化器在训练初期有个普遍问题:模型刚开始时二阶动量统计是空的,直接用峰值学习率会导致早期更新幅度过大、震荡剧烈。warmup 就是让模型先小步走几步,等梯度统计稳定后再放大步长。
实践里 warmup 步数一般是总步数的 5% 到 10%,如果训练很短,甚至可以到 20%。对应的峰值学习率不是越高越好。我之前在视频模型上用 AdamW,峰值 lr 从 3e-4 提到 5e-4,训练前几个 epoch 的 loss 就出现明显抖动,加上 warmup 之后才压住。
学习率调度我用得最多的是 warmup + cosine decay,把学习率从峰值沿余弦曲线降到最低值。后期步长变短,参数更新的随机扰动减少,更容易收敛到平滑的极小值区域。120 epoch 的典型配置可以是:SGD,峰值 lr=0.05,warmup 5 epoch,cosine decay 到 0。
3.2 weight decay 不是 L2 这么简单
很多人以为 weight decay 就等于 L2 正则化,这在 SGD 下近似成立,但在 Adam 下完全不成立。Adam 里把 wd 加到梯度里,会先被二阶矩归一化,导致实际衰减效果在每个参数上不均匀。AdamW 直接把衰减项放在参数更新之后,相当于对每个权重按固定比例缩一缩,行为更稳定。
这个区别在 Transformer 上特别明显。用 Adam+L2 训练 BERT 类模型,经常需要把 wd 调得很小才能稳定;换用 AdamW 后,wd=0.01 就是很可靠的默认值。如果你还在用老代码,建议把 Adam+L2 改成 AdamW。
3.3 我常用的调参顺序:一次只动一个变量
我的调参顺序是这样的:
- 固定优化器和 batch size。
- 粗略扫学习率,按 1e-4、3e-4、1e-3、3e-3 数量级扫,找到验证集上表现最好的一档。AdamW 从 1e-4 到 3e-4 起步很安全。
- 固定学习率后扫 weight decay,顺序从 0、1e-5、1e-4、1e-3、1e-2 找。
- 如果换 batch size,用 linear scaling rule 联动调整学习率。比如 batch size 从 256 翻到 512,lr 近似翻倍;但实际中要配合 warmup,否则容易爆。
- 最后才动 betas 或 momentum 这类细节参数,它们对最终精度的影响通常小于前两项。
一次只动一个变量是铁律,否则你根本不知道是哪个改动起作用。
4. 训练结束不等于优化结束:推理侧的量化和剪枝实战
模型在训练集上收敛了,优化工作只完成一半。线上延迟、显存占用、吞吐量这些部署指标,往往需要单独的优化手段,这也是“Model-Optimizer”应该覆盖的范围。
4.1 先 profile 再动手,别凭感觉做压缩
我见过不少同学一上来就量化 int8,结果延迟没降多少,精度还掉了一截。正确的做法是先定位时间花在哪。
用 torch.profiler 或者 onnxruntime benchmark 先测三件事:端到端延迟、纯推理延迟、单算子耗时。小模型部署里最容易被忽略的瓶颈是 CPU 端的数据预处理,图片解码、缩放、归一化这些操作如果没做异步,GPU 算得再快也被 CPU 拖着跑。
常见的瓶颈分类可以这样判断:
| 症状 | 瓶颈方向 | 对策 |
|---|---|---|
| 端到端延迟高,纯推理延迟低 | 数据预处理 | 异步加载、预处理并行、算子融合 |
| 纯推理延迟高,GPU 利用率低 | 小算子反复 launch | 算子融合、减少 kernel 数 |
| GPU 利用率高,延迟仍高 | 模型本身计算量大 | 量化、剪枝、换小模型 |
| 显存占用高 | 激活值/中间结果 | 梯度重计算、显存池化、batch 减小 |
4.2 PTQ 和 QAT 的选择逻辑
量化是目前收益最直接的压缩手段。PTQ(训练后量化)不需要重新训练,直接拿一组校准数据统计激活值范围,把 float16 或 float32 的模型转成 int8。动态量化适合 CPU 场景,权重是 int8,激活保持浮点,NLU 模型在 CPU 上部署时这个方案性价比很高;静态量化适合 GPU 和边缘设备,激活也转 int8,需要校准集。
我的经验是:PTQ 掉点在 1% 以内,直接上;掉点 2% 到 5%,可以先尝试把敏感层保留浮点,只量化其他层,对比一下精度变化;如果还不够,再考虑 QAT(量化感知训练),也就是在训练时模拟量化误差,让模型适应低精度表示。
有一点要提醒:有些硬件对 per-channel 量化支持不好,选型时先查清楚目标推理引擎支持哪种量化粒度。
4.3 剪枝和蒸馏组合使用
剪枝分结构化剪枝和非结构化剪枝。结构化剪枝直接删除通道或层,部署收益直观,不需要特殊硬件支持;非结构化剪枝是把权重矩阵中接近 0 的稀疏掉,这在普通 GPU 上未必加速,只有在支持稀疏计算的硬件上才有意义。
蒸馏的作用是用大模型的软标签去训练小模型。温度 T 控制软标签的平滑度:T 太低,软标签接近 one-hot,小模型学不到额外信息;T 太高,会把噪声也放大。常见区间是 3 到 7,具体要扫一轮。蒸馏的收益通常很稳定,小模型能拿回大模型精度的 70% 到 90%。
实际操作里,我会先做结构化剪枝减掉一部分通道,再对剪枝后的模型做蒸馏微调,最后量化到 int8。三步叠加,延迟通常能压到原来的一半以下,精度损失可以控制在可接受范围内。
5. 模型优化中的高频翻车点与排查链路
这一节把我在多个项目里重复踩过的坑列出来,每一条都是血泪换来的。
5.1 NaN 的完整排查链路
模型训练突然跑出 NaN,不要急着调低学习率或者加 grad clip。我的排查链路是:
- 固定随机种子,复现问题。
- 检查输入数据中是否有 NaN 或 Inf,尤其是文本和特征工程阶段。
- 在 backward 之后逐层打印梯度统计,看梯度从哪一层开始异常。
- 关掉混合精度,看问题是否消失。
- 如果用了 GradScaler,监控它的 scale 值有没有不断变小甚至溢出。
- 检查网络里有没有除零操作、log 的输入是否为 0、softmax 后接 log 是否有下溢。
我之前遇到过一个案例,问题出在自定义 transformer layer 里把 attention score 除以了 sqrt(d_k),但 d_k 被错误地设成了 0。这种问题跟优化器半毛钱关系都没有,调学习率只会浪费一下午。
5.2 验证集掉点但训练集收敛很好的归因方法
训练集 loss 很低,验证集不涨,第一反应是过拟合,但不止这一种可能。
如果训练集和验证集都有收敛,但验证集指标明显低于训练集,最常见原因确实是过拟合,优先加 weight decay、Dropout、数据增强。如果验证集 loss 来回震荡,先检查评估逻辑是否稳定,比如类别数量少时,单次评估的随机抖动会被放大,可以多次评估取均值。如果 finetune 预训练模型,还要检查 BN 的状态:model.train() 和 model.eval() 被混用,BN 统计量在验证阶段还在更新,验证指标会忽高忽低。
5.3 混合精度与多卡同步的几个隐蔽问题
PyTorch 混合精度训练的典型写法是:
scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()这里容易踩的坑是 GradScaler 的 scale 状态在多卡 DDP 下没有正确同步。我曾在 DDP 训练里忘了处理 scaler 的状态,训练了十几个小时,模型效果奇差,最后才发现是梯度累积和混合精度配合出了问题。
另一个常见问题是 optimizer.zero_grad() 的位置。PyTorch 默认梯度累加,如果你在 backward 之后、step 之前调用 zero_grad,上一轮的梯度会和当前轮累加,导致更新方向完全错乱。正确顺序是:
optimizer.zero_grad() loss.backward() scaler.step(optimizer) scaler.update()梯度累积场景下,还要记得把每个微批的 loss 除以累积步数,否则更新步的梯度会被放大 N 倍。AMP 的 GradScaler 和 DDP 的梯度 all-reduce 配合时,也需要保证 scale 在所有进程上保持一致,否则会出现各卡更新量不同的问题。
说到这块,我最想分享的一个经验是:混合精度的坑大多不是显存不够,而是数据和数值范围突然变了,模型训练表现变得难以解释。遇到这类问题,先回退到 fp32 复现一遍,问题是否依然存在,再逐个环节加回去,能省下大量排查时间。
模型优化这件事,本质上是在多个互相影响的环节之间找平衡。优化器只是其中一环,但它经常被当成替罪羊。我现在的习惯是:拿到一个新任务,先花一天时间把数据冒烟测试跑通,再固定优化器把学习率和 weight decay 扫一遍,训练稳定之后才去考虑量化、剪枝这些部署侧优化。Model-Optimizer 这个名字虽然听起来像一个工具,但它背后代表的是从数据到训练再到部署的整条链路的全局视角,单点死磕往往事倍功半。