
如果你自己动手训练过几个深度学习模型大概都碰到过这种局面loss前几百步降得挺利索到了中后期就开始原地打转偶尔某个batch突然跳出一个尖峰然后训练又像没事一样继续。很多人第一反应是换模型结构、改数据增强甚至怀疑数据标错了。Model-Optimizer这个项目就是我在这类问题里折腾了大半年之后把优化器选型、学习率调度、梯度裁剪、EMA这些训练阶段最容易被忽视、又最能拉开差距的环节收敛成一套可复用工具的全过程。这里先明确一下定位它不是一个论文里的全新优化算法而是一套工程化的训练优化方案。目标是把原来散落在各个训练脚本里的超参数、调度逻辑、梯度处理代码统一收口让实验对比有据可查让换任务换数据时少走弯路。适合正在自己写训练循环、觉得“默认AdamW就够用”但又说不清为什么够用、以及想系统梳理训练调参思路的读者。1. 优化器选型不是拍脑袋几个主流优化器的取舍逻辑1.1 从带动量的SGD说起为什么“最朴素”的方案到现在也不过时很多人入坑深度学习时用的第一个优化器是Adam动不动就“1e-3开训”后来遇到不收敛就怪模型、怪数据其实很少回头想优化器本身。带动量的SGDSGD Momentum在今天依然是CV领域很多baseline的标配原因很简单它的更新方向更“稳”在足够大的batch下梯度噪声被平均掉之后SGD的收敛轨迹更平滑最终往往能到达泛化性更好的平坦极小值区域。这个“平坦极小值”不是玄学。直观理解是自适应学习率类优化器Adam系会对每个参数的梯度做归一化处理相当于每个参数用不同的尺度去更新模型容易被推入一个很“尖”的极小值而SGD每个参数共用同一把尺子更新路径更接近真实的梯度方向解的位置通常更“宽”。用官话说是“泛化差距”用大白话说就是在测试集上不容易一换数据就崩。代价也很明显SGD收敛慢尤其对lr极其敏感需要配合精细的lr schedule否则前期完全跑不动。1.2 Adam系的自适应学习率快是快但它的“默认值”不是白来的Adam的核心思路是给每个参数维护一阶动量梯度均值和二阶动量梯度平方的均值更新时用一阶动量除以二阶动量的开方。效果是让那些梯度尺度差异大的参数都能跑起来调参宽容度高所以它成了NLP和很多多模态任务的默认选择。但Adam有个经典缺陷它内部用的是L2正则而不是真正的权重衰减。L2正则会把权重的衰减项混进梯度里而Adam会把这个项也“除以二阶动量”做归一化导致大梯度参数的正则力度被削弱小梯度参数的正则力度反而被放大。AdamW把权重衰减从梯度计算里拆出来在更新时直接乘以一个衰减系数这才让“解耦”这件事落到实处。用的时候要注意两点其一weight_decay在AdamW里不是越小越好很多任务0.01到0.1之间都能用但换成Adam时同样的数值表现会完全不同其二betas(0.9, 0.999)和eps1e-8这组默认值是配合单精度训练调的一旦切到AMP混合精度或者半精度eps经常需要调大一个量级否则二阶矩在低精度下容易失稳。1.3 新面孔层出不穷Lion、Sophia这类优化器值得追吗最近一两年Lion、Sophia这类新优化器刷了不少榜。Lion本质上把Adam里的一阶二阶矩都省了直接用梯度的符号做更新省显存、省算力在语言模型和某些视觉任务上报出过很漂亮的结果。Sophia则引入了二阶信息的估计号称大模型预训练里能比AdamW少一半步数。我对这类新优化器的态度比较务实如果你的训练预算充足梯度和模型本身已经调顺了没必要为了“看着新”去换如果卡在“收敛太慢”这一步用Lion做一次快速试错很值尤其是显存紧张需要省一个Adam的momentum开销时。需要警惕的是换优化器后lr的尺度完全变了Lion的lr通常只有AdamW的十分之一到五分之一直接套用之前的数值基本必炸。下面这个表是我在CIFAR-10、一个轻量CNN结构上跑过的相对趋势具体数值换模型换数据都会变但排序关系在中小规模任务上很有代表性优化器学习率参考起点收敛速度测试精度趋势调参难度典型问题SGDMomentum0.05-0.1配合cosine较慢高泛化好高lr敏感前期不动lr太小直接废AdamW3e-4-1e-3快中高低后期震荡需配合退火Adam1e-3快中低weight_decay行为不直观Lion1e-4-3e-4最快中高中对lr和wd更敏感一句话总结如果你只想要一个“默认能跑”的起点AdamW 余弦退火是最稳的如果你追求最终精度SGDMomentum配合成熟的schedule值得耐心调如果显存吃紧且任务本身不复杂Lion值得试。2. 学习率策略Model-Optimizer里真正的“隐藏控制器”2.1 Warmup在解决什么问题为什么一上来就大lr很容易崩训练刚开始时模型权重还是随机初始化状态此时梯度方向噪声极大尤其是Transformer这类结构早期某些参数的二阶矩估计会非常离谱。如果一上来就用大学习率等于把模型往一个完全随机、又极不稳定的方向猛推loss很容易在开头几百步冲到一个高平台后面怎么降都回不来。warmup的思路就是先用小学习率“试探”一小段让模型快速过一遍不同数据分布带来的梯度形态把一阶、二阶矩估计预热到一个稳定的水平再把学习率加到峰值。实操里我习惯把warmup步数设成总步数的5%到10%并做成线性增长。有人觉得“训练总共才几万步warmup浪费”实际上省掉warmup导致的返工成本往往高得多。2.2 Cosine退火与OneCycle后期降lr为什么是“免费”的提升训练后期loss开始变平原因不是没梯度而是参数已经在极小值附近来回横跳。此时把学习率降下来就能让更新步长变短让模型在极小值区域“落稳”。Cosine退火是让学习率按余弦曲线从峰值降到接近0前期慢降保持探索能力后期快降完成收敛。它和warmup正好是一对warmup管前期稳定性退火管后期收敛性。OneCycle是另一种策略它把warmup、峰值保持、退火压缩在一个完整周期里峰值lr可以比常规高2到10倍因为模型只在高lr区短暂停留。配合“super-convergence”现象可以在很少的epoch内达到接近充分训练的精度。我实际用下来的感受是OneCycle对CV分类任务很友好对NLP任务不如“warmupcosine”稳因为NLP模型峰值lr太高容易打乱预训练权重结构。2.3 优化器与调度器“绑定”时最容易犯的错调度器必须跟优化器绑定到同一个step节奏里。常见错误有三类一个epoch结束后调一次scheduler.step()但优化器每个batch都在更新导致实际lr曲线和设计完全对不上从checkpoint恢复训练时只恢复了optimizer.state_dict()忘了恢复scheduler.last_epoch或步数计数器lr直接回到起点训练白跑一半验证阶段走了模型forward顺手也调了scheduler.step()等于把验证集也“训练”了一轮这个错极其隐蔽。我自己写Model-Optimizer时干脆把调度逻辑封装进一个step_lr()方法由训练循环统一驱动杜绝手动误调。3. 亲手实现一个轻量Model-Optimizer把选择变成配置3.1 核心设计一个类统一管理optimizer、scheduler、clip和EMA我重构时最想解决的一件事让训练脚本里不再出现十几行互相纠缠的超参赋值。于是设计了下面这个配置对象代码基于PyTorch也适配常见的深度学习框架思路from dataclasses import dataclass, field from torch.optim import SGD, AdamW, Adam from torch.optim.lr_scheduler import LambdaLR import torch dataclass class OptimizerConfig: name: str adamw lr: float 3e-4 betas: tuple (0.9, 0.999) eps: float 1e-8 weight_decay: float 0.05 warmup_steps: int 500 total_steps: int 10000 schedule: str cosine # cosine / constant / onecycle min_lr: float 0.0 grad_clip: float 1.0 use_ema: bool True ema_decay: float 0.999基于这份配置Model-Optimizer类的骨架如下class ModelOptimizer: def __init__(self, model, config: OptimizerConfig): self.model model self.cfg config self.optimizer self._build_optimizer() self.scheduler self._build_scheduler() self.ema_model None if config.use_ema: self.ema_model self._init_ema() def _build_optimizer(self): if self.cfg.name adamw: return AdamW( self.model.parameters(), lrself.cfg.lr, betasself.cfg.betas, epsself.cfg.eps, weight_decayself.cfg.weight_decay, ) elif self.cfg.name adam: return Adam( self.model.parameters(), lrself.cfg.lr, betasself.cfg.betas, epsself.cfg.eps, ) elif self.cfg.name sgd: return SGD( self.model.parameters(), lrself.cfg.lr, momentum0.9, weight_decayself.cfg.weight_decay or 5e-4, ) def _build_scheduler(self): if self.cfg.schedule constant: return LambdaLR(self.optimizer, lr_lambdalambda step: 1.0) def cosine_with_warmup(step): if step self.cfg.warmup_steps: return step / max(1, self.cfg.warmup_steps) progress ( step - self.cfg.warmup_steps ) / max(1, self.cfg.total_steps - self.cfg.warmup_steps) coef 0.5 * (1.0 torch.cos(torch.tensor(progress) * 3.1415926)) return max(self.cfg.min_lr / self.cfg.lr, coef.item()) return LambdaLR(self.optimizer, lr_lambdacosine_with_warmup) def step(self): self.optimizer.step() self.scheduler.step() def clip_grad(self): torch.nn.utils.clip_grad_norm_( self.model.parameters(), self.cfg.grad_clip )有两点值得解释。一是为什么用LambdaLR而不是CosineAnnealingLR因为CosineAnnealingLR不带warmup自己拼逻辑又容易出错LambdaLR里用一个闭包函数把warmup和cosine两段写清楚读起来一目了然。二是为什么把梯度裁剪独立成clip_grad()因为不是每个batch都要裁剪混合精度训练或特定backbone场景下裁剪时机和大小有讲究独立出来方便按场景调用。3.2 训练循环里的四个关键钩子调用顺序不能乱一个标准batch的调用顺序是这样for batch in dataloader: x, y batch logits model(x) loss criterion(logits, y) optimizer.zero_grad() loss.backward() optimizer.clip_grad() # 1. 裁剪梯度 optimizer.step() # 2. 参数更新 # 3. 更新EMA用原权重更新影子权重 optimizer.update_ema() # 4. 调度器步进更新学习率 optimizer.step_lr()这里容易搞错的是EMA的位置EMA更新必须发生在optimizer.step()之后因为EMAModel的decay逻辑是“影子权重向新权重靠拢”如果放在step之前等于把旧权重重复算了一遍等于没用。scheduler放在最后一步则是为了保证每个batch都对应一个新的lr值符合warmup/cosine曲线的定义。如果用了梯度累积假设累积4个batch再更新一次要注意裁剪和step的节奏。我的做法是只在“真正step的那个batch”做clip和step前3个batch只做loss.backward()累积梯度不执行optimizer.zero_grad()。3.3 checkpoint保存Best模型EMA权重和原始权重怎么选EMA指数移动平均的价值在于它不改变训练过程只维护一组“影子权重”每步按ema_decay把新权重向影子权重滑动。实际操作中影子权重通常比训练终点权重更稳测试指标往往高出零点几个点。我保存checkpoint时一直坚持两种权重都存模型当前权重和EMA影子权重。训练过程中监控验证集时用EMA权重去评估最终提交推理时如果没有明显差距优先选EMA版本。这是因为EMA对训练后期的震荡做了平滑泛化通常更好。4. 实测数据从loss曲线重新审视参数配置4.1 一组对比实验SGD vs AdamW vs Lion我在一个轻量CNN网络、CIFAR-10、50个epoch、batch size128的设定下做过一组相对规范的对比。具体数值会因为你的任务有所浮动但相对关系值得参考配置峰值学习率训练Loss形态验证集Top-1达到最优的Epoch备注SGDMomentum Cosine0.1前期平缓下降后期稳定最高约38-42需要精细调lr容易前30epoch看不出优势AdamW Cosine3e-4下降快后期有小幅震荡中高约22-26大部分场景的稳妥起点Adam Cosine1e-3下降极快但后期明显震荡中约18-22建议直接换AdamWLion Cosine1e-4下降最快波动也偏大中高约14-18省显存但对lr更敏感这组实验最直观的启发是收敛最快不等于精度最高SGD的最终acc通常最高但它在第10个epoch可能才刚热身很多没耐心的人在这里就放弃了。4.2 weight_decay、batch size和lr之间的关系weight_decay权重衰减是我见过最容易被人忽略的参数。它本质是对权重做“渐进式惩罚”让模型倾向保留小权重抑制过拟合。在AdamW下很多Transformer类任务用0.01到0.1之间效果都不差但SGD下的最优wd往往在5e-4到5e-3附近差了约一个数量级直接互换会出问题。batch size和lr是另一组强耦合变量。经验法则是“线性缩放”batch size翻倍lr理论上也可以翻倍。前提是warmup要相应拉长否则大batch下梯度更稳定、方差更小但一开始就用大lr会让更新过猛。我在一次实际任务里batch从32提到128lr从2e-4线性翻到8e-4warmup从500步提到1500步收敛曲线和原来几乎对齐。4.3 推荐的调参顺序我调参的顺序经过多次碰壁后固定为先确定batch size其次定warmup和总步数再拿AdamW的默认配置跑通一版接下来只动lr用3倍间隔试3个值然后调weight_decay最后才考虑换优化器或者引入梯度裁剪。原因很简单lr对结果的影响最大但它必须在warmup和总步数确定之后才有意义换优化器是“核选项”放在最后做因为换完整个参数空间都要重新摸一遍。5. 踩坑记录与排查链路优化器也有一堆暗坑5.1 loss突然出尖峰优化器问题还是数据问题loss尖峰这种问题我在不同项目里至少遇过二十次。正确的排查链路是这样的第一步看grad norm。在clip_grad_norm_()之前打印当前的梯度范数如果尖峰时刻grad norm比正常时高两三个数量级说明是梯度爆炸如果grad norm正常但loss还是突跳问题多半在loss计算或数据样本上。第二步看尖峰是否周期出现。如果是每隔固定步数出现一次很可能是dataloader里shuffle逻辑或特定batch里混入了异常样本。我曾经排查过一个案例尖峰每300步准时出现最后发现是数据增强里某次采样产生了一组纯噪声样本。第三步检查AMP混合精度下的参数。如果开了torch.cuda.amploss尖峰可能是float16的梯度下溢或溢出导致此时优先尝试把优化器的eps调成1e-6或1e-7或者给模型某些层单独使用float32尖峰往往就消失了。5.2 AMP混合精度下优化器参数的三种陷阱AMP下最容易被坑的是三件事eps太小fp16能表示的最小正数约6e-8eps1e-8在fp16下基本没有保护作用不加白不加梯度裁剪阈值fp16下梯度的绝对值范围更大沿用fp32时代的1.0可能裁剪过狠我自己通常会先设为2.0或4.0试跑几轮观察norm分布动态loss scaling在用GradScaler时梯度裁剪必须放在scaler.unscale_()之后否则裁剪作用在“被缩放过的梯度”上完全达不到预期效果。5.3 多卡DDP场景scheduler和梯度同步的前后顺序DDP训练里梯度裁剪本身没有问题——DDP会在反向传播时自动完成梯度同步裁剪发生在同步之后是安全的。真正容易踩坑的是scheduler异步如果四张卡各自加载数据、各自step一旦某张卡因IO或数据不均匀慢了一个batch所有卡的lr曲线就会错位。解决办法是保证每个epoch开始时用train_sampler.set_epoch()重新分配数据并在分布式代码里确保scheduler.step()只在主进程执行后由同步点广播或者干脆所有进程都执行同样的step步数。我曾见过一个复现实验中四卡训练结果漂移最终查出来是某台机器CPU性能偏慢导致同步延迟lr更新节奏乱了小半轮。6. 哪些场景不该盲从“默认好优化器”6.1 生成对抗网络与强化学习的特殊需求GAN的训练稳定性比普通监督学习差得多生成器和判别器是同一种optimizer但需要不同的lr常见设置是discriminator的lr略高于generator且beta1通常从0.9降到0.5目的是让梯度更新更“激进”避免判别器被生成器带跑。这和白嫖默认AdamW直接训的体验完全不同。强化学习里环境的奖励信号波动大同一段轨迹内梯度的方差可能差出几个量级所以RL算法里普遍使用“大梯度裁剪”norm阈值常在0.5以下甚至逐参数裁剪。Model-Optimizer在这类场景里需要把grad_clip做成可随时覆盖的配置项不能锁死一个默认值。6.2 大批量训练与超大Embedding场景LAMB和Adafactor当batch size扩到512甚至上千AdamW的逐参数lr缩放会跟不上因为不同层的最优lr步长差异被大batch抹平。LAMBLayerwise Adaptive Moments按层做归一化让每层可以用更大的lr更新训练速度提升显著。这是我在预训练任务上的首选。如果模型大到显存放不下整个Adam的二阶矩状态Adafactor用近似对角化存储把显存占用降一个量级代价是可能需要更长的warmup来稳定。这类场景选优化器不再是“调参问题”而是“容量规划”问题。6.3 推理端的模型压缩优化器和训练不是一回事做剪枝、量化、知识蒸馏时也会提到“优化器”但要区分清楚训练优化器负责更新权重推理阶段的模型压缩工具如量化感知训练、ONNX图优化、模型转换工具是另一套体系解决的问题是“怎么让网络跑得更快更省”。Model-Optimizer的边界要定清楚别把训练逻辑和推理优化混在一个配置里——我在早期版本里犯过这个错导致上线模型时发现推理前端的图优化和训练时的weight_decay参数纠缠在一起排查了半天。model训练稳定后我一般会把推理优化单独开一个配置文件训练侧优化器只管训练指标推理侧优化器只管延迟和精度回退互不打搅。最后分享一点实际体会Model-Optimizer这套东西整理完之后我接手新任务的第一件事不再是急着改模型结构而是先拿一个稳妥的优化器配置跑通盯着loss曲线的形态判断问题方向——是优化器不合适、lr没调好、还是数据本身有问题心里很快会有数。如果你也想搭一套类似的工具我唯一想劝的是别一上来就追求功能齐全先把optimizer、scheduler、EMA这三件套跑通后面再按需加grad clip、AMP、多卡支持路会顺很多。