差 0.0001:二十六步压成六组,手写的梯度训出了同一个模型
前三篇把 loss 到每一个参数的路走完了 —— 二十六个中间量和参数,每个都被 cmp 验过一次。
但那是打开黑盒逐步走。这一篇做相反的事:把黑盒合回去。
交叉熵那八步压成一行,BatchNorm 那十步压成一行,用的是同一个方法。压完之后,
把 loss.backward() 注释掉,用手写的梯度真训 20 万步 —— 看看训不训得起来。
先摆一件贯穿全篇的事
这张图上最该带走的一条:参数没有 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) # ① 算出 pdlogits[range(n), Yb] -= 1 # ② 在正确答案位置减 1dlogits /= 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_invx̂_b = (x_b − μ) · v ← 代码里的 bnrawy_b = γ · x̂_b + β上游给下来的是 dy_b = ∂L/∂y_b。
A 穿过 γ —— 形态 A,逐元素:
∂L/∂x̂_b = γ · dy_bB ∂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̂_kbndiff 换成了 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̂_kD σ² 到 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_kF 三条路加起来
∂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̂ 不相关 |
为什么必须是 0:BN 的输出永远是均值 0、方差 1 —— 所以「把整列 hprebn 同时加一个常数」
或「同时乘一个常数」,BN 都会把它抵消掉,输出完全不变。
既然这两个方向对 loss 没有任何影响,梯度在这两个方向上的分量就必须是 0。
🔑 这跟第十九篇「
dlogit_maxes必须恰好是 0」是同一类现象,但升了一级。 那里是一个标量恒为 0(减最大值是纯数值技巧,对输出毫无影响); 这里是两个方向的分量恒为 0。第十九篇那条操作规则说「『梯度应该是 0』本身是可验证的性质」—— 这是它最强的版本:前向里存在多少个「不影响输出的自由度」,反向的梯度就必须在多少个方向上是 0。
实测:
print(dhprebn.sum(0).abs().max()) # 2.6e-09print((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) + bnbiasunbiased=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—Ex3 | Ex4 | |
|---|---|---|
| batch | 固定一个 | 每步重新随机抽 |
loss.backward() | 跑,当答案 | 注释掉 |
| 参数更新 | 不更新 | 每步更新 |
| 步数 | 1 | 20 万 |
n_hidden | 64(手推时下标才不失控) | 200(参数 4137 → 12297) |
从二十六步缩到六组
bndiff、counts_sum 那些中间量一个都不用算了:
| 组 | 来自 | 产出 |
|---|---|---|
| ① cross_entropy 一行式 | 本篇 Ex2 | dlogits |
| ② Linear 2 | 第二十篇 | dh / dW2 / db2 |
| ③ 穿过 tanh | 第二十篇 | dhpreact |
| ④ BN 的两个参数 | 第二十篇 ①② | dbngain / dbnbias |
| ⑤ BatchNorm 一行式 | 本篇 Ex3 | dhprebn |
| ⑥ Linear 1 + 嵌入表 | 第二十一篇 | dW1 / db1 / dC |
💡 第 ④ 组不能省。 Ex3 只合并了「穿过 BN 到
hprebn」那条路, BN 自己的 γβ 还得单独求 —— 那是参数,不在被压掉的那条链上。
三处要注意的
① 整段包在 with torch.no_grad(): 里。 它做的事是这个块里不建计算图:
y1 = x * 3print(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.0713val 2.1105| val loss | 参数 | |
|---|---|---|
| 第十六、十七篇(PyTorch 反向) | 2.1106 | 12097 |
| 本篇(手写反向) | 2.1105 | 12297 |
差 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()里面是什么,你已经知道了。