跳到内容

第 13 卷 / 第 9 章 / 第 2 课

9.2 计算图、反向传播与 Autograd:梯度在分支处相加,在链路上相乘 ​

一次 forward 可能产生百万个中间值。若对每个参数分别从头求导,计算会大量重复;backpropagation 保存局部依赖,再从 scalar loss 反向复用结果。

它不是新的微积分定理,而是 reverse-mode differentiation 在有向无环计算图上的执行算法。

本课目标 ​

  • 把 forward 表达成计算图与局部 Jacobian;
  • 手算一个两层标量网络的反向过程;
  • 解释共享/分支节点为什么累加梯度;
  • 正确使用 PyTorch .backward()、.grad 与 graph control;
  • 用 finite difference/gradcheck 验证自定义梯度。

1. 计算图记录依赖关系 ​

标量网络:

z1=w1x+b1,a1=ReLU(z1),z2=w2a1+b2,L=12(z2−y)2.

Forward 按拓扑顺序算值。Reverse 从 L¯=∂L/∂L=1 出发,把上游 adjoint 乘局部导数:

z¯2=z2−y,w¯2=z¯2a1,b¯2=z¯2,a¯1=z¯2w2,z¯1=a¯11[z1>0],w¯1=z¯1x,b¯1=z¯1.

横线记号只是“最终 loss 对这个中间值的导数”。每条边做 vector–Jacobian product,不必显式构造完整巨大 Jacobian。

2. Reverse Mode 为什么适合训练 ​

网络通常有:

  • 一个 scalar/小维 loss;
  • 数百万到数十亿 parameters。

Reverse mode 一次反向可得到 scalar loss 对所有上游 parameters 的梯度,成本通常与若干次 forward 同阶。Forward mode 更适合输入方向少、输出多的 Jacobian-vector 场景;现代框架也支持组合二者处理高阶导数/Jacobian。

“Backward 只算每个中间梯度一次”需要限定:共享节点会接收多个下游贡献,框架必须先累加这些贡献,再继续向上游传播。

3. 分支处为什么相加 ​

若:

u=x2,v=3x,L=u+v,

则 x 有两条下游路径:

dLdx=dLdududx+dLdvdvdx=2x+3.

Residual connection、parameter sharing、同一 embedding 多次使用都依赖这条规则。若手写 backward 忘记累加,会得到形状正确但数值错误的梯度。

广播也会影响反向:bias 在 batch 维被广播,bias gradient 就要沿被广播维求和。

4. Matrix Layer 的反向形状 ​

设 batch forward:

Z=XWT+b,G=∂L∂Z.

则:

∂L∂X=GW,∂L∂W=GTX,∂L∂b=∑i=1BGi.

Shape check:

text
G: [B, out]
W: [out, in]
dX = G @ W: [B, in]
dW = G.T @ X: [out, in]
db = G.sum(dim=0): [out]

Shape 对只是必要条件;transpose 放错仍可能在方阵中静默通过,所以还要 numerical gradient check。

5. 用 PyTorch 核对手算梯度 ​

下面是可直接运行的 Python 3 / PyTorch 例子,逐个指定 float64,不修改进程的默认 dtype:

python
import torch

x = torch.tensor(2.0, dtype=torch.float64)
y = torch.tensor(1.0, dtype=torch.float64)
w1 = torch.tensor(0.5, dtype=torch.float64, requires_grad=True)
b1 = torch.tensor(0.1, dtype=torch.float64, requires_grad=True)
w2 = torch.tensor(-1.2, dtype=torch.float64, requires_grad=True)
b2 = torch.tensor(0.3, dtype=torch.float64, requires_grad=True)

z1 = w1 * x + b1
a1 = torch.relu(z1)
z2 = w2 * a1 + b2
loss = 0.5 * (z2 - y) ** 2
loss.backward()

print({
    "loss": loss.item(),
    "dw1": w1.grad.item(),
    "db1": b1.grad.item(),
    "dw2": w2.grad.item(),
    "db2": b2.grad.item(),
})

预期约为 loss=2.0402、dw1=4.848、db1=2.424、dw2=-2.222、db2=-2.02。普通 grad mode 下,只要至少一个输入需要梯度,运算就会被记录。非叶结果也在计算图中,但默认只给需要梯度的叶 tensor 填 .grad;查看中间结果需调用 retain_grad()。

6. .grad 默认累加 ​

接续 torch 导入,下面两个 loss 的图相互独立,但共享同一个叶 tensor:

python
x = torch.tensor(2.0, requires_grad=True)
loss1 = x ** 2
loss2 = 3 * x
loss1.backward()
loss2.backward()
assert x.grad.item() == 7.0

parameter .grad 会累加两次贡献。这对 gradient accumulation 有用,也意味着普通训练每次更新前要清空:

下面训练片段需要 optimizer 与重新 forward 得到的 loss,不能直接复用上方已反传释放的 loss:

python
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()

set_to_none=True 与填零在内存/optimizer 行为上可能有细微差异,使用自定义优化逻辑时要检查 grad is None。

默认 graph 在 backward 后释放部分 saved tensors;要对同一 graph 再 backward 可能需要 retain_graph=True,但反复依赖它常提示 graph 生命周期或 loss 组织有问题。

7. Graph Control:detach、no_grad、inference_mode ​

  • tensor.detach():返回与原 storage 关联但不追踪当前 history 的 tensor;in-place 修改仍需小心。
  • torch.no_grad():上下文内不记录需要反向的运算,常用于 validation/inference。
  • torch.inference_mode():更强的推理优化与限制;生成的 tensors 后续参与 autograd 有额外约束。

错误 detach 会截断本该训练的路径;忘记禁用 gradient 会在验证时浪费内存。不要用 .data 绕过 autograd 的版本检查。

8. In-place Operation 与 Saved Tensor ​

Backward 可能需要 forward 保存的输入/输出。若在原地改写这些 tensors,框架可能报 version mismatch;更危险的自定义代码可能静默破坏数学语义。

ReLU inplace=True、参数原地更新、view/shared storage 要在内存收益与可验证性间权衡。初学/调试阶段优先 out-of-place 表达。

9. 不可导点与数值精度 ​

ReLU 在 0、absolute value 在 0、max ties 处不可唯一求导。Framework 采用某种 subgradient/约定,因此有限差分在这些点可能与 autograd 不一致。

Float32 finite difference 还会受 cancellation/rounding 影响;gradient check 通常使用 float64、适当 ϵ,并避开 kink。

10. Finite Difference Gradient Check ​

对 scalar parameter θ:

gnum=L(θ+ϵ)−L(θ−ϵ)2ϵ.

相对误差可写为:

|gana−gnum|max(1,|gana|,|gnum|).

检查小模型/少量参数,固定随机性,关闭 dropout,避免 batch statistics 变化。PyTorch torch.autograd.gradcheck 会用 numerical approximation 检查自定义 function,输入通常需 double precision 且 requires_grad=True。

Gradient check 通过不能证明 forward 目标正确;forward 和 backward 可能一致地实现了错误公式,所以还需要 reference cases 和 invariants。

11. Vanishing 与 Exploding Gradient ​

深层链式法则包含 Jacobian 乘积:

∂L∂h0=J1TJ2T⋯JLT∂L∂hL.

若典型 singular values 长期小于 1,梯度衰减;大于 1,梯度放大。影响因素包括权重初始化、activation derivative、depth、normalization、residual path 和数据尺度。

ReLU 去掉正区的 sigmoid saturation,却没有约束权重 Jacobian。Residual connection 给梯度增加 identity-like 路径,normalization 和初始化控制尺度,gradient clipping 限制梯度范数;自适应优化器与 weight decay 下,更新幅度还需另查;它们各解决不同部分。

12. 调试梯度流 ​

记录而不是猜:

  • 每层 parameter/gradient norm;
  • activation mean/std、zero/saturation fraction;
  • loss 是否 finite;
  • update-to-weight ratio;
  • NaN/Inf 首次出现的 operation;
  • train/eval mode 与 dtype/device。

可用 hooks、anomaly detection 和 profiler 定位,但 hooks 会改变性能/graph 生命周期。先在极小 batch 上 overfit、检查 loss 是否下降,再扩展训练。

常见误区 ​

  • Backprop 是把错误平均分给参数:它计算 loss 对每个参数的偏导/VJP。
  • 共享节点梯度只算一次就结束:多个下游路径贡献必须相加。
  • 调用两次 backward 会覆盖 .grad:默认累加。
  • Autograd 通过就说明模型正确:只能说明框架按 graph 求了导。
  • ReLU 后没有梯度问题:Jacobian 链仍可能消失或爆炸。

练习 ​

  1. 手算标量网络所有 forward values 与 gradients,再与 PyTorch 比较。
  2. 为 L=x2+3x 画分支图,观察 x.grad 的累加。
  3. 推导 batch affine layer 的 dX/dW/db 并核对 shape。
  4. 故意不清 .grad 做两次 backward,解释结果。
  5. 对自定义 activation 做 float64 central-difference check。

小结 ​

Backpropagation 从 scalar loss 逆拓扑遍历计算图:链路贡献相乘,分支贡献相加。Autograd 自动记录与执行这些局部规则,但 graph 生命周期、梯度累加、不可导点和数值精度仍需开发者理解。

下一课把正确梯度变成稳定训练:初始化控制信号尺度,optimizer 决定 update,normalization 与 regularization 约束训练和泛化。

Built with VitePress | Software Systems Atlas