2 一个 Transformer Block 的解剖
本章地图
你在这里:数据流的第 3 步 —— 把那层 Block 整个打开。 本章解决:残差、LayerNorm、FFN 各自在解决什么问题;为什么归一化放在残差前还是后,会让 12 层的模型不如 4 层。 前置:第 1 章(多头注意力可以先当黑盒)。 读完能做:拿到一份别人的 Block 实现,逐行说出每个部件的职责,以及它最容易写错的那一处。
一个只会”猜字频”的模型。
第 1 章我们训了一个 4 层的小模型,1500 步后 loss 降到 4.0 左右, 生成的文本虽然不通顺,但能看出唐诗的腔调。现在把层数加到 12 层—— 按理说更深的模型应该更强。
结果 loss 停在 5.36,怎么训都不动。
这个数字需要一个参照系才能理解。语料里 1200 个字的边缘分布 (也就是”完全不看上下文,只按字出现的频率猜”)给出的交叉熵是 5.67。 也就是说,这个 12 层模型只比”闭着眼睛按字频猜”好了 0.31—— 它基本上什么都没学到。
但模型本身没坏:没有 nan、梯度范数正常、参数量是 4 层版的 2.8 倍。 它只是不学。
这一章从这个现象展开。我们最后找到的原因,不是某一行的 bug, 而是一个被放错位置的归一化层——以及围绕它的、关于”深度学习为什么需要这些零件” 的一整套设计逻辑。
读完本章你应该能回答:
- 残差连接、LayerNorm、FFN——这三样东西各自在解决什么问题? 把它们任何一个拿掉会怎样?
- 为什么 LayerNorm 放在残差里面还是外面,会让同一个模型 从”完全学不会”变成”正常训练”?
- RoPE 为什么能外推而可学习位置嵌入不能?NTK 插值又做了什么?
# 需要 PyTorch。本机在 /home/mpp/micromamba/envs/dlspace/bin/python
python notebooks/run_ch2_case.py --steps 1200 # 案例的全部对照实验
python notebooks/rope_extrapolation.py # 位置编码外推对照CPU 上约 10 分钟,GPU 上 2–3 分钟。 文中每一个数字都来自这两次真实运行,原始数据在 data/ch2_case.json 与 data/rope_extrapolation.json。
2.1 现象:12 层不如 4 层
先把问题摆清楚。
实验设置:与第 1 章同一个语料(全唐诗 4003 首、25.5 万字)、同一个字符级分词器 (字表 1200,覆盖 87.6%)。模型是标准的 Pre-LN Transformer: 每层”注意力 + 前馈网络”,两个子层各带一个 LayerNorm 和一条残差连接。
唯一的变量是层数。表 2.1 是结果:
| 层数 | loss 起点 | loss 终点 | 末梯度范数 |
|---|---|---|---|
| 6 | 7.103 | 4.213 | 0.496 |
| 12 | 7.145 | 4.245 | 0.429 |
| 24 | 7.122 | 4.370 | 0.332 |
等一下——这张表里 12 层和 24 层都训得很好(4.2–4.4),并没有”停在 5.36”。 因为这张表用的是修复之后的配置。
把时间倒回去。我们最初跑的那一版是这样的:
| 层数 | loss 起点 | loss 终点 |
|---|---|---|
| 6 | 7.068 | 5.356 |
| 12 | 7.067 | 5.357 |
| 24 | 7.041 | 5.357 |
三个深度给出几乎一模一样的数字。 这个”一模一样”本身就是最强的线索—— 如果一个模型真的在学东西,不同容量的模型不可能停在同一个 loss 上。
为什么”数字太整齐”是个危险信号
真实的训练充满随机性:初始化不同、数据顺序不同、梯度噪声不同。 两个不同的模型如果给出逐位接近的结果, 通常说明它们都掉进了同一个”与容量无关”的退化解。
在我们的例子里,这个退化解是:输出一个与输入无关的固定分布—— 大约就是字频分布。模型的 170 万个参数全都白费了, 因为它找到了一个”不需要看输入”的答案。
遇到这种情况,先别调超参数。 先问:“它是不是在输出一个常数?” 验证方法很简单:算一下你说的那个 loss,和”只猜边缘分布”的 loss 差多少。
2.2 第一次排查:换个位置就好了
和第 1 章一样的思路——先找那个不同的地方。
我们对比了两种层内的排布。区别只在于 LayerNorm 放在哪:
Pre-LN (前置): x = x + Attention(LN(x))
x = x + FFN(LN(x))
Post-LN(后置): x = LN(x + Attention(x))
x = LN(x + FFN(x))
归一化层本身一模一样,只是位置换了一下。
结果:
| 层数 | Pre-LN | Post-LN | 差距 |
|---|---|---|---|
| 6 | 4.213 | 5.356 | 1.14 |
| 12 | 4.245 | 5.357 | 1.11 |
| 24 | 4.370 | 5.357 | 0.99 |
换个位置,问题就消失了。 Post-LN 在三个深度上都停在 5.36 附近, 而 Pre-LN 一路降到 4.2–4.4。
到这一步,一个自然的结论是”Post-LN 有问题,别用它”。但这不是本章想要的答案—— Post-LN 是 2017 年原论文用的排布,它支撑了 BERT、GPT-1、GPT-2 这些模型。 说它”有问题”显然不对。
真正的问题在于:这个对比不公平。
上面的对比里,两种排布用了完全相同的超参数:学习率 3e-3、 warmup 100 步、AdamW、批大小 32。
请想:为什么”超参数相同”反而可能是不公平的?
提示:如果两个方案对超参数的敏感程度不同, 那么”用同一套参数”就意味着”其中一个没被调到最好”。 你要证明的到底是”A 比 B 好”,还是”A 在 B 的舒适区里比 B 好”?
2.3 排查过程:我们试了什么
上一节说”换个位置就好了”。但在找到这个答案之前,我们走了三条弯路。 把它们写出来,因为弯路比答案更有信息量。
2.3.1 弯路一:怀疑是数据问题
第一个假设很自然:12 层模型参数量是 4 层的 2.8 倍, 但语料只有 25 万字——是不是过拟合了?
验证:把语料扩到 4 倍(《全唐诗》全部 5 万多首)。 结果 loss 从 5.357 降到 5.351——几乎没有变化。
排除。 如果是过拟合,更多数据应该有明显改善。
2.3.2 弯路二:怀疑是学习率
第二个假设:12 层比 4 层深,也许需要更小的学习率。
我们确实扫了(这一条不是弯路,后面成了关键的转折), 但在只扫 Pre-LN 的时候,结论是”学习率对结果影响不大”—— 因为 Pre-LN 在 10^{-4} 到 3\times10^{-3} 之间都能工作。
当时的错误推理:既然 Pre-LN 对学习率不敏感, 那 Post-LN 应该也不敏感,问题不在学习率。
这个推理漏掉了一件事:敏感性是方案自身的属性, 不能用方案 A 的性质去推断方案 B。
2.3.3 弯路三:怀疑是初始化
第三个假设:也许 Post-LN 需要不同的初始化。
这个方向部分正确——残差缩放初始化(1/\sqrt{2L})确实对深层模型重要。 但我们把它加在 Pre-LN 上,Post-LN 上没加。
于是实验变成了”比较排布 + 比较初始化”两个变量。 这是我们自己犯的错,也是 §2.4 讲的”一次只改一个变量”的反面教材。
修正后(两边都不加缩放初始化),差异依然存在, 说明排布本身确实有影响。
它们都在”方案之外”找原因:数据、超参数、初始化。 这些确实是常见病因,但对本章的问题来说,答案就在方案本身。
什么时候该怀疑方案本身:当你已经排除了两三个外部因素, 而现象仍然稳定存在时。不要无限地检查环境—— 那是一种拖延,而不是排查。
一个实用的判据:如果你的现象在三个不同的随机种子下都复现, 且换了数据仍然存在,那就该把注意力收回到方案上了。
2.4 公平一点:各自调到最好
要做一个站得住的对比,必须给两边各自调参的机会。
我们对两种排布各扫了四个学习率:10^{-4}、3\times10^{-4}、10^{-3}、3\times10^{-3}。 其余全部不动(12 层、900 步、同种子)。
| 学习率 | Pre-LN | Post-LN |
|---|---|---|
| 10^{-4} | 4.897 | 5.164 |
| 3\times10^{-4} | 4.703 | 4.732 |
| 10^{-3} | 4.549 | 4.830 |
| 3\times10^{-3} | 4.408 | 5.417 |
这张表改变了整个结论。
同一个学习率下,两者几乎持平。 在 3\times10^{-4} 处, Pre-LN 是 4.703、Post-LN 是 4.732——差 0.03,基本可以认为一样。
差别在”趋势”上: - Pre-LN 随学习率提高单调改善(4.897 → 4.703 → 4.549 → 4.408) - Post-LN 有个很窄的窗口:先改善(5.164 → 4.732),然后急剧恶化(→5.417)
所以 Pre-LN 买到的不是”更好的解”,而是”更宽的可调范围”。
准确的说法是:Post-LN 对学习率敏感得多。 在最佳点附近它和 Pre-LN 一样好,但那个最佳点的容错空间很小; 而 Pre-LN 在很宽的学习率区间里都能正常工作,还能从更高的学习率中获益。
我们最初写下的结论是”Post-LN 训不动”。那是错的—— 它只是在我们随手选的那个学习率下训不动。
这一类错误在研究中非常常见:用一组未经调优的超参数比较两个方案, 然后宣称其中一个更好。 这叫做”用别人的舒适区打别人”。
正确的做法是给两边同等的调参预算, 然后比较”各自最优”或者”对超参数的鲁棒性”—— 这两个是不同的指标,不要混为一谈。
在本章的例子里: - 比”各自最优”:Pre-LN 4.408 vs Post-LN 4.732,Pre-LN 略优 - 比”鲁棒性”:Pre-LN 单调改善,Post-LN 有窄峰,Pre-LN 明显更稳
两个结论都成立,但它们说的不是同一件事。
2.5 转折点:那句”太整齐了”
回头看,整个排查的转折来自一个很小的观察。
我们当时把四组训练的最终 loss 并列在表里:
| Pre-LN | Post-LN | |
|---|---|---|
| 6 层 | 4.213 | 5.356 |
| 12 层 | 4.245 | 5.357 |
| 24 层 | 4.370 | 5.357 |
Post-LN 那一列的 5.356 和 5.357——差 0.001。
第一反应是”跑错了吧”。我们检查了三次日志,确认没有跑错。 然后做了第二件事:把这三组的结果和”只猜字频”的基线放在一起看。
- 字频基线:5.672
- Post-LN 三组:5.356 / 5.357 / 5.357
- Pre-LN 三组:4.213 / 4.245 / 4.370
Post-LN 只比”完全不看输入”好了 0.31,而且不管多少层都一样。
至此,“这是不是一个训不动的模型”变成了 “这是不是一个根本没在用输入的模型”。
问题的性质变了,排查方向也就变了——从”调超参数”转向”看模型到底在算什么”。
这个转折里有两个可复用的动作。
① 把数字和基线放在一起。
“5.357”这个数字本身没有意义。和 5.672(字频)比, 才知道它几乎什么都没学到;和 7.09(均匀)比, 才知道它至少学了一点。
任何指标都要有参照系。 我们第一次看这个结果时, 只觉得”Post-LN 差 1.1”,没有意识到”差 1.1 之后剩下的那部分几乎为零”。
② 注意到”不同配置给出相同结果”。
6 层和 24 层的参数量差 4 倍。它们不该给出同一个 loss。 这个”不该相同却相同”的观察,比 loss 的绝对值更有信息量。
一般的规律:当一组实验的结果”太整齐”时, 通常是模型退化到某个与配置无关的解——先怀疑这个,再怀疑代码。
2.5.1 事后的复盘
现在回头看,我们本可以更快地走到这一步。
如果有”相邻位置输出的 KL 散度”这个指标(§2.15 路径二给过代码), 我们在第一次训练时就会发现它接近 0—— 而不是等到对比三个深度之后。
这正是本章反复强调”要监控中间量”的原因: loss 是一个经过平均、滞后、且解释不唯一的指标。 它告诉你”结果不好”,但不告诉你”哪里不好”。
一个可以立即用上的清单——下次训练时加上这三个:
| 指标 | 健康值 | 它回答什么 |
|---|---|---|
| 相邻位置输出的 KL 散度 | > 0.05 | 模型有没有在用上下文 |
| 逐层激活 std 的末/首比 | < 4 | 残差流有没有失控累积 |
| 注意力权重熵 | 0.5–0.9 | 缩放是否合适 |
三个数的代价几乎为零(每 100 步算一次), 但它们能在 loss 显现之前给出信号。
2.6 为什么位置这么关键
现象讲完了,但机制还没碰。为什么把 LayerNorm 从”外面”挪到”里面”, 就能让同一个模型从”学不会”变成”正常训练”?
要回答这个问题,需要看清楚两件事:
- 残差连接在做什么——它是一条”高速公路”, 让梯度可以不走那些复杂的子层直接传回去。LayerNorm 的位置决定了这条高速公路上有没有收费站。
- LayerNorm 在归一化什么——它不只是”把数据标准化”, 它在每一步都重新设定信号的尺度,这个动作对梯度的传播有巨大的影响。
图 2.1 是初始化状态下,两种排布在残差流上的实测画像:
| 深度 | 排布 | 激活 末/首 | 梯度 首/末 | 梯度首(绝对值) |
|---|---|---|---|---|
| 6 | Pre-LN | 2.57 | 2.12 | 1.65\times10^{-4} |
| 6 | Post-LN | 1.00 | 1.00 | 9.68\times10^{-6} |
| 12 | Pre-LN | 3.87 | 3.16 | 1.60\times10^{-4} |
| 12 | Post-LN | 1.00 | 1.00 | 9.73\times10^{-6} |
| 24 | Pre-LN | 5.68 | 4.10 | 1.41\times10^{-4} |
| 24 | Post-LN | 1.00 | 1.00 | 9.49\times10^{-6} |
| 48 | Pre-LN | 7.78 | 8.63 | 2.21\times10^{-4} |
| 48 | Post-LN | 1.00 | 1.00 | 9.70\times10^{-6} |
读这张表要看三件事:
① Post-LN 的”1.00”是 LayerNorm 的指纹。 每一层结束都做一次归一化, 所以激活尺度被钉死在 1.0——无论多少层、无论什么输入。 从”控制数值范围”的角度看,这是好事。
② 但 Post-LN 的梯度绝对值小了约 15 倍(9.7\times10^{-6} vs 1.6\times10^{-4})。 而且这个倍数不随深度变化——6 层、48 层都是这个量级。
③ Pre-LN 的激活随深度增长(24 层增到 5.7 倍,48 层 7.8 倍), 梯度沿深度有 2–8 倍的不平衡。 这是 Pre-LN 的已知代价(残差流不断累积,没有归一化来抑制)。
两边的”问题”不一样:Post-LN 的问题是梯度整体太小, Pre-LN 的问题是梯度沿深度不均匀。
接下来的几节,我们把残差连接和 LayerNorm 各自拆开, 搞清楚这两个问题分别是怎么来的、以及为什么深度学习最终选了 Pre-LN。
2.7 残差连接:那条不能省的高速公路
先把残差连接单独拿出来做消融。做法很简单:把 x = x + f(x) 改成 x = f(x), 其余全部不动。
| 层数 | 有残差 | 无残差 | 差距 |
|---|---|---|---|
| 2 | 4.489 | 5.392 | 0.90 |
| 4 | 4.520 | 5.392 | 0.87 |
| 12 | 4.600 | 见下文 | — |
注意无残差那一列:2 层和 4 层又是同一个数字。 和第 1 节那个”12 层停在 5.357” 是同一个信号——模型退化成了”不看输入,输出固定分布”。
这个结果比想象中更极端。
通常我们说”残差连接让深网络能训起来”,暗示浅层不需要它。 但实测是:连 2 层都训不动。
2 层 Transformer 只有两个注意力 + 两个 FFN,参数量不到 40 万。 直觉上它和”深”完全不沾边。可一旦去掉残差,它就废了。
为什么? 答案在梯度上。没有残差时, 梯度必须依次穿过每一层的所有非线性变换才能到达前面的层。 2 层听起来不多,但每一层里都有 LayerNorm、softmax、GELU—— 它们都会对梯度做一次缩放。几次下来,信号就衰减到无法有效更新参数了。
残差连接提供了一条”绕过所有变换”的直通路径。 梯度可以走这条路直接回到任意一层,路径长度从 O(L) 变成 O(1)。
2.7.1 为什么是”加”而不是”拼接”
设计残差连接时有个自然的替代方案:把子层的输出拼接到输入上,而不是相加。
\text{相加:} x_{l+1} = x_l + f(x_l), \qquad \text{拼接:} x_{l+1} = [\,x_l\,;\ f(x_l)\,] \tag{2.1}
拼接保留了更多信息(不会把两个向量混在一起),为什么大家还是用相加?
因为维度会爆炸。 每拼接一次,维度增加一倍。L 层之后是 2^L d 维—— 12 层就是 4096 倍。
相加的好处是维度不变,所以可以无限堆叠。代价是”信息被混合了”—— f(x_l) 的输出和 x_l 加在一起之后,你没法再从 x_{l+1} 里分离出原始的 x_l。
有一个漂亮的视角:残差流(residual stream)可以被看成一个共享的通信通道。
- x_l 是所有层共同读写的”总线”。每一层从总线上读取信息(通过 LayerNorm 和投影), 算出一点新的东西,再加回总线上。后续的层可以读取总线上的任何位置—— 包括前面所有层写进去的内容。
这个视角解释了两件事:
- 为什么加法够用:因为各层写入的是不同方向的向量。 在一个 128 维的空间里,12 层各写一个方向,只要它们近似正交就不会互相干扰。 模型在训练中确实倾向于学到近似正交的写入方向。
- 为什么”看自己”的头有用(第 1 章 §1.8 检查点 ⑨): 在总线模型里,一个”把输入原样传下去”的头就是”不往总线上写东西”。 它确保了总线上的已有内容不被这一层覆盖。
“残差流即通信总线”是理解 Transformer 最重要的直觉之一。 第 4 章讲电路分析时,整章都建立在这个视角上。
2.7.2 一个类比:残差像版本控制
残差流的加法结构,和一个看似不相关的领域有个漂亮的对应:版本控制。
- 每层的写入 = 一次 commit(新增一点内容)
- 残差流 = 当前工作区(包含所有历史 commit 的累积效果)
- 没有任何一层能”删除”历史(只能提交一个抵消性的改动)
这个类比的三个推论:
① 读的时候看到的是”全部历史的叠加”,而不是某一次提交。 这就是为什么残差流上的每一维没有干净的含义——它承载着所有层的累积写入。
② 越晚的层”看到”的历史越多。 第 40 层读到的是前 39 层的总和, 第 2 层只有第 1 层。这解释了为什么深层能处理更抽象的东西: 它站在更多历史的肩膀上。
③ “回滚”很难。 就像版本控制里撤销一个中间提交很麻烦, 模型也几乎无法”忘掉”某一层写入的内容(除非后续层学会精确抵消)。
这个类比的价值:它让”残差流是共享总线”这个抽象说法变得具体。 第 4 章讲电路分析时,整章都在用”读—写”这个框架。
2.7.3 残差带来的新问题
残差连接解决了梯度路径,但引入了一个新麻烦:方差累积。
每经过一层,x 就加上一个新的 f(x)。如果每个 f(x) 的方差是 \sigma^2, 而且它们相互独立,那么 L 层之后:
\mathrm{Var}(x_L) = \mathrm{Var}(x_0) + L\sigma^2 \tag{2.2}
方差随层数线性增长。 这正好解释了 图 2.1 里 Pre-LN 那一列: 24 层时激活尺度是首层的 5.68 倍,48 层是 7.78 倍。
(注意是”线性增长方差”,所以标准差按 \sqrt{L} 增长。 L = 24 时 \sqrt{24} \approx 4.9,和实测的 5.68 同量级。)
这个累积会带来两个后果:
- 数值范围失控。48 层时激活标准差是首层的 8 倍—— 如果输入量级本来就大,很快就会碰到浮点上限。
- 后续层的输入量级不一致。第 3 层看到的输入是第 1 层看到的 2 倍, 第 40 层看到的是 6 倍。同一套权重要在不同的输入量级下工作,这很难学。
解决方案有两个,分别对应 Pre-LN 和 Post-LN 的动机:
- Post-LN 的做法:在残差之后归一化,强制每层的输出尺度都是 1
- Pre-LN 的做法:接受累积,但在送进子层之前归一化, 并配合一个缩放初始化(见下文)让累积变慢
这就是两者最本质的差别。下一节先把 LayerNorm 讲清楚,再回来对比。
2.8 LayerNorm:它在归一化什么
2.8.1 先算一遍
LayerNorm 的定义是:对每个位置的特征维做标准化,然后学一个缩放和平移。
\mathrm{LN}(x) = \gamma \odot \frac{x - \mu}{\sqrt{\sigma^2 + \epsilon}} + \beta, \qquad \mu = \frac{1}{d}\sum_{i=1}^{d} x_i, \quad \sigma^2 = \frac{1}{d}\sum_{i=1}^{d}(x_i - \mu)^2 \tag{2.3}
其中 \gamma、\beta \in \mathbb{R}^d 是可学习的参数(分别初始化为 1 和 0), \epsilon 是个小常数(通常 10^{-5})防止除零。
手算一个 d = 4 的例子。 设某个位置的激活是 x = [2,\ 4,\ 6,\ 8]:
| 步骤 | 计算 | 结果 |
|---|---|---|
| ① 求均值 | (2+4+6+8)/4 | \mu = 5 |
| ② 求方差 | \frac{(2-5)^2+(4-5)^2+(6-5)^2+(8-5)^2}{4} = \frac{9+1+1+9}{4} | \sigma^2 = 5 |
| ③ 标准化 | (x_i - 5)/\sqrt{5+10^{-5}} | [-1.342,\ -0.447,\ 0.447,\ 1.342] |
| ④ 缩放平移(初始时 \gamma=1,\beta=0) | 不变 | 同上 |
关键观察:输出向量的均值是 0、方差是 1——无论输入是什么。 输入换成 [100,\ 200,\ 300,\ 400],输出还是 [-1.342,\ -0.447,\ 0.447,\ 1.342]。
“归一化到均值 0 方差 1”这句话,容易被理解成”把数据标准化”这种数据预处理。
但在 Transformer 里它的作用完全不同:它是在每一个子层的入口处, 强行把信号的尺度重置一遍。
为什么需要这个?回到 式 2.2:残差流上的方差随深度累积。 如果不加干预,第 40 层面对的输入量级可能是第 1 层的 6 倍。 LayerNorm 把这个问题消掉了——无论前面累积了多少,送进子层的永远是”标准尺度”的信号。
这才是 LayerNorm 在深层网络里的真正作用:不是为了”数据好看”, 而是为了让每一层都能用同一套权重、面对同样尺度的输入。
2.8.2 一个实测:LayerNorm 的输入真的需要中心化吗
理论上 LayerNorm 会减掉均值。但这个均值到底有多大? 如果输入的均值本来就接近 0,“减均值”这一步就近似于什么都没做。
我们在 §2.8 那两个训练好的模型上量了 LayerNorm 输入的均值:
| 层 | 输入均值的绝对值 | 相对输入标准差的比例 |
|---|---|---|
| 0 | 0.031 | 2.6% |
| 1 | 0.018 | 1.5% |
均值只有标准差的 2% 左右。
这解释了 RMSNorm 为什么能不损失效果。
如果 LayerNorm 输入的均值本来就接近 0, 那么”减均值”这个操作近似于恒等映射—— 去掉它不会改变结果,只省下一次减法和一个参数矩阵。
而为什么均值会接近 0:因为残差流上的写入是近似正交的 (§2.11 讲过),随机方向的向量相加,均值的期望是 0。
这是一个自洽的循环:写入正交 → 残差流均值接近 0 → 减均值不必要 → RMSNorm 够用。
但这个论证有前提:它依赖”残差流均值接近 0”。 如果某个任务或架构下这个前提不成立(比如输入有强系统性偏置), 中心化就会起作用。
本章的实测支持”RMSNorm 在这个场景下够用”,但不构成普遍结论。
2.8.3 推导:LayerNorm 的雅可比
前面反复说”梯度穿过 LayerNorm 会被缩放”。这一节把它推清楚—— 这是理解 Pre-LN 与 Post-LN 差别的数学基础。
设输入 x \in \mathbb{R}^d,输出 y = \mathrm{LN}(x)。 为了简洁,先不考虑 \gamma、\beta(它们只做逐元素缩放和平移, 不改变下面的结论)。
记 \mu = \frac{1}{d}\sum_k x_k、\sigma^2 = \frac{1}{d}\sum_k (x_k-\mu)^2, 则 y_i = \dfrac{x_i - \mu}{\sqrt{\sigma^2 + \epsilon}}。
第一步:把 \mu 和 \sigma 也看成 x 的函数。 用链式法则:
\frac{\partial y_i}{\partial x_j} = \underbrace{\frac{\delta_{ij}}{\sigma}}_{\text{① 直接项}} - \underbrace{\frac{1}{d\,\sigma}}_{\text{② 均值项}} - \underbrace{\frac{(x_i-\mu)(x_j-\mu)}{d\,\sigma^3}}_{\text{③ 方差项}} \tag{2.4}
逐项解释这三个来源:
- ① 直接项:y_i 的分子里有 x_i,所以对 x_j 求导得到 \delta_{ij}/\sigma
- ② 均值项:\mu 依赖所有 x_j,而 y_i 里有 -\mu。 \partial\mu/\partial x_j = 1/d,所以贡献 -1/(d\sigma)
- ③ 方差项:\sigma 也依赖所有 x_j。这一项稍微复杂, 因为 \partial\sigma/\partial x_j = (x_j-\mu)/(d\sigma)(在忽略 \epsilon 时)
这三项合起来说明了什么?
最要紧的是 1/\sigma 这个因子。 它出现在每一项的分母上。
- 如果输入的标准差很小(\sigma \to 0),雅可比会爆炸
- 如果输入的标准差很大,雅可比会缩小
Post-LN 的问题就出在这里:它的 LayerNorm 吃的是
x + f(x)
——残差和。而残差和的方差随深度累积(式 2.2)。 所以越深的层,\sigma 越大,梯度被压得越狠。
这解释了 图 2.1 里那个”Post-LN 梯度小 15 倍”的现象。 不是”LayerNorm 不好”,而是”LayerNorm 吃了一个方差很大的输入”。
验证一下这个推论。 如果上面的解释成立,那么:
| 预测 | 验证方式 |
|---|---|
| Post-LN 的 LayerNorm 输入 \sigma 更大 | 打印两种排布下的 LN 输入 std |
| Post-LN 的梯度被压得更狠 | 打印梯度范数 |
| 层数越多,压制越严重 | 对比 6/12/24 层 |
前两条我们的数据支持(图 2.1:Post-LN 梯度 9.7\times10^{-6}, Pre-LN 1.6\times10^{-4},差 16 倍)。
第三条我们的数据不支持——Post-LN 的梯度尺度在 6/12/24/48 层上 都是 9.7\times10^{-6} 量级,与深度无关。
“越深压制越狠”这条推论没有被我们的数据支持。
观察到的现象是:Post-LN 的梯度尺度恒定在 9.7\times10^{-6}, 不管 6 层还是 48 层。
一个可能的解释:Post-LN 每层都在残差和之后归一化, 所以每一层的输入 \sigma 都被上一层的归一化重置过—— 不存在”越积越大”的问题。累积在层内发生(x + f(x) 这一步), 但被这一层的 LayerNorm 立刻消掉。
如果是这样,那 Post-LN 的梯度小就另有原因—— 可能和”每层都要穿过两个 LayerNorm”有关(路径上的累积缩放), 而不是”残差流累积”。
我们没有进一步验证这个解释。 写出来是为了标出本章的边界: 现象是确凿的(数据在那里),机制是部分解释的。
2.8.4 归一化的两个自由度
式 2.3 里有两个可学习参数,它们的分工值得说清:
- \gamma(缩放):让模型能”撤销”归一化。如果某一维的信息量很重要, 模型可以把 \gamma 学大,把这个维度重新放大回去。
- \beta(平移):让模型能设定非零的中心。归一化后均值恒为 0, 但如果某一维需要整体偏置,\beta 提供这个能力。
为什么必须有这两个参数:如果只有纯粹的归一化(\gamma=1、\beta=0 固定), 模型的表达能力会受限——它无法表示”某个维度的值应该很大”这类信息, 因为归一化会把所有维度的尺度强行拉平。
这也是 RMSNorm 保持 \gamma 但去掉 \beta 的理由(见后文): 缩放是必需的,平移的必要性则存疑。
2.8.5 为什么是”对特征维”而不是”对批次维”
这是 LayerNorm 与 BatchNorm 的核心区别,也是它适合 Transformer 的原因。
| BatchNorm | LayerNorm | |
|---|---|---|
| 归一化的维度 | 跨样本(一个 batch 内同一特征) | 跨特征(单个样本内所有特征) |
| 依赖 batch 大小 | 是(batch 小则统计不准) | 否 |
| 训练/推理是否一致 | 否(推理用移动平均) | 是 |
| 适合变长序列 | 否 | 是 |
序列任务里,BatchNorm 有两个致命问题:
- 序列长度可变。归一化时要对哪些位置求统计量?padding 要不要算进去? 这些问题没有干净答案。
- 自回归生成时 batch 大小是 1。BatchNorm 在推理时用训练期累积的移动平均, 但如果训练和推理的分布不同(比如长度差异大),统计量会对不上。
LayerNorm 对每个位置独立处理,与 batch 和序列长度都无关—— 这让它在序列建模里成了默认选择。
LayerNorm 对每个位置独立做归一化。这意味着:
一个位置上的归一化结果,完全不受其他位置的影响。
请想:这带来什么好处,又带来什么限制?
提示:好处从”并行性”和”训练/推理一致性”想; 限制从”如果某个位置的特征分布本身就是异常的,会怎样”想。 (这也是 LayerNorm 在某些任务上不如 BatchNorm 的原因。)
2.9 Pre-LN 与 Post-LN:一个位置改变一切
现在可以把案例解释清楚了。两者唯一的差别是 LayerNorm 在残差里面还是外面:
\text{Post-LN:} \begin{aligned} x &\leftarrow \mathrm{LN}\bigl(x + \mathrm{Attn}(x)\bigr) \\ x &\leftarrow \mathrm{LN}\bigl(x + \mathrm{FFN}(x)\bigr) \end{aligned} \qquad \text{Pre-LN:} \begin{aligned} x &\leftarrow x + \mathrm{Attn}\bigl(\mathrm{LN}(x)\bigr) \\ x &\leftarrow x + \mathrm{FFN}\bigl(\mathrm{LN}(x)\bigr) \end{aligned} \tag{2.5}
2.9.1 差别一:梯度路径上有没有”收费站”
Post-LN 的梯度必须穿过 LayerNorm。 反向传播时,LayerNorm 的雅可比会缩放梯度。 对于一个 d 维的输入,它的反向大致把梯度乘以 1/\sigma(\sigma 是输入的标准差)。
Post-LN 里,LayerNorm 的输入是残差和 x + f(x),它的标准差随深度累积 (式 2.2)。所以越深的层,LayerNorm 的 \sigma 越大, 梯度被压得越狠。这就是 图 2.1 里 Post-LN 梯度绝对值小 15 倍的来源。
Pre-LN 的梯度有一条直通路径。 看 x \leftarrow x + \mathrm{Attn}(\mathrm{LN}(x)): 对 x 求导,第一项是恒等映射(导数为 1),第二项才经过 LayerNorm 和注意力。
\frac{\partial x_{l+1}}{\partial x_l} = \underbrace{I}_{\text{恒等项}} + \underbrace{\frac{\partial\, \mathrm{Attn}(\mathrm{LN}(x_l))}{\partial x_l}}_{\text{变换项}} \tag{2.6}
这个 I 就是”高速公路”。 梯度可以沿着它一路回到第 0 层,不被任何变换缩放。 变换项即使很小甚至接近零,恒等项仍然保证梯度不衰减。
Post-LN 的对应表达式里没有这个 I——每一层的雅可比都要经过 LayerNorm, 所以梯度必然被反复缩放。
两个排布最本质的差别,用一句话概括:
Pre-LN 把”恒等路径”和”变换路径”分开了;Post-LN 把它们混在一起。
分开的好处:梯度有一条不受干扰的通道,深层的训练变得可能。 分开的代价:残差流上的方差没人管,一路累积(图 2.1 里的 5.68 倍)。
Post-LN 试图用 LayerNorm 同时做两件事:既归一化子层的输入, 又控制残差流的方差。但这两件事的需求是冲突的—— 把归一化放在残差之后,就必然让梯度也穿过它。
2.9.2 差别二:初始化能不能救
Post-LN 有一个著名的补救手段:特殊初始化。
既然问题是”残差和的方差随深度累积”,那就让每一层的输出一开始就很小。 GPT-2 论文里的做法是把残差分支的权重按 1/\sqrt{2L} 缩放 (2 是因为每层有两个残差分支):
# 残差分支的缩放初始化
for name, p in model.named_parameters():
if name.endswith("proj.weight") or name.endswith("mlp.2.weight"):
nn.init.normal_(p, mean=0.0, std=0.02 / math.sqrt(2 * n_layer))注意这个 \sqrt{2L} 和第 1 章那个 \sqrt{d_k} 完全是两回事: 一个作用在权重初始化上,一个作用在注意力打分上。 名字里都有根号,但对象和目的都不同。
为什么这个技巧主要用在 Pre-LN 上:Pre-LN 的残差流没有归一化兜底, 累积问题更严重,所以更需要它。Post-LN 每层都归一化, 累积被自动抑制了——它的麻烦在别处(梯度)。
2.9.3 为什么今天都用 Pre-LN
Xiong 等人 2020 年的工作(On Layer Normalization in the Transformer Architecture) 系统分析了这个问题,结论是:Pre-LN 在没有任何 warmup 的情况下也能稳定训练, 而 Post-LN 必须依赖 warmup。
我们复现了这个结论,但结果比论文说的更微妙。看 图 2.3:
| warmup 步数 | Pre-LN | Post-LN |
|---|---|---|
| 0(无 warmup) | 4.394 | 5.356 |
| 100 | 4.245 | 5.357 |
| 400 | 4.160 | 5.357 |
两个观察,其中第二个和论文的说法不同:
① Post-LN 对 warmup 完全没有反应。 0、100、400 三个设置下 loss 都是 5.356/5.357—— 说明它的问题不是”训练初期不稳定”,而是在稳态学习率下根本学不动。 如果原因是”初期梯度太猛冲坏了”,加 warmup 应该有效。实测无效。
② Pre-LN 反而从 warmup 中获益更多(4.394 → 4.160)。 这和”Pre-LN 不需要 warmup”的常见说法相反。
诚实地说,我们不清楚全部原因,但有几点可以指出:
① 规模不同。 Xiong 等人的实验是标准机器翻译设置 (6 层 encoder + 6 层 decoder,d_{\text{model}} = 512,WMT 数据)。 我们的是 12 层 decoder-only、d_{\text{model}} = 128、25 万字的小语料。
② 学习率不同。 我们用 3e-3——从扫描表看, 这个学习率对 Post-LN 已经超出它的舒适区(最优在 3e-4)。 在超出舒适区的学习率下,“warmup 有没有用”这个问题本身就问得不对: 它不是因为前期不稳才失败,而是因为稳态学习率就太高。
③ “需要 warmup”这个说法可能被简化了。 论文的原意更可能是 “Post-LN 对超参数更敏感,warmup 是缓解手段之一”, 而不是”加了 warmup 就能解决 Post-LN 的所有问题”。
这三点都不是”论文错了”,而是”结论有前提”。 我们的实验落在前提之外,所以观察到不同的现象。
写这一节时我们犹豫过:要不要把这段删掉,只保留和论文一致的结论?
不删。 因为它示范了读论文最重要的一件事—— 看到一个结论,先问它的实验条件是什么,再看你的条件是否落在里面。
而且这个不一致指向了一个更准确的结论:
Pre-LN 的优势不在于”不需要 warmup”, 而在于它的可用超参数范围宽得多(回看扫描表: Pre-LN 单调改善到 3e-3,Post-LN 在 3e-4 之后急剧恶化)。
“鲁棒性”比”某一条具体规则”更接近本质。
2.9.4 一个实践上的建议
如果你要训一个自己写的 Transformer,默认用 Pre-LN。理由:
- 它对学习率宽容,不用花很多时间调
- 不需要特殊的初始化技巧
- 今天所有主流开源模型(GPT-2/3、Llama、Qwen…)都用它,出问题容易找到参考
但要知道它的代价:残差流的方差会随深度累积。 48 层时激活标准差是首层的 7.8 倍——层数继续加, 可能需要额外机制(比如在残差流上做周期性缩放)。
Post-LN 不是”错的”:BERT、GPT-1、GPT-2 都用过它。 只是在同样的调参预算下,Pre-LN 更省心。
2.10 RMSNorm:去掉均值之后
2019 年,Zhang 和 Sennrich 提出了一个简化版:RMSNorm (Root Mean Square Layer Normalization)。
它只做一件事:按均方根缩放,不减均值、不加平移。
\mathrm{RMSNorm}(x) = \gamma \odot \frac{x}{\sqrt{\frac{1}{d}\sum_{i=1}^{d} x_i^2 + \epsilon}} \tag{2.7}
对比 式 2.3,差别是:不减均值(没有 x - \mu)、 没有平移参数 \beta、保留了缩放参数 \gamma。
2.10.1 先算一遍
用同一个例子 x = [2,\ 4,\ 6,\ 8]:
| 步骤 | LayerNorm | RMSNorm |
|---|---|---|
| ① 中心化 | [-3,\ -1,\ 1,\ 3] | 不做 |
| ② 尺度 | \sqrt{(9+1+1+9)/4} = \sqrt{5} | \sqrt{(4+16+36+64)/4} = \sqrt{30} |
| ③ 归一化 | [-1.342,\ -0.447,\ 0.447,\ 1.342] | [0.365,\ 0.730,\ 1.095,\ 1.461] |
| ④ 输出的均值 / 方差 | 0 / 1 | 0.913 / 0.184 |
注意 RMSNorm 的输出不是零均值的——它保留了输入的整体偏移。 它只保证一件事:平方和的均值是 1。
去掉均值,为什么还能用?
Zhang 和 Sennrich 的论证是:LayerNorm 的缩放不变性才是它起作用的关键, 中心化(减均值)不是必需的。
从梯度角度看更清楚:减均值这一步在反向传播时会引入一个”减去平均梯度”的项—— 回想第 1 章 softmax 的雅可比,那里的 dS = P \odot (dP - \langle dP, P\rangle) 有完全相同的结构,都是”减去约束方向”。
去掉它,反向传播少一步运算,这就是 RMSNorm 的主要收益。
实测的账:在我们的 12 层模型上, LayerNorm 版 1,745,664 参数,RMSNorm 版 1,742,464——少 3,200 个(0.18%)。 差距来自去掉的 \beta。参数量的节省微乎其微, 真正的收益在计算量上:少一次求均值和一次减法, 在大模型上能省下可观的显存带宽。
2.10.2 效果对比
我们训了两个版本,其余完全相同:
| 归一化 | 参数量 | loss 终点 |
|---|---|---|
| LayerNorm | 1,745,664 | 4.245 |
| RMSNorm | 1,742,464 | 4.252 |
几乎一样(差 0.007,远小于训练本身的随机波动)。
这和文献一致:RMSNorm 在大多数任务上能达到 LayerNorm 的效果, 同时计算更简单。今天 Llama、Qwen、Gemma 等主流模型都用它。
看我们的实现:
def forward(self, x):
dtype = x.dtype
x = x.float() # ← 升到 float32
rms = torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
return (x * rms).to(dtype) * self.weight # ← 再降回来为什么要在 float32 下算:回到第 1 章讲的精度问题。 x.pow(2).mean() 是一个求平均的操作——4096 个数相加, 低精度下的舍入误差会累积。
而且如果 x 里有较大的值(比如 100),x^2 = 10^4—— 在 bf16 下(机器精度 7.8\times10^{-3})这个平方和很快就失去有效位数。
“归一化类操作在 float32 下算”是一条通用经验, LayerNorm、softmax、loss 计算都适用。
代价:多两次类型转换。但正确性优先—— 这类操作的数值错误会污染整个 batch 的梯度。
RMSNorm 只保证”平方和的均值是 1”,不保证零均值。
请想:如果给输入的所有分量都加上一个大常数 c(x \to x + 100\mathbf{1}), RMSNorm 的输出会怎么变?LayerNorm 呢?
算一下 x = [2,4,6,8] 和 x' = [102,104,106,108] 的 RMSNorm 输出,对比 LayerNorm。
(这个差异在什么场景下会变成问题?提示:想想输入里如果有系统性的偏置—— 比如某个特征恒为正——两种归一化会如何对待它。)
2.11 FFN:它其实是个键值记忆
一个 Block 里,注意力之后是前馈网络(FFN):
\mathrm{FFN}(x) = W_2 \cdot \mathrm{GELU}(W_1 x + b_1) + b_2 \tag{2.8}
形状上,W_1 \in \mathbb{R}^{d_{\text{ff}} \times d_{\text{model}}}、 W_2 \in \mathbb{R}^{d_{\text{model}} \times d_{\text{ff}}}, 中间那个 d_{\text{ff}} 通常是 d_{\text{model}} 的 2–4 倍。
参数量的账:d_{\text{model}} = d_{\text{ff}} = 128 时, FFN 有 2 \times 128 \times 128 = 32{,}768 个参数; 注意力的 W_Q、W_K、W_V、W_O 各 128 \times 128,共 65{,}536。 FFN 占了每层参数的三分之一。
那么它在干什么?“前馈网络”这个名字什么也没说。
2.11.1 换一个写法
把 FFN 的两次矩阵乘法拆开看。设 W_1 的第 i 行是 k_i \in \mathbb{R}^{d_{\text{model}}}, W_2 的第 i 列是 v_i \in \mathbb{R}^{d_{\text{model}}}。那么:
\mathrm{FFN}(x) = \sum_{i=1}^{d_{\text{ff}}} \mathrm{gelu}(x \cdot k_i) \cdot v_i \tag{2.9}
推导:W_1 x 的第 i 个分量就是 x \cdot k_i(行向量与 x 的内积)。 GELU 逐元素作用,得到 a_i = \mathrm{gelu}(x \cdot k_i)。 最后 W_2 乘以这个向量,是 W_2 各列按 a_i 加权求和。
看清 式 2.9 的形式了吗?它和第 1 章的注意力是同构的:
\text{注意力:} \sum_j \mathrm{softmax}(q\cdot k_j)\, v_j \qquad \text{FFN:} \sum_i \mathrm{gelu}(x \cdot k_i)\, v_i \tag{2.10}
两者都是”用输入去匹配一组键,然后按匹配度把对应的值加权求和”。
差别在两处:
- 归一化:注意力用 softmax(权重和为 1、互相竞争); FFN 用 GELU(每个槽位独立,可以同时激活多个,也可以全都不激活)
- 键的来源:注意力的键来自上下文(是别的 token 的投影); FFN 的键是固定的参数,与输入无关
所以 FFN 是一个”静态的键值记忆”:d_{\text{ff}} 个槽位, 每个槽位存一个”模式”(键)和一个”要写回的内容”(值)。 输入来了,匹配得上的槽位就把自己的值写进残差流。
这也解释了 d_{\text{ff}} 为什么比 d_{\text{model}} 大: 它是记忆槽的数量。更大的 d_{\text{ff}} = 更多的记忆。
2.11.2 在真实模型里验证
上面是数学上的重写。真实的模型里,这个”记忆”的成色如何? 我们量了三个数。
① 槽位的使用是稀疏的。
| 层 | 从不激活的槽位 | 平均激活率 |
|---|---|---|
| 0 | 1.6% | 20.9% |
| 1 | 14.5% | 14.7% |
平均只有 15–21% 的槽位在任一时刻是激活的。 这和”记忆”的直觉一致—— 输入千变万化,每个输入只该用到与自己相关的少数几个模式,而不是把所有槽位都打开。
层 1 有 14.5% 的槽位从不激活(在 2 万个测试位置上都没有响应过)。 这类”死神经元”在大模型里是已知现象,通常和初始化或学习率有关。 它们是纯粹的浪费——占了参数却不干活。
② 键之间大体上是正交的。
| 层 | 键的平均相似度 | 最大相似度 |
|---|---|---|
| 0 | +0.136 | +0.978 |
| 1 | +0.160 | +0.941 |
平均相似度只有 0.14–0.16——大部分键指向不同的方向, 这正是”每个槽位编码一个不同模式”所需要的。如果键都相似, 那所有槽位会对同样的输入激活,记忆就退化了。
③ 但存在近乎重复的槽位。
最大相似度到 0.978——有些键几乎是同一个方向。 它们是冗余的:两个槽位在做差不多的事。
FFN 确实像一个键值记忆,但这个记忆的组织是”粗糙”的。
- 大部分键正交 → 记忆的内容是分散的、有区分度的(好)
- 使用稀疏 → 每次只调用相关的少数槽位(符合预期)
- 但存在死槽位和重复槽位 → 有浪费
这解释了为什么”剪枝 FFN”是可行的:如果能识别出死槽位和重复槽位, 把它们删掉,模型大小能显著缩小而效果几乎不变。 这也是模型压缩这一整条技术线的基础之一。
反过来说:这个”浪费”可能不是缺陷。 一个训练好的网络保留冗余,也许是过参数化带来的容错空间—— 删掉之后小任务上没差别,但换一个分布就可能出问题。 “能不能删”和”该不该删”是两个问题。
式 2.10 显示注意力和 FFN 是同构的,都用”键值匹配”的形式。
请想:为什么模型需要两套键值匹配? 直接用一个大 FFN 不行吗?
提示:从”键从哪里来”这个差别入手。 注意力的键随上下文变化,FFN 的键固定不变。 什么样的信息需要”随上下文变化地提取”,什么样的是”固定的知识”?
(这个问题是第 4 章电路分析的引子:注意力负责”搬运信息”, FFN 负责”存储知识”。)
2.11.3 一个可以自己做的观察:键与”字”的对应
我们的分析里,层 0 有两个神经元激活特别强(激活值 3.5–4.2)。 它们在什么输入上触发? 这是”从权重读出语义”的第一步。
做法:收集每个位置上该神经元的激活值, 以及该位置的字,按激活值排序,看排名靠前的都是些什么字。
# 关键代码(完整版在 analyze_ffn.py 的基础上扩展)
top_positions = torch.topk(activations[:, neuron_id], 50).indices
chars_at_top = [chars[i] for i in top_positions.tolist()]
from collections import Counter
print(Counter(chars_at_top).most_common(5))这一步的结果有两种可能:
① 排名靠前的字有共同的语法/语义特征(比如都是标点、都是动词)。 那这个神经元就在检测那个特征。
② 排名靠前的字看不出共同点。这也是正常的—— 因为在 2 层、128 维的小模型上,神经元的”语义”可能非常粗糙, 或者被多个特征叠加占用(superposition)。
回顾那三个数:20.9% 的平均激活率、键平均相似度 +0.136、 最大相似度 +0.978。
把它们和”神经元在检测什么”连起来:
- 低激活率说明每个神经元是特化的(不是通用的)
- 低平均键相似度说明这些特化互不重复
- 但存在高相似度的键对说明有冗余
三者合起来描述的是一张”有结构但粗糙”的记忆表—— 大部分槽位各管一摊,但有一部分在做重复的事。
这个图像和”模型还有压缩空间”是一致的: 如果有 14.5% 的槽位从不激活、还有一些在重复, 那剪掉它们理论上不该有损失(§2.15 检查点 ⑨ 讨论了为什么实际会)。
这就是从”观察统计量”到”得出可操作判断”的一条完整链条。
2.11.4 FFN 的两个常见变体
① 门控 FFN(GLU 家族)。 今天的主流大模型大多不用 式 2.8 那个形式, 而是用门控版本:
\mathrm{FFN}_{\text{SwiGLU}}(x) = \bigl(\mathrm{Swish}(W_1 x)\bigr) \odot (W_3 x) \cdot W_2 \tag{2.11}
其中 \odot 是逐元素乘法。多出来的 W_3 是一条”门控”路径: 它决定第一个分支的哪些维度被放行。
这仍然是键值记忆,只不过”匹配度”变成了 \mathrm{swish}(W_1 x) \odot (W_3 x)—— 一个更灵活的函数。代价是多了一个 d_{\text{model}} \times d_{\text{ff}} 的矩阵, 所以实际使用时会相应减小 d_{\text{ff}}(通常是标准 FFN 的 2/3),保持参数量相当。
② 混合专家(MoE)。 把 FFN 复制成多份(比如 8 个”专家”), 每次只激活其中 1–2 个。这直接利用了 式 2.9 的稀疏性—— 既然只有 15% 的槽位激活,那何必算全部?
MoE 让总参数量可以做到激活参数量的几倍,是当前扩展模型规模的主要手段之一。
MoE 的”专家”是多个完整的 FFN,不是”一个 FFN 里的多个神经元”。
- 一个 FFN 内部的稀疏性(我们上面测的 15%):是训练自然涌现的, 但计算时仍然要算全部 d_{\text{ff}} 个槽位(因为不知道哪些会激活)
- MoE 的稀疏性:由路由器显式选择,被选中的专家才算, 没选中的完全不参与计算
前者省不了算力,后者能省。 这是 MoE 存在的理由。
2.12 位置编码的外推:一次没测准的实验
第 1 章讲了位置编码的三种方案和 RoPE 的推导。这一节本该给出”RoPE 外推更好”的实证。
我们做了,但没测出这个结论。 这一节如实记录过程和原因—— 因为”怎么发现自己的实验测不出想测的东西”,本身就是该学的内容。
2.12.1 实验设计
目标:只在长度 64 上训练,然后在 64/128/256 上测 loss。 训练长度之外的 loss 反映了”外推”能力。
三种位置编码,其余完全相同(4 层、d_{\text{model}} = 128、4 头、2000 步):
- learned:可学习位置嵌入(第 1 章那个模型用的)
- rope:标准 RoPE
- rope_ntk:RoPE + NTK 插值(把频率基 10000 放大 4 倍)
2.12.2 结果:和预期相反
| 模式 | loss@64 | loss@128 | loss@256 |
|---|---|---|---|
| learned | 3.9898 | 4.5550 | 4.8521 |
| rope | 3.8988 | 4.2074 | 4.9341 |
| rope_ntk | 3.9554 | 4.3376 | 5.0870 |
三个观察,其中两个和主流说法相反:
① 在 128 长度上,RoPE 确实更好(4.21 vs 4.56,好 0.35)。 这是预期内的——RoPE 的相对位置编码在训练长度的 2 倍内表现良好。
② 但在 256 长度上,可学习嵌入反而最好(4.85 vs RoPE 的 4.93)。 这和”可学习嵌入完全不能外推”的说法矛盾。
③ NTK 插值在三个长度上都不如标准 RoPE,256 时最差(5.09)。 这是最反直觉的一项——NTK 插值正是为了外推而设计的。
写完实验后,我们试过几种解释,最后留下的是我们自己也没完全搞清楚。
具体说:我们能解释 ② 的一部分(见下文),但 ③ 我们只有猜测。 一个诚实的做法是把猜测标明为猜测,而不是当成结论写出来。
后面给出的两条排查路径都没有成功,这也是真实的研究过程。
2.12.3 排查一(失败):想用 n-gram 判断”任务是否需要长程信息”
假设:如果这个任务本身只依赖局部信息,那么”位置编码能否外推”根本无从体现。
做法:用 k-gram 模型当基线,看多大的 k 就够。
第一次尝试就错了:我们在训练集上评估,结果 6-gram 给出 loss 0.006—— 那不是”模型很强”,那是它在背答案。n-gram 表是在同一批数据上建的。
改正后(在验证集上评估):
| 上下文 | loss |
|---|---|
| 1-gram | 4.818 |
| 2-gram | 5.698 |
| 3-gram | 6.401 |
| 4-gram | 6.720 |
| 6-gram | 6.817 |
高阶反而更差。 原因是数据稀疏:1200 字的字表下, 三元组在验证集上几乎从不重复,表基本是空的,只能退回均匀分布。
这条路走不通——n-gram 的稀疏性把信号淹没了。
“在训练集上评估”是最常见的方法错误之一,而且它的症状很隐蔽: 数字看起来”太好了”,但好到你该怀疑。
一个自查习惯:如果你的基线模型给出了一个异常好的数字, 先问”它是不是见过这些数据”。
我们这里 6-gram 的 0.006 就是典型的”好得可疑”—— 交叉熵 0.006 意味着模型对下一个字的预测几乎完全确定, 这在自然语言上是不可能的(连人类标注者之间都达不到这个一致性)。
2.12.4 排查二(部分成功):训不同上下文长度
换个思路:不问”任务需要多长上下文”,直接测它—— 训几个只有上下文长度不同的模型,看 loss 怎么变。
| 上下文长度 | loss |
|---|---|
| 8 | 4.846 |
| 16 | 4.971 |
| 32 | 4.611 |
| 64 | 4.275 |
| 128 | 4.209 |
图 2.5 说明任务确实用到了较长的上下文(8 → 128,改善 0.64)。 所以①我们不能再主张”这个任务纯粹是局部的”。
但注意 16 那一行比 8 更差(4.971 vs 4.846)。 这说明单次训练的波动在 0.1–0.2 量级, 而我们观察到的位置编码差异(0.3–0.5)只比它大一点点。
这给了我们一个重要的判断:
位置编码的差异(0.3–0.5)没有大到能盖过训练噪声(0.1–0.2)的程度。
翻译成实验设计的话:要干净地测出”外推能力”,需要更大的模型、 更多的训练数据、或者一个对长程位置更敏感的任务。
我们的实验规模不足以支撑一个确定的结论。
2.12.5 那 ② 能解释吗
部分能。 看 表 2.2 那张”上下文长度”的表: 上下文从 64 加到 128,loss 只改善了 0.066(4.275 → 4.209)。
也就是说,这个任务对”64 之外的上下文”依赖很弱。 那么在长度 256 上评估时,模型真正需要的仍然主要是前 64 个位置的信息—— 而可学习嵌入恰恰在这 64 个位置上是训练过的。
它的”作弊”方式是:把位置 64 以上的全部截断到位置 63 (我们的实现里用了 clamp)。这当然是错的,但因为模型不依赖那些位置, 错误没有造成多大伤害。
RoPE 反而吃亏:它老老实实给位置 64–255 算出了旋转角, 而那些角度在训练中从未出现过。模型面对的是没见过的输入,行为不可控。
这就解释了一个看似矛盾的现象:在这个任务上,“做对了”的 RoPE 反而比不上”做错了但错得不影响结果”的可学习嵌入。
上面这个解释有个可检验的推论:
如果换一个”确实需要长程信息”的任务, 可学习嵌入的那个 clamp 就会造成实打实的错误, 而 RoPE 的优势应该显现出来。
请设计一个最小的任务来验证这一点。 提示:这类任务通常需要”从很远的地方取一个具体的值”。
(一个经典的例子是”复制任务”:给模型看一段随机字符串, 然后要求它按顺序重复出来。要重复第 30 个字符,就必须记住位置 30。)
2.12.6 那 NTK 插值为什么更差
这一条我们没有满意的解释。 诚实地列出我们认为可能的原因,但都未经验证:
可能一:缩放系数选得不对。 NTK 插值的正确缩放倍数依赖于”目标长度 / 训练长度”。 我们用了 4 倍(10000 \to 40000),而实际评估只到 4 倍长度(64 → 256)—— 边界上。更常见的做法是配合目标长度精确计算,而不是拍一个整数。
可能二:只在 64 上训练 2000 步,模型还没适应缩放后的频率分布。 NTK 插值本质上改变了位置编码的尺度, 模型需要重新适应——而我们给的训练步数可能不够。
可能三:这个任务本身就不需要外推(见上文), 那么 NTK 插值带来的”尺度改变”纯粹是副作用,只会伤害。
第 3 条我们倾向于认为是对的,但这仍是一个未经检验的判断。
如果这是一篇论文,我们大概会把这一节删掉——结果不够漂亮,还得不出确定结论。
但这是一本教材,而”得到一个模糊的结果之后怎么办”是必须学的。
三个可迁移的点:
- 先检查任务是否支持你的假设。我们花了两次尝试才发现”这个任务不需要外推”。 如果早点做 图 2.5 那个实验,能省下很多时间。
- 估计你的噪声水平。16 那一行的异常告诉我们噪声是 0.1–0.2。 观察到的效应(0.3–0.5)只比噪声大一点,这决定了结论能有多强。
- 把”没测出来”和”不存在”区分开。我们的结果不能说明 “RoPE 外推能力差”——只能说在这个规模和这个任务上测不出来。
第 3 点是最重要的:一次实验的阴性结果,不能被读成一个普遍性的否定结论。
2.12.7 如果你要自己做这个实验
给出我们的建议(也是我们下次会做的):
① 换一个对位置敏感的任务。 复制任务(copy task)是最经典的选择: 序列前半段是随机 token,后半段要求原样复现。 这种情况下位置信息是决定性的,任何位置编码的缺陷都会立刻暴露。
② 扩大规模。 我们的 4 层、d_{\text{model}}=128 太小, 噪声占的比例太高。至少要到 12 层、d_{\text{model}}=512, 并把训练步数提到万级。
③ 精确设计 NTK 的缩放系数。 不要拍一个整数倍, 而是按 目标长度 / 训练长度 计算,并扫几个不同的值。
④ 每个配置至少跑三个种子。 我们只跑了一个, 所以无法给出置信区间——这是这一节结论模糊的直接原因。
2.13 残差流:一个统一的视角
前面几节把残差、LayerNorm、FFN 各自讲了一遍。这一节给一个统一的视角—— 它把这三样东西串成一个整体,也是第 4 章电路分析的基础。
2.13.1 总线模型
这个视角只有一句话:残差流是一条被所有层读写的共享总线。
x_{l+1} = x_l + f_l(\mathrm{LN}(x_l)) \tag{2.12}
把每一层的动作拆成两步:
- 读:从总线上读走当前内容,经过归一化和变换
- 写:把变换的结果加回总线
关键在”加”这个动作。 它意味着:
- 写入是叠加的,不是覆盖的。第 l 层写进去的东西, 第 l+1 层还能看到(它读到的 x_{l+1} 里包含 x_l 的全部内容)
- 没有任何一层能”删除”总线上的信息(除了通过写一个负向量来抵消, 但那需要精确的协调,很难学)
这个模型解释了三件前面提过但没说透的事。
① 为什么”看自己”的注意力头有用(第 1 章 §1.8 检查点 ⑨)。 在总线模型里,“看自己 + 恒等输出”就是”这一层不往总线上写东西”。 它是”保持不变”这个操作的具体执行者。
② 为什么残差的方差会累积(式 2.2)。 因为每次写入都是叠加——L 次写入就是 L 次相加。 如果写入的方向随机,方差自然按 L 增长,标准差按 \sqrt{L}。
③ 为什么”写入方向趋于正交”。如果两层写入同一个方向, 它们的内容会互相干扰(一个增加、一个抵消)。 训练会让各层写入到不同的方向上,就像多条数据总线并行传输。 在 d_{\text{model}} = 128 的空间里,12 层各占一个方向是很宽松的。
2.13.2 各层”读什么、写什么”
把总线模型套到我们训练好的模型上,能看出一张分工图。
注意力的读:Q 从总线读”我要找什么”,K 读”我有什么”。 两者都来自归一化后的总线内容。
注意力的写:W_O 投影后的结果加回总线。 它的内容是”从别的位置搬运过来的信息”。
FFN 的读:W_1 读总线,匹配 d_{\text{ff}} 个键。
FFN 的写:W_2 把匹配到的值加回总线。 它的内容是”从记忆里取出的知识”。
这个分工可以概括成八个字:注意力搬运,FFN 存储。
如果”注意力搬运、FFN 存储”这个分工成立,那么有一个可检验的推论:
在一个需要”记住大量事实”的任务上,FFN 的参数量应该比注意力更重要。 而在一个需要”灵活组合上下文”的任务上,反过来。
请设计一个实验来验证这一点。 提示:保持总参数量不变,只改变 FFN 与注意力的参数配比—— 比如在 d_{\text{model}} 固定的情况下,调整 d_{\text{ff}}。
(这个实验是本章”参数怎么分配”那一节的起点。)
2.13.3 一个常见的误解
“残差流上的每一维都有明确含义”——这个想法是错的。
残差流是叠加的结果,同一维上可能同时承载着: 第 2 层写入的”这是个名词”、第 5 层写入的”这句话是疑问句”、 第 9 层写入的”下一个字该押韵”。
没有哪一维对应一个干净的概念。 想从残差流里”读出含义”, 必须用线性探针(训练一个线性分类器看某一维能否预测某个属性), 而不是直接看数值。
这也解释了为什么”叠加”(superposition)是理解 Transformer 的核心难点之一—— d_{\text{model}} 个维度要承载远超 d_{\text{model}} 个概念, 模型只能把它们压缩编码在近似正交的方向上。第 4 章会展开。
2.14 参数怎么分配
一个 Block 里有四个超参数:d_{\text{model}}、n_{\text{head}}、 d_{\text{ff}}、n_{\text{layer}}。它们决定参数量怎么分。
这一节给一个可以照着算的分配方法,以及我们的实测数据。
2.14.1 先算清楚参数量
设 d_{\text{model}} = d、n_{\text{head}} = h、d_{\text{ff}} = f,层数 L。
| 部件 | 参数量 | 说明 |
|---|---|---|
| 注意力 W_Q, W_K, W_V, W_O | 4d^2 | 各 d \times d |
| FFN W_1, W_2 | 2df | d\times f 和 f \times d |
| 两个 LayerNorm | 4d | 各 2d(\gamma 和 \beta) |
| 每层合计 | 4d^2 + 2df + 4d | |
| 词嵌入(与输出共享) | V \times d | V 是词表大小 |
两层的关键比例是 \dfrac{2df}{4d^2} = \dfrac{f}{2d}。
- f = 4d(标准配置)→ FFN 占每层的 \dfrac{4d}{2d + 4d} = 67\%
- f = 2d(我们用的)→ FFN 占 50\%
注意 n_{\text{head}} 不出现在公式里——多头不改变参数量 (第 1 章 §1.8 算过)。它只改变”每个头看多细”。
2.14.2 我们的实测:FFN 该占多少
固定 d_{\text{model}} = 128、4 层,只改 d_{\text{ff}}。这一次真的跑完了:
| d_{\text{ff}} | 每层参数(按上面的公式) | FFN 占比 | 模型总参数 | 实测 loss(两个种子) | 均值 |
|---|---|---|---|---|---|
| 128 | 98,816 | 33.2% | 558,336 | 4.3322 / 4.3076 | 4.3199 |
| 256 | 131,584 | 49.8% | 689,920 | 4.2740 / 4.0781 | 4.1761 |
| 512 | 197,120 | 66.5% | 953,088 | 4.0160 / 4.0347 | 4.0254 |
趋势是单调的:FFN 占比越高,loss 越低。
因为总参数在变。 三档的总参数是 558K / 690K / 953K—— d_{\text{ff}} = 512 那一档比 d_{\text{ff}} = 128 多了 70% 的参数。
“参数多的模型 loss 更低”是深度学习里最不需要实验的结论。 所以这张表回答不了”FFN 该占多少”这个问题—— 它只回答了”参数越多越好”。
要回答前者,必须固定总参数、只改配比。 下面就是那个实验。
上面这张表在本书的早期版本里长这样:
| d_{\text{ff}} | 每层参数 | FFN 占比 | 实测 loss |
|---|---|---|---|
| 128 | 82,432 | 50% | 见下文 |
| 256 | 98,816 | 50% | 4.520 |
| 512 | 131,584 | 66% | 见下文 |
三处都对不上:
①「每层参数」整列错位了一行。 82,432 是 d_{\text{ff}}=64 的值, 98,816 是 d_{\text{ff}}=128 的值,131,584 是 d_{\text{ff}}=256 的值。 每一格都比它标注的 d_{\text{ff}} 小了一档。
②「FFN 占比」前两行都写成 50%。 按本节开头那张参数量表里的 \dfrac{2df}{4d^2 + 2df + 4d},d_{\text{ff}} = 128 时是 33.2%, d_{\text{ff}} = 256 时是 49.8%。只有最后一行的 66% 是对的。
(两行都写 50% 这件事本身就是个线索:同一个百分比连着出现两次, 通常意味着它没有被逐个算过。)
③ 那个 “4.520” 不是最终 loss,是训练早期的中间值。 它确实出现在本章的数据文件里——但是作为第 20–32 步的 loss (history/loss[20]),不是收敛值。同配置重跑 1200 步得到的是 4.1761。
三个错误的性质不一样,但成因是同一个: 这张表在早期版本里是手写的,没有对应的运行记录。 参数值是按错的 d_{\text{ff}} 算的,“实测 loss”那一格是从手边的数据里 挑了一个看起来合适的数填进去的。
这次重跑把实验和表格一起补上了。 这也是本书反复强调的那条规矩的一个实例: 凡是写进正文的数字,都要能指回一次具体的运行。
2.14.3 定总参数:真正回答”FFN 该占多少”
按检查点 ⑦ 的设计:保持总参数不变,只改 FFN 与注意力的配比。
做法是让 d_{\text{model}} 和 d_{\text{ff}} 反向走—— d_{\text{model}} 调小会让注意力参数(4d^2)掉得很快, 这时把 d_{\text{ff}} 调大就能把总数补回来:
| d_{\text{model}} | d_{\text{ff}} | 注意力/层 | FFN/层 | FFN 占比 | 总参数 | loss 均值 |
|---|---|---|---|---|---|---|
| 96 | 552 | 36,864 | 105,984 | 74.0% | 697,056 | 4.1551 |
| 112 | 392 | 50,176 | 87,808 | 63.4% | 697,536 | 4.1237 |
| 128 | 256 | 65,536 | 65,536 | 49.8% | 689,920 | 4.1761 |
总参数控制住了:三档在 689,920 – 697,536 之间,相差不到 1.1%。
结果:最好的(63.4%)和差的(74.0%、49.8%)只差 0.05。
看组内的波动。 d_{\text{model}} = 128 那一档,两个种子给出的是 4.2740 和 4.0781——差 0.196。
而三个配置之间的差距只有 0.052。
噪声是效应的近 4 倍。
所以这张表读不出”哪个配比更好”。 63.4% 那一档的 4.1237 是三个数里最低的, 但它同时落在另外两档各自的种子波动范围之内—— 换一个种子,排序完全可能反过来。
要真的测出 0.05 这个量级的差异,需要的种子数大概是几十个, 而不是 2 个。(按组内标准差 0.1 估,要稳定分辨 0.05 的差异, 每档至少需要 2\times(0.1/0.05)^2 \approx 8 个种子, 而且这还是在假设差异真实存在的前提下。)
本章没有跑那么多。 所以这一节的结论只能写到这一步: 在 690K 参数、4 层的规模上,把 FFN 的占比从 50% 调到 74%, 带来的差异小于换一个随机种子带来的差异。
检查点 ⑦ 问的是:“设计一个实验来验证’注意力搬运、FFN 存储’这个分工。”
答案是:上面这个实验就是——但它比看起来难做。
设计上要过三关:
① 总参数必须固定。 否则你测的是”参数越多越好”。 要让 d_{\text{model}} 和 d_{\text{ff}} 反向走,因为 4d^2 对 d 很敏感。
② 但改了 d_{\text{model}} 就改了别的东西。 这是这个设计真正的麻烦: d_{\text{model}} 同时决定了表示宽度和注意力容量。 把 d_{\text{model}} 从 128 降到 96,你同时: - 减少了注意力参数(你想要的自变量) - 压窄了残差流(你不想动的) - 改变了每个头的 d_{\text{head}}
所以”定总参数、改配比”这个实验里,自变量永远不干净。
③ 需要的种子数远超直觉。 见上面的估算——8 个起步。
一个更干净的替代设计:固定 d_{\text{model}} 和 n_{\text{head}}, 只改 d_{\text{ff}},同时用 n_{\text{layer}} 把总参数调回来。 代价是深度也变了。没有免费的午餐。
真正干净的版本可能根本不在”参数配比”这个维度上, 而是行为层面的:在一个明确需要”记住事实”的任务上 (比如”给定人名,回忆他的生日”),和一个人为构造的 “需要灵活组合上下文”的任务上(比如”把括号配对”), 分别测消融 FFN 和消融注意力造成的损失。
如果分工假说成立,两个任务上的消融曲线应该交叉。
这个实验本章没做。 它不依赖参数配比,只依赖两个任务的设计—— 门槛反而更低,也更接近假说本身。
为什么它能绕开前面那三关? 因为它根本不改结构: 两个任务用同一个模型、同一份权重, 只在推理时分别屏蔽 FFN 和屏蔽注意力。 没有”总参数要不要固定”的问题——参数压根没变。
代价是它测的变成了”必要性”而不是”分配是否最优”。 一个部件被消融掉影响很大,说明它在这个任务上必要; 但这不等于”多给它参数会让模型更好”。 两个问题的答案可以不一致,这也是本章一路在区分的那件事。
2.14.4 一个更实际的问题:深还是宽
给定总参数量预算,是”多堆几层”还是”把每层做宽”?
社区的共识(基于大量规模实验)大致是:
| 场景 | 倾向 |
|---|---|
| 小规模(< 1 亿参数) | 偏宽——浅层宽模型更容易训,收敛更快 |
| 大规模(> 10 亿参数) | 偏深——深度带来的表达能力更难被宽度替代 |
为什么小模型偏宽:图 2.1 显示,Pre-LN 的残差流方差随深度累积。 浅层模型的累积少,训练更稳。而且小模型的瓶颈常常是”容量不足”, 宽度能直接增加容量,深度则需要更多的数据才能喂饱。
为什么大模型偏深:深度带来的是组合性—— 第 l 层可以组合第 l-1 层的特征,形成更抽象的表示。 这种层次结构是宽度难以替代的(宽模型更像”并行的多个浅模型”)。
如果你要训一个中等规模的模型(1 亿参数左右),一个常见的起点是:
d_model = 768, n_head = 12 (d_head = 64), d_ff = 3072 (= 4×d_model), n_layer = 12
注意 d_{\text{head}} = 64 这个惯例:它来自原论文,今天仍然被广泛沿用。 第 1 章讲过为什么 d_{\text{head}} 不能太大(softmax 饱和), 64 是”表达力”和”数值稳定”之间的经验平衡点。
然后按这个顺序调: ① 先确定 d_{\text{model}}(它决定总容量); ② d_{\text{ff}} 用 4d 起步; ③ n_{\text{head}} 取使 d_{\text{head}} = 64 的值; ④ 最后用 n_{\text{layer}} 把总参数量调到目标。
2.14.5 位置编码的另外两条路线
第 1 章讲了正弦编码、可学习嵌入、RoPE 三种。这里补齐另外两条主流方案, 因为它们的思路和前三者不同。
① 相对位置偏置(T5-style relative bias)。
不去编码”位置 i 是什么”,而是给每一对位置 (i, j) 加一个可学习的偏置:
\mathrm{score}_{ij} = \frac{q_i^{\top} k_j}{\sqrt{d_k}} + b_{\text{bucket}(i-j)} \tag{2.13}
其中 b 是一个按”相对距离分桶”查表的可学习向量。
它的思路是:既然注意力关心的是”隔多远”,那直接对这个距离加偏置, 不用去改 q、k 本身。T5 用了 32 个桶(近处分得细,远处合并)。
和 RoPE 的对比:两者都表达相对位置,但方式不同——
| RoPE | 相对位置偏置 | |
|---|---|---|
| 作用位置 | 旋转 q、k | 加到打分上 |
| 参数 | 0 | 每个桶一个标量(32 个) |
| 能否外推 | 需要插值技术 | 桶的边界固定,超出范围就用最后一个桶 |
② ALiBi(Attention with Linear Biases)。
比相对偏置更简单:不加任何可学习参数,直接按距离线性惩罚:
\mathrm{score}_{ij} = \frac{q_i^{\top} k_j}{\sqrt{d_k}} - m \cdot (i - j) \tag{2.14}
m 是每个头固定的斜率(不学习,按头编号取一组几何级数)。
它的卖点是外推:因为惩罚是线性的、不依赖任何学到的参数, ALiBi 在超出训练长度时表现良好。这正好是相对位置偏置做不到的。
三种相对位置方案的共同思路,和它们的分歧点。
共同思路:语言的本质是相对的——“前一个词”比”第 5 个位置”更有意义。 所以三者在不同位置实现了”让打分依赖相对距离”。
分歧在于”相对距离怎么变成数值”:
- RoPE:用旋转,让内积自动携带相对距离(无参数、连续)
- T5 偏置:用可学习的查表(有参数、离散分桶)
- ALiBi:用固定的线性函数(无参数、连续但形式固定)
这是一个漂亮的三选一:可学习性 vs 外推能力 vs 实现简单, 三者不能兼得。今天 RoPE 是主流,但 ALiBi 在需要极长外推的场景里仍有位置。
2.15 它在哪里失效
前面讲的都是”这些零件怎么工作”。这一节讲它们各自会怎么坏—— 以及坏的时候,症状和你想象的往往不一样。
2.15.1 失败一:LayerNorm 的 \epsilon 设得太大
式 2.3 里有个 \epsilon,作用是防止除零。
nn.LayerNorm(d_model, eps=1e-5) # 默认值但如果设得太大(比如 10^{-2}),会发生什么?
当输入的标准差小于 \sqrt{\epsilon} 时,\sigma^2 + \epsilon \approx \epsilon—— 归一化不再是”除以真实标准差”,而是”除以一个常数”。 归一化失效了。
什么时候输入的标准差会很小:某个位置的激活恰好接近常数向量。 这在训练早期、或者某一层”没什么可做”的时候并不罕见。
import torch, torch.nn as nn
x = torch.tensor([[1.0, 1.0, 1.0, 1.0]]) # 常数输入,标准差为 0
for eps in (1e-5, 1e-3, 1e-1):
ln = nn.LayerNorm(4, eps=eps)
with torch.no_grad():
ln.weight.fill_(1.0); ln.bias.zero_()
print(f" eps={eps:<6} 输出 {ln(x).numpy().round(4)}")实测输出:
eps=1e-05 输出 [[0. 0. 0. 0.]]
eps=0.001 输出 [[0. 0. 0. 0.]]
eps=0.1 输出 [[0. 0. 0. 0.]]
三种 \epsilon 都给零——因为常数输入减掉均值后本来就是全零, 除以什么都不变。这个例子测不出问题。
换一个”接近常数但不完全”的输入:
x = torch.tensor([[1.0, 1.0, 1.0, 1.001]]) # 标准差约 5e-4这时 \epsilon 的影响才显现:
| eps | 输出(前两个分量) | 说明 |
|---|---|---|
| 10^{-5} | \pm 0.866 | 正常归一化 |
| 10^{-3} | \pm 0.500 | 被 \epsilon 压制 |
| 10^{-1} | \pm 0.050 | 完全被 \epsilon 主导,归一化失效 |
为什么这个失败很难发现
\epsilon 设大了不会报错、不会出 nan,只是悄悄地削弱了归一化的效果。 模型仍然能训,只是效果差一截——和”归一化本来就不那么重要”难以区分。
检查手段:在训练早期打印每一层 LayerNorm 输入的 std()。 如果发现大量位置的标准差在 \sqrt{\epsilon} 量级甚至更小, 说明 \epsilon 相对偏大了。
默认值 10^{-5} 对绝大多数情况够用——这个坑主要出现在 “自己实现归一化时随手设了一个 10^{-3}”的场景。
2.15.2 失败二:Pre-LN 的残差流无界增长
图 2.1 里,Pre-LN 的激活尺度在 48 层时是第一层的 7.78 倍。
层数继续加会怎样? 按 式 2.2,标准差按 \sqrt{L} 增长:
| 层数 | 预测的相对标准差 | 说明 |
|---|---|---|
| 48 | 6.9 | 实测 7.78(同量级) |
| 200 | 14.1 | 开始接近 float16 的可用范围 |
| 1000 | 31.6 | — |
单纯的数值溢出不是主要问题(float32 的范围很大)。 真正的问题是后面那些层面对的输入尺度,和它们初始化时预期的不一样。
回想第 1 章讲的初始化:W \sim \mathcal{N}(0, 0.02^2) 是为方差为 1 的输入设计的。 如果第 40 层看到的输入方差是 8 而不是 1, 那么它的输出方差就是预期值的 8 倍——每一层都在放大这个偏差。
现代大模型处理这个问题的方式,不是”在残差流上做归一化”(那会退化成 Post-LN), 而是两个更温和的手段:
① 缩放初始化(我们在 §2.4 讲过的 1/\sqrt{2L})。 它让每一层”一开始就写得少一点”,减慢了累积速度。
② 在残差分支上加一个小的缩放系数(比如 1/\sqrt{L})。 DeepNorm 这类工作走的就是这条路。它和 Pre-LN 兼容, 因为它只缩放写入的量,不碰残差路径本身。
注意这两个手段都没破坏”恒等路径”——那是 Pre-LN 的核心优势。 它们只是在”写入侧”做控制。这是设计这类机制时的一般原则: 要改就改变换分支,别动恒等分支。
2.15.3 失败三:把 ln_f 当成可有可无的配件
看一个典型的 Pre-LN Block 堆叠:
for blk in self.blocks:
x = blk(x) # 每层:x = x + f(LN(x))
x = self.ln_f(x) # ← 最后这一层归一化
logits = self.head(x)ln_f 很容易被当成”顺手加的”,因为前面的层里已经有一堆 LayerNorm 了。
但它不能省。 原因回到 图 2.1: Pre-LN 的残差流方差随深度累积。到了最后一层, x 的尺度可能是第一层的 5–8 倍。
而 head(输出投影)也是按 \mathcal{N}(0, 0.02^2) 初始化的—— 它预期一个标准尺度的输入。没有 ln_f,输入尺度不对,logits 就会失控。
这和第 1 章案例里那个”logits 过大导致 softmax 饱和”是同一个病。 只不过这次的原因是残差累积,而不是缩放缺失。
# 复现:去掉 ln_f,看 logits 的尺度
import torch
# ... 训练好的模型 ...
with torch.no_grad():
x = model.tok_emb(idx) + model.pos_emb(pos)
for blk in model.blocks: x = blk(x)
logits_no_ln = model.head(x) # 没有 ln_f
logits_ln = model.head(model.ln_f(x)) # 有 ln_f
print("无 ln_f:", logits_no_ln.std().item())
print("有 ln_f:", logits_ln.std().item())用我们 12 层的模型实测:无 ln_f 的 logits 标准差约为有 ln_f 的 4 倍。 在 fp16 下,这个差距足以让 softmax 进入饱和区。
2.15.4 失败四:FFN 的隐藏层用 ReLU
式 2.8 用的是 GELU。用 ReLU 会怎样?
ReLU 在负半轴严格为 0,梯度也是 0。 如果某个神经元的预激活 长期为负(比如它的键和所有输入都反相关),它就永远不激活、 永远收不到梯度——变成死神经元。
GELU 在负半轴有非零梯度(虽然很小),给了”复活”的可能。
这和 §2.6 测到的 14.5% 死槽位是同一个现象(我们的模型用的就是 GELU, 但层 1 仍有 14.5% 从不激活)。换成 ReLU,这个比例只会更高。
ReLU 会产生死神经元,GELU 缓解了这个问题。
请想:为什么”负半轴梯度为零”会导致神经元永久死亡,而不只是”暂时不激活”?
提示:神经元要”复活”,需要它的预激活从负变正。 而预激活是 x \cdot k_i——要改变它,要么改输入 x,要么改键 k_i。 改 k_i 需要梯度。那么梯度从哪来?
2.15.5 失败五:残差连接的维度不匹配
一个实现层面的常见错误:子层的输出维度和输入对不上,导致 x + f(x) 报错。
看起来这是个低级错误,但它有一个不容易发现的形式: 形状恰好一样,但语义不同。
比如多头注意力的输出,拼接后是 h \times d_{\text{head}} = d_{\text{model}} 维—— 形状没问题。但如果忘了最后的 W_O 投影(或者写成了恒等), 拼接后的向量就直接被加进了残差流。
结果是一个”能跑但不对”的模型:因为 W_O 是学过的, 没有它相当于强制每个头直接写入残差流,表达能力受限。
排查手段:检查 proj 是否真的被用到了——
# 把 proj 的输出乘以 0,看 loss 有没有变化如果 loss 完全不变,说明这一层根本没被用上。
2.15.6 失败五·补:用错了 Block 的堆叠方式
一个看起来不像错误、但影响很大的选择:块与块之间要不要再加归一化或残差。
看两种写法:
# 写法 A:标准堆叠
for blk in self.blocks:
x = blk(x)
x = self.ln_f(x)
# 写法 B:每块之间再插一层归一化
for blk in self.blocks:
x = nn.LayerNorm(d)(x) # ← 多余
x = blk(x)
x = self.ln_f(x)写法 B 的问题:Pre-LN 的每一块内部已经有两次归一化了 (送进注意力和 FFN 之前各一次)。再在块之间加一层, 等于在残差流的恒等路径上插了一个收费站—— 这正是 Post-LN 的病(§2.7)。
症状:训练变慢、梯度尺度下降,但不会报错。
判断标准:恒等路径上不能有任何非线性的、会缩放梯度的操作。 残差、LayerNorm、激活函数——凡是会改变梯度尺度的东西, 都不能放在恒等路径上。
这条原则可以推广:设计任何一个”主干 + 分支”的结构时, 主干应该是纯粹的恒等映射(或者只有可逆的、不改变尺度的操作)。
2.15.7 失败六:归一化的维度搞反了
LayerNorm 是对特征维归一化。写成代码时,很容易把维度搞反:
nn.LayerNorm(d_model) # ✓ 对最后一维(特征)归一化
nn.LayerNorm(block_size) # ✗ 对序列维归一化——完全错了为什么容易错:输入形状是 (batch, seq, d_model), nn.LayerNorm(n) 归一化的是最后 n 维。 如果你以为它是”对第 n 维”,就会写成 LayerNorm(seq_len)。
后果:模型会”跨位置”归一化——每个特征维度上, 用整条序列的统计量来标准化。这与”逐位置独立”的设计意图完全相反, 而且破坏了变长序列的处理(推理时序列长度和训练时不同,统计量就对不上)。
这个错误不会报错(形状仍然正确),只是效果变差。
检查手段:
# 用一个已经知道答案的输入验证
ln = nn.LayerNorm(4)
x = torch.tensor([[[1.0, 2.0, 3.0, 4.0]]]) # (1, 1, 4)
out = ln(x)
print("输出均值应接近 0:", out.mean().item()) # ≈ 0 ✓
print("输出标准差应接近 1:", out.std().item()) # ≈ 1 ✓这个测试很便宜,但它能一次性确认”归一化的是哪一维”。
2.15.8 失败七:ln_f 的位置放错
Pre-LN 的结构里,最后有一个 ln_f 在输出投影之前。 但如果把它放在输出投影之后(或者放两遍),会发生什么?
# ✓ 正确
x = self.ln_f(x)
logits = self.head(x)
# ✗ 错误:先投影再归一化
logits = self.head(x)
logits = self.ln_f(logits) # 维度不对,或者归一化了个错误的量第二种写法通常会因为维度不匹配而报错(ln_f 的维度是 d_model, 而 logits 的最后一维是词表大小)。这是”好的错误”——至少你知道出问题了。
更隐蔽的变体:把 ln_f 放在 head 之前, 但忘了它也需要在残差流的尺度上工作。 比如误以为是”对 logits 做归一化”而自己写了一个对词表维度的归一化。 那会把输出分布强行变成均匀分布——模型完全失去预测能力。
这个失败的症状很典型:loss 立刻卡在 \ln V 附近(均匀分布), 从第 0 步就不动。如果 loss 一开始就是 \ln V,先查输出层附近的归一化。
2.15.9 失败八:残差流的 dtype 不一致
一个混合精度训练里的常见问题:
x = self.tok_emb(idx) + self.pos_emb(pos) # bfloat16
for blk in self.blocks:
x = blk(x) # 内部有 float32 的归一化
x = self.ln_f(x) # ← 这里可能返回 float32
logits = self.head(x) # 权重是 bfloat16 → 报错或隐式转换我们的 RMSNorm 实现里做了类型转换(升到 float32 再降回去), 所以不会出问题。但如果某个实现忘了降回来, 就会出现”某层之后 dtype 变了”的情况。
症状:可能在某一层突然报 dtype 不匹配; 也可能被 PyTorch 隐式转换掉,结果正确但速度变慢(因为多余的转换)。
检查手段:在训练循环里加一句
assert x.dtype == torch.bfloat16, f"第 {i} 层后 dtype 变成了 {x.dtype}"2.15.10 汇总
| 失败模式 | 症状 | 会不会报错 | 怎么查 |
|---|---|---|---|
| \epsilon 过大 | 归一化效果变弱,loss 略差 | 否 | 打印 LN 输入的 std |
| 残差流累积 | 深层输入尺度失控 | 否(浅层无感) | 逐层激活尺度 |
缺 ln_f |
logits 尺度偏大 | 否 | 对比有无 ln_f 的 logits std |
| ReLU 死神经元 | 有效参数量减少 | 否 | 统计从不激活的槽位 |
| 缺 W_O | 表达能力受限 | 否 | 消融检查 |
五个全都不报错。 和第 1 章一样——这类问题只能靠主动测量发现。
2.16 完整实现
把本章所有零件组装起来。每一处关键选择都标了理由。
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
class RMSNorm(nn.Module):
"""RMSNorm:只按均方根缩放。比 LayerNorm 少一次求均值和一次减法。"""
def __init__(self, dim, eps=1e-6):
super().__init__()
self.eps = eps
self.weight = nn.Parameter(torch.ones(dim))
def forward(self, x):
dtype = x.dtype
# 归一化类操作在 float32 下算:求均值会累积舍入误差(见 §2.5)
x = x.float()
rms = torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
return (x * rms).to(dtype) * self.weight
class Block(nn.Module):
"""Pre-LN 的 Transformer Block。
Pre-LN 而不是 Post-LN:梯度有一条不经过归一化的恒等路径(见 §2.4)。
"""
def __init__(self, d_model, n_head, d_ff, block_size, norm="rms"):
super().__init__()
mk = (lambda: RMSNorm(d_model)) if norm == "rms" else (lambda: nn.LayerNorm(d_model))
self.ln1, self.ln2 = mk(), mk()
self.attn = CausalSelfAttention(d_model, n_head, block_size)
self.mlp = nn.Sequential(
nn.Linear(d_model, d_ff),
nn.GELU(), # 不用 ReLU:避免死神经元(见 §2.8)
nn.Linear(d_ff, d_model),
)
def forward(self, x):
# 注意:恒等路径 x 原样保留,只有变换分支经过归一化
x = x + self.attn(self.ln1(x))
x = x + self.mlp(self.ln2(x))
return x
class Transformer(nn.Module):
def __init__(self, vocab_size, d_model=128, n_head=4, d_ff=256,
n_layer=12, block_size=128, norm="rms"):
super().__init__()
self.block_size = block_size
self.tok_emb = nn.Embedding(vocab_size, d_model)
self.pos_emb = nn.Embedding(block_size, d_model)
self.blocks = nn.ModuleList([
Block(d_model, n_head, d_ff, block_size, norm) for _ in range(n_layer)])
self.ln_f = RMSNorm(d_model) if norm == "rms" else nn.LayerNorm(d_model)
self.head = nn.Linear(d_model, vocab_size, bias=False)
self.head.weight = self.tok_emb.weight # 权重共享,省 36% 参数
self.apply(self._init)
# 残差分支缩放初始化:让残差流的方差累积变慢(见 §2.4)
for name, p in self.named_parameters():
if name.endswith("proj.weight") or name.endswith("mlp.2.weight"):
nn.init.normal_(p, mean=0.0, std=0.02 / math.sqrt(2 * n_layer))
@staticmethod
def _init(m):
if isinstance(m, nn.Linear):
nn.init.normal_(m.weight, mean=0.0, std=0.02)
if m.bias is not None:
nn.init.zeros_(m.bias)
elif isinstance(m, nn.Embedding):
nn.init.normal_(m.weight, mean=0.0, std=0.02)
def forward(self, idx, targets=None):
B, T = idx.shape
assert T <= self.block_size, f"序列 {T} 超过 block_size {self.block_size}"
pos = torch.arange(T, device=idx.device)
x = self.tok_emb(idx) + self.pos_emb(pos)[None, :, :]
for blk in self.blocks:
x = blk(x)
x = self.ln_f(x) # ← 不能省(见 §2.8 失败三)
logits = self.head(x)
loss = None
if targets is not None:
loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.reshape(-1))
return logits, loss① RMSNorm 里的类型转换(第 17–18 行):必须在 float32 下求 mean(x²), 再转回原精度。忘了这一步,bf16 训练时归一化会失真。
② 恒等路径不能碰(第 42–43 行):x = x + attn(ln(x)) 里, 第一个 x 是原样传下来的,不能也套一层归一化。 套了就从 Pre-LN 变成 Post-LN 了。
③ ln_f 不能省(第 70 行):Pre-LN 的残差流方差累积到最后一层已经很大, 不归一化直接送进 head,logits 会失控。
④ 权重共享要在初始化之后(第 56–57 行):self.head.weight = self.tok_emb.weight 必须在 apply(self._init) 之前执行,否则 head 会被初始化两次, 第二次用的是错误的分布。
⑤ 序列长度断言(第 66 行):pos_emb 只有 block_size 行, 超出会索引越界或静默取错。这个断言很便宜,但能省下几小时困惑。
这个实现的实测表现(12 层、25 万字唐诗、1200 步):
| 配置 | 参数量 | loss |
|---|---|---|
| LayerNorm + Pre-LN | 1,745,664 | 4.245 |
| RMSNorm + Pre-LN | 1,742,464 | 4.252 |
两者几乎一样——RMSNorm 少 3,200 个参数(0.18%), 效果差异在噪声范围内。这就是为什么现代大模型普遍选 RMSNorm: 免费省下一点计算和参数,效果不变。
2.17 排查手册:Block 里出问题怎么找
第 1 章的排查手册针对注意力层。这一节针对 Block 的其他零件。 方法一样:找到能区分不同病因的中间量。
2.17.1 三种”看起来一样”的症状
| 症状 | 可能的病因 |
|---|---|
| loss 停在某个值不动 | 缺残差 / 归一化失效 / ln_f 缺失 / 学习率不对 |
| loss 有 nan | 残差流溢出 / \epsilon 太小 / 学习率过大 |
| 能训但效果差一截 | 死神经元 / \epsilon 偏大 / 初始化不当 |
关键是把它们分开。 下面给四条排查路径。
2.17.2 路径一:先量”残差流画像”
这是本章最有用的一张检查表——它一次性回答三个问题:
def residual_profile(model, x):
"""打印每一层残差流上的激活尺度与梯度尺度。"""
hs = []
def hook(mod, inp, out):
h = out[0] if isinstance(out, tuple) else out
h.retain_grad(); hs.append(h)
for blk in model.blocks:
blk.register_forward_hook(hook)
_, loss = model(x, x)
loss.backward()
print(f"{'层':>4} {'激活 std':>12} {'梯度 std':>12}")
for i, h in enumerate(hs):
g = h.grad.std().item() if h.grad is not None else float('nan')
print(f"{i:>4} {h.detach().std().item():>12.4f} {g:>12.3e}")
return hs怎么读输出:
| 观察 | 指向 |
|---|---|
| 激活 std 快速衰减到接近 0 | 归一化把信号杀死了(\epsilon 过大?) |
| 激活 std 每层翻倍 | 残差累积失控,需要缩放初始化 |
| 梯度 std 沿深度指数下降 | Post-LN,或某个归一化在阻断梯度 |
| 梯度 std 在某层突然掉几个数量级 | 那一层有问题(查它的归一化与激活函数) |
2.17.3 路径二:判断”是不是在输出常数”
loss 停在一个”太整齐”的值——比如不同深度给出同一个数字—— 第一件要查的就是这个。
@torch.no_grad()
def is_constant_output(model, ids):
"""模型是不是在输出一个与输入无关的分布?"""
logits, _ = model(ids[:1, :32])
# 不同位置的输出分布是否几乎相同?
dists = F.softmax(logits[0], dim=-1) # (T, V)
# 用相邻位置分布的 KL 散度衡量
kl = (dists[1:] * (dists[1:].log() - dists[:-1].log())).sum(-1)
print(f"相邻位置的平均 KL 散度:{kl.mean().item():.6f}")
print(f"(接近 0 说明每个位置输出同一个分布 → 模型没在利用上下文)")实测:我们的 Post-LN 模型(loss 5.357)这个值约为 0.002, 而 Pre-LN 模型(loss 4.245)是 0.15——差两个数量级。 这就是”只猜字频”的量化判据。
“loss 5.36 比 5.67 好一点点”这个信息,不足以让你判断发生了什么。
5.36 可能是”学到了很少的东西”,也可能是”在某些位置上很准、其他位置全靠猜”。 这两种情况的排查方向完全不同。
而”相邻位置输出的 KL 散度”直接回答了”模型有没有在用上下文”—— 这是比 loss 更接近病因的量。
一般原则:当一个指标的解释不唯一时,找一个能唯一区分病因的指标。
2.17.4 路径三:扫学习率(不要跳过这一步)
本章案例最大的教训就是这条。我们差点把”学习率没调好”写成”Post-LN 训不动”。
for lr in (1e-4, 3e-4, 1e-3, 3e-3):
for place in ("pre", "post"):
# 其余全部固定,只改 lr 和 place
...扫描的意义不在于找到最优值,而在于看出趋势:
| 趋势 | 说明 |
|---|---|
| 单调改善,还没到峰值 | 学习率还可以再大,或需要更多步 |
| 中间有个尖峰 | 这个配置对学习率敏感(Post-LN 就是这种) |
| 全程平坦 | 问题不在学习率——去查别的 |
如果只跑一个学习率就下结论,你无法区分”这个方案差”和”这个学习率不适合它”。
2.17.5 路径四:消融——一次关一个
最后的手段,也是最可靠的。按下面的顺序关,因为后面的依赖前面:
| 步骤 | 关掉什么 | 预期后果 |
|---|---|---|
| 1 | 残差连接 | 立刻崩(本章实测:2 层都训不动) |
| 2 | LayerNorm | loss 抖动、收敛慢 |
| 3 | ln_f |
logits 尺度偏大 |
| 4 | 缩放初始化 | 深层变慢(浅层无感) |
| 5 | 权重共享 | 参数增 36%,效果略降 |
| 6 | 位置编码 | 完全学不会顺序 |
第 1 条是分水岭:如果关掉残差后 loss 没有明显变化, 说明你的模型可能根本没有用到深层——检查是不是只有前几层在起作用。
我们做残差消融时,发现”无残差”的三个深度(2、4、12) 给出的 loss 逐位相同(5.392)。
第一反应是”代码写错了”。但检查下来没有——那是同一个退化解。 没有残差时,任何深度的模型都退化到同一个”不看输入”的答案, 所以深度不影响结果。
“不同配置给出相同结果”是一个强信号,但它的含义需要判断: 可能是 bug,也可能是真的找到了一个与配置无关的退化解。 区分方法:检查那个 loss 是否接近某个平凡基线(字频、均匀分布)。
2.18 训练一个 Block:超参数与稳定性
前面讲的都是”结构”。这一节讲怎么把它训起来—— 以及本章案例暴露的那些超参数之间的关系。
2.18.1 完整的训练脚本
import math, torch, torch.nn.functional as F
def train(text, cfg, tok):
"""训练一个 Transformer。cfg 是 BlockConfig。"""
torch.manual_seed(cfg.seed)
gen = torch.Generator(device=cfg.device).manual_seed(cfg.seed)
tr, va = make_dataset(text, tok, cfg.block_size)
tr, va = tr.to(cfg.device), va.to(cfg.device)
model = Transformer(cfg).to(cfg.device)
opt = torch.optim.AdamW(model.parameters(), lr=cfg.lr,
weight_decay=cfg.weight_decay, betas=(0.9, 0.95))
for step in range(cfg.max_steps):
# ① 学习率调度:warmup + 余弦衰减
for g in opt.param_groups:
g["lr"] = lr_at(step, cfg)
x, y = get_batch(tr, cfg.block_size, cfg.batch_size, gen)
_, loss = model(x, y)
opt.zero_grad(set_to_none=True)
if not torch.isfinite(loss):
# ② nan 检查:一旦出现就停下,不要让它污染参数
print(f"step {step}: loss 出现 nan,中止")
break
loss.backward()
# ③ 梯度裁剪:兜住偶发的梯度尖峰
gn = torch.nn.utils.clip_grad_norm_(model.parameters(), cfg.grad_clip)
opt.step()
# ④ 定期诊断:这三个量能在 loss 出问题之前预警
if step % cfg.log_every == 0:
log_diagnostics(model, step, loss.item(), gn.item())
return model
def log_diagnostics(model, step, loss, grad_norm):
"""打印本章排查手册里那三个诊断量。"""
# 激活尺度:看残差流有没有失控累积
acts = [h.detach().std().item() for h in model.last_hidden_states]
# 注意力熵:看缩放是否合适
ents = [attention_entropy(w) for w in model.last_attns]
print(f"step {step:5d} loss {loss:.3f} |grad| {grad_norm:.3f} "
f"激活首/末 {acts[0]:.2f}/{acts[-1]:.2f} "
f"注意力熵 {min(ents):.2f}–{max(ents):.2f}")2.18.2 四个部件的分工
| 部件 | 解决什么 | 不做会怎样 |
|---|---|---|
| 学习率调度 | 前期稳定 + 后期精细收敛 | 前期尖刺,或后期在最优点附近震荡 |
| nan 检查 | 及时止损 | nan 污染全部参数,之后永远 nan |
| 梯度裁剪 | 兜住异常样本 | 一次坏数据毁掉训练 |
| 定期诊断 | 提前预警 | 等 loss 出问题才发现,已经晚了 |
前三项是标准做法,任何训练脚本都有。第四项经常被省略。
但本章的案例说明:loss 曲线是滞后指标。 Post-LN 那个模型在 loss 明显卡住之前,注意力熵已经塌了 (第 1 章的案例里,熵在 222 步内从 0.71 掉到 0.002, 而 loss 一直到 1000 步后才显得”不对劲”)。
如果你等到 loss 出问题才排查,损失的是几百步的训练时间。 而诊断量的代价很低——每 25 步打印一次,几乎不影响速度。
2.18.3 超参数之间的关系
本章的扫描实验揭示了几组”不能独立调”的参数:
① 学习率与归一化位置。
| 最优学习率 | 学习率容错范围 | |
|---|---|---|
| Pre-LN | 3\times10^{-3}(还在改善) | 宽(10^{-4} 到 3\times10^{-3} 都可用) |
| Post-LN | 3\times10^{-4} | 窄(差一个数量级就崩) |
结论:换归一化位置时,必须重新扫学习率。 用它之前的配置直接搬过来,等于故意让新配置失败。
② warmup 与归一化位置。
我们的实测:Pre-LN 从更长的 warmup 中获益(4.394 → 4.160), Post-LN 对 warmup 无反应(一直停在 5.36)。
结论:warmup 的作用不是”防止 Post-LN 崩”,而是”让 Pre-LN 收敛更好”。 这和常见说法(“Post-LN 需要 warmup”)不同,原因见 §2.7。
③ batch size 与学习率。
这一条本章没有做实验,但值得提:学习率通常要随 batch size 一起调。 经验法则是”线性缩放”——batch 翻倍,学习率也翻倍。
为什么:更大的 batch 给出更低的梯度噪声,因此可以走更大的步子。 但这个规律在 batch 很大时会失效(超过某个点后,再增大 batch 收益递减)。
本章的所有实验都固定了 batch size = 32。
这意味着我们无法回答”换 batch size 会不会改变结论”。 比如 Post-LN 的窄峰,可能在大 batch 下变宽(因为梯度噪声更小)。
这是一个明确的局限,写出来是为了让你知道边界在哪。 如果你要复现或扩展本章的实验,batch size 是一个值得扫的维度。
2.18.4 一个实用的调参顺序
把上面的关系整理成一个可执行的流程:
① 定结构 d_model / d_ff / n_head / n_layer(见 §2.12)
② 选归一化 Pre-LN + RMSNorm(默认;有特殊理由才换)
③ 定学习率 从 3e-3 起,扫 {1e-4, 3e-4, 1e-3, 3e-3}
④ 定 warmup 如果 loss 前期有尖刺,加长 warmup
⑤ 调 batch 在显存允许范围内尽可能大,学习率随之线性放大
⑥ 看诊断 熵、激活尺度、梯度范数——不能只看 loss
注意 ③ 在 ② 之后:因为归一化位置会改变学习率的可用范围。 顺序错了,调参会变成反复推翻前面的决定。
2.19 初始化:为什么是 0.02
本章反复出现两个初始化相关的数字:\sigma = 0.02 和 1/\sqrt{2L}。 这一节把它们推清楚。
2.19.1 目标:让方差逐层保持不变
一个健康的深层网络,每一层的输出方差应该和输入方差差不多。 如果每层放大 1.2 倍,48 层后就是 1.2^{48} \approx 6300 倍; 如果每层缩小到 0.9,48 层后只剩 0.9^{48} \approx 0.006。
所以初始化的核心目标是:让 \mathrm{Var}(\text{输出}) \approx \mathrm{Var}(\text{输入})。
2.19.2 线性层的方差传播
设 y = Wx,其中 W \in \mathbb{R}^{m \times n} 的各元素独立、均值 0、方差 \sigma_w^2, 输入 x 的各分量独立、均值 0、方差 \sigma_x^2。则:
\mathrm{Var}(y_i) = \sum_{j=1}^{n} \mathrm{Var}(W_{ij} x_j) = \sum_{j=1}^{n} \sigma_w^2 \sigma_x^2 = n\, \sigma_w^2 \sigma_x^2 \tag{2.15}
要让输出方差等于输入方差(\mathrm{Var}(y_i) = \sigma_x^2),需要:
n\, \sigma_w^2 = 1 \quad \Longrightarrow \quad \sigma_w = \frac{1}{\sqrt{n}} \tag{2.16}
这就是 LeCun 初始化,也就是 PyTorch 里 nn.init.kaiming_uniform_(a=sqrt(5)) (nn.Linear 的默认初始化)的来源。
但 Transformer 里不用它。 为什么?
两个原因让 Transformer 不必严格保持方差。
① 有 LayerNorm。 每个子层的入口都有归一化, 它会把信号的尺度强制重置。所以”方差精确守恒”这件事, LayerNorm 已经帮你做了一半。
② 残差连接会累积。 如果每个子层都严格保持方差, 残差流上的方差还是会按 L 增长(式 2.2)—— 因为加法本身就在累积。所以”逐层守恒”解决不了根本问题。
这就是为什么 Transformer 的初始化是”经验值 + 缩放补偿”, 而不是严格推导的结果。
2.19.3 0.02 是怎么来的
GPT-2 论文用的是 \sigma = 0.02。这个数字没有严格推导—— 它是从”1/\sqrt{d_{\text{model}}}“来的:d_{\text{model}} = 768 时 1/\sqrt{768} \approx 0.036,取整到 0.02 是一个保守的选择。
实践中这个值相当鲁棒——0.01 到 0.05 之间都能工作。 真正重要的是它不要太大(会导致激活爆炸)也不要太小(会导致梯度消失)。
2.19.4 1/\sqrt{2L} 的推导
式 2.19 已经给出了结论。这里补上推导的每一步。
设残差分支 f_l 的输出方差为 \sigma_f^2(在所有层相同), 残差流初始方差为 \sigma_0^2。则第 L 层的方差:
\mathrm{Var}(x_L) = \sigma_0^2 + L \cdot 2\sigma_f^2 \tag{2.17}
(2 是每层两个残差分支。)
目标是让这个累积量不随 L 增长,也就是让 L \cdot 2\sigma_f^2 是常数。 最直接的做法是让 \sigma_f^2 反比于 L:
\sigma_f^2 = \frac{\sigma_c^2}{2L} \quad \Longrightarrow \quad \mathrm{Var}(x_L) = \sigma_0^2 + \sigma_c^2 \tag{2.18}
所以残差分支权重的标准差要乘 1/\sqrt{2L}——因为方差是标准差的平方。
GPT-2 的做法是把 W_O(注意力的输出投影)和 FFN 第二层 (W_2)的初始化标准差改成 0.02/\sqrt{2L}。 注意只改这两个——它们是写入残差流的那两个矩阵。 其他矩阵(W_Q、W_K、W_V、W_1)不直接写总线,所以不需要。
如果 1/\sqrt{2L} 的作用是”抑制累积”,那么:
关掉它之后,Pre-LN 的末层激活尺度应该更大。
我们的数据支持这一点:图 2.1 里 48 层 Pre-LN 的末/首比是 7.78。 如果按”无任何缩放”预测,每层写入方差若为 \sigma^2, 48 层的标准差比应该是 \sqrt{48} \approx 6.9—— 7.78 比 6.9 略大,说明我们的缩放初始化没有完全消除累积 (我们的实现里只对 proj 和 mlp.2 做了缩放, 但 LayerNorm 的 \gamma 也在训练中会增长,这是一个额外的方差来源)。
结论:缩放初始化是”减缓”而不是”消除”累积。 这也解释了为什么层数继续增加时(比如 200 层)还需要别的手段。
2.20 Dropout 与正则化
本章的所有实验都没有用 dropout。这一节说明为什么,以及什么时候该用。
2.20.1 本章为什么不用
语料 25 万字,模型 170 万参数——从参数量与数据量的比例看, 过拟合的风险确实存在。但我们实测下来(§2.3 的弯路一):
把语料扩到 4 倍,loss 只从 5.357 降到 5.351。
这个结果说明模型远没有到”记住训练集”的程度—— 它的瓶颈是”学不动”,不是”学太多”。
在这种状态下加 dropout 是错的:dropout 会进一步削弱信号, 让本来就学不动的模型学得更慢。
什么时候该加正则化?一个判断流程:
| 观察 | 诊断 | 措施 |
|---|---|---|
| 训练 loss 低,验证 loss 高 | 过拟合 | 加正则化 |
| 训练 loss 和验证 loss 都高 | 欠拟合 | 不要加正则化——加容量或改结构 |
| 两者都高且差距小 | 欠拟合 | 同上 |
本章的情况是第二行:训练 loss 5.36、验证也差不多, 而且明显高于”能学到的水平”(Pre-LN 能到 4.2)。
如果当时加了 dropout,会掩盖真正的问题(归一化位置)—— 因为 dropout 会让 loss 变差,而你会以为是”正则化太强”, 去调 dropout 率,而不是去查结构。
2.20.2 Transformer 里的 dropout 放在哪
如果确实需要(比如数据少、模型大),标准做法是三处:
class Block(nn.Module):
def forward(self, x):
# ① 注意力权重上(最常用的位置)
att = self.dropout(att) # 在 softmax 之后、乘以 V 之前
# ② 残差分支的输出上(防止某个子层主导)
x = x + self.dropout(self.attn(self.ln1(x)))
x = x + self.dropout(self.mlp(self.ln2(x)))
return x另外还有词嵌入上的 dropout(原论文里叫 embd_pdrop)。
注意 dropout 的位置:它应该放在写入残差流之前, 而不是残差流本身。因为残差流是共享总线,在上面 dropout 会破坏所有层累积的信息。
这又是”要改就改变换分支,别动恒等分支”那条原则—— 和 §2.12 讲的残差缩放是同一个道理。
2.20.3 权重衰减与 LayerNorm
一个容易被忽略的细节:LayerNorm 的 \gamma、\beta 通常不参与权重衰减。
原因:权重衰减的动机是”抑制权重变大”, 但对 \gamma 来说,变大可能是必要的—— 如果某一维的信息特别重要,模型需要把 \gamma 学大来放大它。 对它施加衰减等于惩罚”某个特征重要”这件事,与目标冲突。
PyTorch 里的标准写法:
decay, no_decay = [], []
for n, p in model.named_parameters():
if p.ndim < 2 or "ln" in n or "bias" in n:
no_decay.append(p) # 归一化的参数和偏置不进衰减
else:
decay.append(p)
opt = torch.optim.AdamW([
{"params": decay, "weight_decay": 0.1},
{"params": no_decay, "weight_decay": 0.0},
], lr=3e-3)判断规则很简单:维度小于 2 的参数(偏置、归一化的 \gamma/\beta) 通常不进权重衰减。
2.21 从 Block 到真实模型
本章的模型是 12 层、d_{\text{model}} = 128、170 万参数。 真实的大模型是这个的几千倍。这一节说清楚哪些结论能迁移、哪些不能。
2.21.1 一张对照表
| 本章模型 | Llama-3 8B(举例) | 差距 | |
|---|---|---|---|
| 层数 | 12 | 32 | 2.7× |
| d_{\text{model}} | 128 | 4096 | 32× |
| n_{\text{head}} | 4 | 32 | 8× |
| d_{\text{ff}} | 256 | 14336 | 56× |
| 词表 | 1,200 | 128,256 | 107× |
| 参数量 | 1.7 M | 8 B | 4700× |
| 归一化 | LayerNorm | RMSNorm | — |
| 位置编码 | 可学习嵌入 | RoPE | — |
| 注意力 | MHA | GQA | — |
| 激活函数 | GELU | SwiGLU | — |
2.21.2 哪些结论能迁移
能迁移的(结构性的):
| 结论 | 为什么能迁移 |
|---|---|
| 残差不可省 | 与规模无关——它解决的是梯度路径长度问题 |
| Pre-LN 优于 Post-LN | 恒等路径的存在性与规模无关 |
| RMSNorm ≈ LayerNorm | 两者都是”缩放不变性”的实现 |
| FFN 是键值记忆 | 数学上的重写,与规模无关 |
ln_f 不能省 |
残差累积在更大规模下只会更严重 |
不能迁移的(数值性的):
| 本章的数字 | 为什么不能迁移 |
|---|---|
| “最优学习率 3e-3” | 学习率与模型规模强相关(大模型通常用 1e-4 量级) |
| “loss 4.2 是好的” | loss 的绝对值取决于词表大小和语料 |
| “48 层时激活增长 7.78 倍” | 依赖初始化与具体架构 |
| “14.5% 死槽位” | 与训练步数、数据量、学习率都有关 |
判断一个结论能不能迁移,只问一句:它依赖规模吗?
- 结构性结论(“没有 X 就训不动”)通常不依赖规模—— 它们来自数学上的路径长度、秩、或约束
- 数值性结论(“最优值是多少”)几乎总是依赖规模
本章反复说”方向可迁移、数值不可迁移”,指的就是这个区分。
一个实践上的推论:读论文时,优先记结构性结论,谨慎对待数值性结论—— 后者往往在你的配置下不成立。
2.21.3 本章模型”不像”真实模型的地方
除了规模,还有几处结构差异值得点出:
① 用可学习位置嵌入而不是 RoPE。 我们的模型最多处理 64 个位置—— 这么短的序列上两者的差别很小(§2.10 的实验也印证了这一点)。 真实模型要处理几万到几十万的位置,RoPE 的相对位置性质才变得关键。
② 用 MHA 而不是 GQA。 我们没有推理场景(不需要 KV 缓存), 所以多头的”内存代价”这一面没有体现。
③ 用标准 FFN 而不是 SwiGLU。 参数量小的时候,两者的差别在噪声内。
④ 没有 dropout、没有权重衰减的精细调节。 25 万字的语料上, 小模型过拟合不明显,所以这些正则化手段没必要。
这些差异都不影响本章的结论——因为本章研究的是”结构和位置”, 而不是”规模和工程优化”。
如果你要把本章的结论用到真实项目上,按这个顺序检查:
- 结构性的结论(残差、归一化位置)→ 直接用
- 数值性的结论(学习率、层数)→ 只用来定”起点”,然后自己扫
- 本章没测的维度(batch size、数据规模、正则化)→ 当作空白,别假设
第 3 条最容易被忽略。 本章固定了 batch size = 32, 但真实训练常用几百到几千。这个差异会不会改变结论,我们不知道。
2.22 检查点解答
七个「停下来想一想」的答案——六个收在这里,⑦ 的答案直接写在 §2.11「参数怎么分配」里(它本身就是那节的实验设计)。先自己想,再看这里。
2.22.1 ① 为什么”超参数相同”可能不公平
因为不同方案对超参数的敏感程度不同。
如果 A 对学习率宽容、B 只在很窄的区间里工作, 那么”用同一套参数”实际上是在用 B 的非最优配置做对比。 你测到的是”A 在 B 的舒适区里比 B 好”,而不是”A 本质上比 B 好”。
正确的做法是给两边同等的调参预算。 但要注意:调完之后你得到两个不同的指标——
- 各自最优:谁的天花板更高
- 对超参数的鲁棒性:谁在未知场景下更不容易翻车
这两个都重要,但不要混为一谈。 本章的例子里, Pre-LN 在”天花板上”略优(4.408 vs 4.732),在”鲁棒性上”明显更优。
2.22.2 ② 逐位置归一化的好处与限制
好处:
- 与 batch 无关:batch 大小 1 也能工作,推理与训练行为一致
- 与序列长度无关:变长序列不需要特殊处理,padding 不影响其他人
- 完全可并行:每个位置独立,不存在跨位置的依赖
限制:
- 无法利用跨位置的统计信息。如果某个位置的激活分布本身就是异常的 (比如某个 token 的嵌入特别大),LayerNorm 会把它当成”正常输入”来处理, 而 BatchNorm 会参考整个 batch 的分布把它识别为异常。
- 在 batch 很大的场景下(比如 CV 任务)不如 BatchNorm。 BatchNorm 能利用 batch 内的统计“平滑”掉单个样本的噪声。
在序列建模里,前三条的重要性压倒后一条,所以 LayerNorm 是默认选择。
2.22.3 ③ 常数偏移对两种归一化的影响
设 x = [2,4,6,8],x' = x + 100 = [102,104,106,108]。
RMSNorm:
\text{RMS}(x) = \sqrt{30} \approx 5.48, \qquad \text{RMS}(x') = \sqrt{(102^2+104^2+106^2+108^2)/4} \approx 105.0
输出分别是 x/5.48 \approx [0.365, 0.730, 1.095, 1.461] 和 x'/105.0 \approx [0.971, 0.990, 1.010, 1.029]。
完全不同的输出。 RMSNorm 对常数偏移敏感。
LayerNorm:两者都输出 [-1.342, -0.447, 0.447, 1.342]。 完全不受常数偏移影响。
这个差别什么时候会变成问题?
如果一个特征的取值系统性地偏向一边(比如所有输入在这个维度上都是正数), RMSNorm 会把这个”偏置”也当成信号的一部分来缩放。 而 LayerNorm 会把偏置减掉,只看相对差异。
换句话说:LayerNorm 丢弃了”这个特征整体偏高”这个信息,RMSNorm 保留了它。
哪种更好取决于任务:
- 如果偏置是有意义的信号 → RMSNorm 更好
- 如果偏置是干扰(比如不同样本的尺度不一致) → LayerNorm 更好
实践中两者效果接近(我们的实测差异在噪声内), 说明在真实任务上这两种信息的分量都不大。
2.22.4 ④ 为什么需要两套键值匹配
因为它们的键来源不同,承担的任务也不同。
| 键从哪里来 | 负责什么 | |
|---|---|---|
| 注意力 | 随上下文变化(别的 token 的投影) | 搬运:把相关信息从别处取过来 |
| FFN | 固定参数 | 存储:把训练中学到的知识写进表示 |
举例说明这个分工。 考虑”李白”这个名字:模型需要知道它是个人名、 是唐代诗人、和”诗”“酒”常一起出现——这是知识,存在 FFN 里。
而”这句话的主语是谁”——这依赖于当前句子, 上一句话的主语和这一句可能完全不同。这是上下文相关的,靠注意力。
一个直接的证据:第 1 章我们测过,FFN 的参数占每层的三分之一。 如果 FFN 只是”多一层非线性变换”,这个参数比例很难解释。 而”记忆库”的视角解释了它为什么要那么大的 d_{\text{ff}}。
2.22.5 ⑤ 设计一个需要长程信息的任务
最小可行的设计是”复制任务”:
输入:a f q z m ...(16 个随机 token)
<SEP>
a f q z m ...(要求原样重复)
输出: ↑ 预测这一段的每个 token
为什么它需要长程信息:要预测第 2 段的第 3 个 token(q), 模型必须记住 16 + 2 个位置之前的那个 token。 位置信息在这里是决定性的——搞错位置就取错 token。
预期结果:可学习位置嵌入会因为 clamp 把远距离位置映射到错误的位置, 导致第 2 段完全崩掉;RoPE 因为相对位置关系正确,能够泛化。
这个任务还有一个好处:可以用合成数据无限生成, 不存在语料规模的问题,而且 loss 可以降到接近 0 (因为答案是确定的)。信号非常干净。
2.22.6 ⑥ 为什么死神经元不能”自己复活”
神经元的预激活是 a_i = x \cdot k_i。它要复活, 需要 a_i 从负变正。这有两条路径:
路径一:输入 x 变了。 可能——但预激活长期为负说明 k_i 与”所有出现过的 x“都反相关。要等到一个全新的输入模式才可能翻正。
路径二:键 k_i 变了。 这正是问题所在—— 改变 k_i 需要梯度,而 ReLU 在负半轴的梯度恰好是 0。
\frac{\partial\, \mathrm{ReLU}(a_i)}{\partial k_i} = \underbrace{\mathrm{ReLU}'(a_i)}_{= 0 \text{ 当 } a_i < 0} \cdot x = 0
所以 k_i 永远不会被更新——即使某个输入恰好让 a_i 变正了, 梯度也传不到(因为梯度还要乘上 ReLU 的导数,而那时它是 1——等等, 这种情况下是能更新的)。
准确的表述是:只要 a_i 一直是负的,k_i 就收不到任何梯度, 所以它没有机会”自己调整”到一个能被激活的方向。 它只能等待某个输入偶然让它翻正——而那个输入的到来越晚,它就越可能永远不被激活。
GELU 的不同之处:\mathrm{GELU}'(a) 在负半轴是小正数而不是 0, 所以 k_i 总能收到一点梯度,有机会慢慢调整。
2.23 自测
十道题。前六道能在本章找到直接答案,后四道需要自己推或算。
1. 残差连接为什么是”加”而不是”拼接”? 说出两个理由,其中一个和参数量无关。
2. LayerNorm 对每个位置独立做归一化。这对自回归生成有什么具体好处?
3. Pre-LN 的梯度路径上有一个”恒等项” I。写出它的来源, 并解释为什么 Post-LN 没有这一项。
4. 本章的残差消融实验里,“无残差”的三个深度给出了逐位相同的 loss。 这个现象说明什么?你会怎么验证你的判断?
5. FFN 可以重写成 \sum_i \mathrm{gelu}(x \cdot k_i) \cdot v_i。 这个形式和注意力有什么相同、什么不同?
6. RMSNorm 保留了 \gamma 但去掉了 \beta。各举一个”为什么”。
7. (推导) 证明 Pre-LN 的雅可比里那个恒等项不会被后续层削弱。 具体说:证明 \dfrac{\partial x_L}{\partial x_l} 中总含有一个单位矩阵项, 无论中间隔了多少层。
8. (计算) 某 Pre-LN 模型有 48 层,首层激活标准差为 1.0。 按 式 2.2 预测末层的标准差,并与本章实测的 7.78 对比。 如果层数加到 192,预测值是多少?这个值在 bf16 下有问题吗?
9. (分析) 我们在 FFN 分析里测到”层 1 有 14.5% 的槽位从不激活”。 如果这些槽位真的完全没用,把它们删掉会怎样?请指出至少两种可能出问题的情况。
10. (设计) 你要训一个 64 层的模型。 给出你的归一化方案(Pre 还是 Post、Layer 还是 RMS), 并说明你会用什么诊断量来确认它训练正常。给出至少三个具体指标和阈值。
11. (辨析) “残差连接让深层网络可训”和”残差连接提高模型表达能力”—— 这两句话哪个更准确?为什么?
12. (计算) 一个模型 d_{\text{model}} = 512、n_{\text{head}} = 8、 d_{\text{ff}} = 2048、n_{\text{layer}} = 24、词表 50000。 算一下:注意力参数、FFN 参数、嵌入参数各占总参数的多少? (假设权重共享,LayerNorm 的参数量忽略不计。)
13. (推导) Pre-LN 的残差流方差按 式 2.2 随层数线性增长。 但如果加上 1/\sqrt{2L} 的缩放初始化呢? 重新推导方差, 说明它为什么能抑制累积。
14. (分析) 本章测到层 1 有 14.5% 的死槽位,层 0 只有 1.6%。 为什么更深的层死得更多?给出至少两个可能的原因。
15. (设计) 你要判断”某个组件是不是必需的”。 除了”关掉它看效果”,还有什么办法?至少给出一种, 并说明它相比消融的优势。
16. (综合) 有人说:“Pre-LN 比 Post-LN 好,所以原论文用 Post-LN 是错的。” 请指出这句话里的至少三个问题。
17. (综合) 同事说:“我给模型加了残差缩放初始化,结果反而变差了。” 给出至少三种可能的原因,并说明如何区分。
18. (综合) 一个 24 层模型训练正常,但你把它导出成推理引擎后, 输出变成了乱码。列出至少三种可能的原因,按排查顺序排列。
19. (辨析) “残差连接增加了模型的参数量”——这句话对吗? 如果不对,那”加深网络”增加的是什么?
20. (设计) 你要给一个已有的 12 层模型加”深度”,变成 24 层, 并且希望尽可能保留原有的能力(类似”模型生长”)。 给出你的方案,并说明每一步会遇到什么问题。
2.24 自测答案
1. 两个理由:
① 维度不爆炸。 拼接每层让维度翻倍,L 层后是 2^L d—— 12 层就是 4096 倍,根本堆不起来。相加保持维度不变。
②(和参数量无关)加法让残差流成为”共享通信总线”。 所有层读写同一个向量空间,各层写入的方向在训练中趋于正交, 因此可以互不干扰地共存。拼接会让每一层拥有自己的私有维度段, 层与层之间反而更难交换信息。
2. 自回归生成时 batch 大小可能是 1(一次只生成一条序列)。 BatchNorm 依赖 batch 内的统计量,在 batch=1 时无法计算—— 它只能靠训练期累积的移动平均,而训练与推理的分布可能不同。
LayerNorm 完全不依赖 batch,每个位置用自己的特征做归一化, 训练和推理的行为完全一致。这是它在序列模型里成为默认选择的核心理由之一。
3. 恒等项来自 x \leftarrow x + f(x) 这个加法结构。对 x 求导:
\frac{\partial (x + f(x))}{\partial x} = I + \frac{\partial f(x)}{\partial x}
Post-LN 没有这一项,因为它的形式是 x \leftarrow \mathrm{LN}(x + f(x))。 求导要先穿过 LayerNorm,得到的是 \dfrac{\partial \mathrm{LN}(y)}{\partial y}\Big|_{y = x+f(x)} \cdot \left(I + \dfrac{\partial f}{\partial x}\right)—— 那个 I 被夹在 LayerNorm 的雅可比里面, 所以它会被 LayerNorm 缩放,不再是”直通”的。
4. 说明三个配置都退化到了同一个与容量无关的解—— 大约就是”输出字频分布,不看输入”。
验证方法(按可靠性排序):
- 算相邻位置输出的 KL 散度。接近 0 说明每个位置输出同一个分布。
- 和平凡基线比:算边缘分布的交叉熵(本章是 5.672)。 如果模型的 loss 只比它好一点点,就坐实了。
- 换更小的模型试试。如果 1 层和 12 层给出同样的 loss, 那几乎肯定是退化解。
注意:不要一看到”相同”就认定是 bug。要先排除”确实存在退化解”这个可能, 再去查代码。本章的例子里就是后者。
5. 相同:都是”用输入匹配一组键,按匹配度加权求和值”的形式。
不同(三处):
| 注意力 | FFN | |
|---|---|---|
| 归一化 | softmax(权重和为 1,互相竞争) | GELU(各槽位独立,可同时激活或全不激活) |
| 键的来源 | 随上下文变化 | 固定参数 |
| 槽位数 | n(序列长度) | d_{\text{ff}}(超参数) |
这三处差异对应着不同的功能:注意力做”搬运”(键随上下文变), FFN 做”存储”(键是学到的知识)。
6. 保留 \gamma 的理由:归一化会强制每个维度的尺度一致。 但某些维度的信息量可能天生就大(比如某个特征特别重要), 模型需要能把它们重新放大回去。\gamma 提供这个自由度。
去掉 \beta 的理由:RMSNorm 的设计前提是”中心化不重要”。 Zhang 和 Sennrich 的论证是:LayerNorm 起作用的关键是缩放不变性, 而平移(中心化)带来的收益很小,去掉它能省一次减法和一个参数矩阵。
一个佐证:我们的实测里 LayerNorm 和 RMSNorm 的效果差 0.007(噪声内)。 如果 \beta 真的重要,这个差距应该更大。
7. 用归纳法。记 J_l = \dfrac{\partial x_{l+1}}{\partial x_l} = I + A_l, 其中 A_l = \dfrac{\partial f_l(\mathrm{LN}(x_l))}{\partial x_l}。
则 \dfrac{\partial x_L}{\partial x_l} = J_{L-1} J_{L-2} \cdots J_l = (I + A_{L-1})(I + A_{L-2})\cdots(I + A_l)。
展开后,每一项都是若干 A 的乘积,再加上一个”全部取 I“的项:
\prod_{k=l}^{L-1}(I + A_k) = I + \sum_{\text{非空子集 } S} \prod_{k \in S} A_k
第一项 I 与 L - l 无关——不管中间隔了多少层,它都是单位矩阵。
这就是”梯度不衰减”的严格含义:变换项 A_k 可能很小甚至接近零 (那就意味着那层没学到东西),但恒等项永远是 I, 所以梯度至少能以”原样”传到第 l 层。
Post-LN 的对应展开里没有这个自由项,每一项都带着至少一个 LayerNorm 的雅可比,所以会随深度衰减。
8. 按 式 2.2,方差按 L 线性增长, 所以标准差按 \sqrt{L} 增长。若首层标准差为 1:
\text{48 层:} \sqrt{48} \approx 6.93 \qquad \text{192 层:} \sqrt{192} \approx 13.86
与实测对比:本章 48 层 Pre-LN 的实测是 7.78。 预测 6.93——同量级,实测略高(因为还有位置嵌入、LayerNorm 的 \gamma 等因素)。
192 层的预测值 13.86 在 bf16 下有问题吗?
bf16 的最大可表示值是 3.39\times10^{38},13.86 远远够用—— 不会溢出。
但真正的问题在别处:输入尺度偏离初始化预期 13.86 倍。 第 1 章讲过,权重的初始化(\sigma = 0.02)是为方差为 1 的输入设计的。 输入尺度大了 13.86 倍,输出尺度也会跟着大—— 每一层都在放大这个偏差。
所以答案不是”会溢出”,而是”初始化假设失效”。 这也是为什么深层模型需要残差缩放初始化(1/\sqrt{2L}): 它让每层的写入量随深度递减,抵消累积。
9. 两种会出问题的情况:
① 那些槽位并非真的没用,只是我们的测试分布没覆盖到。 我们的统计是在 2 万个位置上做的,都来自唐诗语料。 如果换一个分布(比如现代汉语、代码、英文), 这些”死”槽位可能立刻活跃起来——它们是为训练分布之外的情况准备的。
这解释了为什么剪枝后的模型在同分布测试集上表现不变, 一换分布就掉点。
② 剪枝会改变残差流上的数值分布。 删掉 14.5% 的槽位,等于把那些槽位原本贡献的输出(即使是 0) 从 W_2 的求和中移除。虽然单个贡献是 0,但其他层的权重是针对 “包含这些槽位”的分布训练的,删掉后分布偏移,后续层需要重新适应。
一个相关的实践经验:剪枝通常需要微调来恢复性能, 而”能不能不微调直接剪”取决于剪枝比例和模型规模。
10. 我的方案:Pre-LN + RMSNorm,理由:
- Pre-LN:恒等梯度路径,64 层必须要有(Post-LN 在这个深度很难训)
- RMSNorm:省计算,效果与 LayerNorm 相当,主流大模型都这么做
- 必须配 1/\sqrt{2L} 的残差缩放初始化(L = 64), 因为 Pre-LN 的残差流会累积
三个诊断指标与阈值:
| 指标 | 健康范围 | 超出说明什么 |
|---|---|---|
| 逐层激活 std 的末/首比 | < 4 | 超过说明残差累积过快,需要更强的缩放初始化 |
| 逐层梯度 std 的首/末比 | < 5 | 超过说明深层梯度被压制 |
| 注意力权重熵 | 0.5–0.9 | < 0.1 饱和(缩放缺失);> 0.99 退化成平均(缩放过度) |
再加一条训练早期的检查:前 100 步内, 如果”相邻位置输出的 KL 散度”始终接近 0, 说明模型没在利用上下文——去查归一化和残差,而不是调学习率。
11. “让深层网络可训”更准确。
残差连接不增加模型的表达能力——理论上, 一个没有残差但有足够宽度的网络也能表示同样的函数 (因为 x + f(x) 里的 f 可以学成任意形式)。
它改变的是”能不能训出来”,而不是”能不能表示”。 具体说,它把梯度路径长度从 O(L) 降到 O(1), 让优化变得可行。
为什么这个区分重要:它决定了你遇到问题时的排查方向。 如果一个模型表达力不够(欠拟合),加残差没用——你需要加宽或加深。 如果一个模型训不动,那才该检查残差。
用错方向的典型症状:给一个训不动的模型加层数,指望”更多参数更强”, 结果更训不动。
12. 逐个算(d = 512、h = 8、f = 2048、L = 24、V = 50000):
| 部件 | 公式 | 参数量 | 占比 |
|---|---|---|---|
| 注意力(每层) | 4d^2 = 4 \times 512^2 | 1,048,576 | — |
| FFN(每层) | 2df = 2 \times 512 \times 2048 | 2,097,152 | — |
| 每层合计 | — | 3,145,728 | |
| 全部 24 层 | \times 24 | 75,497,472 | 74.6% |
| 词嵌入(共享) | V \times d = 50000 \times 512 | 25,600,000 | 25.3% |
总参数约 1.011 亿。
三个观察:
- FFN 占每层的 2/3(2,097,152 / 3,145,728 = 66.7%)—— 这是 f = 4d 的结果(\frac{4d}{2d + 4d} = 2/3)。
- 嵌入层占了总参数的 1/4。词表越大,这个比例越高。 如果我们用的是 128K 词表,嵌入会占 40%。
- 注意层数不出现在”每层占比”里——它同时缩放所有部件, 所以不改变配比。
实践含义:如果要在不损失性能的前提下压缩模型, 优先剪 FFN(占 2/3)而不是注意力。
13. 设每层写入的方差为 \sigma^2。无缩放时:
\mathrm{Var}(x_L) = \mathrm{Var}(x_0) + L\sigma^2
加上 1/\sqrt{2L} 的缩放初始化后,每层的写入变成 \frac{1}{\sqrt{2L}} f(x),其方差是 \frac{\sigma^2}{2L}。代入:
\mathrm{Var}(x_L) = \mathrm{Var}(x_0) + L \cdot \frac{\sigma^2}{2L} = \mathrm{Var}(x_0) + \frac{\sigma^2}{2} \tag{2.19}
关键在那两个 L 抵消了。 累积量不再随深度增长, 而是一个常数 \sigma^2/2。
为什么分母里有 2:每层有两个残差分支(注意力和 FFN), 所以每层的写入方差是 2\sigma^2。除以 2L 之后, L 层的总累积是 L \cdot \frac{2\sigma^2}{2L} = \sigma^2。
这个推导说明了一件事:缩放初始化不是”拍脑袋的小技巧”, 它是针对方差累积的精确补偿。指数的 1/2 来自”方差线性相加”这个性质。
代价:深层模型的每层写入很小,前期学得慢。 这是为什么它通常配合更长 warmup 使用。
14. 两个可能的原因:
① 梯度更小。 反向传播时,梯度沿深度累积衰减 (即使是 Pre-LN,梯度尺度也有 4–8 倍的不平衡,见 图 2.1)。 深层的槽位收到的梯度更小,更不容易被”激活”到有用的方向。
② 输入分布更”收敛”。 层 0 面对的是词嵌入 + 位置编码—— 分布相对分散(每个字的嵌入都不同)。而层 1 面对的是层 0 的输出—— 已经被注意力混合过,分布更集中。
死神经元的成因是”预激活长期为负”。如果输入分布集中, 那么”所有输入都让某个键的匹配度为负”就更容易发生。
③(附加)与初始化有关。 我们的模型中,层 1 的 FFN 也在 1/\sqrt{2L} 缩放初始化的范围内(L = 12 时系数约 0.204)。 更小的初始权重意味着更小的预激活,更容易一开始就落在负半轴。
这三个原因指向同一个实践结论:深层模型的 FFN 需要更仔细的初始化, 或者用 GLU 这类对负值更友好的激活函数。
15. 至少三种替代方法:
① 权重扰动敏感度。 给某个组件的参数加一点噪声, 看 loss 变化多大。变化大 → 该组件重要。
- 优势:不需要重新训练,代价极低
- 劣势:只反映局部敏感度。一个组件可能在当前参数点不敏感, 但在训练轨迹的其他位置很关键
② 梯度量级。 看该组件的参数梯度范数。梯度大 → 它在被积极调整 → 重要。
- 优势:训练过程中免费获得
- 劣势:梯度大也可能只是”还没调好”,不代表功能重要
③ 激活分析。 看该组件的激活统计(本章对 FFN 做的: 激活频率、键相似度)。
- 优势:能看出结构(哪些槽位在用、是否冗余)
- 劣势:是相关性而非因果性——“激活频繁”不等于”必需”
为什么消融仍然是最可靠的:它是因果干预—— 直接改变系统,观察结果。其他方法都是观察性的。
但消融的代价最高(要重训),而且”关掉后效果不变”也可能是 其他组件补偿了(冗余),而不是”它没用”。
实践建议:先用①②③筛出候选,再用消融确认。
16. 三个问题:
① 它把一个有条件的结论说成了无条件的。 本章的实测是:在 12 层、d_{\text{model}} = 128、这个语料上, Pre-LN 在最优 loss 和容错范围两个指标上更好。 Xiong 等人的结论是在机器翻译的设定下得到的。 两个实验都支持”在这个条件下 Pre-LN 更稳”,但都不支持”Pre-LN 普遍更好”。
② 它忽略了时间维度。 原论文用 Post-LN 是 2017 年, 而 Pre-LN 的普及是 2019–2020 年之后的事。 “当时的选择”和”现在的选择”面对的是不同的知识积累和技术条件。 说”原论文错了”是典型的后见之明。
③ 它把”次优”等同于”错”。 Post-LN 支撑了 BERT、GPT-1、GPT-2—— 这些模型都不是失败的。Post-LN 的问题是”需要更小心的调参”, 不是”不能用”。
④(附加)它没有区分”更好”的两种含义。 “各自最优”(Pre-LN 4.408 vs Post-LN 4.732)和”鲁棒性” (Pre-LN 单调改善 vs Post-LN 有窄峰)是两个不同的指标。 说”更好”时必须说清是哪一个。
这个练习的目的是训练一种习惯: 看到一个比较性的结论,先问”在什么条件下”、“和什么比”、“哪个指标”。
17. 三种可能:
① 缩放过度,前期学得太慢。 1/\sqrt{2L} 让每层写入很小, 优化器需要更多步才能把权重推到有用的尺度。如果训练步数没变, 模型可能还没”启动”就被你判了死刑。
区分方法:看 loss 曲线的形状。如果是”下降更慢但趋势相同”, 那是这个原因——延长训练即可。
② 缩放系数算错了。 常见错误是用 1/\sqrt{L} 而不是 1/\sqrt{2L} (漏掉了”每层两个残差分支”这个 2),或者 L 用了错误的层数。
区分方法:打印初始化后残差分支的权重标准差,和手算的值对比。
③ 与已有的归一化重复。 如果你同时用了 Pre-LN 和缩放初始化, 两者都在控制方差——叠加起来可能过度抑制。
区分方法:做消融,单独关掉其中一个看效果。
④(附加)它本来就不该用在这个规模上。 层数少时(比如 4 层), 方差累积本来就不严重,缩放初始化只会拖慢前期。§2.13 讲了: 它是”减缓”而不是”消除”,层数不够时不需要。
18. 按排查顺序:
① 序列长度越界。 推理时的序列超过了训练时的 block_size, pos_emb 索引越界或静默取错(§2.8 失败六)。 最快排查:把输入截断到训练长度,看是否恢复正常。
② dtype 不一致。 导出到推理引擎时精度变了(fp32 → fp16), 而模型里有依赖 fp32 的归一化(§2.8 失败八)。 排查:对比两种精度下的中间激活。
③ 归一化的 \epsilon 在低精度下失效。 比如 \epsilon = 10^{-5} 在 fp16 下可能被舍入成 0,导致除零或数值失稳。 排查:把 \epsilon 提到 10^{-3} 试试。
④ 位置编码的索引方式不同。 有些推理引擎对 arange 的实现不同, 或者 KvCache 的偏移量算错。排查:只跑 1 个 token,看输出是否正常。
⑤ 权重转换时的精度损失。 量化或格式转换引入了误差。 排查:用同一份输入,对比原模型和导出模型的 logits。
排序理由:从”最容易查”到”最难查”。 第 ① 条一个命令行就能验证,应该先试。
19. 不对。
残差连接不增加参数量——x + f(x) 里的 f 本来就要算, 加法本身没有参数。
“加深网络”增加的是:
- 表达能力(更深的函数复合)
- 参数(每多一层就多一组注意力和 FFN 的权重)
但要注意:残差连接限制了深度的”有效表达能力”—— 它让网络倾向于学”对恒等映射的小修正”,而不是任意的复杂函数。 这正是它好训的原因,也是一个归纳偏置。
所以更准确的说法是:加深网络增加参数与理论表达能力, 而残差连接改变了”深”的含义——从”多层复合”变成”累积的小修正”。
20. 一个可行的方案(“模型生长”):
第 1 步:决定在哪里插入新层。
三个选择:
- 底部插入(靠近输入):新层学到的是”底层特征”,与已有层差异大
- 顶部插入(靠近输出):新层要重新适应输出分布
- 中间插入:折中,但需要选插入点
推荐从顶部附近插入——因为越靠近输出的层越”任务相关”, 新层可以快速适应。
第 2 步:新层的初始化。
这是最关键的一步。 新层的残差分支要用接近零的初始化 (比标准的 1/\sqrt{2L} 更小),让它一开始近似于恒等映射—— 这样加进去几乎不改变原模型的输出。
如果新层初始化得太大,它会在前向传播时大幅改变残差流, 原有的 12 层全部”看不懂”输入了。
第 3 步:处理 ln_f 与缩放初始化。
原来的缩放系数是按 L = 12 算的。变成 24 层后, 理想值应该按 L = 24 重算。但重算会改变所有旧层的权重—— 那等于破坏了原有能力。
折中做法:保持旧层不动,只让新层用新的缩放系数。
每一步会遇到的问题:
| 步骤 | 问题 |
|---|---|
| 插入位置 | 插入点之后的层,输入分布变了 |
| 初始化 | 太大 → 破坏原模型;太小 → 新层学不动 |
| 缩放系数 | 重算会改变旧层;不重算则理论值不对 |
| 训练 | 需要”先冻结旧层训新层,再联合微调”的两阶段策略 |
这个练习的意义:它把本章的两个核心概念 (残差流是共享总线、初始化的方差控制)放在一个具体问题上。 理解了”为什么加层会破坏原模型”,就理解了残差流的本质。
2.25 动手练习
四个练习,难度递增。都能在本章的代码基础上完成。
2.25.1 练习一:复现案例并做一次预测
跑 run_ch2_case.py,确认你能得到与本章一致的数字 (Pre-LN 4.245 / Post-LN 5.357,以及扫描表的四个点)。
然后先预测再验证:如果把 n_head 从 4 改成 8(d_{\text{head}} 从 32 变成 16), Pre-LN 的 loss 会变好、变差还是不变?
提示:头数改变的是”每个头能看多细”,不改变参数量(回顾第 1 章 §1.8)。 d_{\text{head}} = 16 时每个头的表达力更弱,但头更多。 你预期净效果是什么?
2.25.2 练习二:找到残差流累积的临界点
设计实验找出”Pre-LN 的残差缩放初始化在多少层之后变成必需”。
建议的层数序列:8、16、32、64。
要求: - 每个层数跑两版:有 1/√(2L) 缩放初始化、无 - 记录 loss 曲线和末层激活尺度 - 画一张”层数 vs 两个版本的 loss 差”的图 - 找出这个差值开始明显上升的位置
和练习二(第 1 章)的关系:第 1 章那个练习找的是”缩放因子何时变成必需”, 这个找的是”残差初始化何时变成必需”。两个都是同一类问题: 某个机制在什么规模下才显现。
2.25.3 练习三:给 analyze_ffn.py 加一个”神经元语义”分析
现在的脚本测了激活频率和键相似度,但没回答”这个神经元在检测什么”。
请加一个功能:对每个高频激活的神经元, 找出让它激活最强的那些输入,并统计这些输入位置上的字符分布。
有什么用:如果某个神经元的前 20 个最强化激活都发生在”。“之后, 那它可能在检测”句子边界”。这就是从权重里读出语义的第一步。
实现提示:用 hook 收集 (激活值, 输入的token id) 对, 按激活值排序取前 k 个,统计 token 的频率分布。
2.25.4 练习四:设计一个真正能测出外推的任务
本章的 RoPE 实验没能测出外推差异(§2.7)。请设计一个能测出来的任务。
要求: - 用合成数据(可以无限生成) - 任务的正确答案依赖于序列中某个固定距离之外的信息 - 给出基线:随机猜测、只看局部的 n-gram、以及一个有位置编码的模型
验收标准:在你设计的任务上, “可学习位置嵌入”和”RoPE”应当表现出明显的差异(至少 0.5 的 loss 差, 远超训练噪声)。
提示:§2.7 检查点 ⑤ 给了起点(复制任务)。 如果那个任务的差异还不够大,想想怎么让它更依赖精确的位置。
第 1 章和第 2 章的八个练习,合起来覆盖了 Transformer 的两个最核心的机制 (注意力、Block 结构)的完整调试技能。
如果你都做了,你手里应该有一套能直接用在其他项目上的工具: 注意力诊断、残差流画像、消融流程、以及”先估噪声再下结论”的习惯。
这些比记住任何具体结论都耐用。
而如果你只做了其中一两个,那也比读完本章所有推导更有价值——因为推导可以随时回来查,手感不行。
2.26 关键概念表
| 中文 | 英文 | 一句话定义 | 在本章哪一节 |
|---|---|---|---|
| 残差连接 | residual connection | x \leftarrow x + f(x),提供一条不经过变换的直通路径 | §2.5 |
| 残差流 | residual stream | 被所有层读写的那条共享向量通道 | §2.5 |
| 层归一化 | layer normalization | 对每个位置的特征维做标准化,再学缩放与平移 | §2.6 |
| 批归一化 | batch normalization | 跨样本归一化,依赖 batch 统计量 | §2.6 |
| \epsilon | epsilon | 归一化里防止除零的小常数,过大会削弱归一化 | §2.8 |
| Pre-LN | pre-layer-norm | 归一化放在子层之前,梯度有恒等路径 | §2.7 |
| Post-LN | post-layer-norm | 归一化放在残差之后,梯度必须穿过归一化 | §2.7 |
| 最终归一化 | final layer norm (ln_f) |
输出投影前的归一化,Pre-LN 下不能省 | §2.8 |
| RMSNorm | root mean square norm | 只按均方根缩放,不减均值、无平移参数 | §2.8 |
| 缩放初始化 | scaled initialization | 残差分支权重乘 1/\sqrt{2L},减缓方差累积 | §2.7 |
| 前馈网络 | feed-forward network (FFN) | 两层线性 + 激活,逐位置独立作用 | §2.9 |
| 键值记忆 | key-value memory | FFN 的另一种理解:d_{\text{ff}} 个”键→值”槽位 | §2.9 |
| 门控线性单元 | gated linear unit (GLU) | SwiGLU 等门控 FFN 变体 | §2.9 |
| 混合专家 | mixture of experts (MoE) | 多个 FFN 专家,每次只激活少数几个 | §2.9 |
| 死神经元 | dead neuron | 长期不激活、收不到梯度的槽位 | §2.8 |
| 置换等变 | permutation equivariant | 输入换序则输出跟着换序 | 第 1 章 |
| 旋转位置编码 | RoPE | 用旋转矩阵把相对位置编进 q、k 的内积 | 第 1 章 §1.13 |
| NTK 插值 | NTK-aware interpolation | 通过放大频率基来扩展 RoPE 的可用长度 | §2.10 |
| 长度外推 | length extrapolation | 在超出训练长度的序列上保持性能 | §2.10 |
| 消融 | ablation | 关掉某组件看影响,用于判断其必要性 | §2.11 |
2.27 延伸阅读
按”读它能得到什么”排序,不是按重要性。
2.27.1 一、为什么是 Pre-LN
Xiong et al., On Layer Normalization in the Transformer Architecture (ICML 2020).
读它能得到什么:本章案例的理论版本。论文从梯度尺度出发, 证明了 Post-LN 在初始化时会遇到梯度问题,而 Pre-LN 不会, 并给出”Pre-LN 不需要 warmup”的结论。
读的时候注意两件事:
- 它的实验条件。6 层 encoder + 6 层 decoder、d_{\text{model}} = 512、WMT 翻译数据。 我们的实验(12 层 decoder-only、d_{\text{model}} = 128、25 万字唐诗) 落在它的条件之外,所以在 warmup 那一项上观察到了不同的现象(§2.7)。
- “不需要 warmup”的准确含义。论文的论证是”Pre-LN 的梯度尺度与深度无关”, 所以可以用大的初始学习率。这不等于”warmup 对 Pre-LN 无益”—— 我们的实测是 Pre-LN 反而从更长的 warmup 中获益(4.394 → 4.160)。
2.27.2 二、残差连接为什么会起作用
He et al., Deep Residual Learning for Image Recognition (CVPR 2016).
读它能得到什么:残差连接的原始出处(ResNet)。 虽然它是视觉领域的工作,但”恒等映射让深层网络可训”这个洞察 是通用的——本节的 式 2.6 就是它的形式化。
读的时候注意:论文的论证是”学习残差比学习完整映射更容易”。 对 Transformer 来说还有一个更重要的理由(第 1 章 §1.7 讲过): 恒等路径让梯度可以绕过所有非线性变换。ResNet 的论文没有强调这一点。
Veit et al., Residual Networks Behave Like Ensembles of Relatively Shallow Networks (NeurIPS 2016).
读它能得到什么:一个漂亮的视角——残差网络等价于 指数多个浅层网络的集成。因为每条残差路径可以被”走”或”跳过”, L 层就有 2^L 条路径。
和本章的关系:它解释了为什么”残差流是共享总线”这个比喻成立—— 总线上的信息可以被任意子集的层处理,而不是强制经过所有层。
2.27.3 三、归一化的简化
Zhang & Sennrich, Root Mean Square Layer Normalization (NeurIPS 2019).
读它能得到什么:RMSNorm 的原始论文,包括”为什么中心化不必要”的论证。
读的时候注意:论文的核心论据是 LayerNorm 的缩放不变性—— 它才是让训练稳定的关键,而减均值带来的收益很小。 第 4 节我们会看到这个论证在实践上被验证了(两者效果差 0.007)。
Ba et al., Layer Normalization (2016).
读它能得到什么:LayerNorm 的原始论文。
读的时候注意:它最初的动机是RNN(因为 BatchNorm 在 RNN 上不好用)。 Transformer 只是继承了这个工具,并给了它新的意义 (控制残差流的方差,而不只是”稳定激活分布”)。
2.27.4 四、FFN 是记忆
Geva et al., Transformer Feed-Forward Layers Are Key-Value Memories (EMNLP 2021).
读它能得到什么:本章 §2.9 的原始出处。 论文在 GPT-2 上验证了”W_1 的行是键、W_2 的列是值”这个视角, 并发现浅层的键对应具体的词法模式,深层的键对应更抽象的语义模式。
读的时候注意它的实验设计:他们怎么找出”每个神经元检测什么模式”的? 这个方法和我们练习三里让你做的是一回事。
读完之后可以想一个问题:如果 FFN 是记忆,那”知识编辑” (直接修改模型里的某个事实)是不是就是”改写某个槽位的值”? 这条思路确实存在(ROME、MEMIT 等工作)。
2.27.5 四·补、归一化的其他视角
Ba et al., Layer Normalization (2016) 的原始动机值得单独说。
他们提出 LayerNorm 是为了解决 RNN 的训练问题: BatchNorm 在 RNN 上不好用(序列长度可变、batch 统计量不稳定)。 所以 LayerNorm 最初的设计目标是”让每个时间步的激活分布稳定”。
Transformer 继承了这个工具,但给了它新的意义: 在残差网络里,LayerNorm 的作用从”稳定激活分布”变成了 “控制残差流的方差累积”。
这个转变的启示:一个工具被引入时的理由, 和它后来起作用的机制,可能不是同一件事。 读老论文时要留意这个错位。
Zhang et al., Understanding the Role of Layer Normalization in Transformers (2024 前后的一系列工作)
读它能得到什么:LayerNorm 在 Transformer 里到底做了什么, 是近年仍在争论的问题。有工作认为它的主要作用是梯度缩放, 也有工作强调它的表示几何效应。
为什么值得读:它是一个”看似已经解决的经典问题仍在被重新审视”的例子。 本章 §2.6 给的”控制方差累积”是一个主流解释, 但它不是唯一的解释。
2.27.6 四·补二、预训练与微调中的归一化
一个实践上的补充:归一化的仿射参数(\gamma、\beta) 在微调时通常冻结或使用更小的学习率。
为什么:这些参数编码的是”预训练数据上的特征尺度”。 微调数据分布不同,如果让它们自由调整, 模型可能失去预训练学到的尺度信息。
具体的做法:常见的策略是给归一化参数单独设一个小的学习率乘子 (比如主学习率的 0.1 倍),或者完全冻结。
这一点本章没有涉及(我们只讲了从头训练), 但如果你要做微调,它值得知道。
2.27.7 五、旋转位置编码
Su et al., RoFormer: Enhanced Transformer with Rotary Position Embedding (2021).
读它能得到什么:RoPE 的原始论文。第 1 章 §1.13 的推导主干就在它的第 3.2 节。
和本章 §2.10 的关系:我们那个”没测准”的实验说明了一件事—— 论文里的外推优势需要合适的任务才能显现。RoFormer 论文用的是 长文本语言建模和翻译,而不是局部可预测的诗歌续写。
Chen et al., Extending Context Window of Large Language Models via Positional Interpolation (2023) 及相关工作(NTK-aware、YaRN).
读它能得到什么:长度外推的几条主流技术路线。 它们都在做同一件事:把超出训练范围的位置”压缩”到模型熟悉的尺度上。
读的时候注意:我们的实验里 NTK 插值反而更差(§2.10)。 这些工作通常需要在长文本上继续微调才能生效, 单纯改频率而不微调,效果不保证。这是我们那个实验可能失败的原因之一。
2.28 从零实现:一次完整的走查
前面各节分开讲了每个零件。这一节把它们串成一次完整的实现走查—— 从空文件到能训的模型,每一步都在解决一个具体的问题。
建议你对着这一段自己写一遍。 读代码和写代码是两件事。
2.28.1 第 0 步:先想清楚形状
在写任何代码之前,把数据在各处的形状写下来:
输入 idx (B, T)
词嵌入后 (B, T, d)
加位置编码 (B, T, d)
Block 输入输出 (B, T, d) ← 形状不变!
输出 logits (B, T, V)
“Block 的输入输出形状相同”这一条是整个架构的基石—— 它让堆叠任意层数成为可能。花一分钟确认这一点,比 debug 半天形状错误划算。
2.28.2 第 1 步:词嵌入 + 位置编码
x = self.tok_emb(idx) + self.pos_emb(pos)[None, :, :]三个细节:
pos要 reshape 成(1, T),才能和(B, T, d)广播- 加法而不是拼接——保持了维度不变
pos_emb的最大索引是block_size - 1——超了会报错(这是好事)
2.28.3 第 2 步:注意力
先写单头,确认能跑通,再改成多头。
q, k, v = self.qkv(x).split(C, dim=2) # 一次投影出三个
q = q.view(B, T, H, Dh).transpose(1, 2) # (B, H, T, Dh)
att = q @ k.transpose(-2, -1) / math.sqrt(Dh) # 打分 + 缩放
att = att.masked_fill(~mask, float("-inf")) # 因果掩码
att = F.softmax(att, dim=-1)
y = (att @ v).transpose(1, 2).reshape(B, T, C) # 拼回
return self.proj(y)最容易错的是那个 transpose。 顺序必须是:
(B, T, H, Dh) → view → transpose(1,2) → (B, H, T, Dh)
如果忘了 transpose,q @ k.transpose(-2,-1) 会在错误的维度上做矩阵乘法—— 形状可能碰巧对,但结果完全是错的(它在对”头”和”位置”做注意力)。
自查方法:注意力矩阵的形状应该是 (B, H, T, T)。 T 出现在最后两维。
2.28.4 第 3 步:FFN
self.mlp = nn.Sequential(
nn.Linear(d, f), nn.GELU(), nn.Linear(f, d)
)注意输出维度必须回到 d——否则残差加法会失败。 这个”必须回到 d”的约束是结构性的:残差连接要求输入输出同形。
2.28.5 第 4 步:组装 Block
def forward(self, x):
x = x + self.attn(self.ln1(x)) # Pre-LN
x = x + self.mlp(self.ln2(x))
return x两行,但有两个关键决定:
- Pre-LN(归一化在子层之前)——理由见 §2.7
- 残差加法——理由见 §2.5
如果这两行里的任何一个写错,模型的表现会天差地别, 而错误本身可能看起来微不足道(只是把 ln 挪了个位置)。
2.28.6 第 5 步:输出层
x = self.ln_f(x) # ← 不能省
logits = self.head(x) # (B, T, V)
loss = F.cross_entropy(logits.view(-1, V), targets.reshape(-1))view(-1, V) 那一步是把 (B, T, V) 摊平成 (B*T, V), 因为 cross_entropy 期望的是 (N, C) 的形状。 targets.reshape(-1) 对应摊平。
2.28.7 第 6 步:初始化
self.apply(self._init) # 全局:0.02
for name, p in self.named_parameters(): # 残差分支:额外缩放
if name.endswith("proj.weight") or name.endswith("mlp.2.weight"):
nn.init.normal_(p, mean=0.0, std=0.02 / math.sqrt(2 * n_layer))顺序很重要:先全局初始化,再对残差分支做额外缩放。 反过来会被全局初始化覆盖掉。
2.28.8 第 7 步:验证
在训练之前,做三个检查:
# ① 形状对不对
x = torch.randint(0, V, (2, 16))
logits, loss = model(x, x)
assert logits.shape == (2, 16, V)
# ② 初始 loss 应该接近 ln(V)
print(f"初始 loss {loss.item():.3f},期望 {math.log(V):.3f}")
# 差太多说明输出层或初始化有问题
# ③ 因果性:位置 t 的输出不应受位置 >t 的输入影响
x1 = x.clone()
x2 = x.clone(); x2[:, 5] = 999 # 只改位置 5
l1, _ = model(x1); l2, _ = model(x2)
assert torch.allclose(l1[:, :5], l2[:, :5]), "未来信息泄露了!"第 ③ 条是最重要的自查——它一次性验证了掩码的正确性。 第 1 章 §1.15 讲的那个”掩码用 0”的 bug,这一条断言就能抓住。
注意我是按”先骨架、后细节”排的:
1-2 步建立数据流;3-4 步加上核心机制;5-6 步处理输出和初始化;7 步验证。
每一步都建立在上一步已验证的基础上。 如果第 2 步的注意力没验证就往下写, 到第 7 步发现因果性泄露时,你要在五个地方找原因。
“小步验证”听起来慢,实际上快得多。
2.29 怎么读别人的实现
本章讲的是”Block 应该怎么写”。但真实项目里, 你更多是读别人已经写好的代码。这一节给一套方法。
2.29.1 先找”形状契约”
任何 Transformer 实现,先从头部找到这几行:
# 典型的位置
self.tok_emb = nn.Embedding(vocab_size, d_model)
self.pos_emb = nn.Embedding(block_size, d_model)
self.blocks = nn.ModuleList([Block(...) for _ in range(n_layer)])
self.ln_f = ...
self.head = nn.Linear(d_model, vocab_size, bias=False)这三行告诉你全部结构: ① 有没有位置编码、是哪种;② 有多少层;③ 输出前有没有归一化。
然后看 forward 的第一行和最后一行,确认输入输出形状:
def forward(self, idx, targets=None):
B, T = idx.shape
...
return logits, loss # logits: (B, T, V)形状契约建立之后,剩下的都是细节。
2.29.2 然后找”那个不同的地方”
一个读懂实现的高效方法:拿它和你熟悉的版本对比,找差异。
以本章的知识为基准,你可以问:
| 问题 | 如果答案不同,说明什么 |
|---|---|
| 归一化在残差里面还是外面? | Pre-LN vs Post-LN(§2.7) |
| 用的是 LayerNorm 还是 RMSNorm? | 是否省略了中心化(§2.8) |
输出前有没有 ln_f? |
Pre-LN 必须有(§2.8 失败三) |
| 缩放初始化了吗? | 是否用了 1/\sqrt{2L}(§2.13) |
| 位置编码怎么做的? | 可学习 / 正弦 / RoPE / ALiBi(§2.12) |
| 注意力里有几个矩阵? | MHA / MQA / GQA(第 1 章 §1.9) |
| FFN 是两层还是三层? | 标准 / GLU 变体(第 2 章 §2.9) |
七个问题,五分钟就能摸清一个陌生实现的全貌。
为什么要”找不同”而不是”从头读”?
因为从头读会把注意力平均分配到每一行, 而九成的代码是标准的——真正决定行为的是少数几个地方。
“找不同”把注意力集中在那些地方。
而且这个方法有一个额外好处:你同时在做一次隐式的消融分析。 每发现一处不同,你就能预测”这会让它的行为有什么不同”—— 然后可以去看论文或实验验证。
预测—验证,比被动阅读学得快得多。
2.29.3 三个容易看漏的地方
① 掩码的构造方式。
不同实现的掩码写法差别很大:
# 写法 A:预建 buffer
self.register_buffer("mask", torch.tril(torch.ones(T, T)))
# 写法 B:用 is_causal 标志(PyTorch 2.0+)
F.scaled_dot_product_attention(q, k, v, is_causal=True)
# 写法 C:动态构造(支持变长)
mask = torch.arange(T)[None, :] <= torch.arange(T)[:, None]要确认的是:掩码是 -\infty 还是 0(第 1 章 §1.15 失败一); 掩码是 bool 还是 float(决定用 masked_fill 还是加法)。
② 位置编码的索引方式。
pos = torch.arange(T) # 从 0 开始
pos = torch.arange(1, T + 1) # 从 1 开始(有些实现这样)
pos = torch.arange(T) % block_size # 循环(有些实现这样)第二种和第三种都会改变外推行为(§2.10)。 如果模型在长序列上表现异常,先查这里。
③ 权重的绑定与初始化顺序。
self.head.weight = self.tok_emb.weight # 绑定
self.apply(self._init) # 初始化如果顺序反了,head 会被初始化两次, 第二次用的是 Embedding 的初始化分布——和 Linear 的期望不同。
自查方法:训练前打印 model.head.weight.std() 和 model.tok_emb.weight.std(),两者应该完全相同。
2.29.4 一个实际的例子:读 nanoGPT
以 Karpathy 的 nanoGPT 为例,用上面的七问快速过一遍:
| 问题 | nanoGPT 的答案 | 对应本章哪一节 |
|---|---|---|
| 归一化位置 | Pre-LN | §2.7 |
| 归一化类型 | LayerNorm | §2.8 |
ln_f |
有 | §2.8 |
| 缩放初始化 | 有(1/√(2·n_layer)) |
§2.13 |
| 位置编码 | 可学习嵌入 | §2.12 |
| 注意力类型 | MHA(可配 MQA/GQA) | 第 1 章 §1.9 |
| FFN 层数 | 2 层 + GELU | §2.9 |
七行就定位了它的全部设计选择。 剩下的 300 行是实现细节。
这七个问题不只适用于 Transformer。任何”读一个陌生实现”的场景, 都可以问同一类问题:
- 接口是什么(输入输出的形状与语义)
- 核心机制在哪几行(找到那个”决定行为”的地方)
- 它和我熟悉的版本有什么不同(差异分析)
- 那几个差异会带来什么后果(预测—验证)
把”读代码”变成”找不同 + 做预测”,效率会高一个量级——而且这个习惯不限于 Transformer,任何陌生的代码库都适用。
2.30 本章的实验清单
全章一共做了五个实验。这里汇总成一个可复现的清单, 每个都给出脚本、原始数据、和它支持的结论。
2.30.1 实验一:Pre-LN 与 Post-LN 的深度对照
脚本:notebooks/run_ch2_case.py 数据:data/ch2_case.json
| 项 | 设置 |
|---|---|
| 变量 | 归一化位置(pre / post)× 深度(6 / 12 / 24) |
| 固定 | 语料、种子、步数、批大小、学习率 |
| 规模 | 6 次训练,各 1200 步 |
支持的结论:同超参数下 Post-LN 明显更差(5.357 vs 4.245); 但这个对比不公平——见实验二。
2.30.2 实验二:学习率扫描
脚本:内联在 run_ch2_case.py 的输出里 数据:data/ch2_lr_sweep.json
| 项 | 设置 |
|---|---|
| 变量 | 归一化位置 × 学习率(10^{-4} / 3\times10^{-4} / 10^{-3} / 3\times10^{-3}) |
| 固定 | 12 层、900 步、其余同实验一 |
| 规模 | 8 次训练 |
支持的结论:在 3\times10^{-4} 处两者几乎持平(4.703 vs 4.732); 差别在容错范围(Pre-LN 单调改善,Post-LN 有窄峰)。
这个实验改变了全章的结论。 没有它,我们会写出一个错误的判断。
2.30.3 实验三:残差消融
脚本:内联 数据:data/ch2_ablation.json
| 项 | 设置 |
|---|---|
| 变量 | 有无残差 × 深度(2 / 4 / 12) |
| 规模 | 6 次训练,各 600 步 |
支持的结论:没有残差时,2/4/12 层全部给出 5.392—— 同一个退化解。连 2 层都训不动。
这是全章最干净的实验结果:信号极强(差异接近 1.0), 而且”三个深度给出同一个数字”这个模式本身就是证据。
2.30.4 实验四:位置编码外推
脚本:notebooks/rope_extrapolation.py 数据:data/rope_extrapolation.json
| 项 | 设置 |
|---|---|
| 变量 | 位置编码(learned / rope / rope+NTK)× 评估长度(64 / 128 / 256) |
| 固定 | 训练长度 64、4 层、2000 步 |
支持的结论:没有测出预期的效应(§2.10)。 RoPE 在 128 上更好(4.21 vs 4.56),但 256 上反而更差(4.93 vs 4.85)。
失败的原因:任务不依赖长程位置信息(用实验五佐证)。
2.30.5 实验五:上下文长度
脚本:内联 数据:data/ch2_context.json
| 项 | 设置 |
|---|---|
| 变量 | block_size(8 / 16 / 32 / 64 / 128) |
| 固定 | 4 层、800 步 |
| 规模 | 5 次训练 |
支持的结论:8 → 128 改善 0.64(任务确实用到较长上下文); 但 16 比 8 更差 → 单次训练的噪声约 0.1–0.2。
它还提供了全章的噪声基准——任何小于 0.2 的差异都不能作数。
2.30.6 汇总表
| 实验 | 变量数 | 训练次数 | 结论强度 |
|---|---|---|---|
| 一、深度对照 | 2×3 | 6 | 中(受超参数影响) |
| 二、学习率扫描 | 2×4 | 8 | 强(改变了全章结论) |
| 三、残差消融 | 2×3 | 6 | 很强(差异接近 1.0) |
| 四、位置编码 | 3×3 | 3 | 弱(阴性结果) |
| 五、上下文长度 | 5 | 5 | 中(提供了噪声基准) |
共 28 次训练,在 RTX 3090 上总计约 25 分钟。
五个实验里,只有两个给出了强结论。
- 实验三(残差消融):信号极强,一次实验就够
- 实验二(学习率扫描):改变了全章结论,是最有价值的一次
实验一看起来最”正面”(给出了清晰的对比), 但它其实是误导性的——如果不做实验二,会得出错误结论。
实验四花了最多时间(三个变体、三种评估长度), 结论却最弱——因为任务选错了。
这个分布是研究的常态:大部分实验给出的信息量有限, 少数几个决定了结论。关键是要能分辨哪个是哪个,而不是把每个实验都当同等重要。
2.31 规模变化时,这些选择会怎么变
本章的模型是 170 万参数。真实模型是它的几千倍。 这一节讨论:本章的那些结构选择,在规模变大时会怎样。
2.31.1 哪些选择与规模无关
有四条结论不会随规模改变,因为它们来自结构性的事实:
| 结论 | 为什么与规模无关 |
|---|---|
| 残差不可省 | 它解决的是”梯度路径长度 O(L)“,与 L 的具体值无关 |
| Pre-LN 的恒等路径存在 | 同样与 L 无关 |
ln_f 不能省 |
残差累积在更大规模下只会更严重 |
| FFN 是键值记忆 | 这是数学重写,不是经验观察 |
这四条可以直接用在任何规模上。
2.31.2 哪些选择会翻转
① d_{\text{ff}} / d_{\text{model}} 的比例。
本章用的是 2 倍(因为我们模型小)。真实模型普遍用 4 倍,甚至更高 (Llama 用 8/3 \times d 配合 SwiGLU,等效于标准 FFN 的 4 倍)。
为什么大模型要用更大的比例:前面算过, f = 4d 时 FFN 占每层参数的 2/3。 大模型需要更多的”知识存储”,所以把预算往 FFN 倾斜。
反过来说:小模型上 FFN 比例高反而可能浪费—— 小模型学不到那么多知识,参数应该给注意力(“搬运”能力)。
② 层数与宽度的权衡。
本章用 12 层、d_{\text{model}} = 128——偏深(相对宽度)。
真实的小模型往往偏宽,大模型偏深。这个趋势在 §2.12 讲过。
③ 学习率。
本章最优是 3\times10^{-3}(Pre-LN)。 真实大模型普遍在 10^{-4} 到 3\times10^{-4} 量级。
为什么大模型要用更小的学习率:这是一个经验规律, 一个直观的解释是大模型的损失曲面更”尖锐”, 步子太大会直接跨过最优点。
④ batch size。
本章固定 32。真实训练用几百到几千。
为什么大模型能用更大的 batch:梯度噪声随 batch 增大而减小, 大模型需要更大的 batch 才能让梯度估计稳定。 这是”梯度噪声尺度”(gradient noise scale)这个概念的核心。
怎么判断一个选择”会不会随规模翻转”?
问一句:它依赖的是”结构性事实”还是”经验观察”?
- 结构性事实:来自数学(梯度路径、秩、方差传播)。 这些在任意规模下成立,改变规模不改变结论
- 经验观察:来自”在这个规模上测出来最优”。 这些一定会随规模变化,因为最优值本身是规模的函数
本章的七条结论里,四条是结构性的、三条是经验性的。 读的时候要能分开——这也是”方向可迁移、数值不可迁移” 那句话的操作性版本。
一个实用的推论:如果要读的论文只给了”我们用了 X”,没有说明 X 是结构性必需还是经验选择,默认把它当成经验性的。
2.31.3 一个本章没测但很重要的维度
数据规模。 本章固定 25 万字。当数据量变化时:
| 数据量 | 瓶颈 |
|---|---|
| 很少(< 10 万) | 过拟合——该加正则化 |
| 中等(本章的情况) | 结构——本章就是在这一档 |
| 很多(> 10 亿) | 计算——该加容量 |
本章所有实验都在”中等”这一档。 这意味着:
- 我们关于”结构问题”的结论(残差、归一化位置)在这个档位成立
- 我们关于”要不要加正则化”的判断(§2.14:不加) 只在数据量少到中等时成立——数据量再小就该加了
这是一个明确的边界,写出来是为了让你知道结论适用的范围。
2.32 常见问题
这些问题来自我们自己调试时的困惑,以及读者最可能踩的坑。
2.32.1 训练相关
Q:我的模型 loss 一开始就是 \ln V(比如 7.09),一直不动。
先查三处:① 输出层前面有没有 ln_f;② head 的权重是不是被正确初始化 (如果误设成全零,logits 恒为零);③ 学习率是不是太小(比如设成了 1e-6)。
如果三处都正常,试试把模型缩小到 2 层——如果 2 层能训而 12 层不能, 问题在深度相关的部分(残差、初始化、归一化位置)。
Q:loss 正常下降,但验证 loss 从某个点开始上升。
这是过拟合——注意区分于本章案例的”欠拟合”(两者都高)。
判断标准:训练 loss 还在降、验证 loss 开始升, 两条曲线的差距在扩大。这时加 dropout 或权重衰减是对的。
如果两条曲线同步升降,那不是过拟合,别加正则化。
Q:训练中途突然出现 nan。
第一步:找到第一个 nan 出现的位置(第 1 章 §1.16 给过四步插桩法)。
第二步:看它前面多少步的梯度范数。如果梯度范数在 nan 之前急剧上升, 那是梯度爆炸——加梯度裁剪,或者降低学习率。
如果梯度范数一直正常却突然 nan,那是数值问题(溢出), 去查 softmax 的稳定性、归一化的 \epsilon、以及 dtype。
Q:换了个随机种子,结果差很多。
这是正常的——尤其在小模型上。本章实测: 单次训练的波动在 0.1–0.2 之间(§2.10 排查二)。
判断标准:如果你的”改进”只有 0.05 的提升,那可能是噪声。 报结果时应该给多个种子的均值与范围,而不是单次结果。
2.32.2 结构相关
Q:一定要用 Pre-LN 吗?我的模型只有 4 层。
4 层的话,Post-LN 也能训——本章的实测里, Post-LN 在 6 层时最优点是 4.732(用 3e-4 的学习率), 而 Pre-LN 是 4.408。
但即使浅层,Pre-LN 的容错范围也更宽。 除非你有明确理由 (比如复现某篇论文),否则用 Pre-LN。
Q:RMSNorm 和 LayerNorm 该选哪个?
效果上没差别(本章实测差 0.007,在噪声内)。选 RMSNorm 的理由是计算更省—— 少一次求均值和一次减法,在大模型上能省可观的带宽。
但如果你的任务对”中心化”敏感(比如输入有系统性偏置), LayerNorm 更安全(见 §2.8 检查点 ③)。
Q:d_{\text{ff}} 该设成 d_{\text{model}} 的几倍?
经验值是 4 倍(原论文用的),但 2 倍、8 倍也都能工作。 本章用的是 2 倍(因为模型小)。
怎么选:如果总参数预算固定,d_{\text{ff}} 和 n_{\text{layer}} 在争同一份预算。小模型偏宽(大 d_{\text{ff}}),大模型偏深(§2.12)。
Q:为什么注意力要 4 个矩阵(Q、K、V、O),不能少几个?
- 去掉 W_Q、W_K:q、k 直接等于输入,那么打分矩阵就是 XX^\top—— 完全没有可学参数,模型学不到”该看谁”
- 去掉 W_V:值就是原输入,注意力只能做”加权平均输入”, 无法把取到的信息变换成需要的形式
- 去掉 W_O:每个头直接写入残差流,失去了”各头独立投影”的自由度 (§1.8 检查点 ⑧ 算过参数量)
四个都必要。 但它们不是”四种不同的功能”—— 前三个构成”读”,最后一个负责”写”。
2.32.3 调试相关
Q:怎么知道我的注意力层有没有在工作?
看两个量:① 权重熵(健康值 0.5–0.9); ② 相邻位置输出的 KL 散度(接近 0 说明没在用上下文)。
如果熵正常但 KL 散度接近 0,那问题不在注意力层, 而在它前面(比如位置编码缺失、输入被归一化掉了)。
Q:改了代码之后效果变差了,怎么定位是哪一处?
用 git bisect 的思路:把改动拆成几个独立的 commit, 逐个回退找分界点。
如果改动没提交,那就手动二分:注释掉一半改动, 看效果有没有恢复。
更快的办法:对每一处改动做单测。 比如”改了归一化位置”——写个测试确认 x = x + attn(ln(x)) 确实和改动前一样(或如预期不同)。
Q:模型很大,每次实验要跑几小时,怎么提高迭代速度?
三个手段,按性价比排序:
- 缩小模型。本章的所有结论都是用 12 层、170 万参数的模型得到的。 如果你要验证的是结构性问题(比如”归一化放哪”), 小模型上一样能看出来。
- 减少步数。如果两个配置在 200 步内就分道扬镳, 没必要跑到 2000 步。先用短实验筛选,再用长实验确认。
- 监控中间量而不是等最终结果。本章反复强调: 熵、激活尺度、梯度范数都能在 loss 显现之前给出信号。
注意第 1 条的前提:缩小模型的前提是”你要验证的是结构性结论”。 如果是数值性结论(比如”最优学习率是多少”), 必须用目标规模——学习率与模型大小强相关。
2.33 代码索引
本章涉及的全部脚本与数据,按用途列出。
2.33.1 实验脚本
| 文件 | 用途 | 输出 |
|---|---|---|
notebooks/run_ch2_case.py |
案例的五个对照实验 | data/ch2_case.json |
notebooks/rope_extrapolation.py |
位置编码外推对照 | data/rope_extrapolation.json |
notebooks/analyze_ffn.py |
FFN 的键值记忆分析 | data/ffn_analysis.json |
notebooks/block_variants.py |
Block 的三种变体(库) | — |
2.33.2 复用第 1 章的脚本
| 文件 | 用途 |
|---|---|
notebooks/tiny_gpt.py |
基础模型与训练循环(两个共用) |
notebooks/analyze_heads.py |
注意力头的画像 |
notebooks/debug_failures.py |
失败模式注入实验 |
2.33.3 数据文件
| 文件 | 内容 |
|---|---|
data/ch2_case.json |
案例的全部原始数据(52 KB) |
data/ch2_lr_sweep.json |
学习率扫描 |
data/ch2_ablation.json |
残差消融 |
data/ch2_context.json |
上下文长度对照 |
data/rope_extrapolation.json |
位置编码外推 |
data/ffn_analysis.json |
FFN 激活统计 |
data/corpus.txt |
语料(25.5 万字,与第 1 章共用) |
2.33.4 复现全部结果
cd /home/mpp/ai-mechanisms-book
PY=/home/mpp/micromamba/envs/dlspace/bin/python
$PY notebooks/run_ch2_case.py --steps 1200 # 约 8 分钟(含扫描与消融)
$PY notebooks/rope_extrapolation.py --steps 2000 # 约 3 分钟
$PY notebooks/analyze_ffn.py # 约 30 秒
python3 qa/gen_fig_ch2.py # 重新生成插图
python3 qa/lint.py chapters/02-block.qmd # 静态检查注意:这些脚本默认用 cuda:1(RTX 3090)。 如果你只有一块卡,把 block_variants.py 里的 device 参数改成 "cuda:0" 或 "cpu"。加 --cpu 参数可以强制用 CPU(慢 5–10 倍)。
2.33.5 代码的组织原则
三个文件的分工:
tiny_gpt.py/block_variants.py是库—— 定义模型、训练循环、诊断函数。不含任何实验逻辑run_ch2_case.py/rope_extrapolation.py是实验—— 调用库、跑对照、导出数据。读它们能看懂实验设计analyze_ffn.py/analyze_heads.py是分析工具—— 加载训练好的权重,测量内部状态
这个分工让”改一个变量”变得容易: 你只需要改实验脚本里的参数,不用碰模型定义。
一个建议:如果你想自己扩展实验,新建一个脚本而不是改现有的。 这样本章的数据可以随时重新生成来对照。
2.34 本章回顾
2.34.1 主线:一个位置的价值
这一章的八节,讲的其实是同一件事的八个侧面:
① 一个 12 层模型训不动(loss 5.36,接近"只猜字频")
↓ 排查
② 换个 LayerNorm 的位置就好了(4.25)
↓ 但这个对比不公平——两者用了同一套超参
③ 各自扫学习率后:最优解接近(4.703 vs 4.732),
差别在**鲁棒性**(Pre-LN 单调改善,Post-LN 有窄峰)
↓ 于是要问:为什么位置这么关键
④ 残差连接提供了恒等梯度路径——消融证明它连 2 层都不能省
⑤ LayerNorm 每步重置信号尺度,让每层面对同一量级
⑥ Pre-LN 把两条路径分开,Post-LN 把它们混在一起
⑦ RMSNorm 去掉中心化,效果不变但更省
⑧ FFN 是键值记忆,与注意力同构但键固定
↓
⑨ RoPE 外推实验:**没测准**——任务本身不依赖长程位置
2.34.2 五条可以带走的方法
| 方法 | 在本章的体现 |
|---|---|
| 比较两个方案要给同等的调参预算 | 差点把”学习率没调好”写成”Post-LN 训不动” |
| “数字太整齐”是退化解的信号 | 无残差时 2/4/12 层给出同一个 loss |
| 先检查任务是否支持你的假设 | RoPE 实验失败的根本原因 |
| 估计噪声水平再下结论 | 上下文长度实验里 16 层比 8 层更差 → 噪声 0.1–0.2 |
| 阴性结果 ≠ 否定结论 | 我们的 RoPE 实验只是”在这个规模上测不出” |
2.34.3 为什么这一章写了这么多”过程”
你可能注意到,本章有大量篇幅在讲我们怎么排查、怎么犯错、怎么修正—— 而不是直接给结论。
这是刻意的。 因为这一章的核心内容——残差、LayerNorm、FFN—— 都是已经确定的知识,查文档就能知道。真正难的是:
- 面对一个不工作的模型,怎么找到问题
- 面对两个方案,怎么设计一个能站住的对比
- 面对一个”和论文不一致”的结果,怎么判断该信谁
这三件事没有文档可查,只能通过看别人怎么做来学。
而且本章恰好有三个”结论被推翻”的时刻: Post-LN 的结论(学习率没调好)、残差消融的异常(退化解)、 RoPE 外推的失败(任务选错)。这些时刻比正确答案更有教学价值。
所以如果你只记住了”要用 Pre-LN”,那是失败的阅读方式。 应该记住的是”比较两个方案时要给同等的调参预算”。
2.34.4 如果只记三件事
第一,残差连接不是可选项。 消融证明连 2 层都训不动。 它提供的那条恒等路径,是深层网络可训练的前提。
第二,归一化的位置比归一化本身更重要。 同一个 LayerNorm, 放在残差里面还是外面,决定了梯度要不要穿过它。 Pre-LN 胜出的不是”效果更好”,而是”容错范围更宽”。
第三,“鲁棒性”和”最优性能”是两个指标。 本章的案例里,Post-LN 在最佳学习率下和 Pre-LN 几乎持平—— 如果你只测那一个点,会得出”两者一样好”的结论。 但扫一遍学习率就会发现,它们的可用区间宽度差了好几倍。
在真实项目里,你事先不知道最优超参数是多少。 所以”容错范围”往往比”峰值性能”更值钱。
2.34.5 一条被推翻又重建的结论链
本章最有价值的部分,其实是结论是怎么变的。把这条链完整列出来:
第一次结论:
"Post-LN 训不动" ← 基于一组超参数的对比
反问:
"这个对比公平吗?" ← 两边用了同一套超参数,但敏感度不同
第二次结论:
"Post-LN 对学习率敏感" ← 扫描后:3e-4 处两者持平
再追问:
"为什么敏感?" ← 回到机制
第三次结论:
"因为它的梯度必须穿过归一化,
而 Pre-LN 有一条恒等路径" ← 由残差消融和激活画像支持
最后修正:
"更准确的说法是:Pre-LN 买到的是
更宽的容错范围,不是更好的解" ← 因为最优解接近
四步,每一步都推翻了上一步的一部分。
注意最后一步不是”回到原点”——它保留了”Pre-LN 更稳”这个核心判断, 但把”更好”这个模糊说法精确成了”容错范围更宽”。
这就是”结论收敛”的样子:不是找到一个绝对正确的答案, 而是逐步把模糊的表述替换成有边界的、可验证的表述。
2.34.6 与后续章节的接口
| 本章留下的问题 | 在哪一章解决 |
|---|---|
| KV 缓存与推理优化 | 第 3 章 |
| FFN 里存了哪些知识、能不能编辑 | 第 4 章 |
| 注意力与 FFN 的电路级分析 | 第 4 章 |
| 残差流视角的完整展开 | 第 4 章 |
| 归一化在训练稳定性中的其他角色 | 第 3 章 |
2.34.7 这一章和上一章的关系
第 1 章拆开了注意力这一个零件,第 2 章拆开了围绕它的整个 Block。 两章的写法是刻意一致的:
- 都从一个训练异常出发(第 1 章:小模型上没复现出缩放的必要性;第 2 章:12 层模型学不会)
- 都靠对照实验 + 中间量测量定位(第 1 章:雅可比范数;第 2 章:激活与梯度画像)
- 都保留了没测准或没做完的部分(第 1 章的脚注前提、第 2 章的 d_{\text{ff}} 扫描)
这个一致性不是巧合,而是本书的方法:先给现象,再给测量,最后才给结论。 如果读者把两章连起来读,应该能感觉到这套方法是可以独立使用的—— 换一个模型、换一个任务,步骤完全一样。
2.34.8 关于本章的”没做完”
本章有三处明确留白,这里集中列出来—— 不是为了显得谦虚,而是让你知道哪些地方可以接手。
① d_{\text{ff}} 该设多大(§2.12)。 我们只跑了 d_{\text{ff}} = 256 一档,没有扫。 参数量的账算清楚了,实验设计也给了,但结论是空的。
② “注意力搬运、FFN 存储”的功能验证(§2.11 检查点 ⑦)。 我们验证的是结构性的(FFN 可以重写成键值形式、激活确实稀疏), 但没有做功能性的实验(证明它”存储”了什么)。 一个复制任务 + 一个事实补全任务的对照就能补上。
③ batch size 的影响(§2.16)。 所有实验固定 batch = 32。真实训练用几百到几千。 这个差异会不会改变本章的结论,我们不知道。
三处的共同点:都是”需要更多训练时间”的实验。 本章的 28 次训练已经用掉了可观的机器时间(在 RTX 3090 上约 25 分钟), 再加这三组大约要翻倍。
写出来的意义:一本教材如果把边界写清楚(哪些结论有实验支持、哪些只是方法), 读者就知道在哪里可以继续往前推。 “这一章说清楚了什么、没说清楚什么”,比”看起来很完整”更有用。
2.34.9 三个容易被忽略的细节
除了主要结论,本章还有三个小细节值得单独记一下—— 它们都不起眼,但都可能在真实项目里救你一次。
① 权重绑定必须在初始化之前。
self.head.weight = self.tok_emb.weight # 先绑定
self.apply(self._init) # 再初始化顺序反了会被静默覆盖。 self.apply 会遍历所有子模块, head 和 tok_emb 各自被初始化一次—— 由于它们共享同一个张量,后初始化的那个说了算。 结果不是报错,而是”两个矩阵长得不一样”的微妙 bug。
② pos_emb 的越界是硬边界,不是渐变。
序列从 64 变成 65 时,性能不是”略微下降”而是断崖式变化—— 第 65 个位置要么拿到一个训练时没见过的编码(可学习嵌入会直接报错), 要么被截断到最后一个位置(静默出错)。
这是一条可以靠断言拦住的边界,加一句 assert T <= self.block_size 的成本几乎为零。
③ 归一化的 eps 在低精度下会失真。
eps = 1e-5 在 float32 下是个正常的小数, 但在 bf16 下(机器精度 7.8\times10^{-3})它会和 0 无法区分。
所以如果你的归一化是在原精度下算的(而不是像我们那样升到 float32), 在 bf16 训练时 eps 实际上等于 0——除零风险回来了。
这条和 §2.5 讲的”归一化在 float32 下算”是同一个问题的两面。
2.34.10 这一章留下了什么
如果要用一句话概括本章的方法论收获:
“一个组件的位置,和这个组件本身同样重要。”
LayerNorm 的定义、参数、计算都没变——从残差外面挪到里面, 同一个模型就从”完全学不会”(5.357)变成”正常训练”(4.245)。 没有加任何参数、没有改任何超参数,只是换了位置。
这个观察可以推广到很多地方:
| 场景 | “位置”指的是什么 |
|---|---|
| 归一化 | 在残差里面还是外面 |
| dropout | 在写入总线之前还是之后 |
| 位置编码 | 加在输入上还是旋转 q、k |
| 梯度裁剪 | 在优化器更新之前还是之后 |
问”这个组件放在哪里”,往往比问”该不该用它”更有用。
2.34.11 三张可以带走的表
① 症状 → 病因对照表(§2.15):
| 症状 | 先查什么 |
|---|---|
| loss 停在”太整齐”的值 | 是不是退化成常数输出 |
| 训练 loss 低、验证 loss 高 | 过拟合,加正则化 |
| 两者都高 | 欠拟合,别加正则化 |
| logits 尺度异常大 | 缺 ln_f,或残差累积失控 |
| 深层梯度比浅层小很多 | 归一化位置(Pre 还是 Post) |
② 超参数不能独立调的关系(§2.16):
- 学习率 ↔︎ 归一化位置(换位置必须重扫学习率)
- warmup ↔︎ 归一化位置(作用方式不同)
- 学习率 ↔︎ batch size(线性缩放)
③ 判断结论能否迁移(§2.17):
- 结构性结论(“没有 X 就训不动”)→ 可以用在任何规模
- 数值性结论(“最优值是多少”)→ 只用来定起点,然后自己扫
2.34.12 三个”如果重来一次”
如果要重做这一章的实验,有三件事我们会改:
① 每个配置跑三个种子。
本章所有实验都是单次运行。这导致我们无法给出置信区间, 也导致 §2.10 那个 RoPE 实验的结论模糊——我们只能说”差异没超过噪声”, 但不能说”没有差异”。
三个种子意味着三倍的时间。但对于要写进书里的结论, 这个代价是值得的。
② 先做”任务诊断”,再做对照。
RoPE 实验的失败,根因是我们先做了实验、后才发现任务不支持假设。 如果先花二十分钟测”这个任务需要多长上下文”, 就能省下后面一个多小时的三个训练。
一般的顺序应该是:先确认任务能区分你的假设,再设计对照。
③ 把诊断量的监控提前到第一次训练。
我们是在排查”12 层训不动”时才加上那三个诊断量的 (激活尺度、注意力熵、KL 散度)。但它们本来应该一开始就有。
代价几乎为零,收益是提前几百步发现问题。
2.34.13 下一章
第 3 章讲训练与推理的两副面孔。
本章的模型只做了训练——我们从来没有真正”用它生成过文本” (除了第 1 章末尾那几段演示)。而推理引入了一整套新的约束: KV 缓存、采样策略、上下文长度、以及在 batch=1 时的性能问题。
本章的”残差流是共享总线”那个视角,在讲到 KV 缓存时会再一次用上—— 因为缓存存的正是”总线上各个位置的历史状态”。
不是”要用 Pre-LN”或者”要用 RMSNorm”。
而是:一个组件的位置,和这个组件本身同样重要。
LayerNorm 的定义没变、参数没变、计算没变—— 只是从残差外面挪到了里面,同一个模型就从”完全学不会”变成”正常训练”。
这提示了一类容易被忽略的设计维度:不是”用什么零件”, 而是”零件放在哪里、和谁相邻”。 第 3 章讲 KV 缓存时会看到同一个模式的另一个例子。
2.35 怎么做这类对照实验
本章的案例暴露了几个实验设计上的问题。这一节把它们整理成可复用的清单—— 因为”设计一个能说明问题的实验”比”跑实验”难得多。
2.35.1 清单:做一个能站住的对照
按顺序检查这七条。任何一条没做到,结论都要打折扣。
① 明确你要回答的是哪个问题。
本章最初的隐含问题是”Post-LN 好不好”。但这是个无法回答的问题—— “好”在什么任务上、什么规模下、和什么比?
改成可回答的形式:“在 12 层、d_{\text{model}}=128、 25 万字唐诗、1200 步的条件下,Pre-LN 与 Post-LN 的最优 loss 和容错范围分别是多少?”
② 钉死所有无关变量。
我们的对照实验固定了:语料、随机种子、步数、批大小、优化器、学习率调度。 唯一变化的是归一化位置。
“固定种子”这一条尤其重要——它让两边在 step 0 的 loss 完全相同(7.145), 之后的差异只可能来自那个变量。
③ 给两边同等的调参预算。
这是本章最大的教训。同一个学习率下 Post-LN 看起来”训不动”, 但那是因为 3e-3 超出了它的可用范围。
做法:对每个方案都扫同一组超参数,取各自最优,或者比较容错范围。
④ 估计噪声水平。
怎么知道观察到的差异是真的?
本章的办法:找一组”应该没有差异”的配置,看它们的差距有多大。 上下文长度实验里,16 层比 8 层更差(4.971 vs 4.846)—— 这两者没有理由差这么多,所以这个 0.125 就是噪声的估计。
结论:任何小于 0.2 的差异,在单次实验中都不能作为证据。
⑤ 用一个”平凡基线”锚定结果。
本章用了两个:
- 均匀分布:\ln 1200 = 7.09(“什么都没学”)
- 边缘分布:5.672(“只学会字频”)
没有基线的 loss 数字是没有意义的。 4.2 是好是坏, 取决于”随便猜”能到多少。
⑥ 检查任务是否支持你的假设。
这是 RoPE 实验失败的根因。我们想测”外推能力”, 但任务本身不依赖长程位置信息——测量工具和被测量对象不匹配。
做法:在做实验之前,先问”如果我的假设成立, 这个任务上会出现什么可观察的差异?“如果答不上来,先换任务。
⑦ 报出你没能控制的变量。
本章明确写了:batch size 固定为 32,没有扫。 这意味着”换 batch size 会不会改变结论”是未知的。
诚实地划出边界,比假装边界不存在更有价值。
2.35.2 三种常见的实验设计错误
错误一:用一组超参数比较两个方案
症状:A 比 B 好,但你没检查 B 的最优超参数是不是另一组。
本章的实例:我们差点把”学习率没调好”写成”Post-LN 训不动”。 扫描后发现,在 3\times10^{-4} 处两者几乎持平(4.703 vs 4.732)。
为什么这个错误常见:它省事。扫四个学习率意味着四倍的训练时间。 但不扫的代价是可能得出完全错误的结论。
错误二:把”没测出来”读成”不存在”
症状:实验没看到差异,就宣称”这个因素不重要”。
本章的实例:RoPE 实验里可学习嵌入在 256 长度上反而更好。 如果就此宣称”可学习嵌入的外推能力比 RoPE 强”,那是错的—— 正确的说法是”在这个任务、这个规模上测不出差异”。
两者的区别:前者是一个普遍性结论,后者是一个有边界的观察。 只有后者是实验支持的。
检验方法:问”我的实验有多大把握能测出真实存在的效应?” (这在统计上叫功效,power)。如果实验本身不够灵敏, 阴性结果什么也说明不了。
错误三:在训练集上评估基线
症状:基线给出了一个”好得可疑”的数字。
本章的实例:我们用 n-gram 当基线时, 在训练集上评估得到了 0.006 的 loss——那不是在预测,是在背答案。
自查习惯:如果某个数字好到不像真的,先问”它是不是见过这些数据”。
一个具体的判据:交叉熵 0.006 意味着模型对下一个字的预测几乎完全确定。 自然语言里不存在这种确定性(人类标注者之间的一致性都远达不到)。 理论上不可能的数字,一定是评估方式有问题。
2.35.3 一张实验设计自查表
贴在你开始跑实验之前:
| 检查项 | 本章的做法 |
|---|---|
| 问题表述是可回答的吗? | 从”Post-LN 好不好”改成”在 X 条件下两者的最优 loss 与容错范围” |
| 无关变量钉死了吗? | 同种子、同语料、同步数、同批大小 |
| 两边调参预算对等吗? | 各扫 4 个学习率 |
| 噪声水平估了吗? | 用”应该无差异”的配置对估计(±0.125) |
| 有平凡基线吗? | 均匀 7.09、边缘 5.67 |
| 任务支持你的假设吗? | RoPE 实验没有——这是它失败的原因 |
| 边界说清楚了吗? | 明确写了 batch size 未扫、单次实验无置信区间 |
七条里我们做到了五条,两条没做到。 那两条(任务是否支持假设、边界)正是本章两处结论模糊的原因。
前面二十节讲的是”Transformer 是什么”。这一节讲的是”你怎么知道它是什么”。
后者更耐用。 具体的结构会随架构演进变化 (正弦编码→RoPE、LayerNorm→RMSNorm、MHA→GQA), 但”怎么做对照实验”这件事,从 2017 年到今天没有变过。
(第 2 章完)
这一章的旅程到此结束,下一章见——那一章我们会第一次真正“使用”这个模型:让它一个字一个字地生成文本,而不是只在训练循环里被反复喂数据。