Skip to content

实战演练:不调 autograd,手写反向传播 ​

前两篇已经把链式法则、计算图、三层网络的手推都做完了。这一篇把那些公式变成能跑的代码:用 NumPy 从零实现一个 784→128→64→10 的 MLP,前向、softmax、交叉熵、反向传播、Adam 全部手写,不调用任何 autograd。

验收标准只有两条:测试集准确率超过 97%,手写梯度与 PyTorch autograd 的相对误差小于 1e−6。

最终结果:8 轮训练,测试集 97.51%;与 autograd 的最终权重最大绝对差 9.46e−14,单步梯度最大绝对差 2.22e−16,梯度校验最优相对误差 1.036e−9。三套数字都远低于验收线。

这一篇会按「先能跑,再对齐,最后解释」的顺序展开。每一节都给出真实数字,脚本可重跑,指标落在同目录的 manual_metrics.json 里。

一、环境与验收线 ​

1.1 只依赖 NumPy 和 PyTorch ​

手写部分只用 NumPy:

python
import numpy as np

SEED = 20260801
DTYPE = np.float64

PyTorch 只在两个地方出现:对照 autograd 的梯度,以及跑一条完全等价的训练流水线做速度参照。换句话说,如果删掉 import torch,手写实现仍然能训到 97.51%。

MNIST 数据直接复用第 1 章下载的四个 idx 文件。脚本按顺序找两个位置:

python
here = Path(__file__).resolve().parent
candidates = [
    here / "data" / "MNIST" / "raw",
    here.parent.parent / "第 1 章:DNN 基础" / "assets" / "data" / "MNIST" / "raw",
]

找到后按第 1 章同样的方式解析、除以 255、用 0.1307 / 0.3081 标准化。这样两章的数字可以直接对比。

设计取舍一:为什么用 float64。

float32 的机器精度约 1e−7。用 float32 做梯度校验,数值差分和解析梯度的差很容易被舍入误差淹没,看到 1e−4 的误差时,你分不清是公式错了还是精度不够。float64 的机器精度约 1e−16,梯度校验能干净地看到 1e−9,autograd 对照能到 1e−14。

代价是内存和速度翻倍。109,386 个参数在 float64 下占 875 KB,float32 只要 437 KB;训练时间也从两秒多变成接近三秒。这个代价在这个教学规模下完全可以接受。真实工程里通常用 float32,梯度校验时临时切到 float64,或者只在小网络上校验。

1.2 同一个种子,同一个 batch 顺序 ​

两条流水线必须吃完全一样的数据顺序,否则梯度对照没有意义。脚本先生成 8 轮的打乱索引:

python
def epoch_batches(n, batch, epochs, seed):
    rng = np.random.default_rng(seed)
    return [rng.permutation(n) for _ in range(epochs)]

手写和 PyTorch 都拿这同一份 batches,按同样的 start:start+64 切片。PyTorch 侧还固定了线程数:

python
torch.manual_seed(SEED)
torch.set_num_threads(4)

线程数会影响 BLAS 的归约顺序,从而影响最后几位的浮点结果。固定线程数不是为了「更快」,是为了让两次运行可比。

1.3 验收线 ​

指标目标实测
测试集准确率≥ 97%97.51%
手写 vs autograd 单步梯度最大绝对差 < 1e−62.22e−16
手写 vs autograd 最终权重最大绝对差 < 1e−69.46e−14
手写 vs 中心差分最大相对误差 < 1e−61.036e−9
训练可重跑固定种子,两次结果一致8 轮 2.96 秒

第一条是任务指标,后三条是正确性指标。任务指标可以靠调参碰运气,正确性指标不能。先保证梯度是对的,再谈准确率。

二、网络结构:784 → 128 → 64 → 10 ​

![网络结构与参数量](/ai-four-kings/第 2 章:反向传播/assets/ch2_practice_graph.svg)

<!-- 配图:四层竖条、每层维度、参数量 109,386、MAC 109,184 -->

结构:

text
输入 784(28×28 拉平)
  → Linear(784, 128) + ReLU
  → Linear(128, 64) + ReLU
  → Linear(64, 10)
  → softmax + 交叉熵

参数量逐层算:

层权重形状权重数偏置数小计
1(128, 784)100,352128100,480
2(64, 128)8,192648,256
3(10, 64)64010650
合计109,184202109,386

一次前向的乘加次数(MAC):

text
784×128 = 100,352
128×64  =   8,192
64×10   =     640
合计     = 109,184 MAC/样本

参数量 109,386,MAC 109,184,两者只差 202 个偏置。全连接层的计算量和参数量基本成正比,这是它的特点,也是 CNN 要解决的问题。

设计取舍二:为什么是 128 和 64。

宽度决定容量,也决定时间。784→256→10 的两层网络在第 1 章就能到 95% 左右;这里加到三层、128 和 64,是为了让「多层反向传播」有足够多的层数可讲,同时把单轮时间压在 0.4 秒以内。如果换成 512 和 256,准确率可能再高一点,但 CPU 上单轮要两秒以上,八轮就是二十秒,读者调试时等待成本变高。

初始化用 He:

python
def init_net(sizes, seed, scheme="he"):
    rng = np.random.default_rng(seed)
    params = []
    for fan_in, fan_out in zip(sizes[:-1], sizes[1:]):
        std = math.sqrt(2.0 / fan_in)
        params.append(rng.normal(0.0, std, (fan_out, fan_in)).astype(DTYPE))
        params.append(np.zeros(fan_out, dtype=DTYPE))
    return params

std = sqrt(2/fan_in) 是 ReLU 的推荐值,目的是让每层的输出方差保持稳定。偏置初始化为 0,这是安全的,因为权重已经随机,对称性已经被打破。

三、前向:把反向要用的值缓存下来 ​

3.1 一份 cache,两处使用 ​

![前向缓存](/ai-four-kings/第 2 章:反向传播/assets/ch2_cache_table.svg)

<!-- 配图:x、z₁、a₁、z₂、a₂、logits、p 的形状与 float64 占用,合计 594 KB -->

前向函数返回 logits 和 cache:

python
def forward_net(params, x, act="relu"):
    """返回 logits, cache。cache 里存 z 与 a,反向时只读不重算。"""
    layers = len(params) // 2
    acts, zs = [x], []
    h = x
    for i in range(layers):
        w, b = params[2 * i], params[2 * i + 1]
        z = h @ w.T + b
        zs.append(z)
        if i < layers - 1:
            if act == "relu":
                h = np.maximum(z, 0.0)
            elif act == "sigmoid":
                h = 1.0 / (1.0 + np.exp(-z))
            else:
                h = np.tanh(z)
            acts.append(h)
        else:
            h = z
            acts.append(h)
    return h, {"acts": acts, "zs": zs}

acts 存每一层的输出(包括输入 x 和最后的 logits),zs 存每一层的预激活。反向时:

  • 算 dW = dzᵀ a_prev 要用 acts[i];
  • 算 ReLU 的导数要用 zs[i-1] 的符号;
  • 算 Sigmoid/Tanh 的导数要用 acts[i-1] 的值。

batch=64、float64 下,一份 cache 的大小:

张量形状元素数字节
x(64, 784)50,176401,408
z₁(64, 128)8,19265,536
a₁(64, 128)8,19265,536
z₂(64, 64)4,09632,768
a₂(64, 64)4,09632,768
logits(64, 10)6405,120
p(64, 10)6405,120
合计608,256 B ≈ 594 KB

594 KB 听起来不多,但这是单个 batch 的缓存。真实训练里框架要保存所有需要求导的中间值,层数、宽度、batch 一乘,显存就上去了。第 6 章讲显存优化时会回到这笔账。

踩坑一:不要为了省内存把 z 覆盖掉。

一个看起来聪明的写法是:

python
z = h @ w.T + b
h = np.maximum(z, 0.0, out=z)     # 原地 ReLU,z 被覆盖

这样 h 和 z 共用一块内存,省下 65 KB。但反向时 ReLU 的导数需要 z 的符号,而 z 已经被改成 max(z,0),负半轴的信息全丢了。结果不是报错,而是梯度悄悄变错:所有位置都按「z>0」处理,死亡 ReLU 的梯度不再为 0。

正确做法是保留 z,用 np.maximum(z, 0.0) 生成新的数组。省内存的代价是正确性,这种交换不要做。

3.2 softmax 的数值稳定 ​

python
def softmax(logits):
    shifted = logits - logits.max(axis=1, keepdims=True)
    exp = np.exp(shifted)
    return exp / exp.sum(axis=1, keepdims=True)

先减去每行的最大值,再做 exp。数学上 softmax(z) = softmax(z − c),因为分子分母同乘 e⁻ᶜ;数值上,减去最大值保证最大的 exp 是 e⁰ = 1,不会溢出。

如果不减:

text
logits = [1000, 1001, 1002]
exp(1002) = inf
p = [nan, nan, nan]

这个 NaN 会沿着 loss、梯度、权重一路传下去,第一个 batch 就报废。MNIST 的 logits 通常只有个位数,不减也不会溢出,但这是运气,不是设计。任何要上真实数据的 softmax 都必须减最大值。

3.3 cache 只活一个 step ​

text
一个 batch 的 cache:594 KB
一个 epoch 的样本:60,000
如果全部保留:60,000 × 594 KB / 64 ≈ 544 MB

544 MB 只是中间值,不含参数、优化器和梯度。Adam 还要为每个参数存两份同形状的状态:m 和 v 各 109,386 × 8 B = 875 KB,合计 1.75 MB。所以真正的内存大头是 cache,不是参数。

框架里的 loss.backward() 在反向结束后会释放计算图(除非 retain_graph=True);我们的 NumPy 版本靠 Python 的引用计数,loss_grads 返回后 cache 在下一轮循环被覆盖,旧的一份自动回收。如果写成 all_caches.append(cache) 做调试,544 MB 会立刻变成现实——这也是排查表里「内存一直涨」那一行的来源。

评估时不需要 cache。evaluate() 只取 logits,完全可以在前向里跳过 zs/acts 的保存;本篇为了代码复用没有这么做,因为测试集只跑一次,省这点内存不值得多写一个分支。这个取舍在推理部署时会反过来:那里没有反向,缓存全部可以省掉。

四、输出层:softmax + 交叉熵的梯度 ​

![输出层梯度](/ai-four-kings/第 2 章:反向传播/assets/ch2_softmax_grad.svg)

<!-- 配图:三类 p/onehot/dz 表、合计为 0、两条性质面板 -->

输出层的梯度只有一次减法:

python
onehot = np.zeros_like(p)
onehot[np.arange(batch), y] = 1.0
dz = (p - onehot) / batch

形状:

text
p:       (64, 10)
onehot:  (64, 10)
dz:      (64, 10)

/batch 是因为损失取的是 batch 内平均:

python
loss = float(-np.log(p[np.arange(batch), y] + 1e-12).mean())

mean() 对 64 个样本求平均,所以每个样本的梯度也要除以 64。如果损失用 sum(),梯度就不除;两者必须一致,否则等效学习率会差 64 倍。

两行可以当场验证的检查:

python
assert np.allclose(p.sum(axis=1), 1.0)       # 每行概率和为 1
assert np.allclose(dz.sum(axis=1), 0.0)      # 每行梯度和为 0(未除 batch 前)

第二个检查很重要:它说明输出层梯度没有「整体平移」的自由度,只沿概率变化的方向。如果这行断言失败,多半是 onehot 的下标写错了,或者 softmax 的轴写错了。

五、隐藏层:同一段代码跑三次 ​

![隐藏层递推](/ai-four-kings/第 2 章:反向传播/assets/ch2_hidden_recursion.svg)

<!-- 配图:i=3/2/1 三行 dz→dW→da,底部循环里的那一行 -->

反向主循环:

python
layers = len(params) // 2
grads = [None] * (2 * layers)
for i in reversed(range(layers)):
    w = params[2 * i]
    grads[2 * i] = dz.T @ cache["acts"][i]
    grads[2 * i + 1] = dz.sum(axis=0)
    if i > 0:
        da = dz @ w
        a_prev = cache["acts"][i]
        if act == "relu":
            dz = da * (cache["zs"][i - 1] > 0)
        elif act == "sigmoid":
            dz = da * a_prev * (1.0 - a_prev)
        else:
            dz = da * (1.0 - a_prev ** 2)

把 i = 3 这一轮展开,形状逐个标出来:

text
dz:        (64, 10)
acts[3]:   a₂ (64, 64)
dW₃ = dzᵀ @ a₂        (10, 64) @ (64, 64) → (10, 64)
db₃ = dz.sum(axis=0)  (10,)
da₂ = dz @ W₃         (64, 10) @ (10, 64) → (64, 64)
dz₂ = da₂ * (z₂ > 0)  (64, 64) ⊙ (64, 64) → (64, 64)

i = 2:

text
dz:        (64, 64)
acts[2]:   a₁ (64, 128)
dW₂ = dzᵀ @ a₁        (64, 64) @ (64, 128) → (64, 128)
db₂ = dz.sum(axis=0)  (64,)
da₁ = dz @ W₂         (64, 64) @ (64, 128) → (64, 128)
dz₁ = da₁ * (z₁ > 0)  (64, 128)

i = 1:

text
dz:        (64, 128)
acts[1]:   x (64, 784)
dW₁ = dzᵀ @ x         (128, 64) @ (64, 784) → (128, 784)
db₁ = dz.sum(axis=0)  (128,)
da₀ = dz @ W₁         (64, 128) @ (128, 784) → (64, 784)

到 da₀ 就传回输入了,不需要再更新输入,循环结束。

为什么是 dz @ w,不是 dz @ w.T ​

前向是:

text
z = h @ w.T + b          (64, in) @ (in, out) → (64, out)

所以 z 对 h 的导数是 w,不是 w.T:

text
dL/dh = dL/dz @ ∂z/∂h = dz @ w

形状验证:(64, out) @ (out, in) → (64, in),和 h 对齐。如果写成 dz @ w.T,(64,10) @ (64,10) 直接报错;如果某一层 out 和 in 碰巧相等,它会悄悄算出一个 (64,64) 的矩阵,训练照跑,梯度全错。这就是后面要加形状断言的原因。

激活函数的导数 ​

激活前向反向用的局部导数
ReLUmax(z, 0)z > 0(0 点取 0)
Sigmoid1/(1+e⁻ᶻ)a(1−a)
Tanhtanh(z)1−a²

ReLU 用的是 z 的符号,Sigmoid/Tanh 用的是 a 的值。所以 cache 里 z 和 a 都要存。这也是为什么 forward_net 返回两个列表,而不是只返回 a。

踩坑二:dz.sum(axis=0) 写成了 dz.sum()。

dz 是 (batch, out)。dz.sum(axis=0) 得到 (out,),正好是偏置的形状;dz.sum() 得到一个标量,广播到 (out,) 时会把所有偏置加上同一个数。这个 bug 不会报错,损失也会下降,但偏置的梯度是错的。检查方法:打印 grads[2*i+1].shape,它必须等于 params[2*i+1].shape。

六、六个梯度张量:形状与公式 ​

![六个梯度张量的形状](/ai-four-kings/第 2 章:反向传播/assets/ch2_param_grad_shapes.svg)

<!-- 配图:W₁/b₁/W₂/b₂/W₃/b₃ 的形状、元素数与梯度公式表 -->

把六个梯度排成一张表:

参数形状元素数梯度公式梯度形状
W₁(128, 784)100,352dz₁ᵀ x(128, 784)
b₁(128,)128Σ_batch dz₁(128,)
W₂(64, 128)8,192dz₂ᵀ a₁(64, 128)
b₂(64,)64Σ_batch dz₂(64,)
W₃(10, 64)640dz₃ᵀ a₂(10, 64)
b₃(10,)10Σ_batch dz₃(10,)

一个统一的形状规则:

text
dW 的形状 = W 的形状 = (out, in)
db 的形状 = b 的形状 = (out,)
dz 的形状 = z 的形状 = (batch, out)
a_prev 的形状 = (batch, in)

于是 dW = dzᵀ @ a_prev 的形状是 (out, batch) @ (batch, in) = (out, in),严丝合缝。

写一个断言函数,在每个 batch 之后检查:

python
def check_shapes(params, grads):
    for p, g in zip(params, grads):
        assert p.shape == g.shape, (p.shape, g.shape)

这行断言很便宜,一次形状比较,但它能挡住上面所有的转置错误和广播错误。手写反向传播的 bug 有八成是形状问题,而且大部分不会报错。

七、手写 Adam:为什么不用现成的 ​

梯度算对之后,更新规则也要自己写。这一篇用 Adam,而不是 SGD:

python
class Adam:
    def __init__(self, params, lr=1e-3, betas=(0.9, 0.999), eps=1e-8):
        self.lr, self.b1, self.b2, self.eps = lr, betas[0], betas[1], eps
        self.m = [np.zeros_like(p) for p in params]
        self.v = [np.zeros_like(p) for p in params]
        self.t = 0

    def step(self, params, grads):
        self.t += 1
        c1 = 1.0 - self.b1 ** self.t
        c2 = 1.0 - self.b2 ** self.t
        for i, (p, g) in enumerate(zip(params, grads)):
            self.m[i] = self.b1 * self.m[i] + (1 - self.b1) * g
            self.v[i] = self.b2 * self.v[i] + (1 - self.b2) * (g * g)
            mhat = self.m[i] / c1
            vhat = self.v[i] / c2
            p -= self.lr * mhat / (np.sqrt(vhat) + self.eps)

![Adam 与更新比例](/ai-four-kings/第 2 章:反向传播/assets/ch2_adam.svg)

<!-- 配图:Adam 五个公式、偏差修正、三层 update ratio 0.31%/0.21%/0.17% -->

五个式子:

text
m ← β₁m + (1−β₁)g
v ← β₂v + (1−β₂)g²
m̂ ← m / (1−β₁ᵗ)
v̂ ← v / (1−β₂ᵗ)
θ ← θ − η m̂ / (√v̂ + ε)

m 是梯度的一阶矩(动量),v 是二阶矩(梯度平方的滑动平均)。m̂、v̂ 是偏差修正:m 和 v 都初始化为 0,前几步会偏小,除以 1−βᵗ 把它们拉回无偏估计。

第一次更新时 t = 1:

text
1 − β₁¹ = 0.1
1 − β₂¹ = 0.001

如果不做修正,第一步的 m 只有真实梯度的 0.1 倍,v 只有 0.001 倍,更新量会小得离谱。修正之后,第一步的更新量接近 η · sign(g),每个参数的步长都在 η 量级,这正是 Adam 冷启动快的原因。

eps = 1e-8 放在 sqrt(vhat) 外面,是为了防止 vhat 为 0 时除零。放在里面(sqrt(vhat + eps))也能跑,但数值行为略有不同。框架的实现通常是放在外面,这里保持一致。

设计取舍三:为什么不用 SGD。

这一篇的目标是「8 轮训到 97%」,不是「研究优化器」。SGD 在同样的 8 轮里通常只能到 95% 左右,需要更多轮或更细的学习率调度;Adam 对学习率不敏感,1e-3 就能稳定下降。选 Adam 是为了让实验聚焦在反向传播,而不是调优化器。第 3 章会把 SGD、Momentum、Adam 放在同一张图上比较。

代价是 Adam 的更新量和梯度量级脱钩:它按每个参数的历史梯度做归一化,所以「梯度很小」不等于「更新很小」。这会让第 3 章的梯度分析多一层解释成本,但在这个规模下利大于弊。

八、训练循环与结果 ​

8.1 循环里的五个动作 ​

python
for ep in range(epochs):
    idx = batches[ep]
    for start in range(0, len(idx), 64):
        bidx = idx[start:start + 64]
        xb, yb = xtr[bidx], ytr[bidx]
        loss, grads, cache = loss_grads(params, xb, yb)
        opt.step(params, grads)
        total_loss += loss * len(bidx)
        correct += int((cache["acts"][-1].argmax(1) == yb).sum())

五个动作:取 batch、前向 + 反向、更新、累计损失、累计正确数。

两个细节:

细节一:损失按样本数加权。 最后一个 batch 可能不足 64(60,000 / 64 = 937.5,每轮最后一个 batch 是 32 个样本)。loss * len(bidx) 再除以总样本数,才是真正的 epoch 平均损失。如果直接对 938 个 batch 的 loss 求平均,最后一个半 batch 会被高估。

细节二:准确率用更新前的 logits。 cache["acts"][-1] 是前向时算出的 logits,此时参数还没被 opt.step 更新。PyTorch 侧的 logits = model(xb) 也是在 opt.step() 之前算的,两边口径一致。如果手写侧更新后再前向一次算准确率,会得到略微不同的数字——不是模型变强了,是统计口径变了。

8.2 八轮曲线 ​

![8 轮训练曲线](/ai-four-kings/第 2 章:反向传播/assets/ch2_train_curves.svg)

<!-- 配图:左图 train/test loss,右图 train/test accuracy;两条实现完全重合 -->

轮train losstrain acctest losstest acc
10.25742792.26%0.13589195.64%
20.10705496.73%0.09962496.82%
30.07662997.63%0.09454797.11%
40.05961698.12%0.10350596.88%
50.04829498.38%0.09728997.14%
60.04145598.60%0.09879697.05%
70.03620298.85%0.10450897.29%
80.02891398.99%0.10446297.51%

四个观察:

  1. 第一轮就到 95.64%,因为 128 和 64 两个隐藏层容量足够,MNIST 又相对简单。
  2. train loss 从 0.257 单调降到 0.029,train acc 从 92.3% 到 99.0%,模型在训练集上已经接近记住。
  3. test loss 在第 3 轮见底(0.0945),之后震荡上升到 0.1045;test acc 却继续升到 97.51%。这是过拟合的典型信号:模型对错例更「自信」,但分类边界还在变好。
  4. 如果按 test loss 做早停,会在第 3 轮停下,准确率 97.11%;按 test acc 做早停,会在第 8 轮停下,97.51%。两个指标给出的最优轮次不同,选哪个取决于部署时更在意「概率校准」还是「分类正确率」。

第 3 章会讲正则化、学习率衰减和早停。这一章只记录现象,不展开。

8.3 两条流水线的耗时 ​

![手写与 PyTorch 的耗时](/ai-four-kings/第 2 章:反向传播/assets/ch2_pipeline_compare.svg)

<!-- 配图:两条流水线步骤对照、8 轮总耗时、逐轮耗时柱 -->

流水线8 轮总耗时每轮范围
手写 NumPy2.96 s0.366–0.378 s
PyTorch autograd4.09 s0.415–0.582 s

手写更快,原因不是 NumPy 比 PyTorch 快,而是少了三样东西:计算图的构建与遍历、算子分发、梯度对象的分配。PyTorch 的每一层都要记录 grad_fn、检查是否需要梯度、在反向时调用 C++ 的 autograd 引擎;这些开销在小网络上占比很高。

这个对比不是严格的性能测试。PyTorch 的优势在更大的网络、GPU、自动微分覆盖不到的复杂结构上。在这里,它的价值是「独立实现的裁判」,不是「更快的训练器」。

8.4 这 0.37 秒花在哪 ​

一次前向的乘加次数:

text
784×128 + 128×64 + 64×10 = 109,184 MAC/样本
60,000 样本 ≈ 6.55e9 MAC ≈ 1.31e10 FLOP

反向大约是前向的两倍(dW 和 da 各一次矩阵乘),一轮总计约 3.9e10 FLOP。0.37 秒跑完,等效约 106 GFLOPS,这是 CPU 上多线程 BLAS 的正常水平。

这也解释了为什么手写版不慢:所有时间都在三次大矩阵乘上,Python 循环只负责调度。如果按样本写 for 循环、逐个元素算,同样的 FLOP 会慢两到三个数量级——第 4 章手写卷积时会看到同样的差距。

九、与 autograd 对齐 ​

9.1 一次前向、一次反向的逐项对照 ​

![四项对齐指标](/ai-four-kings/第 2 章:反向传播/assets/ch2_autograd_match.svg)

<!-- 配图:四张指标卡 9.46e−14、0.0、0.0、2.3e−13 -->

训练开始前,先把同一组初始权重同步给 PyTorch:

python
def sync_torch(model, params):
    with torch.no_grad():
        j = 0
        for m in model.net:
            if isinstance(m, torch.nn.Linear):
                m.weight.copy_(torch.tensor(params[2 * j]))
                m.bias.copy_(torch.tensor(params[2 * j + 1]))
                j += 1

然后喂同一个 batch,比较损失和六个梯度:

梯度最大绝对差最大相对差
W₁5.551e−171.311e−11
b₁5.551e−172.959e−15
W₂5.551e−173.925e−14
b₂2.776e−173.515e−16
W₃2.220e−166.909e−15
b₃1.110e−161.920e−16

![单步梯度差](/ai-four-kings/第 2 章:反向传播/assets/ch2_one_step_diff.svg)

<!-- 配图:六个梯度最大绝对差的对数点图,验收线 1e−6 -->

最大绝对差 2.22e−16,正好是 float64 在 1 附近的机器精度。最大相对差 1.31e−11,出现在 W₁ 的某个接近 0 的元素上——分母很小,相对误差被放大,但绝对差仍然是 1e−17 量级。

两个损失:

text
手写     3.8219637276134497
autograd 3.8219637278466783
差       2.33e−13

损失差比梯度差大两个数量级,因为损失经过 log 和 sum,误差会累积。但 2.33e−13 仍然远低于验收线。

9.2 八轮之后 ​

两条流水线各自从同一组初始权重出发,用同一份 batch 顺序训练 8 轮:

text
最终权重最大绝对差:9.46e−14
逐轮 train loss 最大差:0.0(六位小数下)
逐轮 test acc 最大差:0.0(六位小数下)

逐轮曲线完全重合,是因为指标记录了六位小数;底层权重的差从 1e−16 增长到 1e−14,是 938 次参数更新中浮点舍入逐步累积的结果。Adam 的归一化让两条轨迹没有分叉:每步的差都被压在机器精度附近,没有被放大。

这个结果说明:手写反向传播不仅在公式上对,在 938 次迭代的动力学上也和 autograd 一致。如果某个转置写错,第一轮的权重就会分叉,最终差距会是 1e−2 量级,而不是 1e−14。

9.3 为什么不是精确的 0 ​

两个实现算的是同一个数学式子,但浮点加法不满足结合律。(a+b)+c 和 a+(b+c) 的结果可能差最后一位。差异来自三处:

  1. 归约顺序。 dz.T @ a_prev 在 NumPy 里由 BLAS 按块累加,PyTorch 的 Linear 走的是另一套内核,中间累加的分组不同。
  2. 算子融合。 PyTorch 可能把 add 和 relu 融进一个 kernel,中间结果不落内存,舍入点因此不同。
  3. 内存布局。 同一个矩阵按行主序或列主序遍历,缓存命中率不同,但这不改变数学结果,只改变累加顺序。

数量级可以对上:float64 的机器精度是 2.22e−16,单步最大绝对差正好是这个值;5.551e−17 是它的四分之一。相对差 1.31e−11 出现在 W₁ 中接近 0 的元素上,分母 |a|+|n| 很小,把绝对差放大了,但绝对差仍是 1e−17 量级。

训练 938 步后,差从 1e−16 长到 9.46e−14,平均每步增长约 1e−16,与随机游走的 √938 ≈ 30.6 倍同量级。这说明误差没有被动力学放大;如果某一步的梯度符号错了,差异会按指数增长,而不是这样缓慢累积。

十、梯度校验:用数值差分当裁判 ​

![梯度校验的 U 型曲线](/ai-four-kings/第 2 章:反向传播/assets/ch2_gradcheck.svg)

<!-- 配图:7 个 ε 的相对误差曲线,最低点 1e−5 处 1.036e−9 -->

autograd 只能证明「手写和 PyTorch 一样」,不能证明「两个实现都错了」。数值差分是独立的第三方裁判。

10.1 为什么用小网络 ​

全量网络有 109,386 个参数,中心差分要对每个参数做两次前向:

text
109,386 × 2 = 218,772 次前向

一次前向在 CPU 上约 0.1 毫秒,218,772 次就是二十多秒,而且要做七次 ε 扫描。所以校验用一个小网络:

python
sizes = [8, 5, 4, 3]
params = init_net(sizes, SEED + 7)
x = rng.normal(0, 1, (4, 8))
y = rng.integers(0, 3, 4)

参数量:

text
8×5 + 5 = 45
5×4 + 4 = 24
4×3 + 3 = 15
合计     = 84

84 个参数,4 个样本,一次 ε 扫描 168 次前向,七次也就 1,176 次,秒级完成。小网络能覆盖 ReLU、softmax、交叉熵、矩阵乘的全部路径,形状虽小,逻辑和大网络一致。

10.2 校验代码 ​

python
def numerical_grads(params, x, y, eps):
    grads = [np.zeros_like(p) for p in params]
    for pi, p in enumerate(params):
        for idx in np.ndindex(p.shape):
            old = p[idx]
            p[idx] = old + eps
            lp = loss_grads(params, x, y)[0]
            p[idx] = old - eps
            lm = loss_grads(params, x, y)[0]
            p[idx] = old
            grads[pi][idx] = (lp - lm) / (2 * eps)
    return grads

对每个元素:加 ε 算一次损失,减 ε 算一次损失,中心差分,再把原值写回。np.ndindex(p.shape) 遍历所有下标,对 84 个参数逐个做。

相对误差的定义:

python
rel = abs(a - n) / max(1e-12, abs(a) + abs(n))

分母用 |a| + |n| 而不是 |a|,是为了对称:当解析梯度接近 0 时,不会因为分母太小而把误差放大成噪声。

10.3 七个 ε 的结果 ​

ε最大相对误差最大绝对误差
1e−27.569e−45.773e−4
1e−37.578e−65.776e−6
1e−47.578e−85.776e−8
1e−51.036e−95.895e−10
1e−62.056e−82.473e−10
1e−72.723e−72.477e−9
1e−82.301e−61.812e−8

曲线是标准的 U 型:

  • ε 从 1e−2 降到 1e−5,截断误差按 O(ε²) 下降,相对误差从 7.6e−4 降到 1.0e−9;
  • ε 从 1e−5 降到 1e−8,舍入误差按 O(1/ε) 上升,相对误差从 1.0e−9 升到 2.3e−6;
  • 最优点在 1e−5,误差 1.036e−9。

验收线是 1e−6。最优点留了三个数量级余量;即使选错一个数量级(1e−4 或 1e−6),误差也只有 1e−8 量级,仍然通过。这就是为什么梯度校验要扫描多个 ε,而不是只试一个。

10.4 ReLU 的拐点风险 ​

数值差分有一个隐患:如果某个 z 正好落在 0 附近,+ε 和 −ε 可能把它推到 ReLU 的两侧,一边导数是 1,一边是 0,中心差分算出的斜率是真实斜率的一半。这不是反向传播错了,是函数在 ε 邻域内不可导。

这个实验里的输入是标准正态分布、权重是 He 初始化,z 落在 ±1e−5 内的概率极低,所以七次扫描都没有碰到拐点。如果在真实网络里做校验,遇到误差突然变大,先检查是不是 ReLU 的拐点,而不是急着改反向公式。

10.5 校验通过只说明公式对 ​

梯度校验能证明「解析梯度在这一点、这个 ε 下与损失曲面一致」,不能证明:

  • 训练会收敛:梯度对,学习率仍可能太大;
  • 泛化会好:梯度对,模型仍可能过拟合;
  • 所有输入都对:只在 4 个样本、1 组权重上验证,边界情况(全零输入、极端 logits)没有覆盖。

所以验收表把「梯度校验」和「测试准确率」分列。前者保证实现正确,后者保证任务完成。两者缺一不可,也不能互相替代。

十一、四个诊断:梯度到底传到了哪里 ​

训练能跑通,不代表梯度传得健康。这一节用四个测量回答四个问题:每层的梯度多大、每层的权重多大、多少 ReLU 没激活、加深之后梯度还在不在。

11.1 每层的梯度范数 ​

![逐层梯度范数](/ai-four-kings/第 2 章:反向传播/assets/ch2_grad_norm_layers.svg)

<!-- 配图:三层 W 的梯度范数 0.151/0.152/0.149,b 的梯度 0.0054/0.0047/0.0040 -->

第 8 轮,在 256 个样本上算一次梯度,取每层范数:

层‖dW‖‖db‖
10.1511160.005383
20.1519150.004694
30.1492420.004011

三层权重的梯度范数几乎相等,误差不到 2%。这是一个好信号:反向信号没有在层间被系统性放大或缩小。它来自两个设计:ReLU 的正半轴导数为 1,不会像 Sigmoid 那样每层乘 0.25;He 初始化让每层的权重方差匹配 fan_in,前向和反向的尺度都稳定。

偏置的梯度只有权重的 3% 左右。原因不是偏置不重要,而是偏置的梯度是 Σ_batch dz:64 个样本的 dz 有正有负,相加会抵消一部分;权重的梯度是 dzᵀ a,每个样本的贡献是一个外积矩阵,不会像标量那样互相抵消。量级小不等于没在学,第 8 轮的 db 仍然在 0.004 量级,足以让偏置缓慢调整。

11.2 每层的权重范数 ​

![逐层权重范数](/ai-four-kings/第 2 章:反向传播/assets/ch2_weight_norm_layers.svg)

<!-- 配图:三层 W 范数 26.0/13.9/5.3,形状 128×784、64×128、10×64 -->

层‖W‖形状
126.033(128, 784)
213.870(64, 128)
35.317(10, 64)

第一层最大,最后一层最小。这不是「第一层学得最多」,而是形状决定的:He 初始化下,W 的元素标准差是 sqrt(2/fan_in),范数的期望约是 sqrt(2·fan_out)。fan_out 从 128 降到 64 再到 10,范数自然跟着降。

所以跨层比较权重范数没有意义。要比较「学了多少」,得用相对量:

text
‖ΔW‖ / ‖W‖

下一节会看到,这个比值三层都在千分之几,才是可比的指标。

11.3 零激活比例 ​

![零激活比例](/ai-four-kings/第 2 章:反向传播/assets/ch2_dead_relu.svg)

<!-- 配图:三层 z≤0 比例随 epoch 变化,56%→69%、51%→57%、70%→70% -->

统计方法:每个 batch 算 (z <= 0).mean(),再按样本数加权平均。

轮第 1 层第 2 层第 3 层
156.3%51.1%69.8%
465.6%54.6%69.4%
869.0%57.2%70.2%

第一层的零激活比例从 56% 升到 69%,第二层从 51% 升到 57%,第三层一直在 70% 左右。也就是说,第一层 128 个单元里,平均只有 40 个对当前 batch 有非零输出;第三层 10 个单元里只有 3 个在响应。

这里要澄清一个口径:这个指标统计的是「当前 batch 上 z <= 0 的比例」,不是「永久死亡的单元数」。同一个单元可能在这个 batch 上关闭,在另一个 batch 上打开。但比例随训练持续上升,说明有一部分单元已经长期落在负半轴,靠剩下的单元在支撑分类。

这是 ReLU 的代价。它的好处是没有 Sigmoid 的饱和消失,坏处是容量被浪费。第 3 章会讲 LeakyReLU、ELU、初始化调整和学习率控制,这些都是围绕这个比例做文章。

11.4 加深之后梯度还在不在 ​

![Sigmoid 与 ReLU 的深度对比](/ai-four-kings/第 2 章:反向传播/assets/ch2_sigmoid_vanishing.svg)

<!-- 配图:深度 2–8 的首层/末层梯度比,Sigmoid 从 0.31 掉到 5.5e−5,ReLU 稳定在 2 附近 -->

实验设置:固定前 64 个样本,隐藏层宽度固定 64,深度取 2、3、4、6、8。Sigmoid 用 Xavier 初始化,ReLU 用 He 初始化。指标是「首层权重梯度范数 / 末层权重梯度范数」。

深度SigmoidReLU
20.31352.8459
30.07122.1934
40.02132.8233
60.001242.1579
85.501e−52.2457

Sigmoid 的比值每加深一层就掉一个数量级左右,8 层只剩 5.5e−5。ReLU 的比值在 2 附近波动,不随深度变化。两个网络用的是同一份数据、同样的宽度,唯一的区别是激活函数和初始化。

这个实验解释了第 1 章那个「深层网络训不动」的现象:不是优化器不行,是梯度在连乘中消失了。Sigmoid 的导数上限是 0.25,8 层连乘的理论上限是 0.25⁸ ≈ 1.5e−5,实测 5.5e−5 在同一个量级。

ReLU 也不是完全没有衰减。比值 2 的意思是首层梯度比末层略大,方向和 Sigmoid 相反,但量级稳定。它靠的是正半轴导数为 1,以及 He 初始化把每层的尺度对齐。

十二、batch 大小与梯度噪声 ​

![batch 噪声实测](/ai-four-kings/第 2 章:反向传播/assets/ch2_batch_noise_real.svg)

<!-- 配图:左图余弦相似度均值±标准差,右图相对误差,batch 1–512 -->

实验设置:取 1024 个样本,先算全量梯度 g_full;再对每个 batch 大小随机抽 20 次,算 batch 梯度 g_batch,比较余弦相似度和相对误差。

batch余弦相似度相对误差
10.2064 ± 0.15513.459
80.5952 ± 0.06231.399
320.8315 ± 0.02580.660
1280.9542 ± 0.00630.324
5120.9928 ± 0.00100.123

三个观察:

  1. batch=1 时余弦只有 0.206,标准差 0.155。方向几乎随机,单样本梯度不能代表全量。
  2. 余弦随 batch 增大而上升,标准差随 batch 增大而下降。batch 从 1 到 512,标准差缩小了 150 倍。
  3. 相对误差的下降比余弦慢:batch=512 时余弦已经 0.993,相对误差还有 0.123。因为余弦只测方向,相对误差还测长度;batch 梯度的长度也会波动。

代价在步数。60,000 个样本:

text
batch=64:938 步/轮
batch=512:118 步/轮

同样的 8 轮,batch=512 的参数更新次数只有 batch=64 的八分之一。要拿到同样的更新次数,得训 64 轮。所以「batch 大梯度准」和「batch 小更新多」是一个折中。

这一篇选 64,理由很实际:938 步/轮在 CPU 上 0.37 秒跑完,8 轮不到 3 秒;batch=512 虽然梯度更准,但 8 轮只有 944 步,模型还没训够。第 3 章会讲学习率与 batch 的缩放关系,那里会看到「batch 翻倍、学习率也翻倍」的经验规则。

十三、更新比例:每步到底改了多少 ​

![每轮更新比例](/ai-four-kings/第 2 章:反向传播/assets/ch2_update_ratio.svg)

<!-- 配图:三层 ‖ΔW‖/‖W‖ 随 epoch 变化,范围 0.10%–0.42% -->

定义:

text
update_ratio = ‖ΔW‖ / ‖W‖

ΔW 取的是该轮最后一个 batch 的 Adam 更新量。第 1 轮和第 8 轮:

层第 1 轮第 8 轮
10.374%0.309%
20.175%0.214%
30.108%0.169%

三层都在 0.10%–0.42% 之间,没有哪一层被梯度消失卡住不动。第 1 层的更新比例最大,主要来自参数数量:它有 100,352 个权重,是第 3 层(640 个)的 157 倍,单步更新向量的范数随 √n 增长(约 12.5 倍),而权重范数只大 4.9 倍(26.0 vs 5.3),比值自然更高。按单个元素看,反而是深层改得略多(约 3.6e−4 vs 2.5e−4),所以「浅层动得多」在 Adam 下并不成立。第 3 层从 0.108% 升到 0.169%,说明输出层在训练后期仍在调整。

这个比例之所以能保持稳定,主要是 Adam 的功劳。Adam 用 m̂ / (√v̂ + ε) 对每个参数做归一化,梯度的绝对量级被消掉,只剩方向。如果换成 SGD,深层的更新比例会明显小于浅层,因为深层的梯度经过更多层连乘,量级更小。

把「单步更新比例」写成可估算的形式:

text
‖ΔW‖ ≈ lr · √n · r,  r = 每个坐标的平均归一化步长
‖ΔW‖/‖W‖ ≈ lr · √n · r / ‖W‖

第 1 层 √n = 316.8,第 3 层只有 25.3;第 1 层的权重范数是 26.0,第 3 层是 5.3。代入实测比例,反推出每坐标的平均步长 r:第 1 层约 2.5e−4,第 3 层约 3.6e−4,都小于 lr = 1e−3。原因是 Adam 的分子用平滑后的一阶矩、分母用平滑后的二阶矩,单步方向会被历史梯度拉平,不会每个坐标都走满 lr。

这也解释了为什么「梯度范数小」不等于「更新小」:Adam 把梯度的长度除掉,只留下方向。看梯度范数只能判断信号强弱,判断实际改动要看 update ratio。

要注意口径:这是「单个 step」的比例,不是「整轮累计」的比例。938 个 step 加起来,权重的累计改动远大于 0.3%,但方向有正有负,不能简单相加。

十四、验收:五条线全部通过 ​

![验收](/ai-four-kings/第 2 章:反向传播/assets/ch2_acceptance.svg)

<!-- 配图:五条验收指标表,97.51%、109,386、9.46e−14、1.036e−9、8 轮 2.96 s -->

指标目标实测
测试准确率≥ 97%97.51%
参数量能手算核对109,386
手写 vs autograd 权重差相对误差 < 1e−69.46e−14
手写 vs 中心差分相对误差 < 1e−61.036e−9(最优 ε)
训练可重跑固定种子8 轮 2.96 s

最终数字:

text
test loss = 0.104462
test acc  = 97.51%
train acc = 98.99%
8 轮总耗时 = 2.96 s
与 PyTorch 的最终权重最大绝对差 = 9.46e−14

97.51% 不是这个数据集的极限。第 4 章 CNN 会到 99% 以上,第 3 章调优化器和正则化也能再往上。这一篇的 97.51% 是一个「结构没搭错、梯度没算错、训练没跑偏」的证明,不是性能上限。

还要记录一个不完美的地方:test loss 在第 3 轮之后开始上升,最终 0.1045,比最低点 0.0945 高了 10%。test acc 却在上升。这说明模型在后几轮把一些原本不确定的样本推到了正确一侧,同时对错例变得更加自信。准确率和概率校准在这里分道扬镳,部署时选哪个指标,要看业务是否需要用概率做阈值。

十五、常见坑与排查表 ​

手写反向传播的错误大多不会报错,只会让结果变差。下面这张表按现象排查。

现象可能原因排查方法
loss 变成 nansoftmax 没减最大值;学习率太大;输入没归一化打印 logits 的最大值;检查 p 是否有限;先把 lr 降到 1e−4
loss 停在 2.3 左右全零初始化;标签没对齐;梯度没真正更新检查初始化方差;打印一个 batch 的 x 和 y;更新前后对比权重
浅层梯度全是 0ReLU 大面积死亡;Sigmoid 饱和统计 z <= 0 比例;换 LeakyReLU 或调小学习率
手写与 autograd 差 1e−2转置写错;loss 用 sum 而梯度除了 batch;dtype 不一致先对单步、单样本;检查 dW 形状;确认两边都是 float64
梯度校验误差突然变大ε 太大或太小;碰到 ReLU 拐点;用了 float32扫描 ε;换一组输入;切 float64
准确率和 PyTorch 差零点几更新前后统计口径不同;train/eval 模式不同统一用更新前 logits;PyTorch 侧 model.eval()
内存一直涨把 cache 存进了列表;没有释放 batch 数据确认 cache 只活在当前 step;不要累计 cache
训练很慢数值梯度跑在全量网络上;Python 循环太多;宽度过大只在 84 参数小网络上做 gradcheck;用矩阵运算替代循环

三条最值得记住的:

  1. 先形状,后数值。 所有 dW 必须和 W 同形状,所有 db 必须和 b 同形状。形状对了,八成的 bug 就没了。
  2. 先单样本,后 batch。 单样本的梯度可以手算,batch 只是多了一步平均。单样本对不上,batch 一定对不上。
  3. 先 float64,后 float32。 调试阶段用 float64,能看清 1e−12 的差;确认无误后再切 float32 追速度。

十六、脚本结构与重跑 ​

16.1 文件清单 ​

文件作用
train_manual.py手写训练、autograd 对照、梯度校验、深度/batch 实验,输出 manual_metrics.json
case_three_layer.py案例篇的三层网络手推,输出 case.json
draw_ch2_theory.py理论篇 12 张图
draw_ch2_case.py案例篇 8 张图
draw_ch2_practice.py实战篇 18 张图
svgkit.py三套画图脚本共用的 SVG 工具
manual_metrics.json全部实测指标

16.2 重跑 ​

bash
cd "第 2 章:反向传播/assets"
python3 train_manual.py
python3 case_three_layer.py
python3 draw_ch2_theory.py
python3 draw_ch2_case.py
python3 draw_ch2_practice.py

train_manual.py 会依次做六件事:

  1. 读 MNIST、标准化、按种子打乱;
  2. 初始化 784→128→64→10,打印参数量 109,386;
  3. 单步对照 autograd;
  4. 手写训练 8 轮;
  5. PyTorch 训练 8 轮;
  6. 梯度校验、深度实验、batch 噪声实验,写 JSON。

因为固定了 SEED = 20260801、torch.manual_seed、torch.set_num_threads(4),重跑的结果和这篇的数字一致。MNIST 路径优先找本章 assets/data,找不到就回退到第 1 章的 assets/data。

16.3 metrics 里的字段 ​

字段含义
config结构、batch、轮数、优化器、学习率
manual / torch两条流水线逐轮的 loss、acc、耗时
one_step单步对照的损失与六个梯度差
final_weight_max_diff训练结束后的权重最大绝对差
gradcheck七个 ε 的相对/绝对误差
depthsSigmoid/ReLU 在五个深度上的首末层梯度比
batch_noise五个 batch 大小的余弦与相对误差
manual 里的 dead_relu三层零激活比例
manual 里的 update_ratio三层单步更新比例

16.4 重跑对不上时先查三件事 ​

如果重跑后准确率不是 97.51%、或者梯度差不是 2.22e−16,按顺序检查:

  1. 种子与线程数。 SEED = 20260801、torch.manual_seed(SEED)、torch.set_num_threads(4) 三个都要在训练前设置;线程数不固定,BLAS 归约顺序会变,最后几位就变。
  2. 数据顺序。 手写和 PyTorch 必须共用同一份 batches;如果 PyTorch 侧又建了一个 DataLoader,即使 shuffle=True 和同一个种子,抽样顺序也未必一致。
  3. dtype。 两边都必须是 float64。PyTorch 侧忘了 .double(),或者 NumPy 侧某次运算被提升成 float32,梯度差会从 1e−16 跳到 1e−7。

只有这三项都确认,数字才有可比性。环境不同(BLAS 实现、CPU 架构)仍可能让最后几位变化,但不会改变 97.51% 和 1e−14 这两个量级。

十七、这一章没解决的问题 ​

反向传播现在是一个能逐位复现的算法,不是黑箱。但还有四个问题没有回答:

  1. 梯度方向对了,步子怎么迈?固定学习率 1e−3 在第 3 轮之后 test loss 开始上升,衰减学习率能不能压住?
  2. 97.51% 离 99% 还差一截。差的是模型容量、正则化,还是数据增强?
  3. 全连接层把 28×28 拉平成 784 维,丢掉了空间结构。换 CNN 能补回多少?
  4. 层数继续加深,ReLU + He 还能把梯度传回去吗?残差连接、BatchNorm 各自解决哪一段?

这四个问题分别属于第 3 章「训练工程」和第 4 章「CNN」。这一章留下的最直接问题是第一个:梯度已经能算对,但「怎么用梯度」还没有系统的方法。第 3 章从激活饱和、初始化方差、归一化位置和优化器讲起,把「步子」这件事讲透。

自检 ​

合上笔记,试着回答:

  • 109,386 个参数是怎么算出来的?每层的 W、b 形状是什么?
  • 为什么反向时要缓存 z 和 a?只缓存 a 会缺什么?
  • dz = (p − onehot) / batch 里的 /batch 为什么不能省?
  • da = dz @ W 为什么不用转置?dW = dzᵀ a_prev 的形状怎么对上?
  • 手写梯度和 autograd 的最大绝对差是多少?为什么不是精确的 0?
  • 梯度校验的最优 ε 是多少?为什么误差曲线是 U 型?
  • Sigmoid 8 层的首末层梯度比是多少?ReLU 呢?差异来自哪里?
  • batch 从 1 加到 512,余弦相似度从多少变到多少?代价是什么?
  • 为什么 test loss 在第 3 轮后上升,test acc 却还在升?
  • 如果 db 写成了 dz.sum(),会发生什么?怎么排查?

答不上来的那一条,回到对应小节,把公式、形状和数字重新推一遍。下一篇从「梯度怎么用」开始,进入第 3 章「训练工程」。