我用 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

两个事实:

  1. top-1 逐层变化:the(高频词)→ England(地名)→ Chicago(城市)→ London(欧洲城市)。中间状态从"无信息"逐步收敛到"某个欧洲城市"。
  2. 最终答案是 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,量化了植入后概率分布被打散的程度。

posted @ 2026-07-09 14:56  Niwde  阅读(0)  评论(0)    收藏  举报