BotOf TechAI / IoT / Full-Stack / 植物养护知识分享
W0101 / 夯实底层理论与架构

矩阵、概率与反向传播

从张量形状、链式法则和数值稳定性出发,亲手验证梯度如何从 Loss 回到模型参数。

建议时长
7–9 小时
难度
基础强化
课程位置
1 / 12
LEARNING OBJECTIVES

学完这一课,你应该能:

  • 能在纸上推导两层网络的前向和反向传播
  • 能解释框架为什么计算的是向量–Jacobian 乘积
  • 能用有限差分检查自定义算子的梯度
  • 能定位 softmax、交叉熵和混合精度中的数值问题

1. 先把神经网络看成一串带形状的函数

设批次为 (B),输入维度为 (D),隐藏层宽度为 (H),类别数为 (C):

X   [B, D]
W1  [D, H]    b1 [H]
W2  [H, C]    b2 [C]

Z1 = X @ W1 + b1
H1 = ReLU(Z1)
Z2 = H1 @ W2 + b2
P  = softmax(Z2)
L  = cross_entropy(P, y)

先写形状,再写公式。X @ W1 的内部维度 (D) 相等,输出是 [B,H]b1 [H] 沿批次维广播。很多训练 Bug 不是算法错误,而是某个广播“恰好能运行”,语义却错了。

形状检查原则:每次矩阵乘法都写出输入、收缩维和输出;每次广播都说明复制到了哪些轴。

2. 反向传播就是链式法则的工程化

若标量损失 (L) 依赖向量 (y),而 (y) 依赖向量 (x):

∂L/∂x = (∂y/∂x)ᵀ · ∂L/∂y

(\partial y/\partial x) 是 Jacobian。深度网络的 Jacobian 可能巨大,框架不会完整构造它,而是从上游梯度出发逐节点计算 vector–Jacobian product(VJP)

对线性层 (Z=XW),设 (G=\partial L/\partial Z):

∂L/∂X = G @ Wᵀ       [B,H] @ [H,D] -> [B,D]
∂L/∂W = Xᵀ @ G       [D,B] @ [B,H] -> [D,H]

这两行是以后理解 Attention、LoRA 与 MoE 反向传播的基础。若 y=f(x)+g(x),梯度通过两条路径返回并相加。残差连接 y=x+block(x) 因而保留一条恒等梯度路径。

3. Softmax 与交叉熵要一起推导

pᵢ = exp(zᵢ) / Σⱼ exp(zⱼ)

直接计算 exp(z) 可能溢出。softmax 对所有 logits 同减一个常数不敏感:

import numpy as np

def stable_softmax(x):
    shifted = x - x.max(axis=-1, keepdims=True)
    exp_x = np.exp(shifted)
    return exp_x / exp_x.sum(axis=-1, keepdims=True)

单样本交叉熵 (L=-\log p_y)。把 softmax 与交叉熵合并求导:

∂L/∂z = p - one_hot(y)

所以分类模型 logits 的梯度是“预测概率减真实标签”。框架把 log-softmax 与 NLL 合并,不只是 API 方便,也是为避免概率接近 0 时再取对数的数值问题。

4. 用有限差分验证梯度

中心差分:

df/dx ≈ [f(x+ε) - f(x-ε)] / (2ε)
import torch

torch.manual_seed(17)
x = torch.randn(4, dtype=torch.float64, requires_grad=True)
w = torch.randn(4, dtype=torch.float64)

def loss_fn(value):
    return torch.tanh(value * w).sum()

loss_fn(x).backward()
autograd_grad = x.grad.detach().clone()

epsilon = 1e-6
numeric_grad = torch.zeros_like(x)
with torch.no_grad():
    for i in range(x.numel()):
        plus, minus = x.detach().clone(), x.detach().clone()
        plus[i] += epsilon
        minus[i] -= epsilon
        numeric_grad[i] = (loss_fn(plus) - loss_fn(minus)) / (2 * epsilon)

print("max diff:", (autograd_grad - numeric_grad).abs().max().item())

梯度检查优先用 float64。也不要认为 (\epsilon) 越小越好:太大时差分近似误差大,太小时两个函数值在浮点表示中过于接近,消减误差增大。扫描 1e-21e-10,误差通常呈 U 型。

5. Autograd 中最容易误解的四件事

梯度默认累积

.backward() 把梯度加到 .grad,不是覆盖:

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

梯度累积会故意多次 backward 再 step;通常要把每个 micro-batch 的 loss 除以累积步数。

detach() 切断计算图

tensor.detach() 与原张量共享存储,但不再追踪梯度。它适合记录指标、固定 target 或截断循环状态;误用会让某段网络永远收不到梯度。

In-place 操作会改写中间量

若 backward 需要前向旧值,而你执行 x += ...,框架可能报版本错误。自定义模块先使用非 in-place 版本,再优化性能。

model.eval() 不等于关闭梯度

eval() 控制 Dropout、BatchNorm;no_grad()inference_mode() 控制 Autograd。评测通常两者都需要。

6. 梯度爆炸、消失与混合精度

局部 Jacobian 的谱范数长期大于 1,梯度可能爆炸;长期小于 1,则可能消失。不要只看 Loss:

total_norm = torch.nn.utils.clip_grad_norm_(
    model.parameters(), max_norm=float("inf")
)
tracker.log({
    "loss": loss.item(),
    "grad_norm": total_norm.item(),
    "lr": optimizer.param_groups[0]["lr"],
})

FP16 中,小梯度可能下溢为 0。Loss scaling 先放大 loss 和梯度,更新前再反缩放。出现 NaN 时:

  1. 找到第一个非有限的 activation 或 gradient。
  2. 检查学习率、输入范围、除零、log(0) 与 mask。
  3. 确认在反缩放后做梯度裁剪。
  4. 比较 BF16 与 FP16 的指数范围和精度。

7. 本课实验:两层网络梯度审计

实现 [8,4] -> [8,6] -> [8,3] 分类器:

证据验收条件
形状表列出每个前向张量与梯度张量
手推梯度至少推导 W2 与 W1
数值检查float64 最大绝对误差小于 1e-5
故障实验去掉稳定 softmax,构造溢出
训练曲线同时记录 loss、grad norm、parameter norm

第一版不要调用 nn.Linear,先用裸参数与矩阵乘法完成,再与 nn.Linear 对齐。

8. 课后练习与答案提示

  1. y=x*x+x 的梯度?**提示:**两条路径相加,得到 2x+1
  2. 为什么减去 max(logits) 不改变 softmax?**提示:**分子分母乘了相同常数。
  3. 为什么交叉熵接收 logits?**提示:**可合并 log-softmax 与 NLL,数值更稳定。
  4. 累积 4 步不除 loss 会怎样?**提示:**梯度约放大 4 倍,等效学习率改变。
  5. 为什么 (\epsilon) 不是越小越好?**提示:**截断误差与浮点消减误差同时存在。

9. 延伸阅读