☰
模型优化器选型与调参实践:从AdamW到学习率调度,解决训练收敛难题
2026/10/1 19:38:54 网站建设 项目流程

如果你也经历过这种场景——训练脚本一跑就是两天,loss 卡在一个区间来回震荡,日志里每一轮都长得差不多,最终模型的指标就是上不去——那Model-Optimizer这个名字你多半会感兴趣。它是我最近整理的一个内部项目,核心是把模型训练中最容易被低估的环节:优化器选型、参数配置、学习率调度、梯度处理,做成一套可以复用、可以对照排查的组件。这篇文章把这些天踩过的坑和总结下来的思路完整写出来,适合正在调模型收敛速度、或者觉得“换了个优化器居然差别这么大”的同行参考。

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

1.1 训练的本质:在损失函数上走下山

模型的训练过程,本质上是在一个高维损失函数曲面上寻找低点。这个曲面有多复杂,训练过真实模型的人都有体会:梯度方向在局部看是可靠的,但一旦把视野拉远,就会遇到平坦区域、陡峭峡谷、鞍点、局部极小点交织在一起的情况。

优化器做的事情,就是决定“每一步怎么走”。同样是拿到当前点的梯度,可以原样往下走,可以累积历史方向形成动量,可以根据参数自身的更新幅度调整步长,也可以对不同的参数区别对待。这些微小的差异在几张图上也许不明显,但在几万步训练之后,差距会被放大到足以影响模型是否收敛、收敛得多快、最终精度多少。

我在设计Model-Optimizer的时候,首先明确了一个原则:不把优化器当成一个“黑盒”。每次训练前,我会先在损失曲面的简单子集上做一个 50 步的小实验,只看优化器的更新轨迹和梯度方向的一致性。这件事看起来增加了几分钟工作量,却能在正式训练之前发现大量问题,比如梯度过大导致的震荡、学习率与权重衰减互相打架、动量缓冲区的统计量偏差。

1.2 优化器不是学习率的附庸

不少人一谈起调参,第一反应就是改学习率,优化器好像是“挂在那里不用管”的东西。实际不是。学习率只是优化器内部更新规则里的一个系数,它和动量系数、二阶矩估计、权重衰减耦合在一起,单独调整学习率而忽视其他参数,往往会得到错误的结论。

举一个非常常见的例子:用 Adam 训练一个文本分类模型,初始学习率设为 3e-5 和 1e-4,可能最终精度差距不大;但如果把 beta1 从 0.9 改成 0.99,同时不调整学习率,训练曲线会明显变慢,因为一阶矩的平均窗口变长,相当于给梯度加了更大的惯性。又比如 Adam 的二阶矩估计初值导致前几步更新偏大,配合较大的学习率,很容易在第一步就跳出有效区域。

Model-Optimizer因此把优化器的一组参数视为一个整体配置来管理,而不是散落在训练脚本里的几个 magic number。每个实验都记录完整的优化器配置快照,包括每一处默认值是被显式指定还是沿用默认,这样出问题的时候才能定位到真正的变量。

1.3 Model-Optimizer 项目的切入点

这个项目最初只是为了解决一个很具体的痛点:团队里每个人都有自己写的一套训练循环,每个人对优化器的理解不一样,有人用 SGD 加了动量,有人用 Adam 把 epsilon 改成 1e-4,有人用 AdamW 但没调权重衰减。模型结构类似,结果却很难横向对比。

后来我把它做成一个独立的模块,定义统一接口,允许下面接各种优化器实现。训练脚本里只需要声明任务类型、参数量级、显存上限,Model-Optimizer会自动给出推荐配置,并生成一份“为什么这样推荐”的说明。这个设计最大的收益不是自动化,而是把经验固化下来:同一个任务、同一个 batch size、同样的数据分布,换人跑也能得到一致的训练行为。

2. 主流优化器的进化脉络与选型

2.1 从 SGD 到动量法:给梯度加惯性

普通 SGD 的更新规则极其简单:param -= lr * grad。在凸问题和小数据集上,这个简单的规则有很好的收敛性保证。但在深度网络里,曲面曲率变化剧烈,纯 SGD 容易在峡谷两侧来回震荡,走得很慢。

动量法解决的就是这个问题。它维护一个梯度的指数滑动平均v = momentum * v - lr * grad,然后参数按v更新。这样当某些维度上的梯度方向反复变化时,动量项会互相抵消;当某个方向持续一致时,更新速度会逐步累积。实践中我用 SGD Momentum 训练图像分类模型,大的 momentum 值比如 0.9 到 0.99 区间往往比裸 SGD 稳定很多,而且对学习率没那么敏感。

但动量法也有自己的麻烦:它对所有参数使用同一个学习率,稀疏特征对应的参数更新量少,容易被“平均”淹没。如果任务里有大量稀疏特征,比如推荐系统里的 id 类特征,纯 SGD 家族的收敛速度通常不尽如人意,这也是自适应方法流行的原因。

2.2 Adam 的一阶矩与二阶矩:自适应带来了什么

Adam 可以理解为动量法加逐参数自适应学习率。它维护一阶矩m和二阶矩v,分别估计梯度均值与梯度平方的均值。每个参数的更新量等于lr * m_hat / (sqrt(v_hat) + epsilon)。从直觉上说,梯度变化剧烈的维度会被缩小步长,梯度平稳的维度会获得相对更大的信任。

Adam 在 NLP、多模态、生成模型上表现稳定,尤其是 Transformer 类结构,几乎成了默认配置。但它的问题也很真实。第一,前几步因为二阶矩从 0 开始,估计偏差较大,虽然 Adam 做了偏差修正,但在学习率选择不当的时候早期更新还是会偏大。第二,Adam 把权重衰减直接加到梯度上,而不是在参数更新时解耦处理,这会让 L2 正则的效果大打折扣。第三,它需要维护一阶矩和二阶矩两份状态,显存开销增加明显。

我在Model-Optimizer的早期版本里甚至想完全绕开 Adam,后来发现没必要。实践中最稳妥的做法是先按 Adam 跑通基线,再根据模型的规模和任务特性,要么切到 AdamW,要么考虑 LAMB 这类用于大批量的变体。

2.3 AdamW、LAMB、LARS:大模型训练下的修正

AdamW 看起来只是修正了权重衰减的位置,实际影响却很大。原始 Adam 实现中,权重衰减项参与梯度计算,再被一阶矩和二阶矩处理,相当于每步的衰减强度会被动态缩放;AdamW 则把权重衰减直接从参数中减去,与梯度无关。这个“解耦”让每一轮真实有效的正则幅度更可控,大批量训练时效果更稳。大模型预训练几乎全部采用 AdamW,我实测同样的模型,从 Adam 换到 AdamW,固定随机种子,最终 loss 能低不少。

LAMB 在 AdamW 的基础上做了一层逐层的自适应缩放,让不同 Transformer 层的更新幅度保持一致,从而支持更大的 batch size,在百万级 batch 的预训练任务中很常见。LARS 则多为图像预训练使用,尤其是 ResNet 类结构的超大批量训练,它按层的权重范数与梯度范数之比去缩放更新。Model-Optimizer在配置推荐里会依据任务类型做区分:图像对比学习任务偏好 LARS,文本预训练任务偏好 LAMB 或 AdamW,常规微调任务则保留 AdamW。

2.4 不同业务场景的选型速查表

为了减少每次重新纠结的时间,我整理了一张简化版的选型表,它不覆盖所有场景,但足够支撑大多数训练任务。

任务类型推荐优化器学习率参考关键注意点
传统图像分类SGD Momentum0.01 ~ 0.1配合 cosine 退火,容易调出最佳精度
微调 Transformer 模型AdamW1e-5 ~ 5e-5权重衰减取 0.01,注意 warmup 比例
大规模 NLP 预训练AdamW / LAMB1e-4 ~ 5e-4大批量时优先 LAMB,关注梯度 global norm
对比学习 / 自监督LARS / LAMB0.3 ~ 1.0(LARS 默认)需要配合较大的 batch size 才有优势
稀疏特征推荐模型Adam / AdaFactor1e-3 左右稀疏参数组单独设学习率,关注 embedding 更新量
强化学习策略网络Adam1e-4 ~ 3e-4需额外加上梯度裁剪,状态值估计的 loss 容易爆炸

这张表不是绝对的。每个任务的数据量、模型深度、batch size都会影响最优配置,更靠谱的做法是拿两次实验对比,而不是直接信任某篇文章里的“默认值”。Model-Optimizer内部也沿用这个思路:它只给出推荐起点,然后在日志里显眼地提示要做 at least two runs。

3. 实操:把 Model-Optimizer 接进训练循环

3.1 一个可替换的优化器接口设计

项目里最核心的是一套与具体框架解耦的优化器封装。它不重新实现底层更新公式,而是统一管理参数分组、梯度处理、学习率调度和状态记录。下面是我反复调整后留下的简化版本,思路比代码本身更重要。

import torch from torch.optim import Optimizer from torch.optim.lr_scheduler import LambdaLR class ModelOptimizer: def __init__(self, params, optimizer_type="adamw", lr=1e-3, weight_decay=0.01, betas=(0.9, 0.999), eps=1e-8, grad_clip=1.0): self.params = list(params) self.lr = lr self.grad_clip = grad_clip decay_params = [p for p in self.params if p.requires_grad and p.dim() >= 2] no_decay_params = [p for p in self.params if p.requires_grad and p.dim() < 2] param_groups = [ {"params": decay_params, "weight_decay": weight_decay}, {"params": no_decay_params, "weight_decay": 0.0}, ] if optimizer_type == "adamw": self.optimizer = torch.optim.AdamW(param_groups, lr=lr, betas=betas, eps=eps) elif optimizer_type == "sgd": self.optimizer = torch.optim.SGD(param_groups, lr=lr, momentum=0.9) elif optimizer_type == "lamb": from optimizers.lamb import LAMB self.optimizer = LAMB(param_groups, lr=lr, betas=betas, eps=eps) else: raise ValueError(f"unsupported optimizer: {optimizer_type}") def step(self): if self.grad_clip is not None: torch.nn.utils.clip_grad_norm_(self.params, max_norm=self.grad_clip) self.optimizer.step() def zero_grad(self): self.optimizer.zero_grad(set_to_none=True)

这段代码真正有用的地方在于参数分组。对 Transformer 模型来说,bias 和 LayerNorm 里的 weight 不应该做权重衰减,这是一个特别容易踩的细节。很多默认实现会对所有参数一视同仁,导致 LayerNorm 的 scale 被逐步压小,最终模型表达能力受损,但表面上 loss 又没明显变化,直到看 attention 输出分布才意识到问题。

3.2 学习率策略要与优化器一起调

优化器决定了每一步更新方向,学习率调度则决定每步的“腿长”。我最常用的组合是 linear warmup 配合 cosine decay,这种组合在预训练和微调里都稳定。

def build_scheduler(optimizer, warmup_steps, total_steps): def lr_lambda(step): if step < warmup_steps: return (step + 1) / warmup_steps progress = (step - warmup_steps) / max(1, total_steps - warmup_steps) return 0.5 * (1.0 + math.cos(math.pi * progress)) return LambdaLR(optimizer, lr_lambda)

warmup 的比例怎么定,取决于优化器和模型规模。我用 Adam 训练小型模型时,warmup 占 5% 就够;但训练大型模型时,早期梯度的方差很大,warmup 至少要占到总步数的 1% 到 10%。如果换用 LAMB 这种自适应缩放明显的优化器,warmup 反而是避免第一步直接飞掉的关键:大批量下 LAMB 的更新尺度对学习率非常敏感,没有 warmup 时我见过训练 loss 直接变成 NaN。

另一个容易被忽略的点是,优化器状态与学习率调度器的状态一起保存。恢复 checkpoints 时如果只恢复模型参数和优化器状态,忘了恢复 scheduler 的 step 计数,那么 warmup 会重新执行一遍,学习率曲线出现一个诡异的“下降再上升”,训练行为完全偏离预期。Model-Optimizer里把 optimizer 和 scheduler 的状态打进同一个 checkpoint,恢复接口一次性做完整,这个设计减少了大量无效调试时间。

3.3 梯度裁剪、权重衰减、EMA 的交互关系

梯度裁剪与优化器的关系,比表面看起来更微妙。对 Adam 这类自适应优化器,梯度裁剪的作用不像对 SGD 那么直接,因为 Adam 已经通过二阶矩对更新尺度做了归一化,但裁剪仍然能避免个别异常样本破坏二阶矩估计。实践上我把 grad_clip 放在 optimizer.step 之前统一处理,clip 的 max_norm 初始值设为 1.0,再根据训练中真实的 gradient norm 分布去调整。

权重衰减的数值也不能拍脑袋。很多代码默认 weight_decay=0.01,但对小模型而言,0.01 可能过强,导致模型欠拟合。我经验上先看验证集指标,再试 0.001 和 0.05 两个极端,初步确定量级后再细调。注意 AdamW 里的 weight_decay 与学习率是解耦的,所以不会因为学习率衰减而自动变小,这也是它和 L2 regularization 在训练动态上的关键差异。

EMA(指数移动平均)是另一个常见的模型增强手段。它不属于优化器本体,但在训练过程中维护参数滑动平均,对最终的推理模型往往有 0.2 到 0.5 个百分点的提升。我在实现中把 EMA 更新放在 optimizer.step 之后,用独立的 momentum 因子,比如 0.999。要特别注意 EMA 参数不应该随着优化器一起保存,否则 checkpoint 体积变大,而且推理时如果误用训练参数而不是 EMA 参数,结果会莫名其妙变差。

4. 训练过程中的常见问题与排查心得

4.1 loss 一直不降,先别急着骂优化器

接手一个新任务时,我最常见的误区是一看 loss 不降就立刻换优化器。后来总结下来,真正的问题往往出在三个地方:数据 pipeline 有问题、学习率不合适、模型输出层初始化有缺陷。遇到 loss 不降,先固定优化器不变,用同一个配置跑一个小规模实验,比如只拿 100 个 batch,观察 loss 的前 100 步曲线。

如果曲线是缓慢下降的,说明更新方向基本正确,只是速度不够,可以尝试增大学习率;如果曲线完全水平,不再下降,先检查标签是否有噪声、loss 是否计算错了、每个 batch 的数据是否相似;如果曲线先上升再快速下降,可能学习率偏大,或者 warmup 不够。优化器本身出问题的概率,反而比这些基础问题低得多。

我还养成了一个习惯:把每次实验的 loss 曲线、gradient norm、param norm 全部记录下来,抽样对比。Model-Optimizer里内置了一个 tiny callback,每个指定步数打印一次这部分信息。只看 loss 很难定位问题,但 loss 与 grad norm 一起看,就能判断是更新方向冲突还是步长过冲。比如 loss 震荡但 grad norm 持续很大,说明学习率过高;loss 下降缓慢且 grad norm 很小,说明可能卡在平坦区,优化器的作用空间有限。

4.2 Adam 在大模型下的显存压力与低比特优化器

优化器状态在训练大模型时是一个不容忽视的显存大户。以 Adam 为例,每个参数要额外保存一阶矩和二阶矩两个 float32 张量,相当于参数本身的 8 倍字节数。一个 70 亿参数的模型,仅优化器状态就需要约 56GB 显存,这还没算模型参数、梯度和中间激活。很多大模型训练场景为了省显存,会优先考虑低比特优化器。

Adafactor 用近似方式降低了二阶矩的存储,把逐元素状态变成按行列的低秩分解,显存节省明显,但在接近收敛时的行为偏保守。近两年我实测过一些 8 位 Adam 实现,它把二阶矩量化成 int8,配合动态缩放存储,显存可以压到原来一半以下,大多数任务训练精度和 fp32 Adam 接近,但也出现过个别数据集上收敛不稳定。Model-Optimizer的处理是对这三个选项做显存模拟:根据参数量、batch size、激活显存估算,告诉你在当前显卡上能不能跑得下,再决定使用哪种优化器。

4.3 优化器对比实验的公平性设计

想比较不同优化器,最忌讳只改优化器类型而不控制其他变量。比如 SGD 需要的学习率常常大于 Adam,如果都用同一个学习率,结论自然不公。我在做对比实验时习惯先为每种优化器各做一次粗粒度学习率扫描,找到各自的最优区间,再在同一最优配置下对比。这样虽然是两倍甚至三倍的计算量,但结论可靠得多。

另外不要忽略随机种子、数据顺序和初始化方式。模型初始化不同,优化器优劣可能完全反转。我的固定做法是:先固定随机种子跑出基线,再让所有被比较的优化器在同样的数据顺序和同样的初始化状态下进行训练。有些优化器对数据顺序极敏感,尤其是 batch size 很小的时候,如果对比实验里数据顺序不一致,结果里的大部分差异其实是噪声。

4.4 问题速查表

把这段时间遇到的高频问题整理如下,遇到类似情况时可以直接对照。

现象优先排查方向我常用的调整
loss 一开始直接变成 NaN学习率过大 / 数据里有异常值降低学习率,增加 warmup,开启梯度裁剪
loss 震荡严重,不下降学习率偏高 / batch size 太小按 0.3 倍系数降学习率,或增大 batch size
loss 下降极慢特征未归一化 / 优化器一阶矩动量过强检查输入分布,降低 beta1 或改用 AdamW
微调阶段过拟合明显权重衰减太弱 / 训练步数太多增大 weight_decay 到 0.05 附近,早停
大批量训练性能下降学习率未随 batch size 调整线性缩放学习率,或换 LAMB
显存不足Adam 状态量太大使用低比特优化器或 Adafactor,调整 batch size
恢复 checkpoint 后 loss 曲线异常scheduler 状态丢失 / 未完整保存检查 optimizer 与 scheduler 的 step 是否一致

这张表里没有银弹。每个现象背后可能同时存在多个原因,我的建议是一次只调整一个变量,并且保留每次实验的完整配置记录。Model-Optimizer的做法是把这些排查经验编码为规则提示,比如检测到前段时间 loss 均值波动超过某个阈值,就自动在日志里建议降学习率,并且提示“先检查数据,再调整优化器”。

最后再分享一个我自己习惯的小动作:每轮实验结束后,我会用五行文字总结这次训练里“改动前的配置、改动后的配置、观测到的行为变化、我的下一步假设”。Model-Optimizer本身也从一开始的“换优化器工具”逐渐长成了一本可执行的经验笔记。你现在如果正在被某个模型的收敛问题卡住,不妨先别急着换优化器,把训练曲线、梯度范数和优化器状态这三样东西完整记录下来,很多时候答案已经写在日志里了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询