拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

模型优化器实战指南:从SGD到AdamW的选型、调参与避坑

模型优化器实战指南:从SGD到AdamW的选型、调参与避坑

1. 模型优化器到底在优化什么

第一次看到“Model-Optimizer”这个词,很多人会下意识觉得它又是一个调参工具,或者某个深度学习框架里附带的小模块。但真正在训练一线待过的人都知道,模型优化器远不止“调个学习率”这么简单。它更像是整个训练流程里的“变速箱”——决定模型在损失曲面上以什么姿态、什么速度、什么节奏去逼近最优解。你用的优化器不同,同一个网络结构、同一份数据,最后收敛到的精度、训练耗时、显存占用可能差出一大截。

我最早接触优化器是在做图像分类任务的时候,当时用的是一个很朴素的随机梯度下降,学习率固定,结果训练到后期损失几乎不降,准确率卡在一个尴尬的位置。后来换成带动量的版本,再后来尝试自适应学习率的方法,才真正理解为什么优化器值得单独拿出来讲。Model-Optimizer这个标题背后,其实涵盖了一整套关于参数更新策略、梯度处理、学习率调度、内存效率的工程实践。它要解决的问题很具体:让模型在有限算力下更快、更稳地收敛,同时避免过拟合和梯度异常。

这篇文章适合谁看?如果你正在训练自己的模型,发现损失震荡、收敛慢、显存爆了,或者你只是想知道那些论文里的优化器到底该怎么选、怎么配,那接下来的内容应该能帮到你。我会从整体设计思路讲到具体实操,再到踩过的坑,尽量把每个选择背后的“为什么”说清楚。

2. 优化器的整体设计思路与选型逻辑

2.1 从梯度下降到自适应:优化器的演进脉络

要理解Model-Optimizer的设计,得先回到最根本的问题:我们到底在优化什么。训练一个神经网络,本质上是找一个参数集合,使得损失函数的值最小。最原始的做法是梯度下降,沿着梯度的反方向走一步,步长由学习率决定。但这里有个很现实的问题:不同参数的梯度尺度可能差好几个数量级,用一个全局学习率去更新所有参数,要么大的震荡,要么小的几乎不动。

于是有了动量法,它引入一个“速度”变量,把历史梯度累积起来,相当于给优化过程加了惯性。这样在梯度方向稳定的维度上加速,在震荡的维度上抑制来回摆动。再往后,自适应学习率的方法出现了,比如AdaGrad、RMSProp、Adam。它们的核心思想是:每个参数有自己的学习率,根据该参数历史梯度的大小动态调整。梯度一直很大的参数,学习率就降下来;梯度一直很小的参数,学习率就相对大一些。

Model-Optimizer这个项目标题,如果放在工程实践里,通常意味着你要在多个优化器之间做选择,并且针对自己的任务做定制。选型不是看哪个名字新就用哪个,而是要看任务特性。比如计算机视觉里的卷积网络,带动量的随机梯度下降往往比Adam泛化更好;而自然语言处理里的Transformer,Adam及其变体几乎是默认选择。这背后的原因和损失曲面的几何特性、参数初始化方式、批大小都有关系。

2.2 选型时真正该看的几个指标

很多教程会告诉你“Adam适合大多数情况”,但实际做项目的时候,这个建议太粗糙了。我一般会从下面几个维度去评估一个优化器是否适合当前任务。

第一是收敛速度。这里说的不是训练集上的损失下降快慢,而是验证集指标达到目标值需要多少轮。有些优化器前期下降飞快,但后期在最优解附近反复横跳,反而需要更多轮才能稳定。第二是最终精度。同样的网络和数据,不同优化器能达到的最高验证精度可能差一到两个百分点,这在竞赛或产品落地里是很关键的。第三是显存占用。像Adam这类自适应方法需要为每个参数存储一阶矩和二阶矩,显存开销大约是参数量的两倍。如果你的模型已经很大,优化器状态可能成为压垮显存的最后一根稻草。第四是对超参数的敏感度。有些优化器学习率设错一个数量级就完全训不动,有些则相对鲁棒。

我自己的经验是,如果算力充足且追求极致精度,带动量的随机梯度下降配合学习率预热和余弦退火,往往是最稳的选择。如果算力有限、需要快速迭代实验,Adam或它的改进版本更合适。如果模型参数量极大、显存紧张,就要考虑那些低内存的变体,比如只存储一阶矩或者使用量化状态的方法。

2.3 为什么不能一个优化器走天下

有人可能会问,既然Adam这么方便,为什么还要用别的?这里涉及一个很本质的问题:自适应学习率方法在某些任务上会导致泛化性能下降。有研究指出,Adam在训练后期,二阶矩的估计会变得不稳定,导致有效学习率忽大忽小,模型在最优解附近无法精细调整。而带动量的随机梯度下降虽然前期慢,但它的更新方向更“纯粹”,最终找到的极小值点往往更平坦,泛化更好。

另一个原因是任务对梯度的噪声敏感度不同。批大小很小的时候,梯度估计本身噪声就大,自适应方法可能会放大这种噪声。而批大小很大的时候,随机梯度下降的方差相对可控,配合动量就能很稳。所以Model-Optimizer的选型从来不是孤立的,它和批大小、学习率调度、权重衰减策略是绑在一起的。

3. 核心参数解析与实操配置要点

3.1 学习率:最重要的那个旋钮

学习率是优化器里最核心的参数,没有之一。它决定了每一步更新的大小。设得太小,收敛慢到让人怀疑人生;设得太大,损失直接飞出去变成NaN。我见过太多人因为学习率设错,白白浪费几天算力。

对于带动量的随机梯度下降,学习率通常在0.1到0.01之间,具体要看批大小。有一个经验公式:当批大小增大时,学习率可以按比例放大。比如批大小从128增加到256,学习率可以从0.1提到0.2。但这个线性缩放规则不是万能的,批大小特别大的时候需要配合学习率预热,否则初期梯度方差太大,训练不稳定。

对于Adam,默认学习率是0.001,但这个值在很多任务上偏大。我在做文本分类的时候,试过0.0001到0.0005的范围,发现0.0003左右比较稳。这里有个技巧:如果你不确定,先用一个较小的学习率跑几百步,观察损失下降曲线。如果损失下降很慢但很稳,可以适当调大;如果损失上下跳动甚至上升,就要调小。

还有一个容易被忽略的点是学习率预热。在训练刚开始的时候,模型参数是随机初始化的,梯度可能很大且方向混乱。如果直接用大学习率,很容易把参数推到不好的区域。预热就是在最初几百到几千步里,让学习率从接近零线性增加到设定值。这个技巧在Transformer类模型里几乎是标配。

3.2 动量与二阶矩估计:让更新更平滑

动量系数通常设为0.9,这个值在大多数任务上表现不错。它的作用是累积历史梯度,让更新方向更一致。你可以把它想象成给优化过程加了一个低通滤波器,把高频的梯度噪声滤掉。如果动量设得太大,比如0.99,优化器会变得很“迟钝”,对新的梯度方向反应慢;设得太小,比如0.5,又起不到平滑效果。

Adam里的两个衰减率,beta1和beta2,分别控制一阶矩和二阶矩的指数移动平均。默认值是0.9和0.999。beta1和动量系数类似,beta2决定了梯度平方的累积速度。如果beta2设得太接近1,比如0.9999,二阶矩估计会非常平滑,但也会导致有效学习率在训练初期偏大,因为初始的梯度平方估计接近零。有些实现会做偏差校正,就是为了解决这个问题。

我在实际项目里遇到过一个情况:用Adam训练一个深层网络,损失在前几百步下降很快,然后突然开始震荡。排查后发现是beta2默认值导致二阶矩估计在初期太小,有效学习率被放大了。后来把beta2调到0.99,震荡就消失了。所以不要迷信默认值,要根据任务观察调整。

3.3 权重衰减与正则化:防止过拟合的隐形手

权重衰减在优化器里通常以L2正则化的形式出现,它给损失函数加了一个与参数平方成正比的项,使得参数倾向于变小。但这里有个坑:在自适应优化器里,标准的L2正则化和权重衰减并不等价。因为自适应学习率会缩放梯度,L2正则化项也会被缩放,导致实际的正则化效果和预期不一致。

为了解决这个问题,有了解耦权重衰减的方法,也就是AdamW。它把权重衰减从梯度更新里拆出来,直接作用于参数本身。这样无论学习率怎么变,权重衰减的强度都是稳定的。我在做图像分割任务的时候,从Adam换成AdamW,验证集精度提升了差不多一个百分点,过拟合现象也减轻了。所以如果你用的是自适应优化器,强烈建议用解耦权重衰减的版本。

权重衰减系数通常设在1e-4到1e-2之间。模型越大、数据越少,这个系数应该越大。但也不能太大,否则模型会欠拟合,连训练集都学不好。我一般会从1e-4开始试,如果验证集损失比训练集损失高很多,就逐步调大。

3.4 批大小与优化器的配合

批大小影响梯度估计的方差。批越大,梯度越接近真实梯度,但计算开销也越大。批太小,梯度噪声大,优化器需要更强的平滑能力。这里有一个经验规则:批大小翻倍,学习率也翻倍,同时预热步数也要相应增加。

但批大小不是越大越好。有研究表明,过大的批会导致模型泛化变差,因为梯度噪声本身有正则化效果。我在做推荐系统的时候,试过把批大小从256提到1024,训练速度确实快了,但验证集指标反而降了。后来通过增加权重衰减和调整学习率才勉强追平。所以批大小的选择要在训练效率和泛化之间找平衡。

4. 完整实操流程与关键环节实现

4.1 环境准备与依赖安装

假设你用的是PyTorch,优化器相关的接口都在torch.optim里。先确保你的环境里装好了对应版本的PyTorch和CUDA。我一般会创建一个独立的虚拟环境,避免版本冲突。

python -m venv optimizer_env source optimizer_env/bin/activate pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118

如果你用的是其他框架,比如TensorFlow或JAX,优化器的API名称不同,但核心概念是一样的。这里以PyTorch为例,因为它的优化器实现比较直观,方便我们观察内部状态。

安装完成后,可以写一个简单的脚本来验证优化器是否正常工作。比如定义一个线性回归任务,用不同的优化器去拟合,观察损失下降曲线。这个步骤看起来多余,但能帮你快速确认环境没问题。

4.2 定义模型与优化器的标准流程

先定义一个简单的卷积网络或者全连接网络,然后选择优化器。下面是一个典型的配置示例。

import torch import torch.nn as nn import torch.optim as optim model = MyNetwork() criterion = nn.CrossEntropyLoss() optimizer = optim.AdamW( model.parameters(), lr=3e-4, betas=(0.9, 0.999), weight_decay=1e-4 ) scheduler = optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=100, eta_min=1e-6 )

这里有几个细节值得说。第一,学习率设的是3e-4,这是一个在Transformer类模型里比较常用的值。第二,用了AdamW而不是Adam,权重衰减系数是1e-4。第三,加了余弦退火调度器,让学习率在训练过程中从初始值逐渐降到接近零。这个调度策略在图像分类和自然语言处理里都很常见,能让模型在后期更精细地调整参数。

如果你用的是带动量的随机梯度下降,配置会不一样。

optimizer = optim.SGD( model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4, nesterov=True )

这里用了Nesterov动量,它比标准动量多了一步“前瞻”,在梯度计算时先按当前动量走一步,再计算梯度。实测下来,Nesterov动量在大多数任务上比标准动量收敛更快。

4.3 学习率调度器的选择与配置

学习率调度器决定了学习率随训练进程如何变化。常见的有步进衰减、余弦退火、指数衰减、平台衰减等。我一般会根据任务类型来选。

图像分类任务,我习惯用余弦退火配合预热。预热用线性升温,从0到初始学习率,持续5到10个epoch。然后余弦退火从初始学习率降到接近零。这个组合在ResNet和Vision Transformer上都表现很好。

自然语言处理任务,特别是Transformer,通常用带预热的逆平方根衰减。预热步数一般是总步数的10%左右,然后学习率按步数的平方根倒数衰减。这个策略在原始Transformer论文里就有,后来被广泛沿用。

如果你不确定用哪个,可以先试余弦退火,它比较通用。但要注意,余弦退火的周期T_max要设得合理。如果设得太小,学习率降得太快,模型还没收敛就没什么更新了;设得太大,学习率一直很高,后期损失震荡。

from torch.optim.lr_scheduler import LambdaLR import math def lr_lambda(step): if step < warmup_steps: return step / warmup_steps return math.sqrt(warmup_steps / step) scheduler = LambdaLR(optimizer, lr_lambda)

上面这段代码实现了一个带预热的逆平方根衰减。warmup_steps需要根据总训练步数来定,一般是总步数的5%到10%。

4.4 梯度裁剪与异常处理

训练深层网络的时候,梯度爆炸是个常见问题。特别是循环神经网络和很深的Transformer,梯度可能会变得非常大,导致参数更新过猛,损失变成NaN。梯度裁剪就是给梯度设一个上限,超过这个上限就按比例缩小。

torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

这个操作通常在反向传播之后、优化器更新之前调用。max_norm设1.0是一个比较保守的值,适合大多数情况。如果你发现训练不稳定,可以试着调小到0.5;如果训练很稳但收敛慢,可以调大到5.0。

除了梯度裁剪,还要监控梯度的范数。如果梯度范数持续很大,说明学习率可能太高了,或者模型结构有问题。我一般会在训练脚本里加一个日志,每几百步记录一次梯度范数,方便排查。

4.5 训练循环中的优化器状态管理

优化器是有状态的,特别是自适应优化器,它存储了每个参数的一阶矩和二阶矩。这些状态在保存和加载模型时也需要处理。如果你只保存了模型参数,重新加载后优化器状态是空的,继续训练时会出现学习率突然变化、损失震荡的情况。

# 保存 torch.save({ 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'scheduler_state_dict': scheduler.state_dict(), 'epoch': epoch }, 'checkpoint.pth') # 加载 checkpoint = torch.load('checkpoint.pth') model.load_state_dict(checkpoint['model_state_dict']) optimizer.load_state_dict(checkpoint['optimizer_state_dict']) scheduler.load_state_dict(checkpoint['scheduler_state_dict']) start_epoch = checkpoint['epoch'] + 1

这个细节很多人会忽略,导致恢复训练后效果变差。特别是做长周期训练的时候,一定要把优化器状态一起保存。

5. 常见问题与排查技巧实录

5.1 损失变成NaN怎么办

这是训练中最让人头疼的问题之一。损失变成NaN通常意味着数值溢出,可能是学习率太大、梯度爆炸、或者数据里有异常值。排查步骤可以按下面这个顺序来。

先检查数据。有没有缺失值、无穷大、或者标签越界。我遇到过一次,数据预处理的时候归一化参数算错了,导致输入特征范围在1e10量级,网络第一层就直接溢出了。后来把输入归一化到0到1之间,问题就解决了。

如果数据没问题,就调小学习率。把学习率降一个数量级再试。如果降了之后不NaN了,说明之前的学习率太大。但也要注意,学习率太小会导致收敛慢,所以要在稳定和速度之间找平衡。

再检查梯度。加梯度裁剪,把max_norm设小一点,比如0.1。如果加了裁剪就不NaN了,说明是梯度爆炸。这时候还要看看网络结构,是不是层数太深、有没有残差连接、初始化是不是合理。

还有一个容易被忽略的点是混合精度训练。用float16的时候,数值范围比float32小很多,更容易溢出。如果开了混合精度,可以试试用动态损失缩放,或者把某些敏感操作强制用float32。

5.2 验证集指标不升反降

训练集损失在降,但验证集损失先降后升,这是典型的过拟合。解决办法有几个方向。增加数据增强,比如图像任务里的随机裁剪、翻转、颜色抖动。增大权重衰减系数,从1e-4提到1e-3甚至1e-2。加Dropout层,特别是在全连接层之前。早停,在验证集损失开始上升的时候停止训练。

但还有一种情况是验证集损失和训练集损失一起降,但验证集指标就是不好。这可能是优化器在验证集上泛化不好。可以试试换优化器,比如从Adam换成带动量的随机梯度下降,或者调整学习率调度策略。我有一次做细粒度分类,用Adam训练验证集准确率一直卡在70%左右,换成带动量的随机梯度下降配合余弦退火,直接到了75%。

5.3 训练速度慢得让人着急

训练速度受很多因素影响。首先是硬件利用率,用nvidia-smi看看GPU利用率是不是一直很低。如果GPU利用率只有30%到50%,说明数据加载是瓶颈。可以增加DataLoader的num_workers,开pin_memory,把数据预处理放到GPU上做。

然后是批大小。批大小太小,GPU并行度不够;批大小太大,显存不够。要找到那个刚好把显存占满的批大小。我一般会从32开始试,逐步翻倍,直到显存快满为止。

优化器本身也会影响速度。自适应优化器因为要更新每个参数的状态,计算量比随机梯度下降大。如果模型参数量上亿,优化器状态更新可能占不少时间。这时候可以考虑用低内存的优化器变体,或者用随机梯度下降。

还有一个技巧是梯度累积。如果显存不够,没法用大批大小,可以累积几个小批的梯度再更新一次。这样等效于大批大小,但显存占用不变。

accumulation_steps = 4 for i, (inputs, labels) in enumerate(dataloader): outputs = model(inputs) loss = criterion(outputs, labels) / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()

5.4 常见问题速查表

问题现象可能原因排查方法解决措施
损失变成NaN学习率太大、梯度爆炸、数据异常检查数据范围、梯度范数调小学习率、加梯度裁剪、归一化数据
验证集指标不升过拟合、优化器泛化差对比训练集和验证集损失增加正则化、换优化器、早停
训练速度慢数据加载瓶颈、批大小不合适看GPU利用率、显存占用增加num_workers、调整批大小、梯度累积
损失震荡学习率太大、批大小太小观察损失曲线调小学习率、增大批大小、加动量
恢复训练后效果差优化器状态未保存检查checkpoint内容保存和加载优化器状态
显存不足优化器状态占用大、批大小太大看显存占用换低内存优化器、减小批大小、梯度累积

5.5 几个我踩过的坑

第一个坑是盲目相信默认学习率。Adam的默认学习率是0.001,但这个值对很多任务来说太大了。我刚开始做文本生成的时候,直接用默认值,结果模型生成的文本全是重复的。后来把学习率降到0.0001,生成质量才正常。

第二个坑是忘了调整权重衰减。用Adam的时候,权重衰减的实现和随机梯度下降不一样。如果直接套用随机梯度下降的权重衰减系数,实际正则化效果会弱很多。后来换成AdamW,并且把权重衰减系数调大,过拟合才控制住。

第三个坑是学习率调度器和优化器不匹配。有一次我用余弦退火,但T_max设成了总epoch数,结果训练到一半学习率就降到接近零了,模型还没收敛。后来把T_max改成总epoch数的两倍,让学习率降得更慢,效果就好了。

第四个坑是梯度累积和批归一化的交互。批归一化在训练时用当前批的均值和方差,如果用了梯度累积,每个小批的统计量不一样,会导致训练和推理行为不一致。解决办法是用同步批归一化,或者干脆不用梯度累积。

6. 进阶技巧与性能调优

6.1 分层学习率与参数分组

有时候模型的不同部分需要不同的学习率。比如微调预训练模型的时候,底层特征提取部分的学习率应该小一些,顶层分类器的学习率可以大一些。这时候可以把参数分组,给每组设不同的学习率。

params = [ {'params': model.backbone.parameters(), 'lr': 1e-5}, {'params': model.classifier.parameters(), 'lr': 1e-3} ] optimizer = optim.AdamW(params, weight_decay=1e-4)

这个技巧在迁移学习里特别有用。底层参数已经学得很好,不需要大改;顶层是随机初始化的,需要快速学习。

6.2 优化器状态的量化与压缩

如果模型特别大,优化器状态可能占用大量显存。有一种方法是把优化器状态量化到8位整数,这样显存占用可以降到原来的四分之一。虽然会损失一点精度,但在显存紧张的时候很实用。

# 伪代码示意 optimizer = bitsandbytes.optim.Adam8bit(model.parameters(), lr=1e-3)

这种8位优化器在训练大语言模型的时候很常见。实测下来,精度损失很小,但显存节省很明显。

6.3 学习率查找器

如果你完全不知道学习率该设多少,可以用学习率查找器。它的原理很简单:从很小的学习率开始,每步按指数增长,同时记录损失。损失下降最快的那个学习率,就是比较合适的初始值。

# 伪代码示意 lrs = [] losses = [] lr = 1e-7 for step in range(100): lr *= 1.1 for param_group in optimizer.param_groups: param_group['lr'] = lr loss = train_step() lrs.append(lr) losses.append(loss) # 然后画图,找损失下降最陡的点

这个方法我试过几次,找到的学习率通常比手动试的要好。但要注意,它只是给一个初始值,后续还是需要配合调度器。

6.4 优化器与混合精度训练的配合

混合精度训练用float16做前向和反向,用float32做参数更新。这样可以节省显存、加快计算。但float16的数值范围小,容易溢出。所以需要损失缩放,把损失放大一定倍数,让梯度也放大,避免下溢。

scaler = torch.cuda.amp.GradScaler() for inputs, labels in dataloader: with torch.cuda.amp.autocast(): outputs = model(inputs) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() optimizer.zero_grad()

用了混合精度之后,优化器的更新步骤还是float32,所以优化器状态不受影响。但要注意,梯度裁剪的时候要先取消缩放,否则裁剪的阈值就不对了。

7. 不同任务场景下的优化器配置参考

7.1 图像分类

图像分类任务通常用卷积网络或Vision Transformer。对于卷积网络,带动量的随机梯度下降配合余弦退火是经典组合。学习率0.1,动量0.9,权重衰减5e-4,批大小256,预热5个epoch。对于Vision Transformer,AdamW更合适,学习率3e-4到1e-3,权重衰减0.05,批大小1024,预热10个epoch。

7.2 自然语言处理

Transformer类模型几乎都用AdamW。学习率通常设1e-4到3e-4,beta2设0.98或0.99,权重衰减0.01。学习率调度用带预热的逆平方根衰减。批大小根据显存尽量大,但要注意学习率要相应调整。

7.3 生成对抗网络

生成对抗网络的训练很特殊,生成器和判别器需要分别优化。通常用Adam,学习率2e-4,beta1设0.5而不是默认的0.9。这是因为生成对抗网络的梯度噪声很大,较小的beta1能让优化器对新的梯度更敏感。如果beta1太大,优化器会过于平滑,导致生成器跟不上判别器的变化。

7.4 强化学习

强化学习的优化器配置和任务关系很大。策略梯度方法通常用Adam,学习率3e-4到1e-3。价值函数的学习率可以稍大一些。要注意的是,强化学习的梯度方差很大,可能需要更大的批大小或者梯度裁剪。

8. 我个人的一些经验体会

优化器这个东西,理论是一回事,实际用起来又是另一回事。我最大的体会是:不要迷信任何默认值,也不要迷信任何论文里的推荐配置。你的任务、你的数据、你的硬件,都会影响最优配置。

我一般会先用一个保守的配置跑一个短周期实验,比如10个epoch,观察损失曲线和验证集指标。如果损失下降平稳,验证集指标在升,就继续跑长周期。如果损失震荡或者验证集不升,就调整学习率或换优化器。这个过程可能需要反复几次,但比盲目跑几百个epoch要高效得多。

还有一个习惯是记录每次实验的配置和结果。我会用一个表格记录优化器类型、学习率、权重衰减、批大小、最终验证精度。这样下次遇到类似任务,可以直接从历史配置里找参考,不用从头试。

最后说一个容易被忽略的点:优化器的选择要和模型初始化配合。如果初始化方差太大,初期梯度就会很大,自适应优化器可能会过度反应。如果初始化方差太小,梯度接近零,优化器几乎不动。所以调优化器的时候,也要看看初始化是不是合理。

这个内容后续还可以往分布式训练方向扩展,比如多卡训练时优化器状态如何同步、梯度如何聚合。那又是另一个话题了,有机会再聊。

返回列表