主题
第 14 章 亲手走完 BatchNorm 与 embedding 的反向传播
交叉熵会在一行的类别之间分配梯度,BatchNorm 则在一列的样本之间建立联系。后者最容易写错的地方,是同一个输入同时参与“自己的中心化残差”和“整批的均值、方差”。只对分子求导,会漏掉另外两条路径。
本章按课程第 5 讲的四项练习收尾:先逐节点反传,再化简 BN,然后走回输入线性层和字符表,最后用手写梯度真正更新参数。我们采用课程的
14.1 先给每个中间量一个名字
令 BN 输入为
先过最后的仿射变换:
对 gamma、beta 求和,是因为它们被全批共享。到
14.2 方差与均值两条路都要走回来
对
eps 是常量,不求它的梯度;但它留在导数里,不能为了把公式写漂亮就删除。接着从方差回到平方残差,
最后
这一步把整列梯度的均值减掉。因此每列
下面列出配套变量名,便于逐项核对:
| 数学量 | 代码缓存名 | 反传的关键操作 |
|---|---|---|
pre | 来自 tanh 的局部导数 | |
raw | 乘 gamma | |
inv | 对批次求和 | |
var | 乘 −0.5 与负三次方根 | |
square | 将方差梯度广播,除以 | |
diff | 直接路径加平方路径 | |
mu | 负的批次和 | |
a | 残差直接项加均值路径 |
14.3 官方练习 3:把 BN 反传化简成一式
先将
又因为
中心化保证
这是配套代码采用的统一式,eps 已经包含在
如果前向改为除以
用一列、三个样本核对直觉。输入
其中中间写法只是把均值广播成三项;直接按元素算会更清楚:第一项
这里还有一个细节:eps 为零时,沿输入整体缩放方向的导数也会完全消去;eps 非零时,缩放不变性不再严格成立。因此不要给测试强加“
14.4 第一层与 reshape:梯度按原来的路返回
第一层为
这里
前向把查表输出 view。
14.5 embedding 最容易漏掉的是重复索引
embedding 表
例如一维 embedding 中,索引是 dC[ix] = ... 会让后一次覆盖前一次;某些带重复索引的高级索引写法 dC[ix] += ... 也不能被想当然地当成可靠的散射累加。
最清楚的教学版本是两层循环逐项 +=。张量版本则使用明确具有累加语义的 index_add_:
python
dC = torch.zeros_like(C)
dC.index_add_(0, X.reshape(-1), dE.reshape(-1, C.shape[1]))本书实验的输入包含多次字符 1;若故意用覆盖式赋值,最大误差约为 0.04305。整数索引
14.6 从“看起来对”到确实经过检查
在书根目录运行:
text
.venv/bin/python examples/part2/manual_backprop.py程序固定 seed 2026、单 CPU 线程、float64,批大小为 4、词表大小为 5、上下文为 3、embedding 宽度为 2、隐藏宽度为 4。所有 7 组参数和 19 个中间张量分别检查形状与梯度,共 26 项。这个小模型足够包含全部路径,运行不依赖下载姓名数据。
| 检查 | 本书实测结果 | 能说明什么 |
|---|---|---|
| 26 项与 autograd 对照的最大绝对误差 | 本次输入下逐节点一致 | |
| 整个标量 loss 的 float64 gradcheck | 通过 | 中心差分也支持导数正确 |
| 重复字符坐标 | −0.0314336464423 | 累积了多次使用 |
| 同坐标中心差分 | −0.0314336464635 | 与解析值相差约 |
| 反例:BN 用错分母 | 最大误差约 0.001149 | 测试能拒绝真实错误 |
| 反例:embedding 梯度覆盖 | 最大误差约 0.04305 | 重复索引确实被测到 |
有限差分计算
14.7 官方练习 4:不用 autograd 训练一次
完成验证后,复制一份没有 requires_grad 的参数,在 torch.no_grad() 中前向、调用手写反传、执行 p -= lr * dp。配套代码用同一个四样本批次做 200 次更新,学习率为 0.1,真实检查值如下:
| 已完成更新数 | 平均损失 |
|---|---|
| 0 | 1.657875 |
| 50 | 0.446956 |
| 100 | 0.180666 |
| 200 | 0.071273 |
这一步证明手写梯度确实能驱动参数更新,只证明四样本的拟合,不证明姓名模型的泛化。原课程对姓名数据做更长训练,并用训练集重新校准单层 BN 后评估、采样。本书不把 notebook 中记录的 train≈2.0719,val≈2.1162 说成本机复现;这些是上游的历史记录。
要把手写梯度训练扩到前一部分的数据,保持按完整单词划分 train/dev/test,训练循环抽样本批次;训练结束仅用训练数据计算固定 BN 统计,再用相同前向计算各划分损失。采样时从全零起始上下文出发,按 softmax 概率抽一个字符,左移上下文直到终止符。配套文件的 names_demo() 也完整执行这条流程:12 个内置词按 8/2/2 分组,全部参数关闭自动求导,500 次手写梯度更新后,仅用训练集校准。实测 train/dev/test 损失分别为 0.491513、2.405374、2.466960;固定 seed 2036 的五个样本为 tina.、tanim.、mtna.、tania.、mtina.。程序还断言单样本与批量推理结果一致。这是完整流程的缩小演示,数据太少且含训练未见字符,不用于评价课程模型性能。
练习、提示与答案
- 官方练习 1,后半。 按 14.1–14.2 的原子图实现 BN、线性层和 embedding 反传,逐项输出误差。
- 官方练习 3。 从原子式推导紧凑式,并分别运行 correction=0 和 1。提示:化简只用到中心化后每列和为零,不需要假设输入服从正态。
- 官方练习 4。 删除训练循环中所有自动求导调用,用自己的梯度拟合四样本,再接回独立验证流程。
- 补充。 让某字符从未在批次出现,它的 embedding 梯度应是什么?
前三题完整答案在本章推导和 manual_backprop.py。第四题是全零,直接检查 dC 的相应行;“这个字符是输出标签”不意味着输入 embedding 一定使用过它,除非模型明确共享输入表与输出权重,本课没有这样做。