BatchNorm 原理 从批次统计到训练推理差异
卷积神经网络通过局部连接和共享参数提取特征;反向传播解释参数如何收到梯度。接下来常会遇到一个看似简单的层:先减均值,再除标准差,最后缩放和平移。
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 | μ = (1/M) Σ_i x_i |
ε>0 避免零方差导致除零,也会影响实际输出和梯度。γ、β 是可学习参数;μ、v 是由输入算出的中间量;后文的运行统计是跨批次更新的缓冲量,不能把这三类东西混在一起。
Ioffe 与 Szegedy 的原始论文提出将批次归一化放入网络内部。本文围绕可检查的计算关系展开,不把原论文对训练收益的特定实验结果当成任意模型的保证,也不把“消除内部协变量偏移”当作充分的优化解释。
flowchart TD A[固定一个通道的 M 个值] --> B[计算均值和当前批次方差] B --> C[减均值 除以带 epsilon 的标准差] C --> D[用 gamma 缩放 beta 平移] D --> E[损失给出每个输出的上游梯度] E --> F[梯度同时经过直接路径与统计量路径]
三个数的前向与梯度手算
取 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 | dL/dβ = Σ_i g_i = 7 |
| i | x_i | z_i | y_i | g_i | dL/dx_i |
|---|---|---|---|---|---|
| 1 | 1 | −1 | −1.5 | 1 | 2×(1−7/3+1)=−2/3 |
| 2 | 2 | 0 | 0.5 | 2 | 2×(2−7/3)=−2/3 |
| 3 | 3 | 1 | 2.5 | 4 | 2×(4−7/3−1)=4/3 |
小屏幕可横向滚动上表;使用键盘时可先 Tab 聚焦表格容器,再按方向键查看后面的列。
为什么梯度不是各算各的
改变 x_j,除了直接改变 y_j,还会改变 μ 和 v,从而影响同通道的其他输出。把均值和方差当成常数,会漏掉两条链式法则路径。
令 h_i=γg_i。由于 Σ_i(x_i−μ)=0,沿方差、均值两条支路有:
1 | dL/dv = −0.5 q³ Σ_i h_i (x_i−μ) |
将 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;数值测试可以检查这个不变量。相反,ε 非零时,不能一概断言对输入整体缩放也完全不变。
当前方差与运行方差不是同一个数
本文采用 PyTorch 文档中的约定:训练输出使用分母为 M 的 v;更新 running_var 时使用 M/(M−1)×v,要求 M>1。运行均值与方差分别按如下方式更新:
1 | running_mean ← (1−m) running_mean + m μ |
这里 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 | y_eval = 2 × (2−1)/sqrt(1+1/3) + 0.5 ≈ 2.23205 |
两者不同不一定是错误:当前批次和历史移动统计本来就不同。若训练时同一个 x=2 改为跟 [4,6] 同批,均值与方差又会改变它的输出。
这里讨论 track_running_stats=True。若关闭运行统计,PyTorch 在评估时也用批次统计,不能套用上面的“与同批伙伴无关”。eval() 也不等于关闭自动微分:固定统计下仍可求输入、γ、β 的导数,输入梯度此时是 γqg_i,没有训练模式的统计量耦合项。
flowchart LR
A[启用运行统计的 BatchNorm] --> B{当前模式}
B -- 训练 --> C[当前批次均值与方差]
C --> D[得到输出 并更新运行缓冲量]
B -- 评估 --> E[冻结的运行均值与方差]
E --> F[得到输出 缓冲量不更新]
F --> G[是否记录梯度是另一个开关]
完整单通道标准库实验
代码使用 Python 3,只实现一个通道、固定数值 m 和启用运行统计的行为。它不实现多通道张量、跨设备同步、混合精度或优化器;多通道时应独立重复这套统计。返回的 cache 保存当前前向的 γ 快照,防止之后修改参数影响这次反向。
1 | from dataclasses import dataclass |
运行得到 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等方法分析,不能从一个层的手算直接推断总体效果。