一个标量误差怎样找到所有参数?计算图、链式法则与反向传播
从有限差分的高成本出发,手算微型计算图的反向传播,解释向量—雅可比积、梯度累加与 PyTorch 2.13 autograd 调试。
上一篇用两个 ReLU 隐单元手工分开了 XOR,说明激活函数能让多层网络学非线性表示。但手写那组权重只适用于四个点;真实网络可能有上亿个参数,我们只能观察到最后一个损失标量。
反向传播(Backpropagation)解决的核心问题是:怎样从这一个数出发,高效计算它对每个参数的偏导数? 本文将前向计算展开成计算图(Computational Graph),只围绕局部导数、反向模式自动微分和可检查的 PyTorch autograd 这条主线。
01 为每个参数单独试一次为什么太贵?#
有限差分(Finite Difference)可以用轻微扰动近似一个参数的梯度:
若模型有 个参数,中心差分需要约 次前向计算。 时显然无法每个 mini-batch 都这样做;而 太大会有截断误差,太小又会被浮点舍入差污染。
有限差分适合当小规模“验算器”,不适合当训练引擎。反向传播则复用一次前向中已经计算的中间量,以与前向同一数量级的代价求出所有参数梯度。
02 先把一条公式拆成计算图#
考虑一个标量神经元:
不把它当作一条长公式,而是记录每次操作:
x ─┐
├─ multiply ─► m=wx ─┐
w ─┘ ├─ add ─► z ─► ReLU ─► a ─┐
b ──────────────────────┘ ├─ subtract ─► e ─► square/2 ─► L
y ────────────────────────────────────────────────┘
前向:从左到右算数值,保存反向所需中间量
反向:从 L 到参数,沿边传递“上游梯度 × 局部导数”text这是一张有向无环图(Directed Acyclic Graph,DAG):节点是张量或操作,边表示“这个结果依赖那个输入”。同一参数可以通过多条路径影响损失,反向时必须把这些路径的贡献相加。
03 用五个数手算一次完整反传#
取:
前向传播(Forward Pass):
反向从 开始:
因为 ,ReLU 当前分支的局部导数是 1:
加法节点把上游梯度原样分发给 和 :
乘法节点使用另一个输入作为局部导数:
若学习率 ,梯度下降只更新参数 :
的梯度有用于继续传向更早层,但输入数据本身通常不由优化器更新。反向传播计算梯度,梯度下降或 Adam 使用梯度更新参数;两者不是同一个算法。
04 链式法则为什么能局部化?#
若 ,链式法则(Chain Rule)给出:
每个操作只需知道自己的局部导数,不需要理解整个网络。从右到左计算时,一个下游结果的梯度只算一次,然后被所有上游路径复用;这就是动态规划式的效率来源。
当一个量同时走向两条支路,梯度要相加。例如 :
2w 来自平方分支,3 来自线性分支。若实现只保留最后到达的一条路径,共享参数、残差连接和循环展开结构都会得到错误梯度。
05 张量情况下传的不是整张雅可比矩阵#
对两层网络:
形状为:
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)。
对“大量参数 一个标量损失”的训练问题,一次反向模式正好得到全部参数梯度。如果输出不是标量,backward(gradient=v) 计算的也是给定 的 VJP,并非默认构造整张 Jacobian。
06 反向传播的完整伪代码#
# 前向:计算并保存反向需要的中间量
按拓扑顺序遍历操作:
output = op.forward(inputs)
记录 op、inputs 和必需中间量
# 标量损失的种子梯度
grad[loss] = 1
# 反向:逆拓扑顺序
对每个 op:
input_grads = op.backward(grad[op.output], saved_values)
对每个 input:
grad[input] += input_grads[input] # 多路径必须累加
# 优化是独立步骤
对每个可训练参数 parameter:
parameter -= learning_rate * grad[parameter]text保存中间量会占内存;不保存就需要反向时重算。激活检查点(Activation Checkpointing)正是在二者之间换取:用更多计算节省激活内存。
07 不依赖 autograd,手写标量反向#
def relu(value):
return max(0.0, value)
x, w, b, y = 2.0, -1.0, 3.0, 0.0
# forward
m = w * x
z = m + b
a = relu(z)
loss = 0.5 * (a - y) ** 2
# backward
grad_a = a - y
grad_z = grad_a * (1.0 if z > 0.0 else 0.0)
grad_m = grad_z
grad_b = grad_z
grad_w = grad_m * x
grad_x = grad_m * w
assert loss == 0.5
assert grad_w == 2.0
assert grad_b == 1.0
assert grad_x == -1.0pythonReLU 在 不可导。库会选定一个次梯度(Subgradient)约定;在 PyTorch 中该点的 ReLU 梯度为 0。这不影响几乎处处的求导,却会让恰好落在 0 的有限差分检查变得含糊;检查点应避开不光滑拐点。
08 PyTorch 2.13 autograd 究竟记录了什么?#
根据当前稳定版 torch.autograd ↗、Autograd Mechanics ↗ 与官方教程,PyTorch 在执行张量操作时动态创建 DAG,通过 grad_fn 保留反向入口;每轮前向都会重建图,因而普通 Python 分支和循环可以改变实际执行路径。
import torch
x = torch.tensor(2.0) # 数据,不需存参数梯度
w = torch.tensor(-1.0, requires_grad=True) # 叶子张量
b = torch.tensor(3.0, requires_grad=True) # 叶子张量
y = torch.tensor(0.0)
z = w * x + b # 非叶子,有 grad_fn
a = torch.relu(z)
loss = 0.5 * (a - y).square() # 标量 []
print(loss.shape, loss.grad_fn)
loss.backward()
print(w.grad, b.grad) # tensor(2.), tensor(1.)
assert torch.allclose(w.grad, torch.tensor(2.0))
assert torch.allclose(b.grad, torch.tensor(1.0))
assert z.grad is None # 非叶子默认不保留 .gradpython关键语义:
requires_grad=True要求跟踪对该叶子张量有影响的运算;loss.backward()从标量损失播种子梯度 1,并把结果累加到叶子的.grad;- 非叶子中间张量默认不保留
.grad,调试时可在前向后、反向前调用retain_grad(); detach()返回与当前图断开的张量,不是“复制一份但仍传梯度”;- 一次
backward()后中间结果通常被释放;不应为了绕开错误而默认加retain_graph=True。
09 从两层网络看一次真实训练步#
import torch
from torch import nn
model = nn.Sequential(
nn.Linear(2, 4),
nn.ReLU(),
nn.Linear(4, 1),
)
loss_fn = nn.BCEWithLogitsLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=3e-3)
# X: [N,2] float32;y: [N] float32,元素为 0 或 1
optimizer.zero_grad(set_to_none=True)
hidden_pre = model[0](X) # [N,4]
hidden_pre.retain_grad() # 仅用于本次调试
hidden = model[1](hidden_pre) # [N,4]
logits = model[2](hidden).squeeze(-1) # [N]
loss = loss_fn(logits, y) # []
loss.backward() # 只计算/累加梯度
for name, parameter in model.named_parameters():
assert parameter.grad is not None, f'{name} 断开了计算图'
assert torch.isfinite(parameter.grad).all(), f'{name} 梯度非有限'
print(name, tuple(parameter.shape), parameter.grad.norm().item())
print('hidden_pre grad shape:', hidden_pre.grad.shape) # [N,4]
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
optimizer.step() # 使用已计算的梯度更新pythonclip_grad_norm_ 是一个需要根据任务验证的保护阀,不是梯度爆炸的根治。若频繁触发裁剪,应继续检查学习率、初始化、输入尺度、损失 reduction 和发散的第一层。
10 怎样证明 autograd 没有帮你计算“正确的错误”?#
autograd 只保证对实际执行的可微运算求导。如果标签错位、损失定义错、广播扩张了张量或数据泄漏,它仍可以给出数学上一致的梯度。调试应分层:
- 语义层: 手算一个样本,检查损失和梯度方向是否符合任务。
- 形状层: 为每个主要张量写出轴语义与断言,尤其是 batch、class 和 sequence 维。
- 数值层: 找第一个非有限的前向值或反向梯度,不要只在最后看 NaN loss。
- 导数层: 用 double 精度和极小输入做有限差分或
gradcheck。 - 优化层: 过拟合一个小 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,))pythongradcheck 默认按 double 精度调校;不光滑点、随机操作、低精度和共享存储的重叠张量都可能让数值检查失败,应先理解前提,不要随意放宽容差。
11 最常见的断图、累加与内存错误#
- 忘记清梯度。 连续两次
backward()会把新梯度加到.grad,不是覆盖。 - 意外
detach()、.item()或转 NumPy。 从图中拿出 Python 数值/数组后再组装损失,梯度路径已断。 - 在训练前向中使用
no_grad()或inference_mode()。 这些上下文用于不需要反传的评估或推理。 - 对反向需要的张量就地改写。 autograd 会检查版本并可能报错;即使某次不报错,也不应把
inplace=True当默认优化。 - 对同一张图二次反传。 如果是新训练步,应重做前向;只在算法真的需要复用同一张图时才考虑
retain_graph=True。 - 用输出张量直接
backward()却没给上游向量。 非标量输出需要同形gradient,或先通过合理的sum/mean得到标量目标。 - 用损失后置 mask 掩盖无效前向。 例如先除以 0 产生 Inf,再在损失前排除该元素,无效操作仍已进入图,反向可产生 NaN;应在危险操作之前做 mask。
对难定位的 NaN,可短时启用 torch.autograd.detect_anomaly() 获取导致错误反向函数的前向追踪;它会明显变慢,不应默认常驻生产训练。
12 梯度消失与爆炸从哪里来?#
链式法则会沿深度连乘局部 Jacobian。若典型奇异值长期小于 1,早层梯度会越传越小;若长期大于 1,则会迅速放大。Sigmoid/tanh 的饱和区局部导数很小,ReLU 负区导数为 0,权重尺度又决定线性变换如何放大向量。
有效调试不是只看全局梯度范数,而是沿层记录:
- 参数范数 与梯度范数 ;
- 更新比 ;
- 激活的均值、标准差、零值比例与极值;
- 第一个出现 NaN/Inf 或突变的层;
- 裁剪前后的全局梯度范数和触发频率。
初始化、归一化、残差连接和优化器都会改变梯度流。本篇先建立反传观测方法,不把这些后续主题塞进一篇。
13 与相近求导方法的区别#
| 方法 | 主要代价 | 精度 | 适合场景 |
|---|---|---|---|
| 符号微分 | 表达式可急剧膨胀 | 解析 | 小型闭式公式、数学化简 |
| 有限差分 | 每个参数额外前向 | 近似 | 小规模梯度验算 |
| 前向模式 AD | 约随输入方向数增长 | 机器精度 | 少量输入、大量输出 |
| 反向模式 AD | 约随标量输出数增长 | 机器精度 | 大量参数、少量损失 |
| 反向传播 | 反向模式 AD 在层级网络中的高效应用 | 机器精度 | 神经网络训练 |
反向传播也有明确边界:离散的 argmax、数据库查询或外部黑箱不会自动变得可微;长计算图需要激活内存;正确梯度也不保证非凸目标找到全局最优或模型能泛化。
14 今天真正需要记住什么?#
- 计算图把长公式拆成局部操作;反传按逆拓扑顺序传递“上游梯度 × 局部导数”。
- 同一量经多条路径影响损失时,各路径梯度必须相加。
- 反向模式通过 VJP 避免显式构建巨大 Jacobian,非常适合“多参数到标量损失”。
- PyTorch 动态记录实际执行的图,
.backward()把梯度累加到叶子.grad;清梯度和更新参数是独立步骤。 - autograd 会忠实地对错误程序求导;手算方向、形状断言、有限差分、分层梯度和小 batch 过拟合缺一不可。
15 思考题与小练习#
- 把手算例中的 改为 1,重算前向与所有梯度。为什么 和 都不再更新?这与上一篇的死亡 ReLU 有什么关系?
- 对 画出有分支和合流的计算图,用 手算每个节点的反向值,再与直接求导对照。
- 在两层网络中故意删掉
zero_grad,连续两次对同一 batch 重做前向和反向,比较每个参数的.grad。再用set_to_none=True修复并验证。
相关工作#
- Linnainmaa (1970), The Representation of the Cumulative Rounding Error of an Algorithm as a Taylor Expansion of the Local Rounding Errors ↗:反向累积模式的早期系统表述与芬兰原始学位论文记录。
- Werbos (1974), Beyond Regression: New Tools for Prediction and Analysis in the Behavioral Sciences ↗:将反向动态观点用于学习系统的早期博士论文。
- Rumelhart, Hinton & Williams (1986), Learning Representations by Back-propagating Errors ↗:使多层网络反向传播广为所知的经典工作。
- Baydin et al. (2018), Automatic Differentiation in Machine Learning: A Survey ↗:系统区分符号微分、数值微分与前向/反向模式 AD。
- Paszke et al. (2019), PyTorch: An Imperative Style, High-Performance Deep Learning Library ↗:PyTorch 动态张量计算与自动微分系统的代表性论文。
16 下一篇预告#
反向传播已经能把损失分配到每层参数,但网络变深后,连乘的 Jacobian 可能让信号和梯度逐层放大或衰减。下一篇将追踪初始化时的前向方差与反向梯度,解释 Xavier/He 初始化为什么要根据扇入和激活函数设计尺度。