0%

从零理解自注意力:一步步拆解 GPT 的输入与注意力机制

这是一篇可以边读边运行的自注意力入门教程。我们用一句英文 Your journey starts with one step,把 GPT 处理文本的全过程走一遍:分词 → 词嵌入 → 位置嵌入 → 自注意力 → 训练观察

本教程配套 03.ipynb,面向刚接触深度学习 / Transformer 的初学者。
我们用一句英文 "Your journey starts with one step",把 GPT 处理文本的全过程走一遍:
分词 → 词嵌入 → 位置嵌入 → 自注意力 → 训练观察
每一步都能亲手运行、亲眼看到数字变化。

本次更新:正文前新增「预备理论」一章,先补齐概念,再动手写代码。


预备理论:动手前必须知道的 8 个概念

如果你已经了解神经网络、张量、Transformer 的大背景,可以跳过本章直接看第 0 节。
如果你是纯新手,强烈建议先花 10 分钟读完——后面代码里的每个词都会用到这里的概念。

一、大语言模型(LLM)到底在做什么?

GPT 这类模型本质上只做一件事:给定前面的一串词,预测下一个最可能的词。

1
输入:今天 天气 真          →  模型预测下一个词  →  "好"(概率最高)

把这个动作不断重复(预测→接上→再预测),就能一个词一个词地”写”出整段话。这就是”生成式”的含义。本教程第 5 节的训练实验,就是这个”预测下一词”任务的极简版。

二、为什么要把词变成”向量”?

计算机只会算数字,不会算文字。所以第一件事是把词变成一串数字,也就是向量(vector)。

更妙的是,我们希望这串数字能表达语义:意思相近的词,向量也相近。

1
2
3
猫  → [0.9, 0.1, 0.8, ...]
狗 → [0.8, 0.2, 0.9, ...] ← 和"猫"很接近(都是动物)
汽车 → [0.1, 0.9, 0.0, ...] ← 离得很远(不是动物)

这种”用一串数字表示含义”的向量,就叫 词嵌入(Embedding)。向量的长度叫维度(本教程用 6 维,真实 GPT 用几百上千维)。

三、张量(Tensor):深度学习里的”多维数组”

张量就是多维数组,是所有深度学习框架的基本数据单位。按维度数量分:

名称 维度 例子 形状 shape
标量 0 维 单个数 5 ()
向量 1 维 一个词的嵌入 [0.1, 0.2, ...] (6,)
矩阵 2 维 6 个词的嵌入表 (6, 6)
高维张量 3 维+ 一批句子 (batch, 序列长, 维度)

看懂 shape 极其重要——本教程几乎每个代码块都会 print(x.shape),因为形状对不上是新手最常见的报错来源。PyTorch 里张量用 torch.tensor(...) 创建。

四、矩阵乘法与点积:注意力的数学基石

  • 点积(dot product):两个等长向量对应位置相乘再相加,得到一个数。
    [1,2,3]·[4,5,6] = 1×4 + 2×5 + 3×6 = 32
    点积衡量两个向量有多”相似/对齐”——这正是注意力用来判断”两个词有多相关”的工具。
  • 矩阵乘法(@:本质是”很多次点积”。A @ B 中,结果第 i 行第 j 列 = A 的第 i 行 · B 的第 j 列。
    记住规则:(m, k) @ (k, n) = (m, n),中间的 k 必须相等,会被”消掉”。

五、神经网络的两个基本积木:Embedding 层 和 Linear 层

  • nn.Embedding:一张查找表。给一个整数 ID,返回表中对应那一行向量。用于把 token ID 变成向量。
  • nn.Linear:一次线性变换(矩阵乘法 y = x @ W.T),把向量从一个维度投影到另一个维度。注意力里的 Q/K/V 就是用它生成的。

这两种层内部都有可训练参数(表里的数字 / 权重矩阵 W),训练时会被自动调整。

六、什么是”训练”?—— 参数是学出来的,不是写死的

模型里的参数(Embedding 表、Linear 权重)一开始是随机数,需要用数据”训练”出来。训练循环四步:

1
2
3
4
① 前向计算:拿当前参数算出预测结果
② 算损失 loss:预测和正确答案差多少(越小越好)
③ 反向传播 loss.backward():算出"每个参数该往哪调"(梯度)
④ 更新 optimizer.step():按梯度微调参数

不断重复这四步,loss 越来越小,参数越来越好。第 5 节你会亲眼看到 loss 从 2.36 降到 0,注意力从”没主见”变得”很专注”。

  • 损失函数(Loss):衡量预测有多差的一个数。本教程用均方误差(预测与目标之差的平方的平均)。
  • 梯度(Gradient):告诉每个参数”朝哪个方向调、调多少”能让 loss 变小。
  • 优化器(Optimizer):执行更新的算法,本教程用常用的 Adam学习率 lr 控制每步调整的幅度。

七、Transformer 与自注意力:一张全景图

GPT 的骨架是 Transformer。它处理文本的主线是:

1
文字 → 分词 → 词嵌入 + 位置嵌入 → 【自注意力】×N层 → 预测下一个词

其中最核心的创新就是 自注意力(Self-Attention)。它解决一个关键问题:

理解一个词,不能只看它自己,要参考句子里的其他词。
比如 “他把水果放进篮子里,因为很大” —— “它”指篮子还是水果?必须看上下文。

自注意力让每个词都去”看”句子里所有词,按相关程度把别人的信息揉进自己。它用三个角色完成这件事:

角色 英文 类比 作用
查询 Query (Q) “我想找什么” 当前词的提问
Key (K) “我是什么标签” 每个词的索引标签
Value (V) “我能提供的信息” 每个词的实际内容

一句话记住流程:Q 和每个 K 做点积算相关性 → softmax 变成权重 → 用权重对 V 加权求和。(第 4 节会逐行实现。)

八、Softmax:把一堆分数变成”占比”

Softmax 把任意一组数变成都为正、且加起来等于 1 的一组数,可以理解成”占比 / 概率”。

1
分数 [2.0, 1.0, 0.1]  →  softmax  →  [0.66, 0.24, 0.10]   (和 = 1

在注意力里,它把”相关性分数”变成”注意力权重”:某个词权重 0.9 就代表”我 90% 的注意力都放在它身上”。分数差距越大,softmax 结果越”极端”(接近 0 或 1);分数都差不多时,结果就接近”平均分配”——这正是第 4 节里权重全是 1/6 的原因。

有了这 8 个概念,下面的代码你就能”知其所以然”地读了。开始动手吧!


0. 准备工作

需要的库

1
2
3
4
5
6
7
8
9
import torch
import torch.nn as nn
import tiktoken # OpenAI 的分词器
import numpy as np

torch.manual_seed(123) # 固定随机种子,保证每次结果一样
np.set_printoptions(precision=4, suppress=True) # 打印保留 4 位小数、不用科学计数法

print("torch:", torch.__version__)

安装命令(如果还没装):

1
pip install torch tiktoken numpy matplotlib

为什么要固定随机种子?
神经网络的权重一开始是随机初始化的。固定种子后,你每次运行得到的随机数都一样,方便对照教程里的数字来学习、排错。


1. 分词(Tokenization):把文字切成模型能读的”块”

模型不认识字符串,它只认识数字。第一步就是把句子切成一个个 token(词/子词),再映射成整数 ID。

1
2
3
4
5
6
7
8
9
10
text = "Your journey starts with one step"

tokenizer = tiktoken.get_encoding("gpt2") # 加载 GPT-2 用的分词器
token_ids = tokenizer.encode(text) # 文字 -> 整数ID
tokens = [tokenizer.decode([tid]) for tid in token_ids] # 反查每个ID对应的文字

print("原文:", text)
print("token_ids:", token_ids)
for tid, tok in zip(token_ids, tokens):
print(f" {tid:>6} -> {tok!r}")

输出:

1
2
3
4
5
6
7
8
原文: Your journey starts with one step
token_ids: [7120, 7002, 4940, 351, 530, 2239]
7120 -> 'Your'
7002 -> ' journey'
4940 -> ' starts'
351 -> ' with'
530 -> ' one'
2239 -> ' step'

要点:

  • 6 个单词被切成 6 个 token,每个 token 有一个唯一 ID(比如 Your = 7120)。
  • 注意 ' journey' 前面有个空格——GPT-2 的分词方式会把空格算进 token,这是正常现象。
  • 这 6 个 ID 就是后续所有计算的起点。

2. 词嵌入(Token Embedding):把 ID 变成”有含义的向量”

ID 7120 只是个编号,本身没有含义。我们需要把每个 ID 变成一个稠密向量(一串小数),让相近含义的词有相近的向量。这就是 Embedding(嵌入)

1
2
3
4
5
6
7
8
9
10
vocab_size = 50257   # GPT-2 词表大小(一共有这么多种 token)
d_model = 6 # 每个 token 用 6 维向量表示(真实 GPT 是几百上千维,这里为看得清而缩小)

token_emb_layer = nn.Embedding(vocab_size, d_model)
nn.init.normal_(token_emb_layer.weight, mean=0.0, std=0.02) # 用小随机数初始化

token_embeddings = token_emb_layer(torch.tensor(token_ids))

print("shape:", token_embeddings.shape) # torch.Size([6, 6])
print(token_embeddings.detach().numpy())

输出(6 个词,每个 6 维):

1
2
3
4
5
shape: torch.Size([6, 6])
[[-0.0089 -0.0162 0.0229 0.0074 0.0064 0.0093]
[-0.0019 0.0021 0.0019 0.0218 -0.0174 0.0149]
[ 0.0269 -0.0466 -0.0141 0.0305 0.0215 0.0328]
...]

Embedding 层其实就是一张”查找表”

nn.Embedding(50257, 6) 内部是一张 50257 行 x 6 列 的大表格。给它一个 ID,它就返回对应那一行。

1
2
3
4
5
6
7
8
# 手动查表 vs Embedding 层调用,看结果是否一致
manual = token_emb_layer.weight[torch.tensor(token_ids)] # 直接按行索引
auto = token_emb_layer(torch.tensor(token_ids)) # 用层去查
print("手动查表 == Embedding 调用?", torch.equal(manual, auto)) # True

print("embedding 表的形状:", token_emb_layer.weight.shape) # (50257, 6)
print("前 3 行(前 3 个 token 的向量):")
print(token_emb_layer.weight[:3].detach().numpy())

关键理解: Embedding 层 = 查表操作。层(id) 完全等价于 表[id]。这张表里的数字是可训练参数,训练过程中会不断被调整,让向量逐渐带上语义。

输入必须是整数(long 类型)

1
2
3
input_tensor = torch.tensor(token_ids)
print(" dtype:", input_tensor.dtype) # torch.int64(必须是整数)
print(" shape:", input_tensor.shape) # torch.Size([6])

新手常见报错: 如果你传给 Embedding 的是浮点数(float),会报错。因为它要用这个数当”行号”去查表,行号必须是整数。


3. 位置嵌入(Positional Embedding):告诉模型”词的顺序”

问题来了:自注意力本身不知道词的先后顺序(它同时看所有词)。可是 “狗咬人” 和 “人咬狗” 含义完全不同!

解决办法:再造一张表,专门表示”第几个位置”,然后把位置向量加到词向量上。

1
2
3
4
5
6
7
seq_len = len(token_ids)                    # 序列长度 = 6
pos_emb_layer = nn.Embedding(seq_len, d_model)
nn.init.normal_(pos_emb_layer.weight, mean=0.0, std=0.02)

pos_embeddings = pos_emb_layer(torch.arange(seq_len)) # 位置 [0,1,2,3,4,5]

print("shape:", pos_embeddings.shape) # torch.Size([6, 6])
  • torch.arange(6) 生成 [0, 1, 2, 3, 4, 5],代表 6 个位置。
  • 每个位置也得到一个 6 维向量。

合并:词含义 + 位置 = 模型的真正输入 X

1
2
X = token_embeddings + pos_embeddings       # 逐元素相加
print("X shape:", X.shape) # torch.Size([6, 6])

验证第 0 个词就是两者之和:

1
2
manual = token_embeddings[0] + pos_embeddings[0]
print("是否相等:", torch.equal(X[0], manual)) # True

要点: 现在 X 里的每个向量同时包含了”这个词是什么” + “它在第几位”两种信息。这个 X 就是送进注意力层的输入。


4. 自注意力(Self-Attention):让每个词去”看”其他词

这是 Transformer 的核心。直觉是:理解一个词,需要参考句子里的其他词。 比如理解 “它” 指代谁,得回头看前文。

自注意力给每个词生成三种角色向量:

名称 符号 类比 作用
Query(查询) Q “我想找什么” 当前词发出的提问
Key(键) K “我是什么” 每个词的”标签”
Value(值) V “我能提供什么信息” 每个词的实际内容

一个好懂的比喻:在图书馆找书。 你带着一个需求(Query),扫过每本书的书名标签(Key),标签越匹配的书,你越多地去读它的内容(Value)。

4.1 用三个线性层生成 Q / K / V

1
2
3
4
5
6
7
8
9
10
11
d_in = d_model   # 输入维度 6
d_out = 4 # Q/K/V 的输出维度(可以和输入不同)

W_query = nn.Linear(d_in, d_out, bias=False)
W_key = nn.Linear(d_in, d_out, bias=False)
W_value = nn.Linear(d_in, d_out, bias=False)

Q = W_query(X) # (6, 4)
K = W_key(X) # (6, 4)
V = W_value(X) # (6, 4)
print("Q shape:", Q.shape) # torch.Size([6, 4])

nn.Linear 就是矩阵乘法,它把 6 维的 X 投影成 4 维的 Q/K/V。这三个矩阵是可训练参数

4.2 计算注意力分数:谁和谁相关?

用 Query 和每个 Key 做点积,得到相关性分数。分数越大,说明这两个词越”匹配”。

1
2
3
4
attn_scores = Q @ K.T                       # (6,4) @ (4,6) = (6,6)
d_k = K.shape[-1] # = 4
attn_scores_scaled = attn_scores / d_k ** 0.5 # 除以 √d_k 做缩放
print("scores shape:", attn_scores.shape) # (6, 6)
  • Q @ K.T 得到 6x6 的矩阵:第 i 行第 j 列 = “第 i 个词对第 j 个词的关注分数”。
  • 为什么除以 √d_k? 维度越大,点积数值越大,会让后面的 softmax 变得过于极端(梯度消失)。除以 √d_k 是一种”降温”,让训练更稳定。这也是它叫 Scaled(缩放)Dot-Product Attention 的原因。

4.3 Softmax:把分数变成”注意力权重”

分数还不是概率。用 softmax 把每一行归一化成加起来为 1 的权重。

1
2
3
4
5
6
7
8
9
attn_weights = torch.softmax(attn_scores_scaled, dim=-1)

# 用表格打印方便看
print(" " * 10 + " ".join(f"{t!r:>10}" for t in tokens))
for i, row in enumerate(attn_weights.detach().numpy()):
row_str = " ".join(f"{v:>10.3f}" for v in row)
print(f"{tokens[i]!r:>8}: {row_str}")

print("每行和:", attn_weights.sum(dim=-1).detach().numpy()) # 全是 1

输出(每一行加起来 = 1):

1
2
3
4
5
              'Your'  ' journey'  ' starts'   ' with'    ' one'    ' step'
'Your': 0.167 0.167 0.167 0.167 0.167 0.167
' journey': 0.167 0.167 0.167 0.167 0.167 0.167
...(每个都是 0.167
每行和: [1. 1. 1. 1. 1. 1.]

咦,为什么每个权重都是 0.167(即 1/6)?
因为我们的权重是随机初始化的小数(std=0.02),Q、K 的值都非常接近 0,算出来的分数几乎都是 0,softmax 后自然变成了”平均分配”。

这恰恰说明了一个重要事实:未经训练的注意力是”没主见”的——它对所有词一视同仁。要让它学会”该关注谁”,必须经过训练(见第 5 节)。

4.4 加权求和:得到每个词的”上下文向量” Z

有了权重,就用它对所有 Value 做加权平均,得到每个词融合了上下文信息的新向量 Z。

1
2
Z = attn_weights @ V        # (6,6) @ (6,4) = (6,4)
print("Z shape:", Z.shape) # torch.Size([6, 4])

Z[i] = “第 i 个词看完全句后,得到的新表示”。这就是自注意力层的输出。

4.5 聚焦看一个词:以 “journey” 为例

1
2
3
4
5
6
7
idx = 1  # journey 的位置
print(f"目标词: {tokens[idx]!r}")
print(f"q = {Q[idx].detach().numpy()}")
print("注意力权重:")
for t, w in zip(tokens, attn_weights[idx].detach().numpy()):
print(f" -> 关注 {t!r:>10}: {w:.4f}")
print(f"z = {Z[idx].detach().numpy()}")

journey 这个词:先算出自己的 Query,再和全句每个词的 Key 打分,得到 6 个权重(现在都是 0.1667),最后按权重把 6 个 Value 混合起来,得到它的上下文向量 z。

小结一句话: 自注意力 = 「每个词根据和其他词的相关程度,把大家的信息按比例揉进自己」。


5. 对比实验:让模型真的训练一次

前面我们看到,未训练的注意力权重全是 1/6。现在做个玩具训练,亲眼看看注意力是如何被”塑造”出来的。

任务设定(简化版的”预测下一个词”): 让每个词的上下文向量 Z ≈ 它下一个词的向量。也就是说,模型要学会”往后看”,把注意力放到能帮它预测下一个词的位置上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# 换个种子 + 更大的 embedding(std=1.0),让训练效果更明显
torch.manual_seed(42)
tok_emb = nn.Embedding(vocab_size, d_model)
pos_emb = nn.Embedding(seq_len, d_model)
nn.init.normal_(tok_emb.weight, std=1.0)
nn.init.normal_(pos_emb.weight, std=1.0)

X = (tok_emb(torch.tensor(token_ids)) + pos_emb(torch.arange(seq_len))).detach()

Wq = nn.Linear(d_model, d_out, bias=False)
Wk = nn.Linear(d_model, d_out, bias=False)
Wv = nn.Linear(d_model, d_model, bias=False)

# 目标:每个位置的输出 ≈ 下一个位置的 X
target = torch.zeros_like(X)
target[:-1] = X[1:] # 第 i 个位置的目标 = 第 i+1 个位置的向量
target[-1] = X[-1] # 最后一个没有"下一个",就用自己

optimizer = torch.optim.Adam(
list(Wq.parameters()) + list(Wk.parameters()) + list(Wv.parameters()),
lr=0.05
)

snapshots = []
for step in range(201):
Q = Wq(X); K = Wk(X); V = Wv(X)
scores = Q @ K.T / d_out ** 0.5
weights = torch.softmax(scores, dim=-1)
Z = weights @ V
loss = ((Z - target) ** 2).mean() # 均方误差:输出和目标差多少

optimizer.zero_grad() # 清空上一轮的梯度
loss.backward() # 反向传播算梯度
optimizer.step() # 更新 Wq/Wk/Wv

if step in [0, 20, 50, 100, 200]:
snapshots.append((step, loss.item(), weights.detach().clone()))
w = weights[1].detach().numpy()
row = " ".join(f"{t.strip()!r}={v:.2f}" for t, v in zip(tokens, w))
print(f"step {step:3d} loss={loss.item():.4f} | journey关注: {row}")

输出:

1
2
3
4
5
step   0  loss=2.3566  |  journey关注: 'Your'=0.22 'journey'=0.12 'starts'=0.13 'with'=0.14 'one'=0.19 'step'=0.21
step 20 loss=0.2104 | journey关注: 'Your'=0.57 'journey'=0.21 'starts'=0.07 'with'=0.14 'one'=0.01 'step'=0.00
step 50 loss=0.0465 | journey关注: 'Your'=0.84 'journey'=0.09 'starts'=0.03 'with'=0.04 'one'=0.00 'step'=0.00
step 100 loss=0.0005 | journey关注: 'Your'=0.99 'journey'=0.01 'starts'=0.00 'with'=0.00 'one'=0.00 'step'=0.00
step 200 loss=0.0000 | journey关注: 'Your'=0.99 'journey'=0.00 'starts'=0.00 'with'=0.00 'one'=0.00 'step'=0.00

看懂这段输出,你就懂了训练的本质:

  1. loss 一路下降:2.36 → 0.21 → 0.0000,说明模型输出越来越接近目标。
  2. 注意力从”平均”变得”专注”:一开始 journey 对每个词都差不多(0.1~0.2),训练到最后几乎把全部注意力(0.99)都放到了 'Your' 上。
  3. 这个”专注”不是我们手写的规则,是模型自己学出来的——通过不断调整 Wq/Wk/Wv 让 loss 变小,注意力自然就聚焦到了最有用的位置。

训练的四步口诀:前向计算 → 算 loss → loss.backward() 算梯度 → optimizer.step() 更新
每轮开始别忘了 optimizer.zero_grad() 清空上一轮梯度,否则梯度会累加出错。

可视化:注意力权重的演化

1
2
3
4
5
6
7
8
9
10
11
12
13
import matplotlib.pyplot as plt

fig, axes = plt.subplots(1, len(snapshots), figsize=(4*len(snapshots), 4))
for ax, (step, loss, w) in zip(axes, snapshots):
im = ax.imshow(w.numpy(), cmap='viridis', vmin=0, vmax=1)
ax.set_title(f"step={step}\nloss={loss:.3f}")
ax.set_xticks(range(len(tokens))); ax.set_yticks(range(len(tokens)))
ax.set_xticklabels([t.strip() for t in tokens], rotation=45, ha='right')
ax.set_yticklabels([t.strip() for t in tokens])
fig.colorbar(im, ax=axes, shrink=0.8)
plt.suptitle("注意力权重的训练演化", y=1.02)
plt.tight_layout()
plt.show()

热力图会显示:随着训练步数增加,原本均匀(一片同色)的注意力矩阵,逐渐变成对角线/某几列格外亮的图案——这就是注意力”学会聚焦”的可视化证据。

小提示:如果图里中文标题显示成方框(缺字体),不影响学习。可加上
plt.rcParams['font.sans-serif'] = ['SimHei'](或 ‘Arial Unicode MS’)来显示中文。


6. 全流程回顾

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
文字 "Your journey..."
│ ① 分词 tokenizer.encode

token_ids [7120, 7002, ...] ← 整数
│ ② 词嵌入 nn.Embedding(查表)

token_embeddings (6×6) ← 每个词的含义向量
│ ③ + 位置嵌入 pos_embeddings

X (6×6) ← 含义 + 位置
│ ④ 三个线性层 -> Q, K, V

Q,K,V (6×4)
│ ⑤ scores = Q@K.T / √d_k
│ ⑥ weights = softmax(scores) ← 注意力权重(6×6)
│ ⑦ Z = weights @ V

Z (6×4) ← 每个词融合上下文后的新表示

7. 核心概念速记表

概念 一句话理解
Token 文本被切成的最小单位(词/子词),有唯一整数 ID
Embedding 一张可训练的查找表,把 ID 变成有含义的向量
位置嵌入 给模型补上”词的顺序”信息,直接加到词向量上
Q / K / V 提问 / 标签 / 内容,注意力的三个角色
注意力分数 Q·K 点积,衡量两个词有多相关
√d_k 缩放 防止分数过大导致 softmax 过于极端
Softmax 把分数变成加起来为 1 的注意力权重
上下文向量 Z 按注意力权重对 Value 加权求和的结果
训练 通过降低 loss,让注意力从”平均”学会”聚焦”

8. 动手练习(巩固理解)

  1. d_model 从 6 改成 16,观察各步骤 shape 怎么变。
  2. 换一句你自己的英文,看它被切成几个 token。
  3. 在第 4 节把权重初始化的 std 从 0.02 改成 1.0,看注意力还会不会是均匀的 0.167。(提示:不会了,因为分数不再接近 0)
  4. 在第 5 节把训练步数、学习率 lr 调大或调小,观察 loss 下降速度和注意力聚焦的快慢。
  5. 思考题:如果去掉位置嵌入,模型还能区分 “one step” 和 “step one” 吗?为什么?

恭喜! 你已经手动走完了一个(迷你版)GPT 从文字到注意力输出的完整流程。真实的 GPT 只是把这些模块堆叠很多层、放大很多倍(多头注意力、更大维度、几十层),核心原理和你刚学的一模一样。