4 注意力里能读出什么
本章地图
你在这里:回到第 3 步那个红框。这回不问它怎么算,问的是——它算完的东西,能不能被读懂。 本章解决:注意力图能当解释用吗;消融、探针、对照各自能证明什么,又分别在什么地方失效。 前置:第 1 章(多头注意力的定义)+ 第 2 章(Block 的其余部件)。 读完能做:设计一个站得住的可解释性实验,并说清它的结论到底有多强。
一张漂亮的注意力图,和一个它没有回答的问题。
下面这张图,是第 1 章那个模型第一层第 1 个头(记作 L0H1)在 「空山不見人,但聞人語響」上算出来的注意力权重。横轴是”看的位置”, 纵轴是”从哪个位置看”,颜色越深表示注意力越集中:
空 山 不 見 人 , 但 聞 人 語 響
空 ██
山 ░░ ██
不 · ░░ ██
見 · · ░░ ██
人 · · · ░░ ██
, · · · · ▓▓ ██
但 · · · · · ▓▓ ██
聞 · · · · · · ▓▓ ██
人 · · · · ▓▓ · · ▓▓ ██
語 · · · · · · · · ▓▓ ██
響 · · · · · · · · · ▓▓ ██
(▓▓ = 注意力主要落在紧邻的前一个位置上,██ = 落在自己身上。)
这张图”看起来”在说一件很具体的事:这个头基本不看别的,只看紧挨着的上一个字。 它的平均”前一位权重”是 0.450——也就是说,它把将近一半的注意力 压在左上角那条紧邻对角线的斜带上。
这个解释干净、具体、有物理意义。它对应的正是语言模型最基础的那个统计量: 二元组(bigram)——“見”后面大概率跟”人”,“人”后面大概率跟”,“。 一个专门负责”看上一个字”的头,听起来完全合理。
于是我们写下了这一章的第一个结论:
L0H1 是一个 bigram 头:它把上一个字的信息搬到当前位置,供后续层使用。
这条结论对不对?它是对的——后面 4.4 节会用一个独立的实验证实它。
但这一章真正要讲的不是这条结论,而是它是怎么被证实的,以及 为什么”看图说话”本身不足以证实任何东西。
因为同一个模型里还有另外七个头。它们每一个都能画出一张”看起来在说某件事”的图。 而当我们真的把它们一个个拿掉、看模型行为怎么变的时候, 结果和图上读出来的印象对不上——在第 1 层,拿掉任何一个头, 模型的损失只变化 0.02 到 0.03;而在第 0 层,同样的操作会让损失变化 0.14 到 0.18。
差了六倍。
同样都是”注意力头”,同样都能画出好看的图,因果重要性却不在一个量级上。
# 需要 PyTorch。本机在 /home/mpp/micromamba/envs/dlspace/bin/python
python notebooks/run_ch4_case.py # 本章全部实测数字
python notebooks/analyze_heads.py # 头部画像(本章 4.3 节用的工具)数据导出到 data/ch4_case.json。本章正文里出现的每一个数字都来自这两个脚本。
4.0.1 这张图是怎么来的
上面那张斜带图不是手画的。它来自模型在「空山不見人,但聞人語響」 这句话上的一次前向:
ids = [tok.stoi.get(c, 1) for c in text][:cfg.block_size]
model(torch.tensor([ids]))
w = model.last_attns[0] # 第 0 层的注意力,(1, n_head, T, T)
A = w[0, 1] # 第 1 个头为什么挑这句话? 因为它短(12 个字)、干净(没有生僻字), 而且逗号的位置很清楚。在一张包含太多变量的图上做解读, 等于给自己挖坑。
为什么挑 L0H1? 因为它是八个头里画像最”清楚”的之一 (前一位权重 0.450,是全部八个头里最高的)。
这一步其实就已经埋下了确认偏误。 我们是先看了画像表、 挑了最符合直觉的那个,再回头去画图—— 这个顺序下,“这张图看起来在说 X”这句话的可信度要打折扣。
正确顺序是:先挑(比如按索引),再预测,再验证。 本章没有完全做到——4.8 节会把这当成一个反面教材来讲。
4.0.2 六个问题
本章由这张图引出的六个问题组织起来:
| 问题 | 在哪回答 |
|---|---|
| ① 注意力权重到底是什么量? | 4.2 |
| ② 怎么把图变成能比较的数? | 4.3 |
| ③ 图上看到的模式,有因果作用吗? | 4.4 |
| ④ 怎么知道一个模式不是噪声? | 4.5 |
| ⑤ 有没有”确定有用”的具体电路? | 4.6 |
| ⑥ 除了注意力,还能从模型里读出什么? | 4.7 |
这六个问题的顺序是有讲究的:从”这个量是什么”(定义), 到”怎么量化”(测量),到”有没有因果”(实验), 到”怎么排除噪声”(对照),到”有没有已知的正面例子”(构造), 到”换个角度还能读什么”(推广)。
任何”我在模型里发现了 X”的声称,都可以按这个顺序自查一遍。 缺哪一环,结论就在哪一环上有漏洞。
4.1 先把话说清楚:注意力权重是什么
在开始”读”之前,得先明确读的到底是什么。这一步被跳过,后面全是错的。
4.1.1 注意力的定义里没有”重要性”
回到第 1 章的式子:
\text{Attn}(X) = \text{softmax}\left(\frac{QK^\top}{\sqrt{d_k}}\right)V \tag{4.1}
所以这个式子在说:位置 i 的输出,是所有位置的值向量 v_j 的加权平均, 权重就是 softmax 出来的那个 A_{ij}。
注意这里有两件事被混在一起了:
| 量 | 它是什么 | 它不是什么 |
|---|---|---|
| A_{ij} | 位置 i 从位置 j 取走多少信息(比例) | 位置 j 对输出有多重要 |
| v_j | 位置 j 提供什么内容 | 一个标量 |
A_{ij} 大,只说明”取的比例高”。 如果那个位置的 v_j 本身接近零向量, 取 100% 也等于什么都没取。
一个能说明问题的极端例子
假设某个头算出来的权重是 A_{i1} = 0.99(几乎全压在位置 1 上), 但位置 1 的值向量恰好是 v_1 = \mathbf{0}。
那么位置 i 的输出是 0.99 \times \mathbf{0} + \sum_{j>1} 0.01 \times v_j。 它拿到的几乎全是”零”。
从注意力图上看,这个头”强烈地关注位置 1”——图上是一片深色。 从功能上看,它什么都没做。
图上的深浅和功能上的大小,是两件事。
4.1.2 第二个容易犯的错:把 softmax 的归一化读成”分配”
softmax 保证每一行的权重加起来等于 1。这是一个纯粹的数学约束, 不是模型的”决策”。
后果是:一个头的注意力被分散到 50 个位置上,不代表它”在关注 50 个东西”。 它可能只是没有偏好——所有 logits 都差不多,softmax 就给出一个接近均匀的分布。
我们模型里就有这样的头:
| 头 | 归一化熵 | 说明 |
|---|---|---|
| L0H2 | 0.804 | 接近均匀 |
| L1H3 | 0.871 | 接近均匀 |
归一化熵 = 1 意味着完全均匀(权重平均分给所有允许的位置)。 0.871 已经很接近了。这样的头画出来是一张”弥散”的图, 它看起来”在全局整合信息”——但它其实什么都没选。
上面说 softmax 保证每行和为 1,所以”权重分散”可能只是没有偏好。
那么反过来:如果一个头的权重非常集中(熵接近 0),能说明它很重要吗?
(答案:不能。熵低只说明”它挑得很明确”,不说明挑出来的东西有用。
极端情况:一个头把所有注意力压在一个恒定输出零向量的位置上, 它的熵是 0(完全确定),但功能上毫无作用。
还有一个更隐蔽的情况,见 4.5 节:随机初始化的模型里, 头也会有明确的偏好——因为因果掩码让位置 0 只能看自己, 位置 i 能看 i+1 个位置。这种几何上的不对称会在随机权重下 就造出可测量的”结构”。)
4.1.3 正确的读法:注意力图是证据,不是结论
把上面两条合起来,得到一个工作原则:
注意力图告诉我们”信息从哪里流过来”,不告诉我们”这条信息起了多大作用”。
它是一份关于路由的证据。要把它变成关于功能的结论, 必须再做一个实验:把这条路切断,看行为变不变。
这个”再做一个实验”就是本章的主线。4.3 节先把图变成数字, 4.4 节做因果检验,4.5 节做对照,4.6 和 4.7 节讲两个真的有结论的案例。
4.1.4 先算一遍:一个头的输出到底是怎么算出来的
在讲”怎么读”之前,先把一个头的输出手算一遍。这一步做完, 后面所有的”读图”讨论才有落脚点。
设置:d_{head} = 2,上下文长度 4,单头。四个位置的输入(已经过第一层 LayerNorm 之后)是:
x_0 = (1, 0),\quad x_1 = (0, 1),\quad x_2 = (1, 1),\quad x_3 = (0.5, 0.5) \tag{4.2}
为了能算,假设 W_Q = W_K = W_V = I(恒等矩阵)—— 这只是为了让数字干净,机制不受影响。
第一步:算打分。 q_i = x_i,k_j = x_j,所以 q_i \cdot k_j = x_i \cdot x_j。
i = 3 时(x_3 = (0.5, 0.5)):
| j | x_j | q_3 \cdot k_j | 除以 \sqrt{2} |
|---|---|---|---|
| 0 | (1, 0) | 0.5 | 0.354 |
| 1 | (0, 1) | 0.5 | 0.354 |
| 2 | (1, 1) | 1.0 | 0.707 |
| 3 | (0.5, 0.5) | 0.5 | 0.354 |
第二步:softmax。
e^{0.354} = 1.425,\quad e^{0.354} = 1.425,\quad e^{0.707} = 2.028,\quad e^{0.354} = 1.425 \tag{4.3}
分母 = 1.425 + 1.425 + 2.028 + 1.425 = 6.303。
权重:A_{3,\cdot} = (0.226,\ 0.226,\ 0.322,\ 0.226)。
第三步:加权求和。
\text{out}_3 = 0.226\,x_0 + 0.226\,x_1 + 0.322\,x_2 + 0.226\,x_3 \tag{4.4}
= 0.226(1,0) + 0.226(0,1) + 0.322(1,1) + 0.226(0.5,0.5) = (0.661,\ 0.661) \tag{4.5}
这笔账里最重要的一件事
四个权重没有一个”显著大”:0.226、0.226、0.322、0.226。 最大的是 0.322——只比均匀分布(0.25)高一点点。
如果把这四个数画成注意力图,你会看到一行几乎均匀的浅色。 按 4.1 节的判读标准,这个头会被归为”弥散(接近均匀)“, 结论是”它没有明确偏好,可能在全局整合信息”。
但看输出:(0.661, 0.661)。而如果权重完全均匀, 输出会是 (0.625, 0.625)。
两者差了 0.036,相对差 5.8%。 一个”看起来完全均匀”的头, 输出和真正均匀的输出并不一样——差别来自那个 0.322。
这就是”看图”的极限。 图上那一点点深色,是不是有意义, 图本身回答不了。要靠消融:把这个头拿掉,看模型行为变不变。
图只能告诉你”哪里有候选信号”。判断信号真假的活,得另外干。
上面的例子里,我们假设了 W_Q = W_K = W_V = I 才能手算。
如果 W_V 不是恒等,而是把所有输入都映到 (1, 0) 方向呢?
(答案:那么无论注意力权重是多少,输出都正比于 (1,0)。
\text{out}_3 = \left(\sum_j A_{3j}\right) \cdot (1,0) = (1, 0)
因为 softmax 保证权重和为 1,所以输出和权重完全无关。
这个头画出来的图可以千变万化,功能上却是一个常数。
这就是 4.1.2 节说的”v_j 可能是零向量”的一般形式: 不是 v_j 恰好是零,而是所有 v_j 都落在同一个方向上—— 注意力在它们之间怎么分配,结果都一样。
在真实模型里这种情况不会这么极端,但”OV 电路的秩很低”是常见的。 第 ⑤ 篇延伸阅读里讲的 QK/OV 分解,形式上就是在区分 “权重怎么算”(QK)和”搬什么东西”(OV)。)
4.1.5 把一张注意力图摊成数字
上面那张图是彩色的、好看的、但也是模糊的。 一幅热力图能表达的信息量有一个上限——颜色分级只有那么多档,而人眼分辨相近颜色的能力很差。
读图之前先读一次数字。 这一节把两个头的注意力矩阵完整打印出来, 用同一段文本:空山不見人,但聞人語響。(12 个字符)。
先看 L0H1——画像表里”盯住前一位”的那个头:
TXT = "空山不見人,但聞人語響。" # 12 字,短到能整张打出来
ids = [tok.stoi.get(c, 1) for c in TXT]
model(torch.tensor([ids], device=DEV)) # 一次前向,last_attns 就有了
w = model.last_attns[0][0, 1] # 第 0 层第 1 个头 → (T, T)
for i in range(len(ids)):
row = [f"{w[i, j].item():.3f}" if j <= i else " · " for j in range(len(ids))]
print(f"{TXT[i]} (i={i:2d}) | " + " ".join(row))L0H1 的实际输出(每一行和为 1;· 是因果遮罩挡掉的位置):
| 查询位置 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 但 (6) | .001 | .000 | .001 | .011 | .035 | .907 | .045 | · | · | · | · | · |
| 聞 (7) | .026 | .003 | .002 | .001 | .015 | .568 | .338 | .047 | · | · | · | · |
| 人 (8) | .055 | .024 | .010 | .002 | .002 | .084 | .203 | .521 | .099 | · | · | · |
| 語 (9) | .009 | .031 | .145 | .012 | .002 | .020 | .037 | .104 | .456 | .183 | · | · |
| 響 (10) | .001 | .007 | .090 | .057 | .006 | .026 | .007 | .015 | .113 | .592 | .086 | · |
| 。 (11) | .000 | .001 | .023 | .145 | .061 | .036 | .004 | .002 | .011 | .233 | .379 | .104 |
现在可以逐行读了。
第 6 行(但)是一条”几乎纯”的前一位:0.907 给了 ,,剩下 0.093 散在别处。 这是”盯着前一位”最干净的样子。
第 7 行(聞)差一些:0.568 给 ,,0.338 给 但。 注意 0.338 这一项——但 在 L0H1 这里拿到了相当高的权重。 它不是”前一位”,也不是”自己”。这是画像表完全看不到的东西。
第 9 行(語)是六行里最”花”的: 最大项 0.456(前一位 人),但 j=2(不)拿了 0.145,j=11 之外的 j=3 拿了 0.012, 而 j=8(人)的 0.113 和 j=2 的 0.145 都不小。 一个”前一位头”在第 9 行上,把 0.145 给了一个距离 7 的位置。
第 11 行(。):0.379 给 響(前一位),0.233 给 語(距离 2), 还有 0.145 给 j=3(見)。 这已经不是”前一位头”了——0.379 只是”最大项”,它占了不到四成。
L0H1 在画像表里的”前一位权重”是 0.450。 现在你知道这 0.450 是怎么来的了:
它把 0.907、0.568、0.521、0.456、0.592、0.379 这些数平均了一遍 (再加上前面几行)。
一个 0.450 的平均值,背后是 0.907 到 0.379 的两倍多差距。
这就是为什么画像表只能当粗筛: 一个头在”最干净的位置”上做得很纯,在”最难的位置”上做得很杂, 平均值会把这些差异全部抹平,给出一个听起来很确定、实际什么都不代表的数字。
读画像表的正确姿势:把它当成”去哪一行细看”的索引, 不要当成”这个头是什么”的定义。
再换一个头看。 L1H3——画像表里熵最高(0.871)的那个,也就是”最弥散”的:
| 查询位置 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 空 (0) | 1.000 | · | · | · | · | · | · | · | · | · | · | · |
| 不 (2) | .229 | .294 | .477 | · | · | · | · | · | · | · | · | · |
| 人 (4) | .169 | .156 | .297 | .161 | .217 | · | · | · | · | · | · | · |
| 但 (6) | .121 | .131 | .088 | .097 | .193 | .195 | .174 | · | · | · | · | · |
| 人 (8) | .109 | .115 | .114 | .079 | .127 | .066 | .144 | .136 | .109 | · | · | · |
| 響 (10) | .094 | .099 | .110 | .059 | .086 | .062 | .175 | .101 | .080 | .065 | .070 | · |
| 。 (11) | .035 | .045 | .040 | .035 | .071 | .032 | .073 | .086 | .115 | .129 | .227 | .113 |
它确实很弥散:第 8 行(人)的 9 个可见项里,最小 0.066,最大 0.144——比值只有 2.2。 第 10 行(響)的 11 项里,最小 0.059,最大 0.175——比值 3.0。
但”弥散”不等于”没结构”。 看两个地方:
一、第 0 行是 1.000,这不是选择,是无奈——它是序列的第一个位置, 因果遮罩下只有自己可看。任何头在第一行都是 1.000。 (这就是为什么画像表的统计里要排除 i=0。)
二、最后一行(。)的分布是不均匀的: j=10 拿 0.227,j=9 拿 0.129,j=8 拿 0.115, 而开头几个位置只拿 0.035–0.045。
它有一个”近大远小”的梯度。 不是均匀,是随距离缓慢衰减。
L1H3 的熵是 0.871,L0H2 是 0.804。按熵排序,L0H2 比 L1H3 更”有结构”。
但从矩阵上看,L1H3 有一条清晰的”近大远小”梯度,L0H2 呢?
去看 4.3 节的画像表——L0H2 的对角 0.127、前一位 0.107、距离 3.92。 它的对角和前一位都很低,距离也不长。
(答案:两个头是两种不同的”弥散”。
- L1H3:有规律的弥散——权重随距离单调衰减,熵高是因为衰减慢
- L0H2:无规律的弥散——接近均匀
熵这一个数区分不了这两种。 要区分它们,得看距离分布的形状, 而不只是它的均值或熵。
这就是”用一个数概括一个分布”的固有代价—— 画像表给了四个数,已经比一个数好很多,但仍然不够。 想知道形状,就得把分布画出来或打出来。)
这一节的结论不是一个”新发现”,而是一个方法:
读注意力图之前,先把至少一个头的矩阵完整打印出来。 热力图告诉你”哪里亮”,矩阵告诉你”有多亮、亮在哪、剩下的去了哪里”。
一张 12×12 的表,信息量比一张 12×12 的热力图大得多—— 因为数字的精度是人眼对颜色分辨力的几十倍。
4.1.6 注意力图的三种画法,和它们各自的误导
同一批权重,画法不同,读出来的印象可以完全相反。
画法一:原图。 直接把 T \times T 的矩阵按值上色。
问题:行与行不可比。 位置 5 的权重摊在 6 个位置上, 位置 40 的摊在 41 个位置上。同样的”深色”在数值上完全不同。
画法二:每行归一化后再画。 这正是 softmax 的输出, 所以”原图”其实就是归一化过的。
问题:归一化抹掉了绝对量。 一行权重是 (0.5, 0.5) 还是 (0.0005, 0.0005) 在图上看起来一样(都是两个中等深的格子), 但前者的 softmax 前 logits 差很大,后者几乎相等。
画法三:减去均匀基线。 每个位置减去 1/(i+1), 只画”比均匀多出来的部分”。
这一种最接近”这个头在做什么”,因为它把 4.5 节讲的 “因果掩码的几何效应”减掉了。
回顾 4.3 节的四个量:
| 量 | 减掉几何基线了吗 |
|---|---|
| 归一化熵 | 减了——除以 \log(i+1) |
| 对角 A_{ii} | 没减(但 4.5 节给了随机基线 0.157) |
| 前一位 A_{i,i-1} | 没减(随机基线 0.115) |
| 平均距离 \bar{d} | 没减(但给了”均匀时 ≈ 6”的参考线) |
四个量里只有一个做了归一化。 这是本章画像表的一个已知不足: 对角和前一位这两个量,必须配合 4.5 节的随机基线才能读。
如果一个头的对角是 0.16,它比随机基线 0.157 高 0.003—— 这是噪声,不是”盯住自己”。
4.1.7 一个头的两半:QK 电路和 OV 电路
上面那句”A_{ij} 大不等于有用”,可以写得更精确。 一个注意力头实际上是两个独立的电路拼起来的。
把注意力的计算拆成两段:
\text{out}_i = \sum_j \underbrace{A_{ij}}_{\text{QK 决定}} \cdot \underbrace{(x_j W_V W_O)}_{\text{OV 决定}} \tag{4.6}
所以这个式子在说:输出是两部分的乘积——
- A_{ij} 由 W_Q 和 W_K 决定,它回答”从哪里取”
- x_j W_V W_O 由 W_V 和 W_O 决定,它回答”取过来的是什么”
这两半是独立的。 同一个头可以有很尖的 A(挑得很明确) 和很”扁平”的 OV 电路(取过来的是个几乎不含信息的方向)。
4.2 为什么这个分解有用
因为它把”读注意力图”这个动作精确地定位了:
注意力图只展示了 QK 电路。它完全没有说 OV 电路的事。
一个头可能在”找主语”(QK 很有意义),但找到之后取回来的东西 可能是”一个几乎恒定的向量”(OV 近似秩 1 且方向固定)。
这种情况下,从图上看它是个”语法头”,功能上它是个”偏置项”。
怎么测 OV 电路? 直接量那个矩阵:
# 一个头的 OV 电路的"有效秩"——它到底能传递多少种不同的信息
OV = W_V[layer][head] @ W_O[layer][head] # (d_model, d_model)
U, S, Vt = torch.linalg.svd(OV)
energy = (S**2).cumsum(0) / (S**2).sum() # 累计能量
rank90 = int((energy < 0.9).sum()) + 1 # 承载 90% 能量的秩rank90 越大,这个头能传递的”内容种类”越多。 rank90 = 1 的头,无论它的注意力图多花哨,输出的方向是固定的。
本章没有做这个测量——它是”读图”之外的另一半工作, 但代码上面就写了,三行。
上面的 W_V W_O 写法假设了线性——但两层之间还有 LayerNorm 和 GELU。
严格地说,QK/OV 分解是在忽略非线性的前提下做的近似。 Anthropic 那篇论文(延伸阅读 ④)在推导时明确说了这个前提。
为什么还能用? 因为实践证明这个近似相当好: 大量头的 OV 电路确实接近低秩,而且消融实验的结果和”读 OV”的预测 对得上。
但要记住它是近似。 一个头如果真的高度非线性, 单看 W_V W_O 会低估它。
4.2.1 回到 L0H0:那个”盯住自己”的头在干什么
用上面的框架看 4.3 节表里最极端的那个头:L0H0,对角 0.769。
它 77% 的注意力放在自己身上。从 QK 看,它的模式非常清楚。
但 OV 那一半是什么? 如果一个对角权重很高的头, 它的 W_V W_O 又接近恒等,那它就是一个直通的复制通道—— 把 x_i 原样传下去。
这有用吗? 有。回到第 2 章的残差流: 残差流已经有一条恒等路径了(x \leftarrow x + \dots)。 一个”复制”头等于给这条路径加了一条并行的副本。
为什么需要副本? 因为残差流是加法的—— 如果你想让某个信息在后面的层里更容易被取到(权重更大), 一条办法是把它再加一遍。
所以 L0H0 可能的角色是:给当前的 token 表示”加权”, 让后面层在读取时更容易注意到它。这不是”发现关系”, 是调节信噪比。
上面的猜测(L0H0 是”给当前 token 加权”)是一个假设,不是结论。 本章没有测它的 OV 电路。
如果要验证这个假设,最直接的实验是什么?
(答案:看 L0H0 的输出和它的输入像不像。
# 抓 L0H0 的输入(LayerNorm 之后的 x)和输出(proj 之后)
# 算它们的余弦相似度
cos = F.cosine_similarity(x_in, out, dim=-1).mean()如果 cos 接近 1,说明它确实在做”复制”——输出基本就是输入。 如果 cos 接近 0,说明它在做别的事,对角权重高只是碰巧。
这是一个十分钟就能跑完的实验,而且它的结论是二值的(像还是不像), 不需要复杂的解释。本书把它留作练习。
好的假设的特征就是:能用一个便宜的实验证伪。)
4.3 从图到数字:头部画像
4.3.1 四个量
一张 T \times T 的图不适合比较。八个头放在一起,人会看花。 所以我们把它压成四个标量。这四个量的选择不是随意的—— 每一个都对应一种”这个头在做什么”的假设。
① 归一化熵。
H = -\frac{1}{\log(i+1)}\sum_{j=0}^{i} A_{ij}\log A_{ij} \tag{4.7}
所以这个式子在说:这一行的注意力有多分散。除以 \log(i+1) 是为了 归一化——位置 i 只能看 i+1 个位置,可选项越多,熵自然越大。 归一化之后 H = 1 表示完全均匀,H = 0 表示完全压在一个位置上。
② 对角权重 A_{ii}。 位置 i 有多关注自己。 这个量高说明这个头倾向于”原样保留当前 token”。
③ 前一位权重 A_{i,i-1}。 有多关注紧邻的上一个位置。 这个量高说明它在做 bigram 式的工作。
④ 平均距离。
\bar{d}_i = \sum_{j=0}^{i}(i-j)\cdot A_{ij} \tag{4.8}
所以这个式子在说:注意力平均”往回看多远”。 全是 0 表示只看自己,等于 i 表示一路看到句首。
平均距离要和”完全均匀”比
如果注意力完全均匀(每个允许的位置权重相同),平均距离是多少?
位置 i 的允许集合是 0, 1, \dots, i,共 i+1 个。 平均距离 = \frac{1}{i+1}\sum_{j=0}^{i}(i-j) = \frac{i}{2}。
所以”完全均匀”的参考线是 i/2——而不是 0,也不是某个固定值。
本模型上下文 64,我们测的三条诗各约 24 个字。取中间位置 i \approx 12, 均匀时的平均距离约 6。实测的八个头里,最大的 L1H2 是 4.61—— 说明它比均匀更偏向近处,但偏得不多。
4.3.2 实测:八个头的画像
在三条唐诗上各跑一遍前向,把每个头在每个位置的四个量记下来再平均 (三条序列 × 约 24 个位置 = 71 个位置)。
| 头 | 归一化熵 | 对角 | 前一位 | 平均距离 | 判读 |
|---|---|---|---|---|---|
| L0H0 | 0.294 | 0.769 | 0.050 | 1.20 | 盯住自己 |
| L0H1 | 0.521 | 0.193 | 0.450 | 2.31 | 盯住前一位 |
| L0H2 | 0.804 | 0.127 | 0.107 | 3.92 | 弥散(接近均匀) |
| L0H3 | 0.625 | 0.090 | 0.261 | 3.86 | 偏前一位,较分散 |
| L1H0 | 0.614 | 0.276 | 0.182 | 3.16 | 兼顾自己与前一位 |
| L1H1 | 0.687 | 0.259 | 0.233 | 3.07 | 偏前一位,较分散 |
| L1H2 | 0.703 | 0.147 | 0.108 | 4.61 | 无明显模式 |
| L1H3 | 0.871 | 0.165 | 0.134 | 4.47 | 弥散(接近均匀) |
这张表里有两个头的画像非常干净
L0H0:对角 0.769。 它把 77% 的注意力放在自己身上。 换句话说,这个头基本上是个恒等映射——它把 v_i 原样传下去。
这有用吗? 有用。第 2 章讲过残差流:恒等路径是梯度和信息的高速公路。 一个有相当一部分头在做”恒等”的注意力层,等于给模型多留了一条 “什么都不改”的选项。不是每个头都必须”发现某种关系”。
L0H1:前一位 0.450。 将近一半的注意力压在紧邻的上一位上。 这正是本章开头那张图。它的解释很直接:做一个字符级的二元组模型—— “上一个字是什么”是预测下一个字最有效的单条信息。
(对照:一个纯 bigram 模型在同一个语料上的交叉熵是 5.67; 我们的模型是 4.1489。光是”看上一位”这一个头,就吃掉了那条基线的一部分。)
表里最后一列的”判读”是 analyze_heads.py 里一个 label() 函数给的, 规则写死成这样:
if s["diag"] > 0.5: return "盯住自己"
if s["prev"] > 0.35: return "盯住前一位"
if s["entropy"] > 0.8: return "弥散(接近均匀)"
...这些阈值是人为定的(0.5、0.35、0.8),而且没有任何理论依据。
它们的用处是让八个头能被快速分堆,不是给出结论。 L0H0 的对角是 0.769,远超 0.5——所以这个判断很稳。 但 L0H3 的前一位是 0.261,落在”偏前一位,较分散”这一档, 而 L1H1 是 0.233 也被归进同一档——两者差 12%,其实是同一个量级的噪声。
看到阈值分类,先问阈值是怎么来的。
4.3.3 画像表里的另外几个头
4.3 节重点看了 L0H0 和 L0H1。这一节把剩下六个头过一遍—— 因为”没什么可说”本身也是一条信息。
L0H2(熵 0.804、对角 0.127、前一位 0.107、距离 3.92)
四个量都指向”没有明确偏好”。熵 0.804 接近均匀, 对角和前一位都低于随机基线(0.157 和 0.115)。
它在做什么? 从画像上看不出来。可能它在关注某种 画像表没覆盖的模式——比如”句首”“韵脚位置”“下一个字是标点的位置”。
画像表的局限就在这里:四个量是你选的,模型没义务按你的四个量分化。
L0H3(熵 0.625、前一位 0.261)
前一位权重 0.261,是 L0H1(0.450)的一半多一点。 它像是一个”弱化版的 L0H1”。
两个头做类似的事,这在神经网络里很常见—— 冗余是训练的副产品,不是设计。
L1H0、L1H1(对角 0.276 / 0.259,前一位 0.182 / 0.233)
这两个头的画像很像:对角和前一位都在 0.2 上下,熵在 0.6–0.7。 它们的差别在噪声范围内(对角差 0.017,前一位差 0.051)。
但它们的消融 Δ 差了 33%(+0.0301 vs +0.0227)。
画像像的两个头,因果重要性可以不一样。 再一次说明:画像不是因果重要性的预测器。
L1H2(距离 4.61)、L1H3(熵 0.871)
L1H2 的平均距离是八个头里最大的(4.61), L1H3 是最”弥散”的(熵 0.871)。
两个都像是”看得很远、很散”的头。
4.4 这六个头共同的教训
把它们和 4.4 节的消融表并排:
| 头 | 画像的”清晰度” | 消融 Δ |
|---|---|---|
| L0H0 | 极清晰(对角 0.769) | +0.1741 |
| L0H1 | 极清晰(前一位 0.450) | +0.1772 |
| L0H2 | 不清晰 | +0.1414 |
| L0H3 | 中等 | +0.1466 |
| L1H0 | 中等 | +0.0301 |
| L1H1 | 中等 | +0.0227 |
| L1H2 | 不清晰 | +0.0279 |
| L1H3 | 不清晰 | +0.0236 |
看同一层内部:
- 第 0 层:清晰度从”极清晰”到”不清晰”,Δ 从 0.1741 到 0.1414 —— 差 23%
- 第 1 层:清晰度从”中等”到”不清晰”,Δ 从 0.0301 到 0.0227 —— 差 33%
同一层内部,画像清晰度和因果重要性的关系很弱, 而且两个方向的例子都有(L1H2 画像最不清晰,Δ 却排第二)。
跨层就完全不同:第 0 层整体是第 1 层的 6 倍。
结论:想知道一个头重不重要,看它在第几层比看它长什么样有用得多。
4.4.1 平均值掩盖的一个大洞:L0H0 在逗号处
画像表说 L0H0 的”对角权重”是 0.769——八个头里最高的, 所以标签给了”盯住自己”。
现在把它的矩阵打印出来,只挑几行:
| 查询位置 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 山 (1) | .015 | .985 | · | · | · | · | · | · | · | · | · | · |
| 不 (2) | .000 | .000 | 1.000 | · | · | · | · | · | · | · | · | · |
| 見 (3) | .003 | .002 | .003 | .992 | · | · | · | · | · | · | · | · |
| 人 (4) | .020 | .003 | .005 | .029 | .943 | · | · | · | · | · | · | · |
| , (5) | .360 | .138 | .047 | .074 | .266 | .115 | · | · | · | · | · | · |
| 但 (6) | .001 | .000 | .005 | .000 | .003 | .000 | .990 | · | · | · | · | · |
| 聞 (7) | .001 | .002 | .000 | .024 | .001 | .000 | .024 | .948 | · | · | · | · |
| 響 (10) | .002 | .000 | .001 | .008 | .028 | .005 | .033 | .005 | .010 | .211 | .696 | · |
| 。 (11) | .007 | .000 | .002 | .000 | .004 | .010 | .003 | .000 | .000 | .000 | .001 | .972 |
先看”正常”的行:i=1 到 i=4、i=6、i=7、i=11, 对角权重是 0.985、1.000、0.992、0.943、0.990、0.948、0.972。 这个头在这些位置上几乎只做一件事:把前一个位置的表示原样搬过来。
再看第 5 行(,):对角只有 0.115。
同一行里,它把 0.360 给了序列的第一个位置(空),0.266 给了自己,0.138 给了 山。
这不是”盯住自己”的头。在第 5 行上,它是一个完全不同的头。
把上面这些数(再加上没列出的几行)平均一下:
\overline{A_{ii}} = \frac{1.000 + 0.985 + 1.000 + 0.992 + 0.943 + \mathbf{0.115} + 0.990 + 0.948 + 0.797 + 0.894 + 0.696 + 0.972}{12}
分子里唯一的”异类”就是那个 0.115——它一个人把平均值拉低了 0.07 左右, 但即使如此,平均下来还是有 0.769。
一个头在 11 个位置上是”恒等映射”,在 1 个位置上是”回看句首”—— 画像表把它总结成”盯住自己,对角 0.769”。
先声明:下面是一个猜测,本章没有测它。 但它是可测的。
在因果语言模型里,第一个位置(这里是 空)往往变成一个”什么都不看”的出口—— 学界管这个叫注意力汇(attention sink)。 原因不是它有意义,恰恰相反:因为 softmax 必须把权重分完, 模型需要一个”扔垃圾的地方”。
在序列开头造一个”垃圾桶”,比在别处造省事—— 因为它对所有后续位置都可见,而且是语法上无害的位置。
逗号那一行呢?
, 是一个句法上的断点。在这个位置上,“把前一个字符的表示搬过来”没有用—— 前一个字符是 人,而这里需要的是”这一句说完了,下一句要开始”这个信息。
“回看句首”(0.360 给 空)就是一种表达”重置”的方式—— 句首的表示在那 5 个字符里已经被反复搬过(通过 i=1..4 的对角搬运), 所以它携带了”前面那一整句”的摘要。
这是一个自洽的故事。但自洽的故事在可解释性研究里一文不值—— 需要的是证据。
怎么测这个猜测? 三件事: 1. 统计:在整个验证集上,L0H0 在标点位置的对角权重分布 vs 非标点位置。 如果确有分界,两个分布应该明显分离。 2. 因果:只在标点位置屏蔽 L0H0,看 loss 变化。 如果它真在管”句子边界”,标点位置的 loss 应该涨得比别处多。 3. 干预:把句首位置的残差手动改成零向量,再跑一遍, 看标点位置的行为是否受影响。
写这一节的时候,前两条真的去做了。 结果比预想的更有意思——猜测错了一半。
4.4.2 真去测了:L0H0 在标点位置到底重不重要
第一条(统计):把整段验证集跑一遍,按”当前位置是不是标点” 把每个头的对角权重分成两组:
| 头 | 标点位置的对角 | 非标点位置的对角 | 比值 |
|---|---|---|---|
| L0H0 | 0.3549 | 0.7844 | 0.45 |
| L0H1 | 0.4416 | 0.0693 | 6.37 |
| L0H2 | 0.0521 | 0.0580 | 0.90 |
| L0H3 | 0.0371 | 0.0355 | 1.05 |
| L1H0 | 0.2393 | 0.1900 | 1.26 |
| L1H1 | 0.1793 | 0.0944 | 1.90 |
| L1H2 | 0.0327 | 0.0586 | 0.56 |
| L1H3 | 0.0505 | 0.0743 | 0.68 |
(标点位置 n=8760,非标点 n=51720。标点占全部位置的 14.5%。)
L0H0 的 0.45 确认了那个猜测:它在标点位置的对角权重只有别处的一半不到。 “这个头在标点处换了行为”是站得住的,不是那一段文本的偶然。
但真正没想到的是 L0H1:6.37。
在非标点位置,L0H1 的对角权重只有 0.0693——它几乎从不看自己(符合”前一位头”的身份)。 但在标点位置,它有 0.4416 的权重给了自己。
六个多倍的差距,而且是八个头里最大的一个。 画像表说 L0H1 是”盯住前一位”(前一位 0.450、对角 0.090)。 现在你看出来了:那个 0.090 的对角,几乎全部来自标点位置那 14.5%。
把它们放在一起看:
| 头 | 画像表的对角 | 标点位置对角 | 非标点位置对角 |
|---|---|---|---|
| L0H0 | 0.769 | 0.355 | 0.784 |
| L0H1 | 0.090 | 0.442 | 0.069 |
L0H0 的 0.769 主要来自”别处”(0.784 × 0.855 + 0.355 × 0.145 ≈ 0.72,加上 i=0 那行就是 0.769)。
L0H1 的 0.090 反过来:0.442 × 0.145 + 0.069 × 0.855 ≈ 0.12(和 0.090 同量级, 差异来自 i=0 和其他位置的分布不均)。
也就是说:L0H1 那个”有一点对角”的性质,几乎完全是标点位置的贡献。
如果按”非标点位置的行为”给它起名字,它是一个纯正的前一位头(对角 0.069)。 如果按”标点位置的行为”,它有一小半时间在看自己。
画像表把这两种说法平均成”对角 0.090”,读起来像是”基本不看自己”—— 这个描述对 85% 的位置是对的,对剩下 15% 完全错。
第二条(因果):光知道”行为不同”没用,要知道”重不重要”。 做法是把消融按位置切开:
# mask 是 (B, T) 的布尔张量:这个位置的字符是不是标点
# 只在 mask 为真的位置把头输出置零,别处原样放行
y = o.clone()
y[:, :, H*d_head:(H+1)*d_head] = 0.0
y = torch.where(mask[:, :, None], y, o) # 关键:torch.where 按位置选实测结果(基线 loss 4.1489):
| 头 | 只在标点位置消融 | 只在非标点位置消融 | 全部位置消融 |
|---|---|---|---|
| L0H0 | +0.0090 | +0.1623 | +0.1741 |
| L0H1 | +0.0639 | +0.1167 | +0.1772 |
| L1H3 | +0.0029 | +0.0207 | +0.0236 |
我们猜的是:L0H0 在标点位置”回看句首”是一种”句子边界重置”机制, 应该有功能意义。
数据说:把 L0H0 在标点位置的功能整个拿掉, loss 只涨了 0.0090。
而它在非标点位置的那套”恒等搬运”,拿掉要涨 0.1623——是前者的 18 倍。
换句话说:L0H0 那个最引人注目的异常行为,是它最不重要的一部分。
这正是本章反复强调的那件事的一个活标本: “看起来在做什么”和”拿掉之后会怎样”是两个独立的问题。
而且注意:上一节那个猜测本身并不荒谬——“逗号是句法边界”是一个合理的技术直觉。 它只是错了。 而它错了这件事,只有做因果实验才能发现。
上一节那张 12 字符的矩阵里,L0H0 在逗号行的第一项是 0.360(给位置 0)。
刚才这一节在全验证集上测出来的是 0.0228。
差了 16 倍。
原因不难找:那 12 个字符是 空山不見人,但聞人語響。—— 位置 0 是 空,正好是整个序列的开头,语义上就是一个”句首”。
但验证集里的序列是 128 个字符的随机截取,位置 0 往往是半句话中间的一个字。 它的”句首”意义根本不存在。
所以那 0.360 是”位置 0 恰好是句首”这个巧合造出来的, 不是 L0H0 的普遍行为。
这是一条比它想证明的东西更重要的教训:
在读一个例子的时候,你无法知道它是典型的还是特殊的。 只有统计才能告诉你。
本章那个 12 字符的例子不是随便挑的——它是为了”短到能整张打印”而挑的。 而这个挑选标准本身,就把例子推向了不典型。
“为了让例子好看而挑例子”和”为了让例子有代表性而挑例子”, 是两个互相冲突的目标。 前者做展示,后者做测量—— 不要用展示用的例子去支持测量性的结论。
上面那张消融表里,还有一列没解释:“只在标点” + “只在非标点” = “全部”吗?
L0H0:0.0090 + 0.1623 = 0.1713,而全部位置是 0.1741。差 0.0028。 L0H1:0.0639 + 0.1167 = 0.1806,而全部位置是 0.1772。差 −0.0034。 L1H3:0.0029 + 0.0207 = 0.0236,而全部位置是 0.0236。差 0.0000。
(答案:三个头给出了三种不同的”可加性”。
- L0H0 是超可加(分开拿掉的损失之和 < 一起拿掉):两处的功能有重叠, 一起拿掉时”没有备胎”了
- L0H1 是次可加(分开之和 > 一起拿掉):两处的功能互相依赖, 单独拿掉一处时另一处还能顶上,一起拿掉反而”省了”一点
- L1H3 完全可加:两处独立,互不影响
把”全部位置消融”拆成”标点 + 非标点”,本质上是一次二分组的交互检验。 这和 4.4.4 节成对消融测的是同一件事——只是分组方式不同 (那里按”头”分组,这里按”位置”分组)。
三个差值(+0.0028、−0.0034、0.0000)都很小,都在噪声量级附近 (4.4.5 节估过 Δ 的噪声大约在 0.003 这个量级)。 所以这里能说的只有”没有大的交互”,不能说”L1H3 的位置之间严格独立”。)
这一节的实际长度比原计划多了一倍,但值得:它把一个”我们有三个猜测可惜没测” 的段落,变成了一次猜测被数据修正的完整示范。
被修正的猜测本身比正确的猜测更有教学价值—— 因为它是你自己也会做出来的那种猜测。
如果一个头的异常只出现在少数位置上,那它一定不会被平均值捕捉到。
统计量(均值、方差)是对分布做压缩。压缩总是有损的。 损失多少,取决于分布有多”不规矩”。
一个具体的判断规则:算一个头的画像时,顺手算一下四个量的”逐位置标准差”。
per_pos = [compute_metrics(w, i) for i in range(T)] # 每个位置单独算
for k in ("diag", "prev", "dist"):
vals = [p[k] for p in per_pos[1:]] # 跳过 i=0
print(f"{k}: 均值 {mean(vals):.3f} 标准差 {std(vals):.3f} "
f"最小 {min(vals):.3f} 最大 {max(vals):.3f}")L0H0 的对角项会给出:均值 0.769、最小 0.115、最大 1.000。
看到”最小 0.115”和”最大 1.000”同时出现,你就知道平均值不可信了。
这一步几乎不花时间(数据都已经在手里了), 但它能挡掉本章后面 4.5 节里专门列出来的那一类错误—— “把一个头的行为当成一个数”。
4.4.3 一个被画像表漏掉的东西:头之间的相似度
画像表给每个头四个数,但它不告诉你两个头像不像。
L1H0 和 L1H1 的画像很接近。问题是:它们是在做同一件事,还是在做两件事?
画像接近 ≠ 行为接近。 一个头可能在”看前一位的名词”, 另一个在”看前一位的动词”——它们的平均前一位权重可能一样, 做的事情完全不同。
要测这个,得看它们的注意力矩阵之间的相关性:
# 把每个头在所有位置上的注意力向量拉平,算两两的余弦相似度
flat = {k: A[k].reshape(-1) for k in heads} # 每个头一个长向量
sim = {(a, b): F.cosine_similarity(flat[a], flat[b], dim=0)
for a, b in combinations(heads, 2)}相似度接近 1 的两个头,在每一个位置上给出的分布都一样—— 它们是冗余的,拿掉一个另一个能完全顶上。
这一节用真实数据走一遍。 把所有 8 个头的注意力矩阵拉平,两两算余弦相似度。 数据来自本章的三段文本,用 qa/ 之外的脚本 notebooks/run_ch4_case.py 同款加载方式测得:
| 头对 | 相似度 | 画像 |
|---|---|---|
| L0H2 — L1H3 | +0.716 | 两个最弥散的头 |
| L0H2 — L0H3 | +0.630 | 弥散 + 偏前一位 |
| L0H1 — L1H1 | +0.598 | 两个”前一位”头 |
| L1H2 — L1H3 | +0.586 | 都弥散 |
| L1H1 — L1H3 | +0.563 | |
| L1H0 — L1H3 | +0.561 | |
| L0H1 — L0H3 | +0.553 | |
| L0H2 — L1H1 | +0.541 | |
| ⋮ | ||
| L0H0 — L0H2 | +0.258 | 对角头 vs 弥散头 |
| L0H0 — L0H1 | +0.181 | 对角头 vs 前一位头 |
| L0H0 — L0H3 | +0.145 | 最低的一对 |
这张表说了三件事。
一、最高的 +0.716 也不高。 余弦相似度 0.716 对应的”夹角”大约是 44°。 没有任何一对头是”几乎一样”的——不存在完全冗余的两个头。
这一点和 4.4.4 节的成对消融是吻合的: 如果真有两个头完全冗余,同时拿掉它们的效果应该等于分别拿掉的效果之和(零相互作用)。 实测的 28 对全部是负相互作用——没有一对是冗余的。
二、相似度有明显的层次。 0.716 / 0.630 / 0.598 … 一路降到 0.541,然后断崖式掉到 0.258。
中间那道沟的位置很说明问题: 沟的上方全是”弥散头之间”或”弥散头与前一位头之间”, 沟的下方全是”L0H0 与别人”。
L0H0 是八个头里唯一一个”另一种东西”。
三、相似度矩阵把 8 个头分成了两群,但群内还有结构。 如果只按”对角 vs 非对角”分,应该是 1 vs 7。 但实际上 L0H0 和 L0H2 的 0.258 明显高于它和 L0H3 的 0.145。
这说明 L0H0 和 L0H2 之间有一点点共同的东西—— 回看 L0H2 的画像(熵 0.804、对角 0.127), 它对角不高,但那 0.127 是八个头里第二高的(除了 L0H0 的 0.769)。 这 0.127 就是那 0.258 的来源。
上面说”没有一对头是冗余的”。那多头机制到底在干什么?
如果 8 个头各干各的,为什么不干脆用一个 8 倍宽的头?
(答案:因为”一个 8 倍宽的头”和”8 个头”在数学上不等价。
一个宽度 8 d_h 的头只有一个 softmax 分布—— 它在每个位置上只能选一种”看哪里”的方案。
8 个头有 8 个独立的 softmax 分布—— 每个位置上可以同时用 8 种不同的方式分配注意力。
在本章的数据里,这个区别是可见的: 同一个位置 i=10(響), L0H0 把 0.696 给自己,L0H1 把 0.592 给前一位, L1H3 把 0.070 给自己、0.175 给 j=6。
三种完全不同的分配方式,在同一个位置上同时发生。
这就是第 1 章”多头为什么有用”那个问题的实测版本—— 当时是从”子空间”的角度讲的,这里是从”注意力分配”的角度看到的同一件事。
这正是 4.4.4 节成对消融测到的东西的一个”静态版本”。 两者的区别是:相似度是看出来的,相互作用是测出来的。 如果两者一致,说明你的解释站得住;不一致,说明还有没看到的机制。
上面说”画像接近 ≠ 行为接近”。反过来呢? 两个头的行为(注意力矩阵)高度相关,它们的画像一定接近吗?
(答案:一定接近。
画像是注意力矩阵的四个统计量(熵、对角、前一位、距离)。 如果两个矩阵逐元素都几乎一样,它们的任何统计量当然也一样。
所以”行为接近 ⟹ 画像接近”是成立的,“画像接近 ⟹ 行为接近”不成立。 这是一个单向蕴含。
这个不对称很重要:它说明画像表是一个”粗筛”工具—— 画像差很远的头,行为一定差很远(可以用来快速排除); 但画像接近的头,还需要进一步测行为才能判断。
4.4.4 熵也不是一个数:它随位置变化
画像表给每个头一个”归一化熵”。归一化的意思是除以 \log(i+1)—— 这样不同长度的位置之间才能比较(因为可看的位置数不一样)。
但”能比较”不等于”一样”。 把三段文本按 5 个位置一组分开平均:
| 头 | 位置 0–4 | 5–9 | 10–14 | 15–19 | 20–24 |
|---|---|---|---|---|---|
| L0H0 | 0.199 | 0.429 | 0.192 | 0.413 | 0.271 |
| L0H1 | 0.729 | 0.469 | 0.511 | 0.439 | 0.625 |
| L0H2 | 0.951 | 0.804 | 0.840 | 0.808 | 0.810 |
| L0H3 | 0.656 | 0.694 | 0.624 | 0.636 | 0.652 |
| L1H0 | 0.864 | 0.523 | 0.681 | 0.564 | 0.607 |
| L1H1 | 0.950 | 0.776 | 0.751 | 0.502 | 0.636 |
| L1H2 | 0.961 | 0.703 | 0.879 | 0.511 | 0.636 |
| L1H3 | 0.984 | 0.955 | 0.918 | 0.846 | 0.845 |
这张表比画像表有意思得多,因为它暴露了三件事。
一、位置 0–4 那一列普遍偏高,L1H3 甚至到了 0.984。
为什么? 前几个位置几乎没有信息可用——只有 1、2、3 个前文可看。 归一化熵除以 \log(i+1) 之后,如果模型在”没信息时”倾向于接近均匀分配, 这一段就会给出接近 1 的数。
L1H3 的 0.984 意味着:在前 5 个位置上,它基本上是均匀的。
二、L0H0 那一行是震荡的:0.199 → 0.429 → 0.192 → 0.413 → 0.271。
这个震荡不是噪声。 位置 0–4 和 10–14 是低值,5–9 和 15–19 是高值。
注意这两组的分界:五言诗的标点在位置 5、11、17……附近。 低值段(0–4、10–14)落在”一句的前半”,高值段(5–9、15–19)落在”一句的后半”—— 而后半里包含标点位置。
这和上一节那个”L0H0 在逗号处对角只有 0.115”完全对得上: 标点位置(以及它附近)把这一段的平均熵拉高了。
一个头的画像表里没有”位置”这一维,但它可能恰恰是随位置变化的。
三、L1H3 是唯一一个单调下降的:0.984 → 0.955 → 0.918 → 0.846 → 0.845。
单调下降 = 序列越长,它越”集中”。
这有点反直觉:位置越靠后,可看的东西越多,按理说更有挑的余地, 但也更可能”挑花眼”(熵升高)。L1H3 反过来。
一个可能的解释:位置靠后时,位置 0(那个”垃圾桶”)的可达性变成了一个稳定选项—— 每个头都可以把一部分权重给位置 0 来”减负”。 但这一章没有测这个,所以它只是解释,不是结论。
上面这张表每一格的平均样本数不同。
位置 0–4 那一列,每个序列只有 5 个位置(其中 i=0 被跳过,剩下 4 个)。 位置 20–24 也是 5 个。看起来是对称的,但实际不是——
因为序列长度不同。 本章的 block_size 是 128, 但三段文本最短的(空山不見人,但聞人語響。)只有 12 个字符。 它根本到不了位置 20–24。
所以”位置 20–24”这一列的样本,只来自最长的那段文本。
这是”分组统计”里最常见的一个坑: 分组之后,每一组的样本来源可能不一样。 如果长文本和短文本在别的方面有系统性差异,这个差异就会被读成”位置效应”。
怎么避免? 只统计那些所有样本都覆盖到的位置, 或者按序列长度分层之后再比。
本章这张表没有做这个处理。 所以上面那三条观察里, 第一条(位置 0–4 偏高)是可靠的(所有序列都覆盖), 第二条和第三条要打折扣——它们可能混入了”文本长度”的影响。
上面说”位置 20–24 只来自最长的那段文本”。
本章的 head_profile 函数(4.3 节开头那份代码)把三段文本的所有位置都扔进了一个池子, 然后算平均。
这个做法有同样的毛病吗?
(答案:有,而且更隐蔽。
head_profile 算的是”所有位置的平均”,不做位置分组。 看起来这样就绕开了”分组样本不均”的问题——其实没有。
因为长文本对平均值的贡献更大。 一段 128 字符的文本贡献 128 个位置, 一段 12 字符的文本只贡献 12 个。前者的话语权是后者的 10 倍以上。
所以画像表里的”平均距离 1.20”这类数字,实际上是”以长文本为主”的平均。
更根本的问题是:平均距离天然随序列长度增长(可看的距离变远), 所以把一个 12 字符文本的平均距离和一个 128 字符文本的平均距离放在一起平均, 本身就是把两个不可比的量相加。
严谨的做法:把距离归一化(除以位置 i),或者只统计固定长度的前缀。
本章没有做。 这不影响”L0H0 对角高、L1H3 熵高”这类结论—— 因为那个差距(0.769 vs 0.165)远大于这个偏差的量级—— 但画像表里那些小数点后两位的数字,不该被当成精确值。)
4.5 因果检验:把它拿掉试试
4.5.1 为什么”看图说话”不够
注意力图给你的是相关:位置 i 从位置 j 取了信息。 你要的结论是因果:这条信息对模型的输出起了作用。
这两件事在一般情况下不等价,而在神经网络里尤其容易分开,原因有三个。
原因一:冗余。 同一个信息可以从多条路走。第 2 章讲过残差流—— 每个位置的信息本来就沿着残差流一路传到最后一层。注意力头只是额外开了一条路。 如果原来的路已经够用,去掉这条新的路,输出几乎不变。
原因二:下游会重算。 后面的层可以重新算一遍前面已经算过的东西。 第 0 层的头输出被抹掉,第 1 层的头仍然能从残差流里拿到原始信息。
原因三:v_j 可能是零。 4.1 节讲过的极端情况—— 权重很高,但取过来的东西是零向量。
三个原因指向同一个实验:把这条路由切断,看输出变不变。 变,说明它在起作用;不变,说明它(在当前输入分布下)是冗余的。
上面的”原因三”说:v_j 可能是零向量,所以权重高不等于有作用。
但如果某个头对所有位置算出的 v_j 都接近零,那它的输出就接近零。 这样的话,把它拿掉显然没影响——这个头是不是”没用”?
(答案:“没用”要分两问。
第一问:这个头对当前这个模型没用吗? 是的,拿掉没影响。
第二问:这个头是训练出来的、还是碰巧变成这样的? 这才是关键。
一个恒等输出零的头,可能是训练把它推到了这个状态(那它是在”主动不做事”), 也可能是初始化后就没动过。区分方法:看它在训练过程中怎么演化。
第 2 章讲过”死神经元”——前馈网络里有些单元从不激活。 注意力头也有类似现象,原因不同但表现一样。 “拿掉没影响”只说明冗余,不说明它是多余的。)
4.5.2 怎么做:屏蔽一个头
屏蔽一个头(head ablation)听上去简单:把它的注意力权重置零不就行了?
不行。 如果只把 A 的某一行置零,那一行加起来不等于 1 了, softmax 的归一化被破坏,下游的数全乱。
正确做法是在输出投影之前,把这个头占的那一段特征切片置零:
def ablate_head(model, layer, head):
d_head = model.blocks[layer].attn.d_head
def hook(mod, inp, out):
# proj 的输入是 (B, T, C) —— 头维度已经被 transpose+view 拼回特征维了。
# 所以不能写 y[:, :, head, :](那是 4 维时的写法),
# 要把该头对应的那一段特征切片置零。
y = (out[0] if isinstance(out, tuple) else out).clone()
y[:, :, head * d_head:(head + 1) * d_head] = 0.0
return (y,) + out[1:] if isinstance(out, tuple) else y
return model.blocks[layer].attn.proj.register_forward_hook(hook)注意注释里那两行。 这个 hook 的第一版我们写成了 y[:, :, head, :] = 0.0——那是 4 维张量((B, h, T, d_head))的写法。 但 proj 拿到的已经是 (B, T, C),因为注意力在返回之前做了 transpose(1, 2).contiguous().view(B, T, C)。
结果是 IndexError: too many indices for tensor of dimension 3。
这个错误为什么值得单独讲
它和我们第 1 章、第 3 章踩过的坑是同一类:形状对不上。
但这次有一个额外的细节:注意力头在代码里”消失”过一次。
在 forward 里,头的维度是这样走的:
(B, T, C) → qkv 投影 → (B, T, 3C)
→ view+transpose → (B, h, T, d_head) ← 头是独立的维度
→ 注意力计算 → (B, h, T, d_head)
→ transpose+view → (B, T, C) ← 头被拼回特征维
→ proj → (B, T, C)
头只在中段是一个独立维度。 到了输出投影这一步,它已经变成了 特征维里的一段连续区间——第 head 个头占的是 [head*d_head, (head+1)*d_head)。
所以”屏蔽一个头”在代码层面有两个正确的时机: 在中段(4 维时按索引置零),或者在 proj 之前(3 维时按切片置零)。 选哪个都行,但必须知道自己站在哪一段。
对照第 3 章那个 kv_cache 的 dim=-2——同一类问题的另一次出现。
4.5.3 实测:八个头的因果重要性
基线 loss(验证集) = 4.1489
去掉 L0H0 loss 4.3230 Δ +0.1741
去掉 L0H1 loss 4.3261 Δ +0.1772
去掉 L0H2 loss 4.2903 Δ +0.1414
去掉 L0H3 loss 4.2955 Δ +0.1466
去掉 L1H0 loss 4.1790 Δ +0.0301
去掉 L1H1 loss 4.1717 Δ +0.0227
去掉 L1H2 loss 4.1769 Δ +0.0279
去掉 L1H3 loss 4.1725 Δ +0.0236
把两层的均值算出来:
| 层 | 四个头的 Δ | 均值 |
|---|---|---|
| 第 0 层 | +0.1741, +0.1772, +0.1414, +0.1466 | +0.1598 |
| 第 1 层 | +0.0301, +0.0227, +0.0279, +0.0236 | +0.0261 |
第 0 层的头比第 1 层的头重要 6.1 倍。
这个结果和”看图说话”的结论对不上
回到 4.3 节那张画像表。如果按”画像有多清晰”排序, 最该重要的应该是 L0H1(前一位 0.450,pattern 最明确)和 L0H0(对角 0.769,最极端的偏好)。
实测:它们确实是第 0 层里 Δ 最大的两个(+0.1772 和 +0.1741)。 这一半对上了。
但对不上的是另一半:L1H3 的画像最”没有模式”(熵 0.871,接近均匀), 看起来最该无关紧要——可它拿掉之后 Δ = +0.0236, 比同层画像清晰得多的 L1H0(对角 0.276、前一位 0.182)的 +0.0301 只小一点点。
同一层内部,四个头的 Δ 挤在 0.0227 到 0.0301 这个窄区间里, 和它们的画像清晰度没有明显关系。
层与层之间的差别(6 倍)远大于层内头与头之间的差别(1.3 倍)。
这是本章第一个真正的发现:在这个模型里,“哪个头重要”主要取决于它在第几层, 而不是它长什么样。
上面说”第 0 层比第 1 层重要 6 倍”。这个结论只在当前这个模型、 这个任务、这个数据分布上成立。
三个限制:
- 模型只有 2 层。 “第 0 层”和”第 1 层”在这个模型里的角色是完全不同的: 第 0 层直接处理词嵌入,第 1 层处理已经混合过的表示。 在 12 层、24 层的模型上,这个 6 倍的结论没有任何理由继续成立。
- loss 是一个很粗的指标。 Δ = +0.03 在 loss 上很小, 但它可能对应着生成行为的明显变化——只是平均 loss 把它抹平了。
- 消融是”一次一个”。 同时拿掉两个头,效果可能大于两次单独之和 (相互作用),也可能小于(互相替代)。单头消融测不出这件事, 见 4.9 节。
这三条限制不是免责声明,是结论的一部分。
4.5.4 Δ 只有 0.03,那是什么样的行为变化?
loss 的绝对值不好感知。直接看生成结果。 基线文本是:
春江潮水海平。不見昔年年不見,不見春風吹。不見君不得
去掉 L1H3(Δ 最小之一,+0.0236)之后:
春江潮水海平。不見昔年年不見,不見春風吹
只少了 6 个字。 前 20 个 token 一模一样, 分叉发生在第 21 个 token 上——而且分叉之后,输出只是停在了同一个循环里 (还记得第 3 章那个「不見」循环吗)。
这说明了 loss 指标的一个特点
Δ = +0.0236 对应 6 个 token 的分叉。看起来”很小”。
但注意:这 6 个 token 的分叉,是因为模型走进了两个都”说得通”的循环。 它本来就退化(第 3 章),所以”换个循环”对 loss 的影响很小。
在一个没有退化的模型上,同样的 Δ 可能对应完全不同的行为。
这又回到 4.4.3 节那条限制:loss 是平均量,它对”换了一条同样差的路”不敏感。 要测行为,就得真的跑生成,看输出。
上面的消融用的是平均 loss。它有两个缺点:抹平局部效应、对退化不敏感。
更有信息量的做法是看逐位置的损失变化:
# 分别记下基线和消融后每个位置的 loss,看差异集中在哪儿
delta_per_pos = (loss_ablated - loss_base) # 形状 (T,)
# 如果差异集中在标点位置 → 这个头在管句法
# 如果差异均匀分布 → 这个头在管全局的字符分布
# 如果只在少数位置有大尖峰 → 这个头只在特定上下文里起作用“平均 Δ 很小”和”在某些位置 Δ 很大、在别处是零”是两种完全不同的机制, 但平均之后看不出区别。
4.5.5 那 6 倍是从哪来的:输出范数
4.4 节留了一个问题没有回答:为什么第 0 层的头比第 1 层的头重要 6 倍?
有两个可能的解释:
解释 A:第 0 层在做更基础的工作。 它直接面对词嵌入, 算的是”相邻字符的共现”这种底层统计量;第 1 层在已经混合过的表示上做事, 可替代性更高。
解释 B:第 0 层的头输出本来就更大。 拿掉一个输出量大的东西, 影响自然大——这和它”做的工作是否基础”无关,纯粹是个尺度问题。
这两个解释指向不同的结论(A 是机制性的,B 是平凡的), 而它们可以被一个测量分开:量每个头输出的 L2 范数。
头 ‖out‖ 头 ‖out‖
L0H0 6.1320 L1H0 3.3807
L0H1 5.4026 L1H1 3.3128
L0H2 5.9004 L1H2 3.4974
L0H3 5.5450 L1H3 3.4428
─────────────────────────────────────
第 0 层均值 5.745 第 1 层均值 3.408
第 0 层的输出范数是第 1 层的 1.69 倍。
4.6 1.69 倍 vs 6.1 倍:两个解释都占一部分
把两组数放在一起:
| 量 | 第 0 层 / 第 1 层 |
|---|---|
| 消融 Δ 均值 | 6.1 倍 |
| 输出范数均值 | 1.69 倍 |
范数差解释不了 6.1 倍。 如果消融的效果纯粹是尺度问题, 那两者应该差不多。
所以解释 A 也成立:第 0 层不只输出更大,它输出的东西还更”不可替代”。
粗略地看:如果损失对那个头的依赖是”超线性”的 (也就是范数大一倍,作用大不止一倍),那 6.1 / 1.69 ≈ 3.6 就是那个超线性的指数。 这是一个观察,不是一个定律——它能成立是因为这个模型恰好这么小、 这么浅。但它至少排除了”纯尺度解释”。
上面说”没有死头”——最小的输出范数是 3.31,不接近零。
但 3.31 这个数本身有意义吗? 怎么知道 3.31 算”小”还是”大”?
(答案:要和残差流的尺度比。
第 2 章讲过残差流:x \leftarrow x + \text{Attn}(\cdot)。 如果一个头的输出范数是 3.31,而残差流本身的范数是 30, 那这个头只贡献了 10%——它的影响会被残差流的”底噪”淹没。
正确做法是把头的输出范数除以残差流的范数, 得到一个”相对贡献”。这个比值才是”这个头有多大声”的度量。
本章没有做这个归一化——这是画像表的一个已知缺口。 4.3 节末尾提到过类似的问题:绝对量必须配基线才能读。)
4.6.1 成对消融:头与头之间的相互作用
单头消融有一个根本局限:它测不出头与头之间的关系。
考虑一个极端:头 A 和头 B 在做同一件事。单独拿掉任何一个, 另一个顶上,Δ 都很小。看起来两个都不重要。 但同时拿掉两个,模型可能直接崩掉。
测法很简单:对每一组 (i, j),比较
\Delta_{ij} \quad \text{vs} \quad \Delta_i + \Delta_j \tag{4.9}
所以这个式子在说:如果两个头互不影响,一起拿掉的效果应该等于 分别拿掉的效果之和。差值就是”相互作用”。
本模型 8 个头,\binom{8}{2} = 28 组,全跑一遍。
结果:28 组的相互作用全部是负的。
| 组合 | 实际 Δ | 线性相加 | 相互作用 |
|---|---|---|---|
| L0H0 + L1H0 | +0.1732 | +0.2041 | −0.0309 |
| L0H2 + L1H2 | +0.1537 | +0.1693 | −0.0156 |
| L0H1 + L1H3 | +0.1876 | +0.2007 | −0.0131 |
| L0H0 + L1H1 | +0.1856 | +0.1968 | −0.0112 |
| L0H1 + L1H1 | +0.1893 | +0.1999 | −0.0106 |
4.7 “全部为负”意味着什么
负的相互作用 = 实际损失小于线性相加 = 两个头之间存在冗余。
这在直觉上完全合理:头之间共享残差流,一条路断了信息还能从别处走。 但方向一致(28 组全负)比单个数值更有说服力—— 它说明这不是偶发,而是这个架构的系统性性质。
最值得看的是第一行:L0H0 + L1H0。
单独拿掉 L0H0,Δ = +0.1741。 单独拿掉 L1H0,Δ = +0.0301。 线性相加应该是 +0.2041。
实测:+0.1732。
也就是说——同时拿掉两个,损失比只拿掉 L0H0 还小一点点(+0.1732 vs +0.1741)。
结论:L1H0 在 L0H0 被拿掉之后,变得完全无用。
这暗示了一个依赖关系:L1H0 是在消费 L0H0 产出或搬运的东西。 L0H0 一断,L1H0 就没有原料了,它在不在都一样。
单头消融完全看不出这件事。 单看数据,L1H0 的 Δ = +0.0301, 你会以为它是一个”有点作用的普通头”。成对消融才暴露出它是下游。
上面的”L1H0 依赖 L0H0”是一个从消融数据推出来的假设, 不是被直接观测到的机制。
能直接观测到的是:\Delta_{00,10} \approx \Delta_{00}。
这个现象还有别的解释:
- 饱和:损失有上限(最差也就是均匀分布,5.67), L0H0 已经把它推到接近上限,再拿掉 L1H0 也加不了多少
- L1H0 在做冗余的事,而这件事的另一个提供者恰好也依赖 L0H0
- 纯粹的数值巧合(+0.1732 和 +0.1741 差 0.0009, 而单次评估的噪声可能有这个量级)
要区分这几种,需要看逐位置的数据、看激活的相关性、 或者做一个更细的干预实验。 本章只做到”指出这个现象”这一步。
把”观测到什么”和”推断出什么”分开写,是这一章反复在做的事。
4.7.1 逐位置看:平均值掩盖了什么
最后的这一组数据最值得琢磨。
单头消融报的是”Δ = +0.0236”这样的一个数。这个数是怎么来的? 是在所有位置上取的平均。
把平均拆开,看每个位置的 Δ:
位置 0 1 2 3 4 5 6 7 8 9 10 11
Δ(L1H3) -0.006 -0.020 +0.026 +0.038 +0.012 +0.017 +0.028 -0.001 -0.005 -0.007 +0.035 +0.031
均值 +0.0236,最大 +0.0643,最小 −0.0198, 位置间的标准差 0.0175。
4.8 标准差和均值是一个量级
均值 +0.0236,标准差 0.0175。 两个数差不多大。
这在统计上意味着:这个”平均效应”的可靠性很低。 不同位置的 Δ 从 −0.02 到 +0.06,横跨了四倍的均值范围。
更值得注意的是有负值。 在位置 1(Δ = −0.0198)上, 拿掉 L1H3 反而让模型的损失变小了。
这不是测量误差——这是说:在这个特定的上下文里,L1H3 的输出是有害的。 它把模型往错误的方向推了一点,拿掉它反而更好。
一个”平均有用”的头,在某些位置上是有害的。 “这个头重不重要”这个问题,在位置这个维度上根本没有单一答案。
最后一行数据:只有 9% 的位置的 Δ 超过均值的两倍。 说明绝大多数位置上,这个头的影响可以忽略—— 平均出来的 +0.0236 是被少数位置拉上去的。
报告一个平均 Δ 是不够的。 至少应该同时给出:
- Δ 的分布(不只是均值)
- 负值的比例(有多少位置拿掉反而更好)
- 集中的程度(效应是均匀的,还是集中在少数位置)
这三条能区分出完全不同的机制:
| 分布形态 | 可能的机制 |
|---|---|
| 均匀的小正值 | 这个头在稳定地提供一点边际信息 |
| 少数位置大正值,其余接近 0 | 这个头是条件触发的(只在特定上下文工作) |
| 正负混杂 | 这个头在多个方向上拉扯,净效应是正值 |
本章的 L1H3 属于第三种。
4.8.1 消融实验的统计学:Δ 要多大才算数
4.4 节报了 Δ 值,但没有说这些 Δ 的噪声有多大。 这是一个必须回答的问题——否则你不知道 0.0227 和 0.0301 的差别是真的还是噪声。
测法:重复评估。
验证集 loss 是用随机采样的 20 个 batch 估出来的。每次换一个随机种子, 估计值就会变。把同一组权重评估 10 次,看这个估计值波动多少。
def eval_loss_repeated(model, val, cfg, n_rep=10, n_batch=20):
vals = []
for seed in range(n_rep):
gen = torch.Generator(device=DEV).manual_seed(seed * 1000)
vals.append(eval_loss(model, val, cfg, n_batch=n_batch, gen=gen))
return {"mean": sum(vals)/len(vals),
"std": (sum((v - sum(vals)/len(vals))**2 for v in vals)/len(vals))**0.5,
"min": min(vals), "max": max(vals)}4.9 那么两个 Δ 的差别什么时候可信
判断规则:两个 Δ 的差,要大于两者的合并标准差,才算”有差别”。
设单次评估的标准差是 \sigma。要区分 \Delta_a 和 \Delta_b, 需要
|\Delta_a - \Delta_b| \gtrsim 2\sqrt{2}\,\sigma \tag{4.10}
所以这个式子在说:两个估计值的差,要大于它们各自不确定度的 \sqrt{2} 倍的 2 倍。
这正是本章一个已知的不足:我们报了 8 个头 × 严格的固定种子评估 (每次都用同样的随机种子生成同样的 batch), 所以那个”位置间的标准差”不是评估噪声,是真实的效应差异。
但不同头之间的 Δ 比较(比如 L1H0 的 +0.0301 和 L1H1 的 +0.0227), 在不知道 \sigma 的情况下不能断言”L1H0 更重要”—— 差 0.0074 完全可能是评估噪声。
能断言的是:第 0 层(均值 0.1598)和第 1 层(均值 0.0261)的差别—— 6 倍,远超任何合理的噪声水平。
上面说”不同头之间的 Δ 差别 0.0074 不能断言”。
但本章 4.4.4 节又断言了”L1H0 依赖 L0H0”,依据是 \Delta_{00,10} = 0.1732 \approx \Delta_{00} = 0.1741(差 0.0009)。
这不是自相矛盾吗?
(答案:不是,因为两个断言的逻辑不同。
“L1H0 比 L1H1 更重要”是一个比较断言—— 它需要证明两个数的差是真的。
“L1H0 依赖 L0H0”是一个等价断言—— 它说的是”加进 L1H0 的消融之后,Δ 几乎没有变化”。
等价断言的证据强度更高:即使有噪声, “从 0.1741 到 0.1732” 这个变化(0.0009)也远小于 “从 0.1741 到 0.2041”(如果两者独立,应该是这个值)。
换句话说:我们不是在比较两个测量值, 而是在比较”实际值”和”一个理论预测值”。 后者的差别(0.0309)够大,可以下结论。
4.9.1 一个被反复用到的方法:随机种子固定
本章所有的消融数字都用了固定的随机种子来生成评估 batch:
gen = torch.Generator(device=DEV).manual_seed(0) # 固定为什么? 因为这样基线 loss 和消融后的 loss 是在同一批数据上算的。 两者的差就不含”batch 不同”这个噪声源。
4.10 固定种子的代价
好处:Δ 更干净。同一批数据,只有模型变了。
代价:\sigma 测不出来——因为你永远用同一批数据, 同一批数据上的差是确定的,波动是 0。
所以”固定种子”和”测噪声”是两件互斥的事: - 要干净的 Δ → 固定种子 - 要知道 Δ 的可信区间 → 换种子
正确做法是两个都做:固定种子报 Δ,换种子报 \sigma。 本章只做了前者,这是 4.10 节那张结论强度表里”缺对照”那一栏的一个具体内容。
这类”做了一个正确的选择,但只做了一半”的情况, 在实验设计里非常常见。
4.10.1 头之间到底差多少:一个更细的看法
把 4.4 节的消融 Δ 和 4.8 节要讲的评估噪声放在一起看, 可以得到一个更保守但更可靠的结论:
| 比较 | 差别 | 能不能断言 |
|---|---|---|
| 第 0 层 vs 第 1 层 | 6.1 倍 | 能 |
| 同层内 L0H0 vs L0H3 | 0.1741 vs 0.1466(19%) | 存疑 |
| 同层内 L1H0 vs L1H1 | 0.0301 vs 0.0227(33%) | 存疑 |
能断言的只有跨层那一句。 同层内部的排序, 在没有噪声估计的情况下不能说。
而这句话本身已经足够有用了: “在这个模型里,第 0 层的注意力头比第 1 层重要得多”—— 这是一个可复现、有量级差别、不依赖细微比较的结论。
这是本书反复出现的取舍:结论的精度和可靠性是两回事。
- “第 0 层比第 1 层重要 6 倍”——粗糙,但可靠
- “L0H0 比 L1H1 重要 7.7 倍”——精确,但不可靠(可能是噪声)
写论文的时候后者更吸引人,做工程的时候前者更有用。
本书的选择是:报可靠的,把不可靠的标出来当”待办”。
4.11 对照实验:随机模型长什么样
4.11.1 一个必须先问的问题
4.3 节那张画像表里,八个头的熵从 0.294 到 0.871,跨度很大; 对角从 0.090 到 0.769,跨度更大。
这些数字说明模型学到了东西吗?
要回答这个,必须有一个对照:随机初始化的同结构模型,画像是什么样的?
如果不训练的模型也能给出类似范围的数字,那”画像”就没有信息量—— 你在一张随机噪声里也能解读出故事。
4.11.2 实测:随机的头几乎一模一样
用同样的结构(2 层 4 头 128 维),只把权重随机初始化,跑同样的画像:
seed1000 L0H0 熵 0.958 对角 0.158 前一位 0.115 弥散(接近均匀)
seed1000 L0H1 熵 0.958 对角 0.157 前一位 0.116 弥散(接近均匀)
seed1000 L0H2 熵 0.958 对角 0.157 前一位 0.115 弥散(接近均匀)
seed1000 L0H3 熵 0.958 对角 0.158 前一位 0.115 弥散(接近均匀)
seed1000 L1H0 熵 0.958 对角 0.156 前一位 0.116 弥散(接近均匀)
seed1000 L1H1 熵 0.958 对角 0.158 前一位 0.115 弥散(接近均匀)
seed1000 L1H2 熵 0.958 对角 0.158 前一位 0.116 弥散(接近均匀)
seed1000 L1H3 熵 0.958 对角 0.158 前一位 0.115 弥散(接近均匀)
八个头的数字几乎完全一样,熵全部是 0.9578–0.9579, 对角全部是 0.156–0.158。换成别的随机种子,结论一样。
这条对照线的价值
把训练后的和随机的并排放:
| 量 | 训练后 | 随机 | 差别 |
|---|---|---|---|
| 熵的范围 | 0.294 – 0.871 | 0.9578 – 0.9579 | 训练把方差放大了几百倍 |
| 对角的范围 | 0.090 – 0.769 | 0.156 – 0.158 | 同上 |
训练前,八个头是一个模子刻出来的;训练后,它们分化成了完全不同的角色。
这就是“注意力头分化”(head specialization)的直接证据。 在没有这条对照线之前,你看到 L0H0 的对角 0.769,不知道该不该惊讶。 有了它,才知道 0.769 是”训练特意造出来的”。
随机模型的对角是 0.157。如果注意力完全均匀,位置 i 的对角应该是 1/(i+1)。
拿 i 从 0 到 23 平均一下,1/(i+1) 的均值大约是 0.15 —— 和实测的 0.157 几乎一致。
所以随机模型的对角 0.157 一点都不神秘:它就是”均匀注意力的几何平均值”。
那么:如果一个人的画像表里只有训练后的数字,没有这条对照线, 他看到”对角 0.157”会不会以为这是一个”盯住自己”的头?
(答案:很可能会。0.157 放在 4.3 节那张表里,比 L1H2 的 0.147 还高—— 按那张表的标准,它会被判成”兼顾自己与前一位”。
但它其实是纯粹的均匀分布。
这就是为什么对照实验不是可选项。没有它, 任何”阈值判读”都在拿随机噪声当信号。)
有一件事要澄清:随机模型的八个头数字一样, 不代表随机注意力本身是均匀的。
因果掩码决定了位置 i 只能看 i+1 个位置——这个几何约束在随机权重下依然存在。 具体说:
- 位置 0 只能看自己,对角必然是 1.0
- 位置 1 能看两个位置,随机的对角期望是 0.5
- 位置 23 能看 24 个位置,随机的对角期望是 1/24 ≈ 0.04
平均下来就是 0.157。这条曲线完全由掩码的形状决定,和学到了什么无关。
所以”平均距离 4.47”这类量,必须先减掉几何基线才能比较。 我们在 4.3 节给平均距离配了”完全均匀时 ≈ 6”这条参考线,就是这个原因。
4.11.3 把 0.157 算出来:几何基线的解析形式
4.5 节说随机模型的对角是 0.157,并且指出它”约等于 1/(i+1) 的均值”。 这一节把这个数推导一遍——因为它揭示了一条容易忽略的原则。
前提:随机初始化的模型,注意力权重在因果掩码允许的范围内近似均匀。 位置 i 允许看 0, 1, \dots, i 共 i+1 个位置,每个权重约 1/(i+1)。
对角的期望:
\mathbb{E}[A_{ii}] = \frac{1}{i+1} \tag{4.11}
对 i = 0, 1, \dots, T-1 取平均:
\overline{A_{ii}} = \frac{1}{T}\sum_{i=0}^{T-1}\frac{1}{i+1} = \frac{H_T}{T} \tag{4.12}
其中 H_T = 1 + \frac{1}{2} + \dots + \frac{1}{T} 是第 T 个调和数。
4.12 用我们自己的数据验算
本章测的三条诗各 24 个字,T = 24。
H_{24} = 1 + \frac12 + \frac13 + \cdots + \frac{1}{24} \approx 3.776 \tag{4.13}
\overline{A_{ii}} \approx \frac{3.776}{24} = 0.1573 \tag{4.14}
实测:0.1578、0.1561、0.1571(三个随机种子)。
误差在 0.2% 以内。
这不是巧合——这条基线完全由序列长度决定,和模型学到了什么无关。
看式 式 4.12:基线随 T 变化。
| T | H_T | 基线 \overline{A_{ii}} |
|---|---|---|
| 8 | 2.718 | 0.340 |
| 24 | 3.776 | 0.157 |
| 64 | 4.744 | 0.074 |
| 128 | 5.407 | 0.042 |
同一个头,在不同的序列长度上测,对角权重会差好几倍——而它什么都没变。
这意味着两件事:
第一,跨序列长度比较对角是没有意义的。 你在 T = 8 上测到一个头对角 0.34, 在 T = 64 上测到 0.08,不能得出”这个头在长序列上不那么关注自己了”—— 它可能只是几何基线变了。
第二,本章的画像表是在 24 字的诗上测的。 表里的”对角 0.769”(L0H0) 要对照的是 0.157 这条线,不是 0。
同一个测量,在不同的 T 下,含义完全不同。
上面这张表说明基线随 T 下降得很快(T 从 8 到 128,基线降了 8 倍)。
那么:如果我想比较”两个头谁更关注自己”,但它们的测试序列长度不同, 该怎么办?
(答案:做归一化。
\text{相对对角} = \frac{A_{ii}}{1/(i+1)} \tag{4.15}
这个量在 1 附近表示”和均匀一样”,大于 1 表示”比均匀更关注自己”。 它对 T 不敏感,因为分母已经把几何效应除掉了。
本章的画像表没有做这个归一化——它报的是绝对对角。 这是一个已知的不足:表里的对角值只能在同一个 T 下横向比较。
4.3 节末尾提过这件事(“绝对量必须配基线才能读”), 这里是它的定量版本。)
4.12.1 随机的头为什么”一模一样”
随机模型的八个头,熵全部是 0.9578–0.9579,跨度 0.0001。
为什么这么齐?
因为随机初始化的模型,注意力权重只由几何决定,不由内容决定。
回到打分公式:A \propto \exp(q\cdot k / \sqrt{d})。 随机权重下,q 和 k 都是小随机向量(初始化 \mathcal{N}(0, 0.02^2)), 内积很小,指数几乎不改变均匀分布。
所以每个头、每个位置,注意力都近似均匀——而均匀是同一个分布。 八个头测出来的自然就是同一组数。
4.13 这个”齐”本身就是一条证据
反过来想:如果训练没有起作用,八个头应该一直保持这种”齐”。
实测:训练后熵的范围是 0.294–0.871,跨度 0.577。 随机时跨度 0.0001。训练把方差放大了约 5000 倍。
所以”训练造出了结构”这件事,不需要看图、不需要讲机制, 一个方差对比就够了。
这是本章最硬的一条结论——它不依赖任何对”这个头在做什么”的解读。
任何”我在模型里发现了 X”的声称,都可以配一个这样的对照:
| 步骤 | 做什么 |
|---|---|
| 1 | 用随机初始化的同结构模型跑同一个测量 |
| 2 | 记录随机模型给出什么值(这是”零假设水平”) |
| 3 | 用训练后的模型跑同样的测量 |
| 4 | 只有当训练模型的测量值明显超出随机模型的分布时,才谈得上”发现了什么” |
第 2 步最容易被跳过,因为它看起来是浪费时间—— “一个没训练的模型能有什么好看的?”
但随机模型给出的不是”没有结果”,而是”零假设的分布”。 没有这个分布,你测出来的任何数字都不知道该怎么读。
4.14 归纳头:一个能被证明的电路
4.14.1 什么是归纳头
前面几节讲的是”怎么判断一个头有没有用”。这一节讲一个已经被证明有用的具体电路。
归纳头(induction head)是 2022 年在一项机制可解释性研究里被发现并证明的。 它的行为可以用一句话描述:
如果当前这个 token 之前出现过,就去看”它上一次出现之后跟的是什么”,然后预测那个。
用序列说清楚。假设输入是:
... A B C D ... A
↑ 现在在这里
当前 token 是 A。它上一次出现在前面某个位置。归纳头会把注意力放到 “上一次 A 后面的那一个位置”上——也就是 B 所在的位置。 然后它从 B 那里把信息取过来,供后续层预测”下一个是 B”。
这是一个”复制”电路:它让模型能够续写重复出现过的模式。 在自然语言里,这对应着”前面出现过类似的片段,接下来大概会重复它”。
归纳头为什么需要两层
一个头做不到这件事。它需要两个头接力:
第一层的”前一位头”:把每个位置的”上一个 token 是什么”写进残差流。 (我们模型的 L0H1 正是这种头——前一位权重 0.450。)
第二层的”匹配头”:拿当前位置的 token 去和残差流里每个位置的 “前一个 token”比对,找到相等的那一个,然后关注它的下一位。
关键在第二步:要在位置 i 找到”token 等于当前 token”的历史位置, 模型需要一个匹配操作。而匹配可以用内积实现—— 如果残差流里存的是”前一个 token 的表示”,那么 “当前 token 的表示”和它的内积,就正比于两个 token 的相似度。
这就是归纳头:它把”找相同”变成了”算内积”,而内积正是注意力本来就在做的事。
注意它必须跨两层。 第一层负责”造出可匹配的键”, 第二层负责”匹配 + 复制”。一层做不到,因为同一层的注意力 只能看到原始输入,看不到”前一个 token 是什么”这个派生量。
4.14.2 归纳头的机制:为什么必须两层,以及它怎么算
4.6 节说归纳头”必须跨两层”。这一节把这个论断推一遍—— 因为它示范了一件事:机制可解释性可以给出”不可能”的结论,不只是”观察到了”。
目标:在位置 i,如果 token x_i 之前出现过,就输出 “上次出现之后跟的那个 token”。
用符号写:设 j < i 是满足 x_j = x_i 的最近位置, 我们要让模型在位置 i 预测 x_{j+1}。
要算这个,需要两步:
第一步:在位置 j 处,记下”前一个 token 是什么”。 也就是把 x_{j-1} 这个信息,编码进位置 j 的表示里,供后面匹配用。
第二步:在位置 i 处,找到那个”前一个 token 等于 x_{i-1}“的历史位置。
4.15 为什么第二步是”匹配前一个”而不是”匹配当前的”
这一步容易绕晕,用具体序列走一遍。
位置: 0 1 2 3 4 5 6 7 8
token: A B C D A B C D A
↑
现在在这里(位置 4,token = A)
我们在位置 4,token 是 A。上一次 A 出现在位置 0。
要预测的下一个 token 是什么? 由位置 0 的经验:A 后面跟的是 B(位置 1)。 所以我们想输出 B。
怎么找到 B? 我们不能直接找 B——不知道它是谁。 我们只知道”上一次 A 后面的那个”。
所以匹配的键不能是 A 本身。 如果拿 A 去和历史的 A 匹配, 找到位置 0,然后看位置 0 的输出——但位置 0 的输出是它自己那一层算出来的东西, 不含”后面跟什么”的信息(因果掩码不让它看未来)。
关键的转换:改成匹配”前一个 token”。
在位置 4,前一个 token 是 D(位置 3)。 在历史里找”前一个 token 也是 D“的位置——位置 3。
然后看向位置 3 的后面一个位置,也就是位置 4。 ……这在原地打转。
换个写法:要找的是”位置 j+1 的值”,其中 j 是上次 A 的位置。 而位置 j+1 = 1 的特征是”它的前一个 token 是 A“。
所以在位置 i,要匹配的键是”前一个 token 是 x_i“,而不是”自己是 x_i“。
用 i = 8(第二个 A,位置 8)再走一遍:
- 位置 8 的 token 是
A(它前面位置 7 是A,位置 6 是D……这是第三遍开头) - 要找”前一个 token 是
A“的位置 → 位置 1(它前面位置 0 是A)和位置 5 - 取最近的一个 → 位置 5
- 看向位置 5 的后面,也就是位置 6 —— 那里是
C - 输出
C✓(第一遍里A后面是B,但第三遍的上下文已经变成了B C D A B C D A, 所以最近一次A后面跟的是C——这正是归纳头”学上下文里的重复”的含义)
归纳头匹配的是”前一个 token”,然后指向”匹配位置的下一个”。
现在看两层的分工:
第 0 层的”前一位头”(我们模型的 L0H1,前一位权重 0.450): 它把位置 t 的输出写成”x_{t-1} 的表示”。 这一个头就完成了第一步——每个位置的表示里,现在带着”我前面是谁”。
第 1 层的”匹配头”: 在位置 i,它的查询是 x_i 的表示(经过第 0 层之后)。 它的键是历史位置的表示,而那些表示里含有”前一个 token”。 内积 q_i \cdot k_j 就在问:“位置 j 的前一个 token,和我(位置 i 的 token)像不像?”
像 → 权重大 → 从位置 j 取值 → 值里编码着”位置 j 的 token”→ 输出它。
上面的推导里,第 1 层的匹配头需要”位置 j 的值里编码着位置 j 的 token”。 这个信息从哪来?
(答案:残差流。
第 2 章讲过:残差流是”每个位置的一条直通管道”, 词嵌入 e(x_j) 从第 0 层一直加到最后。 所以位置 j 的值向量里天然含有 e(x_j), 匹配头只要用一个合适的 W_V 把它取出来就行。
这是残差流架构的一个直接红利:原始输入在每个位置都有一份副本, 任何一层都能随时取用。不需要专门”保存”什么。
一个反例:如果没有残差流(纯串行的 RNN 式结构), 中间层的表示会被后面的计算覆盖掉,匹配头就拿不到”位置 j 的原始 token”了。 归纳头能在 Transformer 里长出来,残差流是前提之一。)
4.16 为什么第 0 层的头不行
有人会问:第 0 层的头能不能自己完成这件事?
不能,因为它在位置 i 只能看到原始输入 x_0, \dots, x_i。 “位置 j 的前一个 token 是 x_{j-1}”这个信息是派生的—— 它需要一次注意力操作才能算出来(L0H1 干的就是这个)。
第 0 层的头看得见 x_{j-1} 和 x_j,但它没法把这两个”绑”成一对。
这个”绑”就是注意力的作用:一个头把 x_{j-1} 搬到位置 j, 于是位置 j 的表示里同时含有 x_j(来自残差流)和 x_{j-1}(来自注意力)。
第 1 层的头才能看到这个”配对”。
所以归纳头至少需要两层——这是架构层面的下界,不是调参能绕过的。
上面证明了归纳头至少需要两层。这不等于”两层模型一定有归纳头”。
我们自己的模型就是反例:2 层,但 4.6.3 节的实测显示归纳分数 与随机基线齐平(0.0285 vs 0.0284)。
为什么”够两层”却没长出来? 回到 4.6.4 节那三条原因:
- 每头只有 32 维,内积的分辨率有限,匹配容易出错
- 只有 4 个头,第 0 层要同时承担”前一位”和”造键”两个职责
- 任务是唐诗,重复大多是高频字的偶然撞车,不是有意义的模式重复
“理论上可能”和”实际上发生了”是两件事。 前者用数学证明, 后者只能实测。本章两件都做了:证明了可能性,实测了没有发生。
4.16.1 怎么测:一个干净的指标
归纳头有一个别的头都不具备的特征:它只在”重复出现过”的位置上才起作用。
这个特征直接给出了测法:
造一条人为重复的序列:随机取 P 个 token,把它重复 4 遍。
t₁ t₂ ... t_P t₁ t₂ ... t_P t₁ t₂ ... t_P t₁ t₂ ... t_P看第二遍及以后的每个位置 i(它对应第一遍的位置 j = i - P)
测这个位置 i 的注意力,有多少落在 j+1 上
\text{归纳分数} = \frac{1}{|S|}\sum_{i \in S} A_{i,\,j+1}, \qquad j = i - P \tag{4.16}
所以这个式子在说:在重复出现的位置上,注意力有多大比例 落在”上次出现之后那一位”。
一个真正的归纳头,这个分数会显著高于 1/i(均匀基线)。 一个普通的头,分数会落在均匀基线附近。
这个指标我们写错过一次
第一版的代码里,那个下标写成了 j - 1:
hits.append(m[i, j - 1].item()) # ← 错:这是"上一次**之前**那一位"归纳头看的是”上一次之后那一位”,即 j+1。
写成 j-1 测的是完全不同的东西——它测的是”有没有一个头在关注 上上次出现的位置”,那根本不是归纳头。
这个 bug 不会报错,只会给出一个同样在噪声水平上的分数。 所以它躲过了第一轮测试。发现它是因为:修正前和修正后, 随机对照的分数都在 0.028 上下,而两版的数字看起来都”合理”。
这就是”没有对照就发现不了 bug”的又一个例子—— 只有把训练模型和随机模型放在一起,才会注意到”这个指标根本测不出区别”。
4.16.2 实测:我们的模型没有归纳头
头 训练模型 随机对照
L1H3 0.0285 0.0285
L1H2 0.0269 0.0284
L0H2 0.0253 0.0283
L1H0 0.0232 0.0285
L1H1 0.0221 0.0285
L0H1 0.0143 0.0285
L0H3 0.0141 0.0284
L0H0 0.0007 0.0285
─────────────────────────────
均值 0.0194 0.0284
最高 0.0285 0.0285
这张表要说三件事
第一,随机模型给出了一条极其平整的基线:0.0283–0.0285。 八个头全部落在这个窄带里,这说明 0.0284 就是这个指标在”没有任何归纳行为”时的自然水平。
第二,训练模型的八个头里,只有 L1H3 碰到了这条基线(0.0285), 其余七个全部低于它。 最低的 L0H0 是 0.0007——它对”上次出现之后那一位”完全不看。
第三,没有头显著高于基线。
结论:这个模型里没有归纳头。
这不是”我们的测量失败了”,这就是结论本身。
上面说”没有头高于基线”。但 L0H0 的分数是 0.0007——远低于基线 0.0284。
一个”什么都没学”的头,分数应该在基线附近,而不是远低于它。 0.0007 说明什么?
(答案:说明 L0H0 有明确的偏好——它的对角权重是 0.769, 也就是说它几乎只看自己。位置 i 看自己,自然就看不到 j+1。
所以”低于基线”和”高于基线”一样,都是信息。
对 L0H0 来说,这个分数低是意料之中的:一个恒等头当然不会去做归纳。 真正值得注意的是:其余七个头的分数在 0.014 到 0.029 之间, 虽然高低不一,但没有一个明显跳出来。 这个”平”才是结论。)
4.16.3 为什么没有:三个可能的原因
原因一:模型太小。
归纳头需要第一层”造键”、第二层”匹配”。本模型只有 2 层, 第一层的头必须同时承担”前一位”和”造可匹配的键”两个职责—— 而它只有 4 个头、32 维的每头空间。
文献里报告归纳头的是几十层、上亿参数的模型。 在 42 万参数、2 层的模型上找归纳头,本身就是一次超纲的尝试。
原因二:任务不需要。
回想第 2 章那个实验:上下文从 8 加到 128 只改善了 0.64, 而且 16 比 8 还差。 那个实验的结论是:这个唐诗任务对长程依赖的需求很弱。
归纳头是一种长程电路——它要跨越 P 个位置去找上一次出现。 在一个不需要长程的任务上,模型没有动力长出这个电路。
原因三:字符级 + 小词表。
归纳头在重复的 token 序列上最有用(比如代码里的变量名、 自然语言里的固定搭配)。我们用的是 1200 字的字符表, 唐诗里的重复大多是”的”“不”“春”这类高频字的偶然重复, 不是有意义的模式重复。
如果你想让归纳头出现,需要同时改三件事:
- 加深到至少 4 层(给”造键”和”匹配”各自留出层)
- 换一个真正有重复结构的任务—— 比如让模型学
a b c d a b c d ...这样的周期序列, 或者用代码(变量名会反复出现) - 加宽:每头维度太小时,“匹配”用的内积分辨率不够
然后重跑这一节的指标。 如果归纳分数跳到 0.3 以上(而不是 0.028), 那就是找到了。
本书没做这件事,因为它是”再训一个模型”的量级—— 但这个实验的设计是完整的,你可以直接拿去用。 把”我们没做”和”我们做了但没成功”分清楚,比假装做过要好。
4.16.4 归纳头测完之后:三个”差一点”的观察
4.6 节的结论是”没有归纳头”。但这个结论背后有三个值得单独说的观察—— 它们都是从数据的形状里读出来的,不是从数值大小。
观察一:随机基线窄得反常。
随机模型的 8 个分数:0.0285 0.0284 0.0283 0.0285 0.0285 0.0285 0.0284 0.0285
极差 0.0002。
一条这么窄的带子只能有一个来源:所有头都在做同一件事—— 均匀地看所有允许的位置。
这给了我们一个非常干净的零假设:如果一个头不做任何归纳行为, 它的分数精确地是 0.0284。任何偏离都意味着某种结构。
观察二:训练模型的分数大部分低于基线。
0.0285 0.0269 0.0253 0.0232 0.0221 0.0143 0.0141 0.0007
七个低于基线,一个等于基线。
没有一个高于。
这个分布形状本身就是结论:训练让头变得更不倾向于做”看上次出现之后那一位” 这件事——因为它把注意力集中到了别的地方(对角、前一位)。
观察三:最低的那个头正好是”最专注自己”的那个。
L0H0 归纳分数 0.0007 对角 0.769 ← 恒等头
L0H1 归纳分数 0.0143 前一位 0.450 ← bigram 头
分数最低的 L0H0 恰好是画像里最极端的。
4.17 三个观察合起来说明什么
归纳头需要”注意力分散到远处”。 而训练做的事正相反—— 它把注意力收紧到几个明确的位置(自己、前一位)。
所以”训练造出结构”和”训练造出归纳头”是两件可能冲突的事。
在一个小模型上,分化(specialization)先发生,长程匹配没轮到。 模型先把最基础的统计量(bigram)学到手,把注意力都投在上面, 没有余力去长一个跨越 P 个位置的匹配电路。
这是一个”容量分配”的故事:头的数量、维度、层数都是有限的, 模型把它们用在了收益最高的地方。
而”收益最高的地方”由任务决定——唐诗不需要长程匹配(第 2 章测过)。
上面说”训练让注意力收紧,所以没长出归纳头”。
这是一个事后解释,不是被验证的机制。
要验证它,需要:在一个需要长程匹配的任务上训练同一个模型, 看归纳分数会不会变高。
本章没做这个实验。 它是”再训一个模型 + 换一个数据集”的量级, 超出了本章的范围。
但它是一个清晰的、可执行的后续工作—— 而且预测很明确(在周期序列上训练,归纳分数应该显著上升)。 一个好的后续工作应该长得这样:说清楚做什么、预期看到什么。
4.17.1 换个任务会长出归纳头吗:一个可执行的设计
把上面那个”后续工作”写具体——因为设计实验比描述实验更有用。
任务:周期序列复制。
# 造数据:随机取 P 个 token,重复 rep 遍
# "a b c d e | a b c d e | a b c d e"
def make_cyclic_batch(B, P, rep, vocab, gen):
pat = torch.randint(3, vocab, (B, P), generator=gen)
return pat.repeat(1, rep)为什么这个任务能逼出归纳头?
因为除了”看上次出现之后那一位”,没有别的办法预测下一个 token。 序列本身是随机的(token 之间没有统计关系), 唯一的规律是”它重复了自己”。
模型只有两个选择:学会归纳(看过去找匹配),或者放弃(猜均匀分布)。
预期结果:
| 模型 | 预期归纳分数 |
|---|---|
| 随机初始化 | ≈ 0.028(几何基线) |
| 在唐诗上训练(本章的模型) | ≈ 0.028(实测) |
| 在周期序列上训练 | 应该显著高于 0.028 |
如果第三个也是 0.028,那说明 2 层 4 头结构上不够—— 这时候要加层、加头,而不是继续调任务。
这是一个设计实验时最常见的两难:结果不符合预期时, 你分不清是任务选错了,还是模型不够大。
做法:做阶梯。
- 先固定任务(周期序列),扫模型规模(2/4/8 层)
- 如果规模上去之后归纳分数跳起来 → 是模型太小
- 如果规模上去也没用 → 是任务或数据的问题
而不是:同时改任务和模型,然后猜是哪个因素起作用。
一次只动一个变量——这条第 2 章讲对照实验时就说过, 但它在这里的表现形式不同:不是”两个模型比”,而是”一个模型扫一个维度”。
上面那个周期序列任务,如果用 1 层模型呢?
(答案:理论上不可能,而且可以证明。
4.6.2 节推导过:归纳头需要”第 0 层造键、第 1 层匹配”。 1 层模型只有一次注意力操作,它在位置 i 只能看到原始输入 x_0, \dots, x_i——而”位置 j 的前一个 token 是什么”是一个派生量, 需要一次注意力才能算出来。
所以 1 层模型在周期序列任务上的 loss 应该停在 “均匀分布的交叉熵”附近(\log V),学不动。
这是一个可以预测的负面结果——而可预测的负面结果是验证一个理论 最好的方式:理论说 1 层不行,跑出来确实不行,理论就得到了一次支持。
本章的模型恰好是 2 层,所以这个实验做不了。 要做需要重新初始化一个 1 层模型——代码上就是 Config(n_layer=1)。)
4.18 探针:从激活里读出信息
4.18.1 探针是问”这里有没有这个信息”
前几节问的是”这个头在做什么”。探针问的是另一个问题:
模型的某个中间表示里,有没有编码某个特定的属性?
做法很直接:训一个线性分类器,输入是某一层的激活,输出是那个属性。 如果线性分类器能达到很高的准确率,说明那个属性被线性地编码在里面。
# 探针就是一层线性层 + 二分类
w = torch.zeros(d_model, 1, requires_grad=True)
b = torch.zeros(1, requires_grad=True)
opt = torch.optim.Adam([w, b], lr=0.05)
for _ in range(300):
loss = F.binary_cross_entropy_with_logits(X @ w + b, y[:, None])
opt.zero_grad(); loss.backward(); opt.step()为什么用线性而不是 MLP? 因为线性探针能证明的是更强的结论: 不是”信息可以被解码”(一个足够大的 MLP 能从任何东西里解码任何东西), 而是”信息以线性可分的形态存在”。后者才有意义。
4.18.2 选一个”模型必须自己算出来”的属性
探针实验最容易犯的错是选错属性。我们第一次就选错了。
第一版选的是:“当前位置的字符是不是标点”。
结果:
embed 探针准确率 1.0000 多数类基线 0.8438
词嵌入层的准确率是 1.0000。 这意味着探针根本没有在测模型—— 它从词嵌入里直接读出了答案,因为答案就写在输入里: 不同的 token 有不同的嵌入,标点字符的嵌入天然是可分的。
这不是”模型编码了标点”,这是”输入就是标点”。
探针作弊:一个必须主动防的陷阱
上面那个 1.0000 说明探针学到了一个平凡解: “如果嵌入向量接近某个标点的嵌入,就判它是标点”。
任何在输入里就能看出来的属性,探针都会作弊。 常见的几类:
| 属性 | 会不会作弊 | 为什么 |
|---|---|---|
| 当前 token 是不是标点 | 会 | 答案在输入里 |
| 当前 token 的词性 | 会 | 词性基本由 token 本身决定 |
| 下一个 token 是不是标点 | 不会 | 输入里没有 |
| 这个位置在第几个字 | 不会 | 需要位置信息 |
| 这句话是正面还是负面 | 不会 | 需要跨位置整合 |
做法:把”embed 层”当成对照。 如果某一层的探针准确率和词嵌入层差不多,那它可能什么都没算, 只是在复述输入。
这条对照线是免费的,而且能挡住大部分假阳性。
4.18.3 实测:残差流里能不能读出”下一个是不是标点”
改成预测下一个字符的属性——这是模型必须自己算的。
层 探针准确率 多数类基线
embed 0.7188 0.8438 ← 低于基线
layer1 0.9375 0.8438 ← 明显高于基线
layer2 0.8750 0.8438 ← 略高于基线,但比 layer1 低
这张表读出来的三件事
第一,词嵌入层(0.7188)低于多数类基线(0.8438)。
这是一个很干净的负结果:“下一个字符是不是标点”这个信息, 在输入里完全不存在。 探针只能瞎猜,甚至因为类别不平衡而猜得更差。
这条线是必需的——它证明了后面两层的准确率不是从输入漏过来的。
第二,第 1 层是 0.9375。
模型在第 1 层就已经算出了”下一个大概率是标点”。这符合直觉: 唐诗有固定的句长(五言、七言),逗号句号的位置高度规律。 第 1 层的注意力头完全有能力捕捉到”位置模 5”或”位置模 7”这种模式。
第三,第 2 层反而降到了 0.8750。
这一条最值得琢磨。第 2 层的表示里,“下一个是不是标点”这个信息变弱了。
为什么?因为第 2 层的任务是预测具体的下一个字,不是预测它是不是标点。 它需要把”是标点”这个粗粒度的信息,细化成”是逗号还是句号还是问号”。 抽象属性在往具体答案转化的过程中被稀释了。
“越深的层编码越抽象的信息”是一个常见的说法——但这里的数据不支持它。 在这个 2 层模型上,一个具体的语法属性在中间层最清晰。
这是探针研究里最重要的一条界限。
探针证明的是:信息存在于那个表示里,而且是线性可分的。
探针没有证明的是:模型用了那个信息。
一个反例的思路:假设模型在第 1 层顺手算出了”下一个是不是标点” (因为预测字符分布时这一步是副产品),但第 2 层完全不依赖它—— 第 2 层自己从别的特征里就能预测。这时探针仍然会给出 0.9375, 但这个属性对模型的输出没有因果作用。
怎么补上因果那一半? 和我们 4.4 节做头消融一样: 干预那个表示——把”下一个是标点”这个方向从激活里减掉, 看模型的输出变不变。
# 探针权重 w 给出了"标点方向"。把它从激活里投影掉:
X_intervened = X - (X @ w_hat) @ w_hat.T # w_hat 是 w 的单位向量
# 再跑一遍前向,看 loss 变多少这个实验本书没有做——它是”探针 + 因果干预”的完整形态, 属于后续工作。但它的设计是从本节的探针直接接上去的,只差一次投影。
4.18.4 探针的完整流程:五个决策点
4.7 节给了探针的代码和结果。这一节把流程拆开, 因为探针实验有五个必须主动做的决策,每一个做错都会给出假阳性。
决策一:选什么属性。
标准是:这个属性的答案,不能在输入里直接看到。
我们第一版选了”当前字符是不是标点”,词嵌入层就给到 1.0000。 改成”下一个字符是不是标点”之后,词嵌入层掉到 0.7188(低于基线)—— 这才是一个能用的属性。
决策二:用线性还是 MLP。
必须用线性。 理由不是”简单”:
- 线性探针能证明:那个属性以线性可分的形式存在于表示里
- MLP 探针只能证明:那个属性可以被算出来——但”算”这件事 神经网络本来就会做,一个足够大的 MLP 能从任何东西里算出任何东西
MLP 探针的准确率高,但信息量为零。
决策三:训练集和测试集怎么分。
听起来是常识,但有一个探针特有的陷阱:同一个序列内的位置高度相关。
# 错的:随机划分位置
idx = torch.randperm(N)
train, test = idx[:N//2], idx[N//2:]相邻位置的特征几乎一样(残差流是平滑变化的)。 随机划分会让”训练位置的邻居”落进测试集,准确率被高估。
正确做法:按序列划分。 整条序列要么进训练集,要么进测试集。
决策四:对照基线选什么。
本章用了两个:
- 多数类基线(永远猜出现最多的那一类):本实验是 0.8438
- 词嵌入层:本实验是 0.7188
两个都要。 多数类基线告诉你”不学任何东西能拿多少分”; 词嵌入层告诉你”输入里漏过来多少信息”。
决策五:报什么数。
只报准确率不够。还要报:
- 正例率(本实验 0.1562)——类别极不平衡时, 准确率这个指标本身会误导
- 两个基线
- 探针自己的容量和训练步数(否则别人无法复现)
假设某个属性在数据里的正例率是 5%,而你的探针准确率是 94%。
看起来很厉害? 但”永远猜负类”的基线就是 95%。
你的探针比瞎猜还差。
这不是编的例子——这就是我们第一版探针在 embed 层的表现 (0.7188 vs 0.8438)。如果没有把基线一起报出来, 那个 0.7188 看起来会像一个”模型没学到这个属性”的中等结果, 而实际上它是”探针被类别不平衡带偏了”。
4.18.5 把探针接到因果上:一个没做完的实验
4.7 节末尾说”能读出 ≠ 用到了”,并给了一个干预的写法。 这一节把那个实验的设计补完整——因为它是本章唯一一个”设计了但没跑”的实验。
问题:第 1 层的探针准确率 0.9375。模型真的用了”下一个是不是标点”这个信息吗?
做法:探针的权重向量 \mathbf{w} 定义了激活空间里的一个方向—— “标点性”方向。把这个方向从激活里减掉,看输出变不变。
\tilde{\mathbf{h}} = \mathbf{h} - (\mathbf{h}\cdot\hat{\mathbf{w}})\hat{\mathbf{w}} \tag{4.17}
所以这个式子在说:把 \mathbf{h} 在 \hat{\mathbf{w}} 方向上的分量消掉, 剩下的部分是”和标点性无关的部分”。\hat{\mathbf{w}} = \mathbf{w}/\|\mathbf{w}\| 是单位向量。
然后跑完整的前向,测 loss。
4.19 三种可能的结果,三种不同的结论
| 投影后 loss 变化 | 结论 |
|---|---|
| 几乎不变 | 信息存在但没被用——探针找到的是一个”副产品” |
| 明显变差 | 信息被用了——这是一个真正的因果通路 |
| 变好 | 这个方向在干扰模型——罕见但可能 |
只有第二种才能说”模型用了这个信息”。
注意这个实验比消融更细:消融是拿掉一整个头(可能同时抹掉好几条信息), 投影是只抹掉一个方向。前者粗,后者精确。
本书没跑这个实验的原因是:投影会改变激活的分布, 而后续层是在原来的分布上训练的——分布外输入会让结果难以解释。 要严谨地做,需要一个”控制投影”(投影掉一个同样维度的随机方向), 比较两者的 loss 变化。那至少是另一个小节的工作量。
但设计是完整的,代码只差十行。要接着做的人从这里开始。
上面说”投影会改变激活的分布,让结果难以解释”。 所以要加一个”控制投影”——投影掉一个随机方向,看 loss 变多少。
为什么要随机方向,而不是随便挑一个别的主成分?
(答案:因为你要控制的是”做了一次投影”这件事本身的代价。
投影任何方向都会扰动激活。如果投影掉”标点方向”让 loss 涨了 0.1, 你需要知道”投影掉一个随便什么方向”会让 loss 涨多少。 如果随机方向也涨 0.1,那 0.1 是投影的代价,不是标点性的作用。
这和探针的”词嵌入层对照”是同一个思路: 你要证明的不是”我做了 X 之后有变化”, 而是”我做的 X 造成的,比其他同类的 X 更大”。
控制实验的设计,是这类研究里最难也最容易被跳过的一步。
4.19.1 探针能回答的三种问题和不能回答的一种
4.7 节讲了探针”能读出”和”用到了”的区别。这一节把探针能回答什么 列清楚——因为误用探针最常见的方式是问它答不了的问题。
能回答的问题一:这个信息在不在?
“第 1 层的激活里,有没有’下一个是不是标点’这个信息?” → 探针准确率 0.9375 vs 基线 0.8438。在。
能回答的问题二:在哪一层最清晰?
对比 embed / layer1 / layer2: 0.7188 → 0.9375 → 0.8750。 中间层最清晰。
这是一个反直觉的结果(通常认为越深越抽象), 但在这个模型上有明确的数据。
能回答的问题三:不同属性之间的相对难度?
比如同时测”下一个是不是标点”和”下一个是不是句号”, 两者的准确率差说明了这两件事在表示里的”清晰程度”不同。
不能回答的问题:模型是不是”理解”了这个属性。
这是探针最容易被误用的地方。
4.20 “探针能解码” ≠ “模型理解”
一个思想实验:
假设有一个属性 Z,它和输入完全无关(比如”这句话的作者今天心情如何”)。 但由于训练数据的统计巧合,Z 和某个真正被编码的属性 Y 高度相关。
那么:探针会从激活里”读出” Z,因为 Z 和 Y 相关。
但模型完全不知道 Z 的存在——它只是在编码 Y。
探针测的是”可分性”,不是”表征”—— 只要激活里有一个方向和一个标签相关,线性探针就能读到它, 不管模型有没有”意图”编码那个标签。
这是”探针 ≠ 理解”的技术版本。 它不是哲学问题,是一个具体的统计现象。
延伸阅读 ⑤ 那篇论文给的做法是控制任务:
- 给你的真实标签 Y 训一个探针,得到准确率 A_{\text{real}}
- 把标签随机打乱,再训一个探针,得到 A_{\text{ctrl}}
- 只有 A_{\text{real}} - A_{\text{ctrl}} 才是”真实的可分性”
第 2 步的意义:随机标签和输入没有关系, 但探针仍然能从激活里读出某种东西(因为它有容量)。 A_{\text{ctrl}} 就是这个”探针的选择能力”的下限。
本章用的是”词嵌入层对照”——它是这个思路的一个简化版: 如果输入里就有答案,词嵌入层的探针会很高。 但它挡不住”两个属性相关”这种情况—— 那个需要标签打乱。
本章没做标签打乱。 这是探针实验里一个标准的、本可以做的对照。
4.20.1 一个更严格的探针:把”位置”也控制住
还有一个本章没处理的混淆:位置。
“下一个是不是标点”这个属性,和位置高度相关—— 在五言诗里,标点出现在第 5、10、15……个位置。
所以一个探针可能只是在读”当前位置 mod 5”。
怎么区分? 加一个位置对照:
# 探针对照:预测"当前位置 mod 5 是不是 4"
# 如果这个探针的准确率和"下一个是不是标点"差不多,
# 说明前者可能只是在读位置,没有额外的信息这是”两个属性相关”那个陷阱的一个具体实例, 而且它在本章的数据上非常可能成立(因为诗有固定句长)。
它可信,但它的解读要改。
可信的部分:第 1 层的激活里,确实有信息能区分”下一个是不是标点”。 这是事实,探针准确率摆在那里。
要改的解读:不能说是”模型算出了下一个是标点”。 可能是: - 模型确实在跟踪句内位置(那它等价地知道下一个是标点) - 或者模型在跟踪别的东西,那个东西恰好和标点位置相关
两种情况的探针准确率一样。
区分方法:在句长不固定的文本上重测。 如果准确率掉下来,说明它在读位置;如果不掉,说明它真的在处理标点。
本章用的语料全是五言七言——句长完全固定。 所以这个混淆在本章的数据上是无法排除的。
“我们的语料有一个结构,这个结构可能解释我们观察到的现象”—— 这是做测量时最该主动问的一句话。
上面说”诗的句长固定,所以探针可能只是在读位置”。
这个混淆会不会也影响 4.9 节那个韵脚排查的结论?
(答案:会,而且方向是反的。
4.9 节测到”标点位置的 loss 是 0.246,其他位置是 4.507”, 我们当时的解读是”因为句长固定,数位置就能预测标点”。
这个解读和探针那个混淆是同一件事的两面: - 探针那边:模型可能在读位置 - loss 这边:模型确实在读位置(所以标点才这么好预测)
两者互相印证,而不是互相独立。
这提醒了一件事:当你发现两个实验”结果一致”时, 要先问它们是不是独立的。同一个混淆因素可以同时解释两个实验。
4.21 失效边界:可解释性研究的四个陷阱
这一节把本章踩过的和文献里反复出现的坑收成四条。 它们的共同点是:都不会报错,只会给出一个”看起来合理”的错误结论。
4.21.1 陷阱一:探针作弊
4.7 节已经完整讲过——选了一个”答案写在输入里”的属性, 词嵌入层的探针准确率冲到 1.0000。
识别方法:看词嵌入层那条对照线。 如果它也高,说明你测的不是模型。
推广:任何”从激活里解码属性”的实验,都要先问一句 “这个属性的答案,是不是在输入里就能看到?”
4.21.2 陷阱二:多重比较
本章测了 8 个头。如果测 800 个呢?
“至少有一个头看起来在做某件事”的概率,随头数上升得很快。 假设没有任何头真的在做那件事,但每个头的指标有 5% 的随机波动—— 那么 800 个头里,期望有 800 \times 5\% = 40 个”显著”。
这就是为什么本章每一节都配了对照: 随机模型的八个头给出 0.0284 那条基线, 训练模型的分数才有比较的对象。
一个具体的数字感受
假设你在一个 24 层的模型上,逐层逐头地找”某个特定模式”(比如 “这个头是不是在看动词”),一共 24 × 16 = 384 个头。
如果你按”分数最高的那个头”下结论,你几乎必然会找到一个—— 哪怕模型里根本没有这个模式。
正确做法是先定阈值,再测。 阈值从哪来? 从随机对照来:跑几个随机初始化的模型, 看这个指标的分布,取一个明显超出该分布的阈值。
本章的归纳头就是这么做的:随机模型给出 0.0283–0.0285 的窄带, 所以”显著”的门槛至少在 0.05 以上。实测最高 0.0285,没有头越线。
4.21.3 陷阱三:消融的相互作用
4.4 节做的是单头消融——一次拿掉一个头。 它测不出头与头之间的替代关系。
考虑一个极端情况:两个头在做同一件事。单独拿掉任何一个, 另一个顶上,Δ 都很小。看起来两个都不重要。
但同时拿掉两个,模型可能直接崩掉。
反过来也成立:两个头互相干扰,单独拿掉任何一个都让效果变好, 同时拿掉反而不如只拿掉一个。
怎么测相互作用
做法是成对消融:对每一对头 (i, j),测三个量——
\Delta_i, \quad \Delta_j, \quad \Delta_{ij} \tag{4.18}
如果 \Delta_{ij} \approx \Delta_i + \Delta_j,两个头是独立的。 如果 \Delta_{ij} < \Delta_i + \Delta_j,它们部分冗余(互相能顶)。 如果 \Delta_{ij} > \Delta_i + \Delta_j,它们互补(各管一半,缺一不可)。
本模型 8 个头,成对是 \binom{8}{2} = 28 组,跑一遍完全可行。
本书没有做这个实验——它是本章最后一块没补的拼图。 从 4.4 节的代码接上去只要改一个循环: ablate_head 注册两次 hook 就行了。
4.21.4 陷阱四:确认偏误
这条最难防,因为它不涉及代码。
你画出一张注意力图,然后去找一个解释。 人脑很擅长在噪声里找模式——第八章那种”云像什么”的能力, 在解读注意力图时是负资产。
一、先预测,再看图。
在看到 L0H1 的图之前,先写下”如果它是一个 bigram 头,我应该在图上看到什么”。 然后看图。如果图和预测不符,先怀疑预测,再怀疑图。
二、换一组输入。
本章的画像是在三条诗上测的。如果一个”模式”只在你精心挑的那一条上成立, 它不是模式,是巧合。
三、把解释写成一个能被证伪的句子。
“这个头在关注语义上重要的词”——不可证伪,因为它没说清”重要”怎么测。
“这个头看紧邻的前一位,权重 0.450,拿掉它 loss 涨 0.177”—— 可证伪,而且已经验证过了。
区别在于:后者能错,前者不能。
四条陷阱里,哪一条是本章自己踩过的?
(答案:三条踩过。
陷阱一(探针作弊):第一版探针选了”当前字符是不是标点”, 词嵌入层 1.0000。4.7 节完整记录了。
陷阱二(多重比较):归纳头指标的 j-1 写错那次, 如果没有随机对照线,我们会以为 0.028 是”一个很弱但存在的归纳头”。
陷阱四(确认偏误):本章开头那个”L0H1 是 bigram 头”的结论, 是先看图、后找解释的。它恰好对了(4.4 节的消融证实了), 但流程是错的——正确的顺序是先预测再验证。
陷阱三(相互作用)本章还没踩,因为还没做。
4.21.5 陷阱五:把”消融无影响”当成”这个头没用”
4.4 节的实测里,第 1 层的四个头 Δ 都在 +0.02 到 +0.03 之间。 一个自然的结论是”第 1 层的头基本没用”。
这个结论太强了。 至少有三条反驳:
反驳一:冗余不等于无用。 一个头可以被另一个头完全替代, 但它仍然在做那件事。“拿掉没影响”说的是系统的可替换性, 不是部件的作用。
类比:一架四引擎飞机,关掉一个引擎仍然能飞。 这不说明那个引擎没用——它说的是”剩下的三个够用”。
反驳二:测的是平均性能,不是全部能力。 验证集 loss 是一个平均量。 一个头可能只负责”某类罕见输入”,而那类输入在验证集里占比很低。 拿掉它,平均 loss 几乎不变,但那类输入全错了。
反驳三:消融改变的是模型,不是任务。 拿掉一个头之后, 模型会用一个”残废但自洽”的方式继续工作。它可能发展出了新的补偿路径 (在一次前向里不可能,但我们在评估的是同一个权重下的行为)。 准确说:消融测的是”这个头在现有其他部件都在的情况下有多必要”, 不是”这个头本身有多重要”。
| 反驳 | 补法 |
|---|---|
| 冗余 | 成对/成组消融(4.4.4 节做了) |
| 平均掩盖 | 逐位置看 Δ(4.4.5 节做了) |
| 只测一类输入 | 按输入类型分层评估(本章没做) |
第三条的补法:把验证集分成若干子集(比如”含标点的句子”、 “长句”、“生僻字多的句子”),在每个子集上分别测消融效果。
一个头可能只在某个子集上重要。平均之后看不出来。
4.21.6 陷阱六:把”可解释”当成”已解释”
这是最根本的一条,也是本章反复在绕的那个点。
“这个头在看前一位”——这句话解释了什么?
它解释的是信息路由:位置 i 从位置 i-1 取了信息。
它没有解释的是: - 取过来之后,模型用它做了什么? - 如果换成从别处取,结果会怎样? - 为什么是 0.45 这个数值,而不是 0.2 或 0.8?
换句话说:你描述了机制的一环,但没有描述机制。
4.22 一个类比
想象你要解释一台计算器为什么能算加法。
“按键 3 的信号传到了运算单元”——这是一个真实的、 可测量的、有因果作用的描述。
但它不是”计算器怎么算加法”的解释。 它只是链路里的一跳。
注意力图给出的就是这种级别的东西:一跳的路由信息。 它比”什么都没说”强,比”解释了模型”弱得多。
这个定位很重要——它决定了你应该拿注意力图做什么: 当作线索,而不是当作结论。
如果注意力图只给出”一跳”,机制可解释性是不是注定徒劳?
不是。 4.6 节的归纳头就是一个反例—— 那是一个完整的、端到端的、能被证明的电路: 从”哪个头做什么”到”为什么必须两层”到”消融掉能力就没了”。
归纳头之所以能说清楚,是因为它足够小、足够具体、有明确的输入输出。
这个领域现在的主流判断是:小的、可验证的电路能被理解; “整个模型在做什么”这种大问题,目前还没有可用的方法。
所以务实的做法是:不要问”这个模型可解释吗”, 问”这个具体的电路能不能被验证”。
4.22.1 一个必须先回答的问题:为什么用这么小的模型
本章整章都在分析一个 2 层、4 头、42 万参数的模型。 读者有权问:这么小的模型,结论有意义吗?
诚实的回答分三层。
第一层:小模型上成立的结论,大模型上不一定成立。
这一点本章反复标注了:
- “第 0 层比第 1 层重要 6 倍”——只在 2 层模型上有意义
- “没有归纳头”——很可能只是因为太小
- “头之间有冗余”——大模型的冗余模式可能完全不同
所以本章的结论首先应该读成”关于这个模型的结论”, 其次才是”关于 Transformer 的一个例子”。
第二层:但方法是可以迁移的。
本章真正想教的不是那些数字,而是这套流程:
| 方法 | 在小模型上 | 在大模型上 |
|---|---|---|
| 头部画像 | 8 个头,一眼看完 | 上千个头,要分组 |
| 消融 | 8 次前向 | 要抽样或分组 |
| 随机对照 | 秒级 | 秒级(同样便宜) |
| 归纳头指标 | 秒级 | 秒级 |
| 探针 | 分钟级 | 分钟级 |
只有前两项受规模影响,后三项完全一样。 而”配随机对照”“做因果检验”这两条原则,和规模无关。
第三层:小模型有一个大模型没有的好处——你能看完。
在一个上千个头的模型上做”头部画像”,你会得到一张一千行的表, 然后你必须写脚本去筛,筛完之后你并不知道自己漏了什么。
本模型的 8 个头,你可以一个一个看过去, 然后对每一个形成判断。 这种”完整覆盖”是小模型独有的。
4.23 这和第 1、2 章的选择是一致的
回顾第 1 章:为了复现”去掉 \sqrt{d_k} 会怎样”, 我们用了 42 万参数的模型——因为在那个规模上, 实验可以在几分钟内跑完,可以反复做对照。
第 2 章:不同深度的对照(6/12/24/48 层),每个都只有几十万参数。
本书从头到尾都在做同一个取舍: 用能完整分析的模型,讲清楚机制;而不是用真实的规模,讲一个看不清的故事。
代价是结论的适用范围有限。 这个代价在本章标注得最清楚—— 因为本章的结论很大程度上依赖”模型有多小”。
4.23.1 如果只有时间做一个实验,做哪个
在本章的五个实验里,如果必须砍掉四个,保留哪个?
答案:随机对照。
理由:
- 头部画像本身不产生结论,它只产生候选
- 消融给了因果,但如果没有对照,你不知道 Δ = 0.03 算大算小
- 归纳头是一个特例,不是通用方法
- 探针需要一个正确的属性选择,很容易做错
而随机对照是一个”元实验”:它给所有其他测量提供零点。
本章的数据完全支持这个判断:
| 没有随机对照会怎样 | 实际发生 |
|---|---|
| 8.3 节的”对角 0.157”会被当成”盯住自己” | 实际是均匀基线 |
| 归纳头指标的 0.028 会被当成”弱归纳头” | 实际是几何基线 |
| “熵 0.958”会被当成”模型没学到东西” | 实际是”训练后方差放大 5000 倍”的证据 |
三个误判,都是靠同一条对照线纠正的。
任何”我测到了 X”的声称,都要配一个”如果什么都没发生, 我会测到什么”。
- 测注意力模式 → 配随机模型
- 测探针准确率 → 配多数类基线 + 词嵌入层
- 测消融效应 → 配逐位置分布(而不只是均值)
- 测电路存在 → 配几何基线
这四种对照,本质上都是在回答同一个问题: “零假设长什么样?”
而”零假设长什么样”这个问题,只能测,不能猜—— 本章那个 0.0284 就是测出来的,猜不出来。
4.24 从观察到结论:一条完整的推理链
前面几节各自给了一种检验。这一节把它们串成一条链—— 这是本章最实用的一节,因为它给出的是一个可以照着走的流程。
4.24.1 六个台阶
从”我看到了一个模式”到”这个模式是真的”,中间有六级台阶。 每一级都比上一级贵,所以不要一上来就做最贵的。
| 级 | 问题 | 方法 | 成本 |
|---|---|---|---|
| 1 | 这个模式在这个输入上存在吗? | 画图/统计 | 分钟 |
| 2 | 换个输入还在吗? | 多组输入重测 | 分钟 |
| 3 | 随机模型也有吗? | 随机对照 | 分钟 |
| 4 | 它在数据里常见吗? | 分层统计 | 十分钟 |
| 5 | 拿掉它行为变吗? | 消融 | 十分钟 |
| 6 | 只拿掉它、不动别的呢? | 定向干预(投影) | 半小时 |
关键在”从下往上”和”从贵到便宜”的顺序: 先用便宜的筛,筛不过就别做贵的。
4.24.2 用本章的 L0H1 走一遍
第 1 级:模式存在吗?
「空山不見人」上,L0H1 的前一位权重是 0.45。 存在。 这一步没花任何成本——画一张图就看到了。
第 2 级:换个输入还在吗?
三条诗、71 个位置上的平均是 0.450。 在。 而且这个平均值本身就已经是”多次测量”了。
第 3 级:随机模型也有吗?
随机模型的对应量是 0.115。 0.450 是它的 3.9 倍。 随机模型不会产生这个模式。
第 4 级:它在数据里常见吗?
这一级本章做得不够。“前一位权重 0.45”是一个平均量。 它在什么条件下更高?标点位置?句首?生僻字后面?
没做这一级的后果是:我们知道 L0H1 平均而言在看前一位, 但不知道它什么时候不看。
第 5 级:拿掉它行为变吗?
Δ = +0.1772,是八个头里最大的。 变。 而且变得不小。
第 6 级:定向干预。
没做。 消融拿掉的是整个头(可能同时抹掉多条信息), 要精确地测”前一位这一条路”,需要构造一个只针对它的干预。
4.25 这条链走完之后的结论是什么
“L0H1 是一个在做 bigram 的头,拿掉它损失涨 0.177。”
注意这个结论的边界:
- 它说的是这一个模型、这一个任务、这一个数据分布
- 它说的是平均行为,不是”它在每个位置都这样”
- 它没有说为什么恰好是前一位,而不是”模型决定要做一个 bigram”
但它是可靠的——因为六个台阶里走过了四个, 而且第 3 级(随机对照)给了一个硬门槛(0.115), 第 5 级(消融)给了因果证据。
一个走完四级链条的结论,比一个”看起来很有道理”的解释强得多。
4.25.1 反过来:本章哪个结论最弱
诚实起见,把本章的结论按链条完整度排一下:
| 结论 | 走到了第几级 | 强度 |
|---|---|---|
| 训练造出了结构(熵方差放大 5000 倍) | 3(对照) | 强 |
| L0H1 是 bigram 头 | 1,2,3,5 | 较强 |
| 第 0 层比第 1 层重要 6 倍 | 1,5 | 中(缺对照) |
| 头之间有冗余(28 组全负) | 1,5 | 中 |
| L1H0 依赖 L0H0 | 1,5 | 弱(缺第 6 级) |
| L0H0 是”复制/加权”头 | 1 级都没走完 | 只是假设 |
| 模型没有归纳头 | 1,2,3 | 较强 |
| 第 1 层编码了”下一个是不是标点” | 1,3 | 中(缺因果) |
“L0H0 是复制头”是本章最弱的一条——它连第 1 级都没走完 (我们说它”可能”在复制,但没测它的输出和输入的余弦相似度)。
把这张表列出来,比假装所有结论一样可靠要好。
任何一次”我发现了 X”的汇报,都可以配一张这样的表:
好处有两个:
- 逼自己说清楚每条结论的证据强度,而不是笼统地说”我们发现”
- 给读者一个权重——他可以据此决定信到什么程度
第三章的结论表、第二章的排查表,本质上是同一个东西。 这本书反复在做的一件事,就是把”结论”和”结论的可靠性”分开写。
4.25.2 陷阱七:把”训练造出了结构”当成”结构有意义”
4.5 节的方差对比(0.0001 → 0.577)证明了训练造出了结构。 但”结构”和”有意义的结构”是两回事。
训练可以让头变得高度专门化——专门化到做一件完全无用的事。
举个极端的例子:假设某个头在训练过程中被推成了 “只在遇到某个罕见字时激活”——它在训练数据上可能确实降低了 loss (因为那个罕见字后面总是跟着同一个字), 但在测试集上它是纯噪声。
“这个头的注意力模式很清晰” ≠ “这个模式对模型有用”。
这两件事要分开测:
| 断言 | 测法 |
|---|---|
| 训练造出了结构 | 与随机对照(做了,方差放大 5000 倍) |
| 这个结构有用 | 消融(做了,第 0 层 6 倍于第 1 层) |
| 这个结构为什么有用 | 需要定向干预(没做) |
本章走完了前两步,第三步没做。
上面说”一个头可以被训练成只做一件无用的事”。这真的会发生吗?
(答案:会,但有个限制。
如果那个头做的事完全无用,梯度会把它推回去—— 因为它的输出会参与 loss 的计算,无用就意味着它对 loss 没有贡献, 那它的权重就没有理由保持那个形状。
所以”完全无用”的专门化不稳定。
但”在训练集上有用、在测试集上无用”的专门化是稳定的—— 这就是过拟合。在这个 42 万参数、25 万字语料的模型上, 过拟合是可能的(参数量不小)。
怎么检测:看这个头在验证集上的行为和训练集上是否一致。 本章的画像是在几条固定的诗上测的——那是训练集还是验证集? 两条都可能(那几条诗可能出现在训练数据里)。
这是一个本章没有控制的变量。 严格的做法是明确区分 “训练时见过的文本”和”没见过的文本”,分别测画像。
4.25.3 陷阱八:把”一个头的模式”当成”模型的能力”
这是最隐蔽的一条:从局部机制推出整体能力的结论。
错误推理的样子:
我们发现 L0H1 在看前一位,权重 0.45。 所以模型的”语言能力”主要来自 bigram 统计。
为什么这是错的:L0H1 只是 8 个头里的一个。 即使它完全在做 bigram,另外 7 个头在做什么,我们没测。
正确推理的样子:
L0H1 的前一位权重是 0.450,显著高于随机基线 0.115。 拿掉它,验证集 loss 从 4.1489 涨到 4.3261。 所以模型对”前一位”这个信息的依赖,有相当一部分走的是 L0H1 这条路。
注意差别:前者是”模型的能力来自 X”,后者是”模型对 X 的依赖有一部分走这条路”。 后者是能测的,前者不是。
4.26 还有一个更麻烦的问题:我们不知道”另外 7 个头在做什么”
本章的画像表给了八个头四个数。四个数能覆盖多少”头在做什么”的可能性?
答案:很少。
考虑这些模式,四个量一个都测不出来:
- “看句首的第一个字”(距离大但方向固定,平均距离会很大但没区分度)
- “看上一个标点”(可变距离,平均下来是个中间值)
- “看词的边界”(字符级模型上没有明确的词)
- “看韵脚”(4.9 节排查过,没找到)
画像表是一个”粗筛”,它筛掉的是”明显有某种简单模式”的头。 剩下的头里可能还有很多结构,只是不在我们选的四个维度上。
这就是为什么本章最后只能说”第 0 层比第 1 层重要”这种粗粒度的结论—— 细粒度的结论需要更细的测量维度,而维度是你选的, 模型没义务按你选的维度分化(4.3 节说过这句话)。
有一个通用做法:看残差。
对每个头,把它的注意力矩阵减去”用四个统计量能解释的部分”, 看剩下的是什么。
# 简单版:看注意力矩阵的"意外之处"
# 比如:对每个位置,减去它和"前一位头"的平均模式
A_resid = A - A.mean(0) # 减去所有位置的平均分布
# 剩下的尖峰就是这个头"在特定位置上额外做的事"如果某个头在绝大多数位置上都和平均分布一样, 只在少数位置有尖峰——那四个统计量(都是平均量)就把这些尖峰抹掉了。
本章 4.4.5 节的逐位置 Δ 分析就是在做同一件事: 把平均拆开,看分布。结果发现 L1H3 的 Δ 有正有负、标准差和均值同量级。
平均量掩盖分布,这在本书里出现过四次了(第 3 章两次,本章两次)。
4.27 一次完整的排查:我们想找一个”韵脚头”
前面几节讲的是方法。这一节记录一次真实的分析过程——包括走错的路。 它的价值不在于结论,而在于展示”读图”这件事在实际操作中会怎么跑偏。
4.27.1 起因:诗有一个明显的结构
唐诗有韵脚。近体诗里,偶数句的最后一个字押同一个韵: 「空山不見人,但聞人語響。返景入深林,復照青苔上。」——「響」和「上」押韵。
一个自然的问题是:模型有没有专门的”韵脚头”?
这个假设很有吸引力:具体、有语言学意义、而且能画出一张漂亮的图。
4.27.2 第一次尝试:先问”任务需要吗”
在第 2 章吃过两次亏之后(RoPE 外推、上下文长度), 这次我们先做前置检查:模型在韵脚位置的损失,和别处有区别吗?
语料是每行一联的格式,所以”韵脚”是可以精确定位的:每行「。」前面的那个字。
按”下一个字符是什么”给所有位置分组,测各自的交叉熵:
| 下一个字符是 | loss | 位置数 |
|---|---|---|
| 句号(韵脚位置) | 0.246 | 18,378 |
| 逗号 | 0.307 | 18,568 |
| 其他 | 4.507 | 213,920 |
| 整体 | 3.884 | 250,866 |
4.28 这张表的第一个意外:差距比预想的大得多
预测标点的 loss 是 0.25–0.31,预测普通字符是 4.51。差了 15 倍。
这不是”稍微容易一点”——标点位置几乎是确定的。
为什么?因为诗的句长是固定的(五言或七言)。 模型只要数到第 5 个字,就知道下一个必然是「,」或「。」。
所以”韵脚位置的 loss 低”这件事,和”押韵”没有关系。 它低是因为句长固定——一个纯粹的”数位置”就能利用的规律。
这直接改变了整个排查的方向:我们要找的不该是”韵脚头”, 而是”数位置的头”。但”数位置”这件事,第 2 章讲位置编码时已经覆盖了。
4.28.1 第二次尝试:看韵脚位置的注意力
但”标点容易预测”不等于”注意力模式没变化”。也许第 1 层在句尾做了别的事。
测法:对每个头,比较韵脚位置和位置匹配的对照位置的平均注意力距离。
4.29 我们的第一版对照选错了
第一版里,对照组取的是”序列开头那些非韵脚位置”。
结果整张表看起来都在说”韵脚位置看得远得多”——八个头全部如此。
但这是纯几何效应。 平均距离随位置 i 单调增长 (在位置 2 最多只能往回看 2 格)。韵脚在行尾(i 大), 对照在行首(i 小)——比的是两个不同的东西。
改成位置匹配的对照(同一行里往前挪两格的位置,绝对下标接近), 数字才变得可读。
这个错误和本章 4.5 节讲的”因果掩码的几何基线”是同一件事: 任何和位置相关的量,都必须做位置匹配才能比较。
4.29.1 实测:位置匹配之后
每个头的平均注意力距离(括号里是前一位权重):
| 头 | 韵脚位置 | 对照位置 | 差 |
|---|---|---|---|
| L0H0 | 1.15 (0.047) | 1.18 (0.048) | −0.03 |
| L0H1 | 3.31 (0.472) | 3.29 (0.446) | +0.02 |
| L0H2 | 5.88 (0.074) | 5.47 (0.056) | +0.41 |
| L0H3 | 5.13 (0.093) | 4.15 (0.091) | +0.99 |
| L1H0 | 5.07 (0.096) | 2.03 (0.041) | +3.03 |
| L1H1 | 3.06 (0.649) | 4.89 (0.331) | −1.83 |
| L1H2 | 12.17 (0.111) | 5.52 (0.057) | +6.65 |
| L1H3 | 10.71 (0.053) | 8.60 (0.146) | +2.10 |
4.30 这张表里有一条清晰的分界线
第 0 层的两个”有明确模式”的头,完全不理会位置。
- L0H0(对角 0.769):差 −0.03——它在句尾和句中做的事情一模一样
- L0H1(前一位 0.450):差 +0.02——它也不管
这两个头是”位置无关”的:它们执行的是和内容绑定的操作, 在哪儿都一样。
第 1 层的四个头全部表现出明显的位置依赖,而且方向不一样:
- L1H1 看得更近:距离从 4.89 掉到 3.06, 前一位权重从 0.331 涨到 0.649——几乎翻倍。 它在句尾变得极度关注紧邻的前一位。
- L1H2 看得更远:距离从 5.52 涨到 12.17——翻了一倍多。
- L1H0、L1H3 也都往远处看。
所以模型确实在句尾做了不同的事——但方式不是”有个头去押韵”, 而是”第 1 层的注意力模式整体改变了”。
4.30.1 这个结果怎么解读
“L1H1 在句尾更看重前一位”意味着什么?
一个可能的解释:句尾的字需要和前面配合。 「響」和「上」押韵——如果模型要在句尾”押上前面的韵”, 它需要知道上一句末尾用的是什么韵字。
但 L1H1 看的是紧邻的前一位,不是上一句的末尾。 它看不到韵脚。
另一个解释更简单:在句尾,模型要预测「,」或「。」。 而具体是逗号还是句号,取决于这一句到没到头—— 也就是取决于前面几个字的位置。L1H1 看前一位,可能是这个链条的一环。
两种解释我们都没有进一步验证。 要区分它们, 需要一个针对 L1H1 在句尾位置的定向干预。
这次排查没有找到”韵脚头”。但它产出了三件更有用的东西:
① 一个前置检查:在找任何”XX 头”之前,先测任务对 XX 的依赖。 这次的前置检查直接改变了问题的方向—— 从”找韵脚头”变成”找数位置的头”。
② 一个测量陷阱:位置相关的量必须做位置匹配。 我们第一版对照选了序列开头,整张表被几何效应污染。
③ 一条真实的分界线:第 0 层的头位置无关,第 1 层的头位置依赖。 这条结论本身是有价值的——它说明两层在做性质不同的工作。
“什么也没找到”和”找到了分界线”可以是同一件事的两种说法, 取决于你测量得够不够细。
回顾第 2 章的 RoPE 外推实验:我们想验证”RoPE 能不能外推”, 结果三种位置编码都没有明显差异。
当时的原因和这次一模一样:那个任务对长程依赖的需求很弱, 所以测不出位置编码的差别。
同一个陷阱,在相隔两章的两个实验里各踩一次。
现在的做法是:测任何”长程机制”之前,先测任务的依赖强度 (第 2 章的上下文长度实验就是干这个的:8 → 128 只改善 0.64)。
这个”前置检查”应该成为标准流程的第一步。
4.31 交互实验
4.31.1 实验 4-1 注意力图实验室
用真实的注意力权重(本章那个模型在几条唐诗上算出来的), 可以逐层逐头地切换,看每个头的注意力图长什么样。
动手:先把八个头都看一遍,记下你觉得”最有意义”的那一个。 然后再看随机模型(右上角可以切)的注意力图—— 你会发现随机的头也有”形状”。 这个对比是本章 4.5 节的现场版。
4.31.2 实验 4-2 消融实验室
逐个屏蔽头,实时看验证集 loss 和生成文本怎么变。
动手:先把第 0 层的四个头各屏蔽一次,再把第 1 层的四个各屏蔽一次。 感受一下那 6 倍的差别。 然后问自己: 如果只看注意力图,你能预测出这个差别吗?
4.32 上手实现
4.32.1 一次完整的头部解剖
把本章用到的工具串成一个可复用的流程。
"""给任意一个训练好的模型做一次完整的头部解剖。"""
import torch, math, json
from tiny_gpt import Config, TinyGPT
def dissect(ckpt_path, texts, n_batch=20):
"""返回:画像表 + 消融表 + 归纳分数。"""
ck = torch.load(ckpt_path, map_location="cpu", weights_only=False)
cfg = Config(**ck["cfg"]); cfg.device = "cpu"
model = TinyGPT(cfg); model.load_state_dict(ck["state_dict"]); model.eval()
stoi = {c: i for i, c in enumerate(ck["itos"])}
# ① 画像
prof = head_profile(model, stoi, texts)
# ② 消融
base = eval_loss(model, ck["itos"])
abl = {}
for L in range(cfg.n_layer):
for H in range(cfg.n_head):
h = ablate_head(model, L, H)
try:
abl[f"{L}-{H}"] = eval_loss(model, ck["itos"]) - base
finally:
h.remove()
# ③ 归纳头
ind = induction_score(model, cfg)
return {"profile": prof, "ablation": abl, "induction": ind}三个使用要点:
① 画像要在多条文本上测。 一条文本上的”模式”可能是巧合。 本章用三条,71 个位置。
② 消融要报告 Δ 而不是绝对值。 基线 loss 随模型而变, 只有差值能横向比较。
③ 归纳头要配随机对照。 这个指标的”零假设水平”不是一个显然的数 (本模型是 0.0284),必须实测出来。
能,但要改一处:真实模型的头有几百上千个,逐个消融太慢。
工业界的做法是分组消融: 按画像(比如”对角权重高的”、“熵低的”)把头分成若干组, 一次消融一组。这样几十次前向就能扫完。
代价是丢失了头与头之间的差别——你只能得到”这一类头整体重不重要”。 这是算力换分辨率的取舍,不是方法上的退步。
4.32.2 完整代码:一次头部解剖
下面是可以直接跑的完整流程(notebooks/run_ch4_case.py 的骨架)。 每个函数都能单独用。
import torch, math, itertools
from tiny_gpt import Config, TinyGPT
# ── 画像 ────────────────────────────────────────────────────
@torch.no_grad()
def head_profile(model, stoi, texts, n_layer, n_head):
"""四个统计量:归一化熵、对角、前一位、平均距离。"""
acc = {(L, H): [] for L in range(n_layer) for H in range(n_head)}
for text in texts:
ids = [stoi.get(c, 1) for c in text][:model.cfg.block_size]
model(torch.tensor([ids]))
for L, w in enumerate(model.last_attns): # (1, h, T, T)
for H in range(n_head):
m = w[0, H]
for i in range(m.shape[0]):
row = m[i, :i + 1] # 因果掩码:只看 j <= i
row = row / row.sum()
ent = -(row.clamp_min(1e-12).log() * row).sum().item()
acc[(L, H)].append({
# 熵除以 log(i+1):把"可选项个数"这个几何因素除掉
"ent": ent / math.log(i + 1) if i > 0 else 0.0,
"diag": row[i].item(),
"prev": row[i - 1].item() if i > 0 else 0.0,
"dist": sum((i - j) * row[j].item() for j in range(i + 1)),
})
out = {}
for (L, H), rows in acc.items():
mean = lambda k: sum(r[k] for r in rows) / len(rows)
out[f"{L}-{H}"] = {"layer": L, "head": H,
"entropy": round(mean("ent"), 4),
"diag": round(mean("diag"), 4),
"prev": round(mean("prev"), 4),
"distance": round(mean("dist"), 3)}
return out三个实现要点:
① row = m[i, :i+1] 之后要重新归一化。 注意力矩阵本身已经归一化过, 但取出下三角之后和不再是 1(因果掩码在 softmax 之前生效, 所以完整的一行和是 1,但那是包含未来位置的——未来位置是 0, 所以其实取不取都一样)。这一步是为了保险,不影响结果。
② 熵除以 \log(i+1)。 这是 4.5 节讲的”减掉几何基线”。 不除的话,位置 5 和位置 30 的熵不可比。
③ 平均距离要按位置算,不能按整条序列。 位置 i 的最大距离是 i, 不是 T。在一整条序列上取平均会把不同位置混在一起。
4.32.3 完整代码:消融
def ablate_head(model, layer, head):
"""屏蔽一个头:在输出投影之前,把该头的特征切片置零。"""
d_head = model.blocks[layer].attn.d_head
def hook(mod, inp, out):
# proj 的输入是 (B, T, C)——头维度已经被拼回特征维了
y = (out[0] if isinstance(out, tuple) else out).clone()
y[:, :, head * d_head:(head + 1) * d_head] = 0.0
return (y,) + out[1:] if isinstance(out, tuple) else y
return model.blocks[layer].attn.proj.register_forward_hook(hook)
def ablation_table(model, val, cfg, n_layer, n_head):
"""单头消融 + 成对消融。"""
base = eval_loss(model, val, cfg)
single = {}
for L in range(n_layer):
for H in range(n_head):
h = ablate_head(model, L, H)
try:
single[f"{L}-{H}"] = eval_loss(model, val, cfg) - base
finally:
h.remove() # ← 一定要 remove,否则 hook 会累积
pairs = []
for a, b in itertools.combinations(single, 2):
La, Ha = map(int, a.split("-")); Lb, Hb = map(int, b.split("-"))
ha = ablate_head(model, La, Ha)
hb = ablate_head(model, Lb, Hb)
try:
d = eval_loss(model, val, cfg) - base
finally:
ha.remove(); hb.remove()
pairs.append({"pair": (a, b), "delta": d,
"linear": single[a] + single[b],
"interaction": d - single[a] - single[b]})
return {"base": base, "single": single, "pairs": pairs}finally: h.remove() 不能省
register_forward_hook 返回一个 handle,不 remove 的话 hook 会一直挂着。
后果:循环里第二次 ablate_head 时,上一个 hook 还在。 你以为是”只屏蔽了 L0H1”,实际上是”屏蔽了 L0H1 和 L0H0”。
这个 bug 的症状是”消融效果单调递增”——每测一个头, 前面测过的所有头都是屏蔽状态。数字看起来很”合理”(越往后越大), 所以不容易发现。
用 try/finally 是最省事的防法:即使 eval_loss 抛异常, hook 也会被清掉。
这是第 2 章讲过的”状态泄漏”的又一个实例—— 和 model.eval() 忘了调、缓存忘了清是同一类问题。
4.32.4 完整代码:归纳头
@torch.no_grad()
def induction_score(model, n_layer, n_head, n_seq=40, rep=4, seed=0):
"""在人为重复的序列上,测每个头"看向上次出现之后那一位"的比例。"""
g = torch.Generator().manual_seed(seed)
P = model.cfg.block_size // rep
score = {(L, H): [] for L in range(n_layer) for H in range(n_head)}
for _ in range(n_seq):
pat = torch.randint(3, model.cfg.vocab_size, (P,), generator=g)
ids = pat.repeat(rep).unsqueeze(0)
model(ids)
for L, w in enumerate(model.last_attns):
for H in range(n_head):
m = w[0, H]
for i in range(P, ids.shape[1]): # 第二遍起
j = i - P # 上次出现的同一位置
if j + 1 >= i:
continue
score[(L, H)].append(m[i, j + 1].item()) # ← j+1,不是 j-1
mean = lambda xs: sum(xs) / len(xs) if xs else 0.0
return {f"{L}-{H}": round(mean(v), 4) for (L, H), v in score.items()}j + 1 那个下标是全部代码里最容易写错的一处(我们写错过一次, 而且它不报错、只给出一个同样在噪声水平的分数)。
自查方法:跑一遍随机模型。 如果随机模型的分数和训练模型差不多, 要么是这个模型真没有归纳头,要么是你的下标写错了。 这两种情况必须靠额外的证据区分——本章靠的是”随机基线是 0.0284 这个窄带, 而训练模型的分数大部分低于它”。
如果不想跑随机对照,有一个更快的自查: 把 j + 1 改成 j(指向”上次出现的那一位”本身)。
对真正的归纳头,指向 j+1 的分数应该明显高于指向 j 的分数 (它要抄的是”后面那个”,不是”那个”本身)。
如果两个分数差不多,说明这个指标测不出归纳行为—— 要么模型没有,要么实现有问题。
代价是这个自查不能区分”没有归纳头”和”写错了”, 但它能快速告诉你”这个测量有没有分辨力”。
4.32.5 一份可以带走的清单
写任何”我从模型里读出了 X”的分析时,按这个顺序做:
- 先说清楚 X 的定义(A_{ij} 是路由比例,不是重要性)
- 给出测量方法和代码
- 在多个输入上测(一条输入上的模式可能是巧合)
- 配随机对照(这是零假设的分布)
- 配一个因果实验(消融或干预)
- 报告分布的完整形态(不只是均值)
- 说清楚结论的边界(哪个模型、哪个任务、哪一层)
七步里最容易跳过的是第 4 步和第 6 步—— 因为第 4 步看起来”浪费”(一个没训练的模型有什么好看的), 第 6 步看起来”啰嗦”(平均值不就够了吗)。
而本章所有被数据纠正的错误,都出在这两步上。
4.32.6 把这一套搬到真实模型上
本章全程用的是一个 2 层 4 头、约百万参数的小模型。这不是偷懒,是必需 (理由在 4.8 节)。但方法本身是可搬的——你迟早会想在自己的模型上跑一遍。
搬过去的时候,有五处会不一样。
一、头的输出切片不再是一个 view 能解决的。
本章的 TinyGPT 里,多头输出被 transpose + reshape 拼回特征维, 所以 proj 的输入是 (B, T, C),切 [:, :, head*d_head:(head+1)*d_head] 就够。
但很多实现(包括 HuggingFace 的多数模型)走的是另一条路: proj 是一个 nn.Linear,它自己把 (B,T,C) 映射到 (B,T,C), 中间没有”头”这个维度。
这时正确的做法是切 proj 的权重矩阵,而不是切它的输入:
# HuggingFace 风格:把某个头对应的输出列整块置零
W = model.blocks[L].attn.proj.weight # (C_out, C_in) 或 (C_in, C_out)
lo, hi = H * d_head, (H + 1) * d_head
with torch.no_grad():
W[:, lo:hi] = 0.0 # 具体是哪个轴,要 print 出来看注意”具体是哪个轴”这件事必须自己确认。 nn.Linear 的权重形状是 (out_features, in_features),但有些实现会转置存。 打印一次 W.shape 并和 d_model 比对,比查文档快。
二、层和头的名字不一样。
本章用 model.blocks[L].attn.proj。 HuggingFace 是 model.transformer.h[L].attn.c_proj(GPT-2) 或 model.model.layers[L].self_attn.o_proj(Llama 系)。 先用 model.named_modules() 把结构打出来,再写钩子:
for name, mod in model.named_modules():
if any(k in name for k in ("proj", "attn", "mlp")):
print(f"{name:<60} {type(mod).__name__}")三、真实模型的注意力张量可能不落地。
FlashAttention 和很多融合 kernel 根本不返回注意力矩阵—— 它们把 QK^\top 算在片上,直接用掉,不回写显存。
所以”读注意力图”这件事,在有些模型上需要先关掉融合 kernel:
# HuggingFace:强制走 eager 实现,注意力矩阵才会返回
model = AutoModelForCausalLM.from_pretrained(
name, attn_implementation="eager", torch_dtype=torch.float32)这一步会显著变慢、变占显存(eager 实现要物化 T \times T 的矩阵)。 在 4096 上下文、32 头上,一层的注意力矩阵就是 4096^2 \times 32 \times 4 字节 ≈ 2 GB。 所以真实模型上通常只看一层、一个 batch、一条短序列。
四、真实的头数是几十,不是四个。
Llama-3-8B 有 32 层 × 32 头 = 1024 个头。 逐个画出来是不可能的。
这时要做的是先筛:
# 按"这个头在验证集上的平均熵"排序,先看最极端的十几个
ent = {name: attention_entropy(w) for name, w in all_heads.items()}
top = sorted(ent.items(), key=lambda x: x[1])[:16] # 最尖的 16 个
bot = sorted(ent.items(), key=lambda x: -x[1])[:16] # 最平的 16 个“最尖”和”最平”两端,通常是信息量最大的地方。 中间那 900 多个头彼此差别很小。
五、消融的代价随规模爆炸。
本章消融一个头要跑 20 个 batch。 真实模型上,一次前向就是几秒到几十秒,1024 个头逐个消融是几个小时。
真实做法是: - 只对筛出来的十几个候选头做逐个消融 - 或者做分组消融(一层一层拿掉、一组的头一起拿掉) - 或者用 ACDC 那类自动化方法(延伸阅读 ⑦)做边级剪枝, 它把”评估多少次”从”头数”降到了”候选边数”,但通常还能再筛一轮
不是任何一条技术步骤,而是”对照”。
本章的每一个结论都配了一个对照:随机初始化模型、随机抽头、位置匹配的对照组。
真实模型上,随机初始化对照很容易做(把权重重新初始化一遍) ——但它几乎没有信息量。
因为真实模型是在海量数据、长时间上训出来的, “随机初始化和训练后差多少”这个问题,答案永远是”差很多”。 这个对照太弱了。
真实模型上有意义的对照,是”任务对照”: - 想让某个头负责”翻译”,就在翻译任务和同分布的非翻译任务上分别测 - 想看某个头是否管”句法”,就在语法正确的句子和打乱词序的句子上分别测 - 想看某个头是否管”指代”,就在需要指代消解和不需要的最小对立对上分别测
最小对立对(minimal pair)是真实模型上最有力的工具—— 两个输入只差一个属性,其他全一样,行为差异就只能是那个属性造成的。
本章用的”位置匹配对照”就是最小对立对思想的一个实例(4.9 节)。 把它推广到语义属性,是真实模型上做可解释性的主要方法。
4.32.7 一份可以带走的清单(续)
前面那份清单讲了”读图”的三步。这里补上”做实验”的四条:
| # | 规矩 | 反例 |
|---|---|---|
| 4 | 每个”行为”结论都要配一个同结构的随机对照 | 随机模型也有的模式,不是学出来的 |
| 5 | 每个”重要”结论都要配一次消融,且消融位置要分组 | 全位置消融会掩盖”只在 15% 位置起作用”的头 |
| 6 | 每个”机制”结论都要配一个最小对立对 | 没有对照的”位置差异”可能是距离的几何效应 |
| 7 | 每个数字都要说清楚它是”哪一个池子的平均” | 12 字符例子的 0.360 和全集的 0.0228 差 16 倍 |
这四条和前面三条合起来,就是本章全部的方法论。
注意它们都不是关于”怎么算”的——怎么算在本章是最简单的一部分, einsum 写一遍就完了。难的全在”和什么比”。
4.32.8 排错速查:症状 → 病因 → 处置
这一章的方法本身不难,难的是出错的时候不容易发现—— 可解释性实验的典型故障不是”报错”,而是”跑通了,但结果是错的”。
下面这张表把本章踩过和见过的坑按症状排好,便于对照。
| 症状 | 最可能的病因 | 处置 |
|---|---|---|
| 探针准确率 1.0000 | 标签里含答案(预测”当前字符”而不是”下一个”) | 换成真正的预测任务;加词嵌入层基线 |
| 探针比基线只高一点 | 任务太简单或太难,信息量在分辨率之下 | 先看正类比例;换一个更难/更易的属性 |
| 每个头的熵都接近 1.0 | 模型没训练好,或看的是随机初始化的模型 | 先确认 loss 收敛;跑一遍随机对照 |
| 每个头的熵都接近 0 | 序列太短(前几个位置归一化熵天然小) | 跳过 i=0 和开头几个位置 |
| 消融某个头,loss 下降 | 头学坏了,或者评估用了同一批数据 | 检查是否在训练集上评估 |
| 所有头的消融 Δ 都是 0 | 钩子没生效(层名写错 / 挂在了错的模块上) | 在钩子里 print(out.shape) 确认被调用 |
| Δ 大得离谱(>1.0) | 钩子返回值形状错了,破坏了残差流 | 确认返回的 tuple 结构和原函数一致 |
| 归纳头分数和随机模型一样 | 指标写错了(看 j-1 而不是 j+1) | 用一条构造序列手工验算一个位置 |
| 归纳头分数全都接近 1.0 | 序列重复次数太多,每个位置都能直接看到答案 | 确认 j+1 < i 这个条件生效 |
| 位置分组的结论很漂亮 | 两组的位置分布不一样(几何混淆) | 换成位置匹配的对照组 |
| 换个种子结论就翻盘 | Δ 在噪声量级(本章约 0.003) | 多跑几个种子,报告分布而不是单点 |
| 例子里的数和全集对不上 | 例子的挑选标准把不典型样本挑进来了 | 报数时说明”这是哪一个池子的平均” |
这张表里最值得记住的两行是第一行和倒数第二行。
第一行(探针作弊)是本章第一版真实犯过的错—— 用”当前字符是不是标点”当标签,词嵌入层直接给出 1.0000。 它不会报错,只会给你一个漂亮的结果。
倒数第二行(换种子翻盘)是最容易被忽略的一行。 一个 Δ = 0.03 的结果,在这个模型上的噪声量级(约 0.003)之上, 但如果你把种子从 0 换到 1 就变成 0.01, 那这个 0.03 就只是一个种子的运气。
报告结论时永远要问一句:换一批数据,它还在吗?
本章的材料可以原样做成一节课,不需要 GPU—— 一个百万参数的模型在 CPU 上训练只要几分钟(第 2、3 章的 notebook 里已包含)。
建议的节奏:
| 时段 | 内容 | 形式 |
|---|---|---|
| 0–15 min | 打开实验 4-1,让学生自己猜哪个头在干什么 | 个体操作 |
| 15–35 min | 公布画像表,对照自己的猜测 | 讲授 |
| 35–55 min | 打开实验 4-2,自己动手消融,记录 Δ | 个体操作 |
| 55–75 min | 把全班的 Δ 汇总到黑板上,看有没有人拿到不一样的结果 | 集体 |
| 75–100 min | 讲 L0H0 那个”猜测被推翻”的完整过程 | 讲授 |
| 100–120 min | 让学生自己写一个猜测,并说清楚”要用什么实验才能验证它” | 小组 |
第 4 段(汇总 Δ)是整节课最关键的一段。 如果全班结果一致,说明这个测量是稳的; 如果有人拿到不一样的值,那正好是讨论”噪声量级”和”随机种子”的入口。
第 6 段不要求他们做实验,只要求他们设计实验—— “说清楚用什么实验能验证”这件事,比”跑出一个数”难得多, 也是这一章真正想教的东西。
误解一:“注意力权重高 = 模型认为这个字重要”。
这个误解来自 softmax 那一步被忽略。第 4.1 节专门处理了它, 但讲完之后学生还是会不自觉地滑回去。 有效的纠正方式不是再讲一遍,而是让他们自己算一次: 给一组 QK^\top 的值,让手算 softmax, 然后问”把某一个权重翻倍,输出会变多少”—— 他们会发现输出是加权平均,权重翻倍不等于贡献翻倍。
误解二:“消融掉一个头 loss 不变,说明它没用”。
这个误解比第一个更难纠正,因为它听起来是对的。 纠正的入口是”冗余”(4.4.4 节): 拿掉一个头 loss 不变,可能是因为另一个头顶上了。 要发现这一点,得成对消融——本章测了 28 对,全部是负相互作用。
一个好的课堂提问:“如果你拿掉一个头什么都没发生, 你怎么区分’它没用’和’它有用但被替代了’?”
标准答案是”成对消融”——但那需要 \binom{8}{2}=28 次实验。 如果头数是 32,就是 496 次。 这个数字本身就能让学生理解 为什么真实模型上的可解释性研究需要自动化方法(延伸阅读 ⑦)。
4.32.9 怎么用这份书单
九篇文献,按读的目的分三类:
要动手前先读的(①④):归纳头那篇(①)是本章 4.6 节的直接来源, 它把”什么是归纳头、怎么测、为什么必须两层”讲得比本章细。 数学框架那篇(④)把 QK/OV 分解(本章 4.2 节)放进了完整的代数体系里, 读完你会知道本章那个分解在数学上处在什么位置。
要判断”该不该信”时读的(②③⑤⑥):这四篇是一场持续几年的争论。 ②和③是正反两方,⑤和⑥是这场争论的方法论总结。 本章 4.1 节的立场(“注意力图是证据,不是结论”)就是从这场争论里来的。 如果你只读两篇,读③和⑤——它们各自代表了”最有力的反驳”和”最实用的对策”。
要往下走时读的(⑦⑧⑨):⑧是一个被完整画出来的电路(目标形态), ⑦是把消融自动化的方法(规模化的工具), ⑨解释了为什么”一个头一个功能”这个假设本身就不牢靠(根本限制)。
这九篇加起来,大致就是”机制可解释性”这个研究方向从起点到 2023 年的主干脉络。 文献会继续增加,方法却变得很慢——本章讲的这几件事,十年内大概都还成立。 本章做的事情,是用一个百万参数的模型把它们全部重做一遍—— 规模小到你能亲手验算每一步,但方法一步不少。
4.33 延伸阅读
4.33.1 ① In-context Learning and Induction Heads(2022)
Olsson et al., Anthropic。arXiv:2209.11895。
读它能得到什么:归纳头的发现过程。这篇是本章 4.6 节的原始出处, 而且写法非常值得学——它是从一个现象(模型突然学会上下文学习) 反推到一个具体电路(归纳头),而不是先有电路再找解释。
读的时候注意什么:
- 重点看它怎么”证明”归纳头存在。不是看图,而是三个独立的证据: ① 消融(拿掉它,上下文学习能力掉)② 训练动力学(它的形成和 能力跃升在同一时刻)③ 合成任务(在人为构造的重复序列上直接测出来)。 三个证据指向同一个结论,才叫证明。
- 本模型测不出归纳头(4.6 节)。这篇论文里的模型是几十层的, 规模差距是主要解释。
- 注意”相变”这个词。归纳头不是慢慢长出来的, 是在训练的某一步突然出现的。这类相变现象在本章没有涉及, 但在机制可解释性里很常见。
4.33.2 ② Attention is not Explanation(2019)
Jain & Wallace。arXiv:1902.10186。
读它能得到什么:本章核心立场(注意力图是证据不是解释)的出处之一。 作者用一系列实验说明:注意力权重和特征重要性(梯度、leave-one-out) 经常对不上——换一组同样好的注意力权重,预测几乎不变。
读的时候注意什么:
- 它引发了后续的争论。见 ③。
- 它的实验设计值得学:不是”看图说话”,而是构造对抗性的反例—— “如果注意力真的是解释,那么把权重换掉应该会改变预测; 但实测不变,所以它不是解释”。这是可证伪的写法。
4.33.3 ③ Attention is not not Explanation(2019)
Wiegreffe & Pinter。arXiv:1908.04626。
读它能得到什么:对 ② 的反驳。作者指出 ② 的实验有方法论问题 (比如那些”对抗性”的注意力分布其实是训练不出来的), 并给出了一套更严格的检验流程。
读的时候注意什么:
- 两篇一起读,比只读一篇有用得多。 ② 说”注意力不是解释”, ③ 说”你的证明不成立”——而两者其实都同意:看图说话不够, 但注意力确实携带信息。 本章 4.4 节的立场和这个共识一致。
- 注意 ③ 提出的检验清单,它比 ② 的结论更有操作性。
4.33.4 ④ A Mathematical Framework for Transformer Circuits(2021)
Elhage et al., Anthropic。Transformer Circuits Thread。
读它能得到什么:把 Transformer 看成”残差流 + 一组读写操作”的形式化框架。 本章讲的”注意力头从残差流读、往残差流写”这套语言,就是从这来的。
读的时候注意什么:
- 它定义了 QK 电路和 OV 电路——注意力权重由 QK 决定、 实际搬运的内容由 OV 决定。本章 4.1 节说的”权重高不等于作用大”, 在形式上就是:QK 大但 OV 可能把小内容搬过来。
- 前几节不用全部读懂。第一节(残差流)和第二节(QK/OV 分解) 是最有用的,后面涉及更复杂的电路。
4.33.5 ⑤ 关于探针的方法论(2020)
Hewitt & Liang, Designing and Interpreting Probes with Control Tasks。 arXiv:1909.03368。
读它能得到什么:本章 4.7 节那个”探针作弊”问题的系统性处理。 作者提出控制任务(control task): 用随机的标签训一个探针,看它能达到多高的准确率—— 这个准确率就是”探针选择能力”的下限,必须从真实准确率里减掉。
读的时候注意什么:
- 本章用的”词嵌入层对照”是这套思路的一个简化版。 完整版更严格:还要控制探针的容量、训练步数等。
- 它说明了为什么”线性探针”比 MLP 探针更有说服力: 容量越小,“探针自己学会”的空间越小。
4.33.6 ⑥ 关于”注意力图能不能当解释”的一场完整争论
这个领域的争论值得单独拿出来说,因为它是”一个结论如何被证伪、 又如何被部分恢复”的教科书案例。
2019 年,两篇论文几乎同时出现:
《Attention is not Explanation》(Jain & Wallace)做了四组实验, 核心结论是:注意力权重和特征重要性对不上。 他们的杀手锏是构造对抗性的注意力分布—— 找一组”看起来完全不同但预测结果一样”的注意力权重。 如果注意力真是解释,换掉它应该会改变预测。
《Attention is not not Explanation》(Wiegreffe & Pinter)反驳说: 那些对抗分布是人工构造的,模型自己训练不出它们。 在真实训练出来的注意力分布里,换掉权重确实会改变预测。
谁对? 后来的共识大致是:
- Jain & Wallace 证明了:“注意力权重 = 重要性”这个等式不成立
- Wiegreffe & Pinter 证明了:“注意力完全无信息”这个结论也不成立
- 两者都同意:注意力携带信息,但”读图”不是”解释”
本章的立场和这个共识一致,而且把它落到了可操作的形式上: 用消融来补上因果那一半。
从这场争论里能学到的三件事
第一,一个”反例”的力量取决于它有多真实。
Jain & Wallace 的四组实验里,前三组(相关性分析)说服力一般—— “两个量对不上”可能是因为它们本来就在测不同的东西。 只有第四组(对抗分布)是硬证据,因为它构造了一个具体的反例。 而 Wiegreffe & Pinter 的反驳也正是针对这一组: 你的反例是人工的,现实中不会出现。
第二,“证伪一个强命题”和”证明一个弱命题”是不同难度的事。
打掉”注意力 = 解释”很容易(找一个反例就行)。 建立”注意力能提供什么信息”很难(需要正面构造)。
本章花在”证明有结构”上的篇幅(4.5 节的方差对比)比花在 “打掉错误解读”上的还多,就是这个原因。
第三,争论的最终产物通常不是”谁赢”,而是一条更精确的界线。
这个案例的产物是:“注意力权重是路由证据,不是重要性度量”—— 这句话两边都同意,而且它比争论前任何一方的说法都更准确。
4.33.7 ⑦ 把消融自动化:ACDC(2023)
Conmy et al., Towards Automated Circuit Discovery for Mechanistic Interpretability。arXiv:2304.14997。
读它能得到什么:本章的 4.4 节(消融)和 4.6 节(归纳头)都是手工做的—— 人挑一个头、测它、再挑下一个。这篇论文把它自动化了。
核心想法:从整个计算图出发,反复尝试移除一条边, 看损失变多少;把”移除也没关系”的边全部剪掉。 剩下的子图就是”这个任务真正用到的电路”。
读的时候注意什么:
- 它把本章的消融从”头”细化到了”边”。 一个头内部的 QK 电路和 OV 电路(4.2 节)可以分别剪—— 这比”整个头拿掉”精细得多。
- 代价是计算量:一张图的边数是头的几十倍。
- 它没有解决”读图”的根本问题,只是把”哪一部分重要”做得更细。 最后一步——理解剪出来的子图在做什么——还是要人来做。
4.33.8 ⑧ 一个被完整画出来的电路:IOI(2022)
Wang, Variengien, Conmy, Shlegeris, Steinhardt, Interpretability in the Wild: a Circuit for Indirect Object Identification in GPT-2 small。arXiv:2211.00593。
读它能得到什么:本章的结论是”能被证明的电路很少,这个小模型上连归纳头都没有”。 这篇论文是那个”很少”里最完整的一个例子—— 它把一个具体任务(间接宾语识别)在 GPT-2 small 上的整条电路画了出来, 包括哪些头、它们各自负责哪一步、信号怎么一层层流过去。
和本章的关系:
它是”本章方法论做到极致”的样子。 本章做的每一步它都做了,而且还多了: - 用最小对立对构造数据——两个句子只差一个名字,正确答案因此不同 - 用三种独立的证据交叉验证(消融、注意力模式、激活修补) - 负结果也报——它明确列出”我们试过但没找到作用的头”
读的时候注意什么:
- 它的对象是 GPT-2 small(12 层),本章是 2 层。 层数一多,“电路”才有可能分成几个阶段—— 本章那个 2 层模型里,第 0 层和第 1 层之间没有中间阶段可分。
- 它花了很多篇幅排除替代解释,而不是只展示支持自己结论的证据。 这一部分比结论本身更值得学。
- 它不是”完整解释”——论文自己说得很清楚, 这条电路之外还有很多头的作用没搞清楚。
4.33.9 ⑨ 为什么一个头会管好几件事:叠加(2022)
Elhage et al., Toy Models of Superposition。arXiv:2209.10652。
读它能得到什么:本章 4.4.3 节留了一个没解决的问题—— “我们不知道另外 7 个头在做什么”, 而且很可能同一个头在不同上下文里做不同的事(本章的 L0H1 就是这样: 非标点位置是纯前一位头,标点位置有一小半时间在看自己)。
这篇论文解释了为什么。
核心想法:模型的表示维度是有限的,但要表示的特征远多于维度。 于是模型会把多个特征”叠加”到同一个方向上—— 每个特征只占这个方向的一部分,代价是单个特征的可读性下降。
和本章的关系:
它给”多头多义”提供了一个结构性解释: 一个头看起来在做几件不相干的事,不是因为模型混乱, 而是因为它在一个维度预算下同时编码了多个特征。
读的时候注意什么:
- 它是玩具模型上的结论,不是对真实模型的直接测量。 它有说服力,但它证明的是”这种现象在原理上是可能的、甚至是最优的”, 不是”某个具体的头一定在叠加”。
- 它解释了本章 L0H1 那种”位置依赖的行为切换”: 如果”前一位”和”句法边界”两个特征被叠加到同一个子空间里, 头在不同位置上的行为就会不同——这正是本章测到的。
- 它也让”一个头一个功能”这个默认假设变得可疑。 本章一直在用这个假设(“给头起名字”), 这篇论文提醒你:那个名字可能只对一部分位置成立—— L0H0 的画像就骗了我们一次。
这一章真正要你记住的是
注意力图是一份路由表,不是一份功能说明书。
它告诉你”信息从哪里流过来”。它不告诉你”这条信息起了多大作用”—— 那需要把路切断,看行为变不变。
本章做了三个层次的检验:
| 检验 | 方法 | 本模型的结果 |
|---|---|---|
| 训练有没有造出结构 | 与随机初始化对照 | 有:熵从 0.958 分化到 0.294–0.871 |
| 结构有没有因果作用 | 单头消融 | 分层:第 0 层 6 倍于第 1 层 |
| 有没有已知的具体电路 | 归纳头指标 | 没有:最高分与随机基线齐平 |
三个检验给出三个不同的答案。这正是这个领域的常态—— “注意力可解释吗”这个问题没有单一答案, 取决于你问的是哪一层、哪个头、哪种功能。
而唯一不能用的方法是”看图讲一个好听的故事”。
4.34 本章回顾
把这一章压缩成六句话。
一、注意力图的每一行是一个概率分布,不是一个重要性评分。 行和为 1,权重高不代表贡献大。“重要性”这个词在注意力里没有定义。
二、画像(熵、对角、前一位、距离)是粗筛,不是定义。 它能告诉你”去哪一行细看”,不能告诉你”这个头是什么”。 L0H0 的对角 0.769 背后,是从 0.115 到 1.000 的一整段分布。
三、看图说话不算证据。 本章反复出现的模式是:一个头的异常行为(L0H0 在标点位置对角 0.355、非标点 0.784) 和它的因果重要性(标点位置消融只涨 0.0090)可以完全脱钩。 “看起来在做什么”和”拿掉会怎样”是两个独立的问题。
四、对照实验是本章唯一真正有力的工具。 - 随机初始化模型 → “训练造出了结构”(熵方差 0.0001 → 0.577) - 位置匹配的对照组 → “韵脚头”的候选们其实都没有韵脚偏好 - 词嵌入基线 → 探针的 0.9375 是真的,不是作弊
五、能被证明确实在起作用的电路很少,但确实存在。 归纳头是唯一一个有干净指标、有机制解释、有跨模型复现的电路。 而本章这个小模型上,它不存在——最高分 0.0285,和随机模型的 0.0284 齐平。 “我找了但没找到”是一个合法的结论, 前提是你知道自己在找什么、用什么指标找、指标本身有没有被随机对照校准过。
六、搬到真实模型上,变的不只是代码(4.12.3 节)。 最要紧的是对照变了——随机初始化对照在真实模型上几乎没有信息量, 最小对立对才是主力工具。
最后留一个问题
本章测出:第 0 层的头平均比第 1 层的头重要 6.1 倍。
这个结论能推广吗?
在一个 32 层的模型上,“第 0 层最重要”显然不成立—— 中间的层往往承担最多的”计算”,第 0 层更多是”预处理”。
那本章这个 6.1 倍是什么?
一个可能的回答:这是一个”层数太少”的产物。 只有 2 层的时候,第 0 层无处可退,必须把该做的都做了; 第 1 层可以直接把第 0 层的表示”抄”过去输出。 36 层的时候,每一层都可以偷懒,因为后面还有很多层。
这个解释听起来合理,而且它也是可测的: 把同一个语料、同一个架构,训成 2 层、4 层、8 层三个版本, 看”第 0 层的相对重要性”是不是随层数下降。
这个实验本章没做。 如果你要接着做,它是本章所有”待办”里最值得做的一个—— 因为它测的是一个关于”深度”的普遍问题,而不只是一个玩具模型的细节。
下一章换个话题:上篇到这里把”注意力是怎么算的、能读出什么”讲完了。 下篇从第 5 章开始进入扩散模型——但你会发现, 本章反复出现的那些方法论(对照、消融、最小对立对、把结论和可靠性分开写) 会一字不改地继续用下去。