第 9 章 2017—2018:Transformer 革命——Transformer、BERT 与 GPT
学习目标
- 理解自注意力与多头注意力的计算
- 理解 Transformer 的编码器-解码器结构与位置编码
- 理解 BERT(2018)的双向掩码语言模型与 GPT 的自回归语言模型
- 分别用 PyTorch 实现三种架构
9.1 时代背景:Attention Is All You Need
2017 年 6 月,Google 发表《Attention Is All You Need》,提出 Transformer:不用循环、不用卷积,只用注意力。它有两个决定性的优势:
- 并行:RNN 必须逐时间步计算,Transformer 一次性处理整个序列;
- 长距离依赖:注意力让任意两个位置直接相连,一步到位,不受梯度消失限制。
2018 年,基于 Transformer 的 BERT(Google,编码器)与 GPT(OpenAI,解码器)相继出现,预训练 + 微调范式统治了 NLP,并一路演化成今天的 ChatGPT 与千亿参数大模型。
9.2 自注意力:序列内部的加权聚合
把每个词向量映射成三个向量:查询 Q、键 K、值 V。一个词对另一个词的注意力,取决于它们的「查询-键匹配度」:
Attention(Q, K, V) = softmax(QKᵀ / √dₖ)·VQKᵀ计算所有词对之间的相关性分数;- 除以
√dₖ防止分数过大导致 softmax 饱和; - softmax 得到权重,对 V 加权求和,输出聚合了全局信息的向量。
这个机制叫自注意力(self-attention),因为 Q、K、V 都来自同一序列。多头注意力把它重复 H 次(每组用不同的投影),让模型同时关注不同角度的关系(语法、语义、位置……),再拼接结果。
examples/ch09_transformer.py 用 2 层编码器做「序列里有没有数字 3」分类:
Transformer 参数量:17,474
step 100:loss = 0.0001,准确率 = 100.0%
step 200:loss = 0.0000,准确率 = 100.0%
step 300:loss = 0.0000,准确率 = 100.0%
样本序列: [4, 9, 3, 0, 3, 9, 7, 3] 标签: 1
第 0 层平均注意力矩阵(行=查询,列=键):
0.04 0.04 0.33 0.06 0.28 0.03 0.06 0.18
0.03 0.08 0.20 0.07 0.20 0.12 0.13 0.20
...300 步就 100% 学会。注意力矩阵显示,序列里三个「3」(位置 2、4、7)吸引了明显更高的权重——模型确实学会了「盯住目标数字」。
9.3 位置编码:给注意力加上顺序
注意力本身是置换不变的:交换任意两个词的顺序,输出不变(只是交换了位置)。可语言是讲顺序的。Transformer 用位置编码把位置信息注入输入:
- 原始论文用正弦/余弦函数(不同频率),任意长度都能外推;
- 现代实现常用可学习的位置嵌入(
nn.Embedding/nn.Parameter)。
examples/ch09_transformer.py 里的 PositionalEncoding 就是正弦版。
9.4 BERT:双向的掩码语言模型
2018 年,Google 的 BERT(Bidirectional Encoder Representations from Transformers)提出掩码语言模型(MLM):随机遮住 15% 的词,让双向编码器根据上下文猜出来。训练完成后,把 BERT 的最后一层输出接到具体任务(分类、问答)上微调即可。
「双向」是 BERT 与 GPT 的关键区别:GPT 只能看左边的词(自回归),BERT 左右都能看。类似地,阅读理解的上下文信息往往在「后面」出现,双向更合适。
examples/ch09_bert.py 在《论语》上训练微型 BERT:
MiniBERT 参数量:43,966
step 200:loss = 2.3501
step 400:loss = 1.5405
step 600:loss = 1.0720
step 800:loss = 0.6284
补全'温故而_新':候选 知 温 学模型从「温故而知新」中学会了「知」——补全的第一候选正确。
9.5 GPT:自回归的续写机器
2018 年,OpenAI 的 GPT(Generative Pre-trained Transformer)用自回归语言模型预训练:预测下一个词,而且只能看左边。这就够了——因为生成天然是「从左到右」的。训练完的 GPT 本身就是「续写器」,给定前缀,自己生成后面。
examples/ch09_gpt.py 在《道德经》上训练字符级 GPT:
MiniGPT 参数量:118,261
step 100:loss = 0.7613
step 200:loss = 0.1131
step 300:loss = 0.0543
step 400:loss = 0.0374
生成文本: 道可道非常道名可名非常名无名天地之始有名万物之母故常无欲以观其妙常有欲以观其徼此两者同出而异名同谓之玄玄之又玄众妙之门天下皆知训练 400 步,模型已经能「背」出《道德经》第一章,一字不差。GPT 的核心机制:
- 因果掩码:注意力矩阵的上三角设为 -∞,让每个位置只能看自己及左边;
- 采样生成:温度控制多样性,
multinomial按概率采样。
9.6 编码器、解码器,还是两者都要
| 架构 | 结构 | 预训练任务 | 代表 |
|---|---|---|---|
| 编码器 | 双向 | MLM | BERT |
| 解码器 | 自回归(因果) | 下一个词 | GPT |
| 编码器-解码器 | 两者组合 | 序列到序列 | T5、BART |
2018 年后,这三种架构各自演进:GPT 线走向 ChatGPT(第 12 章),编码器线成为检索与嵌入的标配,编码器-解码器线用于翻译与摘要。
完整代码
本章用到的完整示例代码:
examples/ch09_transformer.py
"""第 9 章示例(2017):小型 Transformer 编码器做序列分类。
任务:8 个数字组成的序列,判断其中是否出现过数字 3。
自注意力让模型可以直接"看到"序列中的任意位置,并行处理整条序列。
运行方式:
uv run python examples/ch09_transformer.py
"""
import warnings
import torch
from torch import nn
warnings.filterwarnings("ignore", message=".*nested_tensor.*")
torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"
SEQ_LEN, VOCAB, N = 8, 10, 4000
g = torch.Generator().manual_seed(0)
X = torch.randint(0, VOCAB, (N, SEQ_LEN), generator=g)
y = (X == 3).any(dim=1).long()
class PositionalEncoding(nn.Module):
def __init__(self, d_model, max_len=64):
super().__init__()
pe = torch.zeros(max_len, d_model)
pos = torch.arange(max_len).unsqueeze(1).float()
div = torch.exp(torch.arange(0, d_model, 2).float() * (-torch.log(torch.tensor(10000.0)) / d_model))
pe[:, 0::2] = torch.sin(pos * div)
pe[:, 1::2] = torch.cos(pos * div)
self.register_buffer("pe", pe.unsqueeze(0))
def forward(self, x):
return x + self.pe[:, : x.shape[1]]
class TinyTransformer(nn.Module):
def __init__(self):
super().__init__()
d_model, nhead = 32, 4
self.embed = nn.Embedding(VOCAB, d_model)
self.pos = PositionalEncoding(d_model)
encoder_layer = nn.TransformerEncoderLayer(
d_model, nhead, dim_feedforward=64, batch_first=True,
activation="gelu", norm_first=True,
)
self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=2)
self.head = nn.Linear(d_model, 2)
def forward(self, x):
return self.head(self.encoder(self.pos(self.embed(x))).mean(dim=1))
model = TinyTransformer().to(device)
opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
print(f"Transformer 参数量:{sum(p.numel() for p in model.parameters()):,}")
for step in range(300):
idx = torch.randint(0, N, (64,), generator=g)
xb, yb = X[idx].to(device), y[idx].to(device)
opt.zero_grad()
loss = nn.functional.cross_entropy(model(xb), yb)
loss.backward()
opt.step()
if (step + 1) % 100 == 0:
acc = (model(xb).argmax(1) == yb).float().mean().item()
print(f"step {step + 1}:loss = {loss.item():.4f},准确率 = {acc * 100:.1f}%")
# 观察注意力:取一个含 3 的样本,打印第 0 层平均注意力矩阵
sample = X[0].unsqueeze(0).to(device)
with torch.no_grad():
attn = model.encoder.layers[0].self_attn(
model.pos(model.embed(sample)),
model.pos(model.embed(sample)),
model.pos(model.embed(sample)),
)[1][0]
print("样本序列:", X[0].tolist(), " 标签:", y[0].item())
print("第 0 层平均注意力矩阵(行=查询,列=键):")
for row in attn:
print(" ".join(f"{v:.2f}" for v in row))examples/ch09_bert.py
"""第 9 章示例(2018):微型 BERT——掩码语言模型预训练。
把《论语》文本中的部分字符随机遮住,让 Transformer 编码器根据上下文猜出来。
双向的编码器结构让每个位置都能看到左右两侧,这是 BERT 与 GPT 的根本区别。
运行方式:
uv run python examples/ch09_bert.py
"""
import warnings
import torch
from torch import nn
warnings.filterwarnings("ignore", message=".*nested_tensor.*")
torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"
text = (
"学而时习之不亦说乎有朋自远方来不亦乐乎人不知而不愠不亦君子乎"
"学而不思则罔思而不学则殆温故而知新可以为师矣知之为知之不知为不知是知也"
"三人行必有我师焉择其善者而从之其不善者而改之"
)
chars = sorted(set(text))
MASK = len(chars)
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
data = torch.tensor([stoi[c] for c in text], device=device)
BLOCK = 32
class MiniBERT(nn.Module):
def __init__(self, vocab, d_model=48, nhead=4):
super().__init__()
self.embed = nn.Embedding(vocab + 1, d_model)
self.pos = nn.Embedding(BLOCK, d_model)
encoder_layer = nn.TransformerEncoderLayer(
d_model, nhead, dim_feedforward=96, batch_first=True,
activation="gelu", norm_first=True,
)
self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=2)
self.head = nn.Linear(d_model, vocab)
def forward(self, x):
B, T = x.shape
h = self.embed(x) + self.pos(torch.arange(T, device=x.device))
return self.head(self.encoder(h))
model = MiniBERT(len(chars)).to(device)
opt = torch.optim.AdamW(model.parameters(), lr=2e-3)
print(f"MiniBERT 参数量:{sum(p.numel() for p in model.parameters()):,}")
for step in range(800):
start = torch.randint(0, len(data) - BLOCK, (16,), device=device)
xb = torch.stack([data[s:s + BLOCK] for s in start])
mask = torch.rand(xb.shape, device=device) < 0.15
yb = xb.clone()
xb_masked = xb.clone()
xb_masked[mask] = MASK
opt.zero_grad()
logits = model(xb_masked)
loss = nn.functional.cross_entropy(logits[mask], yb[mask])
loss.backward()
opt.step()
if (step + 1) % 200 == 0:
print(f"step {step + 1}:loss = {loss.item():.4f}")
# 让模型补全:"温故而__新"(遮住"知")
model.eval()
sentence = "温故而知新可以为师"
ids = torch.tensor([stoi[c] for c in sentence], device=device).unsqueeze(0)
ids[0, 3] = MASK
with torch.no_grad():
prob = model(ids).softmax(-1)[0, 3]
top = torch.topk(prob, 3)
print("补全'温故而_新':候选", " ".join(itos[i.item()] for i in top.indices))examples/ch09_gpt.py
"""第 12 章示例:微型 GPT(因果 Transformer)字符级语言模型。
语料为《道德经》选段,训练后让模型续写文本。
运行方式:
uv run python examples/ch12_gpt.py
"""
import torch
from torch import nn
torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"
text = (
"道可道非常道名可名非常名无名天地之始有名万物之母故常无欲以观其妙常有欲以观其徼"
"此两者同出而异名同谓之玄玄之又玄众妙之门天下皆知美之为美斯恶已皆知善之为善斯不善已"
"故有无相生难易相成长短相形高下相倾音声相和前后相随是以圣人处无为之事行不言之教"
"万物作焉而不辞生而不有为而不恃功成而弗居夫唯弗居是以不去不尚贤使民不争不贵难得之货使民不为盗"
"不见可欲使民心不乱是以圣人之治虚其心实其腹弱其志强其骨常使民无知无欲使夫智者不敢为也"
"为无为则无不治道冲而用之或不盈渊兮似万物之宗挫其锐解其纷和其光同其尘湛兮似或存"
)
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
data = torch.tensor([stoi[c] for c in text], device=device)
BLOCK, D_MODEL, NHEAD, N_LAYER = 48, 64, 4, 2
class Block(nn.Module):
def __init__(self):
super().__init__()
self.ln1 = nn.LayerNorm(D_MODEL)
self.attn = nn.MultiheadAttention(D_MODEL, NHEAD, batch_first=True)
self.ln2 = nn.LayerNorm(D_MODEL)
self.mlp = nn.Sequential(
nn.Linear(D_MODEL, 4 * D_MODEL), nn.GELU(), nn.Linear(4 * D_MODEL, D_MODEL),
)
def forward(self, x, mask):
x = x + self.attn(self.ln1(x), self.ln1(x), self.ln1(x), attn_mask=mask)[0]
return x + self.mlp(self.ln2(x))
class MiniGPT(nn.Module):
def __init__(self, vocab):
super().__init__()
self.tok = nn.Embedding(vocab, D_MODEL)
self.pos = nn.Embedding(BLOCK, D_MODEL)
self.blocks = nn.ModuleList([Block() for _ in range(N_LAYER)])
self.ln_f = nn.LayerNorm(D_MODEL)
self.head = nn.Linear(D_MODEL, vocab)
def forward(self, x):
B, T = x.shape
h = self.tok(x) + self.pos(torch.arange(T, device=x.device))
mask = torch.triu(torch.full((T, T), float("-inf"), device=x.device), diagonal=1)
for block in self.blocks:
h = block(h, mask)
return self.head(self.ln_f(h))
model = MiniGPT(len(chars)).to(device)
opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
print(f"MiniGPT 参数量:{sum(p.numel() for p in model.parameters()):,}")
for step in range(400):
start = torch.randint(0, len(data) - BLOCK, (32,), device=device)
xb = torch.stack([data[s:s + BLOCK] for s in start])
yb = torch.stack([data[s + 1:s + BLOCK + 1] for s in start])
opt.zero_grad()
loss = nn.functional.cross_entropy(model(xb).transpose(1, 2), yb)
loss.backward()
opt.step()
if (step + 1) % 100 == 0:
print(f"step {step + 1}:loss = {loss.item():.4f}")
model.eval()
start = "道可道"
with torch.no_grad():
for _ in range(60):
idx = torch.tensor([[stoi[c] for c in start[-BLOCK:]]], device=device)
logits = model(idx)[0, -1] / 0.8
start += itos[torch.multinomial(logits.softmax(-1), 1).item()]
print("生成文本:", start)动手实践
- 运行三个示例脚本,分别观察分类准确率、MLM 补全、文本生成效果。
- 在
examples/ch09_transformer.py中把任务改成「序列中 3 的出现次数 ≥ 2」,重新训练,观察准确率与注意力矩阵。 - 把
examples/ch09_bert.py的掩码率从 0.15 改成 0.3,观察 loss 变化。 - 修改
examples/ch09_gpt.py的采样温度(0.2 / 0.8 / 1.5),观察生成文本的多样性。
常见错误
| 错误写法 | 现象 | 原因 |
|---|---|---|
| 因果掩码忘记加 | GPT 变成「偷看答案」,训练 loss 虚低 | 注意力矩阵上三角要设为 -∞ |
| 位置编码维度与序列长度不匹配 | index out of bounds | 生成时序列长度超过训练 BLOCK |
忘了 除以 √dₖ | softmax 饱和,注意力退化 | 缩放是标准做法 |
| BERT 把 MASK 位置当普通词 | 模型直接抄答案 | 输入要替换为 MASK 符号,目标用原词 |
nn.MultiheadAttention 输出取错 | 拿到的是 attention 而不是输出 | 返回 (output, weights),[0] 才是输出 |
| 序列长度变化但位置表固定 | 推理报错 | 用可学习 Embedding 时要 padding 或限制长度 |
章末练习
基础
- 写出自注意力的公式,并解释每个符号。
- 为什么 Transformer 需要位置编码?
- BERT 与 GPT 的预训练任务分别是什么?
提高
- 计算:Q、K、V 都是 (L, d) 时,自注意力的时间复杂度(用大 O 表示),并与 RNN 比较。
- 实现多头注意力:把 d 维分成 H 个头,分别计算后拼接,与
nn.MultiheadAttention对比输出。 - 修改 GPT 示例,加入温度采样参数,生成 3 段不同温度下的文本并比较。
挑战
- 实现一个「下一个字符预测」的编码器-解码器 Transformer(简化 T5),在数字加法任务上训练。
- 用 BERT 风格 MLM 做一个「完形填空」小应用:给定句子与空位,输出候选词及概率。
章末自测
- Transformer 的核心模块是?
- A. 卷积
- B. 自注意力
- C. 循环
- D. 池化
- 自注意力中除以 √dₖ 的目的是?
- A. 加速
- B. 防止 softmax 饱和
- C. 增加参数
- D. 正则化
- 多头注意力中「头」的作用是?
- A. 增加深度
- B. 关注不同子空间的关系
- C. 减少参数
- D. 池化
- BERT 的预训练任务是?
- A. 预测下一个词
- B. 掩码语言模型
- C. 翻译
- D. 分类
- GPT 生成时使用什么掩码?
- A. 卷积掩码
- B. 因果掩码
- C. 池化掩码
- D. 无掩码
- Transformer 相比 RNN 的主要优势?
- A. 顺序计算
- B. 并行计算与长距离依赖
- C. 更少参数
- D. 无需数据
- 位置编码解决什么问题?
- A. 梯度消失
- B. 注意力对顺序不敏感
- C. 过拟合
- D. 数据不平衡
- 《Attention Is All You Need》发表于?
- A. 2015
- B. 2017
- C. 2018
- D. 2020
- 掩码语言模型遮住的比例一般是?
- A. 5%
- B. 15%
- C. 50%
- D. 100%
- GPT 的「自回归」指?
- A. 双向看上下文
- B. 只根据左边预测右边
- C. 随机生成
- D. 卷积生成
