观文听傑

返回

上一篇用两个 ReLU 隐单元手工分开了 XOR,说明激活函数能让多层网络学非线性表示。但手写那组权重只适用于四个点;真实网络可能有上亿个参数,我们只能观察到最后一个损失标量。

反向传播(Backpropagation)解决的核心问题是:怎样从这一个数出发,高效计算它对每个参数的偏导数? 本文将前向计算展开成计算图(Computational Graph),只围绕局部导数、反向模式自动微分和可检查的 PyTorch autograd 这条主线。

01 为每个参数单独试一次为什么太贵?#

有限差分(Finite Difference)可以用轻微扰动近似一个参数的梯度:

LθjL(θj+ε)L(θjε)2ε\frac{\partial L}{\partial \theta_j} \approx \frac{L(\theta_j+\varepsilon)-L(\theta_j-\varepsilon)}{2\varepsilon}

若模型有 PP 个参数,中心差分需要约 2P2P 次前向计算。P=108P=10^8 时显然无法每个 mini-batch 都这样做;而 ε\varepsilon 太大会有截断误差,太小又会被浮点舍入差污染。

有限差分适合当小规模“验算器”,不适合当训练引擎。反向传播则复用一次前向中已经计算的中间量,以与前向同一数量级的代价求出所有参数梯度。

02 先把一条公式拆成计算图#

考虑一个标量神经元:

z=wx+b,a=ReLU(z),L=12(ay)2z=wx+b,\qquad a=\operatorname{ReLU}(z),\qquad L=\frac12(a-y)^2

不把它当作一条长公式,而是记录每次操作:

x ─┐
   ├─ multiply ─► m=wx ─┐
w ─┘                    ├─ add ─► z ─► ReLU ─► a ─┐
b ──────────────────────┘                         ├─ subtract ─► e ─► square/2 ─► L
y ────────────────────────────────────────────────┘

前向:从左到右算数值,保存反向所需中间量
反向:从 L 到参数,沿边传递“上游梯度 × 局部导数”
text

这是一张有向无环图(Directed Acyclic Graph,DAG):节点是张量或操作,边表示“这个结果依赖那个输入”。同一参数可以通过多条路径影响损失,反向时必须把这些路径的贡献相加。

03 用五个数手算一次完整反传#

取:

x=2,w=1,b=3,y=0x=2,\quad w=-1,\quad b=3,\quad y=0

前向传播(Forward Pass):

m=wx=2m=wx=-2 z=m+b=1z=m+b=1 a=ReLU(1)=1a=\operatorname{ReLU}(1)=1 L=12(10)2=0.5L=\frac12(1-0)^2=0.5

反向从 L/L=1\partial L/\partial L=1 开始:

La=ay=1\frac{\partial L}{\partial a}=a-y=1

因为 z=1>0z=1>0,ReLU 当前分支的局部导数是 1:

Lz=Laaz=1×1=1\frac{\partial L}{\partial z} =\frac{\partial L}{\partial a} \frac{\partial a}{\partial z} =1\times1=1

加法节点把上游梯度原样分发给 mmbb

Lm=1,Lb=1\frac{\partial L}{\partial m}=1,\qquad \frac{\partial L}{\partial b}=1

乘法节点使用另一个输入作为局部导数:

Lw=Lmmw=1×x=2\frac{\partial L}{\partial w} =\frac{\partial L}{\partial m} \frac{\partial m}{\partial w} =1\times x=2 Lx=Lmmx=1×w=1\frac{\partial L}{\partial x} =\frac{\partial L}{\partial m} \frac{\partial m}{\partial x} =1\times w=-1

若学习率 η=0.1\eta=0.1,梯度下降只更新参数 w,bw,b

w10.1×2=1.2,b30.1×1=2.9w\leftarrow-1-0.1\times2=-1.2,\qquad b\leftarrow3-0.1\times1=2.9

xx 的梯度有用于继续传向更早层,但输入数据本身通常不由优化器更新。反向传播计算梯度,梯度下降或 Adam 使用梯度更新参数;两者不是同一个算法。

04 链式法则为什么能局部化?#

L=f(g(h(w)))L=f(g(h(w))),链式法则(Chain Rule)给出:

Lw=Lffgghhw\frac{\partial L}{\partial w} =\frac{\partial L}{\partial f} \frac{\partial f}{\partial g} \frac{\partial g}{\partial h} \frac{\partial h}{\partial w}

每个操作只需知道自己的局部导数,不需要理解整个网络。从右到左计算时,一个下游结果的梯度只算一次,然后被所有上游路径复用;这就是动态规划式的效率来源。

当一个量同时走向两条支路,梯度要相加。例如 L=w2+3wL=w^2+3w

Lw=2w+3\frac{\partial L}{\partial w}=2w+3

2w 来自平方分支,3 来自线性分支。若实现只保留最后到达的一条路径,共享参数、残差连接和循环展开结构都会得到错误梯度。

05 张量情况下传的不是整张雅可比矩阵#

对两层网络:

Z1=XW1+b1,H1=ϕ(Z1),Z2=H1W2+b2,L=(Z2,y)Z_1=XW_1+b_1,\quad H_1=\phi(Z_1),\quad Z_2=H_1W_2+b_2,\quad L=\ell(Z_2,y)

形状为:

X [N,D] ─► Z₁ [N,H] ─► H₁ [N,H] ─► Z₂ [N,C] ─► L []
             W₁ [D,H]                  W₂ [H,C]
             b₁ [H]                    b₂ [C]

反向:
dL/dZ₂ [N,C]
  ├─► dL/dW₂ = H₁ᵀ @ dL/dZ₂                 [H,C]
  ├─► dL/db₂ = sum_batch(dL/dZ₂)             [C]
  └─► dL/dH₁ = dL/dZ₂ @ W₂ᵀ                 [N,H]
          │ 逐元素乘 φ'(Z₁)

        dL/dZ₁ [N,H]
          ├─► dL/dW₁ = Xᵀ @ dL/dZ₁              [D,H]
          └─► dL/db₁ = sum_batch(dL/dZ₁)       [H]
text

一个向量输出对一个向量输入的全部导数是雅可比矩阵(Jacobian Matrix)。反向模式自动微分(Reverse-mode Automatic Differentiation)并不逐层显式存储巨大 Jacobian,而是把上游向量与局部 Jacobian 相乘,即向量—雅可比积(Vector-Jacobian Product,VJP)。

对“大量参数 \rightarrow 一个标量损失”的训练问题,一次反向模式正好得到全部参数梯度。如果输出不是标量,backward(gradient=v) 计算的也是给定 vv 的 VJP,并非默认构造整张 Jacobian。

06 反向传播的完整伪代码#

保存中间量会占内存;不保存就需要反向时重算。激活检查点(Activation Checkpointing)正是在二者之间换取:用更多计算节省激活内存。

07 不依赖 autograd,手写标量反向#

ReLU 在 z=0z=0 不可导。库会选定一个次梯度(Subgradient)约定;在 PyTorch 中该点的 ReLU 梯度为 0。这不影响几乎处处的求导,却会让恰好落在 0 的有限差分检查变得含糊;检查点应避开不光滑拐点。

08 PyTorch 2.13 autograd 究竟记录了什么?#

根据当前稳定版 torch.autogradAutograd Mechanics 与官方教程,PyTorch 在执行张量操作时动态创建 DAG,通过 grad_fn 保留反向入口;每轮前向都会重建图,因而普通 Python 分支和循环可以改变实际执行路径。

关键语义:

  • requires_grad=True 要求跟踪对该叶子张量有影响的运算;
  • loss.backward() 从标量损失播种子梯度 1,并把结果累加到叶子的 .grad
  • 非叶子中间张量默认不保留 .grad,调试时可在前向后、反向前调用 retain_grad()
  • detach() 返回与当前图断开的张量,不是“复制一份但仍传梯度”;
  • 一次 backward() 后中间结果通常被释放;不应为了绕开错误而默认加 retain_graph=True

09 从两层网络看一次真实训练步#

clip_grad_norm_ 是一个需要根据任务验证的保护阀,不是梯度爆炸的根治。若频繁触发裁剪,应继续检查学习率、初始化、输入尺度、损失 reduction 和发散的第一层。

10 怎样证明 autograd 没有帮你计算“正确的错误”?#

autograd 只保证对实际执行的可微运算求导。如果标签错位、损失定义错、广播扩张了张量或数据泄漏,它仍可以给出数学上一致的梯度。调试应分层:

  1. 语义层: 手算一个样本,检查损失和梯度方向是否符合任务。
  2. 形状层: 为每个主要张量写出轴语义与断言,尤其是 batch、class 和 sequence 维。
  3. 数值层: 找第一个非有限的前向值或反向梯度,不要只在最后看 NaN loss。
  4. 导数层: 用 double 精度和极小输入做有限差分或 gradcheck
  5. 优化层: 过拟合一个小 batch,确认梯度非零、参数真的变化、损失能显著降低。

自定义可微操作时,当前官方 API 提供 torch.autograd.gradcheck

import torch

def smooth_function(w):
    return torch.sin(w).mul(w.square()).sum()

w = torch.randn(4, dtype=torch.float64, requires_grad=True)
assert torch.autograd.gradcheck(smooth_function, (w,))
python

gradcheck 默认按 double 精度调校;不光滑点、随机操作、低精度和共享存储的重叠张量都可能让数值检查失败,应先理解前提,不要随意放宽容差。

11 最常见的断图、累加与内存错误#

  1. 忘记清梯度。 连续两次 backward() 会把新梯度加到 .grad,不是覆盖。
  2. 意外 detach().item() 或转 NumPy。 从图中拿出 Python 数值/数组后再组装损失,梯度路径已断。
  3. 在训练前向中使用 no_grad()inference_mode() 这些上下文用于不需要反传的评估或推理。
  4. 对反向需要的张量就地改写。 autograd 会检查版本并可能报错;即使某次不报错,也不应把 inplace=True 当默认优化。
  5. 对同一张图二次反传。 如果是新训练步,应重做前向;只在算法真的需要复用同一张图时才考虑 retain_graph=True
  6. 用输出张量直接 backward() 却没给上游向量。 非标量输出需要同形 gradient,或先通过合理的 sum/mean 得到标量目标。
  7. 用损失后置 mask 掩盖无效前向。 例如先除以 0 产生 Inf,再在损失前排除该元素,无效操作仍已进入图,反向可产生 NaN;应在危险操作之前做 mask。

对难定位的 NaN,可短时启用 torch.autograd.detect_anomaly() 获取导致错误反向函数的前向追踪;它会明显变慢,不应默认常驻生产训练。

12 梯度消失与爆炸从哪里来?#

链式法则会沿深度连乘局部 Jacobian。若典型奇异值长期小于 1,早层梯度会越传越小;若长期大于 1,则会迅速放大。Sigmoid/tanh 的饱和区局部导数很小,ReLU 负区导数为 0,权重尺度又决定线性变换如何放大向量。

有效调试不是只看全局梯度范数,而是沿层记录:

  • 参数范数 θl\|\theta_l\| 与梯度范数 gl\|g_l\|
  • 更新比 ηgl/(θl+ϵ)\eta\|g_l\|/(\|\theta_l\|+\epsilon)
  • 激活的均值、标准差、零值比例与极值;
  • 第一个出现 NaN/Inf 或突变的层;
  • 裁剪前后的全局梯度范数和触发频率。

初始化、归一化、残差连接和优化器都会改变梯度流。本篇先建立反传观测方法,不把这些后续主题塞进一篇。

13 与相近求导方法的区别#

方法主要代价精度适合场景
符号微分表达式可急剧膨胀解析小型闭式公式、数学化简
有限差分每个参数额外前向近似小规模梯度验算
前向模式 AD约随输入方向数增长机器精度少量输入、大量输出
反向模式 AD约随标量输出数增长机器精度大量参数、少量损失
反向传播反向模式 AD 在层级网络中的高效应用机器精度神经网络训练

反向传播也有明确边界:离散的 argmax、数据库查询或外部黑箱不会自动变得可微;长计算图需要激活内存;正确梯度也不保证非凸目标找到全局最优或模型能泛化。

14 今天真正需要记住什么?#

  1. 计算图把长公式拆成局部操作;反传按逆拓扑顺序传递“上游梯度 × 局部导数”。
  2. 同一量经多条路径影响损失时,各路径梯度必须相加。
  3. 反向模式通过 VJP 避免显式构建巨大 Jacobian,非常适合“多参数到标量损失”。
  4. PyTorch 动态记录实际执行的图,.backward() 把梯度累加到叶子 .grad;清梯度和更新参数是独立步骤。
  5. autograd 会忠实地对错误程序求导;手算方向、形状断言、有限差分、分层梯度和小 batch 过拟合缺一不可。

15 思考题与小练习#

  1. 把手算例中的 bb 改为 1,重算前向与所有梯度。为什么 wwbb 都不再更新?这与上一篇的死亡 ReLU 有什么关系?
  2. L=(w2+3w)2L=(w^2+3w)^2 画出有分支和合流的计算图,用 w=2w=2 手算每个节点的反向值,再与直接求导对照。
  3. 在两层网络中故意删掉 zero_grad,连续两次对同一 batch 重做前向和反向,比较每个参数的 .grad。再用 set_to_none=True 修复并验证。

相关工作#

16 下一篇预告#

反向传播已经能把损失分配到每层参数,但网络变深后,连乘的 Jacobian 可能让信号和梯度逐层放大或衰减。下一篇将追踪初始化时的前向方差与反向梯度,解释 Xavier/He 初始化为什么要根据扇入和激活函数设计尺度。

一个标量误差怎样找到所有参数?计算图、链式法则与反向传播
https://zwjcode.cn/blog/backpropagation-computational-graph-autograd
作者
发布于 2026年8月31日
版权协议 CC BY-NC-SA 4.0
评论加载似乎遇到了问题,请尝试刷新页面。