Batch Normalization反向传播详解:从CS231N作业到梯度检查全复盘
发布时间:2026/10/15 5:10:09 锦皓数字建站

从开始对着CS231N作业2的代码框架发愁到把Batch Normalization的梯度的来龙去脉彻底捋清楚我大概花了一个完整下午。前向实现五分钟搞定反向传播推导却翻来覆去推了三遍才敢提交。这篇笔记就把那一下午的完整复盘记录下来包括前向实现里容易被忽略的三个细节、反向推导中真正卡住我的均值链路以及把BN接进全连接网络后观察到的那些有意思的训练现象。正在写作业、或者刚接触归一化想彻底搞懂原理的人应该能找到你想要的东西。1. 作业2要你在Batch Normalization上交什么先看清任务边界作业2从简单的全连接网络开始一个模块一个模块加进去Affine层、ReLU、Dropout一路走到Batch Normalization。BN在作业里的位置很特别它不改变网络结构只改变每一层激活值的分布但实现起来却比Dropout难出一截。这一节的任务可以拆成三步递进第一步写前向传播输入一批数据输出归一化后的结果第二步写反向传播给定上游梯度求出对输入x、对缩放参数gamma、对偏移参数beta的梯度第三步把写好的BN层接进FullyConnectedNet和没有BN的基准网络做对比实验。第三步听起来最简单但恰恰是它暴露了我对BN的理解漏洞。因为前两步你还可以靠公式硬套第三步要在训练和测试两种模式下运行同一个网络我的模型测试准确率一度出现剧烈波动最后才发现是BN的running_mean和running_var更新出了问题。子任务输入输出我踩过的主要坑batchnorm_forwardx、gamma、beta、bn_param输出out和cachecache里忘了缓存gamma导致反向没法算batchnorm_backwarddout和cache输出dx、dgamma、dbeta忽略均值mu对x的反馈链路梯度检查必挂接入FullyConnectedNet训练网络配置加normalizationbatchnorm测试模式忘记关闭running统计量不更新准确率飘忽不定为什么这一节值得手写一遍因为BN已经变成深度学习框架的标准组件大部分人只会写bn nn.BatchNorm2d(...)却不知道它内部发生了什么。作业逼着你从numpy层面复现一次这个过程能让你真正理解归一化三个字的分量它做的不只是把数据变换到零均值单位方差还引入了两个可学习参数gamma和beta让网络可以自己决定要不要恢复原来的分布。2. 前向传播里的三个细节轴、分母和缓存2.1 在(N, D)张量上按特征维度做归一化输入的x形状是(N, D)N是一个batch里的样本数D是每个样本的特征维度。Batch Normalization做的事情是对每个特征维度d用当前batch里所有样本在这个维度上的均值和方差做归一化。也就是说np.mean(x, axis0)得到长度为D的均值向量方差同理。我第一次写的时候差点按axis1求均值那样相当于对每个样本的所有特征做归一化得到的结果是每个样本自己把特征拉平了这完全偏离了BN的初衷。BN的batch含义就在这个轴的选择上统计量来源于batch中多个样本的同一个特征通道。代码实现极其简单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 beta2.2 eps为什么用1e-5而不是更小的值分母上的eps是为了防止方差为0时除零。这个值在作业框架里默认是1e-5很多深度学习框架也是这个量级。没必要用更小的1e-12因为在float32精度下过小的eps反而会在反向传播时产生糟糕的数值行为。方差很小的时候np.sqrt(var eps)的梯度1/sqrt(...)会变得非常大数值稳定性反而下降。我试过把eps调成1e-12梯度检查时误差立刻就上去了。这是第一个让我意识到数值稳定性是真实工程问题的地方不是随便抄一个超参数就完事。2.3 缓存哪些变量直接决定反向能不能顺畅写出来作业框架的batchnorm_forward要求返回一个cache这个cache会被反向函数使用。官方框架里cache通常是(x, sample_mean, sample_var, x_hat, eps)这样一个元组。问题在于反向传播计算dx_hat dout * gamma时需要gamma但官方cache里没有gamma。我当时的处理方式是修改自己的forward把gamma也缓存进去cache (x, sample_mean, sample_var, x_hat, eps, gamma)这是一个很实用的经验无论作业框架给的cache长什么样你都可以根据自己反向推导的需要来决定缓存内容。只要gradient check能过灵活性完全在你这边。3. 反向传播推导复盘真正坑人的是均值那条链路3.1 先把gamma和beta两条路走完前向公式中最后一步是y gamma * x_hat beta。从上游传来的梯度dout对gamma和beta的贡献非常直接dbeta np.sum(dout, axis0) dgamma np.sum(dout * x_hat, axis0)为什么要对axis0求和因为gamma和beta都是形状为(D,)的向量每个分量会被N个样本共享。比如beta_d对输出y[i, d]都有贡献所以梯度要累加N个样本上的值。这一部分属于链式法则的基础运用不太容易出错。接下来是核心难点dx_hat dout * gamma然后要从dx_hat求dx。难点在于x_hat并不是简单地对x做逐元素运算它依赖两个全局统计量均值mu和方差var而这两个统计量又各自依赖所有x_i。如果把计算图画出来从x_hat到x有三条路径一条直接通过(x - mu) / sqrt(var eps)一条通过mu还有一条通过var。忽略后两条梯度必错。3.2 从dx_hat到dx两条隐式依赖链路我先定义一些符号令s sqrt(var eps)N是batch大小。前向是x_hat_i (x_i - mu) / s其中mu (1/N) * sum_j x_jvar (1/N) * sum_j (x_j - mu)^2。先求var的梯度。var出现在分母上并且本身是x的函数dvar sum_i (-0.5) * dx_hat_i * (x_i - mu) * s^{-3}再求mu的梯度。x_hat的表达式中减去的mu也来自所有x_jdmu sum_i (-1 / s) * dx_hat_i最终对每个x_i三部分相加dx_i dx_hat_i / s dvar * 2 * (x_i - mu) / N dmu / N第二项是var通过(x_i - mu)^2对x_i的影响第三项是mu通过(1/N) * x_i对x_i的影响。合并化简后得到一个很紧凑的numpy实现def batchnorm_backward(dout, cache): x, mu, var, x_hat, eps, gamma cache N x.shape[0] dgamma np.sum(dout * x_hat, axis0) dbeta np.sum(dout, axis0) dx_hat dout * gamma inv_std 1.0 / np.sqrt(var eps) dx inv_std * ( dx_hat - np.mean(dx_hat, axis0) - (x - mu) / (var eps) * np.mean(dx_hat * (x - mu), axis0) ) return dx, dgamma, dbeta这个公式里每一项的来历都值得琢磨。np.mean(dx_hat, axis0)来自均值链路(x - mu) / (var eps) * np.mean(dx_hat * (x - mu), axis0)来自方差链路。如果只看第一项inv_std * dx_hat那等于把x_hat当成了普通逐元素变换完全忽略mu和var是x的函数的这一事实梯度检查时数值梯度会给你一个大嘴巴。3.3 数值梯度检查的判定标准与翻车点作业框架里有eval_numerical_gradient_array会用中心差分计算数值梯度然后和你的解析梯度比较。判断通过的标准通常看最大误差是否小于1e-7量级但因为我用了float32的数据类型误差在1e-6以内已经算达标。如果误差飙升到1e-3甚至1e-1基本可以断定梯度公式里有错误。我调试时用的样本量很小N4D5gamma和beta全初始化为1和0。这个配置的好处是计算快而且能够覆盖均值、方差两条链路都起作用的情况。如果N1均值链路会退化成一种奇怪的形式公式本身没错但不容易暴露问题。另一个容易翻车的点是输入x的分布。如果用全零初始化去测试梯度那方差为0inv_std直接爆炸。我用的是np.random.randn生成的标准正态分布数据这样均值和方差都是典型值梯度检查更稳定。4. 训练和测试的双模式running统计量是BN最容易翻车的角落4.1 为什么测试时不能用batch统计量训练时BN用当前batch的均值和方差做归一化这是没问题的。但到了测试阶段模型要面对的是单个样本或很小的batch如果还用batch统计量结果会非常不稳定。比如batch大小是1均值就是它自己方差恒为0归一化直接失效。所以标准做法是维护一组全局统计量running_mean和running_var在训练过程中用滑动平均不断更新running_mean (1 - momentum) * running_mean momentum * sample_mean running_var (1 - momentum) * running_var momentum * sample_var测试时用这组running统计量做归一化等于让模型用整个训练集分布的先验信息而不是依赖当前batch的噪声统计。4.2 momentum用0.9还是0.99差别还真不小作业框架里momentum默认值是0.9。这个值的含义是当前的batch统计量对新running统计量的贡献占10%历史统计量占90%。momentum越小running统计量对近期batch的变化越敏感momentum越大更新越平滑。我实验中发现训练集很小的时候momentum0.9会让running_var明显滞后于真实方差测试时归一化稍微偏移而momentum0.99会更稳。很多框架里直接写死0.1的系数即历史权重0.9道理是一样的。如果你看到测试准确率刚开始很低、后来慢慢回升很可能是running统计量还在热身阶段。4.3 训练模式下忘记更新running统计量的排查过程我当时把BN接进全连接网络后训练loss下降得比基准快但测试准确率一直没有突破而且每个epoch之间波动非常大。第一反应是overfitting加了正则化也没用。后来我仔细检查forward代码发现问题在于mode参数的处理。我在训练循环里没有把BN的bn_param[mode]设置成train导致测试时模型仍然在计算当前batch的均值和方差等于把训练时的非平稳性带到了测试阶段。这是一个非常隐蔽的坑因为前向代码在语法上完全正确训练loss也正常只有测试阶段崩溃。排查方法很笨但有效在训练前打印一次running_mean的初值全0跑一个batch后打印更新后的值再在测试阶段打印使用的统计量逐层验证数据流。只要统计量没有按预期更新问题马上就能定位。4.4 关于running统计量的另一个经典误区有的同学会在梯度检查时发现奇怪的现象第一次梯度检查通过了第二次再跑同样的检查却不稳定。原因就是梯度检查只涉及forward和backward一次但running统计量已经发生了更新。BN层的梯度计算并不依赖running统计量但如果你在同一个session里重复执行多次forwardrunning统计量本身在变数值梯度和解析梯度自然可能出现细微偏差。所以做梯度检查时最好用一次性的小输入或者明确知道running统计量的更新不影响当前梯度的计算。这个小问题不解决很容易让新手白排查几个小时。5. 接进FullyConnectedNet之后训练曲线告诉我BN到底改变了什么5.1 相同学习率下的loss曲线对比作业要求在同一个全连接网络上分别训练有BN和没有BN的版本。我用的是三层隐藏层、每层100个神经元的配置学习率和正则化强度保持一致。结果符合预期有BN的版本loss下降明显更快七八个epoch后就追上了基准网络十几个epoch的成绩。但真正有意思的是损失曲线的形状。无BN的网络loss下降曲线在初始阶段有一个明显犹豫期——前几百次迭代几乎不动然后才突然开始下降。有BN的网络基本上在第一个epoch就开始稳步下降。原因很直观没有BN时前面几层输出的分布还在剧烈变化网络要花额外的时间去适应这种变化BN把每层输入的分布钉在了差不多的尺度上梯度更新方向更稳定自然更快进入正常训练状态。5.2 BN如何降低了对初始化尺度的敏感度我额外做了一组实验把权重初始化的标准差从0.01加大到0.1。无BN的网络几乎当场死亡损失曲线直接发散。而有BN的网络虽然也受到冲击但还能继续训练最终收敛效果也只是略微下降。这就是BN常说的降低对初始化敏感度的直接体现。因为BN层会主动把前一层的输出重新缩放到合适范围所以初始权重哪怕偏大经过归一化后传给激活函数的值也不会极端。它相当于在每一层入口加了一个自适应调节器把危险的输入范围给拉回来。5.3 从BN到LayerNorm的一点延伸思考做完作业后我顺带想了一个问题为什么后来的大规模模型普遍不是用Batch Normalization而是用Layer Normalization之类的变体BN的致命弱点是依赖batch维度。当batch size太小或者数据分布在多个设备上被切分时batch统计量本身就不可靠。Layer Normalization不依赖batch它对每个样本的所有特征做归一化相当于把归一化的坐标轴换了方向。我在作业框架里顺手实现过一个简单的LN版本前向就是把轴从axis0换成axis1无需维护running统计量。这个对比让我意识到归一化的本质其实是在控制每一层激活值的尺度防止它在跨层传播时被指数级放大或缩小。至于在哪个维度上做归一化取决于你更信任哪一维的统计可靠性CV里一批图像往往有相似分布所以BN在batch维度上有效NLP里每个样本长度和相关统计都不一样所以按特征维度归一化更稳妥。另外我实际使用中发现BN和Dropout一起用的时候可以稍微减少Dropout的比例。BN本身给每个batch的归一化过程中引入了统计噪声这种噪声多少有一点正则化的味道。不过作业里没有深入对比这个细节我自己的实验也只是在小数据集上的初步观察不构成普适结论。最后说点实在的做完这一节最大的收获不是能默写BN的公式而是明白了归一化在深度学习训练中扮演的角色。理解BN你对训练动力学里的很多现象——学习率调大为什么不一定爆、初始化尺度为什么不像以前那么敏感——都会有直感层面的提升。我的建议是别急着调框架里的nn.BatchNorm1d先用numpy把这个层完整写一遍。写不出来的地方就是你理解不到位的地方而梯度检查是最好的老师它永远不会骗你。跑通之后可以再玩点花样把BN位置换到激活函数后面试试、改变momentum看测试曲线变化、把BN改成LN对比一下。这些改动每一样都能刷出新的观感比单纯记代码有意思得多。这篇笔记里的推导和踩坑记录就当是给和自己一样在作业里挣扎的人留一张地图。祝写BN反向的时候一次通过梯度检查。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。