bobofamilyNIU CHUN
20/36

同一个偏差,既当分子又去造分母:BatchNorm 反向的十三步

2026-09-1810 分钟—阅读

上一篇的八步停在 logits。那是神经网络的边界 —— 那八步全是 softmax 和对数, 一个参数都没碰到。

这一篇往里走十三步:第 4 段四个求导,第 5 段九个。参数第一次出现。


从这一步起,梯度开始有用

前面八步算的全是中间量的梯度 —— dprobs、dcounts、dnorm_logits,算完就扔。 它们存在的唯一理由是把梯度往下游传。

dW2 不一样:

W2.data -= lr * dW2 # 第 9 段会这么用

这是第一个能直接拿去更新模型的东西。

四千多个参数不是一次拿完的,三段各管一摊:

参数个数哪一段
W2 · b21755第 4 段(本篇)
bngain · bnbias128第 5 段(本篇)
W1 · b1 · C2254第 6 段(下一篇)
合计4137

W2 是 64×27 = 1728 加上 b2 的 27,1755 个,占全部参数的 42% —— 一个输出层就吃掉四成, 因为 27 个类别那一维比隐藏层的 64 只小一半,而它前面是 64。


h 是谁生产的,又被谁使用

两套权重各管一段:W1 把 embcat 变成 hprebn,W2 把 h 变成 logits。反向倒着走,所以第 4 段先处理右边的 Linear 2,左边的 Linear 1 要等第 6 段。

前向对应的是这两行:

h = torch.tanh(hpreact) # (32,64)
logits = h @ W2 + b2 # (32,27)

❗ 生产 h 的权重是 W1,不是 W2

「h 是权重乘上一层的输出」这个说法对一半 —— 那个权重是 W1:

输入权重输出
Linear 1embcat (32,30)W1 (30,64)hprebn (32,64)
Linear 2h (32,64)W2 (64,27)logits (32,27)

🔑 h 有两个身份:它是第一层的产物(被 W1 生产出来),同时是第二层的输入(被 W2 使用)。 第 4 段处理的是第二个身份。

h 家族:三个名字,三个位置

变量名字拆解是什么
hprebnh + pre + bnBatchNorm 之前的隐藏层值
hpreacth + pre + activation激活函数之前的隐藏层值
hhidden隐藏层最终输出,值域 (−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 的九步

七行前向里只有最后一行用参数,前六行全是无参数的统计量计算 —— 这就是 BatchNorm 只有 128 个参数却要写七行的原因。两个分叉点都在左边那张图上标了出来。

前向七行:

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−1
bnvar_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)² ← 括号里就是 bndiff
x̂[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 反向里有两个不同的系数:

步前向反向系数
⑥ 求 dbndiff2bnvar = 1/(n-1) * ...1/(n−1) = 1/31
⑨ 求 dhprebn 路 Bbnmeani = 1/n * ...1/n = 1/32

均值除 n,方差除 n−1。 记混了会怎样:

1/32 = 0.031250
1/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 段后175542%
第 5 段后188346%

剩下的 W1、b1、C 共 2254 个在第 6 段 —— 而那一段的前三行, 就是本篇「矩阵乘的反向」那一节的复制粘贴:把 h 换成 embcat、W2 换成 W1 就行。 真正新的只有最后两步。