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 行上,它是一个完全不同的头。

重要0.769 是怎么来的

把上面这些数(再加上没列出的几行)平均一下:

\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 倍”。这个结论只在当前这个模型、 这个任务、这个数据分布上成立。

三个限制:

  1. 模型只有 2 层。 “第 0 层”和”第 1 层”在这个模型里的角色是完全不同的: 第 0 层直接处理词嵌入,第 1 层处理已经混合过的表示。 在 12 层、24 层的模型上,这个 6 倍的结论没有任何理由继续成立。
  2. loss 是一个很粗的指标。 Δ = +0.03 在 loss 上很小, 但它可能对应着生成行为的明显变化——只是平均 loss 把它抹平了。
  3. 消融是”一次一个”。 同时拿掉两个头,效果可能大于两次单独之和 (相互作用),也可能小于(互相替代)。单头消融测不出这件事, 见 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。它有两个缺点:抹平局部效应、对退化不敏感。

更有信息量的做法是看逐位置的损失变化:

# 分别记下基线和消融后每个位置的 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}。

这个现象还有别的解释:

  1. 饱和:损失有上限(最差也就是均匀分布,5.67), L0H0 已经把它推到接近上限,再拿掉 L1H0 也加不了多少
  2. L1H0 在做冗余的事,而这件事的另一个提供者恰好也依赖 L0H0
  3. 纯粹的数值巧合(+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 节那三条原因:

  1. 每头只有 32 维,内积的分辨率有限,匹配容易出错
  2. 只有 4 个头,第 0 层要同时承担”前一位”和”造键”两个职责
  3. 任务是唐诗,重复大多是高频字的偶然撞车,不是有意义的模式重复

“理论上可能”和”实际上发生了”是两件事。 前者用数学证明, 后者只能实测。本章两件都做了:证明了可能性,实测了没有发生。

4.16.1 怎么测:一个干净的指标

归纳头有一个别的头都不具备的特征:它只在”重复出现过”的位置上才起作用。

这个特征直接给出了测法:

  1. 造一条人为重复的序列:随机取 P 个 token,把它重复 4 遍。

    t₁ t₂ ... t_P  t₁ t₂ ... t_P  t₁ t₂ ... t_P  t₁ t₂ ... t_P
  2. 看第二遍及以后的每个位置 i(它对应第一遍的位置 j = i - P)

  3. 测这个位置 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 字的字符表, 唐诗里的重复大多是”的”“不”“春”这类高频字的偶然重复, 不是有意义的模式重复。

提示怎么才能在自己机器上看到归纳头

如果你想让归纳头出现,需要同时改三件事:

  1. 加深到至少 4 层(给”造键”和”匹配”各自留出层)
  2. 换一个真正有重复结构的任务—— 比如让模型学 a b c d a b c d ... 这样的周期序列, 或者用代码(变量名会反复出现)
  3. 加宽:每头维度太小时,“匹配”用的内积分辨率不够

然后重跑这一节的指标。 如果归纳分数跳到 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 头结构上不够—— 这时候要加层、加头,而不是继续调任务。

提示怎么区分”任务不对”和”模型太小”

这是一个设计实验时最常见的两难:结果不符合预期时, 你分不清是任务选错了,还是模型不够大。

做法:做阶梯。

  1. 先固定任务(周期序列),扫模型规模(2/4/8 层)
  2. 如果规模上去之后归纳分数跳起来 → 是模型太小
  3. 如果规模上去也没用 → 是任务或数据的问题

而不是:同时改任务和模型,然后猜是哪个因素起作用。

一次只动一个变量——这条第 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。

探针测的是”可分性”,不是”表征”—— 只要激活里有一个方向和一个标签相关,线性探针就能读到它, 不管模型有没有”意图”编码那个标签。

这是”探针 ≠ 理解”的技术版本。 它不是哲学问题,是一个具体的统计现象。

提示怎么防这个陷阱:控制任务

延伸阅读 ⑤ 那篇论文给的做法是控制任务:

  1. 给你的真实标签 Y 训一个探针,得到准确率 A_{\text{real}}
  2. 把标签随机打乱,再训一个探针,得到 A_{\text{ctrl}}
  3. 只有 A_{\text{real}} - A_{\text{ctrl}} 才是”真实的可分性”

第 2 步的意义:随机标签和输入没有关系, 但探针仍然能从激活里读出某种东西(因为它有容量)。 A_{\text{ctrl}} 就是这个”探针的选择能力”的下限。

本章用的是”词嵌入层对照”——它是这个思路的一个简化版: 如果输入里就有答案,词嵌入层的探针会很高。 但它挡不住”两个属性相关”这种情况—— 那个需要标签打乱。

本章没做标签打乱。 这是探针实验里一个标准的、本可以做的对照。

4.20.1 一个更严格的探针:把”位置”也控制住

还有一个本章没处理的混淆:位置。

“下一个是不是标点”这个属性,和位置高度相关—— 在五言诗里,标点出现在第 5、10、15……个位置。

所以一个探针可能只是在读”当前位置 mod 5”。

怎么区分? 加一个位置对照:

# 探针对照:预测"当前位置 mod 5 是不是 4"
# 如果这个探针的准确率和"下一个是不是标点"差不多,
# 说明前者可能只是在读位置,没有额外的信息

这是”两个属性相关”那个陷阱的一个具体实例, 而且它在本章的数据上非常可能成立(因为诗有固定句长)。

警告那 0.9375 这个数还可信吗

它可信,但它的解读要改。

可信的部分:第 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”的汇报,都可以配一张这样的表:

结论 | 证据等级 | 缺什么 |

好处有两个:

  1. 逼自己说清楚每条结论的证据强度,而不是笼统地说”我们发现”
  2. 给读者一个权重——他可以据此决定信到什么程度

第三章的结论表、第二章的排查表,本质上是同一个东西。 这本书反复在做的一件事,就是把”结论”和”结论的可靠性”分开写。

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 章出现过一次

回顾第 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”的分析时,按这个顺序做:

  1. 先说清楚 X 的定义(A_{ij} 是路由比例,不是重要性)
  2. 给出测量方法和代码
  3. 在多个输入上测(一条输入上的模式可能是巧合)
  4. 配随机对照(这是零假设的分布)
  5. 配一个因果实验(消融或干预)
  6. 报告分布的完整形态(不只是均值)
  7. 说清楚结论的边界(哪个模型、哪个任务、哪一层)

七步里最容易跳过的是第 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 章开始进入扩散模型——但你会发现, 本章反复出现的那些方法论(对照、消融、最小对立对、把结论和可靠性分开写) 会一字不改地继续用下去。