同一个偏差,既当分子又去造分母:BatchNorm 反向的十三步
上一篇的八步停在 logits。那是神经网络的边界 —— 那八步全是 softmax 和对数,
一个参数都没碰到。
这一篇往里走十三步:第 4 段四个求导,第 5 段九个。参数第一次出现。
从这一步起,梯度开始有用
前面八步算的全是中间量的梯度 —— dprobs、dcounts、dnorm_logits,算完就扔。
它们存在的唯一理由是把梯度往下游传。
dW2 不一样:
W2.data -= lr * dW2 # 第 9 段会这么用这是第一个能直接拿去更新模型的东西。
四千多个参数不是一次拿完的,三段各管一摊:
| 参数 | 个数 | 哪一段 |
|---|---|---|
W2 · b2 | 1755 | 第 4 段(本篇) |
bngain · bnbias | 128 | 第 5 段(本篇) |
W1 · b1 · C | 2254 | 第 6 段(下一篇) |
| 合计 | 4137 |
W2 是 64×27 = 1728 加上 b2 的 27,1755 个,占全部参数的 42% —— 一个输出层就吃掉四成,
因为 27 个类别那一维比隐藏层的 64 只小一半,而它前面是 64。
h 是谁生产的,又被谁使用
前向对应的是这两行:
h = torch.tanh(hpreact) # (32,64)logits = h @ W2 + b2 # (32,27)❗ 生产 h 的权重是 W1,不是 W2
「h 是权重乘上一层的输出」这个说法对一半 —— 那个权重是 W1:
| 输入 | 权重 | 输出 | |
|---|---|---|---|
| Linear 1 | embcat (32,30) | W1 (30,64) | hprebn (32,64) |
| Linear 2 | h (32,64) | W2 (64,27) | logits (32,27) |
🔑 h 有两个身份:它是第一层的产物(被
W1生产出来),同时是第二层的输入(被W2使用)。 第 4 段处理的是第二个身份。
h 家族:三个名字,三个位置
| 变量 | 名字拆解 | 是什么 |
|---|---|---|
hprebn | h + pre + bn | BatchNorm 之前的隐藏层值 |
hpreact | h + pre + activation | 激活函数之前的隐藏层值 |
h | hidden | 隐藏层最终输出,值域 (−1, 1) |
三个形状全是 (32,64)。 BatchNorm 和 tanh 都不改变维度 —— 这正好是上一篇那张三类层分工表的结论: 只有混合层会改维度(30→64、64→27),非线性和归一化都不会。
矩阵乘的反向:凑形状快,推下标才知道为什么
形态 D 的判据上一篇推完了,这里不重讲。值得记的是实战里怎么快。
前向写成下标形式:
logits[b,j] = Σ_i h[b,i] · W2[i,j] + b2[j] i 从 0 到 63靠形状凑:三秒钟出答案
已知:dlogits (32,27)、h (32,64)、W2 (64,27)
凑 dh,目标 (32,64): (32,64) = (32,27) @ (27,64) dh dlogits W2.T ✅ 只有这一种
W2 @ dlogits → (64,27) @ (32,27) 内维 27≠32 ✗ dlogits.T @ W2 → (27,32) @ (64,27) 内维 32≠64 ✗
凑 dW2,目标 (64,27): (64,27) = (64,32) @ (32,27) dW2 h.T dlogits ✅候选极少,而且矩阵乘对形状很挑剔 —— 内维对不上直接报错,不会悄悄算出个错的。 所以凑形状在这里是可靠的。
但凑形状不告诉你求和遍历的是谁
dh = dlogits @ W2.T # (32,27) @ (27,64)dW2 = h.T @ dlogits # (64,32) @ (32,27)两个式子长得像,物理含义完全相反:
| 求和遍历 | 含义 | |
|---|---|---|
dh[b,i] | j(输出维,27 个) | 一个激活值影响了 27 个输出,汇总 27 个输出的意见 |
dW2[i,j] | b(样本维,32 条) | 一个权重被 32 条样本共用,汇总 32 条样本的意见 |
上一篇的钩子正是这个:第十三篇写「不用背,靠形状凑」,上一篇接上去说「其实能推」,
而推完才知道 dW2 里被转置的为什么是 h 不是 dlogits —— 因为要收缩的是样本维。
凑形状给不出这一层。
db2:缺样本下标,沿样本方向求和
b2[j] 缺下标 b,形态 B:
db2 = dlogits.sum(0)⚠️ 这次是
sum(0)不是sum(1)。 上一篇八步里的sum全是sum(1)(沿类别方向), 因为那些变量形状是(32,1)。b2的形状是(27,),缺的是样本维。⚠️ 也不加
keepdim——b2本身就是一维(27,),不是(1,27)。
穿过 tanh
局部导数是 1 - h**2,用的是 tanh 的输出:
dhpreact = (1.0 - h**2) * dh这一行是全系列第三次出现了 —— 第十四篇用它解释饱和为什么掉灭梯度、第十七篇用它算过量,
这次是亲手推出来的(商法则,令 f = eˣ − e⁻ˣ、g = eˣ + e⁻ˣ,注意 f' = g 且 g' = f)。
那张 1−h² 取值表也在第十四篇,不重复。
🔑 「用前向输出当局部导数」这是第二次:上一篇第 ⑦ 步的
exp用的是counts, 这里 tanh 用的是h。两处都省掉了一次重算。
BatchNorm 的九步
前向七行:
bnmeani = 1/n * hprebn.sum(0, keepdim=True) # (1,64) 每列均值 μbndiff = hprebn - bnmeani # (32,64) 去中心化 x−μ ⚠️分叉bndiff2 = bndiff**2 # (32,64) 平方bnvar = 1/(n-1) * bndiff2.sum(0, keepdim=True) # (1,64) 方差 σ²,Bessel 除 n−1bnvar_inv = (bnvar + 1e-5)**-0.5 # (1,64) 1/σ,ε 防除零bnraw = bndiff * bnvar_inv # (32,64) 标准化完成hpreact = bngain * bnraw + bnbias # (32,64) γβ 调回去 ★唯一用参数的一行BatchNorm 做什么、为什么要做、γβ 拿什么换,第十六篇整篇在讲,这里只用它的代码。
E[ax+b] 和 Var(ax+b) 那两条运算规则在第十五篇。
两个写法上的细节:
(bnvar + 1e-5)**-0.5 一个指数完成两步 —— x^0.5 是开方、x^-1 是取倒数,
所以 x^-0.5 就是 1/√x。验算 4.0 ** -0.5 = 0.5 ✓。
+ 1e-5 是防除零:某个神经元在这 32 条样本上输出完全一样时方差是 0,1/√0 直接炸成 nan。
加上 ε 之后得 1/√1e−5 ≈ 316 —— 很大,但至少有限。PyTorch 的 nn.BatchNorm1d 默认也是 1e-5。
轴换了:这一段全是 sum(0)
bnmeani.shape # (1, 64) ← dim=0 是 1,所以 sum(0)counts_sum.shape # (32, 1) ← 上一篇,dim=1 是 1,所以 sum(1)🔑 判据一个字没变:变量哪一维是 1,就对哪一维求和。只是这次 1 在前面。 数学上说,
μ_j缺的是样本下标b,所以沿b求和。
上一篇 SumAxis 那张图画的就是这件事,不重画。
🔑 两个分叉点,和归一化的通病
| 分叉变量 | 路 A(短) | 路 B(长) |
|---|---|---|
bndiff | 直接进 bnraw | 经 bndiff2 → bnvar → bnvar_inv,绕四步 |
hprebn | 直接进 bndiff | 经 bnmeani,绕一步 |
bndiff 为什么必然分叉,看数学定义就够了:
σ²_j = (1/(n−1)) · Σ_b (x[b,j] − μ_j)² ← 括号里就是 bndiffx̂[b,j] = (x[b,j] − μ_j) / σ_j ← 分子也是 bndiff同一个偏差,既是分子,又被平方后拿去造分母。 这不是代码写法问题 —— BatchNorm 的数学定义本身就要求它出现两次。
而这一点上一篇已经见过一次了:
counts(上一篇) | bndiff(本篇) | |
|---|---|---|
| 路 A(短) | 直接进 probs | 直接进 bnraw |
| 路 B(长) | 经 counts_sum → counts_sum_inv | 经 bndiff2 → bnvar → bnvar_inv |
| 绕了几步 | 2 步 | 4 步 |
| 为什么分叉 | 既是分子,又参与分母 | 既是分子,又参与分母 |
🔑 这是归一化的通病,不是这两个算子各自的毛病。 凡是「把一批数除以由这批数自己算出来的量」,分子就必须先被拿去造分母, 反向就必然分叉。softmax 是这样,BatchNorm 是这样,LayerNorm、RMSNorm 也都是这样。
上一篇第三条操作规则说「要求谁的梯度,就搜它在前向里出现在哪些等号右边」—— 这一节是它的反向用法:看到归一化,不用搜也知道那个分子至少有两条路。
★ 第一次遇到系数不是 1 的求和反向
第 ⑥ 步,前向是求和,所以反向是广播(形态 C)。但局部导数不是 1:
dbndiff2 = (1.0/(n-1)) * torch.ones_like(bndiff2) * dbnvar上一篇第 ⑥ 步长这样:
dcounts += torch.ones_like(counts) * dcounts_sum # 系数是 1差别在前向:上一篇前向是纯 counts.sum(1),系数 1;这里前向写的是
1/(n-1) * bndiff2.sum(0),那个 1/(n−1) 也要跟着反向传下来。
Bessel 修正就在这里体现 —— 用
n−1而不是n来算样本方差,这件事不影响推导方法, 只改变这一个系数。
⚠️ 只差 3% 的那个错
BatchNorm 反向里有两个不同的系数:
| 步 | 前向 | 反向系数 |
|---|---|---|
⑥ 求 dbndiff2 | bnvar = 1/(n-1) * ... | 1/(n−1) = 1/31 |
⑨ 求 dhprebn 路 B | bnmeani = 1/n * ... | 1/n = 1/32 |
均值除 n,方差除 n−1。 记混了会怎样:
1/32 = 0.0312501/31 = 0.032258相对差 = 3.23%3% 的相对误差落在一堆量级本来就很小的梯度上,maxdiff 会是个很小但不为零的数 ——
看起来和上一篇说的「浮点加法不满足结合律」造成的那种 1e-9 是一路货。
不是。 上一篇那张 cmp 解读表在这里第一次被真正考验:
| 输出 | 含义 |
|---|---|
exact: True | 完美 |
exact: False · approximate: True,maxdiff 约 1e−9 | ✅ 对的,纯舍入 |
exact: False · approximate: False,maxdiff 很小但不是 1e−9 量级 | ❌ 系数记混了 |
🔑
maxdiff小不等于对。判据永远是approximate那一列。torch.allclose默认rtol=1e-5—— 3% 的相对误差过不了这一关,approximate会老实报False。 而如果只盯着maxdiff的绝对大小,这个错会被放过去。这也是上一篇为什么坚持「每推一步立刻 cmp」:攒着一起验的话, 这个 3% 会往下游传,最后你看到的是一堆都不对,而不是第 ⑥ 步不对。
.clone() 第二次出现
第 ⑨ 步:
dhprebn = dbndiff.clone()dhprebn += (1.0/n) * torch.ones_like(hprebn) * dbnmeani.clone() 的道理上一篇第 ⑧ 步讲过(Python 的引用语义,不是数学),这里条件完全相同:
右边是单独一个已存在的变量名,而这个梯度后面还要 +=。
判据:
= dbndiff→ 别名,要.clone();= a * b→ 乘法产生新张量,不用。 所以只有分叉点的第一条路需要它。症状也一样:
cmp('bndiff', ...)在这行之前是绿的,跑完第 ⑨ 步突然红了 —— 而你根本没动bndiff。
那个 1/n 是哪来的
把求和展开,盯住其中一项,其余 31 项对它来说都是常数:
∂μ_j / ∂x[5,j] = 1/n = 1/32 = 0.03125物理含义:hprebn 里任何一个数变大,都会把它那一列的均值抬高 1/32,
从而影响同一列全部 32 条样本的 bndiff。
为什么是九步,而 Linear 只要三行
Linear 的反向是三行,没有任何跨样本的东西。BatchNorm 是九步。差别只有一个原因:
BatchNorm 是网络里唯一让样本互相影响的操作。
hprebn[5][j] ──→ bnmeani[j] ──→ 影响同一列全部 32 条样本的 bndiff └──────────────────────→ 只影响自己那一格第十六篇算过这笔账(那张 SampleCoupling 图讲的就是同一行放进两批不同邻居里会得到不同输出),
第十一篇给「评估」下定义时也补过一条:BatchNorm 是全系列第一个让「前向」本身在训练和推理时不一样的东西。
这一篇是同一件事在反向侧的形状:hprebn 的梯度里混着全部 32 条样本的贡献。
❗ 公式里三条路,代码里只有两条
∂L/∂hprebn[5][j] = 直接路径 + 经 μ_j(来自 32 条) + 经 σ_j(来自 32 条)但第 ⑨ 步只写了两条。因为经 σ 那条藏在 dbndiff 里面了 ——
第 ⑦ 步的路 B(经 bndiff2 → bnvar → bnvar_inv)就是它。合并之后,
从 hprebn 的角度看只剩两条。
💡 这正是反向传播省力的地方:不用一口气算完整条长路。 每一步只管自己那一跳,上游传下来的数已经把后面全部的信息包含进去了。
收官
十三步走完,四千多个参数里拿下了 1883 个:
| 累计 | 占比 | |
|---|---|---|
| 第 4 段后 | 1755 | 42% |
| 第 5 段后 | 1883 | 46% |
剩下的 W1、b1、C 共 2254 个在第 6 段 —— 而那一段的前三行,
就是本篇「矩阵乘的反向」那一节的复制粘贴:把 h 换成 embcat、W2 换成 W1 就行。
真正新的只有最后两步。