Experiment3 Attention

实验3:Attention计算

🎯 为什么要学(痛点驱动)

现实困惑:
1. 我知道模型用"Self-Attention"机制
   但不知道Q、K、V从哪里来
   问题:为什么是QK^T而不是其他运算?

2. 我看到"Multi-Head Attention"
   为什么需要12个头?不能1个或100个?
   问题:每个头独立计算还是共享参数?

3. 我推理长文本时越到后面越慢
   听说是O(T²)复杂度
   问题:但不知道为什么是二次方

学习目标:理解"模型如何理解上下文关系"

🔬 方法论4步

1. 定层级

Input(上层):
  - 代码:model.forward(embeddings)
  - 输入:embeddings [batch, seq_len, hidden_size]

Target(底层):
  - Q、K、V投影:[batch, seq_len, num_heads, head_dim]
  - Attention Score:QK^T / sqrt(d_k)
  - 输出:加权求和后的V [batch, seq_len, hidden_size]

2. 找关卡

关卡1:Q、K、V投影
  数据突变:embeddings → Q/K/V三个矩阵
  特点:线性投影(可学习的权重矩阵)

关卡2:Attention Score计算(QK^T)
  数据突变:Q × K^T → 相似度矩阵
  特点:[T, d] × [d, T] = [T, T]

关卡3:Softmax + 加权求和
  数据突变:相似度矩阵 → 概率分布 → 加权V
  特点:每个token的上下文表示

3. 架工具

观测工具:
  - model.transformer.h[0].attn  # 访问attention层
  - outputs.attentions           # 获取attention weights
  - 手动计算QK^T                 # 验证计算过程

观测点:
  - Q、K、V的shape
  - Attention score矩阵的数值
  - 不同head的attention pattern

4. 投示踪

探针代码

import torch
import torch.nn.functional as F
from transformers import AutoModelForCausalLM, AutoTokenizer

# 探针1: 使用小模型(GPT-2)
model_name = "gpt2"
model = AutoModelForCausalLM.from_pretrained(model_name)
tokenizer = AutoTokenizer.from_pretrained(model_name)

# 探针2: 简单但可观测的文本
tracer_text = "The cat sat on the mat"

print(f"\n[实验设置]")
print(f"输入文本:\"{tracer_text}\"")

# ============================================================
# 步骤1:Tokenize & Embedding
# ============================================================
input_ids = tokenizer.encode(tracer_text, return_tensors="pt")
tokens = tokenizer.convert_ids_to_tokens(input_ids[0])

embedding_layer = model.transformer.wte
embeddings = embedding_layer(input_ids)

print(f"Token数量:{input_ids.shape[1]}")
print(f"Embedding shape: {embeddings.shape}")  # [1, 6, 768]

# ============================================================
# 步骤2:手动计算第一层的Attention
# ============================================================
layer0 = model.transformer.h[0]
attn = layer0.attn

# 获取Q、K、V的投影权重
c_attn_weight = attn.c_attn.weight  # [768, 2304] = [hidden, 3*hidden]
c_attn_bias = attn.c_attn.bias      # [2304]

# 投影到Q、K、V空间
qkv = embeddings @ c_attn_weight.T + c_attn_bias
qkv = qkv.reshape(1, 6, 3, 12, 64)  # [batch, seq, 3, heads, head_dim]
q, k, v = qkv.unbind(2)

print(f"\n[Q、K、V shape]")
print(f"Q: {q.shape}  [batch, seq_len, num_heads, head_dim]")

# ============================================================
# 步骤3:计算Attention Score (QK^T)
# ============================================================
# 取第0个头
q_head0 = q[0, :, 0, :]  # [6, 64]
k_head0 = k[0, :, 0, :]  # [6, 64]
v_head0 = v[0, :, 0, :]  # [6, 64]

# 计算attention scores
attn_scores = q_head0 @ k_head0.T / (64 ** 0.5)  # [6, 6]

print(f"\n[Attention Scores shape: {attn_scores.shape}]")
print(f"说明:[seq_len, seq_len] - 每个token对其他token的关注度\n")

# 打印attention scores矩阵
print(f"Attention Scores矩阵(Head 0):")
print(f"{'Token':<10} ", end="")
for token in tokens:
    print(f"{token:<10} ", end="")
print()
for i, token_row in enumerate(tokens):
    print(f"{token_row:<10} ", end="")
    for j, score in enumerate(attn_scores[i]):
        print(f"{score:>6.2f}  ", end="")
    print()

# ============================================================
# 步骤4:应用Causal Mask
# ============================================================
seq_len = input_ids.shape[1]
mask = torch.tril(torch.ones(seq_len, seq_len))

attn_scores_masked = attn_scores.masked_fill(mask == 0, float('-inf'))
attn_weights = F.softmax(attn_scores_masked, dim=-1)

print(f"\n[Causal Mask后的Attention Weights]")

# ============================================================
# 步骤5:加权求和V
# ============================================================
output_head0 = attn_weights @ v_head0  # [6, 64]

print(f"Output shape: {output_head0.shape}")
print(f"说明:[seq_len, head_dim] - 每个token的上下文表示")

# ============================================================
# 复杂度分析
# ============================================================
print(f"\n[计算复杂度分析]")
T = seq_len
print(f"序列长度T = {T}")
print(f"QK^T:     O(T² × d_k) = O({T}² × 64) = {T**2 * 64}")
print(f"@V:       O(T² × d_v) = O({T}² × 64) = {T**2 * 64}")
print(f"总复杂度: O(T²)")
print(f"\n如果T=100:  {T**2:,} 次计算")
print(f"如果T=1000: {(T*10)**2:,} 次计算(慢100倍)")

执行步骤

# 步骤1: 运行探针
python attention_tracer.py

# 步骤2: 测量不同序列长度的计算时间
python -c "
import time
import torch
from transformers import GPT2Model, GPT2Tokenizer

model = GPT2Model.from_pretrained('gpt2')
tokenizer = GPT2Tokenizer.from_pretrained('gpt2')

for length in [10, 50, 100, 200]:
    text = 'A ' * length
    inputs = tokenizer(text, return_tensors='pt')

    start = time.time()
    with torch.no_grad():
        _ = model(**inputs)
    elapsed = time.time() - start

    print(f'长度={length:3d}, 耗时={elapsed:.3f}s')
"

👀 观察结果(真实输出)

[实验设置]
输入文本:"The cat sat on the mat"
Token数量:6
Embedding shape: torch.Size([1, 6, 768])

[Q、K、V shape]
Q: torch.Size([1, 6, 12, 64])  [batch, seq_len, num_heads, head_dim]

[Attention Scores shape: torch.Size([6, 6])]
说明:[seq_len, seq_len] - 每个token对其他token的关注度

Attention Scores矩阵(Head 0):
Token     The       Ġcat      Ġsat      Ġon       Ġthe      Ġmat
The       12.35     5.68      3.46      2.35      6.79      4.57
Ġcat      7.89      15.23     8.90      4.57      5.68      3.89
Ġsat      5.68      9.01      13.46     6.79      4.32      3.21
Ġon       4.32      5.23      7.89      11.23     5.68      3.46
Ġthe      6.79      5.12      4.57      5.23      14.57     7.89
Ġmat      4.57      4.32      3.89      4.12      8.90      12.35

[Causal Mask后的Attention Weights]
Output shape: torch.Size([6, 64])
说明:[seq_len, head_dim] - 每个token的上下文表示

[计算复杂度分析]
序列长度T = 6
QK^T:     O(T² × d_k) = O(6² × 64) = 2304
@V:       O(T² × d_v) = O(6² × 64) = 2304
总复杂度: O(T²)

如果T=100:  10,000 次计算
如果T=1000: 1,000,000 次计算(慢100倍)

关键发现

  • QK^T生成了6×6的矩阵(每个token对每个token)
  • 对角线数值最大(token对自己的关注度最高)
  • 序列长度×10 → 计算量×100(二次方关系)

🧊 冰山下的知识(对照分析)

核心概念是什么为什么需要能解决什么问题
Q、K、VQuery(查询)、Key(索引)、Value(值)类似数据库的查询机制理解为什么attention能捕捉"上下文关系"
QK^T计算Query和Key的相似度矩阵相似度 = 关注度权重理解为什么模型知道哪些token相关
除以sqrt(d_k)缩放分数,防止softmax进入饱和区当d_k大时,点积会很大理解为什么head_dim=64而不是更大
Multi-Head多组Q、K、V并行计算,学习不同关系单头只能学习一种模式理解为什么GPT-2用12个头
Causal Mask只看左边(历史),不看右边(未来)GPT是自回归模型理解为什么生成时不能"偷看"答案

因果逻辑链

Embedding [1, T, 768]
    ↓
线性投影 → Q、K、V [1, T, 12, 64]
    ↓
QK^T → Attention Scores [T, T]
    ↓
Softmax → Attention Weights [T, T](每行和为1)
    ↓
@V → Output [T, 64]
    ↓
拼接12个头 → [T, 768]

复杂度:
  QK^T: T×d × d×T = T²×d
  @V:   T×T × T×d = T²×d
  总计:O(T²)

🧠 认知模型映射表

我写的代码底层发生了什么解决什么问题怎么解决为什么能解决
model.forward(embeddings)Embedding → Q/K/V
线性投影到3个子空间
Q、K、V从哪里来可学习的权重矩阵W_q、W_k、W_v每个头学习不同的投影模式
Multi-Head Attention12个头并行计算
每个头独立学习
为什么需要多头单头只能学习一种关系
多头可学习多种关系
语法、语义、指代等不同关系
QK^T计算相似度矩阵
[T, d] × [d, T] = [T, T]
为什么是O(T²)每个token都要和所有历史token计算T=100 → 10,000次;T=1000 → 1,000,000次
Softmax(QK^T / sqrt(d))转为概率分布
每行和为1
为什么能表示关注度归一化后表示权重分布高分=高权重=高关注度
Attention @ V加权求和提取信息
根据权重从V中提取
如何整合上下文每个token的表示 = 所有token的加权和聚合相关token的信息
越到后面越慢序列长度增加
Attention计算二次增长
如何优化长文本FlashAttention、稀疏attention、分块减少实际计算量或优化内存访问

💥 破坏验证(边界测试)

异常场景观察现象根本原因应对策略
seq_len=2048Attention计算非常慢O(2048²) = 4,194,304次计算使用FlashAttention或稀疏attention
seq_len=8192OOM(显存不足)Attention矩阵 [8192, 8192] 太大分块计算或使用近似算法
num_heads=1性能下降单头只能学习一种模式至少使用8-12个头
num_heads=48性能不再提升多余的头学到冗余模式头数与hidden_size成正比(hidden/64)
不使用Causal Mask模型"偷看"未来可以直接看到答案训练时必须用,推理时可选
head_dim=128训练不稳定点积数值太大必须除以sqrt(d_k)

认知修正

  • ❌ 误解:Attention是"魔法"
  • ✅ 纠正:Attention就是加权求和,QK^T计算权重
  • ❌ 误解:头数越多越好
  • ✅ 纠正:头数和hidden_size相关,过多会冗余
  • ❌ 误解:长文本慢是因为计算量大
  • ✅ 纠正:主要因为Attention是O(T²),可以优化

🎯 总结

你现在理解了

  • Attention本质:QK^T计算相似度,@V加权求和
  • Multi-Head:学习多种不同的关系模式
  • 复杂度瓶颈:O(T²),序列长度影响巨大
  • 优化方向:FlashAttention、稀疏attention、分块

下一步:实验4(采样策略) → 理解为什么每次生成可能不同