我用 logit lens 看了眼 GPT-2 在"想"什么
我用 logit lens 看了眼 GPT-2 在"想"什么
目标:在 GPT-2 预测下一个词之前,看它逐层的中间状态,而不是只看最终输出。环境是 Google Colab 免费档 + GPT-2 small,零成本可复现,完整代码在文末。记录里有两个和直觉不符的结果:模型答错了,以及"只看排名第一"会得出错误结论。
三个前置概念
- 残差流:一条从头贯穿到尾的向量"传送带"。一个词变成向量放上去,每一层读一份、算一算、加回去(累加,不是替换)。所以某一层传送带上的向量,代表模型到这一层为止的中间状态。
- 反嵌入:模型最后一步,拿传送带上的向量乘一个矩阵,得到词表里每个词的分数,最高的就是预测的下一个词。
- logit lens:不只在最后一层乘,每一层都乘一次,得到"模型在这一层若直接输出会说什么"。
GPT-2 small 有 12 层,每个向量 768 维。
实验一:logit lens 逐层看排名
句子:The Eiffel Tower is in the city of ___。埃菲尔铁塔在巴黎,正确答案 Paris。在每一层做一次 logit lens,记录 Paris 的排名:
| 层 | 这层 top-1 | Paris 排名 |
|---|---|---|
| 0–4 | the |
5918 → 767 |
| 5–6 | England |
279 → 123 |
| 8 | Chicago |
第 8 |
| 9–11 | London |
第 2 |
两个事实:
- top-1 逐层变化:
the(高频词)→England(地名)→Chicago(城市)→London(欧洲城市)。中间状态从"无信息"逐步收敛到"某个欧洲城市"。 - 最终答案是 London,不是 Paris,模型答错。可能原因是 "city of London" 在训练语料里是高频搭配。logit lens 反映的是模型的实际预测,包括错误的预测。
实验二:用 hook 覆盖中间层("换念头")
logit lens 只读取激活。这一步改写激活:在前向计算途中覆盖某一层,观察输出变化。
两句话:
- A:
The Eiffel Tower is in the city of(基线预测 London) - B:
The Colosseum is in the city of(基线预测 Rome)
做法:取出 B 在某一层、最后位置的向量,让模型跑 A,在同一层把 A 该位置的向量覆盖成 B 的,看 A 的预测。对 0~11 每层各做一次:
| 覆盖第几层 | A 的预测 |
|---|---|
| 0–1 | London(不变) |
| 2–8 | Paris |
| 9–11 | Rome |
覆盖靠后的层(离输出近)导致预测变化,这不意外。需要解释的是中间层:2–8 层覆盖后,A 的预测是 Paris——一个 A 和 B 的基线都没有输出的词。"覆盖一层=直接替换答案"无法解释第三个词的出现。
只看 top-1 会得出错误结论
把三个候选的概率打出来,而不只看谁排第一:
A 原始: London 0.081 Paris 0.069 Rome 0.004
覆盖2–8: London 0.077→0.009、Paris 0.069→0.020(两者都在下降)
覆盖9–11:Rome 0.017 ≈ B 基线 0.018
- A 基线里 London(0.081)和 Paris(0.069)接近并列,模型本就把 Paris 当强候选,London 只领先一点。
- 中间层"Paris 当选"不是 Paris 概率上升:Paris 从 0.069 降到 0.020,London 从 0.077 降到 0.009,两者都在降,只是 London 降得更快,于是 Paris 相对靠前。
结论:只看 argmax(排名第一)会得到"中间层把答案改对成 Paris"这个错误判断;看完整概率分布才能得到正确解释——是 London 被压低,不是 Paris 被抬高。
概率分布:模型不确定时的形态
Rome 当 top-1 时概率只有 0.0168。打出 top-15 看其余概率的分布:
0 ' Rome' 0.0168
1 ' E' 0.0166
2 ' L' 0.0164
3 ' T' 0.0149
' San' ' St' ' Pal' (城市名前缀)
12 ' Paris' 0.0081
- Rome 只比单个字母
E高 0.0002。前排是一批单个大写字母和城市名前缀。原因:很多城市名是多 token,模型不确定时把概率分散到"城市名的首个碎片"上。 - 对比不干预时的 top-15:全是完整欧洲城市(London、Paris、Amsterdam、Berlin、Brussels、Barcelona 等),前 15 名概率之和约 0.35;覆盖之后只有约 0.19。头部概率占比越低,分布越平,模型越不确定。
对三段状态的解释:覆盖 B 的向量后,A 原有的"欧洲城市"分布被打散;剩下起主导作用的信号决定 top-1——第 8 层是 Paris(A 自身的 "Eiffel" 通过注意力仍在起作用),第 9 层是 Rome(B 的答案已写入)。
附带搞清的三个问题
这三个不在主线上,但都是跑的过程里冒出来的问题,连问题带结论一起记。
问题一:模型看到的"词",和我们写的单词是一回事吗?
跑之前我默认一个单词就是一个输入单位。打出来看:
print(model.to_str_tokens("The Eiffel Tower is in the city of"))
['<|endoftext|>', 'The', ' E', 'iff', 'el', ' Tower', ' is', ' in', ' the', ' city', ' of']
8 个单词被切成 11 个 token:开头多了个 <|endoftext|>(起始标记),Eiffel 被拆成 E/iff/el 三块。换成中文:
print(model.to_str_tokens("今天的天气真不错"))
['<|endoftext|>', '�', '�', '天', '的', '天', '�', '�', '�', '�', '不', '�', '�', '�']
8 个汉字被切成 13 个 token,大量显示为乱码——GPT-2 几乎没在中文上训练过,一个汉字被拆成多个字节碎片,单个字节不是合法字符,就成了 �。
结论:模型的输入单位是子词/字节(BPE 分词),不是单词。这也解释了前面 top-15 里为什么冒出单个大写字母——不确定时,模型押的是"某个城市名的第一个碎片"。
问题二:向量的某一个维度,是不是对应一个具体概念?
我一开始的猜想是:768 维里,也许第 1 维管"是不是地名"、第 2 维管"是不是人物"……像一张属性表,一列一个概念。
反例是词向量里的经典等式:国王 - 男人 + 女人 ≈ 王后。向量能做这种加减还得到有意义的结果,说明"性别"不是存在某一个维度里,而是空间里的一个方向(多个维度的组合):从"男人"沿这个方向走到"女人",从"国王"沿同一方向走到"王后"。
结论:一个概念对应一个方向,不是单个维度。所以单看向量里某一个分量没有意义,要看整体指向哪。
问题三:这套"方向对应概念"的编码,是谁规定的?
接着问题二:如果"性别"是某个方向,这个方向是谁定的?是模型开发者设计的,还是训练出来的?
是训练自动形成的。哪个方向表示什么概念,是梯度下降为了把"预测下一个词"做得更好而产生的副产物;开发者事先没有指定,模型训完后也不能直接读出"性别在哪个方向"。
结论:内部编码无人指定,是训练自己长出来的,因此对人不透明。可解释性做的就是事后去破译这套编码——本文的 logit lens 和覆盖实验,是最基础的两种破译手段。
以上现象都可以用文末代码复现。
附:完整代码(可复现)
Google Colab 免费档,运行时切 GPU(T4),按顺序运行。
1. 装库
!pip install transformer_lens -q
2. 加载 GPT-2 small
from transformer_lens import HookedTransformer
model = HookedTransformer.from_pretrained("gpt2") # 12 层, d_model=768
3. logit lens:逐层看 Paris 排名
prompt = "The Eiffel Tower is in the city of"
tokens = model.to_tokens(prompt)
logits, cache = model.run_with_cache(tokens)
answer_token = model.to_single_token(" Paris")
print(f"{'层':>3} | {'该层top-1':>12} | {'Paris排名':>8} | {'Paris概率':>9}")
print("-" * 46)
for layer in range(model.cfg.n_layers):
resid = cache["resid_post", layer][:, -1:, :] # 该层、最后位置的768维向量
normed = model.ln_final(resid) # 过最后的 layernorm
layer_logits = model.unembed(normed)[0, 0] # 乘 W_U → 词表分数
probs = layer_logits.softmax(dim=-1)
top = model.to_string(layer_logits.argmax().item())
rank = (layer_logits > layer_logits[answer_token]).sum().item()
print(f"{layer:>3} | {top:>12} | {rank:>8} | {probs[answer_token].item():.4f}")
实测输出:
层 | 该层top-1 | Paris排名 | Paris概率
----------------------------------------------
0 | the | 5918 | 0.0000
1 | the | 4949 | 0.0000
2 | the | 2831 | 0.0000
3 | the | 1270 | 0.0000
4 | the | 767 | 0.0001
5 | England | 279 | 0.0004
6 | England | 123 | 0.0011
7 | Ing | 59 | 0.0028
8 | Chicago | 8 | 0.0283
9 | London | 1 | 0.2015
10 | London | 1 | 0.1515
11 | London | 1 | 0.0687
4. 换念头 + 逐层扫描
prompt_A = "The Eiffel Tower is in the city of" # 基线 London
prompt_B = "The Colosseum is in the city of" # 基线 Rome
tokens_A = model.to_tokens(prompt_A)
tokens_B = model.to_tokens(prompt_B)
_, cache_B = model.run_with_cache(tokens_B)
print("A 基线:", model.to_string(model(tokens_A)[0, -1].argmax().item()),
" B 基线:", model.to_string(model(tokens_B)[0, -1].argmax().item()))
print(f"{'植入层':>6} | {'A 预测':>10}")
for L in range(model.cfg.n_layers):
b_thought = cache_B["resid_post", L][:, -1, :] # 读 B 的向量
def patch_hook(activation, hook): # 写:覆盖 A 的最后位置
activation[:, -1, :] = b_thought
return activation
logits = model.run_with_hooks(
tokens_A, fwd_hooks=[(f"blocks.{L}.hook_resid_post", patch_hook)]
)
print(f"{L:>6} | {model.to_string(logits[0, -1].argmax().item()):>10}")
实测输出:
A 基线: London B 基线: Rome
植入层 | A 预测
0 | London
1 | London
2 | Paris
3 | Paris
4 | Paris
5 | Paris
6 | Paris
7 | Paris
8 | Paris
9 | Rome
10 | Rome
11 | Rome
5. 三段状态对比 + top-15
def show_topk(logits, title, k=15):
probs = logits[0, -1].softmax(-1)
topk = probs.topk(k)
print(f"—— {title} ——")
for i, (p, idx) in enumerate(zip(topk.values, topk.indices)):
print(f"{i:>3} | {repr(model.to_string(idx.item())):>12} | {p.item():.4f}")
print(f"前{k}名之和 = {topk.values.sum().item():.3f}\n")
def patch_at(layer):
b_thought = cache_B["resid_post", layer][:, -1, :]
def patch_hook(activation, hook):
activation[:, -1, :] = b_thought
return activation
return model.run_with_hooks(tokens_A, fwd_hooks=[(f"blocks.{layer}.hook_resid_post", patch_hook)])
show_topk(model(tokens_A), "A 原始(不干预)")
show_topk(patch_at(8), "植入 B 第8层")
show_topk(patch_at(9), "植入 B 第9层")
实测输出:
—— A 原始(不干预) ——
0 | ' London' | 0.0805
1 | ' Paris' | 0.0687
2 | ' Amsterdam' | 0.0314
3 | ' New' | 0.0258
4 | ' Berlin' | 0.0248
5 | ' Hamburg' | 0.0157
6 | ' L' | 0.0146
7 | ' Brussels' | 0.0138
8 | ' Cologne' | 0.0137
9 | ' E' | 0.0127
10 | ' St' | 0.0117
11 | ' Istanbul' | 0.0101
12 | ' B' | 0.0099
13 | ' D' | 0.0096
14 | ' Barcelona' | 0.0091
前15名之和 = 0.352
—— 植入 B 第8层 ——
0 | ' Paris' | 0.0196
1 | ' B' | 0.0188
2 | ' L' | 0.0186
3 | ' E' | 0.0172
4 | ' St' | 0.0138
5 | ' S' | 0.0131
6 | ' G' | 0.0124
7 | ' T' | 0.0115
8 | ' Cologne' | 0.0114
9 | ' D' | 0.0114
10 | ' London' | 0.0093
11 | ' Amsterdam' | 0.0088
12 | ' P' | 0.0079
13 | ' Berlin' | 0.0078
14 | ' San' | 0.0078
前15名之和 = 0.189
—— 植入 B 第9层 ——
0 | ' Rome' | 0.0168
1 | ' E' | 0.0166
2 | ' L' | 0.0164
3 | ' T' | 0.0149
4 | ' B' | 0.0140
5 | ' P' | 0.0132
6 | ' C' | 0.0131
7 | ' S' | 0.0125
8 | ' San' | 0.0120
9 | ' G' | 0.0117
10 | ' St' | 0.0116
11 | ' V' | 0.0083
12 | ' Paris' | 0.0081
13 | ' Pal' | 0.0080
14 | ' Milan' | 0.0080
前15名之和 = 0.185
三段的"前15名之和"从 0.352 → 0.189 → 0.185,量化了植入后概率分布被打散的程度。
本文来自博客园,作者:Niwde,转载请注明原文链接:https://www.cnblogs.com/Niwde/p/21289309

浙公网安备 33010602011771号