1. 从一个 epoch 的 loss 曲线说起刚接触 PyTorch 那会儿我写训练脚本基本是抄模板optimizer torch.optim.SGD(model.parameters(), lr0.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_{t1} \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_{t1} \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_{t1} \theta_t - \eta \cdot g_t$$展开后你会发现weight decay 相当于在梯度里加了一项 $\lambda \theta$这正是 L2 正则的梯度。所以对 SGD 来说weight_decay1e-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。当 d0 时就是标准动量。实际用的时候如果你开了 dampening 又开了 nesterovPyTorch 会直接报错因为 nesterov 的公式要求 dampening 必须为 0。这个限制很多人不知道配错了会一脸懵。nesterov是动量的一种改进。普通动量是先算梯度再按累积速度走而 Nesterov 是先按累积速度往前探一步在那个位置算梯度再修正。直觉上它更聪明因为它在预判的位置评估梯度能更早地感知到方向变化从而减少 overshoot。实测下来在凸问题上 nesterov 通常比普通动量收敛更快在深度网络上也有帮助但增益没有想象中那么大属于开了不亏的选项。3. optim.SGD 的参数全解析与配置逻辑3.1 构造函数的每个参数都值得掰开看torch.optim.SGD的签名大致是这样torch.optim.SGD( params, lr0.001, momentum0, dampening0, weight_decay0, nesterovFalse, maximizeFalse, foreachNone, differentiableFalse )我逐个说清楚它们的实际含义和配置建议。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}, ], momentum0.9, weight_decay1e-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否则容易发散。我一般调参的顺序是先定 momentum0.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(), lr0.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(fepoch {epoch}, loss {loss.item():.4f})跑下来你会发现lr0.1 的纯 SGD 收敛得还行但如果你把 lr 调到 0.5loss 会开始震荡甚至发散调到 0.01收敛明显变慢。这就是 SGD 对 lr 的敏感性。现在加上动量optimizer optim.SGD(model.parameters(), lr0.1, momentum0.9, nesterovTrue)同样的 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}, ], lr0.01, momentum0.9, nesterovTrue)为什么要给 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_max100) 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_lr0.01, wd1e-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}, ], lrbase_lr, momentum0.9, nesterovTrue) optimizer build_optimizer(model) scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max100) 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_norm5.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收敛极慢没开动量对比 momentum0/0.9开动量 0.9我踩过最典型的一个坑是换了数据集之后忘了调 lr结果 loss 一直震荡。后来养成习惯每换一次数据或模型结构先用一个很小的 lr 跑几十步确认 loss 在稳定下降再逐步放大 lr 找临界点。这个从小往大试的方法比盲目猜 lr 高效得多。5.2 那些文档里不写的坑第一个坑zero_grad()的位置。标准写法是在backward()之前清零。如果你在step()之后清零逻辑上也对但一旦中间有梯度累积的需求就容易乱。我建议统一放在backward()前形成肌肉记忆。第二个坑参数分组时忘了过滤requires_gradFalse的参数。冻结的层如果被塞进优化器虽然不会更新但会白白占用显存和计算。养成if not p.requires_grad: continue的习惯。第三个坑weight_decay 和 lr 一起调的时候容易顾此失彼。我的经验是固定一个调另一个。通常先固定 weight_decay1e-4把 lr 调好再回头微调 weight_decay。第四个坑nesterov 和 dampening 同时开报错。这个前面提过报错信息还算清楚但新手容易懵。记住 nesterov 要求 dampening0 就行。第五个坑以为 momentum 越大越好。momentum 太大比如 0.99在训练后期会导致更新刹不住车在最优解附近来回跳。通常 0.9 是甜点任务特别平滑时可以到 0.95。5.3 怎么判断该用 SGD 还是 Adam这是个老生常谈的问题我的实际经验是SGD 配合好的调度器在图像分类等任务上往往能取得更好的最终精度但调参成本高、收敛慢Adam 开箱即用、收敛快但最终精度有时略逊且对 weight_decay 的处理有坑。如果你的任务是快速验证想法、或者数据量不大用 Adam 起步更省心。如果追求极致精度、有时间调参SGD momentum nesterov 余弦退火是经典组合。我个人的习惯是先用 Adam 快速跑通流程、确认模型结构没问题再切到 SGD 精调。这样既省时间又能拿到 SGD 的精度红利。提示切换优化器时学习率一定要重新调。Adam 的默认 lr1e-3 和 SGD 的 0.01 完全不是一个量级直接套用必翻车。6. 把 SGD 用出花来的几个进阶思路6.1 梯度累积模拟大 batch显存不够、batch 开不大时可以用梯度累积。原理是多次 backward 累加梯度再统一 step 一次等效于大 batchaccum_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, nname: print(f{n}: {grad.norm().item():.4f}))跑几步看看如果某层梯度范数一直是 0说明它没在学如果某层梯度范数特别大可能是梯度爆炸的源头。这个手段在排查为什么某层不更新时特别有用。6.3 学习率范围测试科学地找 lr与其猜 lr不如做一次范围测试。让 lr 从极小值指数增长到较大值记录每个 lr 对应的 loss画出曲线选 loss 下降最陡那段对应的 lrlrs 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 较新版本里foreachTrue会用融合算子批量更新参数在参数数量多的时候能提速。我在一个参数量几千万的模型上对比过开启 foreach 后每个 step 大约快 5% 到 10%参数越多收益越明显。但它对某些自定义参数结构可能不兼容如果遇到奇怪的报错先把它关掉试试。日常训练可以默认开着出问题再排查。7. 我个人的一点使用体会用 SGD 这几年最大的感受是它不像 Adam 那样帮你把活干了而是把控制权完全交给你。学习率、动量、weight decay、调度器每一个旋钮都实实在在影响结果。这既是负担也是自由——当你理解了每个参数背后的数学你就能针对具体任务做出精准的调整而不是被优化器的默认行为牵着走。我现在做新项目的固定流程是先用 Adam 快速验证模型能跑通、结构没问题然后切到 SGD做一次学习率范围测试配上 momentum0.9、nesterovTrue、余弦退火和 warmup参数分组把 bias 和 norm 层的 weight_decay 关掉。这套组合在图像分类、分割、检测任务上都表现稳定基本不用大改。最后分享一个小技巧训练过程中定期保存优化器的 state_dict不只是模型权重。因为 SGD 的动量缓冲区也在 state_dict 里断点续训时如果只恢复模型不恢复优化器动量会从零开始前几个 step 的行为会和之前不一致。这个坑我在做长周期训练时踩过一次续训后 loss 突然跳了一下查了半天才发现是动量没恢复。把optimizer.state_dict()一起存下来续训时optimizer.load_state_dict()加载就能无缝衔接。