2.5 PyTorch 中的优化器:从手动更新到参数组与状态管理

Author

jshn9515

Published

2026-05-23

Modified

2026-05-23

前面几节里,我们已经知道一件事:只要计算图被正确记录,调用 loss.backward() 之后,模型参数就会得到梯度。

但是,梯度本身不会自动修改参数。它只是告诉我们:如果想让 loss 变小,参数应该往哪个方向移动。真正根据梯度更新参数的,是优化器(optimizer)

比如最简单的梯度下降可以写成:

\[ \theta \leftarrow \theta - \eta \nabla_\theta L \]

其中,\(\theta\) 是参数,\(\nabla_\theta L\) 是参数的梯度,\(\eta\) 是学习率。

如果不用优化器,我们其实也可以手动更新参数:

import torch
import torch.nn as nn
import torch.nn.functional as F

x = torch.randn(8, 3)
y = torch.randn(8, 1)

linear = nn.Linear(3, 1)

pred = linear(x)
loss = F.mse_loss(pred, y)
loss.backward()

lr = 0.1
with torch.no_grad():
    for param in linear.parameters():
        param -= lr * param.grad

这段代码可以工作。但它存在几个问题:

torch.optim 要解决的就是这些问题。

这一节我们从最基本的 optimizer.step() 开始,逐步理解 PyTorch 优化器到底在管理什么。

from pprint import pprint

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch import Tensor

print('PyTorch version:', torch.__version__)
PyTorch version: 2.12.1+cpu

2.5.1 从手动更新到 optimizer.step()

我们先用一个简单的线性模型作为例子:

model = nn.Linear(3, 1)
optimizer = optim.SGD(model.parameters(), lr=0.1)

优化器创建时,最重要的是告诉它两件事:

  • 我们要更新哪些参数;
  • 我们想用什么规则更新这些参数。

这里的:

optimizer = optim.SGD(model.parameters(), lr=0.1)

意思就是,把 model.parameters() 返回的所有参数交给 SGD,并用学习率 0.1 更新它们。

一个最小的训练步骤通常长这样:

x = torch.randn(8, 3)
y = torch.randn(8, 1)

pred = model(x)
loss = F.mse_loss(pred, y)

optimizer.zero_grad()
loss.backward()
optimizer.step()

print('Loss:', loss.item())
Loss: 0.6273305416107178

其中,这三行非常常见:

optimizer.zero_grad()
loss.backward()
optimizer.step()

它们分别对应训练中的三个动作:

  • 清空旧梯度,避免累加;
  • 根据当前 loss 计算新梯度,写到参数的 .grad 中;
  • 根据新梯度和优化器状态更新参数。

注意,backward() 只负责把梯度写到参数的 .grad 属性里,真正修改参数值的是 optimizer.step()

我们可以直接看一下更新前后参数有没有变化:

model = nn.Linear(3, 1)
optimizer = optim.SGD(model.parameters(), lr=0.1)

before = model.weight.detach().clone()

pred = model(x)
loss = F.mse_loss(pred, y)

optimizer.zero_grad()
loss.backward()
optimizer.step()

after = model.weight.detach().clone()

flag = before.allclose(after)
max_err = (before - after).abs().max().item()
print('Is the parameter unchanged?', flag)
print('Max absolute difference:', max_err)
Is the parameter unchanged? False
Max absolute difference: 0.12110725045204163

这说明参数确实被优化器修改了。

2.5.2 为什么每次更新前要 zero_grad

一个很容易忽略的细节是:PyTorch 中的梯度默认是累加的,而不是覆盖。也就是说,如果我们连续调用两次 backward(),第二次得到的梯度不会覆盖第一次,而是加到原来的 .grad 上。

我们用一个很小的例子看看:

w = torch.tensor([1.0], requires_grad=True)

loss1 = w ** 2
loss1.backward()
print('After first backward:', w.grad)

loss2 = w ** 2
loss2.backward()
print('After second backward:', w.grad)
After first backward: tensor([2.])
After second backward: tensor([4.])

第一次反向传播后,梯度是 2;第二次反向传播后,梯度变成了 4。不是因为新的梯度是 4,而是因为旧的梯度 2 和新的梯度 2 累加起来的和是 4。

这就是为什么训练循环里通常要写:

optimizer.zero_grad()
loss.backward()
optimizer.step()

如果不清空梯度,每个 batch 的梯度都会叠加到前面的 batch 上,参数更新就不再对应当前 batch 的 loss

当然,梯度累加并不总是错误。有时候我们会故意累加多个 mini-batch 的梯度,再统一更新一次参数。这叫做梯度累积(gradient accumulation)

例如:

model = nn.Linear(3, 1)
optimizer = optim.SGD(model.parameters(), lr=0.1)

optimizer.zero_grad()

for i in range(4):
    x = torch.randn(8, 3)
    y = torch.randn(8, 1)

    pred = model(x)
    loss = F.mse_loss(pred, y)
    loss = loss / 4
    loss.backward()

optimizer.step()

这里我们故意不在每个 mini-batch 后面清空梯度,而是让 4 个 mini-batch 的梯度累加起来,再调用一次 step()

所以,更准确地说,zero_grad() 不是因为 backward() 必须这样用,而是因为 PyTorch 默认累加梯度。如果这次更新只想使用当前 batch 的梯度,就要在 backward() 之前清空旧梯度。

2.5.3 set_to_none 是什么

optimizer.zero_grad() 默认会把参数的 .grad 置为 None

model = nn.Linear(3, 1)
optimizer = optim.SGD(model.parameters(), lr=0.1)

x = torch.randn(8, 3)
y = torch.randn(8, 1)

loss = F.mse_loss(model(x), y)
loss.backward()

flag1 = model.weight.grad is None
print('Whether grad is None before zero_grad?', flag1)

optimizer.zero_grad(set_to_none=True)
flag2 = model.weight.grad is None
print('Whether grad is None after zero_grad?', flag2)
Whether grad is None before zero_grad? False
Whether grad is None after zero_grad? True

有时候我们也会看到:

optimizer.zero_grad(set_to_none=False)

这会把梯度设成 0,而不是 None

model = nn.Linear(3, 1)
optimizer = optim.SGD(model.parameters(), lr=0.1)

loss = F.mse_loss(model(x), y)
loss.backward()

optimizer.zero_grad(set_to_none=False)
print(model.weight.grad)
tensor([[0., 0., 0.]])

两者都可以达到清空旧梯度的目的,但语义稍微不同:

  • .grad = None:表示这个参数目前还没有梯度;
  • .grad = 0:表示这个参数有梯度,只是梯度值为 0。

在大多数训练代码里,使用默认的 set_to_none=True 就可以。它通常更省内存,也可以让 PyTorch 在下一次反向传播时重新分配梯度张量。不过,如果你的代码假设 .grad 一定是张量,而不是 None,就需要注意这个区别。

2.5.4 参数组:不同参数可以有不同学习率

到目前为止,我们把模型的所有参数都交给了同一个优化器,并使用同一个学习率:

optimizer = optim.SGD(model.parameters(), lr=1e-3)

但实际训练中,我们经常希望不同部分使用不同超参数。

例如,在微调预训练模型时,backbone 可能用较小学习率,最后的分类头用较大学习率。因为 backbone 已经学过很多通用特征,不想改得太快;而 head 是随机初始化的,需要更快学习当前任务。

这时可以使用参数组(parameter groups)

class TinyModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.backbone = nn.Sequential(
            nn.Linear(10, 32),
            nn.ReLU(),
            nn.Linear(32, 32),
            nn.ReLU(),
        )
        self.head = nn.Linear(32, 2)

    def forward(self, x: Tensor) -> Tensor:
        x = self.backbone(x)
        return self.head(x)


model = TinyModel()
optimizer = optim.SGD(
    [
        {'params': model.backbone.parameters(), 'lr': 1e-4},
        {'params': model.head.parameters(), 'lr': 1e-3},
    ],
    weight_decay=1e-2,
)

for i, group in enumerate(optimizer.param_groups):
    print(
        f'Parameter group {i}: '
        f'learning rate = {group["lr"]}, '
        f'weight decay = {group["weight_decay"]}'
    )
Parameter group 0: learning rate = 0.0001, weight decay = 0.01
Parameter group 1: learning rate = 0.001, weight decay = 0.01

这里传给优化器的不再是一个简单的参数迭代器,而是一个由字典组成的列表。每个字典描述一组参数,以及这一组参数自己的优化超参数。

如果某个参数组没有显式设置某个超参数,就会使用优化器构造函数里的默认值。比如上面的两个参数组都没有单独设置 weight_decay,所以它们都会使用外层的 weight_decay=1e-2

参数组也常用于设置某些参数不做 weight decay。例如,很多训练代码会对 bias 和 normalization 层的参数关闭 weight decay:

def split_weight_decay_params(model: nn.Module):
    decay = []
    no_decay = []

    for name, param in model.named_parameters():
        if not param.requires_grad:
            continue
        if name.endswith('bias') or 'norm' in name.lower():
            no_decay.append(param)
        else:
            decay.append(param)

    return [
        {'params': decay, 'weight_decay': 1e-2},
        {'params': no_decay, 'weight_decay': 0.0},
    ]


model = nn.Sequential(
    nn.Linear(10, 32),
    nn.LayerNorm(32),
    nn.Linear(32, 2),
)
optimizer = optim.SGD(split_weight_decay_params(model), lr=1e-3)

for i, group in enumerate(optimizer.param_groups):
    print(
        f'Parameter group {i}: '
        f'weight decay = {group["weight_decay"]}, '
        f'number of params = {len(group["params"])}'
    )
Parameter group 0: weight decay = 0.01, number of params = 3
Parameter group 1: weight decay = 0.0, number of params = 3

所以,优化器并不是只能统一更新整个模型。它内部其实维护的是一组一组的参数,每一组都可以有自己的学习率、权重衰减、动量等配置。根据需要,我们可以灵活地把模型参数分成不同的组,交给优化器管理。

2.5.5 优化器不只是公式,也有状态

如果使用最简单的 SGD,并且没有 momentum,那么参数更新只依赖当前梯度 \(g\)

\[ \theta \leftarrow \theta - \eta g \]

但是,很多优化器不仅依赖当前梯度,还会保存某些历史信息。例如带 momentum 的 SGD 会维护一个动量缓冲区:

\[ v_t = \mu v_{t-1} + g_t \]

Adam 和 AdamW 会维护梯度的一阶矩和二阶矩估计。

这些历史信息不是模型参数,但它们会影响后续更新。因此,优化器内部也有状态(state)

我们可以直接看一个优化器的 state_dict()

model = nn.Linear(3, 1)
optimizer = optim.AdamW(model.parameters(), lr=1e-3)

pprint(optimizer.state_dict(), sort_dicts=False)
{'state': {},
 'param_groups': [{'lr': 0.001,
                   'betas': (0.9, 0.999),
                   'eps': 1e-08,
                   'weight_decay': 0.01,
                   'amsgrad': False,
                   'maximize': False,
                   'foreach': None,
                   'capturable': False,
                   'differentiable': False,
                   'fused': None,
                   'decoupled_weight_decay': True,
                   'params': [0, 1]}]}

刚创建优化器时,state 通常是空的,因为还没有进行过任何一步更新。

执行一次更新后,再看:

x = torch.randn(8, 3)
y = torch.randn(8, 1)

loss = F.mse_loss(model(x), y)
optimizer.zero_grad()
loss.backward()
optimizer.step()

state_dict = optimizer.state_dict()
pprint(state_dict, sort_dicts=False)
{'state': {0: {'step': tensor(1.),
               'exp_avg': tensor([[ 0.0602, -0.0157,  0.0787]]),
               'exp_avg_sq': tensor([[3.6230e-04, 2.4498e-05, 6.1956e-04]])},
           1: {'step': tensor(1.),
               'exp_avg': tensor([-0.1917]),
               'exp_avg_sq': tensor([0.0037])}},
 'param_groups': [{'lr': 0.001,
                   'betas': (0.9, 0.999),
                   'eps': 1e-08,
                   'weight_decay': 0.01,
                   'amsgrad': False,
                   'maximize': False,
                   'foreach': None,
                   'capturable': False,
                   'differentiable': False,
                   'fused': None,
                   'decoupled_weight_decay': True,
                   'params': [0, 1]}]}

一个优化器的 state_dict 里通常有两部分:

  1. state:每个参数对应的优化器内部状态;
  2. param_groups:参数组配置,比如学习率、weight decay、betas 等。

对于 AdamW,我们通常会看到类似 stepexp_avgexp_avg_sq 这样的状态。这些就是 AdamW 更新时需要用到的历史信息。当然,至于这些状态分别是什么意思,我们在后面会详细介绍。

这也解释了为什么恢复训练时,只保存模型参数还不够。如果只保存 model.state_dict(),模型权重可以恢复,但 SGD 的动量、Adam/AdamW 的一阶矩和二阶矩都会丢失。

因此,更完整的 checkpoint 通常会包含:

checkpoint = {
    'model': model.state_dict(),
    'optimizer': optimizer.state_dict(),
}
torch.save(checkpoint, 'checkpoint.pth')

对应地,加载时也要恢复两部分:

model = nn.Linear(3, 1)
optimizer = optim.AdamW(model.parameters(), lr=1e-3)

checkpoint = torch.load('checkpoint.pth')
model.load_state_dict(checkpoint['model'])
optimizer.load_state_dict(checkpoint['optimizer'])

这样训练才能尽量从中断的位置继续,而不是只拿到同一组模型参数重新开始优化。

2.5.6 foreach 和 fused:同一个优化器的不同实现方式

有时候我们会在优化器里看到这样的参数:

optimizer = optim.AdamW(
    model.parameters(),
    lr=1e-3,
    foreach=True,
)

或者:

optimizer = optim.AdamW(
    model.parameters(),
    lr=1e-3,
    fused=True,
)

这些参数不是在改变 AdamW 的数学目标,而是在选择怎么执行参数更新

在 PyTorch 里,同一个优化器大致可以有三种实现路线:

  1. for-loop:最传统的方式,逐个参数张量更新;
  2. foreach:把一组张量打包,调用批量张量操作;
  3. fused:把多个更新操作融合到更少的 kernel 里执行。

最容易理解的是 for-loop。它像这样逐个参数处理:

for param in params:
    update(param)

这种方式简单、通用,但如果模型有很多参数张量,就会产生很多小操作,尤其在 GPU 上可能效率不高。

foreach 的思路是:不要一个张量一个张量地更新,而是把很多张量作为一个列表,一起交给底层实现处理。它通常比普通 for-loop 更快,尤其是在 GPU 上有很多参数张量时。但 foreach 也不是完全免费的,因为它经常需要保存中间的张量列表,所以峰值显存可能会更高。

fused 则更进一步。它希望把优化器更新里的多个操作融合起来,减少 kernel 的反复启动和中间读写。直观地说,foreach 更像是一次处理很多张量,而 fused 更像是把一次更新里的多个操作合并执行。

因此,在支持良好的 CUDA 场景下,fused=True 可能更快。但它对设备、数据类型和优化器实现的支持要求更高。

我们可以先把它们理解为三种执行方式:

  1. for-loop:最朴素,兼容性最好;
  2. foreach:通常更快,但可能多占显存;
  3. fused:更激进,可能最快,但支持范围更有限。

实际使用时,如果没有特别需求,通常可以先让 PyTorch 使用默认选择。只有当你在优化大模型训练性能、显存或遇到兼容性问题时,才需要手动指定 foreachfused

Tip

关于 PyTorch 的 foreachfused 在不同优化器里的支持情况,官方文档有更详细的说明和兼容性列表,可以参考torch.optim - Algorighms

下面这段代码只是演示参数如何传入。不同机器、不同 PyTorch 版本和不同设备上,是否支持 fused=True 可能不一样。

model = nn.Linear(10, 2)
optimizer = optim.AdamW(
    model.parameters(),
    lr=1e-3,
    foreach=False,
    fused=False,
)

print(optimizer)
AdamW (
Parameter Group 0
    amsgrad: False
    betas: (0.9, 0.999)
    capturable: False
    decoupled_weight_decay: True
    differentiable: False
    eps: 1e-08
    foreach: False
    fused: False
    lr: 0.001
    maximize: False
    weight_decay: 0.01
)

2.5.7 optimizer.step() 默认不会记录计算图

前面我们手动更新参数时,写了:

with torch.no_grad():
    param -= lr * param.grad

这是因为普通训练中,参数更新本身通常不需要被 Autograd 记录。

换句话说,我们一般只关心 loss 如何对参数求梯度,而不关心 optimizer.step() 这个更新过程本身如何再被求导。

PyTorch 的优化器也是这个默认逻辑。optimizer.step() 默认会在不记录梯度的上下文里更新参数。

这对普通训练是合理的,因为如果每一步参数更新都被记录进计算图,显存会迅速增长,训练也会变得复杂。但是,有些更高级的场景确实需要对优化过程求导。例如元学习,可微分优化,学习学习率,或者把若干步梯度更新当成计算图的一部分。

这时需要让优化器的更新过程也参与 Autograd。PyTorch 优化器里对应的参数叫 differentiable。设置为 True 后,优化器的 step 过程就会被追踪,允许我们对更新后的参数继续求导。

例如:

model = nn.Linear(3, 1)
optimizer = optim.SGD(
    model.parameters(),
    lr=0.1,
    differentiable=True,
)

flag = optimizer.defaults['differentiable']
print('Is optimizer step differentiable?', flag)
Is optimizer step differentiable? True

不过,differentiable=True 不是常规训练需要打开的选项。它会让优化器 step 的计算也被追踪,通常会带来更多内存开销,也可能要求更谨慎地写代码。

所以可以先记住:对于大多数训练场景,使用默认的 differentiable=False 就好;只有在需要对参数更新过程继续求导的特殊场景下,才考虑设置 differentiable=True

2.5.8 一个完整的优化步骤

现在,我们可以把前面的内容连起来,写一个完整的优化步骤。

model = nn.Sequential(
    nn.Linear(10, 32),
    nn.ReLU(),
    nn.Linear(32, 1),
)
optimizer = optim.AdamW(
    [
        {'params': model[0].parameters(), 'lr': 1e-3},
        {'params': model[2].parameters(), 'lr': 1e-2},
    ],
    weight_decay=1e-2,
)

x = torch.randn(16, 10)
y = torch.randn(16, 1)

model.train()
pred = model(x)
loss = F.mse_loss(pred, y)

optimizer.zero_grad()
loss.backward()
optimizer.step()
print('Loss:', loss.item())
Loss: 0.6075477004051208

这段代码背后发生了几件事:

  • model(x):执行前向传播,计算预测值 pred,并构建计算图;
  • loss.backward():根据当前的 loss 计算梯度,并把结果累加到模型参数的 .grad 属性中;
  • optimizer.step():根据参数的 .grad 和优化器内部状态,更新参数值;
  • optimizer.zero_grad():清空旧梯度,避免下一次 backward() 继续累加。

其中,优化器不仅保存了超参数,也可能保存了历史状态。参数组决定了不同参数如何被更新,foreachfused 决定了更新过程如何被高效执行,而 differentiable=True 则决定了更新过程本身是否进入计算图。

2.5.9 本章小结

这一节我们从最简单的手动梯度下降出发,理解了 PyTorch 优化器的作用。backward() 负责计算梯度,把结果写到参数的 .grad 中;optimizer.step() 才真正根据这些梯度修改参数。

由于 PyTorch 默认会累加梯度,所以常规训练中需要在每次反向传播前调用 optimizer.zero_grad()。如果故意不清空梯度,也可以实现梯度累积。

优化器接收的不一定是一组统一参数,也可以是多个参数组。不同参数组可以设置不同学习率、weight decay 等超参数,这在微调和大模型训练中非常常见。

同时,优化器自己也有状态。像 AdamW 这样的优化器会保存历史梯度统计量,因此恢复训练时通常要同时保存 model.state_dict()optimizer.state_dict()

最后,我们还区分了优化器的几种执行实现。foreachfused 不改变优化算法的数学含义,而是改变更新过程的执行方式。普通训练时一般使用默认设置即可;只有在关心性能、显存或可微分优化时,才需要进一步控制这些选项。

下一节,我们就把从数据加载、模型定义、损失计算到优化器更新的完整训练循环写出来,看看它们是如何协同工作的。