1. 为什么我劝你先别急着换模型,而是先搞懂优化器
干深度学习这些年,我见过太多同学一遇到模型效果不好,第一反应就是换网络结构、堆数据、加 trick,折腾一圈下来,loss 曲线还是跟心电图一样乱跳。说实话,很多时候问题压根不在模型本身,而在那个天天在跑、却又最容易被忽略的组件——优化器。
Model-Optimizer 这个名字听起来像某个具体项目,其实它指向的是深度学习训练中最核心的一道工序:怎么让模型的参数朝着正确的方向、以合适的速度、稳定地收敛到理想的点。说得更直白一点,优化器就是那个决定“模型参数每次该往哪走、走多大步”的引擎。没有它,反向传播算出来的梯度再准,也只是摆设。
这篇文章不是给你堆理论公文的,而是想把我自己在实际项目中反复调试优化器的经验、踩过的坑、以及最后沉淀下来的一套可直接上手的优化器实战方案,完整地整理出来。不管你是刚入门的初学者,还是已经训过几个模型但总觉得效果不稳定的进阶玩家,这篇内容应该都能让你少走不少弯路。你会搞清楚:Adam、SGD、AdamW 到底该怎么选?学习率设多少才算合理?warmup 到底起什么作用?梯度爆炸、loss 震荡、收敛过慢这些老大难问题,又该怎么通过优化器层面逐一击破。
先说结论:优化器选型和超参配置,对你的模型最终效果影响之大,往往超出你的想象。下面所有的内容,都是我在实际训练中真金白银砸出来的经验,希望能帮你省下几个通宵调参的夜晚。
2. 优化器选型:别只会 Adam,选错可能直接毁掉你的模型
2.1 从 SGD 到 AdamW:四款主流优化器的性格差异
每次有人问我“优化器应该用哪个”,我第一反应不是直接给答案,而是反问他:你的任务是什么类型?你的数据量大概多少?你的模型有多深?因为不同优化器的“性格”差别太大了,选错代价真的很高。
先说说最老牌、但依然能打的 SGD(带动量)。它就像一位稳健的传统派老师傅,每一步都基于当前梯度方向,加上历史动量的惯性,走得很稳。实践中最稳妥的用法是把 momentum 设成 0.9 左右。用 SGD 的好处是最终收敛点通常更“平缓”,泛化能力普遍比自适应类优化器好一些,尤其在 CV 分类、检测这类任务上表现很稳。但它的致命缺点也随之而来:收敛太慢,动不动就要训几百上千个 epoch,而且学习率特别敏感,设大了震荡,设小了半天不动,对新手极不友好。
Adam 的出现解决了大部分人的痛点。它同时利用了一阶动量(梯度的均值)和二阶动量(梯度的平方均值),让每个参数都拥有独立的自适应学习率。这意味着你不用花太多心思在人工调学习率上,默认的 0.001(即 1e-3)在不少任务上都能直接跑起来。但 Adam 也有它自己的“性格缺陷”:训练后期学习率被历史梯度累积压得太低,容易收敛不到极优点,而且它对学习率虽然比 SGD 包容,却远比很多人想象中敏感。
RMSProp 在 RNN、时间序列这类任务上是老牌强手,它通过滑动平均来调节每个参数的步长,很擅长处理梯度尺度差异大的场景。但它在 CV 大模型上的泛化表现不如 SGD 稳定,用的场景反而越来越窄了。
AdamW 是我目前的主力推荐。它的核心改动是在权重衰减(weight decay)和梯度更新之间做了“解耦”,让每一轮都会对权重做一个独立的、均匀的衰减,而不是像原版 Adam 那样把 L2 正则混进梯度里。别小看这个改动,实践表明 AdamW 能让模型收敛到更平的极小值,泛化能力明显优于原版 Adam,而且对大 batch 训练、Transformer 类模型格外友好。
2.2 我是怎么根据任务类型选择优化器的
根据我的实际经验,你可以直接照着下面这张“选型表”来做初筛,基本不会出大错:
| 任务类型 | 首选优化器 | 备选方案 | 适用场景说明 |
|---|---|---|---|
| 图像分类、目标检测(CNN) | SGD + Momentum | AdamW | 数据量充足、追求极致精度时,SGD 收敛点更稳 |
| Transformer、BERT、GPT 系列 | AdamW | Adam | 必须配合学习率 warmup,权重的解耦衰减很关键 |
| 图像生成(GAN、Diffusion) | AdamW | Adam | 生成任务对稳定性要求高,AdamW 更可控 |
| 强化学习、RNN 时序任务 | Adam / RMSProp | AdamW | 非平稳目标,自适应学习率能救你的命 |
| 小数据快速实验跑通 | Adam | AdamW | 先用 Adam 搭基线,稳定后再换最优算法 |
| 大模型预训练 / 大规模并行训练 | LAMB | AdamW | 超大 batch 场景下 LAMB 更好地平衡了精度和速度 |
这套选型逻辑背后不复杂:如果你的训练数据足够多、任务足够“传统”,SGD 这种简单优化器反而容易带来更好的泛化;如果你的模型又大又深、梯度分布极其不均衡,那 Adam 家族的自适应特性才能把训练稳住。我做项目时,一般会先用两个优化器各跑几个 epoch 对比一下 loss 下降趋势,用数据说话,而不是凭感觉赌。
2.3 一个真实的对比实验:同样模型,优化器不同,结果天差地别
去年我在一个医疗影像分类项目上,模型结构用的是 ResNet50,数据量大约八万张。一开始我图省事直接上了 Adam,学习率设 0.001,结果训练集 loss 蹭蹭往下掉,看起来一切顺利。结果一到验证集,准确率卡在 88% 左右再也上不去,换了好几个数据增强方案都没用。
后来我冷静下来,把优化器换成了 SGD + Momentum(0.9),初始学习率设为 0.01,并加了 Cosine 学习率衰减。跑完同样的 epoch 之后,验证集准确率直接干到了 92.5%。这个提升在比赛或者发论文里,可能就是决定性的。
那是不是说 Adam 不好呢?也不是。同一个项目我用 AdamW 重新测了一轮,把 weight decay 调到 0.05、学习率调成 0.0001,最终准确率也能接近 92%。这个对比给我的教训很深:优化器不是一个可以随手选完就忘的配置项,它需要你像做实验一样去对待,去和你的任务本身匹配起来。
3. 学习率与超参数:真正拉开效果差距的关键角力场
3.1 学习率策略:从固定值到 Cosine 衰减的进阶之路
学习率是优化器里最核心、也最容易翻车的超参数。很多学员最开始习惯从训练到结束都用同一个学习率,这在复杂任务上几乎注定失败。原因不难理解:训练初期需要大步快跑,快速跨过崎岖区域找到大致方向;训练后期需要在最优解附近小步慢走,精细地收敛到低点。一个僵硬的固定学习率不可能同时满足这两种需求。
我目前最推荐的方案是 warmup + Cosine Annealing 的阶梯式策略。原理上,在训练刚开始时,模型参数还是随机的,梯度方向往往不准确。如果这时候直接用大学习率,很容易让模型在早期就跑偏甚至炸掉。warmup 的作用是让学习率从 0 或一个极小的值线性爬升到预设峰值,给模型一个“热身期”,让梯度统计量稳定下来,然后再进入正常训练节奏。
至于 Cosine Annealing,则是让学习率在每一个周期内按照余弦函数从峰值平滑地降到接近 0。相比常用的 Step 衰减(比如每隔 30 个 epoch 除以 10),Cosine 曲线下降得更加平滑、没有突变,能够更从容地帮助模型探索和收敛到尖锐的局部极小值之外的平缓区域。这个策略在 ViT、BERT 和很多 Kaggle 比赛方案里都是默认配置,确实是简单高效的良心 trick。
3.2 关键超参定位:learning rate、weight decay、momentum、batch size
学习率怎么定初始值?这里分享一个我长期使用的经验法则:把 batch size 翻倍的同时,学习率也可以近似翻倍(线性缩放法则)。比如你的 baseline 是 batch size 64、学习率 1e-3,那么扩到 128 时,学习率可以试 2e-3。不过要注意,这个法则只在合理范围内成立,学习率过大依然会导致收敛不稳定。
再来说 weight decay。很多人忽略了它,但它对最终泛化性能影响巨大。在 AdamW 中,weight decay 是直接施加在权重上的,替代了传统 L2 正则。实践中,我常用的默认值是 0.01 到 0.05 之间。注意,Transformer 类模型通常要比 CNN 模型更喜欢稍大的 weight decay,而过大真的会让模型“欠拟合”,过小则会让训练后期权重震荡。你可以这样想:weight decay 就是给权重加的“阻尼”,防止参数为了拟合个别样本而丧失泛化能力。
momentum 在 SGD 里的默认值我几乎固定用 0.9,它对加快收敛、平滑梯度方向至关重要。而在 Adam 家族里,一阶动量系数 beta1 一般保持默认 0.9,二阶动量 beta2 在 NLP 任务中从 0.999 调到 0.98 甚至 0.95,反而会让训练更稳定,因为 beta2 太接近 1 时历史梯度平方累积过重,后期更新步长会过小。
3.3 参数计算实例:从一个 Kaggle 项目看完整配置
我拿一个真实的人物属性识别项目做完整演示。模型结构是 EfficientNet-B3,图片输入尺寸 256x256,batch size 我设定为 64。整体配置我做了以下几步计算和选择:
第一步,根据默认经验把初始学习率设为 0.001。第二步,我把总训练 epoch 设为 30,并决定前 3 个 epoch 做线性 warmup,从 0.0001 线性升到 0.001。第三步,剩余 27 个 epoch 采用 Cosine 衰减,最终学习率降到 0.00001 以下。第四步,weight decay 设为 0.02,SGD 的 momentum 设为 0.9。
关于 warmup 时长的具体计算,我一般是按“总迭代步数的 5% 到 10%”来确定。假设训练集 5 万张图,batch size 64,一个 epoch 就是约 781 步,30 个 epoch 就是 23430 步。那么 warmup 选择 3 个 epoch 约 2343 步,正好略少于 10%,完全符合经验区间。这套配置最终线上测试 AUC 达到了 0.978,相比我最初用默认 Adam 固定学习率的一版提升了整整 2 个百分点左右,验证集的表现也更加平滑。
4. 高阶优化技巧:从“能用”到“高效”的临时和长期方案
4.1 混合精度训练与优化器的隐藏联动
如果你还在用 FP32 训练大模型,那我建议你尽快试试 AMP(自动混合精度)。它把一部分运算用 FP16 执行,从而显存占用直接减半,训练速度也能提升一倍以上。但这里有个极其隐蔽的坑,如果你不了解优化器和 AMP 的关系,很容易把模型训崩。
AMP 在反向传播时,梯度值是以 FP16 精度参与计算的。如果某个参数的梯度值特别小,低于 FP16 能表示的下限,那么梯度会被直接“刷新”成 0。优化器一看梯度是 0,就不更新这个参数了,模型自然怎么训都训不动。所以实际框架里都内置了一个动态损失缩放(Dynamic Loss Scaling)机制:先把 loss 放大一定倍数,再反向传播,等优化器更新前再缩小回来。这样小梯度就能被保留在 FP16 可表示范围内了。
说到优化器和 AMP 的联动,我踩过一次印象特别深的坑。有一次我开了 AMP 之后,发现训练开始阶段 loss 在正常下降,但几十步之后突然变成 NaN,一度怀疑是模型出了问题。后来才排查到,是优化器状态参数(比如 Adam 里的 moment 和 variance)没有同步做缩放处理。解决办法也很直接:确保优化器参数初始化精度和模型参数一致,并且在保存和加载检查点时用 FP32 保存 optimizer 的 state_dict。如果你用的是 PyTorch,AMP 要配合 GradScaler 使用,并且 step 操作要写在 scaler.scale(loss).backward() 和 scaler.step(optimizer) 这个固定流程里,顺序错了必炸。
4.2 梯度累积与梯度裁剪:在有限显存下实现稳定大 batch
经常有同学问我“我显存不够,batch size 设小一点不就行了?为什么效果就是变差了?”这里涉及到一个关键知识点:小 batch size 带来的梯度噪声大,更新方向不稳定,模型的 BN 统计量也很难估计准确。这时候,梯度累积就是显存瓶颈下的最佳替代方案。
梯度累积的玩法很简单:假设你目标 batch size 是 128,但显存只支持 32,那就连续以 32 的 batch size 跑 4 个 forward/backward,期间不让优化器 step,只是把梯度累加起来。攒够了 4 个 batch 的梯度,再用累加后的梯度去更新一次参数。但我必须提醒你两个关键细节:第一,loss 要记得除以累积步数,例如 4 个 batch 的 loss 相加后除以 4,否则梯度的量级会放得过大,直接毁掉你的训练曲线;第二,如果模型里有 BatchNorm 层,梯度累积不能完全等价于真正的加大 batch,因为 BN 的均值/方差估计仍来自每个小 batch,可能引起统计量振荡。
还有一个几乎是大模型标配的防爆武器——梯度裁剪(Gradient Clipping)。我用 max_norm=1.0 作为默认值,它做的事情是计算整个参数梯度的 L2 范数,如果超过阈值就对所有梯度等比例缩放。这样做能防止某个异常 batch 把参数推出安全的参数空间。对于 Transformer 这类模型,梯度裁剪基本是必备操作,否则训到一半 loss 突然跳个 NaN 是常有的事。
4.3 EMA:一个让最终模型表现再上一个台阶的免费午餐
EMA(指数滑动平均)是我在做任何“吃着碗里看锅里”的竞赛时必上的一招。它的思路非常优雅:训练时我们不只用当前权重去评估,而是维护一个额外的“影子权重”,它是历史所有权重的指数平均。因为训练后期模型权重比较剧烈地振荡,而影子权重相当于给这些振荡做了一次低通滤波,往往能得到一个更稳定、更平滑的模型。
买一送一的是,EMA 写起来极其简单。比如一个 PyTorch 里实现 EMA 的常见流程,大致如下:
ema_weights = {} for name, param in model.named_parameters(): if param.requires_grad: ema_weights[name] = param.detach().clone() # 每个 optimizer.step() 之后调用 decay = 0.999 for name, param in model.named_parameters(): if param.requires_grad: ema_weights[name].mul_(decay).add_(param.detach(), alpha=1 - decay) # 评估时把影子权重替换到模型上,评估完再换回来这里 decay 值一般设 0.99 到 0.9999 之间。意思是影子权重对近期更新的依赖权重约 0.001 到 0.01,既能跟上训练节奏,又能过滤掉大部分高频抖动。我在表格识别和图像分割任务上分别加了 EMA,成绩普遍能提升零点几个点到一两个点,几乎没有成本。唯一的注意点是:EMA 只作用于可训练参数,BN 的 running_mean/running_var 不参与 EMA,而且保存模型时最好把影子权重一并保存,方便后续想换回来做实验。
4.4 优化器状态压缩:大模型训练时的救急手段
训练超过一亿参数模型的时候,你可能沮丧地发现:光是优化器的状态,占用的显存就比模型本身还要多。Adam 家族的标准状态由 fp32 的一阶动量(momentum)和二阶动量(variance)组成,加上模型参数和梯度本身,显存压力非常大。我的一台 24GB 显存的卡,在训练近亿参数的模型时,差点把显存拉爆。
好在业界已经沉淀了一套成熟方案:优化器状态分片(如 ZeRO)、混合精度存储和 8-bit 优化器。如果用的是 HuggingFace 生态,直接开启 zero_optimization stage 1 就能把优化器状态分片到多卡,进一步降低单卡显存;单卡场景则可以用 bitsandbytes 加载 8-bit Adam 优化器——它把一阶和二阶动量压缩成 8-bit 整数存储,极大地削减显存开销。实测下来,这种做法几乎不伤害最终精度,而显存节省非常可观。如果你正在被显存不足折磨,这是非常值得优先尝试的路径。
5. 训练过程的常见问题与排查技巧:从现象到根因
5.1 训练 Loss 不下降:先别怀疑模型,八成是优化器在作怪
Loss 纹丝不动是训练中最绝望的场景之一。我见过的绝大多数情况,都不是模型结构问题,而是学习率设置和优化器配置出了问题。排查我建议按下面的顺序来:
- 第一条,学习率过小:可以尝试把学习率放大 10 倍来粗测一下 loss 是否开始下降,如果动了,再逐步细调到合适值。
- 第二条,优化器选型和任务不匹配:比如序列任务用了轻率的 SGD,梯度方向不稳定,自然难收敛。
- 第三条,数据预处理有问题:特征没有归一化,会导致某些维度的梯度幅度远大于其他维度,自适应优化器会努力去适配,但普通 SGD 就很容易卡住。
- 第四条,模型长期没有收敛,也可能诊断出初始化和激活函数饱和的问题,这时候优化器能做的帮助有限,但依旧值得先用精细的学习率扫描一遍。
我自己的习惯是:遇到 Loss 不下降,先第一时间把学习率范围扫描一遍,画出不同学习率下的 loss 变化曲线,找一个最陡峭、最稳定的区间,再回来调整优化器参数。这个过程耗时半小时,省下的可能是后面几天的无效训练。
5.2 训练早期 Loss 剧烈震荡或飙升
如果你刚训了几个 step,loss 不是直线下降,而是在高位剧烈震荡,甚至直接涨上天,恭喜,你遇到了典型的优化器超参数激进问题。最常见的原因是学习率太大。这就像开车起步就踩死了油门,车身猛地窜出去,直接冲出赛道。解决办法是先将学习率降一个数量级,比如从 1e-3 降到 1e-4,观察曲线是否恢复平稳。
另一个被我反复强调的原因是 warmup 没设置或太短。我在训练一个多模态模型时,最初没有做 warmup,前 100 步 loss 直接爆表,之后量级也恢复不到正常水平。但加上 2000 步的 warmup 后,一切回归正轨。尤其对 Adam 这类有累积二阶动量的优化器,冷启动初期梯度方差极高,你不给一点缓冲时间,它就会把参数推到奇怪的区域去。
5.3 训练后期 Loss 下降很慢怎么办
训练进行到后半段,loss 逐渐趋于平缓,这是正常的。但如果平缓得像条水平线,而且验证指标一直上不去,那就要考虑是不是训练“撞墙”了。这时我常用的手段是降低学习率或者开启余弦退火重启。简单来说,就是给训练过程一个“第二次呼吸”,通过调低学习率让模型在更小的范围内精细搜索低点。
还有一种情况是优化器的 momentum 或 beta2 让更新变“懒”了。这时可以适当调低 beta2,从 0.999 降到 0.98 左右,这样能更及时地响应新的梯度方向,不至于让历史梯度把当前更新的步长压得寸步难行。如果再配合 EMA 做评估,往往最后几个百分点就是这样抠出来的。
5.4 一个屡试不爽的调优路径:先复现基线,再单变量迭代
最后一个我想认真分享的经验是想让优化器发挥出最佳状态,最重要的不是某一个出厂默认值,而是你围绕默认值展开的系统化实验循环。我自己的流程已经非常固定:第一步,在默认配置下把项目跑通一遍,确保数据、模型、优化器三者的链路没有问题,把这条基线认真记录下来。第二步,从学习率开始动刀,单独调大或调小数次,画出学习率与最终验证指标的关系曲线。第三步,在最优学习率附近固定下来,再单独调 weight decay 或 beta2,一次只动一个变量,记录对比表。
这个方法论看起来简单,却极其有效。很多人失败是因为同时改了学习率、优化器、batch size、数据增强,最后出了问题都找不到是哪一步引起的。单变量迭代确实慢一些,但在优化器的世界里,“快”往往是幻觉,“稳”才是真正的快。
6. 从 Model-Optimizer 延伸开来:训练提速和部署的最后一公里
6.1 训练加速:从优化器层面也能挤出水来
很多人以为训练速度只跟 GPU 算力有关,其实优化器的选择和使用方法也能显著影响训练速度。一个明显的地方是,SGD 类优化器状态占用极少,训练时显存占用更小,因此 Batch Normalization 的统计量估计更稳定,小批量下的泛化更好,训练速度也更快。而 Adam 家族因额外的二阶动量计算,单步耗时相对更高,所以如果你的任务用 SGD 就能跑出好效果,用 SGD 反而是性价比更高的选择。
另外,打开 cudnn.benchmark 并把输入数据固定为同类尺寸都会带来一定的速度提升,这个属于基建层面,但配合优化器的“减少 state 访问”逻辑,可以从系统底层节省访问显存的时间。遇到大模型,可以直接使用 fused optimizer(比如 Apex 提供的 FusedAdam),这类算子把优化器的多步更新融合进一个 kernel,能明显减少 kernel launch 的开销。单步更新虽然只省几毫秒,积少成多就是按小时计算的时间节省。
6.2 算子融合与 gradient checkpoint 的搭配战术
说起显存优化,gradient checkpointing 是更彻底的手段。它用额外的重计算来换取显存下降,原理是通过在 forward 过程中不保存中间激活,反向传播需要时重新算一遍。使用之后显存占用可以降低 50% 以上,代价是训练时间增加约 30%。这是一笔典型的“空间换时间”交易,和优化器本身没有直接关系,但配合起来相当重要。
我曾在一个 6GB 显存的显卡上训练 ViT-Base,如果不做任何优化,batch size 只能设为 1,优化器状态占了大量空间。开了 gradient checkpointing 并把 batch size 提到 8,同时搭配 AdamW 后,模型效果才逐渐显现出来。所以如果你想用 save memory 的方式训练大模型,这三件事建议一起做:gradient checkpointing + 半精度训练 + 优化器状态压缩。
6.3 如何把优化器最终效果,延续到推理和部署阶段
训练完模型只是第一阶段,很多人在部署时发现训练集和验证集指标不错,但真实环境下却大打折扣。这正是因为训练时模型只学会了“训练时的数据分布”,到了真实世界便不太适应。这个时候,除了在训练时依赖优化器寻找更平缓的极小值(例如用 AdamW 替代 Adam),在推理阶段也要加入一些投入小收益大的操作。
最典型的是把 BN 层的统计量重新校准一次,俗称“fold BN”。具体做法是:使用训练集的一小部分数据,重新跑一遍前向,统计 BN 层的 running_mean 和 running_var,然后把它们固化进模型。这一步能有效消除训练和推理时数据分布不一致带来的偏差。再配合上训练阶段已经通过 EMA 学到的平滑权重,部署后的模型会更稳定,也能更好地应对实际数据的波动。
7. 实操后的总结与建议:把优化器变成你自己的工具箱
这篇文章从优化器的选型讲到超参设置,再从 AMP、梯度累积讲到 EMA 和问题排查,信息量确实不小。但这些内容底层贯穿的,其实是同一个理念:优化器从来不是训练流程里一个once-and-for-all的默认开关,而是你可以反复打磨和调优的重要武器。我自己的项目经验是,当你把优化器从随手用默认 Adam 升级成有策略、有依据、有实验支撑的配置之后,模型的收敛稳定性、最终精度和训练效率都会有肉眼可见的提升。
在这里我也根据自己的实践经验,再给几点很实际的小建议:一是写实验日志,给每次训练记录下优化器类型、学习率、batch size、warmup 和 weight decay,这样在任何时候都能回溯当初的设定;二是勇于尝试替代方案,AdamW 未必在每个任务上都比 Adam 强,SGD 也未必永远是最佳选择,一切用验证集指标说话;三是关注显存和速度的平衡,优化器的选择不只是精度问题,更是工程资源的全局优化问题。
我在实际训练中发现,很多看起来高深莫测的操作,拆解开无非是一步步实验、对比、分析之后沉淀下来的习惯。调参的速度会随着实验数量逐渐上升,你对优化器“手感”也会越来越准。希望这篇内容能帮你把 Model-Optimizer 这块拼图真正拼进你自己的训练流程里,也希望你下次再打开训练脚本,不是随手敲一行 optimizer = Adam,而是能真正想清楚:这个优化器,到底为什么值得出现在这里。