
干这行久了你会发现一个规律很多人搭模型架构能搭得头头是道一遇到 optimizer 配置就开始随缘。去年我维护内部一个多模态检索模型时同一套代码、同一个 baseline把 Adam 换成 AdamW再把学习率从默认的 1e-3 降到 3e-4收敛曲线肉眼可见地稳了最终 top-1 检索准确率直接涨了 1.7 个点。这个改动成本几乎为零但收益比我在网络结构上折腾两周还大。所以后来我把常用优化器在多个任务上完整跑了一遍对比整理了一套从选型、参数配置到问题排查的方法也就是这个叫 Model-Optimizer 的实践项目。这篇东西就是那次实践的完整记录适合那些刚刚跨过入门阶段、准备认真调优自己模型的工程师也适合每次训练发散、急需一个排查思路的同学。1. 先搞清楚 Model-Optimizer 到底在解决什么问题1.1 优化器问题是训练里最便宜的“收益点”我见过太多团队花几周调网络结构、换 loss function却很少有人在 optimizer 上做系统实验。但实际情况是训练发散、loss 不降、收敛太慢这类问题八九成和优化器选择、学习率策略脱不开干系。网络结构决定的是模型能力上限优化器决定的却是能不能到达这个上限、以及用多快速度到达。很多情况下你换一个匹配当前任务特性的 optimizer比你在 backbone 上换一个 attention 模块带来的提升更直接。Model-Optimizer 这个项目本质上做的是“优化器选型与调参基线库”这件事。我搭建了一套统一配置入口让同一个任务可以一键切到 SGD、Adam、AdamW、LAMB 等不同优化器再配合几组标准学习率和 warmup 策略跑对比实验。它解决的痛点有三个第一每次换模型换任务都要重新从零调优化器缺少可复用的基线模板第二单跑一次训练看不出优化器之间的差异没法判断到底是结构问题还是训练策略问题第三论文里的优化器配置经常写不清楚需要一套自己的 benchmark 来验证合理配置区间。1.2 项目结构怎么搭才不白费功夫围绕这个目标我把项目代码组织成三个层次用起来非常顺手你也可以直接抄这个结构model-optimizer/ ├── configs/ │ ├── adamw_cv.yaml │ ├── adamw_nlp.yaml │ ├── sgd_cv.yaml │ └── lamb_pretrain.yaml ├── scripts/ │ ├── train.py │ ├── benchmark.py │ └── lr_range_test.py └── model_optimizer/ ├── optimizer_factory.py ├── scheduler_factory.py └── tracker.pyconfigs把每个优化器在不同任务下的完整配置拆成 YAML 文件代码不动只改配置文件scripts的三份脚本负责训练、横向对比和学习率范围测试核心工厂类则负责把配置解析成可训练的 optimizer 和 scheduler 组合。这套结构最关键的一点是把“对比实验”作为一等公民来设计。单次训练哪怕表现很好你也说不清楚是模型结构厉害还是优化器配置恰好匹配只有同结构、同数据、不同优化器放在同一个评测框架里跑结论才真正可迁移。这样做的好处不光在实验层面。当你手头同时有好几个项目在推进回来复现时不需要重新回忆当初用了什么参数所有关键配置都在配置文件里锁着。尤其对于多人协作的情况一个统一的 optimizer factory 可以避免“不同人用不同默认参数结果完全不同”这种经典悲剧。2. 主流优化器的核心差异从 SGD 到 AdamW 再到 LAMB2.1 SGD momentum带惯性的下山者先回到最朴素的随机梯度下降。每次迭代只用当前梯度来更新参数相当于一个完全没有记忆的蒙眼下山者每一步都只看脚底下的坡度。加上 momentum 之后更新方向变成历史梯度方向的加权累加相当于这个下山者有了“惯性感”可以在平缓区域靠冲量穿过小坑而不是困在原地。公式表达也很简单每一步先更新速度项再用速度项去更新参数v_t μ * v_{t-1} g_t θ_t θ_{t-1} - lr * v_t这里的 μ 通常取 0.9表示保留多少比例的上一轮速度。这个经典组合在不少 CV 分类任务上依然有很强的竞争力尤其是数据量中等、训练充分时它的泛化性往往好于自适应方法。缺点是学习率太敏感调大一点训练直接发散调小一点收敛慢到让人失去耐心。我在 CIFAR 和 ImageNet 子集上都验证过SGD momentum 配上 0.1 量级的学习率和余弦退火能跑出相当扎实的结果但你需要多花不少时间做 warmup 和学习率扫描不像 Adam 系列那样随便给一个数都能往下走。2.2 Adam 的自适应学习率到底自适应了什么Adam 和 SGD 最大的区别在于它给每一个参数单独维护了一个“学习率”。它的核心是同时维护梯度的一阶矩估计和二阶矩估计二阶矩相当于记录了每个参数维度上历史梯度平方的滑动平均。如果一个维度的梯度一直很大对应的有效步长会自动缩小如果一个维度的梯度一直很小甚至接近零有效步长会自动放大。写成更新式是m_t β1 * m_{t-1} (1 - β1) * g_t v_t β2 * v_{t-1} (1 - β2) * g_t^2 θ_t θ_{t-1} - lr * m_t / (√v_t ε)β1 默认 0.9 控制动量β2 默认 0.999 控制二阶矩的滑动范围ε 默认 1e-8 是为了防止除零而加入的小常数。这套机制的直观收益是你不需要像 SGD 那样精心手动调每个维度的步长它对学习率的敏感度低得多尤其适合稀疏梯度场景比如 embedding 层特别大的推荐模型和 NLP 模型。在我做 Model-Optimizer 的过程里Adam 是项目初期的默认选项因为它总能以一个不出错的姿态跑起来给你一个还过得去的 baseline。但它的隐含问题也埋在自适应里下文 AdamW 的动机正是针对这个缺陷来的。2.3 AdamW 的关键改进把权重衰减从梯度里解耦Adam 时代的常见做法是在 loss 里加 L2 正则项让梯度里多一项 wd * θ然后整个送入 Adam 的自适应机制。这在普通 SGD 里没问题但 Adam 会拿二阶矩去缩放这一项结果不同参数维度上的权重衰减被不均匀地缩小了正则效果变得不可控。AdamW 的思路很直白权重衰减不放进梯度而是在参数更新完之后单独做一步θ_t θ_{t-1} - lr * m_t / (√v_t ε) - lr * wd * θ_{t-1}这样衰减项完全不受自适应学习率影响每个维度的衰减力度是均匀的。这个“解耦”的理念在 Transformer 类模型上尤其关键因为这类模型的参数分布差异极大如果用 Adam 的耦合形式正则项基本是在随机缩放。我在中小规模 Transformer 上的横向对比里AdamW 普遍比 Adam 收敛更稳验证集损失也略优关键是它基本不需要比 Adam 多调什么参数直接换掉就能受益。现在它已经是我几乎所有非 CV 任务的默认选择。2.4 LAMB 和更大规模训练时的取舍到了大规模预训练场景比如 batch size 动辄几千上万时AdamW 也会遇到问题因为大 batch 会显著放大梯度噪声和参数更新幅度的不均衡。LAMB 在 AdamW 的基础上引入了一个 layer-wise 的自适应缩放对每一层单独计算一个 trust ratio让更新幅度大的层不被小梯度层拖累从而在超大 batch 下依然能保持稳定更新。数学上它就是在 AdamW 的更新式前面乘上一个系数θ_t θ_{t-1} - lr * trust_ratio * (m_t / (√v_t ε) wd * θ_{t-1})这个 trust_ratio 一般是当前层参数范数和更新量范数的比值。训练实践中LAMB 能让 64k 这种夸张的 batch size 稳定收敛但中小型项目里用不上它反而会因为额外计算和配置复杂度拖慢进度。做 Model-Optimizer 对比时我把它限定在 4k 以上 batch size 的实验里结论是它的优势只在那种规模下才真正显现。同类思路还有 Lion、Adafactor 这些变体它们都是在“自适应”和“参数状态开销”之间做取舍没有哪一个能通吃所有场景。2.5 选型决策表不同任务该怎么选把上面几种优化器的特点整理成一张决策表方便你在新项目开始时快速定位优化器内存开销收敛速度泛化性适合场景SGD momentum低慢高中小规模 CV 分类、数据量充足时Adam中快中NLP 任务、稀疏梯度、快速 baselineAdamW中快中高Transformer、多模态、大多数推荐任务LAMB高快中超大 batch 大规模预训练Lion低快中大规模扩散模型、ViT 类任务需要强调一个我在项目里反复确认过的结论没有“最好的优化器”只有“当前配置组合下最合适的优化器”。决策表的作用是缩小初始搜索范围而不是替你省掉 baseline 对比那一步。尤其当你切换任务领域时哪怕结构看起来相似最优优化器也可能完全不同。3. 参数配置方法真正影响训练结果的部分3.1 学习率基线从 LR Range Test 开始优化器选定了真正决定训练体验的是参数配置。我对所有新任务做的第一件事不是直接开跑完整训练而是先跑一次学习率范围测试也就是把学习率从一个极小值逐步线性增大到一个较大值同时记录每个 step 的 loss。观察到的 loss 曲线通常会先下降、到底部附近后急剧上升最佳学习率就落在曲线开始快速上升之前的那个区间一般取底部左侧一个数量级范围内的值。这个方法的原理是它在几十个 step 内快速暴露了一个任务对学习率的敏感区间省去了完整训练的漫长等待。我实测过的经验是CV 分类任务用 SGD 时这个测试给出的区间往往以 0.01 到 0.1 为中心而 Transformer 类任务配 AdamW合理起点基本落在 3e-4 到 5e-4极端情况下 1e-4。如果你不想做测试拿 AdamW 3e-4 当作默认起点是一个很稳的赌注然后以 0.3 到 0.5 倍的步长往下搜一轮基本能定位到可用区间。3.2 warmup、衰减策略与 batch size 的联动学习率不是一个孤立的值它必须和训练过程的时间轴配合。warmup 的核心作用是让模型在训练初期不要被过大的参数更新带偏尤其当模型是随机初始化时梯度的方差很大直接顶着最终学习率往上冲很容易陷入坏的初始区域。Transformer 类任务里我基本固定用 5% 到 10% 的 warmup 步数比例CNN 任务如果起步就用了比较大的学习率也会加一个短 warmup 来稳定初期波动。衰减策略里我最常用的是余弦退火它在训练后期把学习率平滑降低到接近零帮助模型在 loss 曲面的底部做精细收敛。相比之下阶梯式下降更容易在突变点附近造成 loss 回弹。还有一个容易踩的联动点当你把 batch size 翻倍时梯度噪声减小学习率通常也应该翻倍这叫做线性缩放规则但前提是你同步调整了 warmup 步数否则大学习率加短 warmup 可能会直接让训练前几个 step 就爆掉。学习率、warmup、batch size 是一个整体不要只调其中一个。3.3 betas、eps、weight_decay 的调整边界这三个参数经常被人忽略但在特定场景下它们就是决定性因素。betas 的第一项控制动量第二项控制二阶矩的衰减速度默认的 0.9 和 0.999 覆盖了绝大多数场景当你的数据分布变化较剧烈时把 β2 从 0.999 调到 0.95 左右可以让自适应学习率更快地适应新数据分布我在非平稳的增量训练任务里试过稳定性有明显改善。eps 这个常数在标准 fp32 训练里几乎没人动但在混合精度训练下1e-8 可能太小二阶矩估计稍小一点的维度除出来会直接产生超大更新间接导致 loss 变 NaN。我踩过一次挺深的坑AMP 开启后 loss 到某一步突然变成 NaN排查了数据、模型初始化、梯度裁剪全都没问题最后是把 eps 调到 1e-6 才稳下来。weight_decay 的起点按优化器不同要区别对待AdamW 配 0.01SGD 配 5e-4 是最常见的基线过大会导致明显欠拟合过小则几乎起不到正则作用。3.4 一份可以直接抄的配置参考基于 Model-Optimizer 项目的完整实验数据我整理了一份任务适配的起始配置表你可以在新项目里直接用然后根据自身数据规模微调任务类型优化器学习率betasweight_decaywarmup中小规模 CV 分类SGD momentum0.05 起—5e-45%大规模 CV 分类AdamW3e-4(0.9, 0.999)0.055%中小规模 NLP 微调AdamW2e-5 到 5e-5(0.9, 0.98)0.016%生成模型AdamW1e-4(0.9, 0.999)0.0110%超大 batch 预训练LAMB1e-3 量级(0.9, 0.999)0.0110%以 NLP 微调为例配套的 PyTorch 实现大概是下面这样里面用到了 transformers 库的线性衰减 scheduler 做早期预热import torch from torch.optim import AdamW from transformers import get_linear_schedule_with_warmup total_steps len(train_loader) * epochs warmup_steps int(total_steps * 0.06) optimizer AdamW( model.parameters(), lr3e-5, betas(0.9, 0.98), eps1e-6, weight_decay0.01 ) scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepswarmup_steps, num_training_stepstotal_steps )这套配置我在好几个下游任务里验证过基本都能跑出和论文接近的结果。要注意的是如果你换用了更大的模型或者更大的 batch记得把学习率按前面说的线性缩放规则同步调整。4. 训练现场的优化器问题排查4.1 loss 冲到 NaN优化器参数怎么背锅训练中 loss 突然变 NaN 是最让人崩溃的问题之一。排查时不要一上来就怀疑模型结构先看优化器和混合精度相关的几个嫌疑点。第一是 eps 太小在混合精度或二阶矩较小的维度上一个接近零的 v_t 会把更新量放大到溢出这时把 eps 调到 1e-6 甚至 1e-7 是成本最低的尝试。第二是梯度里本身混入了 NaN 或 Inf你可以每个 step 检查一下 grad norm如果发现某一步出现非有限值定位到具体层大概率是某一层的输出爆掉了这就不完全是优化器的问题但仍然可以通过梯度裁剪来兜底。第三是动态损失缩放机制在 AMP 下反复跳过更新。当 loss scaler 连续检测到 inf 梯度时它会减小缩放因子并跳过当前 step表现为 loss 一段时间不变然后 NaN。建议你把 scaler 的日志打开观察 skipped steps 的数量如果频繁跳过往往是学习率太大导致内部溢出这时候把 lr 降到原来的一半试试。还有一个容易被忽略的点加载预训练权重时如果旧的 optimizer state 和当前模型参数对不上也会导致更新值异常这种问题会在后面专门展开。4.2 loss 不降或者收敛极慢该往哪个方向查loss 一直不降但也没有发散这种情况最常见的原因是学习率选择不当而且通常是“太小”而不是“太大”。一个高效的做法是回到 LR Range Test把学习率测试区间扩大一到两个数量级重新扫一遍看当前学习率是否落在合理区间之外。另一个方向是检查优化器是否和任务特性匹配如果你的任务有大量稀疏特征SGD 会很难训练AdamW 会更快地激活低频参数如果是超大 batch普通 AdamW 又可能因为更新幅度不均衡而反复震荡这时候就要考虑 LAMB 这类自适应缩放的方案。我还会比较同一学习率下 SGD 和 AdamW 的初始 loss 下降速度差异。如果两者第一轮的 loss 下降速度差别极大通常说明当前数据分布更适合某一类优化器的更新机制而不是模型结构有问题。还有一个实际排查点检查优化器是否真的作用在全部模型参数上我遇到过一次 low-level bug某个子模块用了冻结参数的逻辑结果 optimizer 里只注册了一部分参数导致训练几个 epoch 后 loss 就不再下降因为后半段网络根本没有任何更新。4.3 加载预训练模型后换优化器为什么效果反而变差这是我在迁移学习场景下经常遇到的问题。一个模型在大规模数据上用 AdamW 预训练过你对下游任务做微调时如果把优化器换成 Adam即便学习率调得很低效果往往也会倒退。原因在于预训练过程积累的优化器状态包括一阶动量估计和二阶动量估计本质上携带了历史梯度分布信息这是与预训练阶段优化器绑定的。换掉优化器等于丢掉了这部分信息模型相当于在一个新的更新规则下重新适应前期自然会不稳定。解决办法有两个一是尽量沿用预训练阶段的优化器类型和超参只降低学习率二是如果一定要换优化器把学习率再往下压一个数量级并适当增加 warmup 步数给模型足够时间去适应新的更新规则。另外如果你要接着别人的 checkpoint 继续训练请务必连同 optimizer state 一起保存和加载。torch.save的时候不要只存model.state_dict()还要把optimizer.state_dict()和scheduler.state_dict()一并保存否则继续训练时 Adam 的二阶矩估计从零开始初期每个参数维度的有效学习率会异常放大轻则收敛变慢重则直接崩掉。4.4 常见问题速查表把上面这些经验沉淀成一张速查表真正遇到问题时照着查就好症状可能原因排查动作解决方案loss 突然 NaNeps 过小检查是否有超大 grad norm调大 eps 到 1e-6loss 突然 NaNAMP 下更新被跳过检查 scaler 的 skipped steps降低 lr或调整缩放因子loss 不降lr 过小跑一次 LR Range Test按测试结果调大 lr收敛后震荡剧烈lr 过大观察 val loss 波动幅度降低 lr 或换余弦退火微调效果低于预期换掉了预训练优化器对比原 checkpoint 的 optimizer 配置沿用原来优化器降低 lr继续训练发疯式上涨optimizer state 未加载检查 checkpoint 是否包含 state_dict保存并加载完整 optimizer state这张表只覆盖了最常踩的坑实际场景里还会有多种原因叠加的情况。排查原则永远是“先隔离变量再改配置”一次只动一个参数记录前后差异这样你才能对每个环境变量形成自己的判断力。5. 实操过程中的个人体会5.1 复现论文时优化器配置比网络结构更值得先抄我复现过的不少模型里论文正文里写的优化器配置和官方代码里的实际配置经常对不上官方开源代码里往往藏着更好用的细节比如正确的 weight_decay、torch 里默认和论文不一致的 eps。所以我的习惯是复现时永远以开源代码的 config 为首要参考没有代码时才按论文推导。在 AdamW 和 3e-4 这个起点上多数 Transformer 任务都能跑出一个可接受的 baseline你不用再花大量时间去搜他没有写出来的参数。这里强烈建议你把官方实现中的 lr、betas、eps、weight decay、warmup ratio 这五个字段完整记录下来它们比结构上的细微差别更能决定你的复现能否达标。5.2 优化器状态是一个需要尽早管理的存储成本当你把模型规模推到亿级参数以上optimizer state 的体积会相当惊人。Adam 的每个参数需要额外保存一阶动量、二阶动量和针对 AMP 的 master weights总存储开销大概是模型参数量的三倍以上。这意味着一个 10 亿参数模型光 optimizer state 就可能占据十几 GB 显存。我在设计 Model-Optimizer 时就专门加入了状态跟踪模块实时统计 model 参数、gradient、optimizer state 三者的显存占比这能快速帮你判断到底是不是状态开销吃掉了 batch size 的空间。如果你在显存受限的卡上训练大模型可以考虑 Adafactor它用行和列的二阶矩近似替代完整二阶矩能省下一大块显存代价是某些任务上收敛速度略慢。另外保存 checkpoint 时要注意优化器状态也是很大的 io 负担分布式训练里最好启用全局步数对齐确保所有 rank 保存的 state 是同一个步数的加载时才能恢复一致状态。5.3 把优化器对比做成自动化脚本一晚上出结论最后想分享一个我用了很久也特别推荐的小技巧。与其每次手动换配置、启动训练、盯曲线不如直接把对比实验脚本化。我会给同一份数据、同一个模型分别跑三组配置例如 AdamW 3e-4、SGD 0.05、Adam 1e-3每组用相同的 seed、相同的 warmup 设置全程记录训练 loss、验证指标和内存占用。第二天早上打开结果目录一张对比表直接告诉我哪个优化器在这个任务上占优再基于最优那组往下精调学习率。在这个项目里我还给每组实验自动打上了 git commit hash、环境依赖版本和数据集的 hash这样每次实验结果都可复现不会出现那种“昨天跑出来好结果今天复现不了”的尴尬。这套机制本身不复杂但它让调优从玄学变成了成体系的工程化操作。我在实际维护 Model-Optimizer 项目时最大的体会就是优化器绝不是凑数用的超参它是整个训练策略的核心组件多花一个晚上做横向对比远比闷头调几十轮 lr 有意义得多。