从开始对着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_forward | x、gamma、beta、bn_param,输出out和cache | cache里忘了缓存gamma,导致反向没法算 |
| batchnorm_backward | dout和cache,输出dx、dgamma、dbeta | 忽略均值mu对x的反馈链路,梯度检查必挂 |
| 接入FullyConnectedNet训练 | 网络配置加normalization='batchnorm' | 测试模式忘记关闭,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, axis=0)得到长度为D的均值向量,方差同理。
我第一次写的时候差点按axis=1求均值,那样相当于对每个样本的所有特征做归一化,得到的结果是每个样本自己把特征拉平了,这完全偏离了BN的初衷。BN的"batch"含义就在这个轴的选择上:统计量来源于batch中多个样本的同一个特征通道。
代码实现极其简单:
sample_mean = np.mean(x, axis=0) sample_var = np.var(x, axis=0) 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, axis=0) dgamma = np.sum(dout * x_hat, axis=0)为什么要对axis=0求和?因为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_j,var = (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_j:
dmu = 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, axis=0) dbeta = np.sum(dout, axis=0) dx_hat = dout * gamma inv_std = 1.0 / np.sqrt(var + eps) dx = inv_std * ( dx_hat - np.mean(dx_hat, axis=0) - (x - mu) / (var + eps) * np.mean(dx_hat * (x - mu), axis=0) ) return dx, dgamma, dbeta这个公式里每一项的来历都值得琢磨。np.mean(dx_hat, axis=0)来自均值链路,(x - mu) / (var + eps) * np.mean(dx_hat * (x - mu), axis=0)来自方差链路。如果只看第一项inv_std * dx_hat,那等于把x_hat当成了普通逐元素变换,完全忽略mu和var是x的函数的这一事实,梯度检查时数值梯度会给你一个大嘴巴。
3.3 数值梯度检查的判定标准与翻车点
作业框架里有eval_numerical_gradient_array,会用中心差分计算数值梯度,然后和你的解析梯度比较。判断通过的标准通常看最大误差是否小于1e-7量级,但因为我用了float32的数据类型,误差在1e-6以内已经算达标。如果误差飙升到1e-3甚至1e-1,基本可以断定梯度公式里有错误。
我调试时用的样本量很小,N=4,D=5,gamma和beta全初始化为1和0。这个配置的好处是计算快,而且能够覆盖均值、方差两条链路都起作用的情况。如果N=1,均值链路会退化成一种奇怪的形式,公式本身没错,但不容易暴露问题。
另一个容易翻车的点是输入x的分布。如果用全零初始化去测试梯度,那方差为0,inv_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越大,更新越平滑。
我实验中发现,训练集很小的时候,momentum=0.9会让running_var明显滞后于真实方差,测试时归一化稍微偏移;而momentum=0.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里重复执行多次forward,running统计量本身在变,数值梯度和解析梯度自然可能出现细微偏差。
所以做梯度检查时最好用一次性的小输入,或者明确知道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版本,前向就是把轴从axis=0换成axis=1,无需维护running统计量。
这个对比让我意识到,归一化的本质其实是在控制每一层激活值的尺度,防止它在跨层传播时被指数级放大或缩小。至于在哪个维度上做归一化,取决于你更信任哪一维的统计可靠性:CV里一批图像往往有相似分布,所以BN在batch维度上有效;NLP里每个样本长度和相关统计都不一样,所以按特征维度归一化更稳妥。
另外,我实际使用中发现BN和Dropout一起用的时候,可以稍微减少Dropout的比例。BN本身给每个batch的归一化过程中引入了统计噪声,这种噪声多少有一点正则化的味道。不过作业里没有深入对比这个细节,我自己的实验也只是在小数据集上的初步观察,不构成普适结论。
最后说点实在的
做完这一节,最大的收获不是能默写BN的公式,而是明白了"归一化"在深度学习训练中扮演的角色。理解BN,你对训练动力学里的很多现象——学习率调大为什么不一定爆、初始化尺度为什么不像以前那么敏感——都会有直感层面的提升。
我的建议是别急着调框架里的nn.BatchNorm1d,先用numpy把这个层完整写一遍。写不出来的地方就是你理解不到位的地方,而梯度检查是最好的老师,它永远不会骗你。跑通之后可以再玩点花样:把BN位置换到激活函数后面试试、改变momentum看测试曲线变化、把BN改成LN对比一下。这些改动每一样都能刷出新的观感,比单纯记代码有意思得多。
这篇笔记里的推导和踩坑记录,就当是给和自己一样在作业里挣扎的人留一张地图。祝写BN反向的时候一次通过梯度检查。