ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

深度学习优化器选型与调参实战:从SGD到AdamW,解决模型收敛与泛化难题

深度学习优化器选型与调参实战:从SGD到AdamW,解决模型收敛与泛化难题 经常有人问我“我的模型为什么不收敛”、“同样的代码换了数据之后效果怎么差了这么多”。我第一个反问的往往是“你用的哪个优化器”然后很多人就愣住了。说实话在深度学习的整个训练闭环里优化器是最容易被当成“黑盒旋钮”来对待的一环但几乎所有和训练稳定性相关的疑难杂症追根溯源都能在优化器上找到影子。这篇文章我想好好聊聊“Model-Optimizer”这个在我项目里占据重要位置的工程化组件结合我自己的实际踩坑经历帮大家把这个环节彻底盘明白。先说清楚这篇文章适合谁你如果已经能跑通一个简单的训练脚本但对损失函数曲线为什么长这样、换优化器之后效果为什么忽上忽下、怎么把训练过程变得又稳又快感到困惑那这篇内容就是为你准备的。我不会只罗列优化器原理而是会把选型逻辑、超参数联动、训练工程里容易被忽略的几个物理细节一次讲透。1. 一半的炼丹bug都和Optimizer有关先看它在训练闭环里的真实角色1.1 优化器不是“更新参数”而是在“猜下一步往哪走”很多人看优化器觉得它就是一行代码optimizer Adam(model.parameters(), lr1e-4)然后往训练循环里一扔就完事。这种理解不能说错但会让你在调试时完全使不上力。我对优化器的理解是它是整个训练过程中唯一负责“根据当前状态决定参数下一步怎么动”的组件。模型本身决定了“误差面”长什么样损失函数决定了哪个方向是“更好”但具体怎么在误差面上往下走、走多大步、要不要参考历史的步子、不同参数要不要区别对待——这些都是优化器说了算。把它类比成下山过程会更直观。你的模型是一座山上的背包客损失函数是山的高度梯度是每个人脚底感受到的坡度。优化器就是决定这个背包客“怎么迈腿”的脑子和习惯是每一步都重新判断方向还是沿着一个惯性方向闷头走是每块肌肉参数都用同样的力气还是根据地形实时调整不同肌肉的发力。同一个山坡不同习惯的人走速度和摔跤的概率完全不一样。1.2 从SGD到Momentum为什么加一个“惯性”训练快这么多早期的训练基本全靠SGD随机梯度下降它的更新规则简单到有点朴素# SGD的更新本质当前梯度方向 * 学习率一步一算 param - lr * grad问题在于真实的损失表面并不光滑。你可以把mini-batch的梯度理解为“带噪声的方向估计”——单看一步方向可能偏得离谱如果每一步都机械地按当前瞬时方向去走路径就会呈锯齿状不仅慢还容易在小坑里打转。Momentum动量的引入就是为了解决这个锯齿问题。它维护一个“历史梯度的指数滑动平均”每次的实际更新方向是“过去一段时间的平均方向加上当前的校正”。用大白话说就是给下山过程加了惯性山谷里左右来回的震荡互相抵消真正向下的趋势被放大。# Momentum不只是看当前梯度还带上了历史上的“动量” momentum_buffer momentum * momentum_buffer grad param - lr * momentum_buffer我自己实测下来在CNN图像分类任务上同样的epoch数加了0.9动量的SGD比裸SGD收敛速度快小一倍左右最终精度通常也更高。这个差距在loss面比较“陡峭”或者batch size偏小时的场景尤为明显。1.3 自适应方法登场Adam为什么能“无脑”跑通大多数模型SGDMomentum的问题是它对“参数尺度”一视同仁。但神经网络里的参数并不平等——有些层比如embedding的梯度天然就大有些层比如深层全连接的梯度天然就小。如果你用同一个学习率去更新它们那些梯度小的参数会被“饿死”梯度大的参数又会“一步跨过头”。Adam的核心创新在这一点上非常优雅它对每个参数单独维护一阶动量梯度均值和二阶动量梯度平方的均值实际的更新量等于“一阶动量除以二阶动量的平方根”。翻译成人话就是梯度大的参数更新时自动缩小步幅梯度小的参数更新时自动放大步幅。这就是它“自适应学习率”名字的来源。# Adam更新规则简化版每个参数有自己的“有效学习率” exp_avg beta1 * exp_avg (1 - beta1) * grad exp_avg_sq beta2 * exp_avg_sq (1 - beta2) * grad ** 2 param - lr * exp_avg / (sqrt(exp_avg_sq) eps)这也解释了为什么Adam在NLP、多模态等模型结构复杂、参数尺度差异大的任务上几乎是默认选择——它不需要你对每个模块的梯度尺度做太多人工干预相对“无脑”就能收敛得不错。2. 主流优化器选型SGD、Adam、AdamW到底按什么标准挑2.1 SGDMomentum老黄牛型选手什么场景它反而最强被Adam“娇惯”过的朋友往往看不上SGD觉得它又慢又蠢。但说句公道话SGDMomentum在CV领域、尤其在使用预训练ResNet或ViT做微调时效果经常比Adam系列好。我个人的理解是SGD的更新路径更“老实”每一步都直接反映当前batch的真实梯度方向不容易因为自适应机制而把某些细微特征给“抹平”了。一个典型的例子是目标检测里的微调。我在用SGDMomentum微调Faster R-CNN时loss能稳稳当当地降到预期水平换成Adam后前期收敛确实快但到了后期loss尾巴明显变高mAP也掉了1-2个点。这种“前期快、后期疲软”的现象其实在不少文献里都被提到过——Adam在泛化性能上普遍逊于SGD。如果你对泛化性能有极致要求手上的数据集不大比如几万张图以内训练周期能接受那SGDMomentum依然是值得优先尝试的选项。它的另一个好处是超参数少你只需要关注一个学习率调起来省心。2.2 Adam的隐患它为什么会“忘掉”泛化能力Adam的问题学术界吵了很多年我自己体会最深的是它对“学习率衰减”非常迟钝。SGD在训练后期需要配合明显的手动学习率衰减才能逼近最优解但Adam因为每个参数都在自适应缩放即便全局学习率不降有效步长也常常“没感觉”。结果就是模型在训练集上越走越顺在验证集上却早就停止进步甚至开始反弹。这就是很多人说的Adam泛化差的一个直观原因。另一个隐患来自它的二阶动量初始为0。训练早期二阶动量被低估导致有效学习率异常偏大前期几步容易走飞。这也是为什么Adam配warmup几乎成为标配——先用小学习率把二阶动量“焐热”再放开步子走。2.3 AdamWweight decay的复位解决了我多少问题AdamW是我这两年固定使用的主力优化器。它和Adam的唯一区别是把“权重衰减”weight decay从梯度的整体缩放里拎出来单独作用于参数本身。在传统AdamL2正则里正则项会先加进梯度再做自适应缩放实际效果变成了“梯度大的参数正则弱、梯度小的参数正则强”非常拧巴。AdamW的做法是先按正常的Adam规则更新再对每个参数直接乘以一个小于1的系数# AdamW把weight decay从梯度里摘出来直接在参数上做衰减 param - lr * weight_decay * param # 这一行和Adam的梯度部分是分开的这种“解耦”带来的实际收益我自己在BERT类模型微调上感受很明显同样用3e-5的学习率AdamW的验证loss比Adam低了约0.3最终下游任务指标普遍高0.5到1个点。Hugging Face的Transformers库默认优化器就是AdamW这本身就是一个强烈的信号。2.4 一键选型对照表按任务类型直接抄我整理了一张选型表虽然简陋但很实用至少能帮你避开“用错优化器导致的莫名效果差”任务类型我优先使用的优化器理由备选CV分类/检测从预训练模型微调SGDMomentum0.9后期泛化好收敛稳定AdamW低学习率NLP分类/实体识别BERT等微调AdamW与预训练优化器习惯一致泛化稳Adam需调正则从零训练的TransformerAdamW warmup训练前期稳定支持长周期收敛Adam warmup生成模型GAN/扩散模型Adam或AdamW双优化器生成任务对自适应速率更敏感视生成器/判别器而定大batch训练batch size 1024LAMB对Transformer / SGD大动量大batch下学习率需要线性缩放AdamW 大学习率注意选型表只是起点不是终点。真正靠谱的做法是“小规模数据上做对比实验看验证集表现再定主力优化器”。我几乎每个新项目都会做一个优化器小pk成本不高收益却很实在。3. 把Optimizer调到“顺手”状态学习率、warmup、weight decay的内部配合3.1 学习率是主人其他都是仆人如果你只能调一个超参数那必须是学习率。模型能不能收敛、收敛到多好、会不会震荡学习率几乎起决定性作用。我的经验值是Transformer类模型用AdamW时学习率从1e-5到5e-5这个区间里探索默认取3e-5CNN类用SGD时从0.01到0.1探索默认取0.02到0.03。但这不是铁律。学习率与batch size存在线性缩放关系batch size翻倍时学习率一般也乘以2否则有效更新步长会相对变小。一个很小的技巧如果你的训练“loss下降缓慢但验证指标还能涨”可以试试把学习率放大2倍到3倍很多时候训练速度会突然快起来。反过来如果loss在下降但验证指标已经开始退化把学习率缩小到原来的0.5倍往往能稳住。3.2 warmup不是玄学它保护的是训练早期那几步warmup即前若干步用一个比较小的学习率预热然后逐步加到目标学习率。很多人嫌麻烦不愿意加但它在两个场景下几乎不可或缺使用Adam/AdamW时二阶动量尚未被“焐热”初始有效步长过大warmup可以防止前几步把embedding或分类层直接推飞。使用大学习率时模型起始权重离收敛点很远如果一开始就大步走容易进入一个错误的误差面区域后面再想拉回来就难了。我自己惯用的配置是线性warmup 余弦退火cosine decaywarmup步数占总训练步数的5%到10%。例如总共训练10万步warmup设5000到10000步。这个组合在多种任务上表现稳定几乎成了我的默认配置。# 一个典型的warmup cosine调度器配置示例PyTorch from torch.optim.lr_scheduler import LinearLR, CosineAnnealingLR warmup_steps 1000 total_steps 20000 warmup_scheduler LinearLR(optimizer, start_factor0.01, total_iterswarmup_steps) cosine_scheduler CosineAnnealingLR(optimizer, T_maxtotal_steps - warmup_steps)3.3 weight decay一个常年被误解的正则化开关weight decay在AdamW里是直接乘在参数上的相当于“每隔一步参数向0靠拢一点点”。它的作用不是“让loss更低”而是“防止参数变得太大”从而提升泛化能力。我见过很多人把weight decay调到0.1甚至更大理由是“想更狠地正则”结果模型欠拟合连训练loss都下不去。我的习惯是默认0.01到0.05之间具体看正则需求。数据集小、模型大时weight decay可以调大一点0.05以上数据集充足时0.01往往就够。还有一个容易踩的坑对bias、LayerNorm的scale参数一般不做weight decay。原因很简单这些参数大多是“位置偏移”或“归一化强度”并不存在“过大”的泛化问题。PyTorch里给优化器分组传递参数可以轻松实现这一点optimizer_grouped_parameters [ {params: [p for n, p in model.named_parameters() if bias in n or LayerNorm in n], weight_decay: 0.0}, {params: [p for n, p in model.named_parameters() if bias not in n and LayerNorm not in n], weight_decay: 0.01}, ]3.4 我自己惯用的几组训练配置把多个超参数看成一套完整配置而不是孤立项之后训练效果会稳定很多。我直接把我常用的几组配置贴出来供参考BERT类模型微调AdamWlr3e-5weight_decay0.01线性warmup5%步数线性衰减batch size 32。ResNet系列从零训练ImageNet级别数据集SGDMomentum0.9lr0.1配合batch size 256weight_decay1e-4cosine退火无warmup或极短warmup。扩散模型从零训练AdamWlr1e-4weight_decay0.01配合gradient clipping到1.0。GPT类大模型从零预训练AdamWlr3e-4weight_decay0.1warmup比例1%cosine退火到最大lr的10%并开启梯度裁剪至1.0。4. 训练现场踩过的Optimizer相关坑附完整排查思路4.1 现象loss卡在0.693不下降问题出在“优化器没更新参数”有一次我在微调一个二分类模型时loss一直卡在0.693。熟悉二分类的朋友都知道0.693这个值很特殊——它对应着“模型对每个样本都输出概率0.5”也就是完全没学会。我一开始怀疑数据有问题来回检查了三遍数据没发现问题。最后我打印了model.parameters()的梯度发现梯度全为0然后才注意到修改网络结构之后我忘了重新optimizer AdamW(model.parameters(), ...)新的模型参数根本没有被优化器绑定。这个坑非常低级但实际工作中太常见了。排查思路是在训练循环里加两行调试代码直接观察梯度是否正常流动而不是盲目调整学习率或者网络结构。# 训练循环中的调试检查确认梯度确实在被“优化” for name, p in model.named_parameters(): if p.grad is None: print(fWARNING: {name} has no grad) elif p.grad.abs().max() 0: print(fWARNING: {name} has zero grad)4.2 现象梯度爆炸grad clip真的够用吗训练初期loss突然飙到NaN大概率是梯度爆炸。预防手段有两个一是gradient clipping梯度裁剪二是降低学习率。我先说结论grad clip是“兜底”不是“万能药”。当梯度值达到1e10以上时即便裁剪过后参数更新二阶动量依然可能处于异常状态比如Adam的exp_avg_sq里存了巨大的梯度平方后面再想恢复就很难。所以如果你的训练需要频繁仰仗grad clip才能稳住真正该做的是把学习率降下来或者找出导致梯度爆炸的结构性问题比如不使用残差连接的深层网络。我的建议配置是grad clip的阈值设为1.0对Transformer类或5.0对CNN类同时配合学习率warmup。两者一起用训练过程会明显安稳。4.3 现象fp16训练下Adam出现NaN不是你的代码错了用NVIDIA GPU进行混合精度训练时一个高频现象是Adam优化器在fp16下时不时报NaN。根因在于Adam需要维护exp_avg和exp_avg_sq两个状态而fp16能表示的数值范围很小梯度尺度极端时很容易溢出到Inf/NaN。解决思路有两个方向一是把优化器状态保持在fp32PyTorch的AMP对Adam的默认行为就是这样二是直接用bf16而不是fp16——bf16保留了和fp32同等的指数范围在支持它的GPU如A100、H100上训练时几乎不会因为指数溢出产生NaN。我的建议是如果硬件支持优先用bf16混合精度如果只能用fp16务必确认优化器参数在fp32空间更新不要贪图省显存而把优化器状态也降到fp16。4.4 断点续训时没有正确恢复optimizer状态导致的“一切回到解放前”断点续训是个很常见的需求。我见过不少同事的做法是只保存了model.state_dict()然后还在重新创建optimizer后从某个学习率开始继续训练。结果往往就是训练曲线突然崩掉或者模型表现比中断前还差。原因很简单optimizer里不但有当前的参数还有momentum buffer、exp_avg_sq等历史状态。如果你丢失了这些状态参数虽然还是旧的但“惯性”和“每个参数的缩放因子”都是全新的相当于一个本来按既定节奏走的人突然被清零记忆再出发步伐节奏完全混乱。正确的做法是把optimizer的state_dict和scheduler的state_dict一起保存和恢复。# 保存checkpoint torch.save({ model: model.state_dict(), optimizer: optimizer.state_dict(), scheduler: scheduler.state_dict(), step: global_step, }, checkpoint_path) # 恢复checkpoint checkpoint torch.load(checkpoint_path) model.load_state_dict(checkpoint[model]) optimizer.load_state_dict(checkpoint[optimizer]) scheduler.load_state_dict(checkpoint[scheduler]) global_step checkpoint[step]注意如果你改了超参数比如中途降低学习率请在恢复时覆盖optimizer里的相应字段否则旧状态会接管一切。5. 从“只跑通”到“可复现”Optimizer的工程化管理经验5.1 在配置里给optimizer单独留一个段落理由和模板很多项目把学习率和优化器参数散落在训练代码的各个角落换实验像在做考古极其痛苦。我的习惯是用yaml配置文件把优化器信息集中管理起来并让训练代码对这个配置有完整的“唯一真源”single source of truth意识# config/train.yaml 中的optimizer段落 optimizer: name: AdamW lr: 3e-5 weight_decay: 0.01 betas: [0.9, 0.999] eps: 1e-8 scheduler: name: warmup_cosine warmup_ratio: 0.05 min_lr_ratio: 0.1 # 退火到最大lr的10%配置文件一旦固定下来就要让实验记录系统自动记录这个配置文件、代码版本、数据的hash值。我吃过最大的亏就是训练完模型之后忘了记当时的优化器配置结果几个月后要复现只能靠Git历史一点点翻非常低效。5.2 记录optimizer的一切版本、超参数、甚至随机种子优化器行为还有一个隐性因素参数更新顺序。PyTorch的optimizer按参数组的顺序更新如果你改了模型代码里named_parameters的返回顺序比如改了网络注册顺序优化器的行为虽然不会本质改变但在浮点加法顺序敏感的情况下可能会带来微小的结果差异。为了让实验可复现我自己会在每个实验目录下保留一个experiment_meta.yaml内容大致包括优化器名称和全部超参数scheduler名称和调度参数PyTorch/框架版本、CUDA版本、GPU型号随机种子以及是否开启了torch.backends.cudnn.benchmark和torch.use_deterministic_algorithms数据集的shuffle种子和采样顺序5.3 换框架时optimizer带来的微妙差异同样的AdamWPyTorch、TensorFlow、JAX实现出来的系数默认值可能不完全一样。这一点在复现别人论文里尤其致命。我遇到过最典型的情况是论文里写的学习率明明是3e-5但用PyTorch复现时怎么都不收敛。后来仔细一看对方用的是TensorFlow的AdamW实现其中的epsilon默认值是1e-7而不是PyTorch的1e-8而且它对weight decay的处理方式也有细微差别——AdamW在TensorFlow里等同于“decoupled weight decay”实现而在某些PyTorch版本中需要依赖optimizer的单独实现。想避坑只有一个笨办法把论文/官方代码里的优化器配置逐字复制包含eps、betas、weight_decay、以及是否配合AMSGrad全部对齐。很多人只抄学习率其他参数全用默认值结果复现不出来还怪论文其实大多是优化器配置没对齐。6. 结合我最近的实验Model-Optimizer在长尾数据集上的行为记录最后聊一个我最近做的文本分类实验来展示优化器在真实场景里的微妙之处。数据是不均衡的长尾分类几百个类别大部分类别样本极少模型是RoBERTa-base。我一开始用了标准的AdamWlr3e-5训练了10个epoch。训练loss一路下降验证集整体精度看起来也还行但按类别细分后发现尾部类别的F1非常差比头部类别差了20多个点。换了好几种数据采样方法改善都有限。后来我做了个小改动把优化器从AdamW换成SGDMomentumlr0.01并且在分类头单独设置了一个略高的学习率。结果出乎意料尾部类别的F1涨了将近5个点。我复盘之后的理解是AdamW的自适应机制在训练后期太“迁就”头部类别的梯度导致尾部类别的细微信号被淹没而SGDMomentum的更新更“一视同仁”对低频次特征的利用更充分。那次实验给我的触动很大好多时候不是模型不行而是优化器和你的数据分布不匹配。如果你的数据集不均衡不妨把SGDMomentum列入备选优化器认真跑一组对比实验不要因为“大家都在用Adam”就放弃其它选项。还有一个小技巧分享给你如果你在训练过程中发现验证loss长期下不去可以先打开梯度监视grad norm确认梯度没有消失确定梯度正常后再去调优化器的学习率策略。顺序不要反否则很容易白费力气。优化器是一个需要被认真对待的组件它值得你为每个项目单独留出调试时间。
返回列表