深度学习优化器实战:从SGD到AdamW的调参与实现解析
训练深度学习模型这几年我发现自己花在“调参”上的时间有一大半都耗在了优化器上。拉开训练日志先看的不是loss曲线而是优化器配置这几乎成了我的肌肉记忆。所以当朋友要我聊聊“Model-Optimizer”这个项目时我一点都不意外——名字听起来像个小工具库但它的核心其实是所有深度学习训练里最容易被低估、也最容易翻车的那个环节你到底用哪个优化器、学习率怎么调、梯度怎么处理以及这些组件怎么组合才能让一个模型真的收敛。这个需求不是凭空出现的。我在实际项目里接手过不少“别人训练到一半的模型”其中大部分case不是网络结构有问题而是优化器选错了、参数没对齐、或者说调度策略不合适。今天我不打算泛泛而谈“优化器很重要”而是把我在自研Model-Optimizer组件过程中的思路、代码、踩坑记录完整拆开讲清楚每一步怎么做、为什么这么做以及哪些地方是文字教程一般不会写、但你实际一定会遇到的。1. 项目拆解Model-Optimizer到底在解决什么问题1.1 名字背后的真正含义先说一个容易被误会的地方。很多人以为“Model-Optimizer”是模型压缩工具比如剪枝、量化、蒸馏那类东西。但实际上在深度学习训练的语境里Model-Optimizer最常指的是一整套服务于“模型权重更新”的逻辑组件。它不只是某个优化算法而是包括优化器本身、学习率调度器、梯度裁剪、混合精度配合、参数分组更新策略等在内的一整套训练基础设施。为什么要强调这个区别因为我在很久之前犯过一个典型错误只把Adam当“优化器”然后框架里所有网络都硬套一个默认参数跑到底。结果在CV任务里用Adam跑ResNet收敛是收敛了但精度明显比不过SGDMomentum在NLP任务里用SGD调Transformer又慢得怀疑人生。后来才明白优化器不是“装上去就完事”的插件它内部的学习率、动量、权重衰减每一项都要跟模型结构、数据规模、总训练步数配合起来看。这个Model-Optimizer项目的目标也就明确下来造一个灵活的优化器封装层能在不改动模型代码的情况下按需切换优化器、调度器、梯度处理方式并且把每一套组合的配置沉淀下来方便复现和对比。说白了就是把“调参”这件事从玄学变成工程。1.2 它要覆盖的三个核心能力第一个是优化器本身的实现与切换。SGD、Adam、AdamW、LAMB这些算法虽然有现成库但实际使用时经常需要打开黑盒去改某个参数的默认行为。比如Adam的epsilon在不同精度下有不同坑LAMB需要针对layer-wise自适应这些都得暴露出来。第二个是学习率调度。优化器负责“怎么走”调度器负责“走多快”。我见过太多人只设置了初始学习率训到一半loss不降了也不会调学习率最后模型性能卡在次优解上。调度策略什么时候用warmup、什么时候用cosine decay、什么时候用step decay这个是Model-Optimizer的核心功能不是可有可无的附加项。第三个是梯度处理与数值稳定性。包括梯度裁剪、梯度累积、混合精度训练时的动态loss缩放以及参数分组时对不同层设置不同学习率。这些环节单独看起来不起眼但它们几乎决定了训练是否会中途崩掉。2. 优化器原理与选型逻辑别再无脑Adam2.1 从SGDMomentum说起我见过很多入门读者第一直觉是用Adam因为它“快、省心、不用调”。但真正做工业级训练时SGDMomentum依然霸占大量视觉任务榜单原因值得讲透。SGDStochastic Gradient Descent的更新规则是直接用梯度乘以学习率 theta theta - lr * grad它的问题是在损失曲面比较崎岖时梯度方向抖动厉害收敛路径非常曲折。为了解决这个问题Momentum引入了“历史渐变方向”的概念类似于物理里的小球滚下山坡不仅看当前梯度还要保持之前的运动趋势 v momentum * v grad theta theta - lr * v这个v相当于梯度的指数滑动平均。我用一个生活化类比你站在一个下坡弯道上没有惯性的话每步都得重新判断方向走得很纠结有惯性之后你不会被局部颠簸带偏方向能够更平滑地冲下去。Momentum默认取值0.9代表了“90%的运动趋势来自过去、10%来自当前梯度”的含义大体上很稳。在实际使用中SGDMomentum的优点是它不依赖梯度二阶矩估计对噪声没那么敏感而且泛化能力往往更好。早年我在ImageNet类任务上做过对比同一套ResNet结构SGDMomentum在相同epoch数下的最终验证精度通常比Adam高出0.5到1个百分点。这个差异在准确率敏感的场景里就是决定性的。缺点就是学习率需要手动精调warmup和decay得配合好否则前期容易发散。2.2 Adam及其变体为什么能“开箱即用”Adam的核心贡献是把一阶动量对应SGD的momentum和二阶动量做了结合对每个参数都自适应地调整学习率。一阶动量记录梯度方向二阶动量记录梯度振幅的平方均值然后用二者比值决定每一步的更新大小。具体公式简化成这个印象参数更新量大致等于 m_t / (sqrt(v_t) epsilon)。m_t是梯度的指数滑动平均v_t是梯度的平方滑动平均。分母里的sqrt(v_t)会做大致的归一化梯度大就把更新量缩小梯度小就把更新量放大。这就是Adam对稀疏梯度和不同scale参数都相对友好的原因。我经常把它类比成“一个自动调节步长的旅行者”路陡就小步走路平就大步走。但Adam也有非常现实的坑权重衰减的处理就是一例。经典Adam在做L2正则化时是把weight decay直接加到梯度里再代入一阶二阶动量计算这在实现上会和真正意义的解耦weight decay产生差异导致Adam的正则化效果在训练后期偏弱。于是AdamW出现它把weight decay从梯度流程中拿出来直接作用在参数更新上theta theta - lr * (update weight_decay * theta)。这个看似微小的改动在Transformer类模型上带来了明显的泛化收益现在几乎所有预训练模型默认用AdamW而不是Adam。2.3 选型表和建议我把自己常用到的场景整理成一张表很久以来都作为Model-Optimizer初始配置的基准参考场景推荐优化器默认学习率备注CNN视觉分类/检测SGDMomentum0.01-0.1配合warmup泛化好调LR收益高Transformer类BERT/GPTAdamW1e-4到5e-5需要配合cosine schedule大规模预训练1024卡LAMB1e-3到4e-3layer-wise自适应适合大batchGAN的判别器/生成器Adam1e-4到3e-4两个网络LR通常不同稳定性优先微调小模型/小数据AdamW或Adam1e-5到1e-4数据少时LR宁小勿大需要强调的是这个表不是标准答案只是我很多个实验里筛出来的起点。真正要定参数还是得对具体任务做几次短训练对比几分钟就能看出趋势。但至少别再一个Adam默认配置打天下了。3. 核心参数与实操要点学习率、动量、epsilon、权重衰减3.1 学习率整个调度系统的中枢学习率是优化器里最敏感的参数没有之一。我在初学阶段以为学习率就是“一个数字”后来发现它必须根据优化器类型、batch size、总训练步数来同步调整。比如SGD的0.1在batch size 128下能用搬到batch size 256就要按比例或sqrt规则调否则loss很容易直接炸。实践中最靠谱的路径是“warmup 主体decay”。warmup阶段从很小的学习率比如目标的1/10甚至1/100线性或余弦增长到峰值目的是让模型在初始不稳定状态中逐步适应梯度尺度避免一开始就大步幅乱撞。我之前在一个图像模型上试过不用warmup初始LR直接拉满0.1前几百步loss就飞上天了之后再怎么调都救不回来加了500步warmup之后同样的峰值LR就顺利收敛了。主体decay有两种取向。Step decay适合训练轮数不固定、需要阶段性降低学习率来稳定收敛的情况Cosine decay则更适合固定epoch数目的训练它会从峰值平滑下降到接近0末尾阶段反而能让loss磨得很低。我自己的习惯是有明确总步数的训练用cosine没有明确总步数的先把step decay放进去避免调度策略反过来限制训练时间。3.2 beta1、beta2、epsilon的微妙影响很多人对beta1、beta2、epsilon不管不问直接吃默认值这其实有隐患。beta1是所谓的一阶动量衰减系数控制历史梯度方向的影响程度默认0.9。在大多数任务里它不需要怎么改但在某些噪声特别大的场景比如联邦学习、强化学习我会把beta1降到0.8甚至0.5让当前梯度对更新方向的干预更大换来更快的响应。beta2是经常被忽略的一个值控制二阶动量衰减默认0.999。它决定了Adam对“梯度历史振幅”的记忆长度。如果训练过程中梯度尺度变化剧烈beta2太接近1会让历史方差估计“反应迟钝”导致更新步长过度放大反过来如果beta2设得太小比如0.9历史信息过少自适应效果就打折扣。在长序列Transformer训练中我遇到过大loss spike时如果把beta2降到0.98稳定性会明显改善但准确率会有轻微代价所以需要权衡。epsilon就更微妙了。它加在分母sqrt(v_t)epsilon上作用是防止除零以及设定“自适应更新量”的下限。默认1e-8在FP32训练里挺安全但开启AMP混合精度后如果epsilon太小v_t的平方根估计会出现数值不稳定导致权重更新时出现奇怪的抖动。我的经验是混合精度训练时把epsilon提高到1e-6或1e-7能省掉不少debug时间。这个细节在算子库文档里可能只是一句带过实战里却能救命。3.3 weight decay正则化不只是“往loss里加一项”权重衰减的作用是让权重在更新过程中不断向零收缩从而抑制过拟合。传统L2正则化把weight decay加进loss再求梯度等效效果是每一次梯度更新都会额外减去一个小的权重项。但正如前文说的Adam算法里直接这样做会导致正则化效果被二阶动量缩放所以AdamW做了解耦。Model-Optimizer这种封装里我强烈建议统一采用解耦weight decay写法。选择weight decay数值也要看模型规模。我见过一些新手在超大参数模型上把weight decay设成0.01结果训练到后期所有参数都被压制得太厉害模型表达能力下降。一般来说CNN任务常用1e-4到5e-4Transformer预训练常用0.01到0.1微调阶段通常降到0.001以下甚至不动。这背后的直觉是参数越多越需要正则化来约束自由度但训练数据越小正则化强度越要谨慎否则模型学不动。4. 实操从零实现一个可用的Model-Optimizer工具箱4.1 基础类设计与参数分组很多开源优化器看起来是个单独的类实际使用时不方便的地方在于“所有参数共用一个配置”。真实场景里你必须支持参数分组比如主干网络用一套学习率最后的分类头或embedding层用另一套学习率。下面这个最小设计是我在Model-Optimizer里坚持的范式class ModelOptimizer: def __init__(self, params, base_lr, defaultsNone): # params: list of parameter groups # base_lr: base learning rate # defaults: global default hyperparameters self.param_groups [] for group in params: group {**defaults, **group} group[lr] group.pop(lr, base_lr) self.param_groups.append(group)每个param_group是一个字典至少包含params参数迭代器、lr和优化器专属超参数。这样优化器在step()时就能按组取出对应的学习率、动量、权重衰减。我在项目里还加了一个额外的log接口每次step之后记录每个group的平均梯度范数和更新范数这个习惯帮我发现了很多潜在的数值问题。4.2 实现SGDMomentum理解真实写法框架内置的SGD封装太完善我反而建议有精力的同学手动写一遍哪怕只写一次也能彻底理解动量在做什么。def step(self): for group in self.param_groups: momentum group[momentum] weight_decay group[weight_decay] lr group[lr] for p in group[params]: if p.grad is None: continue grad p.grad.data if weight_decay ! 0: grad grad.add(p.data, alphaweight_decay) if momentum ! 0: state self.state[p] if momentum_buffer not in state: state[momentum_buffer] torch.zeros_like(p.data) buf state[momentum_buffer] buf.mul_(momentum).add_(grad) grad buf p.data.add_(grad, alpha-lr)注意weight_decay是直接加在梯度上这是L2的写法。如果要用解耦版本就改成在参数更新时单独执行p.data.add_(p.data, alpha-lr * weight_decay)。实战里我会默认用解耦写法因为它在AdamW和SGD之间保持了一致的语义方便切换对比。这个实现虽然简单但它把优化器的状态管理给摊开了momemtum buffer保存在state里每个参数独立一份实际运行时并不需要你手动做这些东西框架会替你搞定。但理解了buffer的含义才能明白为什么momentum从0.9改到0.99会让更新路径“沉”很多。4.3 Adam与AdamW的差异代码层面看清楚Adam的实现集中在估计算一阶和二阶动量上def step(self): for group in self.param_groups: beta1, beta2 group[betas] eps group[eps] step_count group[step] 1 for p in group[params]: if p.grad is None: continue grad p.grad.data state self.state[p] if m not in state: state[m] torch.zeros_like(p.data) state[v] torch.zeros_like(p.data) m, v state[m], state[v] m.mul_(beta1).add_(grad, alpha1 - beta1) v.mul_(beta2).addcmul_(grad, grad, value1 - beta2) m_hat m / (1 - beta1 ** step_count) v_hat v / (1 - beta2 ** step_count) update m_hat / (v_hat.sqrt() eps) if group[weight_decay] ! 0: update update.add(p.data, alphagroup[weight_decay]) p.data.add_(update, alpha-lr)这个写法把weight decay加到update上属于比较经典的AdamW近似实现。严格来说AdamW原始论文是把weight decay写到参数更新公式里如下面这个等价形式p.data.mul_(1 - lr * group[weight_decay]) p.data.add_(m_hat / (v_hat.sqrt() eps), alpha-lr)两者在实际效果上几乎一致但第二种在语义上更清晰权重衰减不参与梯度动量统计。这也是我在项目里坚持使用的写法。step_count需要注意它应该是每个参数组共享的全局step而不是per-parameter step。很多手写实现容易在每个参数里单独维护step这样不同参数的bias correction会不一致训练后期差异虽然不大但不严谨。4.4 学习率调度器的联动以及梯度裁剪Model-Optimizer的真正核心其实是优化器与调度的配合而不是优化器本身。我在封装里把调度器做成一个独立的回调类在每次optimizer.step()之前或之后更新当前学习率class CosineWarmupScheduler: def __init__(self, optimizer, warmup_steps, total_steps, min_lr_ratio0.01): self.optimizer optimizer self.warmup_steps warmup_steps self.total_steps total_steps self.min_lr_ratio min_lr_ratio self._step 0 def step(self): self._step 1 if self._step self.warmup_steps: factor self._step / self.warmup_steps else: progress (self._step - self.warmup_steps) / ( max(1, self.total_steps - self.warmup_steps) ) factor 0.5 * ( 1 math.cos(math.pi * progress) ) factor self.min_lr_ratio (1 - self.min_lr_ratio) * factor for group in self.optimizer.param_groups: group[lr] group[initial_lr] * factor在写调度器时有个容易忽略的点必须在optimizer初始化时把每个group的初始学习率保存为initial_lr否则cosine decay从峰值往下走时一旦lr被上一次调度器改过就找不到原始基准。我在早期踩过这个坑调度器第二次恢复时直接把lr乘成了一个小数训练直接废掉了。梯度裁剪则是另一个常被低估的环节。它不改变优化器算法但在更新前对梯度的范数做缩放。常见写法是max_norm和clip_normtorch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)max_norm1.0表示如果全局梯度范数超过1.0就等比缩小到1.0。这个操作对Transformer训练几乎是标配尤其是在学习率前期不稳定或数据有异常样本时能有效防止单个样本把权重撞飞。但要注意梯度裁剪会改变梯度的绝对尺度所以如果用了clip调度器和warmup的设计也要相应保守一点不要再用一个很大的峰值LR。4.5 混合精度下的优化器适配我把它单独拎出来说是因为Model-Optimizer这类组件如果在AMP环境下没做好适配会带来一堆奇怪的数值问题。混合精度训练时前向传播用FP16梯度用FP16存储但优化器更新时通常需要把梯度和参数都转回FP32做累积否则小梯度直接丢失。在PyTorch里最省心的做法是用GradScaler它会动态调整loss缩放系数防止梯度下溢。但问题在于我们手写优化器时必须注意在step之前就把FP16的梯度转换到FP32或者用fp32 master weight方式。最常见的坑是直接拿FP16梯度做momentum更新导致momentum buffer里累积误差而训练后期不收敛。实践中我的做法是模型参数保持FP32副本前向用FP16反向得到的梯度在optimizer内部cast到FP32后再做更新。很多开源框架的AMP模式下其实也是这么干的但用自定义优化器时这个逻辑必须自己保证。可以先用一个小toy模型验证数值一致性再大规模训练成本很低但收益极高。5. 常见问题与排查技巧实录5.1 训练loss不下降反而原地抖动这通常不是优化器出问题而是学习率过大或过小。过大的表现是loss剧烈震荡甚至逐步上升过小则是loss几乎不动降得很慢。我的排查顺序是先用一个小batch数据做过拟合测试看loss能不能降到很低。如果能说明优化器本身没问题问题在训练细节。观察梯度范数如果梯度过大就考虑加大warmup或引入梯度裁剪。如果loss在某个值附近震荡且不上不下就调低学习率一个数量级再跑几百步。确认是否用了正确的weight decay过大的weight decay可能让模型过早进入“欠拟合”状态。这套排查方法我复用了很多次基本能区分出是模型代码问题、数据问题还是优化器配置问题。5.2 损失突然变成NaN之前用Adam很少见NaN问题我遇到最多的是在AMP手写优化器大学习率组合里。先检查一下epsilon是否太小FP16下推荐1e-6到1e-7再检查是否为参数更新出现inf或NaN可以打印更新量和梯度范数。一个实用技巧在optimizer.step前后对权重检查NaN定位是梯度生产成了NaN还是更新步骤引入了NaN。如果是梯度过大导致的先开梯度裁剪如果是损失本身溢出就调高GradScaler的初始scale或者把loss缩放系数调大。还有一次我遇到的情况是embedding层的某个id对应的梯度异常最后发现是数据里有脏样本跟优化器半毛钱关系都没有。这说明排查NaN时话别钻牛角尖要把数据和模型也一起怀疑。5.3 收敛速度很慢怎么提速很多人第一反应是加大学习率但如果已经接近“max feasible LR”加大就会炸。这时我更喜欢换成更大batch size并同步调整学习率或者切换到LAMB这类针对大batch设计的优化器。LAMB通过layer-wise的自适应学习率让每个层都能用合适尺度更新在大batch下比AdamW稳得多。另外不要忽略参数分组里的差异学习率。我的经验是底层特征提取层和顶层分类头对学习率的敏感度不同把顶层学习率调成底层的5到10倍往往能在同样步数内带来明显的收敛速度提升。这也是Model-Optimizer里参数分组最有价值的应用场景之一。5.4 调度器“莫名其妙”重置或异常这类问题多半是因为训练在中断后从头恢复了调度器但optimizer里的lr已经变了或者initial_lr没有被正确保存。我的做法是每次保存checkpoint时不仅保存optimizer的state_dict还要保存当前step、调度器step、GradScaler的scale。恢复时先用这些信息重建调度器和优化器再继续训练。别小看这个细节我至少因为checkpoint恢复问题浪费过一整天的训练时长。6. 最后说点个人经验如果只让我留一条建议我会说优化器选型和调度设计一定要在项目开始前就定不要训练到一半再改。虽然很多人说“中途换优化器也能救回来”但救回来的成本往往是你多跑了几十个小时的训练和好几版无效对比。我自己维护Model-Optimizer这套小工具至今最大的收获不是学会了几个API而是建立了“每个超参数必须有明确目的”的训练习惯。比如学习率为什么是1e-4而不是2e-4是跑过对比实验得到结论还是纯粹拍脑袋如果每个参数都能给出理由模型的最终表现基本不会让你太难堪。另外建议所有刚接触深度学习的读者抽一个周末把SGDMomentum、Adam、AdamW的手写实现各敲一遍。这个过程比读十篇优化器论文都管用因为你在写代码时才能真正看到那些公式里的动量和自适应因子是如何一步步组织成最终更新的。之后再遇到loss不收敛、NaN、模型不work你排查起来会有一个非常清晰的脑内地图而不是像无头苍蝇一样乱试参数。这就是我目前对Model-Optimizer这件事的全部实战体会。如果你也在做训练框架的封装或调优希望这些记录能帮你在排查时少走几个弯路。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →