news 2026/9/30 5:31:06

深度学习优化器全解析:SGD到AdamW的原理与PyTorch实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深度学习优化器全解析:SGD到AdamW的原理与PyTorch实践

做训练跑实验的兄弟,应该都体会过这种场景:模型结构没变,数据没换,就换了个优化器,收敛速度差出两三倍,最终精度也差出半个点以上。甚至有时候在 A 任务上跑得很稳的 Adam,切到 B 任务上直接 loss 震荡到飞起。很多教程把优化器当成一个黑盒,告诉你“用 Adam 就行,lr 设 3e-4”,但真到调参、改代码、追 bug 的时候,不懂里面的门道就很被动。

这篇文章想把 Model-Optimizer 这事讲透——不光是 PyTorch 里torch.optim怎么用,而是从优化器的工作原理、不同优化器的取舍、到源码层面的实现细节、再到实际训练里踩过的坑,完整过一遍。不管你是刚入门、还在照着别人配置抄,还是已经写了几年训练脚本、想搞清楚 momentum 和 weight decay 到底怎么在工作,这篇都适合。看完了你至少能回答几个问题:Adam 和 SGD 到底差在哪;为什么现在越来越多人用 AdamW;weight decay 和 L2 正则明明是同一件事,为什么实现上非要分两套。

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

1.1 梯度下降的两大核心难题

要理解优化器,先要回到梯度下降本身。优化器的目标很直白:用梯度信息更新模型参数,让 loss 降下去。但真跑起来会遇到两个绕不开的难题,一个是学习率不好选,另一个是梯度方向不稳。

先看学习率。这个参数可能是整个训练过程里最敏感的一个数。lr 设大了,loss 容易发散,典型表现是训练刚开始 loss 直接跳到 NaN;lr 设小了,loss 磨磨蹭蹭地降,半天看不到效果,白烧电费。更麻烦的是,最佳学习率不是固定的,训练初期梯度大、可以走得猛一点,训练后期接近收敛了、得走小步才不会震荡。这是个动态问题,光靠一个固定 lr 很难同时满足两头。

再看梯度方向。真实训练中每个 batch 的梯度都是整体梯度的一个带噪声的估计,可能这一批梯度朝东、下一批就朝西,参数就被拽得来回晃。尤其在一些 loss 曲面比较“狭长”的区域——比如某个方向的梯度很大、另一个方向的梯度很小——普通的 SGD 会沿着大梯度方向来回震荡,而小梯度方向又走得特别慢。这就像你在一个狭长的山谷里往下走,两边的坡很陡、前后的坡很缓,你每一步都在两壁之间撞来撞去,真正前进的速度反而很慢。

这两大难题,就是优化器发展的主线。后来出现的所有花活,本质上都是在解决其中一个或两个问题:动量方法解决方向不稳,自适应学习率解决尺度不敏感,各种组合优化器则是想同时解决这两个问题。

1.2 优化器的演进主线和分类

把优化器看成一套“梯度加工系统”,它的工作流程可以拆成三步:拿到梯度、对梯度做某种加工、用加工后的结果更新参数。不同优化器的区别,全在第二步的加工方式上。

最早期的朴素 SGD,加工方式是什么都不做,拿到梯度直接乘 lr 更新参数。它的优点是简单、可解释、归纳偏置好,缺点就是对 lr 极其敏感,方向不稳的问题也完全没解决。

SGD + Momentum 做了第一层加工:给梯度加了一个“惯性”,让更新方向不只依赖当前梯度,还叠加了历史梯度的累积。这相当于让参数更新在时间轴上做了平滑,批间噪声的影响被压低,方向稳了很多。

AdaGrad 换了个思路,不再管方向,而是给每个参数配了一个自适应学习率:历史梯度大的参数,lr 自动减小;历史梯度小的参数,lr 保持较大。这在稀疏数据场景下效果突出,但 AdaGrad 有个致命问题——累积梯度平方和单调增长,越到后期 lr 被压得越小,最后直接学不动了。RMSProp 就是用指数滑动平均替代了累加求和,解决了这个“学习率归零”的问题。

真正把两套思路合流的是 Adam。它同时维护一阶动量(类似 Momentum 的惯性)和二阶动量(类似 RMSProp 的自适应学习率),而且加了偏差修正,训练初期不会因为动量初始化过小而走不动。Adam 推出后在各种任务上大杀四方,几乎成了默认选择。

再往后,LAMB、LARS 解决大规模分布式训练下的 lr 缩放问题;AdamW 修正了 Adam 里 weight decay 的实现方式;狮身人面像般的 Lion 用符号函数做更新,大幅度省内存。主线很清楚:谁能在保持稳定收敛的同时,让超参数更鲁棒、更省内存、更好调,谁就会在工程实践里被更多人用上。

2. 常用优化器原理逐行拆解

2.1 SGD + Momentum:惯性到底是什么

先看 SGD + Momentum 的更新公式。PyTorch 的实现里,在torch.optim.SGD传入momentum=0.9就开启了动量模式。计算过程可以拆成两步。

第一步更新动量缓冲:

buf = momentum * buf + grad

第二步更新参数:

param = param - lr * buf

这个buf就是梯度的指数滑动平均。momentum=0.9的含义是:当前更新方向里,90% 来自历史累积的动量,10% 来自当前批次的新梯度。换成生活化类比,就像你在推一个很重的球,不是每推一下球就立刻变向,而是它会顺着之前的方向继续滚,你只是逐步给它施加一个转向的力。

这样做的好处体现在几个层面。首先是震荡被抑制了。如果某一步梯度突然反向(比如某个 batch 跟整体分布差异极大),momentum 累积的惯性会“中和”这一下突变,参数不会大幅跳变。其次是在梯度方向一致的平缓区域,动量会让有效步长变大,加速穿过平坦区域。这就是为什么在有动量之后,收敛速度经常能快一截。

但要注意,动量不是越大越好。momentum=0.99虽然平滑能力更强,但会让更新对近期梯度的响应变慢,遇到 loss 曲面突变时来不及转向。我在实际训练里的经验是:0.9 是最通用的起点,如果发现 loss 曲线震荡得厉害,可以往 0.95 调;如果发现收敛变慢、且确认不是 lr 的问题,可以往 0.8 调。很少见到需要超过 0.99 的场景。

2.2 RMSProp / AdaGrad:自适应学习率如何工作

自适应学习率的核心思想,是给每个参数分开配学习率。AdaGrad 的做法是维护一个历史梯度平方的累积和state_sum,然后每个参数的更新除以这个累积和的平方根:

state_sum = state_sum + grad^2 param = param - (lr / (sqrt(state_sum) + eps)) * grad

这样梯度大的参数,state_sum 涨得快、lr 被压低;梯度小的参数,state_sum 涨得慢、lr 保持较大。在稀疏特征场景下,那些只偶尔出现、但出现时梯度较大的特征,能获得相对较大的更新步长,这对 Embedding 类参数非常友好。

但 AdaGrad 的累积和不封顶,训练到后期,即便是小梯度参数,state_sum 也被堆到很大,所有参数的 lr 都趋近于零,模型基本冻结。RMSProp 的改进就是用滑动平均替代累加:

state_avg = decay * state_avg + (1 - decay) * grad^2 param = param - (lr / (sqrt(state_avg) + eps)) * grad

这里decay一般取 0.9 或 0.99,表示只关注近期的梯度统计量,而不是从头到尾的累积。这样旧梯度的影响会随时间指数衰减,学习率不会单调收缩到零。

这个“滑动平均”思路,几乎是整个自适应优化器的基石——Adam 的二阶动量就是同样的形式。理解这一点,你就知道betas这个参数在控制什么了。betas=(0.9, 0.999)中,第二个数就是二阶动量的衰减系数,0.999 意味着二阶统计量会参考过去大约 1000 步的历史梯度能量,这给了它对梯度尺度变化的极高平滑度。

2.3 Adam 及其偏差修正的细节

Adam 把一阶动量和二阶动量组合在一起。PyTorch 里的更新逻辑可以这样看:

exp_avg = beta1 * exp_avg + (1 - beta1) * grad exp_avg_sq = beta2 * exp_avg_sq + (1 - beta2) * grad^2

这里exp_avg相当于动量的角色,决定更新方向;exp_avg_sq相当于每个维度的梯度能量统计,决定每个维度的更新步长。然后:

denom = sqrt(exp_avg_sq) + eps param = param - lr * exp_avg / denom

但直接这么做会有个问题。训练第一步时,exp_avg和exp_avg_sq都是从零初始化的,滑动平均的初始阶段数值会严重偏向零,相当于前若干步的动量被打了折扣,更新步长会异常小。为了修正这个偏差,Adam 引入了偏差修正:

exp_avg_hat = exp_avg / (1 - beta1^t) exp_avg_sq_hat = exp_avg_sq / (1 - beta2^t)

t是当前迭代步数。分母(1 - beta^t)在 t 很小时接近 0,相当于把被低估的统计量放大;随着 t 增大,分母趋近 1,修正项的作用逐渐消失。这个修正只在训练前期有意义,大概几十步之后就可以忽略不计了,但如果没有这几步修正,Adam 在最开始的收敛速度会明显慢一截。

eps这个参数也值得多说一句。denom里加eps的目的是防止除零,但它的实际作用不止于此。eps太小时,二阶动量很小的维度会被极度放大,更新步长可能过大导致震荡;eps太大时,归一化的效果会被削弱,Adam 就退化得接近 SGD。PyTorch 默认的eps=1e-8在大多数 32 位浮点训练里够用,但混合精度训练时建议调到1e-6或1e-4,因为 FP16 的精度范围有限,太小的 eps 会让某些维度的 denom 值精度不够、出现不稳定的更新。

2.4 AdamW 与解耦权重衰减

AdamW 是现在越来越主流的默认选择。它解决的问题要从 Adam 里的 weight decay 说起。

常规 Adam 实现里,权重衰减是加在梯度上的:在求梯度之后,把weight_decay * param加到梯度里,然后一并进入动量和自适应学习率的计算流程。这样带来的问题是:不同参数因为历史梯度尺度不同,weight_decay 的实际力度会被二阶动量的归一化改变。一个梯度小的参数和一个梯度大的参数,即使 weight_decay 系数相同,它们的正则强度也不一样,削弱了 weight_decay 的可控性。

AdamW 的做法是解耦:权重衰减不再进梯度,而是直接在参数更新之后单独做一次衰减:

param = param - lr * (exp_avg / (sqrt(exp_avg_sq) + eps)) param = param - lr * weight_decay * param

这样 weight_decay 就是一个纯粹、独立的正则项,不管参数的历史梯度尺度如何,衰减比例都由lr * weight_decay控制,行为完全可预测。

我个人的体会是,AdamW 不仅让 weight_decay 的语义更清晰,实际效果也普遍更好。在 CV 和 NLP 的多个任务里,AdamW 配合相对较大的 weight_decay(比如 1e-2 到 5e-2)在同样步数下能跑出比 Adam 更干净的结果。这个“干净”体现在测试集 loss 更低、泛化更好,而不是只盯着训练 loss 下降得有多快。

3. PyTorch 优化器实战:从 model.parameters() 到 param_groups

3.1 优化器定义与参数分组

PyTorch 里使用优化器非常简单,常见的写法是这样:

import torch from torch import nn from torch.optim import AdamW model = nn.Sequential( nn.Linear(128, 256), nn.ReLU(), nn.Linear(256, 10), ) optimizer = AdamW(model.parameters(), lr=3e-4, weight_decay=1e-2)

但真正生产级的训练代码,很少会直接喂model.parameters()。更多时候是用param_groups给不同层配置不同的超参数。比如骨干网络和新增的分类头,一个希望用小 lr 微调,一个希望用大 lr 从头学,就可以这样设置:

backbone_params = [p for name, p in model.backbone.named_parameters() if p.requires_grad] head_params = [p for name, p in model.head.named_parameters() if p.requires_grad] optimizer = AdamW([ {"params": backbone_params, "lr": 1e-5}, {"params": head_params, "lr": 3e-4, "weight_decay": 0.0}, ])

参数分组的坑在于:如果某组参数列表为空,PyTorch 不会报错,但这一组实际上什么都不更新,而你很可能完全没意识到。我踩过这个坑——定义了一个freeze_backbone的开关,某次实验误触发了开关后,骨干参数完全不更新,训练 loss 下降得非常缓慢,排查了老半天才找到原因。建议在创建 optimizer 之后加一段断言:

for group in optimizer.param_groups: assert len(group["params"]) > 0, f"param group {group} has empty params"

另外要注意,同一个参数不能同时出现在多个 param group 里。如果骨干参数既被分进“backbone_params”又被默认组接收,PyTorch 会直接报错,所以全量参数传入时最好用model.parameters()而不是手动拼。这个限制在源码里就是通过参数地址比对实现的。

3.2 一个完整的训练循环长什么样

优化器在训练循环里的用法,很多新手会混淆。一个标准步骤是:

for batch_x, batch_y in dataloader: optimizer.zero_grad() loss = criterion(model(batch_x), batch_y) loss.backward() optimizer.step()

很多人不理解为什么每次都要zero_grad()。这是因为 PyTorch 的梯度是累加的——backward()会把新算出的梯度加到param.grad上,而不会覆盖。这是为了支持类似梯度累积的用法:一个小的 batch 分成几个 micro-batch 分别 forward/backward,梯度在多次 backward 之间累加,最后统一step(),等效于大 batch 的效果。

如果不zero_grad(),上一轮的梯度会跟本轮叠加,更新方向就是这几轮梯度的累加,一旦累加多轮,方向会严重偏离真实梯度,训练直接废掉。常见的“loss 不降反升”的经典案例,一大半就是忘了清空梯度。

进阶用法里,梯度累积的写法是这样的:

accumulation_steps = 4 optimizer.zero_grad() for idx, (batch_x, batch_y) in enumerate(dataloader): loss = criterion(model(batch_x), batch_y) loss = loss / accumulation_steps loss.backward() if (idx + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()

注意这里把loss除以了accumulation_steps,否则等效 batch size 变大后,梯度累积的规模也会等比例放大,lr 需要相应调整。这个细节直接关联到 3.5 节要讲的大 batch 与学习率的关系。

还有一个很少人注意的点:optimizer.step()不会自动清空param.grad,所以即使你执行了step(),下次backward()之前依然要zero_grad()。有些框架(比如 Hugging Face Trainer)会在内部帮你处理这些,但在手写训练脚本时,这个顺序必须刻在脑子里。

3.3 weight decay 与 L2 正则的实现差异

weight decay 和 L2 正则,在数学上本来是一回事:L2 正则给 loss 加一个lambda/2 * ||theta||^2的惩罚项,求梯度会多出lambda * theta;而 weight decay 是直接把参数按比例缩小,等效于在更新时额外减去一个lr * lambda * theta的量。两者在朴素 SGD 下是完全等价的,但如果套到 Adam 里就不一样了。

前文已经讲过 AdamW 与常规 Adam 的差异,这里再补一个实际训练的观察。用torch.optim.AdamW时,如果你之前用的是 Adam + L2 正则,直接平移到 AdamW 时 weight_decay 数值最好重新调。因为同样的 1e-2,在 AdamW 里是纯正的、独立的正则力度,而在 Adam 里会被二阶动量归一化削弱一部分。我见过不少人在迁移到 AdamW 时沿用原来的 weight_decay,结果模型欠正则化、精度掉了,然后怀疑 AdamW 本身有问题。其实不是 AdamW 的问题,是参数迁移时没有重新标定。

为了验证这个问题,你可以做一个小实验:设计一个简单的二分类任务,分别用 Adam + L2 和 AdamW 跑同一组超参数,打印不同层的参数范数。你会发现 AdamW 的平均参数范数明显更小,这就是解耦之后正则力度更为直接的表现。

3.4 梯度裁剪与 EMA 的配合使用

优化器本身只负责更新参数,但训练里经常需要配套两个机制:梯度裁剪和 EMA。

梯度裁剪解决的是梯度爆炸问题。常见做法是torch.nn.utils.clip_grad_norm_:

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

这行代码放在loss.backward()之后、optimizer.step()之前。它的作用是将所有参数的梯度拼成一个向量,计算整体范数,如果超过max_norm就等比例缩放。这样能在不改变梯度方向的前提下限制步长上限,对于 RNN、Transformer、GAN 这类容易出现异常梯度的模型几乎是标配。

EMA(指数移动平均)是在优化器之外对参数做平滑。简单实现是:

decay = 0.999 ema_params = {} def update_ema(model, ema_params, decay): for name, param in model.named_parameters(): if param.requires_grad: if name not in ema_params: ema_params[name] = param.detach().clone() else: ema_params[name].mul_(decay).add_(param.detach(), alpha=1 - decay)

EMA 不改变优化器本身的更新规则,而是在训练过程中维护一套参数副本,这套副本是模型参数的慢速平均,通常比训练末尾的瞬时参数更平滑、泛化更好。评测时用 ema_params 里的参数替换模型权重来计算指标,而不是直接评估 current model。

EMA 与优化器的关系在于:如果你用 AdamW 这类自适应 lr 优化器,模型参数每次更新的方向是经过归一化的,参数轨迹比较稳定,EMA 的收益相对有限;但如果用 SGD,参数轨迹噪声更大,EMA 的提升会更明显。这也是为什么一些老牌 CV 任务在 SGD + EMA 的组合下能跑出很强结果的原因之一。

4. 超参调优与优化器选型:不同任务的实测经验

4.1 学习率策略与 warmup

学习率本身只是优化器的一个初始化参数,但训练全程学习率怎么变化,是优化器能否高效收敛的关键配套。最常用的 LR Schedule 是 cosine 衰减和带 warmup 的线性衰减。

warmup 解决的是什么问题?训练初期,模型参数是随机初始化的,梯度统计还不够稳定,尤其是 Adam 的二阶动量刚开始在建立过程中,直接用较大的学习率很容易让模型走出一个不理想的初始区域。warmup 让 lr 在最初几千步内从很小线性增长到目标值,等动量统计量稳下来以后再用全速学习率。典型设置:

from torch.optim.lr_scheduler import LinearLR, CosineAnnealingLR warmup_epochs = 5 total_epochs = 100 scheduler_warmup = LinearLR(optimizer, start_factor=0.01, total_iters=warmup_epochs) scheduler_main = CosineAnnealingLR(optimizer, T_max=total_epochs - warmup_epochs) # 训练循环中按顺序调用 # if epoch < warmup_epochs: scheduler_warmup.step() # else: scheduler_main.step()

实际训练中我的经验是:warmup 步数一般占总数量的 1% 到 5%。数据集越大,warmup 占比可以越小。例如 ImageNet 级别的训练,warmup 也就 5 个 epoch;而一个小规模数据集、训练总量只有 20 个 epoch 时,warmup 可能需要 1 到 2 个 epoch 才有明显作用。

4.2 大 batch 与优化器的关系

大 batch 训练的问题可以参考线性缩放法则:batch size 从 256 提到 1024(4 倍),学习率从 0.1 提到 0.4,理论上收敛轨迹近似。但实际训练时会发现,简单放大 lr 往往导致训练不稳,尤其是在训练的早期阶段。这时候真正的工程方案不是一个劲调 lr,而是用 LAMB 或者 LARS 这类为大 batch 设计的优化器。

LAMB 的核心是在 Adam 的框架上加了“层wise 自适应缩放”:每一层的更新量除以该层权重的范数,相当于给每个层一个独立的归一化。这样某层的参数量纲差异、梯度尺度差异就不影响全局的学习率大小。用 LAMB 在 64K batch size 下训练,依然能达到和小 batch 相当的结果。这个特性让 LAMB 在预训练大模型时几乎是标配。

对于多数单卡或小规模多卡训练(batch size 不超过 1024),我的建议是别折腾 LAMB,直接用 AdamW 或 SGD,配上 4.1 节的 warmup 就足够了。大 batch 优化器适合的阶段是“单卡显存不够、强行梯度累积导致等效 batch 上万”时,这时线性放大 lr 很容易翻车,换成 LAMB 反而是最省心的。

4.3 任务类型与优化器选型建议

CV 分类任务里,SGD + Momentum 至今仍有一席之地。尤其在 ImageNet 级别的训练中,SGD 配合精细调过的 lr 和 weight decay,泛化性能往往比 Adam 系更好。但这不是因为 SGD 本身更强,而是它没有自适应归一化带来的“隐式大 lr”问题,更容易配合 weight decay 做出可控的正则。在自己做小规模 CV 实验时,SGD 需要手动调的次数比较多,AdamW 的鲁棒性更好。

NLP 里的 Transformer 类模型,AdamW 几乎是默认答案。BERT、GPT 系列预训练、微调实验,绝大多数都用 AdamW。原因在于 Transformer 的不同层、不同参数模块(attention 和 FFN 的梯度尺度差异很大)对自适应学习率特别依赖,AdamW 的逐参数归一化能省去大量层间尺度调参的精力。

GAN 训练是优化器问题的重灾区。生成器和判别器的 loss 尺度不同,如果两者共用同一套优化器超参,很容易出现一个强一个弱的不平衡。实际做法是分开建优化器、分别调 lr。常规建议生成器用稍小的 lr,判别器用稍大的 lr。另外 GAN 对梯度裁剪特别敏感,裁剪太狠生成器容易学不到东西,所以 GAN 里更多的是依赖优化器本身的稳定性,而不是靠额外裁剪。

扩散模型和近期的大模型微调,有一个明显趋势:Adafactor 这类省内存优化器正在被更多人用于大规模场景。Adafactor 不存储完整的二阶动量矩阵,而是用行和列统计量的外积来近似,内存占用从 O(n) 降到接近 O(sqrt(n))。如果你的模型大到 AdamW 的动量缓存撑爆显存,Adafactor 是优先考虑的替代方案,代价是需要对 eps 和 lr 做些额外调整。

各任务选型的粗略对照可以参考下面这张表:

任务/模型类型常用优化器默认 lr 参考特别说明
CNN 图像分类(小规模)SGD + Momentum / AdamW1e-2 / 3e-4SGD 需要配合细致 lr schedule
CNN 图像分类(大规模)SGD + Momentum0.1 起步(按 batch 线性缩放)warmup + cosine 是标配
BERT/GPT 类微调AdamW1e-5 ~ 5e-5层间差异化 lr 常见
大规模预训练AdamW / LAMB1e-4 ~ 1e-3batch 特别大时优先 LAMB
GANAdam / AdamW(分离)G: 1e-4 ~ 2e-4, D: 2e-4 ~ 4e-4生成器和判别器分开调
大权重模型/显存受限Adafactor1e-3 量级二阶动量近似,省内存

4.4 超参调优的次序与方法

优化器相关超参数不是一次调一个、逐个试,那样效率太低。我的习惯是按照“影响从大到小”的次序调整:lr 永远是最关键的,先找到量级合适的 lr;然后是 batch size 与 lr 的配合;再是 weight_decay;最后才是 betas、eps 这类二阶参数。

找 lr 有一个很快的办法:torch.optim.lr_scheduler里的LRFinder或者第三方库torch-lr-foundation,在一个 epoch 里让 lr 从极小线性增长到极大,绘制 loss 曲线,loss 下降最快且还没发散的位置附近就接近好的 lr 起始值。这个方法不用跑很多次实验,一次就能给出靠谱的区间。

weight_decay 的调整则更依赖任务。预训练大模型时 weight_decay 可以给到 0.1 级别,因为数据量足够大、模型容量足够大,强正则不会欠拟合。小数据集微调时,weight_decay 建议先给 0.01 左右,过强正则容易压住有效特征的学习。这个参数的标定,最好在固定好 lr 之后再动,否则两个因素同时变化很难定位是谁导致的。

betas 和 eps 这些第二阶梯参数,大多数时候用默认值就行,不必过于执念。只有在混合精度训练里有针对性调整 eps 的需求,这一点在下一节会展开讲。

5. 高频踩坑排查实录

5.1 忘掉 zero_grad 的经典翻车

这个错法太常见了,几乎每个训练过的人都经历过至少一次。现象是 loss 曲线在刚开始几步快速下降,然后突然开始震荡甚至上升。原因就是上一轮的梯度没清空,与当前梯度叠加后,更新方向混乱。

排查方法很简单,在optimizer.step()之后打印一下next(model.parameters()).grad的范数,如果连续两步之间 grad 范数有明显跳变且不收敛,八成就是这个问题。

还有一个变体:用了optimizer.zero_grad(set_to_none=True)之后,某些层因为输入恒为零或经过 mask,param.grad会是None。如果后面的代码里直接对param.grad做操作,会报错。这种情况下要加上if p.grad is not None判断,不要假设所有参数都有梯度。

5.2 混合精度训练对优化器状态的影响

AMP(自动混合精度)训练时,模型参数和优化器状态通常维护在 FP32,但 forward 和 backward 过程中的激活值、梯度可能在 FP16。这带来一个容易被忽略的问题:FP16 梯度的动态范围比 FP32 窄得多,Adam 的二阶动量exp_avg_sq统计的是梯度的平方,一旦梯度很小,平方值在 FP16 下会直接下溢成零,导致对应维度的学习率被无限放大。

所以混合精度训练时,建议把 Adam 的eps调大到 1e-6 或 1e-4。这个偏移给二阶动量一个底噪,避免极端小梯度维度被放大到失控。另一个工程做法是调整梯度缩放策略:AMP 的 GradScaler 可以自动处理梯度缩放,但如果你的优化器代码里手动处理了梯度,需要确保 GradScaler 的处理在optimizer.step()之前生效。

还有一个细节:scaler.scale(loss).backward()之后,梯度是被放大过的,如果你在中间插了clip_grad_norm_,必须先scaler.unscale_(optimizer)再裁剪,否则会把放大后的梯度范数当成真实梯度范数来用,裁剪力度会有偏差。

5.3 分布式训练中的优化器同步问题

DDP(DistributedDataParallel)下,优化器的状态初始化和梯度同步有一个容易犯错的地方。DDP 在backward()时会自动做梯度 all-reduce,所以每个进程拿到的是全局平均梯度,这一步不需要手动处理。但要注意的是,DDP 的随机种子必须同步,否则每个进程的初始权重不一样,训练会直接失效。这个种子同步与优化器关系不大,却是分布式训练里最常见的初始翻车点。

更隐蔽的问题是:如果训练日志保存了优化器 checkpoint,而你在恢复训练时进程数变了(比如之前用 8 卡、现在用 4 卡恢复),优化器的state中按参数索引保存的状态可能与新进程的参数不对齐。PyTorch 的torch.load配合model.load_state_dict处理模型权重没问题,但优化器 state 是依赖参数对象地址的,跨进程恢复时最好从头训练优化器状态,或者只在完全相同分布式配置下恢复。

经验值:模型可以随意换设备恢复,优化器 state 恢复尽量保证配置一致。很多训练中断续训时效果不如从头训,不是因为权重恢复有问题,而是优化器状态恢复不完整——尤其像 Adam 这种依赖历史统计量的优化器,丢了二阶动量后,学习率行为变化很大。

5.4 恢复 checkpoint 时被忽略的 EMA 状态

如果你用了 3.4 节的 EMA,恢复 checkpoint 时不仅要恢复 model 和 optimizer,还要把 EMA 参数副本一起保存和恢复。不然断点续训后,EMA 参数从零开始累积,至少需要再跑很多步才能回到之前的平均水平,这会导致评估指标断崖式下跌。

保存时建议把这几样打包在一个 dict 里:

torch.save({ "model": model.state_dict(), "optimizer": optimizer.state_dict(), "ema": ema_params, "epoch": epoch, "global_step": global_step, }, checkpoint_path)

恢复时按对应 key 逐一 load。EMA 的decay值很大(通常 0.999 或 0.9999),状态落后于模型参数,恢复后至少需要再训练几百步让 EMA 追上来,否则直接用恢复的 EMA 评估会有轻微劣化。

5.5 常见问题速查表

现象可能原因排查方向
loss 不下降lr 过小 / param group 为空 / 忘记 zero_grad检查 lr 量级、打印 param_groups、检查 zero_grad
loss 迅速 NaNlr 过大致发散 / 梯度爆炸降低 lr、加梯度裁剪、检查输入是否有 NaN
训练中测试集 loss 震荡lr 太大 / 动量过强 / noise 太高降低 lr、调低 momentum、确认 batch 是否太小
微调时预训练权重退化层间 lr 未区分骨干用小 lr、头部用大 lr
混合精度训练精度下降eps 未调 / grad scaling 处理不当调大 eps、检查 unscale 时机
恢复训练后效果明显变差optimizer state 未恢复 / EMA 未恢复统一保存模型、优化器、EMA、step 计数
分布式训练 loss 不一致种子不同 / 数据采样不一致同步种子、确认 DDP 的 shuffle 策略

6. 一个可以照抄的端到端训练模板

前面讲了很多原理和坑,最后给一个可以直接改着用的训练模板。这个模板把 optimizer、scheduler、grad clip、EMA、AMP、分布式保存全部串起来,适合中小规模的 CV/NLP 训练。

import torch from torch import nn from torch.optim import AdamW from torch.optim.lr_scheduler import LinearLR, CosineAnnealingLR def train_one_epoch(model, loader, optimizer, criterion, scaler, clip_norm=None): model.train() total_loss = 0.0 for x, y in loader: optimizer.zero_grad(set_to_none=True) with torch.autocast(device_type="cuda", dtype=torch.float16): loss = criterion(model(x), y) scaler.scale(loss).backward() if clip_norm is not None: scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), clip_norm) scaler.step(optimizer) scaler.update() total_loss += loss.item() return total_loss / len(loader) model = ... # 你的模型 optimizer = AdamW(model.parameters(), lr=3e-4, weight_decay=0.01) total_epochs = 100 warmup_epochs = 5 scheduler = LinearLR(optimizer, start_factor=0.01, total_iters=warmup_epochs) main_scheduler = CosineAnnealingLR(optimizer, T_max=total_epochs - warmup_epochs) scaler = torch.cuda.amp.GradScaler() for epoch in range(total_epochs): epoch_scheduler = scheduler if epoch < warmup_epochs else main_scheduler avg_loss = train_one_epoch(model, train_loader, optimizer, criterion, scaler, clip_norm=1.0) # 评估代码省略 if epoch < warmup_epochs: scheduler.step() else: main_scheduler.step()

这段代码的写法有几个细节值得说明:zero_grad(set_to_none=True)比默认的zero_grad()更快,它把grad直接置为 None 而不是置为全零张量,少了一次内存写操作;AMP 下先unscale_再裁剪,避免裁剪力度偏差;scheduler 的 step 放在 epoch 结束之后,也就是用 epoch 粒度做调度,如果希望步级别调度,要把 scheduler.step() 移到每个 batch 的 optimizer 更新之后。

换优化器时,只需要把 AdamW 换成 SGD/Adam/LAMB 对应的类,再把 lr 的初始值按第 4 节表格里的参考值调整即可。但记得如果换成 SGD,weight_decay 的实现语义会变回“L2 正则”那一套,这时 weight_decay 数值建议重新从 5e-4 量级往上试——SGD 的 weight_decay 和 AdamW 的 weight_decay 不是可以直接平移的关系。

最后再分享一个小技巧:在写训练代码的时候,把 optimizer 的创建抽成一个独立函数,入参只有 model 和超参 dict,这样实验脚本里可以快速替换优化器类型而不动训练主循环。我自己的实验仓库里,这个函数的签名大概长这样:

def build_optimizer(model, config): if config["optimizer"] == "adamw": return AdamW(model.parameters(), lr=config["lr"], weight_decay=config["weight_decay"]) if config["optimizer"] == "adam": return Adam(model.parameters(), lr=config["lr"], weight_decay=config["weight_decay"]) if config["optimizer"] == "sgd": return SGD(model.parameters(), lr=config["lr"], momentum=0.9, weight_decay=config["weight_decay"]) if config["optimizer"] == "lamb": return LAMB(model.parameters(), lr=config["lr"], weight_decay=config["weight_decay"]) raise ValueError(f"unknown optimizer {config['optimizer']}")

参数都在一个config里,跑实验时改一个参数文件就能控制优化器选型,不用在代码里翻来翻去。这也是我做实验效率最高的方式——优化器的选择,本质上是一个可以快速迭代的实验变量,而不应该成为一个需要反复改代码的工程负担。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 5:30:56

Windows10下用VMware虚拟机安装Ubuntu超详细教程

前阵子好几个朋友问我&#xff0c;Windows10 的电脑上到底怎么才能跑一套完整的 Linux 环境&#xff0c;既要能用 Ubuntu 做开发、跑服务&#xff0c;又不能影响日常办公和游戏。我的回答始终很统一&#xff1a;装虚拟机&#xff0c;这可能是普通用户接触 Linux 最稳妥、最不折…

作者头像 李华
网站建设 2026/9/30 5:30:55

游戏角色模型替换全流程解析:以高斯杰斯提斯为例

这次我们来看一个游戏角色模型替换的需求&#xff0c;目标角色先拿“高斯杰斯提斯”来当例子。这里说的“模型替换”不是改游戏数值&#xff0c;而是把游戏内的角色 3D 模型和贴图资源换掉&#xff0c;实现改皮肤、改外形、做二次创作这类效果。这类工作在游戏 Mod 圈里很常见&…

作者头像 李华
网站建设 2026/9/30 5:30:55

Tomcat安装配置与项目部署全流程详解

1. 写在前面&#xff1a;为什么每个Java开发都得会装Tomcat如果你接触过Java Web开发&#xff0c;那Tomcat这个名字你八成绕不开。它是目前应用最广的Servlet容器&#xff0c;也是Java Web开发者在本地开发、测试、上线阶段都会频繁打交道的一个基础组件。简单说&#xff0c;你…

作者头像 李华
网站建设 2026/9/30 5:30:22

网络货运平台运营方案:发票合规、后市场与金融风控实战解析

简介&#xff1a;一份聚焦网络货运平台运营的PDF方案&#xff0c;面向物流企业管理者、平台运营人员及行业研究者&#xff0c;系统梳理平台在营改增政策下的核心优势&#xff1a;解决个体运输户开票难、为小微车队提供运单/司机/车辆/调度/结算等管理工具&#xff0c;并通过平台…

作者头像 李华
网站建设 2026/9/30 5:30:19

vSAN扩容实操手册:纵向/横向扩容、Resync控制与故障域避坑

简介&#xff1a;《VMware vSAN 扩容手册 v1.1》是一份面向 VMware 运维与虚拟化工程师的官方实践指南&#xff0c;针对业务增长带来的 vSAN 容量与性能瓶颈&#xff0c;系统讲解横向扩容&#xff08;增加节点&#xff09;、纵向扩容&#xff08;增加磁盘/磁盘组&#xff09;及…

作者头像 李华
网站建设 2026/9/30 5:30:04

YOLOv11多光谱融合与农业无人机遥感作物监测实战

简介&#xff1a;面向智慧农业、无人系统与目标检测方向的研究者和工程师&#xff0c;这份实战文档系统阐述了YOLOv11算法与多模态数据融合在农业无人机遥感作物生长监测中的完整应用方案。文档共38页&#xff0c;以PDF单一文件打包&#xff0c;压缩包约2.07MB。内容从YOLOv11的…

作者头像 李华