☰
PyTorch SGD优化器深度解析:动量、weight decay与参数配置实战
2026/10/14 23:14:45 网站建设 项目流程

1. 从一个 epoch 的 loss 曲线说起

刚接触 PyTorch 那会儿,我写训练脚本基本是抄模板:optimizer = torch.optim.SGD(model.parameters(), lr=0.01),然后loss.backward()、optimizer.step()、optimizer.zero_grad()三件套走一遍,跑起来就算完事。直到有一次做一个图像分类的小项目,loss 曲线像心电图一样上下横跳,准确率死活卡在 60% 上不去,我才意识到问题不在模型结构,而在我对优化器这个"看不见的手"几乎一无所知。

optim.SGD是 PyTorch 里最基础、也最容易被低估的优化器。它做的事情表面上看很简单——拿着每个参数的梯度,按学习率往反方向挪一步。但真正把它用明白,你需要搞清楚:动量到底在动什么、weight decay 和 L2 正则是不是一回事、dampening 什么时候该动、nesterov 凭什么能加速收敛、参数分组怎么配才能让不同层各得其所。这些细节直接决定了你的模型是收敛到 95% 还是卡在 60%。

这篇内容就是围绕torch.optim.SGD展开的一次彻底拆解。我会从它的数学原理讲到参数配置,从单层线性回归的实操演示讲到多层网络里的参数分组策略,再把我自己踩过的坑整理成一份排查清单。适合已经能跑通训练脚本、但想让模型收敛得更稳更快的朋友,也适合想真正理解优化器内部机制、而不是只会调包的同学。看完之后,你至少能做到:拿到一个 SGD 配置,能判断它合不合理;loss 不降的时候,知道该从哪几个参数下手。

2. 优化器到底在优化什么:SGD 的数学骨架

2.1 梯度下降的直觉:蒙眼下山

先把最朴素的想法说清楚。假设你在一座大雾弥漫的山上,看不见路,只能感受到脚下哪个方向最陡。梯度就是"最陡的上坡方向",那么梯度的反方向就是"最陡的下坡方向"。你每次朝下坡方向迈一小步,步子大小就是学习率 lr。这就是最原始的梯度下降。

用公式写出来,对于参数 $\theta$:

$$\theta_{t+1} = \theta_t - \eta \cdot g_t$$

其中 $g_t$ 是第 t 步的梯度,$\eta$ 是学习率。PyTorch 的optim.SGD在没有开动量、没有 weight decay 的时候,干的就是这一件事。听起来简单,但问题马上就来了:如果山谷是一个狭长的峡谷,梯度在陡峭方向很大、在平缓方向很小,那么你会在陡壁之间来回震荡,前进速度极慢。这就是为什么纯 SGD 收敛慢、容易震荡。

2.2 动量:给下山的小球加上惯性

动量的引入就是为了解决震荡。它的思路是:不要只看当前这一步的梯度,而是把历史梯度累积起来,形成一个"速度"向量。就像给下山的小球加上惯性,即使当前梯度方向变了,小球也不会立刻掉头,而是带着之前的势头继续往前冲。

PyTorch 里动量的更新逻辑是这样的(这是理解 SGD 的关键):

$$v_{t} = \mu \cdot v_{t-1} + g_t$$ $$\theta_{t+1} = \theta_t - \eta \cdot v_t$$

注意这里有个容易搞混的点:PyTorch 的动量是直接累加梯度,而不是像某些教材里写的先做指数移动平均再乘 $(1-\mu)$。这个差异导致 PyTorch 里动量项的实际缩放和教科书版本不一样,这也是为什么很多人从别的框架迁移过来时,发现同样的 momentum 值效果对不上。我第一次遇到这个问题时,调了半天学习率都没对上,后来翻源码才明白是累加方式不同。

2.3 weight decay 与 L2 正则:长得像,其实不一样

这是 SGD 里最经典的坑。很多人以为weight_decay就是 L2 正则,其实在 SGD 里它们恰好等价,但在 Adam 等自适应优化器里就不等价了。这个区别后来催生了 AdamW。

在 SGD 中,加上 weight decay 后的更新是:

$$g_t = \nabla f(\theta_t) + \lambda \cdot \theta_t$$ $$\theta_{t+1} = \theta_t - \eta \cdot g_t$$

展开后你会发现,weight decay 相当于在梯度里加了一项 $\lambda \theta$,这正是 L2 正则的梯度。所以对 SGD 来说,weight_decay=1e-4和手动加 L2 惩罚是一回事。但为什么还要单独设这个参数?因为把它交给优化器处理,实现上更高效,而且和动量、nesterov 的组合行为是经过精心设计的。

注意:如果你用的是 Adam,千万别以为 weight_decay 还是 L2 正则。Adam 的自适应学习率会把 L2 项也一起缩放,导致正则效果被扭曲。这也是为什么后来有了 AdamW——把 weight decay 从梯度里拆出来单独做。用 SGD 的话,这个问题不存在,可以放心用。

2.4 dampening 和 nesterov:两个冷门但关键的开关

dampening这个参数存在感很低,默认是 0。它的作用是给动量的累加"踩一脚刹车":

$$v_t = \mu \cdot v_{t-1} + (1 - d) \cdot g_t$$

d 就是 dampening。当 d=0 时就是标准动量。实际用的时候,如果你开了 dampening 又开了 nesterov,PyTorch 会直接报错,因为 nesterov 的公式要求 dampening 必须为 0。这个限制很多人不知道,配错了会一脸懵。

nesterov是动量的一种改进。普通动量是"先算梯度,再按累积速度走",而 Nesterov 是"先按累积速度往前探一步,在那个位置算梯度,再修正"。直觉上它更聪明,因为它在"预判"的位置评估梯度,能更早地感知到方向变化,从而减少 overshoot。实测下来,在凸问题上 nesterov 通常比普通动量收敛更快,在深度网络上也有帮助,但增益没有想象中那么大,属于"开了不亏"的选项。

3. optim.SGD 的参数全解析与配置逻辑

3.1 构造函数的每个参数都值得掰开看

torch.optim.SGD的签名大致是这样:

torch.optim.SGD( params, lr=0.001, momentum=0, dampening=0, weight_decay=0, nesterov=False, maximize=False, foreach=None, differentiable=False )

我逐个说清楚它们的实际含义和配置建议。

params是最重要的参数,它决定了"优化谁"。你可以传一个model.parameters(),也可以传一个字典列表做参数分组。后者是进阶用法,后面单独讲。

lr学习率,SGD 的命门。默认 0.001 对 SGD 来说偏小,实际项目里常见的是 0.01 到 0.1 起步,配合学习率调度器逐步衰减。SGD 对学习率极其敏感,太大直接发散,太小收敛慢到怀疑人生。

momentum动量系数,常用 0.9。这个值不是随便定的,0.9 意味着历史梯度的有效记忆长度大约是 $1/(1-0.9) = 10$ 步,对大多数任务够用。如果任务噪声大,可以调到 0.95 甚至 0.99,让更新更平滑。

weight_decay权重衰减,常用 1e-4 到 1e-2。它控制模型复杂度,防止过拟合。注意它和 lr 是耦合的,lr 变了 weight_decay 的效果也会变。

nesterov布尔值,开了就用 Nesterov 动量。前提是 momentum > 0 且 dampening = 0。

maximize是个冷门参数,默认 False 表示最小化 loss。设成 True 就变成最大化,一般用不到,除非你在做对抗训练之类需要最大化目标的场景。

foreach和differentiable属于性能与高级特性。foreach开启后会尝试用融合的算子加速,在多参数场景下能快一些;differentiable用于需要二阶梯度或元学习的场景。日常训练不用管。

3.2 参数分组:让不同层用不同策略

真实项目里,不同层往往需要不同的学习率或 weight decay。比如微调预训练模型时,主干网络希望用小学习率,新加的分类头希望用大学习率;又比如 bias 和 norm 层通常不做 weight decay,只对权重矩阵做。

参数分组就是干这个的。你传一个列表,每个元素是一个字典,字典里有params和该组专属的超参:

optimizer = torch.optim.SGD([ {'params': model.backbone.parameters(), 'lr': 1e-4}, {'params': model.classifier.parameters(), 'lr': 1e-2}, {'params': model.bias_params(), 'weight_decay': 0}, ], momentum=0.9, weight_decay=1e-4)

这里有个细节:字典里没写的超参,会从构造函数的外层参数继承。所以上面 bias 那组虽然没写 lr,但会继承外层的默认 lr。这个继承机制很实用,但也很容易让人误以为每组都独立,配的时候要心里有数。

3.3 学习率、动量和 weight decay 的联动关系

这三个参数不是独立的,它们之间存在耦合。我总结几条实战规律:

学习率翻倍,weight_decay 通常也要相应调整,因为 weight_decay 的效果是 $\eta \lambda \theta$,lr 变了这个乘积就变了。有些实践里会用"解耦"的思路,让 weight_decay 不随 lr 缩放,但标准 SGD 里它们是绑在一起的。

动量越大,等效学习率越大。因为动量累积了历史梯度,实际步长会比单步梯度大。所以当你把 momentum 从 0 调到 0.9 时,可能需要适当降低 lr,否则容易发散。我一般调参的顺序是:先定 momentum=0.9,再调 lr,最后微调 weight_decay。

下面这张表是我在不同任务上总结的经验区间,仅供参考,具体还得看数据和模型:

参数常见区间典型值调整方向
lr1e-3 ~ 1e-10.01 / 0.1loss 震荡就降,收敛慢就升
momentum0.8 ~ 0.990.9噪声大就升,欠拟合就降
weight_decay1e-5 ~ 1e-21e-4过拟合就升,欠拟合就降
nesterovTrue/FalseTrue开了通常不亏

4. 手把手实操:从单层回归到多层网络

4.1 最小可复现例子:线性回归看 SGD 行为

先从一个能跑通的最小例子开始,这样你能直观看到每个参数的作用。我们拟合一个简单的线性关系 y = 3x + 2,加一点噪声。

import torch import torch.nn as nn import torch.optim as optim torch.manual_seed(42) # 造数据 x = torch.randn(200, 1) y = 3 * x + 2 + 0.1 * torch.randn(200, 1) # 模型 model = nn.Linear(1, 1) # 优化器:先试纯 SGD optimizer = optim.SGD(model.parameters(), lr=0.1) criterion = nn.MSELoss() for epoch in range(100): optimizer.zero_grad() pred = model(x) loss = criterion(pred, y) loss.backward() optimizer.step() if epoch % 20 == 0: print(f"epoch {epoch}, loss {loss.item():.4f}")

跑下来你会发现,lr=0.1 的纯 SGD 收敛得还行,但如果你把 lr 调到 0.5,loss 会开始震荡甚至发散;调到 0.01,收敛明显变慢。这就是 SGD 对 lr 的敏感性。

现在加上动量:

optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9, nesterov=True)

同样的 lr,加了动量后收敛会更快,前几个 epoch 的 loss 下降更猛。但如果你把 lr 也保持 0.5 再加动量,发散会更严重,因为动量放大了有效步长。这个对比实验我建议你自己跑一遍,比看任何文字都直观。

4.2 参数分组实操:微调场景的经典配置

假设你在做一个迁移学习任务,主干是一个预训练网络,后面接了一个新的分类头。典型配置是这样:

# 把参数分成两组:需要 weight decay 的和不需要的 decay_params = [] no_decay_params = [] for name, param in model.named_parameters(): if not param.requires_grad: continue # bias 和 norm 层不做 weight decay if 'bias' in name or 'norm' in name: no_decay_params.append(param) else: decay_params.append(param) optimizer = optim.SGD([ {'params': decay_params, 'weight_decay': 1e-4}, {'params': no_decay_params, 'weight_decay': 0.0}, ], lr=0.01, momentum=0.9, nesterov=True)

为什么要给 bias 和 norm 层关掉 weight decay?因为 weight decay 的本质是"把参数往 0 拉",这对权重矩阵是合理的正则,但对 bias 和归一化层的缩放参数来说,把它们拉向 0 会破坏模型的表达能力。bias 本来就该自由调整,norm 层的 gamma 和 beta 也是。这个细节在不少论文的附录里都有提到,但很多教程不讲,导致新手一股脑全加 weight_decay,效果反而变差。

4.3 学习率调度:SGD 的最佳搭档

SGD 单独用往往不够,配上学习率调度器才是完全体。最经典的是阶梯衰减和余弦退火。

scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100) for epoch in range(100): train_one_epoch() scheduler.step()

余弦退火的好处是前期学习率大、探索充分,后期学习率小、精细收敛,曲线平滑没有突变。相比阶梯衰减那种"到点砍一半"的硬切换,余弦退火在大多数任务上表现更稳。我实测下来,同样的 SGD 配置,加余弦退火比不加通常能涨 1 到 2 个点的准确率。

还有一个技巧是 warmup:前几个 epoch 让学习率从很小的值线性升到目标值,避免训练初期梯度噪声大导致的不稳定。这个在 Transformer 类模型里几乎是标配,在 CNN 上也有帮助。

def warmup_lambda(epoch): if epoch < 5: return (epoch + 1) / 5 return 0.5 * (1 + math.cos(math.pi * (epoch - 5) / 95)) scheduler = optim.lr_scheduler.LambdaLR(optimizer, warmup_lambda)

4.4 一个完整的训练循环模板

把上面的东西拼起来,给你一个可以直接抄的训练模板:

import torch import torch.nn as nn import torch.optim as optim import math def build_optimizer(model, base_lr=0.01, wd=1e-4): decay, no_decay = [], [] for name, p in model.named_parameters(): if not p.requires_grad: continue if 'bias' in name or 'norm' in name: no_decay.append(p) else: decay.append(p) return optim.SGD([ {'params': decay, 'weight_decay': wd}, {'params': no_decay, 'weight_decay': 0.0}, ], lr=base_lr, momentum=0.9, nesterov=True) optimizer = build_optimizer(model) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100) for epoch in range(100): model.train() for xb, yb in train_loader: optimizer.zero_grad() loss = criterion(model(xb), yb) loss.backward() # 梯度裁剪,防止梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() scheduler.step() # 验证...

这里我加了梯度裁剪,因为 SGD 配合大学习率时,偶尔会遇到梯度爆炸,裁剪一下能显著提升稳定性。max_norm 设 5.0 是个比较安全的默认值,具体可以看梯度范数的分布来调。

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

5.1 loss 不降或震荡:按这个顺序排查

遇到 loss 不降,别急着换优化器,先按下面这个顺序查:

现象可能原因排查方法解决方向
loss 剧烈震荡lr 太大打印每步 loss降 lr 到 1/10 试
loss 几乎不动lr 太小 / 梯度消失打印梯度范数升 lr 或检查网络
loss 先降后升过拟合 / lr 未衰减看验证集曲线加 weight_decay 或调度器
loss 变 NaN梯度爆炸 / lr 过大打印梯度梯度裁剪 + 降 lr
收敛极慢没开动量对比 momentum=0/0.9开动量 0.9

我踩过最典型的一个坑是:换了数据集之后忘了调 lr,结果 loss 一直震荡。后来养成习惯,每换一次数据或模型结构,先用一个很小的 lr 跑几十步,确认 loss 在稳定下降,再逐步放大 lr 找临界点。这个"从小往大试"的方法比盲目猜 lr 高效得多。

5.2 那些文档里不写的坑

第一个坑:zero_grad()的位置。标准写法是在backward()之前清零。如果你在step()之后清零,逻辑上也对,但一旦中间有梯度累积的需求就容易乱。我建议统一放在backward()前,形成肌肉记忆。

第二个坑:参数分组时忘了过滤requires_grad=False的参数。冻结的层如果被塞进优化器,虽然不会更新,但会白白占用显存和计算。养成if not p.requires_grad: continue的习惯。

第三个坑:weight_decay 和 lr 一起调的时候,容易顾此失彼。我的经验是固定一个,调另一个。通常先固定 weight_decay=1e-4,把 lr 调好,再回头微调 weight_decay。

第四个坑:nesterov 和 dampening 同时开报错。这个前面提过,报错信息还算清楚,但新手容易懵。记住 nesterov 要求 dampening=0 就行。

第五个坑:以为 momentum 越大越好。momentum 太大(比如 0.99)在训练后期会导致更新"刹不住车",在最优解附近来回跳。通常 0.9 是甜点,任务特别平滑时可以到 0.95。

5.3 怎么判断该用 SGD 还是 Adam

这是个老生常谈的问题,我的实际经验是:SGD 配合好的调度器,在图像分类等任务上往往能取得更好的最终精度,但调参成本高、收敛慢;Adam 开箱即用、收敛快,但最终精度有时略逊,且对 weight_decay 的处理有坑。

如果你的任务是快速验证想法、或者数据量不大,用 Adam 起步更省心。如果追求极致精度、有时间调参,SGD + momentum + nesterov + 余弦退火是经典组合。我个人的习惯是:先用 Adam 快速跑通流程、确认模型结构没问题,再切到 SGD 精调。这样既省时间,又能拿到 SGD 的精度红利。

提示:切换优化器时,学习率一定要重新调。Adam 的默认 lr=1e-3 和 SGD 的 0.01 完全不是一个量级,直接套用必翻车。

6. 把 SGD 用出花来的几个进阶思路

6.1 梯度累积模拟大 batch

显存不够、batch 开不大时,可以用梯度累积。原理是多次 backward 累加梯度,再统一 step 一次,等效于大 batch:

accum_steps = 4 for i, (xb, yb) in enumerate(train_loader): loss = criterion(model(xb), yb) / accum_steps loss.backward() if (i + 1) % accum_steps == 0: optimizer.step() optimizer.zero_grad()

注意 loss 要除以 accum_steps,否则梯度会被放大 accum_steps 倍,等效学习率就变了。这个细节很多人漏掉,导致切换累积步数后训练行为突变。

6.2 用 hook 观察梯度,做到心里有数

调 SGD 的时候,光看 loss 不够,最好能看梯度。用 hook 可以方便地监控每层梯度范数:

for name, p in model.named_parameters(): if p.requires_grad: p.register_hook(lambda grad, n=name: print(f"{n}: {grad.norm().item():.4f}"))

跑几步看看,如果某层梯度范数一直是 0,说明它没在学;如果某层梯度范数特别大,可能是梯度爆炸的源头。这个手段在排查"为什么某层不更新"时特别有用。

6.3 学习率范围测试:科学地找 lr

与其猜 lr,不如做一次范围测试。让 lr 从极小值指数增长到较大值,记录每个 lr 对应的 loss,画出曲线,选 loss 下降最陡那段对应的 lr:

lrs = torch.logspace(-5, 0, 100) for lr in lrs: for group in optimizer.param_groups: group['lr'] = lr.item() # 跑一步,记录 loss

这个方法来自 fast.ai 的实践,虽然多花一点时间,但能帮你快速定位合理的 lr 区间,比盲目试错高效得多。我一般在正式训练前都会跑一次,心里有底。

6.4 关于 foreach 和性能的一点实测

PyTorch 较新版本里,foreach=True会用融合算子批量更新参数,在参数数量多的时候能提速。我在一个参数量几千万的模型上对比过,开启 foreach 后每个 step 大约快 5% 到 10%,参数越多收益越明显。但它对某些自定义参数结构可能不兼容,如果遇到奇怪的报错,先把它关掉试试。日常训练可以默认开着,出问题再排查。

7. 我个人的一点使用体会

用 SGD 这几年,最大的感受是:它不像 Adam 那样"帮你把活干了",而是把控制权完全交给你。学习率、动量、weight decay、调度器,每一个旋钮都实实在在影响结果。这既是负担,也是自由——当你理解了每个参数背后的数学,你就能针对具体任务做出精准的调整,而不是被优化器的默认行为牵着走。

我现在做新项目的固定流程是:先用 Adam 快速验证模型能跑通、结构没问题,然后切到 SGD,做一次学习率范围测试,配上 momentum=0.9、nesterov=True、余弦退火和 warmup,参数分组把 bias 和 norm 层的 weight_decay 关掉。这套组合在图像分类、分割、检测任务上都表现稳定,基本不用大改。

最后分享一个小技巧:训练过程中定期保存优化器的 state_dict,不只是模型权重。因为 SGD 的动量缓冲区也在 state_dict 里,断点续训时如果只恢复模型不恢复优化器,动量会从零开始,前几个 step 的行为会和之前不一致。这个坑我在做长周期训练时踩过一次,续训后 loss 突然跳了一下,查了半天才发现是动量没恢复。把optimizer.state_dict()一起存下来,续训时optimizer.load_state_dict()加载,就能无缝衔接。

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

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

立即咨询