4、自动求导与计算图:理解计算图、自动微分原理、torch.autograd、梯度累积、反向传播机制及requires_grad和grad_fn

在深度学习框架出现之前,训练神经网络最痛苦的部分之一就是手动推导梯度公式。对于简单的线性模型尚可应付,但一旦网络层数加深、激活函数复杂化,手动求导几乎不可能完成。PyTorch 之所以能成为科研与工业界的主流框架,其核心杀手锏之一就是 动态计算图 与 自动微分引擎(torch.autograd)。本章将深入剖析计算图的构建逻辑、自动微分的数学原理、梯度在反向传播中的流动方式,以及控制梯度行为的核心参数 requires_grad 与 grad_fn。理解这些底层机制,不仅能帮助你写出更高效的训练代码,还能在遇到梯度爆炸、梯度消失或显存溢出时,快速定位问题根源。

PyTorch 自动求导与计算图核心机制 输入 x requires_grad=False 权重 w requires_grad=True 线性变换 z = x*w + b grad_fn: AddBackward 激活函数 a = σ(z) grad_fn: SigmoidBackward 损失函数 L = MSE(a, y) grad_fn: MseLossBackward 前向传播 反向传播 梯度累积结果 w.grad = ∂L/∂w b.grad = ∂L/∂b 计算图特性 动态构建 每次前向重新生成 关键属性 requires_grad: 是否追踪 grad_fn: 反向函数指针 梯度清零 optimizer.zero_grad() 避免跨batch累积 核心要点 只有 requires_grad=True 的叶子张量会累积梯度;非叶子张量的 grad 默认不保存;grad_fn 记录了反向传播所需的操作类型

4.1 计算图:深度学习中的“数据流管道”

计算图(Computational Graph)是深度学习框架中用于描述数学运算的有向无环图(DAG)。图中的节点(Node)代表张量或运算,边(Edge)代表数据流向。PyTorch 采用动态计算图机制,即每次前向传播时都会重新构建计算图,这使得网络结构可以在运行时动态修改,非常适合研究原型设计和变长输入场景。

计算图的构建遵循“惰性追踪”原则:只有当你对某个张量执行了运算操作,且该张量的 requires_grad=True 时,PyTorch 才会自动记录该操作,并生成对应的 grad_fn 对象。这个 grad_fn 本质上是一个指向反向传播函数的指针,它保存了执行反向计算所需的全部信息,包括输入张量、运算类型以及必要的中间变量。

核心原理拆解:

  • 叶子张量(Leaf Tensor):由用户直接创建的张量,如模型权重 w 和偏置 b。它们是反向传播的起点,梯度最终累积在这些张量的 .grad 属性中。
  • 非叶子张量(Non-leaf Tensor):由运算得到的中间结果,如 z = x*w + b 中的 z。它们也有 grad_fn,但默认不保存梯度以节省内存。
  • grad_fn 链:从损失函数开始,通过 grad_fn 的 next_functions 属性可以逐层回溯到叶子张量,形成一条完整的反向传播路径。
张量类型 创建方式 requires_grad 默认值 grad_fn 梯度保存
叶子张量 torch.tensor() / torch.randn() False(需手动设置) None 保存在 .grad 中
非叶子张量 由运算产生 继承自输入 有(如 AddBackward) 默认不保存(可保留)
模型参数 nn.Parameter() True None 保存在 .grad 中

4.2 自动微分原理:链式法则的工程化实现

自动微分(Automatic Differentiation, AD)并非数值微分或符号微分,而是将复合函数的求导过程分解为一系列基本运算(加减乘除、指数、对数、三角函数等)的链式法则应用。PyTorch 的 torch.autograd 引擎在反向传播时,从输出节点(损失函数)开始,沿着计算图反向遍历,每经过一个节点就调用该节点 grad_fn 中定义的局部梯度计算函数,并将梯度传递给前驱节点。

以表达式 L = (σ(x*w + b) - y)^2 为例,反向传播过程如下:

  1. 第一步:计算 ∂L/∂a = 2*(a - y),其中 a = σ(z)。
  2. 第二步:计算 ∂a/∂z = σ(z) * (1 - σ(z))(Sigmoid 函数的导数)。
  3. 第三步:计算 ∂z/∂w = x,∂z/∂b = 1。
  4. 第四步:通过链式法则相乘,得到 ∂L/∂w = ∂L/∂a * ∂a/∂z * ∂z/∂w。

PyTorch 的高明之处在于,它将上述每一步的局部导数都封装在对应的 grad_fn 中。例如 AddBackward 知道如何分配梯度(将上游梯度原样传递给两个输入),SigmoidBackward 知道如何利用前向保存的 z 值计算 Sigmoid 导数。这种设计使得框架无需知道整个网络的全局结构,只需每个算子“各司其职”,即可完成任意复杂网络的梯度计算。

4.3 requires_grad 与 grad_fn:梯度流的“开关”与“路标”

requires_grad 是张量的一个布尔属性,它决定了 PyTorch 是否追踪该张量上的所有运算。当设置为 True 时,该张量及其运算结果会被记录到计算图中,以便后续计算梯度。这个属性具有传染性:如果一个运算的任一输入 requires_grad=True,那么输出张量的 requires_grad 也会自动变为 True。

grad_fn 则是张量的一个属性,它指向产生该张量的运算函数对象。对于叶子张量,grad_fn 为 None;对于非叶子张量,它记录了运算类型和反向传播所需的上下文信息。通过检查 grad_fn,你可以清晰地了解张量是如何被计算出来的,这在调试网络时非常有用。

实战技巧:在推理阶段(不需要梯度时),使用 torch.no_grad() 上下文管理器或 model.eval() 配合 with torch.no_grad(): 可以显著减少内存消耗并加速计算。因为此时不会构建计算图,也不会保存中间变量。

import torch

# 创建需要梯度的张量
x = torch.tensor([2.0], requires_grad=True)
w = torch.tensor([3.0], requires_grad=True)
b = torch.tensor([1.0], requires_grad=True)

# 前向计算
z = x * w + b
a = torch.sigmoid(z)
loss = (a - 1.0) ** 2

print("z.requires_grad:", z.requires_grad)  # True,因为输入需要梯度
print("z.grad_fn:", z.grad_fn)              # AddBackward
print("a.grad_fn:", a.grad_fn)              # SigmoidBackward
print("loss.grad_fn:", loss.grad_fn)        # PowBackward

# 反向传播
loss.backward()
print("w.grad:", w.grad)  # 自动计算出的梯度
print("b.grad:", b.grad)
print("x.grad:", x.grad)

4.4 梯度累积与反向传播机制:深入 backward() 内部

PyTorch 的反向传播机制基于链式法则的递归调用。当你调用 loss.backward() 时,PyTorch 会从 loss 张量出发,沿着计算图反向遍历。每个节点的 grad_fn 会计算当前梯度对输入的贡献,并将梯度累加到前驱节点的 .grad 属性中(如果前驱节点是叶子张量且 requires_grad=True)。

这里有一个关键特性:梯度默认是累加的。也就是说,如果你在一个循环中多次调用 backward(),梯度会在 .grad 中不断累加,而不是覆盖。这是为了支持梯度累积(Gradient Accumulation)技术——当显存不足以容纳较大 batch size 时,可以将一个大的 batch 拆分成多个小的 mini-batch,分别计算梯度并累加,最后统一更新参数。

常见陷阱:忘记调用 optimizer.zero_grad() 是初学者最常犯的错误。如果不清零梯度,每次迭代的梯度都会累加,导致参数更新方向严重偏离,模型无法收敛。正确的训练循环应该是:optimizer.zero_grad() → loss.backward() → optimizer.step()。

梯度累积的典型实现方式如下:

# 梯度累积示例:模拟 batch_size=8,实际每次处理 2 个样本
accumulation_steps = 4
optimizer.zero_grad()
for i, (inputs, labels) in enumerate(dataloader):
    outputs = model(inputs)
    loss = criterion(outputs, labels)
    loss = loss / accumulation_steps  # 归一化损失
    loss.backward()
    if (i + 1) % accumulation_steps == 0:
        optimizer.step()
        optimizer.zero_grad()

4.5 案例拆解:手动实现一个线性回归的梯度计算

为了更直观地理解自动求导的底层逻辑,我们通过一个简单的线性回归案例,对比“手动推导梯度”与“PyTorch 自动求导”两种方式。

场景设定:假设我们有 100 个样本点,特征维度为 1,目标值 y = 2x + 1 + ε(ε 为高斯噪声)。我们希望训练一个线性模型 y_pred = wx + b,使用均方误差损失。

关键输入数据:

  • 特征 x:在 [-1, 1] 区间均匀采样。
  • 真实权重 w_true = 2.0,b_true = 1.0。
  • 学习率 lr = 0.1,迭代 100 轮。

分析判断过程:手动推导梯度时,我们需要计算 ∂L/∂w = Σ(2*(y_pred - y)*x)/N 和 ∂L/∂b = Σ(2*(y_pred - y))/N。而使用 PyTorch,只需定义模型和损失函数,调用 backward() 即可。

实际操作步骤:

import torch
import torch.nn as nn

# 生成数据
torch.manual_seed(42)
x = torch.linspace(-1, 1, 100).reshape(-1, 1)
y = 2 * x + 1 + 0.1 * torch.randn(x.size())

# 定义模型
model = nn.Linear(1, 1)
criterion = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)

# 训练循环
for epoch in range(100):
    model.train()
    y_pred = model(x)
    loss = criterion(y_pred, y)
    
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    
    if (epoch+1) % 20 == 0:
        print(f'Epoch {epoch+1}, Loss: {loss.item():.4f}')

print(f"最终权重: {model.weight.item():.4f}, 偏置: {model.bias.item():.4f}")

结果对比:经过 100 轮迭代,模型学到的权重约为 2.0,偏置约为 1.0,与真实值高度吻合。整个过程中,我们无需手动编写任何梯度公式,PyTorch 的 autograd 引擎自动完成了所有反向传播计算。

风险与代价:自动求导虽然方便,但会占用额外的显存来保存计算图和中间变量。对于超大规模模型,需要谨慎管理计算图的释放时机(如使用 del 或 torch.cuda.empty_cache())。

复盘经验:理解 requires_grad 和 grad_fn 的底层行为,能帮助你在模型调试时快速判断“为什么这个张量没有梯度”或“为什么梯度值异常”。例如,如果某个参数在训练中始终不更新,检查它是否被错误地设置了 requires_grad=False,或者是否被 torch.no_grad() 包裹。

4.6 常见误区与总结复盘

误区 正确理解 后果
认为所有张量默认需要梯度 只有 requires_grad=True 的张量才会被追踪 浪费内存,计算图过大
在 no_grad 模式下调用 backward() 该模式下不会构建计算图,调用 backward() 会报错 程序崩溃
多次调用 backward() 而不清零梯度 梯度会累加,导致参数更新异常 模型无法收敛
试图修改 requires_grad 为 False 的张量的数据 需要先调用 detach() 分离出新的张量 运行时错误