ARTICLE DETAIL

资讯详情

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

模型优化器实战指南:从SGD到AdamW的学习率调度与调参技巧

模型优化器实战指南:从SGD到AdamW的学习率调度与调参技巧 1. 模型优化器到底在优化什么第一次看到“Model-Optimizer”这个词很多人会下意识觉得它又是一个调参工具或者某个深度学习框架里附带的小模块。但真正在训练一线待过的人都知道模型优化器远不止是“调学习率”那么简单。它更像是整个训练流程里的变速箱——同样的发动机模型结构同样的油量数据变速箱调得好不好直接决定了你是平稳加速还是原地空转。我最初接触优化器是在做一个图像分类项目的时候。当时用的是最朴素的随机梯度下降学习率设了个0.01结果训练损失像坐过山车一样上下乱窜准确率卡在60%死活上不去。后来换成带动量的版本又把学习率做了分段衰减同样的网络结构准确率直接拉到78%。那一刻我才真正意识到优化器不是配角它很大程度上决定了模型能不能“学得动”。所谓Model-Optimizer从广义上讲是一套围绕模型训练过程进行参数更新策略设计与调优的方法论和工具集合。它要解决的问题非常具体给定一个损失函数和一批参数如何计算更新量、更新多大、往哪个方向更新、什么时候该停下来。这些问题听起来简单但背后涉及梯度噪声、鞍点、局部极小值、学习率敏感度等一系列让人头疼的实战难题。这篇文章适合谁看如果你正在训练自己的模型发现损失不收敛或者收敛太慢如果你已经能跑通训练脚本但不知道那些优化器参数到底该怎么设如果你听说过Adam、RMSProp、LAMB这些名字但说不清楚它们之间的本质区别——那这篇内容就是写给你的。我会从优化器的核心逻辑讲起把每个关键参数的来龙去脉拆开再结合我自己的实操记录把踩过的坑和总结出来的技巧一并分享出来。2. 优化器的核心逻辑与方案选型2.1 从梯度下降到自适应学习率为什么需要“优化器的优化器”要理解Model-Optimizer的价值得先回到最原始的梯度下降。假设你站在山坡上想走到谷底最直接的办法就是沿着当前最陡的方向迈一步。这一步迈多大就是学习率。迈小了走到天荒地老迈大了直接跨过谷底冲到对面山坡上来回震荡。原始梯度下降的问题在于它对所有参数一视同仁用同一个学习率。但实际模型中不同参数的梯度尺度差异巨大。比如在自然语言处理任务里词嵌入层的梯度往往非常稀疏而全连接层的梯度则密集得多。用同一个学习率去更新要么稀疏参数学得太慢要么密集参数更新过猛导致发散。自适应学习率方法就是来解决这个问题的。它的核心思想是给每个参数单独维护一个学习率根据该参数历史梯度的大小动态调整。梯度一直很大的参数说明它已经接近最优了学习率就该小一点梯度一直很小的参数说明还没怎么学到东西学习率就该大一点。这个逻辑听起来很直觉但实现起来有很多细节变体也就衍生出了不同的优化器家族。2.2 主流优化器家族对比SGD、Adam、LAMB该怎么选在实际项目中我常用的优化器就那么几个但每个都有它最适合的场景。下面这张表是我自己整理的经验对照不是教科书上的理论对比而是实际用下来觉得最值得参考的维度。优化器核心机制适合场景主要缺点我的使用频率SGDMomentum动量累积固定学习率计算机视觉、精调阶段对学习率极其敏感高Adam一阶矩二阶矩自适应NLP、Transformer、快速原型泛化有时不如SGD极高AdamWAdam解耦权重衰减需要正则化的场景仍需调学习率高LAMB层级自适应信任比超大batch训练实现复杂调参多中RMSProp梯度平方滑动平均RNN、非平稳目标缺少动量修正低选优化器这件事我的原则是先看任务类型再看batch size最后看你对训练稳定性的要求。Transformer类模型基本无脑Adam或AdamW起步因为自注意力机制对学习率非常敏感自适应方法能省去大量调参时间。卷积网络做图像任务如果追求极致精度SGDMomentum配合余弦退火往往能比Adam高出零点几个百分点但代价是你得花时间调学习率和动量系数。LAMB比较特殊它主要是为超大batch场景设计的。当你的batch size大到几千甚至几万的时候传统优化器的更新量会变得极不稳定LAMB通过层级自适应和信任比机制把更新量控制在合理范围内。我只有在做分布式训练、单卡塞不下模型的时候才会考虑它。2.3 学习率调度优化器的“第二层优化”很多人把优化器选好就以为万事大吉了其实学习率调度才是真正拉开差距的地方。同一个Adam固定学习率和带warmup余弦衰减的学习率最终效果可能差出好几个点。Warmup的逻辑很简单训练刚开始时模型参数是随机初始化的梯度方向非常不可靠。如果这时候用大学习率很容易把参数带偏。所以前几百到几千步让学习率从0线性增加到设定值给模型一个“热身”的过程。这个技巧在Transformer训练里几乎是标配我试过去掉warmup训练损失在前500步直接飙到nan。余弦衰减则是让学习率按照余弦曲线从初始值缓慢降到接近0。它的好处是训练后期学习率足够小模型能在局部极小值附近精细搜索而不是一直在大步跳来跳去。我通常会把余弦衰减和warmup组合使用形成“先升后降”的经典曲线。注意学习率调度的总步数一定要和实际训练步数匹配。我见过有人设了100个epoch的余弦衰减结果只训了30个epoch就停了学习率还处在高位模型根本没收敛到位。3. 核心参数拆解与实操要点3.1 学习率最重要的参数没有之一如果只能调一个参数那一定是学习率。它直接决定了每次参数更新的步长。学习率太大损失震荡甚至发散学习率太小收敛慢到怀疑人生。怎么找到合适的学习率我常用的方法是学习率扫描。具体操作是从极小的学习率比如1e-7开始每训练一个batch就按指数增长学习率同时记录损失值。然后画出损失随学习率变化的曲线找到损失下降最快且还没开始上升的那个点通常就在曲线最低点左侧一点。这个方法的原理是在低学习率区域损失下降缓慢但稳定到了某个临界点损失开始震荡上升说明学习率过大了。实际操作中我一般会先跑一轮扫描确定大致范围然后在这个范围里取几个值做短周期训练对比。比如扫描结果显示1e-3到1e-2之间比较合适那我就分别用3e-3、5e-3、8e-3各训5个epoch看验证集表现。这个方法虽然费点时间但比盲目猜测靠谱得多。还有一个经验学习率的最优值和batch size大致呈线性关系。如果你把batch size翻倍学习率也可以相应翻倍。这个规律在batch size不是特别大的时候比较准但到了超大batch场景就需要用LARS或LAMB这类专门的方法来做层级缩放。3.2 动量系数与beta参数让更新方向更“聪明”动量Momentum的引入是为了解决梯度下降在峡谷地形中来回震荡的问题。想象一个球从山坡滚下来如果没有任何惯性它会在谷底两侧来回弹跳。有了动量球就像有了惯性能沿着谷底方向持续加速同时抑制垂直方向的震荡。在SGD中动量系数通常设为0.9。这个值的含义是当前更新方向由90%的历史累积方向和10%的当前梯度方向共同决定。我试过0.99结果模型对梯度变化的响应变得非常迟钝训练损失下降很慢也试过0.5震荡抑制效果又不明显。0.9是一个经过大量实践验证的平衡点。Adam里的beta1和beta2本质上是动量的变体。beta1控制一阶矩梯度均值的滑动平均通常设0.9beta2控制二阶矩梯度平方均值的滑动平均通常设0.999。beta2之所以比beta1更接近1是因为梯度平方的波动比梯度本身更大需要更长的记忆来平滑。这里有个坑我踩过如果你把beta2设得太小比如0.99自适应学习率会变得非常不稳定因为二阶矩估计的方差太大。反过来如果beta2设得太大比如0.9999自适应学习率的变化会非常缓慢模型在训练前期几乎是用固定学习率在跑失去了自适应的优势。3.3 权重衰减与正则化防止模型“死记硬背”权重衰减Weight Decay是优化器里最容易被忽视但又极其重要的参数。它的作用是在每次更新时让参数值稍微向0靠拢一点从而限制模型复杂度防止过拟合。在原始SGD里权重衰减和学习率是耦合的衰减量等于学习率乘以衰减系数。这意味着你调学习率的时候实际上也在调衰减强度。AdamW的出现就是为了解耦这两者让权重衰减独立于学习率。我在做Transformer微调时通常会把权重衰减设为0.01配合AdamW使用。如果发现验证集损失比训练集损失高很多说明过拟合了可以把权重衰减加到0.05甚至0.1。但权重衰减不是越大越好。我试过在小型数据集上把权重衰减设到0.5结果模型直接欠拟合训练损失都降不下去。所以这个参数需要根据模型大小和数据量来平衡。一般来说模型参数量越大、训练数据越少权重衰减就应该越大。提示Batch Normalization层和偏置项通常不参与权重衰减。这些参数本身数量很少对模型复杂度影响微乎其微但对训练稳定性很重要。如果你用的是PyTorch可以通过参数分组来实现这一点。3.4 梯度裁剪防止训练“爆掉”的安全阀梯度裁剪是我强烈建议默认开启的一个机制。它的作用很简单当梯度的范数超过某个阈值时按比例缩小梯度使其不超过阈值。这就像给训练过程装了一个保险丝防止个别batch产生异常大的梯度把参数带飞。在RNN和Transformer训练中梯度爆炸是家常便饭。我通常会把梯度裁剪的阈值设在1.0到5.0之间。阈值太小梯度被过度裁剪模型学不动阈值太大又起不到保护作用。一个实用的技巧是先跑几百步记录梯度范数的分布然后把阈值设在95%分位数附近。PyTorch里用torch.nn.utils.clip_grad_norm_就可以实现。注意这个操作要在loss.backward()之后、optimizer.step()之前调用。我见过有人把它放在optimizer.step()之后结果完全没起作用白白浪费了调试时间。4. 完整实操流程与关键环节实现4.1 环境准备与基础配置在开始训练之前先把优化器相关的配置理清楚。我用的是PyTorch下面是一个典型的配置模板你可以直接拿去改。import torch import torch.nn as nn import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR, SequentialLR # 假设model已经定义好 model MyModel() # 参数分组权重衰减只作用于权重矩阵不作用于偏置和归一化层 decay_params [] no_decay_params [] for name, param in model.named_parameters(): if not param.requires_grad: continue if bias in name or norm in name or bn in name: no_decay_params.append(param) else: decay_params.append(param) optimizer optim.AdamW([ {params: decay_params, weight_decay: 0.01}, {params: no_decay_params, weight_decay: 0.0} ], lr1e-4, betas(0.9, 0.999), eps1e-8) # 学习率调度warmup 余弦衰减 warmup_epochs 5 total_epochs 50 warmup_scheduler LinearLR(optimizer, start_factor0.01, total_iterswarmup_epochs) cosine_scheduler CosineAnnealingLR(optimizer, T_maxtotal_epochs - warmup_epochs) scheduler SequentialLR(optimizer, schedulers[warmup_scheduler, cosine_scheduler], milestones[warmup_epochs])这段配置里参数分组是关键。权重衰减只作用于权重矩阵偏置和归一化层的参数不衰减。这个细节看起来小但在大模型训练中影响很大。我做过对比实验参数分组后验证集准确率能稳定提升0.3到0.5个百分点。4.2 训练循环中的优化器操作顺序训练循环里优化器相关操作的顺序非常重要顺序错了轻则效果打折重则训练崩溃。正确的顺序是这样的前向传播计算损失清空上一步的梯度optimizer.zero_grad()反向传播计算梯度loss.backward()梯度裁剪clip_grad_norm_优化器更新参数optimizer.step()学习率调度器更新scheduler.step()这里有两个容易出错的地方。第一zero_grad()一定要在backward()之前调用否则梯度会累积。PyTorch默认是累积梯度的如果你忘了清零相当于变相增大了batch size训练动态会完全改变。第二scheduler.step()的调用时机取决于调度器类型。对于按epoch更新的调度器应该在每个epoch结束后调用对于按step更新的则在每个batch后调用。我习惯在epoch级别做调度这样更直观。for epoch in range(total_epochs): model.train() for batch_idx, (data, target) in enumerate(train_loader): data, target data.cuda(), target.cuda() optimizer.zero_grad() output model(data) loss criterion(output, target) loss.backward() # 梯度裁剪 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() # epoch级别的调度器更新 scheduler.step() # 验证 model.eval() # ... 验证逻辑 ...4.3 学习率扫描的实操记录学习率扫描是我每次换新模型或新数据集时必做的第一件事。具体操作是设置一个极小的初始学习率然后每个batch乘以一个增长因子同时记录损失。def lr_find(model, optimizer, criterion, train_loader, start_lr1e-7, end_lr1.0, num_iter200): lrs [] losses [] factor (end_lr / start_lr) ** (1 / num_iter) lr start_lr for param_group in optimizer.param_groups: param_group[lr] lr model.train() best_loss float(inf) for i, (data, target) in enumerate(train_loader): if i num_iter: break data, target data.cuda(), target.cuda() optimizer.zero_grad() output model(data) loss criterion(output, target) loss.backward() optimizer.step() lr * factor for param_group in optimizer.param_groups: param_group[lr] lr lrs.append(lr) losses.append(loss.item()) # 如果损失开始发散提前停止 if loss.item() 4 * best_loss: break if loss.item() best_loss: best_loss loss.item() return lrs, losses跑完扫描后把lrs和losses画成曲线。你会看到损失先缓慢下降然后加速下降到达一个最低点后开始上升。最佳学习率通常取最低点左侧一个数量级的位置。比如最低点在1e-2那实际训练时用1e-3左右比较稳妥。我最近一次做文本分类任务时扫描结果显示最低点在3e-3附近我最终选了5e-4作为初始学习率配合warmup和余弦衰减训练非常稳定。如果直接拍脑袋设1e-3前期损失震荡会明显大很多。4.4 分布式训练下的优化器调整当你从单卡切换到多卡分布式训练时优化器需要做相应调整。最核心的变化是有效batch size变成了单卡batch size乘以卡数。根据前面说的线性缩放规则学习率也应该相应放大。但线性缩放不是无限有效的。当有效batch size超过某个阈值通常是几千后线性缩放会导致训练不稳定。这时候就需要用平方根缩放或者LAMB这样的层级自适应方法。我的经验是有效batch size在512以下线性缩放没问题512到4096之间可以用平方根缩放再大就得考虑LAMB或LARS了。另外分布式训练中梯度是跨卡平均的所以梯度裁剪的阈值不需要随卡数变化。但如果你用的是梯度累积来模拟大batch那裁剪阈值需要按累积步数做相应调整。5. 常见问题与排查技巧实录5.1 损失不下降或下降过慢这是最常见的问题原因通常有以下几个。第一学习率太小。如果你用的是Adam默认学习率1e-3通常是个不错的起点但如果模型很深或者数据分布很特殊可能需要调到1e-4甚至更低。第二梯度消失。深层网络里反向传播的梯度会逐层衰减到了浅层几乎为零。这时候可以检查每层的梯度范数如果浅层梯度比深层小好几个数量级就需要考虑加残差连接或者用更好的初始化方法。第三数据有问题。我遇到过标签全部错位的情况模型怎么训都不收敛排查了半天才发现是数据加载器的shuffle和标签没对齐。排查顺序建议是先确认数据没问题再检查梯度范数最后调学习率。数据问题往往最隐蔽但也最致命。5.2 损失震荡剧烈或出现NaN损失震荡通常意味着学习率过大或者batch size太小导致梯度噪声太大。可以先尝试把学习率降一半如果震荡明显减弱那就是学习率的问题。如果降了学习率还是震荡可以考虑增大batch size或者加梯度累积。出现NaN就更严重了通常是梯度爆炸或者数值溢出。首先检查有没有加梯度裁剪这是最基本的防护。其次检查损失函数里有没有log(0)或除以零的操作。我遇到过一次NaN是因为用了混合精度训练某些层的梯度在fp16下溢出了改成fp32后问题消失。另外Adam的eps参数如果设得太小比如1e-12在梯度接近零的时候也可能导致数值不稳定通常1e-8是个安全值。5.3 验证集表现远差于训练集这是典型的过拟合。优化器层面的应对手段主要是增大权重衰减。我通常会把权重衰减从0.01逐步加到0.05、0.1观察验证集损失的变化。但权重衰减不是万能的如果加了之后训练集损失也降不下去说明模型容量本身就不够需要换更大的模型或者加更多数据。另一个容易被忽视的点是学习率调度。如果学习率在训练后期还是很高模型会在训练集上过度优化导致泛化变差。确保余弦衰减的最终学习率足够小通常设为初始学习率的1%到0.1%。5.4 常见问题速查表现象可能原因排查方法解决手段损失不下降学习率过小、梯度消失、数据问题检查梯度范数、验证数据标签调大学习率、加残差连接、修复数据损失震荡学习率过大、batch过小降低学习率观察降学习率、增大batch、梯度累积出现NaN梯度爆炸、数值溢出检查梯度范数、损失函数梯度裁剪、换fp32、调大eps验证集差过拟合、学习率后期过高对比训练验证损失增大权重衰减、确保学习率衰减到位训练前期不稳定缺少warmup观察前几百步损失加warmup、降低初始学习率多卡效果变差学习率未缩放对比单卡多卡损失曲线按线性或平方根缩放学习率5.5 几个我踩过的坑和独家技巧第一个坑是优化器状态没保存。做实验的时候我习惯只保存模型权重觉得优化器状态无所谓。结果有一次训练到一半中断了恢复训练时优化器状态全丢了Adam的动量和二阶矩估计都归零导致恢复后的前几百步损失剧烈震荡白白浪费了半天时间。从那以后我保存checkpoint时一定会把optimizer.state_dict()和scheduler.state_dict()一起存下来。第二个坑是学习率调度器的step时机。PyTorch的CosineAnnealingLR如果按epoch更新但你在epoch中间调用了scheduler.step()学习率会提前衰减导致训练后期学习率过小。我现在的做法是明确区分按epoch和按step的调度器在代码里用注释标清楚避免混淆。第三个技巧是关于Adam的eps参数。默认的1e-8在大多数情况下没问题但如果你做的是强化学习或者生成模型梯度尺度可能非常小这时候把eps调到1e-6甚至1e-5能显著提升训练稳定性。这个技巧是我在一个生成任务里偶然发现的当时损失一直卡在某个值下不去调大eps后直接突破了瓶颈。第四个技巧是优化器参数的初始化。Adam的动量和二阶矩默认初始化为0这在训练刚开始时会导致更新量偏大。虽然warmup能缓解这个问题但如果你不想加warmup可以把Adam的amsgrad设为True它会用历史最大的二阶矩来归一化相当于一种自适应的warmup。6. 优化器进阶从手工调参到自动优化6.1 超参数搜索的实用策略手工调优化器参数效率太低我现在的做法是用Optuna做自动化搜索。搜索空间不用太大学习率取对数均匀分布范围设在1e-5到1e-2权重衰减取对数均匀分布范围1e-6到1e-1beta1和beta2固定为0.9和0.999就行这两个参数对结果影响相对小。搜索目标用验证集损失但要注意早停策略。我通常给每个试验分配固定的训练步数比如总步数的20%然后取验证损失最低的那个配置。这样一轮搜索下来大概需要几十个试验每个试验几分钟到几十分钟不等总体可接受。有个细节值得注意搜索出来的最优学习率往往偏大因为短周期训练和长周期训练的最优学习率不完全一样。我的做法是把搜索结果的0.5到0.7倍作为最终学习率这样在完整训练中更稳定。6.2 优化器与模型架构的协同设计优化器不是孤立存在的它和模型架构、初始化方法、归一化层都有交互。比如用了Layer Normalization的模型对学习率的敏感度通常比用Batch Normalization的模型低因为Layer Norm的梯度尺度更稳定。再比如用了残差连接的模型梯度可以绕过非线性层直接回传优化器更容易处理。我在设计新模型时会同步考虑优化器的选择。如果模型里有大量稀疏梯度比如嵌入层我会倾向于用Adam而不是SGD。如果模型是纯卷积结构且追求极致精度我会用SGDMomentum配合精细的学习率调度。这种协同设计的思路比先定模型再选优化器要高效得多。6.3 从零实现一个简单的自适应优化器理解优化器最好的方式是自己写一个。下面是一个简化版的Adam实现去掉了一些边界处理但核心逻辑完整。class SimpleAdam: def __init__(self, params, lr1e-3, betas(0.9, 0.999), eps1e-8): self.params list(params) self.lr lr self.beta1, self.beta2 betas self.eps eps self.m [torch.zeros_like(p) for p in self.params] self.v [torch.zeros_like(p) for p in self.params] self.t 0 def step(self): self.t 1 for i, p in enumerate(self.params): if p.grad is None: continue g p.grad.data # 更新一阶矩和二阶矩 self.m[i] self.beta1 * self.m[i] (1 - self.beta1) * g self.v[i] self.beta2 * self.v[i] (1 - self.beta2) * g * g # 偏差修正 m_hat self.m[i] / (1 - self.beta1 ** self.t) v_hat self.v[i] / (1 - self.beta2 ** self.t) # 参数更新 p.data - self.lr * m_hat / (torch.sqrt(v_hat) self.eps) def zero_grad(self): for p in self.params: if p.grad is not None: p.grad.detach_() p.grad.zero_()写一遍之后你对beta1、beta2、eps这些参数的理解会深刻很多。比如偏差修正为什么重要——如果不做修正训练初期的m和v都偏向0更新量会异常大。这个细节在调参时经常被忽略但自己实现一遍就再也不会忘了。6.4 优化器选择的决策树最后分享一个我在实际项目中用的决策流程帮你快速缩小优化器选择范围。第一步看任务类型。NLP和Transformer类模型直接从AdamW开始。计算机视觉卷积网络先试SGDMomentum不行再换AdamW。强化学习和生成模型Adam或RMSProp。第二步看batch size。小于512上面选的就行。512到4096考虑加warmup和线性缩放。大于4096上LAMB或LARS。第三步看训练稳定性。如果损失震荡严重先降学习率、加梯度裁剪、加warmup。如果还不行换优化器或者调beta2。第四步看泛化要求。如果验证集和训练集差距大增大权重衰减确保学习率衰减到位。如果追求极致精度在训练后期切换到SGD做精调。这个决策树不是绝对的但能帮你在大多数场景下快速找到方向。我自己的项目里八成以上的情况用AdamW配合warmup和余弦衰减就能搞定剩下两成需要根据具体情况做调整。提示优化器调参没有银弹。同样的配置在不同数据集上效果可能差很多。建立自己的实验记录习惯每次改动只调一个参数记录验证集变化积累几个月后你就有自己的直觉了。我在实际使用中发现优化器调参最忌讳的就是一次性改多个参数。你改了学习率又改了权重衰减结果效果变好了但你不知道是哪个起了作用。每次只动一个哪怕慢一点积累下来的经验才是真正属于你的。另外不要迷信论文里的默认参数那些参数是在特定数据集上搜出来的你的数据分布不一样最优参数大概率也不一样。花半天时间做一次学习率扫描比后面调一周都管用。
返回列表