bobofamilyNIU CHUN
22/36

差 0.0001:二十六步压成六组,手写的梯度训出了同一个模型

2026-09-2017 分钟—阅读

前三篇把 loss 到每一个参数的路走完了 —— 二十六个中间量和参数,每个都被 cmp 验过一次。

但那是打开黑盒逐步走。这一篇做相反的事:把黑盒合回去。

交叉熵那八步压成一行,BatchNorm 那十步压成一行,用的是同一个方法。压完之后, 把 loss.backward() 注释掉,用手写的梯度真训 20 万步 —— 看看训不训得起来。


先摆一件贯穿全篇的事

圆圈连线图抓的是连接结构,但它画的是一条样本 —— 所以「参数被 32 条共用」和「BatchNorm 让样本互相影响」这两件事,在那套语言里根本无法表达。而这两件事正好决定了接下来两节的形状。

这张图上最该带走的一条:参数没有 batch 维。

W2 是 (64,27),一份;h 是 (32,64),32 行。32 条样本共用同一份参数, 所以反向时 32 份意见必须汇总成一份 —— 这就是反向里到处是 sum(0) 的原因, 第二十篇那九步里全是它。

💡 以后看任何网络图,顺手给每个节点标上真实形状,再问一句:这一层的参数有 batch 维吗? 答案永远是没有 ⟹ 反向必然要把 batch 维消掉。


任务变了:先在纸上化简,再翻译成代码

前三篇是「前向摊成原子操作,一步一步倒推」。Ex2 和 Ex3 反过来:

前向中间变量要算几个梯度
Ex1(前三篇)摊开的二十多行counts、bnvar、bndiff…26 个
Ex2 / Ex3合并成一个式子一个都没有各 1 个

方法只有一句话:

🔑 数一数变量在前向里出现了几次,反向就有几项。 Ex2 是两次 → 两项,Ex3 是三次 → 三项。不是凑出来的,是数出来的。

这其实是第十九篇第三条操作规则(「要求谁的梯度,就搜它在前向里出现在哪些等号右边」) 在化简之后的用法 —— 那时搜的是代码行,现在搜的是一个式子里的各处出现。


八步压成一行

为什么不用从 logit_maxes 开始

Ex1 的第一步是 logit_maxes,Ex2 里它根本不存在 —— F.cross_entropy 内部照样减最大值 防 exp 溢出,但那个中间结果不暴露出来。

🔑 反向传播沿前向的依赖图倒着走,前向有几个中间变量,反向就有几个梯度要算。 前向合并之后中间变量全没了,所以只剩 dlogits 一个。

而且第十九篇第 ⑧ 步已经证明过:dlogit_maxes 恒等于 0(softmax 的平移不变性)。 既然那条路的贡献是 0,跳过它当然不影响结果 —— 这正好解释了两种算法为什么答案一致: Ex1 那条「经过 logit_maxes 的路」加的是个 0,等于没加。

化简:log 把除法拆成减法

对一条样本(省掉样本下标 b,正确答案是 y):

L = −log p_y = −log( e^(z_y) / Σ_k e^(z_k) )

用 log(a/b) = log a − log b 展开:

L = −z_y + log Σ_k e^(z_k)
└─┬─┘ └──────┬──────┘
第一项 第二项

数字验一下(缩成 4 类,正确答案 y=2,z = [1.2, 1.9, 3.0, 1.2]): −3.0 + log(33.42) = −3.0 + 3.51 = 0.51 ✓

表达式含哪些 logits
第一项−z_y只含正确答案那一个
第二项log Σ_k e^(z_k)含全部 27 个

🔑 化简之后,前向从「一个分式」变成了「两项相加」。 除法没了 → 不用商法则;中间量全消失了 → 不用一步步算。 Ex1 那八步全是在把这个分式硬算出来。

对照求导

第一项 −z_y:j = y 时导数是 −1;j ≠ y 时式子里根本没有 z_j,导数是 0。 合起来就是 −1[j=y]。

第二项 log Σ_k e^(z_k):它含全部 27 个,对谁求导都不为 0。用 d(log f)/dx = f'/f, 而求和式里只有 k=j 那一项含 z_j(其余都是常数),所以分子是 e^(z_j):

∂/∂z_j · log Σ_k e^(z_k) = e^(z_j) / Σ_k e^(z_k) = p_j

💡 这一步很巧:求导之后那一堆 exp 自己变回了 p_j —— softmax 又出现了。 这就是为什么最终公式里只需要一个 F.softmax。

两项相加:

∂L/∂z_j = p_j − 1[j=y]

前向两项,反向两项,一一对应:前向「只挑一个」→ 反向「只在那一个位置动手」; 前向「汇总全部」→ 反向「每个位置都有贡献」。

1[·] 是指示函数,条件成立是 1、不成立是 0。把它对所有 j 排成一行,就是 one-hot 向量 —— 它的作用是把分段函数压成一个表达式。

这个结果第十篇就给过了(∂L/∂logits = (prob − onehot(y)) / N),连「正确答案梯度为负 → 推高, 其余为正 → 压低」那套直觉也讲过,末尾还写着「V5 那一集要你亲手推这个化简」。 这一节兑现的就是那句话 —— 新的是推导,不是结果。

那个 1/n 是哪来的

前面推的是一条样本的 loss,而代码里的 loss 是 32 条的平均。 z[b,c] 只出现在 L_b 里(第 b 条的 logits 不影响别的样本的 loss),其余 31 项都是常数:

∂loss/∂z[b,c] = (1/n) · (p[b,c] − 1[c = y_b])

这就是形态 C,而且 1/n 这个局部导数已经见过两次了:

前向局部导数在哪
.mean()1/n这里
bnmeani = 1/n * hprebn.sum(0)1/n上一篇第 ⑨ 步
bnvar = 1/(n-1) * bndiff2.sum(0)1/(n−1)上一篇第 ⑥ 步

它跟第十九篇第 ① 步那个 −1/n 同源,区别只在乘的时机: Ex1 第一步先乘、后面八步一路传下去;Ex2 跳过中间,最后统一乘。乘法可以交换顺序。

★ 如果 loss 用 .sum() 而不是 .mean()

没有这个 1/n,梯度直接是 p − onehot,大 32 倍。

后果不只是数值大:batch size 一变,梯度大小跟着变,学习率就得跟着调。 用 .mean() 就没这个问题 —— 梯度大小与 batch size 无关。 这就是几乎所有框架的 loss 默认是 mean 而不是 sum 的原因。

公式 → 代码

dlogits = F.softmax(logits, 1) # ① 算出 p
dlogits[range(n), Yb] -= 1 # ② 在正确答案位置减 1
dlogits /= n # ③ 除以 n

三个符号,三行代码。 第 ② 行的索引 [range(n), Yb] 跟第十九篇第 ① 步一模一样 —— 都是「每行选出正确答案那一列」。代码不用真造一个 one-hot 矩阵再相减, 直接在该减的位置减就行。

⚠️ 这次 exact: False 才是对的,True 反而可疑

logits | exact: False | approximate: True | maxdiff ~8e-9

两种算法数学上等价,但浮点运算路径不同:Ex1 走几十次运算,Ex2 走三次。 舍入误差累积不一样,结果必然差在末几位。

🔑 跟第十九篇第 ⑧ 步那个 False 性质不同:那里是「梯度本该恰好是 0,两个数抵消后剩零头」; 这里是实打实的非零数,两条路径都对,只是都不精确。

⚠️ 反过来说,如果这里给出 exact: True,那才可疑 —— 说明很可能不小心复用了 Ex1 的结果,而不是独立算了一遍。

这是 cmp 那张解读表第三次被用到,而且每次的用法都不一样:

结论
第十九篇False + approximate: True + maxdiff ~1e−9 = 对的,纯舍入
第二十篇maxdiff 小不等于对 —— 判据是 approximate 那一列(系数差 3% 的那个错)
本篇这里出现 True 反而可疑 —— 两条路径不该给出逐位相同的结果

每行的和还是 0

Σ_c ( p[b,c] − 1[c=y_b] ) = 1 − 1 = 0

因为概率加起来是 1,one-hot 也加起来是 1。 这跟第十九篇第 ⑦ 步那个「dnorm_logits 每行和为 0」是同一件事 —— 那时是先观察再证明的,现在从公式上直接看得见。


十步压成一行

同一个方法,换个对象。BatchNorm 合并成一个式子:

y[b,j] = γ_j · ( x[b,j] − μ_j ) / σ_j + β_j

(为了式子短,把 √(σ² + ε) 简写成 σ。)

数一数 x 出现了几次

出现位置怎么出现的
① 分子里直接出现
② 分子的 μ_j 里μ_j = (1/n)(… + x[b,j] + …)
③ 分母的 σ_j 里σ_j 含 (x[b,j] − μ_j)²

三次出现 ⟹ 三条路 ⟹ 反向公式三项。

这正好把上一篇那个「两个分叉点」换了个说法:那时是在摊开的代码里数分叉, 现在是在合并的式子里数出现次数 —— 数的是同一件事。

❗ 先证「三次」就是全部

「数出现次数」这句话有个前提得先堵上:σ² 的式子里也含 μ,那 x → μ → σ² 是不是第四条路?

∂σ²/∂μ = (1/(n−1)) Σ_k 2(x_k − μ)·(−1) = −2/(n−1) · Σ_k (x_k − μ)

而 Σ_k (x_k − μ) ≡ 0 —— 那正是 μ 的定义(减掉均值之后总和为零)。所以这条路恒等于 0。

🔑 三条就是全部,不是近似,也不是忽略不计。 少了这一步,「数出现次数」只是个口诀;有了它才是个判据。

逐项推

先把符号定下来(第 j 列,n 条样本,下标 j 一律省掉):

μ = (1/n) Σ_k x_k
σ² = (1/(n−1)) Σ_k (x_k − μ)²
v = (σ² + ε)^(−1/2) ← 代码里的 bnvar_inv
x̂_b = (x_b − μ) · v ← 代码里的 bnraw
y_b = γ · x̂_b + β

上游给下来的是 dy_b = ∂L/∂y_b。

A 穿过 γ —— 形态 A,逐元素:

∂L/∂x̂_b = γ · dy_b

B ∂L/∂v —— v 是 (1,64),被整列 n 个 x̂ 共用,缺样本下标 ⟹ 形态 B,对 k 求和:

∂L/∂v = Σ_k (∂L/∂x̂_k)(∂x̂_k/∂v) = γ Σ_k dy_k (x_k − μ)

这里用一次那个关键代换 x_k − μ = x̂_k / v:

= (γ/v) Σ_k dy_k · x̂_k

bndiff 换成了 bnraw —— 之所以要换,是因为代码里 bnraw 是现成的,而 bndiff 在压缩之后根本不算了。

C v 到 σ² —— 幂函数求导,n = −1/2:

∂v/∂σ² = −½ (σ² + ε)^(−3/2) = −½ v³
∂L/∂σ² = (γ/v)(Σ_k dy_k x̂_k) · (−½ v³) = −½ γ v² Σ_k dy_k x̂_k

D σ² 到 x_b —— 求和式里只有 k=b 那一项含 x_b:

∂σ²/∂x_b = 2(x_b − μ)/(n−1)

⚠️ 那个 n−1 第一次出现在这里。 它一路带到最后,就是公式里的 n/(n−1)。

E ∂L/∂μ —— μ 同样被整列共用,形态 B:

∂L/∂μ = Σ_k (∂L/∂x̂_k)(∂x̂_k/∂μ) = γ Σ_k dy_k · (−v) = −γ v Σ_k dy_k

F 三条路加起来

∂L/∂x_b = γ·dy_b · v ← ① 直接
+ (−γ v Σ_k dy_k) · (1/n) ← ② 经 μ
+ (−½ γ v² Σ_k dy_k x̂_k) · 2(x_b − μ)/(n−1) ← ③ 经 σ²

第三项再用一次 x_b − μ = x̂_b / v,v² 约掉一个:

③ = −γ v · x̂_b · Σ_k dy_k x̂_k / (n−1)

现在三项都带 γv,提出来:

∂L/∂x_b = γv · [ dy_b − (1/n) Σ_k dy_k − x̂_b Σ_k dy_k x̂_k / (n−1) ]

再把 1/n 也提到括号外,第 ③ 项的 1/(n−1) 就变成了 n/(n−1):

∂L/∂x_b = γv/n · [ n·dy_b − Σ_k dy_k − n/(n−1) · x̂_b · Σ_k dy_k x̂_k ]

这就是那三行代码,v 是 bnvar_inv、x̂ 是 bnraw:

dhprebn = bngain * bnvar_inv / n * ( # 公因子 γv/n
n * dhpreact # ① 自己
- dhpreact.sum(0) # ② 均值的修正
- n/(n-1) * bnraw * (dhpreact * bnraw).sum(0) # ③ 方差的修正
)

前向三次出现,反向三项,代码三行。 两个 .sum(0) 就是 B 和 E 那两条跨样本的求和。

✅ 这条链子单独核过一遍:用纯 Python 按上式算出 ∂L/∂x,再对同一组随机输入做中心差分 数值微分,n=32 时最大相对误差 9.4e−09;中间的 ∂L/∂v、∂L/∂μ、∂L/∂σ²、∂σ²/∂x_b 也各自和数值微分对齐到小数点后六位。

🔑 那个 n/(n−1) 是一路推出来的,不是凑的 —— 源头在第 D 步 ∂σ²/∂x_b 的分母, 提 1/n 出去时变成 n/(n−1)。这就是 Bessel 修正的痕迹: 教科书用 1/n 算方差时,这个系数就是 1,整个式子一模一样。 上一篇第 ⑥ 步那个「第一次遇到系数不是 1 的求和反向」,在这里变成了式子里的一个因子。

为什么后两项是「减」

看前向 x̂ = (x − μ)/σ:x[b,j] 变大,μ_j 也跟着变大 → 分子的增量被部分抵消; 同时 σ_j 也跟着变大 → 分母变大,进一步压缩。

🔑 BN 会自己「抵抗」输入的变化,所以梯度里要减掉这两部分抵抗。 直接效果被均值和方差的连锁反应削弱了,反向必须如实反映。

★ 两个必须是 0 的量:BN 的反向是一次投影

把三项重新组织(忽略系数):

dx ∝ dy − dy的整批平均 − x̂ · (dy 与 x̂ 的相关)
└─── 第②项 ───┘ └─────── 第③项 ───────┘

这两项各自保证一件事:

项保证
减去平均整列梯度的和为 0
减去相关项梯度跟 x̂ 不相关
上半是前向:整列加一个常数、整列乘一个常数,BatchNorm 都会抵消掉,三组 x̂ 逐位相同。既然这两个方向对 loss 毫无影响,反向的梯度在这两个方向上的分量就必须恰好是 0 —— 下半那条垂线就是被减掉的部分。

为什么必须是 0:BN 的输出永远是均值 0、方差 1 —— 所以「把整列 hprebn 同时加一个常数」 或「同时乘一个常数」,BN 都会把它抵消掉,输出完全不变。 既然这两个方向对 loss 没有任何影响,梯度在这两个方向上的分量就必须是 0。

🔑 这跟第十九篇「dlogit_maxes 必须恰好是 0」是同一类现象,但升了一级。 那里是一个标量恒为 0(减最大值是纯数值技巧,对输出毫无影响); 这里是两个方向的分量恒为 0。

第十九篇那条操作规则说「『梯度应该是 0』本身是可验证的性质」—— 这是它最强的版本:前向里存在多少个「不影响输出的自由度」,反向的梯度就必须在多少个方向上是 0。

实测:

print(dhprebn.sum(0).abs().max()) # 2.6e-09
print((dhprebn * bnraw).sum(0).abs().max()) # 2.9e-07

⚠️ 第二个比第一个大两个数量级,是正常的 —— 它多做了一次逐元素乘法再求和, 舍入误差被 bnraw 的量级(±2 左右)放大了一次。 判断标准不是「谁更小」,而是跟数值本身的量级比:dhprebn 里的数在 1e−3 量级, 1e−7 已经是完全抵消了。

⚠️ unbiased=True 不能漏

验证前向合并时:

hpreact_fast = bngain * (hprebn - hprebn.mean(0, keepdim=True)) / \
torch.sqrt(hprebn.var(0, keepdim=True, unbiased=True) + 1e-5) + bnbias

unbiased=True 对应的就是 1/(n−1)。写成 False 前向就跟 Ex1 对不上 —— 会差出几个百分点,不是 1e−7。这跟上一篇那个「1/n 还是 1/(n−1)」是同一个坑的另一面。

maxdiff 的大小取决于中间量的量级,不是步数

Ex2 那次 maxdiff 是 8.1e−09,这次 Ex1 走了十步反而只有 9.3e−10。

因为误差大小取决于中间量的量级 —— BN 的中间量(bnvar、bnvar_inv)都在 1 附近, 舍入没怎么被放大;而 softmax 那边 counts_sum 可能是几十。


撤掉 backward()

前面所有验证都是在一个固定的 batch 上、验一步。Ex4 要回答另一个问题: 用这些手写的梯度,模型真的能训起来吗?

Ex1—Ex3Ex4
batch固定一个每步重新随机抽
loss.backward()跑,当答案注释掉
参数更新不更新每步更新
步数120 万
n_hidden64(手推时下标才不失控)200(参数 4137 → 12297)

从二十六步缩到六组

bndiff、counts_sum 那些中间量一个都不用算了:

组来自产出
① cross_entropy 一行式本篇 Ex2dlogits
② Linear 2第二十篇dh / dW2 / db2
③ 穿过 tanh第二十篇dhpreact
④ BN 的两个参数第二十篇 ①②dbngain / dbnbias
⑤ BatchNorm 一行式本篇 Ex3dhprebn
⑥ Linear 1 + 嵌入表第二十一篇dW1 / db1 / dC

💡 第 ④ 组不能省。 Ex3 只合并了「穿过 BN 到 hprebn」那条路, BN 自己的 γβ 还得单独求 —— 那是参数,不在被压掉的那条链上。

三处要注意的

① 整段包在 with torch.no_grad(): 里。 它做的事是这个块里不建计算图:

y1 = x * 3
print(y1.grad_fn) # <MulBackward0> ← 记住了「我是乘法来的」
with torch.no_grad():
y2 = x * 3
print(y2.grad_fn) # None ← 什么都没记

🔑 这正好印证第十九篇第 ⑦ 步那个观察:反向需要前向的中间结果, 所以它们必须留在显存里。关掉反向,就不用留了。 忘了写照样能跑出正确结果 —— 这不是正确性问题,是显存和速度问题。

② p.data += -lr * grad —— 梯度指向 loss 上升最快的方向,所以要减。 学习率前 10 万步 0.1、后 10 万步 0.01,大步走完再小步精调。

③ grads 的顺序必须跟 parameters 对齐。

parameters = [C, W1, b1, W2, b2, bngain, bnbias]
grads = [dC, dW1, db1, dW2, db2, dbngain, dbnbias]

⚠️ 错位了不会报错(只有形状不匹配才会),但训练会彻底跑飞。

running 统计量为什么必须有

bnmean_running = 0.999 * bnmean_running + 0.001 * bnmean

指数滑动平均,每步用 0.1% 的新值更新,相当于记住最近约 1000 步。

🔑 推理时 batch 只有 1 条,x.std(0) 是 nan —— 根本算不出 μσ。 这就是 model.train() / model.eval() 存在的唯一原因:切换的就是 「现场算 μσ」还是「用训练期攒好的」。第十六篇实现过一次,这是第二次用上。

评估时的前向跟训练只差一行:

# 训练:当场算这一批的 μσ
hpreact = bngain * (hprebn - bnmean) * (bnvar + 1e-5)**-0.5 + bnbias
# 评估:用训练期攒的
hpreact = bngain * (hprebn - bnmean_running) * (bnvar_running + 1e-5)**-0.5 + bnbias

结构完全一样,只是 μσ 的来源变了。 如果评估时也用当前 batch 的 μσ, 同一个模型喂 22655 条和喂 1 条会给出不同预测 —— 模型就不确定了。

★ 验收

train 2.0713
val 2.1105
val loss参数
第十六、十七篇(PyTorch 反向)2.110612097
本篇(手写反向)2.110512297

差 0.0001 —— 已经是浮点噪声的量级。

❗ 参数数差了 200,但两个模型其实是同一个。 差的那 200 个就是 b1: 第十六篇算过这笔账,BN 前面那一层的 bias 是失效的,所以那边把它去掉了; V5 的起始代码留着它(而且第二十篇讲过,它还被故意初始化成非零,为的是防止假 True)。

留着也没用 —— hprebn = embcat @ W1 + b1 给整列加了同一个常数, 而下一行 bndiff = hprebn - bnmeani 里 bnmeani 也跟着涨了同一个数,正好抵消。 这正是本篇上一节那个「整列加常数,BN 会抵消掉」的直接后果: b1 落在那个「做了等于没做」的方向上。 所以两边比的是同一个模型。

🔑 为什么这比 cmp 更有说服力

cmp 验证的是一个 batch、一步,那是静态的。而如果某个梯度有系统性偏差, 单步 cmp 可能过得去,20 万步之后 loss 曲线会分叉、val loss 会明显偏高。

上一篇那个「只差 3% 的错」就是现成的例子:1/n 和 1/(n−1) 记混了, maxdiff 小得像浮点误差 —— 单看那一个数很容易放过去。 而 20 万步的累积不会放过它。

🔑 cmp 验的是「这一步的式子对不对」,Ex4 验的是「二十六步凑在一起,跑 20 万步不跑偏」。 两个是不同的问题,缺一不可。

train 和 val 只差 0.039

没有明显过拟合。 12297 个参数对 18 万条训练样本来说还是偏少, 模型处在欠拟合那一侧 —— 加宽加深还能继续降 loss。 (如果 train 掉到 1.5 而 val 还是 2.1,那才是过拟合,模型在背训练集。)

第十二篇立的那把尺子(先有 train/val/test 再谈调参)在这里第一次给出了跨篇可比的读数。


V5 完结

从第十八篇的工具,到这一篇的 0.0001,这一集做完了。真正带得走的不是那二十六个式子:

带走的以后在哪用
形状直觉(sum(0) 还是 sum(1))调试任何形状 bug
中间量必须留在显存里OOM、gradient_checkpointing
广播的反向是求和、求和的反向是广播写自定义算子
「梯度应该是 0」是可验证的性质验证任何「应该不影响结果」的改动
model.eval() 具体在切什么推理部署

backward() 从此不再是黑盒。下次遇到梯度异常,你知道该去看哪一步。

下一集(V6,WaveNet 式的层级结构)跳过。 它把这个 MLP 加深加宽,loss 还能再降一截, 但用的还是这一篇拆完的那套零件 —— 按性价比取舍,直接进 V7。 下一篇从莎士比亚和 tokenizer 开始,一路搭到 Transformer;而 backward() 里面是什么,你已经知道了。