这是一篇可以边读边运行的自注意力入门教程。我们用一句英文 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 | 猫 → [0.9, 0.1, 0.8, ...] |
这种”用一串数字表示含义”的向量,就叫 词嵌入(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 | ① 前向计算:拿当前参数算出预测结果 |
不断重复这四步,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 | import torch |
安装命令(如果还没装):
1 | pip install torch tiktoken numpy matplotlib |
为什么要固定随机种子?
神经网络的权重一开始是随机初始化的。固定种子后,你每次运行得到的随机数都一样,方便对照教程里的数字来学习、排错。
1. 分词(Tokenization):把文字切成模型能读的”块”
模型不认识字符串,它只认识数字。第一步就是把句子切成一个个 token(词/子词),再映射成整数 ID。
1 | text = "Your journey starts with one step" |
输出:
1 | 原文: Your journey starts with one step |
要点:
- 6 个单词被切成 6 个 token,每个 token 有一个唯一 ID(比如
Your= 7120)。 - 注意
' journey'前面有个空格——GPT-2 的分词方式会把空格算进 token,这是正常现象。 - 这 6 个 ID 就是后续所有计算的起点。
2. 词嵌入(Token Embedding):把 ID 变成”有含义的向量”
ID 7120 只是个编号,本身没有含义。我们需要把每个 ID 变成一个稠密向量(一串小数),让相近含义的词有相近的向量。这就是 Embedding(嵌入)。
1 | vocab_size = 50257 # GPT-2 词表大小(一共有这么多种 token) |
输出(6 个词,每个 6 维):
1 | shape: torch.Size([6, 6]) |
Embedding 层其实就是一张”查找表”
nn.Embedding(50257, 6) 内部是一张 50257 行 x 6 列 的大表格。给它一个 ID,它就返回对应那一行。
1 | # 手动查表 vs Embedding 层调用,看结果是否一致 |
关键理解: Embedding 层 = 查表操作。层(id) 完全等价于 表[id]。这张表里的数字是可训练参数,训练过程中会不断被调整,让向量逐渐带上语义。
输入必须是整数(long 类型)
1 | input_tensor = torch.tensor(token_ids) |
新手常见报错: 如果你传给 Embedding 的是浮点数(float),会报错。因为它要用这个数当”行号”去查表,行号必须是整数。
3. 位置嵌入(Positional Embedding):告诉模型”词的顺序”
问题来了:自注意力本身不知道词的先后顺序(它同时看所有词)。可是 “狗咬人” 和 “人咬狗” 含义完全不同!
解决办法:再造一张表,专门表示”第几个位置”,然后把位置向量加到词向量上。
1 | seq_len = len(token_ids) # 序列长度 = 6 |
torch.arange(6)生成[0, 1, 2, 3, 4, 5],代表 6 个位置。- 每个位置也得到一个 6 维向量。
合并:词含义 + 位置 = 模型的真正输入 X
1 | X = token_embeddings + pos_embeddings # 逐元素相加 |
验证第 0 个词就是两者之和:
1 | manual = token_embeddings[0] + pos_embeddings[0] |
要点: 现在 X 里的每个向量同时包含了”这个词是什么” + “它在第几位”两种信息。这个 X 就是送进注意力层的输入。
4. 自注意力(Self-Attention):让每个词去”看”其他词
这是 Transformer 的核心。直觉是:理解一个词,需要参考句子里的其他词。 比如理解 “它” 指代谁,得回头看前文。
自注意力给每个词生成三种角色向量:
| 名称 | 符号 | 类比 | 作用 |
|---|---|---|---|
| Query(查询) | Q | “我想找什么” | 当前词发出的提问 |
| Key(键) | K | “我是什么” | 每个词的”标签” |
| Value(值) | V | “我能提供什么信息” | 每个词的实际内容 |
一个好懂的比喻:在图书馆找书。 你带着一个需求(Query),扫过每本书的书名标签(Key),标签越匹配的书,你越多地去读它的内容(Value)。
4.1 用三个线性层生成 Q / K / V
1 | d_in = d_model # 输入维度 6 |
nn.Linear 就是矩阵乘法,它把 6 维的 X 投影成 4 维的 Q/K/V。这三个矩阵是可训练参数。
4.2 计算注意力分数:谁和谁相关?
用 Query 和每个 Key 做点积,得到相关性分数。分数越大,说明这两个词越”匹配”。
1 | attn_scores = Q @ K.T # (6,4) @ (4,6) = (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 | attn_weights = torch.softmax(attn_scores_scaled, dim=-1) |
输出(每一行加起来 = 1):
1 | 'Your' ' journey' ' starts' ' with' ' one' ' step' |
咦,为什么每个权重都是 0.167(即 1/6)?
因为我们的权重是随机初始化的小数(std=0.02),Q、K 的值都非常接近 0,算出来的分数几乎都是 0,softmax 后自然变成了”平均分配”。
这恰恰说明了一个重要事实:未经训练的注意力是”没主见”的——它对所有词一视同仁。要让它学会”该关注谁”,必须经过训练(见第 5 节)。
4.4 加权求和:得到每个词的”上下文向量” Z
有了权重,就用它对所有 Value 做加权平均,得到每个词融合了上下文信息的新向量 Z。
1 | Z = attn_weights @ V # (6,6) @ (6,4) = (6,4) |
Z[i] = “第 i 个词看完全句后,得到的新表示”。这就是自注意力层的输出。
4.5 聚焦看一个词:以 “journey” 为例
1 | idx = 1 # journey 的位置 |
journey 这个词:先算出自己的 Query,再和全句每个词的 Key 打分,得到 6 个权重(现在都是 0.1667),最后按权重把 6 个 Value 混合起来,得到它的上下文向量 z。
小结一句话: 自注意力 = 「每个词根据和其他词的相关程度,把大家的信息按比例揉进自己」。
5. 对比实验:让模型真的训练一次
前面我们看到,未训练的注意力权重全是 1/6。现在做个玩具训练,亲眼看看注意力是如何被”塑造”出来的。
任务设定(简化版的”预测下一个词”): 让每个词的上下文向量 Z ≈ 它下一个词的向量。也就是说,模型要学会”往后看”,把注意力放到能帮它预测下一个词的位置上。
1 | # 换个种子 + 更大的 embedding(std=1.0),让训练效果更明显 |
输出:
1 | step 0 loss=2.3566 | journey关注: 'Your'=0.22 'journey'=0.12 'starts'=0.13 'with'=0.14 'one'=0.19 'step'=0.21 |
看懂这段输出,你就懂了训练的本质:
- loss 一路下降:2.36 → 0.21 → 0.0000,说明模型输出越来越接近目标。
- 注意力从”平均”变得”专注”:一开始
journey对每个词都差不多(0.1~0.2),训练到最后几乎把全部注意力(0.99)都放到了'Your'上。 - 这个”专注”不是我们手写的规则,是模型自己学出来的——通过不断调整 Wq/Wk/Wv 让 loss 变小,注意力自然就聚焦到了最有用的位置。
训练的四步口诀:
前向计算 → 算 loss → loss.backward() 算梯度 → optimizer.step() 更新。
每轮开始别忘了optimizer.zero_grad()清空上一轮梯度,否则梯度会累加出错。
可视化:注意力权重的演化
1 | import matplotlib.pyplot as plt |
热力图会显示:随着训练步数增加,原本均匀(一片同色)的注意力矩阵,逐渐变成对角线/某几列格外亮的图案——这就是注意力”学会聚焦”的可视化证据。
小提示:如果图里中文标题显示成方框(缺字体),不影响学习。可加上
plt.rcParams['font.sans-serif'] = ['SimHei'](或 ‘Arial Unicode MS’)来显示中文。
6. 全流程回顾
1 | 文字 "Your journey..." |
7. 核心概念速记表
| 概念 | 一句话理解 |
|---|---|
| Token | 文本被切成的最小单位(词/子词),有唯一整数 ID |
| Embedding | 一张可训练的查找表,把 ID 变成有含义的向量 |
| 位置嵌入 | 给模型补上”词的顺序”信息,直接加到词向量上 |
| Q / K / V | 提问 / 标签 / 内容,注意力的三个角色 |
| 注意力分数 | Q·K 点积,衡量两个词有多相关 |
| √d_k 缩放 | 防止分数过大导致 softmax 过于极端 |
| Softmax | 把分数变成加起来为 1 的注意力权重 |
| 上下文向量 Z | 按注意力权重对 Value 加权求和的结果 |
| 训练 | 通过降低 loss,让注意力从”平均”学会”聚焦” |
8. 动手练习(巩固理解)
- 把
d_model从 6 改成 16,观察各步骤 shape 怎么变。 - 换一句你自己的英文,看它被切成几个 token。
- 在第 4 节把权重初始化的
std从 0.02 改成 1.0,看注意力还会不会是均匀的 0.167。(提示:不会了,因为分数不再接近 0) - 在第 5 节把训练步数、学习率
lr调大或调小,观察 loss 下降速度和注意力聚焦的快慢。 - 思考题:如果去掉位置嵌入,模型还能区分 “one step” 和 “step one” 吗?为什么?
恭喜! 你已经手动走完了一个(迷你版)GPT 从文字到注意力输出的完整流程。真实的 GPT 只是把这些模块堆叠很多层、放大很多倍(多头注意力、更大维度、几十层),核心原理和你刚学的一模一样。