
1. 为什么 Batch Normalization 值得用一整篇笔记来讲2020年那版作业2里Batch Normalization是我花时间最多的一节。当时我刚把全连接网络和反向传播手写顺觉得“再加一层”不过是把求导链拉长一点结果真正动笔去做前向、反向、放进CNN里跑实验时才发现这个层远比想象中微妙。作业2要求手写BN的前向传播和反向传播还要分别验证在全连接网络和卷积网络上的效果稍不留神就会出现“前向对、梯度错”或者“训练好、测试崩”的情况。为什么这个层值得单独写一篇笔记因为它在整个深度学习训练中承担的角色不是普通线性层或激活层能比的。普通层做的是确定性的数学变换而BN在训练阶段引入的是整个mini-batch的统计量也就是均值、方差。这意味着同一个样本在不同batch里经过BN之后的结果是不同的。这种“动态归一化”让网络的中间层不再被输入的绝对尺度牵着走训练因此更稳定收敛速度更快对初始化和学习率也宽容很多。这篇文章不是什么泛泛的原理科普而是从作业2的实际需求出发先讲BN要解决的问题再给完整的前向实现、反向推导和可运行代码最后聊一聊我在调试时踩过的坑。如果你正在做类似的课程作业或者想自己在框架外手写BN来加深理解这篇笔记应该能帮你省下不少反复试验的时间。2. Batch Normalization 到底在解决什么问题2.1 内部协变量偏移一个看起来很吓人的名字早年的深度学习实践里有一个经典经验训练前要把输入数据归一化到零均值、单位方差附近。如果不做归一化比如输入特征取值范围横跨0.001到10000那么网络第一层的梯度很容易被大数值特征主导损失函数地形会变得特别狭长梯度下降走起来歪歪扭扭学习率稍微调大一点就直接发散。但问题不止出现在输入层。网络训练过程中每一层的输出都会因为上一层参数的变化而改变分布。上一层参数不断更新下一层看到的输入分布就一直在变。这个现象在论文里叫“内部协变量偏移”听起来很学术其实可以类比成厨师做菜每道菜的咸淡取决于上次调味时盐罐子撒了多少盐罐子的状态每道菜都在变厨师就很难总结出“放多少盐合适”。在没有BN的年代人们只能靠更小心地初始化、更小的学习率、更精细的学习率衰减来缓解这种分布漂移。但这些手段都是“被动适应”并没有改变层与层之间输入分布不稳定的本质。BN的想法很直接与其让网络被动适应每一层输入的漂移不如直接在每一层输出之后加一个可微的归一化操作硬生生把分布拉回到一个可控范围。2.2 从“归一化输入”到“归一化中间层”BN做的事情和“输入标准化”非常相似只是作用位置从网络入口换到了任意隐藏层之后。具体来说对每个mini-batch我们会单独计算当前batch在某个维度上的均值 mu 和方差 sigma^2然后把每个样本减去均值、除以标准差得到归一化后的值mu 1/N * sum(x_i) var 1/N * sum((x_i - mu)^2) x_hat (x - mu) / sqrt(var eps)这里 N 是batch大小eps 是一个很小的常数防止方差为0导致除零错误。归一化之后中间层的输出在训练时会被拉成近似零均值、单位方差。不过如果直接强制中间层输出“标准正态分布”那这个网络的表达能力会被削弱。比如某个隐藏层本来就需要偏置一点、尺度大一点才能有好的非线性效果强行归一化成标准分布反而会让激活函数只工作在一段有限区域内。所以BN在归一化之后又加了一步缩放和平移y gamma * x_hat beta这里的 gamma 和 beta 是两个可学习参数维度与归一化维度一致。gamma初始值一般设为1beta初始值设为0这样一开始BN等价于纯归一化但随着训练网络可以自己学出合适的缩放和平移恢复表达能力。在作业里最常见的一个问题BN应该放在激活函数之前还是之后按照原始论文的推荐放在线性变换之后、激活函数之前效果通常更好。也就是说一个典型的带BN的块是“全连接层 / 卷积层 - BN - ReLU”。如果你看到某些实现是“ReLU - BN”也不要惊讶那是后来的变体作业里一般按前面那种顺序来。2.3 训练和测试为什么必须分开处理BN第一个容易混淆的点就是训练和测试时的行为完全不同。训练时我们手头有一个mini-batch可以直接算batch内均值、方差来做归一化。但真正到了测试阶段通常只跑一个样本也可能只有一个batch_size很小的输入。如果这时候还去算当前batch的均值、方差结果会非常不稳定尤其是batch_size1时方差直接变成0归一化完全失去意义。所以标准做法是在训练过程中额外维护一组“运行均值”和“运行方差”。每处理一个mini-batch就用当前的batch统计量去更新这组运行值更新方式类似动量平均moving_mean momentum * moving_mean (1 - momentum) * batch_mean moving_var momentum * moving_var (1 - momentum) * batch_varmomentum通常取0.9或0.99。测试时不再计算当前batch的统计量而是直接用训练阶段积累下来的 running mean 和 running var 做归一化。可以说训练阶段BN是一个“动态系统”测试阶段则变成了一个定死的线性变换这点不搞清楚写出来的模型就会出现“训练没问题一测试就崩”的情况。下面这个表格可以快速对比阶段均值/方差来源gamma/beta输出的性质训练当前batch统计量参与梯度更新输出分布被动态调整测试训练时累积的running统计量固定使用相当于定参的线性变换3. 作业里的前向实现从零手写一个BatchNorm层3.1 先处理2D输入全连接层场景作业里最常见的第一步是实现针对2D输入的全连接场景。假设输入 x 的形状是 (N, D)N是batch大小D是特征维度。我们需要对每个特征维度分别计算均值和方差也就是说 axis0。我当时第一次写时踩了一个典型坑直接用 np.mean(x) 将所有元素求了平均完全没有按维度分开。正确做法是 axis0结果是长度 D 的均值向量。下面是完整的训练阶段前向实现def batchnorm_forward(x, gamma, beta, bn_param): mode bn_param.get(mode, train) eps bn_param.get(eps, 1e-5) momentum bn_param.get(momentum, 0.9) N, D x.shape running_mean bn_param.get(running_mean, np.zeros(D, dtypex.dtype)) running_var bn_param.get(running_var, np.ones(D, dtypex.dtype)) out, cache None, None if mode train: sample_mean np.mean(x, axis0) sample_var np.var(x, axis0) x_hat (x - sample_mean) / np.sqrt(sample_var eps) out gamma * x_hat beta cache (x, x_hat, sample_mean, sample_var, gamma, beta, eps) running_mean momentum * running_mean (1 - momentum) * sample_mean running_var momentum * running_var (1 - momentum) * sample_var elif mode test: x_hat (x - running_mean) / np.sqrt(running_var eps) out gamma * x_hat beta cache None bn_param[running_mean] running_mean bn_param[running_var] running_var return out, cache这里有几个细节需要解释清楚。第一方差用的是 np.var 而不是 np.std因为后面反向传播需要单独保存方差标准差要在前向时临时加eps再开方。第二eps在方差内部还是在根号内不同代码风格有差异作业里一般取1e-5足够小不会影响训练又能避免除零。第三running_mean 和 running_var 需要存放在 bn_param 这个字典里而不是作为局部变量否则每次调用都会重置。还有一点容易出问题running_var 的初始值。如果初始化为0测试阶段一开始可能因为 running_var 过小而放大数值所以常见做法是初始化为1。当然如果训练迭代足够多一开始的初始化值影响会越来越小但初始化为1更稳妥。3.2 测试模式的forward实现测试模式的前向非常简单但很多人在作业里会把训练还是测试的开关写反。我一般用一个字符串 mode 来区分比如 bn_param[mode] train。测试时就是直接拿已经训练好的 running stats 来做归一化if mode test: x_hat (x - running_mean) / np.sqrt(running_var eps) out gamma * x_hat beta bn_param[running_mean] running_mean bn_param[running_var] running_var return out, cache注意测试模式下的 x_hat 也需要用 gamma 和 beta 做缩放平移不能只做标准化。有些同学测试阶段只归一化忘了缩放平移最后准确率会明显下降因为网络在训练时学到的 gamma、beta 相当于一层可学习的线性变换测试时必须还回去。3.3 实现中的一些细节坑写前向代码时我整理过一张避坑清单基本覆盖了作业里常见的低级错误gamma 和 beta 的 shape 要和归一化维度一致。2D输入时是 (D,)4D输入时是 (C,)。计算均值时一定要指定 axis。全连接层用 axis0后面处理卷积特征时要用 axis(0, 2, 3)。方差默认是有偏方差也就是除以N而不是N-1。BN的训练阶段使用的是总体方差因为梯度推导是按照除以N来的。如果用无偏方差反向传播会不一致。训练模式下的 cache 里必须保存 x 和 x_hat反向传播需要重算中间量。每次调用完 forward 后要更新 bn_param 里的 running_mean、running_var否则测试阶段拿到的是全零或全一的统计量。数据类型要保持一致。作业里常用 float64 进行梯度检查但实际网络训练用 float32不要因为精度问题导致梯度检查失败。4. 反向传播的推导与代码这是作业2里最容易卡住的部分4.1 计算图画出来一切都好说第一次推BN反向时我盯着公式看了很半天觉得每一行都能看懂但合在一起就不知道怎么对x求梯度。后来发现一个最有效的方法是先把计算图画出来。BN的前向由这样的依赖链构成x - 求 mean - 求 var - 计算 x_hat - 计算 out \\- 依赖 mean, var反向传播时我们拿到的是 dout也就是 out 的梯度。要得到 dx就得沿着链从 out 到 x_hat再到 x。注意 x 同时通过三条路径影响着最终 out一是直接通过 x_hat 的分子影响二是通过 mean 影响三是通过 var 影响。如果漏掉任何一条路径梯度就是错的。作业里对这种“多路径”的梯度要求特别严格尤其是均值、方差都是对x的整体统计量每个 x_i 的梯度都会受到batch内其他样本的影响。这也是BN反向传播和普通逐元素操作最大的区别。4.2 分步推导梯度公式为了减少推导量可以先把对 gamma 和 beta 的梯度写了这两部分最简单dgamma sum_over_batch(dout * x_hat) dbeta sum_over_batch(dout)为什么是求和因为 out gamma * x_hat beta在归一化维度上 gamma 和 beta 是共享参数所以该维度所有样本的梯度要加总。这个跟普通全连接层对 bias 求梯度的逻辑一样。接下来定义 dx_hat dout * gamma表示对 x_hat 的梯度。然后要从 out 的梯度反推到 x。这里我先给出一个分步实现它虽然不是最简形式但每一步都对应计算图上的一个节点不容易出错def batchnorm_backward(dout, cache): x, x_hat, mean, var, gamma, beta, eps cache N x.shape[0] dgamma np.sum(dout * x_hat, axis0) dbeta np.sum(dout, axis0) dx_hat dout * gamma dvar np.sum(dx_hat * (x - mean) * -0.5 * (var eps) ** -1.5, axis0) dmean np.sum(-dx_hat / np.sqrt(var eps), axis0) dvar * np.mean(-2 * (x - mean), axis0) dx dx_hat / np.sqrt(var eps) dvar * 2 * (x - mean) / N dmean / N return dx, dgamma, dbeta这段代码里dvar 表示 loss 对 var 的梯度。由于 var 是 (x - mean) 平方的平均值所以 var 对 x 的梯度是 2*(x - mean)/N。dmean 由两部分组成x_hat 对 mean 的直接依赖以及 var 对 mean 的间接依赖。最后 dx 也是三部分相加。这段代码我已经在多个版本的作业里验证过和数值梯度对比相对误差在1e-8到1e-10之间。如果你更熟悉“用中间量缓存再链式反推”的写法可以把 dx_hat 那一步拆得更细。但最终 dx 的公式其实可以浓缩成一种更优雅的形式很多参考实现直接写成dx (1 / N) * inv_std * (N * dx_hat - np.sum(dx_hat, axis0) - x_hat * np.sum(dx_hat * x_hat, axis0))这两种写法本质上是等价的。第一种分步法的好处是好理解第二种一条式子写完适合写进速度更快的版本。作业里一般先要求你按照某种统一接口实现后续优化任务里再改成高效形式。我建议先掌握分步法等到确认梯度检查通过后再尝试精简。4.3 高效实现技巧如果只是完成作业分步法已经够用了。但后续作业里可能会要求把BN层应用到大网络里这时候性能问题就出来了。一个常见优化是避免在反向里反复计算 np.sqrt(var eps)前向时直接缓存 inv_std 1 / sqrt(var eps)这样反向就能少做一次除法、一次开方。另外dx_hat 在分步法中已经算出来了如果后面要继续做卷积层反向dx_hat 还可以作为后续层的“上游梯度”继续传。所以我建议把 cache 设计得尽量完整不要只缓存个别变量。一个比较充足的 cache 至少包含 x、x_hat、mean、var、gamma、beta、eps以及可选的 inv_std。这样即便写了多种实现也能方便对比。4.4 用数值梯度检查抓bug在所有作业里梯度检查是最能救命的工具。实现完 forward 和 backward 后先用小随机输入跑一遍梯度检查。比如生成一个 N4、D3 的随机xgamma、beta也随机生成然后调用数值梯度函数比较每个参数的解析梯度和数值梯度。我第一次做梯度检查时相对误差一直在1e-4左右怎么调都不达标。最后发现是在 forward 里用了 np.std而 np.std 默认是有偏标准差它和反向里假设的“方差”定义不一致。换成先算 np.var 再开根号之后误差立刻降到1e-9左右。另一个常见问题是梯度检查时忘记把 bn_param[mode] 设为 train导致测试模式下的 cache 为 None直接把反向传播写崩了。5. 把BatchNorm塞进CNN作业2里的实际落地5.1 卷积层的通道维归一化实现完2D版BN下一步是让它工作在卷积特征图上。卷积层的输出形状一般是 (N, C, H, W)其中 N 是batch大小C 是通道数H 和 W 是空间尺寸。BN在卷积网络里不是对所有像素、所有通道统一归一化而是按通道分别做归一化。这句话的意思是对于第 c 个通道我们要汇集所有样本、所有空间位置上的像素来计算均值和方差。所以归一化维度是 axis(0, 2, 3)gamma和beta的shape是 (C,)而不是 (C,H,W) 或者 (N,C,H,W)。这一点非常关键一旦写错通道之间的信息就会被混在一起网络表达能力完全错乱。一个非常实用的技巧是把4D特征图重排成2D矩阵再复用前面写好的 batchnorm_forward。具体做法def spatial_batchnorm_forward(x, gamma, beta, bn_param): N, C, H, W x.shape x_flat x.transpose(0, 2, 3, 1).reshape(-1, C) out_flat, cache batchnorm_forward(x_flat, gamma, beta, bn_param) out out_flat.reshape(N, H, W, C).transpose(0, 3, 1, 2) return out, cache这里 transposing 的意义是让通道维度变成最后一维然后 reshape 成 (NHW, C)这样每列对应一个通道。经过2D BN后再 reshape 回 (N, H, W, C)transpose 回 (N, C, H, W)。整个过程只是排列顺序变了数值完全没变。反向传播也可以照葫芦画瓢先重排调用2D backward再重排回来。5.2 训练稳定性的变化和对比把BN接入卷积网络之后最直观的感受是loss曲线变得“乖”了。没有BN时训练前期loss经常会出现突然的陡峭上升尤其是学习率稍微大一点的时候深层的梯度瞬间爆炸loss直接跳到几百。加上BN之后即使学习率翻倍loss曲线也只会出现小幅波动整体保持稳定下降。原因在于BN限制了每一层输出的尺度范围减少了前层参数更新对后层输入分布带来的冲击相当于给整个网络装了一个缓冲。作业里一般会要求对比有无BN时的准确率或loss曲线你大概率会看到带BN的验证集准确率更早到达平台期而且最终准确率也更高。不过要提醒一句BN不是万能的。它提升的是训练的稳定性和速度而不是增加模型的表达能力。如果网络结构本身不合理或者是数据标签噪声太大BN也不能直接救回所有问题。5.3 学习率和初始化怎么跟着调整加了BN之后最值得调的超参数其实是学习率。原来用0.001只能用得非常小心换上BN后可以直接试试0.01甚至0.05。因为BN让每层输入的尺度更加一致梯度的大小也相对稳定所以可以承受更大的更新步长。L2正则的权重也需要适当调整。BN本身已经对中间层分布做了一定约束所以正则强度不需要像以前那样大否则容易出现欠拟合。表现在作业里就是训练集准确率也上不去。另外BN对参数初始化的敏感度会大大降低你可以放心地用更简单的初始化策略比如从均值为0、标准差为0.01的高斯分布里采样。6. 常见问题与排查技巧来自现场的真实教训6.1 训练损失不降 / 测试准确率差如果训练阶段loss一直不降先检查BN是不是被放在训练模式但用了测试分支。很多人会在代码里写死 bn_param[mode] test结果整个训练过程中BN完全没有用batch统计量网络等于少了归一化深层容易梯度爆炸。另一个高频原因是学习率过大BN虽然能缓解但不是无限度容忍学习率到0.5以上时照样发散。测试准确率差的第一步排查方向是 running_mean 和 running_var 有没有正确更新。很多实现里 forward 返回 out 和 cache但忘记在 bn_param 里更新 running statistics导致测试时用的还是初始值。我的习惯是每次 forward 后都打印一下 running_mean 的前几个值如果一直是0或者初始值那肯定没更新。6.2 梯度检查不过梯度检查是作业里最折磨人的环节常见问题有这么几类方差定义不一致。forward 里如果用 np.stdbackward 里必须按标准差传播而不是按方差传播。建议统一用 var再开根号。忘记除以N。var 对 x 的梯度是 2*(x-mean)/N这里的N不能丢。缓存变量和实际使用变量不一致。比如缓存的 x_hat 是用旧均值算的但 backward 里又用了新的 mean那梯度永远不对。随机种子问题。如果输入是随机生成的梯度检查每次跑的数值梯度可能基于不同的前向结果导致误差大。固定 np.random.seed 会很有帮助。6.3 测试时单样本结果怪异把训练好的模型搬到测试环境如果一次只预测一个样本BN层没有batch统计量可用如果代码依然走训练分支就会算出batch内均值等于该样本本身方差为0归一化后输出全部是零再乘上gamma也不正常。解决办法就是我前面强调的测试时严格使用 running stats。如果你在写自己的网络时想偷懒有的框架会自动处理这个逻辑比如PyTorch里 BN 层有 training 标志eval模式会自动切换到 running stats。但作业要求手写所以必须自己维护这个分支。7. 最后分享一点我自己的实操心得回过头看BN这段内容之所以值得写这么长是因为它训练了我在实现深度学习模块时最重要的习惯不要只看前向公式要沿着计算图把梯度的每一条路径都走一遍。很多经典层比如全连接、卷积、ReLU反向都比较直观而BN是少有的“一个输入样本的梯度还要依赖batch里其他样本”的层一旦想通这一点后面再学LayerNorm、GroupNorm、InstanceNorm都会轻松很多。另外一个很实际的建议在写任何归一化层之前先明确“归一化维度是哪几个轴”。2D输入是axis04D卷积输入是axis(0,2,3)transformer里做LayerNorm通常是axis-1。这个决定直接影响了均值、方差、gamma、beta的形状也是实现和调试的起点。如果你在做作业时卡在BN的梯度上先别急着去看别人的实现。试着把计算图手画一遍标出每个节点的缓存变量再一步步求。这个过程有点像解一道链式法则的积分题一开始慢但练过几次之后遇到其他更复杂的归一化层就不会心虚了。Batch Normalization不是某个花哨的trick它已经成了现代网络的标准组件值得你花一整晚把它的前向、反向和训练测试差异彻底吃透。