跳到内容

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

数学塔的链式法则终于接上模型工坊的生产线。一次 forward 可能有百万个中间值;若对每个参数分别从头求导,计算会大量重复。Backpropagation 保存局部依赖,再从 scalar loss 反向复用结果。

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

本课目标

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

1. 计算图记录依赖关系

标量网络:

$$ z_1=w_1x+b_1,quad a_1=\operatorname{ReLU}(z_1), $$

$$ z_2=w_2a_1+b_2,quad L=\frac12(z_2-y)^2. $$

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

$$ \bar z_2=z_2-y,qquad \bar w_2=\bar z_2a_1,qquad \bar b_2=\bar z_2, $$

$$ \bar a_1=\bar z_2w_2,qquad \bar z_1=\bar a_1\mathbf1[z_1>0], $$

$$ \bar w_1=\bar z_1x,qquad \bar b_1=\bar 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=x^2,qquad v=3x,qquad L=u+v, $$

则 $x$ 有两条下游路径:

$$ \frac{dL}{dx} =\frac{dL}{du}\frac{du}{dx} +\frac{dL}{dv}\frac{dv}{dx} =2x+3. $$

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

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

4. Matrix Layer 的反向形状

设 batch forward:

$$ Z=XW^T+b,qquad G=\frac{\partial L}{\partial Z}. $$

则:

$$ \frac{\partial L}{\partial X}=GW,qquad \frac{\partial L}{\partial W}=G^TX,qquad \frac{\partial L}{\partial b}=\sum_{i=1}^{B}G_i. $$

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
import torch

torch.set_default_dtype(torch.float64)

x = torch.tensor(2.0)
y = torch.tensor(1.0)
w1 = torch.tensor(0.5, requires_grad=True)
b1 = torch.tensor(0.1, requires_grad=True)
w2 = torch.tensor(-1.2, requires_grad=True)
b2 = torch.tensor(0.3, 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(),
})

只有 requires_grad=True 且参与 graph 的 tensors 才被追踪。Parameter 通常是 leaf tensor,其 gradient 累加到 .grad。中间 tensor 默认不一定保留 .grad;调试时可 retain_grad(),但会增加内存。

6. .grad 默认累加

连续调用 backward:

python
loss1.backward()
loss2.backward()

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

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:detachno_gradinference_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、适当 $\epsilon$,并避开 kink。

10. Finite Difference Gradient Check

对 scalar parameter $\theta$:

$$ g_{num}=\frac{L(\theta+\epsilon)-L(\theta-\epsilon)}{2\epsilon}. $$

相对误差可写为:

$$ \frac{|g_{ana}-g_{num}|} {\max(1,|g_{ana}|,|g_{num}|)}. $$

检查小模型/少量参数,固定随机性,关闭 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 乘积:

$$ \frac{\partial L}{\partial h_0} =J_1^TJ_2^T\cdots J_L^T \frac{\partial L}{\partial h_L}. $$

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

ReLU 去掉正区的 sigmoid saturation,却没有约束权重 Jacobian。Residual connection 给梯度增加 identity-like 路径,normalization 和初始化控制尺度,gradient clipping 限制爆炸的 update;它们各解决不同部分。

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=x^2+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