1. 为什么值得把 optim.SGD 单独拎出来讲
如果你写过一段像样的 PyTorch 训练代码,大概率写过这么一行:optimizer = optim.SGD(model.parameters(), lr=0.01)。它太常见了,常见到很多人把它当成一个"默认选项",抄进代码里就不再管它。但真到了模型不收敛、loss 震荡、训练慢得让人抓狂的时候,你会发现十有八九问题就出在这行看起来最不起眼的优化器配置上。
optim.SGD是 PyTorch 里最基础、最"原始"的优化器,全称是随机梯度下降(Stochastic Gradient Descent)。它做的事情用一句话说清楚:拿着反向传播算出来的梯度,按一个固定的步长去更新每一个参数。就这么简单。但正是因为它简单,所有超参数都得你自己扛——学习率、动量、权重衰减、梯度裁剪、学习率调度,一个都不能少。相比之下,Adam 这类自适应优化器帮你把一部分活儿干了,SGD 则把控制权完全交给你。
这篇文章适合谁看?如果你刚入门深度学习,想搞明白优化器到底在干什么,SGD 是最好的起点;如果你已经用了一段时间 Adam,但发现某些任务上 SGD 反而效果更好,想搞清楚为什么,这篇也能给你答案;如果你正在做模型调优,被学习率折磨过,那咱们算是同路人。我会从优化器的整体设计思路讲起,把optim.SGD的每个参数拆开揉碎,配上可以直接跑的代码和参数计算过程,最后把我自己踩过的坑整理成一份排查清单。全程用大白话,不堆公式,但该讲清楚的原理一个不落。
2. 优化器到底在做什么:从整体设计思路说起
2.1 优化器的本质是一个"参数更新器"
训练神经网络的过程,说白了就是不断调整一堆参数,让损失函数的值越来越小。反向传播负责算出"每个参数往哪个方向调、调多少"(也就是梯度),而优化器负责真正执行这个调整动作。你可以把优化器想象成一个司机:梯度是导航给出的方向,优化器决定踩多大油门、要不要带点惯性、遇到颠簸要不要减速。
PyTorch 把所有优化器都抽象成了torch.optim.Optimizer这个基类,它规定了统一的接口:初始化时传入参数组,训练时调用zero_grad()清空梯度、step()执行更新。不同的优化器只是重写了step()里的更新逻辑。SGD 的更新逻辑最简单,一句话概括就是"参数 = 参数 - 学习率 × 梯度"。加上动量之后稍微复杂一点,但核心思想没变。
理解这个抽象很重要,因为它意味着你可以随时把 SGD 换成 Adam、RMSprop,而不用改动训练循环的其他部分。这也是为什么我建议新手先把 SGD 吃透——它是理解其他所有优化器的地基。
2.2 为什么 PyTorch 要保留这么一个"笨"的优化器
有人会问,既然 Adam 收敛快又省心,为什么还要用 SGD?这个问题我在实际项目里被问过很多次。答案不是"因为 SGD 更高级",恰恰相反,是因为 SGD 更"笨",所以它在某些场景下泛化能力更好。
自适应优化器(比如 Adam)会为每个参数单独调整学习率,这在训练初期收敛很快,但很多研究表明,它在某些任务上最终收敛到的解泛化性能不如精心调过的 SGD。尤其是计算机视觉里的图像分类、目标检测任务,SGD + 动量 + 学习率衰减这套组合至今仍是很多经典模型的标准配置。原因在于 SGD 的更新方向更"纯粹",它老老实实跟着当前 batch 的梯度走,噪声反而起到了正则化的作用,不容易过拟合到训练集的某些局部特征上。
所以选 SGD 还是 Adam,本质上是在"收敛速度"和"泛化能力"之间做权衡。我的经验是:如果数据量不大、任务比较新、想快速验证想法,先用 Adam 跑通;如果追求最终的精度上限、有足够的算力做调参,那就上 SGD。这不是绝对的规则,但能帮你少走弯路。
2.3 一个完整的优化器配置包含哪些部分
很多人以为优化器就是optim.SGD(model.parameters(), lr=0.01)这一行,其实这只是冰山一角。一个真正能打的训练配置,至少包含这几块:
- 参数组划分:不同层可以用不同的学习率,比如预训练的主干网络用小学习率,新加的分类头用大学习率。
- 动量(momentum):给更新加上惯性,帮助冲出鞍点和局部极小值。
- 权重衰减(weight_decay):对参数大小做惩罚,抑制过拟合。
- 学习率调度器(lr_scheduler):训练过程中动态调整学习率,通常是先大后小。
- 梯度裁剪(gradient clipping):防止梯度爆炸,尤其在 RNN 和 Transformer 里几乎是标配。
这几块配合起来,才构成一个完整的优化策略。下面我会逐个拆解,每个都给出为什么这么设、怎么设、设错了会怎样。
3. optim.SGD 的核心参数逐个拆解
3.1 lr:学习率是唯一真正重要的超参数
如果只能调一个参数,那一定是学习率。它决定了每次参数更新的步长。设太大,loss 会震荡甚至发散;设太小,训练慢得像蜗牛,还可能卡在某个平坦区域出不来。
那怎么选一个合适的初始学习率?我常用的方法是学习率扫描:从很小的值(比如 1e-5)开始,每个 batch 后把学习率乘以一个固定倍数(比如 1.1),同时记录 loss,画出一条 loss 随学习率变化的曲线。曲线最低点对应的学习率,通常就是比较理想的初始值,实际用的时候再取它的一半到三分之一。
对于 SGD 来说,常见的初始学习率范围是这样的:
| 任务类型 | 常用初始学习率 | 说明 |
|---|---|---|
| 图像分类(CNN) | 0.01 ~ 0.1 | 配合 momentum 0.9 使用 |
| 目标检测 | 0.01 ~ 0.02 | 通常用 warmup 预热 |
| 语义分割 | 0.01 ~ 0.05 | 对学习率较敏感 |
| 微调预训练模型 | 0.001 ~ 0.01 | 比从头训练小一个量级 |
| RNN / 序列任务 | 0.1 ~ 1.0 | 配合梯度裁剪 |
注意这里的数值是配合 batch size 的。如果你把 batch size 从 32 改成 256,学习率通常也要相应放大,一个经验法则是线性缩放:batch size 翻倍,学习率也翻倍。但这个规则不是万能的,放大到一定程度后需要改用平方根缩放,否则训练会不稳定。
提示:学习率扫描虽然好用,但比较费时间。如果赶进度,可以先凭经验设一个值,观察前几百个 step 的 loss 曲线,如果 loss 下降平滑就继续,如果上下跳动就调小,如果几乎不动就调大。
3.2 momentum:给下山的路加一点惯性
SGD 最被人诟病的一点是容易卡在鞍点或局部极小值附近来回震荡。动量的引入就是为了解决这个问题。它的思想很朴素:更新的时候不只看当前梯度,还把之前的更新方向累积进来,相当于给参数更新加了一个"惯性"。
用公式说,带动量的 SGD 更新是这样的:先算v = momentum * v + grad,然后param = param - lr * v。这里的v就是速度,它累积了历史梯度。当梯度方向一直一致时,速度会越来越大,加速收敛;当梯度方向来回变化时,正负抵消,减小震荡。
momentum的取值一般在 0.9 左右,这是经过大量实践验证的"甜点值"。设成 0.9 意味着当前更新方向里,历史方向的权重占九成,当前梯度只占一成。设太小(比如 0.5)惯性不够,效果不明显;设太大(比如 0.99)容易冲过头,反而震荡。
还有一个参数叫dampening,默认是 0,一般不用动。它是对当前梯度做衰减的,只有在 momentum 不为 0 时才有意义。我见过有人把它和 weight_decay 搞混,这里明确一下:dampening 作用在梯度上,weight_decay 作用在参数上,两码事。
另外提一句nesterov这个选项。它是动量的一种改进形式,叫 Nesterov 加速梯度。简单说,普通动量是"先看当前梯度再走",Nesterov 是"先按惯性走一步,再看那个位置的梯度"。理论上 Nesterov 收敛更快,实践中也经常能带来一点提升。我的建议是:用 SGD 就顺手把nesterov=True加上,代价很小,收益通常为正。
3.3 weight_decay:抑制过拟合的隐形推手
weight_decay做的事情是在每次更新时,额外把参数往 0 的方向拉一点点。它的效果等价于 L2 正则化,目的是让模型参数不要太大,从而降低过拟合风险。
在optim.SGD里,weight_decay 的实现方式是在梯度上加上weight_decay * param,然后一起参与更新。所以它和你在 loss 里手动加 L2 正则项,数学上是等价的,但写起来更简洁。
取值方面,常见的范围是 1e-4 到 1e-2。图像分类任务里 5e-4 或 1e-4 比较常见。设太大,模型会欠拟合,学不动;设太小,等于没加。我的经验是:如果你的模型在训练集上表现很好但验证集差很多,可以适当加大 weight_decay;如果训练集本身就学不好,先别动它,去调学习率。
注意:weight_decay 和 momentum 一起用时,PyTorch 的实现是把 weight_decay 加在梯度上再算动量的。这和某些框架的实现顺序不同,如果你从别的框架迁移过来,结果对不上,先检查这里。
3.4 参数组:让不同层用不同的学习率
optim.SGD的第一个参数不是简单的model.parameters(),而是一个可以包含多个字典的列表,每个字典定义一组参数和它们专属的超参数。这就是参数组机制。
为什么需要它?最典型的场景是迁移学习。你加载了一个预训练的主干网络,然后在后面接了一个新的分类头。主干网络的参数已经学得很好了,只需要微调,学习率应该小;分类头是随机初始化的,需要快速学习,学习率应该大。如果两组参数用同一个学习率,要么主干被破坏,要么分类头学得太慢。
配置起来大概是这样:
optimizer = optim.SGD([ {'params': model.backbone.parameters(), 'lr': 1e-4}, {'params': model.classifier.parameters(), 'lr': 1e-2} ], momentum=0.9, weight_decay=5e-4)注意这里momentum和weight_decay是写在最外层的,会作为默认值应用到所有参数组。如果某一组想用不同的值,可以在字典里单独指定。这种灵活性在实际调优时非常有用。
4. 把优化器用起来:完整实操流程
4.1 训练循环的标准骨架
光有优化器还不够,得把它嵌进训练循环里才能跑起来。一个标准的 PyTorch 训练循环长这样:
model = MyModel() criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1) for epoch in range(num_epochs): model.train() for inputs, targets in train_loader: optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, targets) loss.backward() optimizer.step() scheduler.step()这里面有几个容易出错的点,我一个个说。
optimizer.zero_grad()必须在loss.backward()之前调用。PyTorch 的梯度是累加的,如果你不清零,上一次的梯度会和这一次叠加,导致更新量越来越大,训练直接崩掉。我见过新手把这个顺序写反,结果 loss 一路飙升,排查半天才发现。
scheduler.step()的位置也有讲究。在 PyTorch 1.1 之后,官方的建议是在每个 epoch 结束后调用,而不是每个 batch 后。如果你用的是CosineAnnealingLR这类按 epoch 调整的调度器,放在 epoch 循环里;如果是OneCycleLR这类按 step 调整的,就放在 batch 循环里。放错位置会导致学习率变化节奏完全乱掉。
4.2 学习率调度:让学习率跟着训练节奏走
固定学习率的问题在于:训练初期需要大学习率快速下降,训练后期需要小学习率精细调整。一个固定的值没法同时满足这两个需求。所以实践中几乎都会配一个学习率调度器。
最常用的是StepLR,每隔固定的 epoch 数把学习率乘以一个衰减系数。比如step_size=30, gamma=0.1表示每 30 个 epoch 学习率变成原来的十分之一。这种策略简单粗暴,在很多经典任务上效果稳定。
MultiStepLR是它的升级版,可以指定在哪些 epoch 衰减,比如milestones=[60, 120, 160]。这比等间隔衰减更灵活,可以根据 loss 曲线的平台期来定。
CosineAnnealingLR让学习率按余弦曲线从初始值平滑降到接近 0。它的好处是下降过程平滑,没有突变,适合训练周期比较长、追求极致精度的场景。
OneCycleLR则是先让学习率从很小升到一个峰值,再降下来,整个训练过程走一个周期。这个策略在快速训练里很受欢迎,能在较少的 epoch 内达到不错的效果。
选哪个?我的经验是:不确定就用StepLR或MultiStepLR,稳;追求精度上限用CosineAnnealingLR;训练预算有限、想快速出结果用OneCycleLR。
4.3 梯度裁剪:防止训练中途"炸掉"
梯度裁剪不是优化器的参数,而是独立的一步操作,通常在loss.backward()之后、optimizer.step()之前调用:
loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step()max_norm设成多少?常见的是 1.0 或 5.0。它的作用是:如果所有参数梯度的总范数超过这个值,就按比例缩小,保证不超过。这在 RNN、Transformer 这类深层或序列模型里几乎是必须的,因为梯度爆炸会让参数瞬间变成 NaN。
怎么判断需不需要裁剪?训练时打印一下梯度范数,如果经常出现几百甚至上千的值,那就该裁了。如果梯度范数一直很温和(个位数),裁剪也不会有什么坏处,只是多了一点计算开销。
4.4 一个可复现的完整配置示例
把上面这些拼起来,给你一个我常用的、可以直接抄的配置:
import torch import torch.nn as nn import torch.optim as optim model = MyModel() criterion = nn.CrossEntropyLoss() optimizer = optim.SGD( model.parameters(), lr=0.05, momentum=0.9, weight_decay=5e-4, nesterov=True ) scheduler = optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=100, eta_min=1e-5 ) for epoch in range(100): model.train() for inputs, targets in train_loader: inputs, targets = inputs.cuda(), targets.cuda() optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, targets) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() scheduler.step() print(f"Epoch {epoch}, LR: {scheduler.get_last_lr()[0]:.6f}")这套配置在中等规模的图像分类任务上表现很稳。学习率 0.05 配合余弦退火,动量 0.9 加 Nesterov,权重衰减 5e-4,梯度裁剪 5.0。你可以根据自己的任务微调,但整体框架是通用的。
5. 常见问题与排查技巧实录
5.1 loss 不下降或直接变 NaN 怎么办
这是最常见的问题,排查顺序我一般是这样:
先看学习率是不是太大。把学习率缩小 10 倍再跑几百个 step,如果 loss 开始正常下降,那就是学习率的问题。如果缩小后 loss 几乎不动,那可能是学习率太小,或者模型本身有问题。
再看梯度。在loss.backward()之后打印一下梯度范数,如果出现 NaN 或 inf,说明反向传播出了问题。常见原因包括:输入数据里有 NaN、loss 函数用错、网络里有除零操作。梯度裁剪能缓解但不能根治,得找到源头。
还要检查数据。标签是不是越界了?输入是不是没归一化?我遇到过一次 loss 一直不降,最后发现是数据加载时把图像和标签弄反了,这种低级错误反而最难发现。
5.2 训练集表现好但验证集差很多
这是典型的过拟合。针对优化器层面,可以做的调整有:加大 weight_decay、减小学习率、加学习率衰减让后期更新更精细。但要注意,过拟合的根本解法通常是加数据、加正则化(dropout、数据增强),优化器只能缓解。
如果加了 weight_decay 之后训练集也学不好了,说明正则化太强,往回退一点。这个平衡点需要试几次才能找到。
5.3 训练前期 loss 震荡得厉害
震荡通常意味着学习率偏大,或者 batch size 太小导致梯度噪声大。可以先试试减小学习率,或者加一个 warmup:训练最开始的一小段时间里,让学习率从很小的值线性升到初始值。warmup 能有效缓解前期震荡,在 Transformer 类模型里几乎是标配。
PyTorch 没有内置 warmup,但可以用LambdaLR自己写:
def warmup_lambda(epoch): if epoch < 5: return (epoch + 1) / 5 return 1.0 scheduler = optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=warmup_lambda)这段代码让学习率在前 5 个 epoch 从 1/5 线性升到 1,之后保持不变。简单但有效。
5.4 常见问题速查表
| 现象 | 可能原因 | 优先排查项 |
|---|---|---|
| loss 变 NaN | 学习率过大、梯度爆炸 | 减小 lr、加梯度裁剪 |
| loss 不下降 | 学习率过小、数据有问题 | 学习率扫描、检查数据 |
| loss 震荡 | lr 偏大、batch 太小 | 减小 lr、加 warmup |
| 验证集差 | 过拟合 | 加大 weight_decay、加正则 |
| 训练后期停滞 | 学习率没衰减 | 加 lr_scheduler |
| 参数更新无变化 | 梯度为 0、zero_grad 位置错 | 检查梯度、检查调用顺序 |
5.5 几个我踩过的坑
第一个坑:zero_grad()忘了写。这个错误很隐蔽,因为代码能跑,只是 loss 会慢慢变大。我当时的反应是"模型怎么越训越差",查了半天才发现是梯度累加导致的。
第二个坑:scheduler 和 optimizer 的绑定顺序。如果你在创建 scheduler 之后又重新创建了 optimizer,scheduler 还指向旧的 optimizer,学习率调整就不会生效。这个错误不会报错,只会让你纳闷"为什么学习率一直没变"。
第三个坑:weight_decay 加在了 BatchNorm 的参数上。BatchNorm 的 weight 和 bias 通常不应该加 weight_decay,因为它们的作用是缩放和平移,正则化它们反而有害。正确做法是把 BatchNorm 的参数单独分一组,weight_decay 设为 0。这个细节很多教程不讲,但在实际调优时影响不小。
bn_params = [p for n, p in model.named_parameters() if 'bn' in n or 'bias' in n] other_params = [p for n, p in model.named_parameters() if 'bn' not in n and 'bias' not in n] optimizer = optim.SGD([ {'params': bn_params, 'weight_decay': 0}, {'params': other_params, 'weight_decay': 5e-4} ], lr=0.05, momentum=0.9)这段代码把 BatchNorm 和 bias 参数排除在权重衰减之外,是我从一次精度提升实验里总结出来的,虽然改动很小,但在一些对精度敏感的任务上能带来可观的收益。
6. 从 SGD 出发:优化器选型的个人经验
把 SGD 吃透之后,再看其他优化器会清晰很多。Adam 本质上是在 SGD 的基础上,为每个参数维护了梯度的一阶矩和二阶矩估计,用它们来动态调整每个参数的学习率。RMSprop 则是只用了二阶矩。理解了 SGD 的更新逻辑,这些变体就不难懂了。
我的选型习惯是这样的:新任务先用 Adam 快速验证可行性,学习率设 1e-3,基本不用怎么调就能跑起来。等确认了模型结构没问题,再切换到 SGD 精调,这时候学习率要重新扫描,通常比 Adam 大一个量级。如果 SGD 调出来的效果确实比 Adam 好,那就用 SGD;如果差不多,看训练成本,SGD 通常需要更多 epoch,算力紧张就用 Adam。
还有一个折中方案是AdamW,它修正了 Adam 里 weight_decay 的实现问题,在很多任务上表现比 Adam 更稳。如果你不想在优化器上花太多心思,AdamW是个不错的默认选择。
但不管用哪个优化器,学习率永远是最值得花时间调的参数。我见过太多人换了各种优化器、加了各种 trick,最后发现只是学习率没设对。把学习率扫描这一步做扎实,比什么都强。
最后分享一个我常用的调试习惯:训练开始后的前 100 个 step,把 loss、学习率、梯度范数都打印出来。这三个指标基本能告诉你训练是不是在正轨上。loss 平滑下降、学习率符合预期、梯度范数在合理范围,那就可以放心让它跑;任何一个不对劲,趁早停下来查,别等跑了半天才发现问题。这个习惯帮我省下的时间,比我学过的任何调参技巧都多。