卷积神经网络通过局部连接和共享参数提取特征;反向传播解释参数如何收到梯度。接下来常会遇到一个看似简单的层:先减均值,再除标准差,最后缩放和平移。

BatchNorm 的难点不在这一行算式,而在统计量从哪里来、梯度经过哪些路径、训练和评估用了哪套统计量。同一个输入,换一批同伴可能得到不同训练输出;切到评估模式后,又可能得到第三个结果。

先固定统计轴,再写公式

本文只实现一个通道,把参与该通道归一化的 M 个值写成 x_1 到 x_M。它们不一定分别来自 M 个样本:空间位置也可能参与统计。

输入形状 一个通道参与统计的位置 M
BatchNorm1d 的 (N,C) 当前通道的 N 个样本 N
BatchNorm1d 的 (N,C,L) 当前通道的批次与序列位置 N×L
BatchNorm2d 的 (N,C,H,W) 当前通道的批次与空间位置 N×H×W

通道之间各有自己的均值、方差、γ 和 β,不把全部通道混成一组。PyTorch BatchNorm1d与BatchNorm2d说明了相应统计轴。这里假设所有值都应参与统计;带 padding 的变长序列需要额外考虑掩码,不能默认零填充没有影响。

训练前向的定义为:

1
2
3
4
5
μ = (1/M) Σ_i x_i
v = (1/M) Σ_i (x_i−μ)²
q = 1 / sqrt(v+ε)
z_i = (x_i−μ) q
y_i = γ z_i + β

ε>0 避免零方差导致除零,也会影响实际输出和梯度。γ、β 是可学习参数;μ、v 是由输入算出的中间量;后文的运行统计是跨批次更新的缓冲量,不能把这三类东西混在一起。

Ioffe 与 Szegedy 的原始论文提出将批次归一化放入网络内部。本文围绕可检查的计算关系展开,不把原论文对训练收益的特定实验结果当成任意模型的保证,也不把“消除内部协变量偏移”当作充分的优化解释。

三个数的前向与梯度手算

取 x=[1,2,3],γ=2、β=0.5、ε=1/3。这里故意使用较大的 ε,让中间数易于手算;它不是建议采用的训练超参数。

μ=2、v=2/3,故 q=1、z=[−1,0,1],输出 y=[−1.5,0.5,2.5]。注意 z 的均值是 0,但方差是 2/3;只要 ε>0 且 v 有限,非零 v 时归一化方差 v/(v+ε) 小于 1。

设损失对三个输出的上游梯度 g=[1,2,4]。等价的检验损失可取 L=Σ_i g_i y_i,这只用于检查该层导数,不是完整分类训练目标。

1
2
3
4
dL/dβ = Σ_i g_i = 7
dL/dγ = Σ_i g_i z_i = 3
mean(g) = 7/3
mean(g z) = 1
ix_iz_iy_ig_idL/dx_i
11−1−1.512×(1−7/3+1)=−2/3
2200.522×(2−7/3)=−2/3
3312.542×(4−7/3−1)=4/3

小屏幕可横向滚动上表;使用键盘时可先 Tab 聚焦表格容器,再按方向键查看后面的列。

为什么梯度不是各算各的

改变 x_j,除了直接改变 y_j,还会改变 μ 和 v,从而影响同通道的其他输出。把均值和方差当成常数,会漏掉两条链式法则路径。

令 h_i=γg_i。由于 Σ_i(x_i−μ)=0,沿方差、均值两条支路有:

1
2
3
dL/dv = −0.5 q³ Σ_i h_i (x_i−μ)
dL/dμ = −q Σ_i h_i
dL/dx_i = h_i q + (dL/dv) 2(x_i−μ)/M + (dL/dμ)/M

将 x_i−μ=z_i/q 代入,就得到便于实现的训练反向:

1
dL/dx_i = γq [g_i − mean(g) − z_i mean(g z)]

这个推导仍保留 ε:它在 q 与 z 内,没有被省略。对同一通道全部输入加相同常数,不改变训练输出,因此 Σ_i dL/dx_i=0;数值测试可以检查这个不变量。相反,ε 非零时,不能一概断言对输入整体缩放也完全不变。

BatchNorm 的三条反向路径:输入直接影响归一化,同时经均值与方差影响同通道全部输出;评估时改用冻结的运行统计
原创计算关系图;同一通道的样本通过统计量耦合,运行统计不参与梯度更新。查看完整图

当前方差与运行方差不是同一个数

本文采用 PyTorch 文档中的约定:训练输出使用分母为 M 的 v;更新 running_var 时使用 M/(M−1)×v,要求 M>1。运行均值与方差分别按如下方式更新:

1
2
running_mean ← (1−m) running_mean + m μ
running_var ← (1−m) running_var + m [M/(M−1)] v

这里 m 是统计量的更新系数,不是优化器 momentum,也不是旧统计量的权重。运行方差是各批次校正方差的移动平均,不应宣称它等于把所有历史样本拼接后算出的总体方差;不同批次均值之间的变化并未按合并方差公式累计。

例如初始 running_mean=0、running_var=1,m=0.5。第一批 [1,2,3] 的校正方差为 1,更新后运行均值为 1、运行方差仍为 1。第二批 [2,4,6] 的均值为 4、v=8/3、校正方差为 4,更新后得到 running_mean=2.5、running_var=2.5。

评估模式为什么会改变结果

在启用运行统计的约定下,评估输出使用固定的 running_mean、running_var,不再依赖当前批次的其他输入。刚处理第一批后,对单个 x=2 做评估,得到:

1
2
y_eval = 2 × (2−1)/sqrt(1+1/3) + 0.5 ≈ 2.23205
y_train(来自第一批)= 0.5

两者不同不一定是错误:当前批次和历史移动统计本来就不同。若训练时同一个 x=2 改为跟 [4,6] 同批,均值与方差又会改变它的输出。

这里讨论 track_running_stats=True。若关闭运行统计,PyTorch 在评估时也用批次统计,不能套用上面的“与同批伙伴无关”。eval() 也不等于关闭自动微分:固定统计下仍可求输入、γ、β 的导数,输入梯度此时是 γqg_i,没有训练模式的统计量耦合项。

完整单通道标准库实验

代码使用 Python 3,只实现一个通道、固定数值 m 和启用运行统计的行为。它不实现多通道张量、跨设备同步、混合精度或优化器;多通道时应独立重复这套统计。返回的 cache 保存当前前向的 γ 快照,防止之后修改参数影响这次反向。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
from dataclasses import dataclass
import json
import math


def finite(x):
if type(x) not in (int, float):
return False
try:
return math.isfinite(x)
except OverflowError:
return False


def vector(xs):
if not isinstance(xs, (list, tuple)) or not xs:
raise ValueError("expected nonempty list or tuple")
if not all(finite(x) for x in xs):
raise ValueError("expected finite real numbers")
return tuple(float(x) for x in xs)


@dataclass(frozen=True)
class Cache:
z: tuple
inverse_std: float
gamma: float
training: bool


class BatchNormChannel:
def __init__(self, gamma=1.0, beta=0.0, eps=1e-5, momentum=0.1):
if not all(finite(x) for x in (gamma, beta, eps, momentum)):
raise ValueError("nonfinite parameter")
if eps <= 0 or not 0 <= momentum <= 1:
raise ValueError("invalid epsilon or momentum")
self.gamma, self.beta = gamma, beta
self.eps, self.momentum = eps, momentum
self.running_mean, self.running_var = 0.0, 1.0

def forward(self, xs, training=True):
x = vector(xs)
if type(training) is not bool:
raise ValueError("training must be bool")
if training and len(x) < 2:
raise ValueError("training requires M greater than one")
if training:
mean = math.fsum(x) / len(x)
centered = [v - mean for v in x]
variance = math.fsum(v * v for v in centered) / len(x)
else:
mean, variance = self.running_mean, self.running_var
centered = [v - mean for v in x]
if not all(finite(v) for v in (mean, variance, variance + self.eps)):
raise ValueError("statistics overflowed")
inverse_std = 1.0 / math.sqrt(variance + self.eps)
z = tuple(v * inverse_std for v in centered)
y = [self.gamma * v + self.beta for v in z]
if not all(finite(v) for v in y):
raise ValueError("output overflowed")
if training:
m = self.momentum
next_mean = (1 - m) * self.running_mean + m * mean
corrected = variance * (len(x) / (len(x) - 1))
next_var = (1 - m) * self.running_var + m * corrected
if not all(finite(v) for v in (next_mean, next_var)):
raise ValueError("running statistics overflowed")
self.running_mean, self.running_var = next_mean, next_var
return y, Cache(z, inverse_std, self.gamma, training)


def backward(cache, upstream):
g = vector(upstream)
if not isinstance(cache, Cache) or len(g) != len(cache.z):
raise ValueError("invalid cache or upstream shape")
dbeta = math.fsum(g)
dgamma = math.fsum(a * b for a, b in zip(g, cache.z))
if cache.training:
dx = [cache.gamma * cache.inverse_std *
(a - dbeta / len(g) - z * dgamma / len(g))
for a, z in zip(g, cache.z)]
else:
dx = [cache.gamma * cache.inverse_std * a for a in g]
if not all(finite(v) for v in (*dx, dgamma, dbeta)):
raise ValueError("gradient overflowed")
return dx, dgamma, dbeta


if __name__ == "__main__":
bn = BatchNormChannel(gamma=2.0, beta=0.5, eps=1/3, momentum=0.5)
y, cache = bn.forward([1, 2, 3])
dx, dgamma, dbeta = backward(cache, [1, 2, 4])
first_stats = [bn.running_mean, bn.running_var]
evaluation, _ = bn.forward([2], training=False)
changed_batch, _ = bn.forward([2, 4, 6])
print(json.dumps({"train": y, "dx": dx,
"dgamma": dgamma, "dbeta": dbeta,
"first_stats": first_stats,
"eval_x2": evaluation[0],
"train_x2_with_new_peers": changed_batch[0],
"second_stats": [bn.running_mean, bn.running_var]}, indent=2))

运行得到 train=[−1.5,0.5,2.5]、dx 约为 [−0.666667,−0.666667,1.333333]、dgamma=3、dbeta=7;两次运行统计分别为 [1,1]、[2.5,2.5]。这些是教学输入的计算结果,不代表某个网络的训练收益。

前向和反向都为 O(M) 时间,输出、中间缓存与梯度为 O(M) 空间。实现检查有限输入与常见浮点溢出,但不承诺处理所有接近浮点极限的数据;它也不替代张量框架的数值稳定性与内核优化。

接进模型前检查什么

情形 应检查的边界
M=1 本文训练实现拒绝;N=1 的卷积若 H×W>1,不等于 M=1
同通道值全相等 z 全为 0,输出为 β;输入轻微扰动后仍可能有非零梯度
很小的批次 统计噪声与同伴组成可能明显影响输出
梯度累积 多次小批次的统计不自动等于一次大批次统计
分布迁移 运行统计可能与部署输入不匹配;先测量再决定更新策略
验证集 不应为得到更好分数而无意中更新模型运行缓冲量
保存模型 除可学习参数,还要保存运行统计与模式约定
数值差分 训练目标每次重算批次统计;不要让反复前向污染要比较的缓冲状态

项目 tests/verify-batchnorm-article.cjs 用独立的标量目标计算前向,以中心差分检查输入、γ、β 梯度,并核对两种方差、运行统计更新、评估批次独立性、平移不变量和缓存快照。数值验证检查这一层,没有声称已经训练一个完整 CNN。

自检:切换 eval 后结果改变,是否说明训练前向一定算错了?

不一定。启用运行统计时,两种模式使用的均值和方差不同。应先核对当前批次、运行缓冲量和模式,再检查计算;eval 与是否记录梯度也不是同一个开关。

可以回到卷积共享梯度比较两种耦合:卷积是同一个参数在多个位置使用,BatchNorm 是多个输入共同决定统计量。模型之间的实际改进则要用固定评测集和配对 Bootstrap等方法分析,不能从一个层的手算直接推断总体效果。