实战演练:不调 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:
import numpy as np
SEED = 20260801
DTYPE = np.float64PyTorch 只在两个地方出现:对照 autograd 的梯度,以及跑一条完全等价的训练流水线做速度参照。换句话说,如果删掉 import torch,手写实现仍然能训到 97.51%。
MNIST 数据直接复用第 1 章下载的四个 idx 文件。脚本按顺序找两个位置:
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 轮的打乱索引:
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 侧还固定了线程数:
torch.manual_seed(SEED)
torch.set_num_threads(4)线程数会影响 BLAS 的归约顺序,从而影响最后几位的浮点结果。固定线程数不是为了「更快」,是为了让两次运行可比。
1.3 验收线
| 指标 | 目标 | 实测 |
|---|---|---|
| 测试集准确率 | ≥ 97% | 97.51% |
| 手写 vs autograd 单步梯度 | 最大绝对差 < 1e−6 | 2.22e−16 |
| 手写 vs autograd 最终权重 | 最大绝对差 < 1e−6 | 9.46e−14 |
| 手写 vs 中心差分 | 最大相对误差 < 1e−6 | 1.036e−9 |
| 训练可重跑 | 固定种子,两次结果一致 | 8 轮 2.96 秒 |
第一条是任务指标,后三条是正确性指标。任务指标可以靠调参碰运气,正确性指标不能。先保证梯度是对的,再谈准确率。
二、网络结构:784 → 128 → 64 → 10

<!-- 配图:四层竖条、每层维度、参数量 109,386、MAC 109,184 -->
结构:
输入 784(28×28 拉平)
→ Linear(784, 128) + ReLU
→ Linear(128, 64) + ReLU
→ Linear(64, 10)
→ softmax + 交叉熵参数量逐层算:
| 层 | 权重形状 | 权重数 | 偏置数 | 小计 |
|---|---|---|---|---|
| 1 | (128, 784) | 100,352 | 128 | 100,480 |
| 2 | (64, 128) | 8,192 | 64 | 8,256 |
| 3 | (10, 64) | 640 | 10 | 650 |
| 合计 | 109,184 | 202 | 109,386 |
一次前向的乘加次数(MAC):
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:
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 paramsstd = sqrt(2/fan_in) 是 ReLU 的推荐值,目的是让每层的输出方差保持稳定。偏置初始化为 0,这是安全的,因为权重已经随机,对称性已经被打破。
三、前向:把反向要用的值缓存下来
3.1 一份 cache,两处使用

<!-- 配图:x、z₁、a₁、z₂、a₂、logits、p 的形状与 float64 占用,合计 594 KB -->
前向函数返回 logits 和 cache:
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,176 | 401,408 |
| z₁ | (64, 128) | 8,192 | 65,536 |
| a₁ | (64, 128) | 8,192 | 65,536 |
| z₂ | (64, 64) | 4,096 | 32,768 |
| a₂ | (64, 64) | 4,096 | 32,768 |
| logits | (64, 10) | 640 | 5,120 |
| p | (64, 10) | 640 | 5,120 |
| 合计 | 608,256 B ≈ 594 KB |
594 KB 听起来不多,但这是单个 batch 的缓存。真实训练里框架要保存所有需要求导的中间值,层数、宽度、batch 一乘,显存就上去了。第 6 章讲显存优化时会回到这笔账。
踩坑一:不要为了省内存把 z 覆盖掉。
一个看起来聪明的写法是:
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 的数值稳定
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,不会溢出。
如果不减:
logits = [1000, 1001, 1002]
exp(1002) = inf
p = [nan, nan, nan]这个 NaN 会沿着 loss、梯度、权重一路传下去,第一个 batch 就报废。MNIST 的 logits 通常只有个位数,不减也不会溢出,但这是运气,不是设计。任何要上真实数据的 softmax 都必须减最大值。
3.3 cache 只活一个 step
一个 batch 的 cache:594 KB
一个 epoch 的样本:60,000
如果全部保留:60,000 × 594 KB / 64 ≈ 544 MB544 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 + 交叉熵的梯度

<!-- 配图:三类 p/onehot/dz 表、合计为 0、两条性质面板 -->
输出层的梯度只有一次减法:
onehot = np.zeros_like(p)
onehot[np.arange(batch), y] = 1.0
dz = (p - onehot) / batch形状:
p: (64, 10)
onehot: (64, 10)
dz: (64, 10)/batch 是因为损失取的是 batch 内平均:
loss = float(-np.log(p[np.arange(batch), y] + 1e-12).mean())mean() 对 64 个样本求平均,所以每个样本的梯度也要除以 64。如果损失用 sum(),梯度就不除;两者必须一致,否则等效学习率会差 64 倍。
两行可以当场验证的检查:
assert np.allclose(p.sum(axis=1), 1.0) # 每行概率和为 1
assert np.allclose(dz.sum(axis=1), 0.0) # 每行梯度和为 0(未除 batch 前)第二个检查很重要:它说明输出层梯度没有「整体平移」的自由度,只沿概率变化的方向。如果这行断言失败,多半是 onehot 的下标写错了,或者 softmax 的轴写错了。
五、隐藏层:同一段代码跑三次

<!-- 配图:i=3/2/1 三行 dz→dW→da,底部循环里的那一行 -->
反向主循环:
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 这一轮展开,形状逐个标出来:
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:
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:
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
前向是:
z = h @ w.T + b (64, in) @ (in, out) → (64, out)所以 z 对 h 的导数是 w,不是 w.T:
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) 的矩阵,训练照跑,梯度全错。这就是后面要加形状断言的原因。
激活函数的导数
| 激活 | 前向 | 反向用的局部导数 |
|---|---|---|
| ReLU | max(z, 0) | z > 0(0 点取 0) |
| Sigmoid | 1/(1+e⁻ᶻ) | a(1−a) |
| Tanh | tanh(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。
六、六个梯度张量:形状与公式

<!-- 配图:W₁/b₁/W₂/b₂/W₃/b₃ 的形状、元素数与梯度公式表 -->
把六个梯度排成一张表:
| 参数 | 形状 | 元素数 | 梯度公式 | 梯度形状 |
|---|---|---|---|---|
| W₁ | (128, 784) | 100,352 | dz₁ᵀ x | (128, 784) |
| b₁ | (128,) | 128 | Σ_batch dz₁ | (128,) |
| W₂ | (64, 128) | 8,192 | dz₂ᵀ a₁ | (64, 128) |
| b₂ | (64,) | 64 | Σ_batch dz₂ | (64,) |
| W₃ | (10, 64) | 640 | dz₃ᵀ a₂ | (10, 64) |
| b₃ | (10,) | 10 | Σ_batch dz₃ | (10,) |
一个统一的形状规则:
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 之后检查:
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:
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 五个公式、偏差修正、三层 update ratio 0.31%/0.21%/0.17% -->
五个式子:
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:
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 循环里的五个动作
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 八轮曲线

<!-- 配图:左图 train/test loss,右图 train/test accuracy;两条实现完全重合 -->
| 轮 | train loss | train acc | test loss | test acc |
|---|---|---|---|---|
| 1 | 0.257427 | 92.26% | 0.135891 | 95.64% |
| 2 | 0.107054 | 96.73% | 0.099624 | 96.82% |
| 3 | 0.076629 | 97.63% | 0.094547 | 97.11% |
| 4 | 0.059616 | 98.12% | 0.103505 | 96.88% |
| 5 | 0.048294 | 98.38% | 0.097289 | 97.14% |
| 6 | 0.041455 | 98.60% | 0.098796 | 97.05% |
| 7 | 0.036202 | 98.85% | 0.104508 | 97.29% |
| 8 | 0.028913 | 98.99% | 0.104462 | 97.51% |
四个观察:
- 第一轮就到 95.64%,因为 128 和 64 两个隐藏层容量足够,MNIST 又相对简单。
- train loss 从 0.257 单调降到 0.029,train acc 从 92.3% 到 99.0%,模型在训练集上已经接近记住。
- test loss 在第 3 轮见底(0.0945),之后震荡上升到 0.1045;test acc 却继续升到 97.51%。这是过拟合的典型信号:模型对错例更「自信」,但分类边界还在变好。
- 如果按 test loss 做早停,会在第 3 轮停下,准确率 97.11%;按 test acc 做早停,会在第 8 轮停下,97.51%。两个指标给出的最优轮次不同,选哪个取决于部署时更在意「概率校准」还是「分类正确率」。
第 3 章会讲正则化、学习率衰减和早停。这一章只记录现象,不展开。
8.3 两条流水线的耗时

<!-- 配图:两条流水线步骤对照、8 轮总耗时、逐轮耗时柱 -->
| 流水线 | 8 轮总耗时 | 每轮范围 |
|---|---|---|
| 手写 NumPy | 2.96 s | 0.366–0.378 s |
| PyTorch autograd | 4.09 s | 0.415–0.582 s |
手写更快,原因不是 NumPy 比 PyTorch 快,而是少了三样东西:计算图的构建与遍历、算子分发、梯度对象的分配。PyTorch 的每一层都要记录 grad_fn、检查是否需要梯度、在反向时调用 C++ 的 autograd 引擎;这些开销在小网络上占比很高。
这个对比不是严格的性能测试。PyTorch 的优势在更大的网络、GPU、自动微分覆盖不到的复杂结构上。在这里,它的价值是「独立实现的裁判」,不是「更快的训练器」。
8.4 这 0.37 秒花在哪
一次前向的乘加次数:
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 一次前向、一次反向的逐项对照

<!-- 配图:四张指标卡 9.46e−14、0.0、0.0、2.3e−13 -->
训练开始前,先把同一组初始权重同步给 PyTorch:
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−17 | 1.311e−11 |
| b₁ | 5.551e−17 | 2.959e−15 |
| W₂ | 5.551e−17 | 3.925e−14 |
| b₂ | 2.776e−17 | 3.515e−16 |
| W₃ | 2.220e−16 | 6.909e−15 |
| b₃ | 1.110e−16 | 1.920e−16 |

<!-- 配图:六个梯度最大绝对差的对数点图,验收线 1e−6 -->
最大绝对差 2.22e−16,正好是 float64 在 1 附近的机器精度。最大相对差 1.31e−11,出现在 W₁ 的某个接近 0 的元素上——分母很小,相对误差被放大,但绝对差仍然是 1e−17 量级。
两个损失:
手写 3.8219637276134497
autograd 3.8219637278466783
差 2.33e−13损失差比梯度差大两个数量级,因为损失经过 log 和 sum,误差会累积。但 2.33e−13 仍然远低于验收线。
9.2 八轮之后
两条流水线各自从同一组初始权重出发,用同一份 batch 顺序训练 8 轮:
最终权重最大绝对差: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) 的结果可能差最后一位。差异来自三处:
- 归约顺序。
dz.T @ a_prev在 NumPy 里由 BLAS 按块累加,PyTorch 的Linear走的是另一套内核,中间累加的分组不同。 - 算子融合。 PyTorch 可能把
add和relu融进一个 kernel,中间结果不落内存,舍入点因此不同。 - 内存布局。 同一个矩阵按行主序或列主序遍历,缓存命中率不同,但这不改变数学结果,只改变累加顺序。
数量级可以对上: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 倍同量级。这说明误差没有被动力学放大;如果某一步的梯度符号错了,差异会按指数增长,而不是这样缓慢累积。
十、梯度校验:用数值差分当裁判

<!-- 配图:7 个 ε 的相对误差曲线,最低点 1e−5 处 1.036e−9 -->
autograd 只能证明「手写和 PyTorch 一样」,不能证明「两个实现都错了」。数值差分是独立的第三方裁判。
10.1 为什么用小网络
全量网络有 109,386 个参数,中心差分要对每个参数做两次前向:
109,386 × 2 = 218,772 次前向一次前向在 CPU 上约 0.1 毫秒,218,772 次就是二十多秒,而且要做七次 ε 扫描。所以校验用一个小网络:
sizes = [8, 5, 4, 3]
params = init_net(sizes, SEED + 7)
x = rng.normal(0, 1, (4, 8))
y = rng.integers(0, 3, 4)参数量:
8×5 + 5 = 45
5×4 + 4 = 24
4×3 + 3 = 15
合计 = 8484 个参数,4 个样本,一次 ε 扫描 168 次前向,七次也就 1,176 次,秒级完成。小网络能覆盖 ReLU、softmax、交叉熵、矩阵乘的全部路径,形状虽小,逻辑和大网络一致。
10.2 校验代码
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 个参数逐个做。
相对误差的定义:
rel = abs(a - n) / max(1e-12, abs(a) + abs(n))分母用 |a| + |n| 而不是 |a|,是为了对称:当解析梯度接近 0 时,不会因为分母太小而把误差放大成噪声。
10.3 七个 ε 的结果
| ε | 最大相对误差 | 最大绝对误差 |
|---|---|---|
| 1e−2 | 7.569e−4 | 5.773e−4 |
| 1e−3 | 7.578e−6 | 5.776e−6 |
| 1e−4 | 7.578e−8 | 5.776e−8 |
| 1e−5 | 1.036e−9 | 5.895e−10 |
| 1e−6 | 2.056e−8 | 2.473e−10 |
| 1e−7 | 2.723e−7 | 2.477e−9 |
| 1e−8 | 2.301e−6 | 1.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 每层的梯度范数

<!-- 配图:三层 W 的梯度范数 0.151/0.152/0.149,b 的梯度 0.0054/0.0047/0.0040 -->
第 8 轮,在 256 个样本上算一次梯度,取每层范数:
| 层 | ‖dW‖ | ‖db‖ |
|---|---|---|
| 1 | 0.151116 | 0.005383 |
| 2 | 0.151915 | 0.004694 |
| 3 | 0.149242 | 0.004011 |
三层权重的梯度范数几乎相等,误差不到 2%。这是一个好信号:反向信号没有在层间被系统性放大或缩小。它来自两个设计:ReLU 的正半轴导数为 1,不会像 Sigmoid 那样每层乘 0.25;He 初始化让每层的权重方差匹配 fan_in,前向和反向的尺度都稳定。
偏置的梯度只有权重的 3% 左右。原因不是偏置不重要,而是偏置的梯度是 Σ_batch dz:64 个样本的 dz 有正有负,相加会抵消一部分;权重的梯度是 dzᵀ a,每个样本的贡献是一个外积矩阵,不会像标量那样互相抵消。量级小不等于没在学,第 8 轮的 db 仍然在 0.004 量级,足以让偏置缓慢调整。
11.2 每层的权重范数

<!-- 配图:三层 W 范数 26.0/13.9/5.3,形状 128×784、64×128、10×64 -->
| 层 | ‖W‖ | 形状 |
|---|---|---|
| 1 | 26.033 | (128, 784) |
| 2 | 13.870 | (64, 128) |
| 3 | 5.317 | (10, 64) |
第一层最大,最后一层最小。这不是「第一层学得最多」,而是形状决定的:He 初始化下,W 的元素标准差是 sqrt(2/fan_in),范数的期望约是 sqrt(2·fan_out)。fan_out 从 128 降到 64 再到 10,范数自然跟着降。
所以跨层比较权重范数没有意义。要比较「学了多少」,得用相对量:
‖ΔW‖ / ‖W‖下一节会看到,这个比值三层都在千分之几,才是可比的指标。
11.3 零激活比例

<!-- 配图:三层 z≤0 比例随 epoch 变化,56%→69%、51%→57%、70%→70% -->
统计方法:每个 batch 算 (z <= 0).mean(),再按样本数加权平均。
| 轮 | 第 1 层 | 第 2 层 | 第 3 层 |
|---|---|---|---|
| 1 | 56.3% | 51.1% | 69.8% |
| 4 | 65.6% | 54.6% | 69.4% |
| 8 | 69.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 加深之后梯度还在不在

<!-- 配图:深度 2–8 的首层/末层梯度比,Sigmoid 从 0.31 掉到 5.5e−5,ReLU 稳定在 2 附近 -->
实验设置:固定前 64 个样本,隐藏层宽度固定 64,深度取 2、3、4、6、8。Sigmoid 用 Xavier 初始化,ReLU 用 He 初始化。指标是「首层权重梯度范数 / 末层权重梯度范数」。
| 深度 | Sigmoid | ReLU |
|---|---|---|
| 2 | 0.3135 | 2.8459 |
| 3 | 0.0712 | 2.1934 |
| 4 | 0.0213 | 2.8233 |
| 6 | 0.00124 | 2.1579 |
| 8 | 5.501e−5 | 2.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 1–512 -->
实验设置:取 1024 个样本,先算全量梯度 g_full;再对每个 batch 大小随机抽 20 次,算 batch 梯度 g_batch,比较余弦相似度和相对误差。
| batch | 余弦相似度 | 相对误差 |
|---|---|---|
| 1 | 0.2064 ± 0.1551 | 3.459 |
| 8 | 0.5952 ± 0.0623 | 1.399 |
| 32 | 0.8315 ± 0.0258 | 0.660 |
| 128 | 0.9542 ± 0.0063 | 0.324 |
| 512 | 0.9928 ± 0.0010 | 0.123 |
三个观察:
- batch=1 时余弦只有 0.206,标准差 0.155。方向几乎随机,单样本梯度不能代表全量。
- 余弦随 batch 增大而上升,标准差随 batch 增大而下降。batch 从 1 到 512,标准差缩小了 150 倍。
- 相对误差的下降比余弦慢:batch=512 时余弦已经 0.993,相对误差还有 0.123。因为余弦只测方向,相对误差还测长度;batch 梯度的长度也会波动。
代价在步数。60,000 个样本:
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 翻倍、学习率也翻倍」的经验规则。
十三、更新比例:每步到底改了多少

<!-- 配图:三层 ‖ΔW‖/‖W‖ 随 epoch 变化,范围 0.10%–0.42% -->
定义:
update_ratio = ‖ΔW‖ / ‖W‖ΔW 取的是该轮最后一个 batch 的 Adam 更新量。第 1 轮和第 8 轮:
| 层 | 第 1 轮 | 第 8 轮 |
|---|---|---|
| 1 | 0.374% | 0.309% |
| 2 | 0.175% | 0.214% |
| 3 | 0.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,深层的更新比例会明显小于浅层,因为深层的梯度经过更多层连乘,量级更小。
把「单步更新比例」写成可估算的形式:
‖Δ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%,但方向有正有负,不能简单相加。
十四、验收:五条线全部通过

<!-- 配图:五条验收指标表,97.51%、109,386、9.46e−14、1.036e−9、8 轮 2.96 s -->
| 指标 | 目标 | 实测 |
|---|---|---|
| 测试准确率 | ≥ 97% | 97.51% |
| 参数量 | 能手算核对 | 109,386 |
| 手写 vs autograd 权重差 | 相对误差 < 1e−6 | 9.46e−14 |
| 手写 vs 中心差分 | 相对误差 < 1e−6 | 1.036e−9(最优 ε) |
| 训练可重跑 | 固定种子 | 8 轮 2.96 s |
最终数字:
test loss = 0.104462
test acc = 97.51%
train acc = 98.99%
8 轮总耗时 = 2.96 s
与 PyTorch 的最终权重最大绝对差 = 9.46e−1497.51% 不是这个数据集的极限。第 4 章 CNN 会到 99% 以上,第 3 章调优化器和正则化也能再往上。这一篇的 97.51% 是一个「结构没搭错、梯度没算错、训练没跑偏」的证明,不是性能上限。
还要记录一个不完美的地方:test loss 在第 3 轮之后开始上升,最终 0.1045,比最低点 0.0945 高了 10%。test acc 却在上升。这说明模型在后几轮把一些原本不确定的样本推到了正确一侧,同时对错例变得更加自信。准确率和概率校准在这里分道扬镳,部署时选哪个指标,要看业务是否需要用概率做阈值。
十五、常见坑与排查表
手写反向传播的错误大多不会报错,只会让结果变差。下面这张表按现象排查。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| loss 变成 nan | softmax 没减最大值;学习率太大;输入没归一化 | 打印 logits 的最大值;检查 p 是否有限;先把 lr 降到 1e−4 |
| loss 停在 2.3 左右 | 全零初始化;标签没对齐;梯度没真正更新 | 检查初始化方差;打印一个 batch 的 x 和 y;更新前后对比权重 |
| 浅层梯度全是 0 | ReLU 大面积死亡;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;用矩阵运算替代循环 |
三条最值得记住的:
- 先形状,后数值。 所有
dW必须和W同形状,所有db必须和b同形状。形状对了,八成的 bug 就没了。 - 先单样本,后 batch。 单样本的梯度可以手算,batch 只是多了一步平均。单样本对不上,batch 一定对不上。
- 先 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 重跑
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.pytrain_manual.py 会依次做六件事:
- 读 MNIST、标准化、按种子打乱;
- 初始化
784→128→64→10,打印参数量 109,386; - 单步对照 autograd;
- 手写训练 8 轮;
- PyTorch 训练 8 轮;
- 梯度校验、深度实验、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 | 七个 ε 的相对/绝对误差 |
depths | Sigmoid/ReLU 在五个深度上的首末层梯度比 |
batch_noise | 五个 batch 大小的余弦与相对误差 |
manual 里的 dead_relu | 三层零激活比例 |
manual 里的 update_ratio | 三层单步更新比例 |
16.4 重跑对不上时先查三件事
如果重跑后准确率不是 97.51%、或者梯度差不是 2.22e−16,按顺序检查:
- 种子与线程数。
SEED = 20260801、torch.manual_seed(SEED)、torch.set_num_threads(4)三个都要在训练前设置;线程数不固定,BLAS 归约顺序会变,最后几位就变。 - 数据顺序。 手写和 PyTorch 必须共用同一份
batches;如果 PyTorch 侧又建了一个DataLoader,即使shuffle=True和同一个种子,抽样顺序也未必一致。 - dtype。 两边都必须是
float64。PyTorch 侧忘了.double(),或者 NumPy 侧某次运算被提升成float32,梯度差会从 1e−16 跳到 1e−7。
只有这三项都确认,数字才有可比性。环境不同(BLAS 实现、CPU 架构)仍可能让最后几位变化,但不会改变 97.51% 和 1e−14 这两个量级。
十七、这一章没解决的问题
反向传播现在是一个能逐位复现的算法,不是黑箱。但还有四个问题没有回答:
- 梯度方向对了,步子怎么迈?固定学习率 1e−3 在第 3 轮之后 test loss 开始上升,衰减学习率能不能压住?
- 97.51% 离 99% 还差一截。差的是模型容量、正则化,还是数据增强?
- 全连接层把 28×28 拉平成 784 维,丢掉了空间结构。换 CNN 能补回多少?
- 层数继续加深,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 章「训练工程」。