Transformer架构和实现
目录
一、核心思想
1.1 一句话理解Transformer
Transformer由Google在2017年论文《Attention is All You Need》中提出。它的核心思想可以用一句话概括:用**纯注意力机制(Self-Attention)**取代循环神经网络(RNN/LSTM),实现完全并行的序列处理。
1.2 核心创新(4个关键突破)
| 完全并行计算 | RNN必须逐词串行处理,速度慢 | 所有词同时处理,像GPU渲染一样快 |
| Self-Attention | RNN难以捕捉远距离依赖 | 每个词直接看到所有其他词,无视距离 |
| Multi-Head Attention | 单一视角不够全面 | 8个"专家"从不同角度理解句子 |
| Positional Encoding | Attention不感知词序 | 给每个词加上"位置标签" |
1.3 生活中的类比:会议室里的头脑风暴
想象一个会议室里有6个人,每人代表句子中的一个词。他们要理解彼此之间的关系:
-
RNN的方式:第1个人把想法传给第2个人,第2个人结合自己的想法再传给第3个人…。问题是传到第6个人时,第1个人的原始想法可能已经被稀释模糊了。而且每个人都必须等前一个人说完才能开始工作(串行)。
-
Transformer的方式:所有人同时把自己的想法写在黑板上的6个位置。然后每个人可以同时查看所有6个位置的信息,并根据自己的需求有选择地关注某些位置的信息。所有人都同时工作(并行),而且每个人都能直接看到所有人的原始信息(无视距离)。
这就是Transformer革命性的本质:让所有信息对所有人同时可见,每个人自主决定关注什么。
1.4 论文信息
- 论文: Attention is All You Need (2017)
- 链接: https://arxiv.org/pdf/1706.03762.pdf
- 作者: Google Research (Ashish Vaswani et al.)
- 引用量: 10万+(截至2024年,是AI领域引用最多的论文之一)
- 影响力: 催生了BERT、GPT、T5、 ViT等几乎所有现代NLP/CV大模型
二、为什么需要Transformer?——从RNN的困境说起
2.1 回顾:RNN做了什么,以及它的"硬伤"
在上一章的Seq2Seq中,我们使用GRU(一种RNN)逐词处理句子。让我们深入理解RNN的三个致命问题:
问题1: 串行计算——“高速公路变单车道”
RNN处理句子 "I love deep learning":
Step 1: 处理"I" → hidden_1 (耗时t)
Step 2: 处理"love" → hidden_2 (耗时t, 必须先等Step 1完成)
Step 3: 处理"deep" → hidden_3 (耗时t, 必须先等Step 2完成)
Step 4: 处理"learning" → hidden_4 (耗时t, 必须先等Step 3完成)
总耗时: 4t (无法并行)
而Transformer可以同时处理这4个词,总耗时 ≈ t。
句子越长,优势越明显。
问题2: 长距离依赖——“传话游戏的最后一环”
考虑句子: "The cat that ate the fish which was caught by the fisherman is sleeping."
问题: "is sleeping"的主语是谁?
答案: "The cat" (距离 "is" 有10个词)
在RNN中,"The cat"的信息经过10步传递后变得模糊。
这就像"传话游戏"——传了10个人后,原始信息面目全非。
Transformer中,"is"可以直接看到"The cat"(距离为0),
信息不需要经过中间词传递。
问题3: 梯度消失/爆炸——“信号衰减”
RNN的反向传播需要沿时间步展开(BPTT),梯度需要经过多次乘法。如果每次乘的值都小于1,梯度会指数级衰减(梯度消失);如果都大于1,会指数级增长(梯度爆炸)。这导致RNN难以学习长距离的模式。
Transformer的Self-Attention通过直接连接避免了这种多步传播。
2.2 Transformer如何解决这些问题
RNN Transformer
串行? 是 (逐词处理) 否 (所有词同时处理)
长距离? 信息衰减,需要逐词传递 每个词直接看到所有其他词
梯度? 沿时间展开,梯度消失/爆炸 直接连接,梯度畅通
训练速度? 慢 (GPU利用率低) 快 (GPU并行能力充分发挥)
实际影响: 训练一个大型翻译模型,RNN可能需要一周,Transformer可能只要一天(同样的硬件)。
2.3 "Attention is All You Need"这个名字的含义
论文标题故意说"Attention is All You Need"(注意力就是你所需要的一切),是在强调:你不需要RNN,不需要卷积,你需要的只是Attention。 这是一种宣言——我们找到了更简单、更优雅、更高效的方案来取代原来复杂的架构。
三、Transformer整体架构
3.1 “巨型夹心饼干”——Encoder-Decoder对称结构
Transformer整体上仍然是Encoder-Decoder架构(和Seq2Seq一样),但内部的"发动机"从RNN换成了Self-Attention。
输入(Source) 输出(Target)
↓ ↓
┌───────────────┐ ┌───────────────┐
│Input Embedding│ │Output Embedding│
│+ Positional │ │+ Positional │
│ Encoding │ │ Encoding │
└───────┬───────┘ └───────┬───────┘
│ │
┌───────────────┴───────────────┐ ┌───────────────┴───────────────┐
│ Encoder × N (N=6) │ │ Decoder × N (N=6) │
│ ┌─────────────────────────┐ │ │ ┌─────────────────────────┐ │
│ │ 1. Multi-Head Self-Attn │ │ │ │ 1. Masked Multi-Head │ │
│ │ 2. Feed Forward │ │ │ │ Self-Attention │ │
│ └─────────────────────────┘ │ │ │ 2. Multi-Head Cross- │ │
│ 重复6次 │ │ │ Attention │ │
└───────────────┬───────────────┘ │ │ 3. Feed Forward │ │
│ │ └─────────────────────────┘ │
│ │ 重复6次 │
│ └───────────────┬───────────────┘
│ │
└──────────→ Cross-Attention ←────────┘
(Decoder的Q查询Encoder的K,V)
│
↓
┌───────────────┐
│ Linear │
│ + │
│ Softmax │
└───────────────┘
│
↓
Output Probabilities
(每个目标词的概率分布)
3.2 数据流的完整路径
让我们跟踪一个翻译任务的数据流:
任务: 英语 "I love you" → 德语 "Ich liebe dich"
Encoder侧:
"I" "love" "you"
↓ ↓ ↓
词嵌入 + 位置编码
↓ ↓ ↓
Encoder Layer 1 → Encoder Layer 2 → … → Encoder Layer 6
↓ ↓ ↓
memory (encoder输出,供Decoder的Cross-Attention使用)
Decoder侧:
"SOS" "Ich" "liebe" "dich"
↓ ↓ ↓ ↓
词嵌入 + 位置编码
↓ ↓ ↓ ↓
Decoder Layer 1 → … → Decoder Layer 6
↑ ↑
└── 每层的Cross-Attention都查看Encoder的memory ──┘
↓
Linear + Softmax → 预测下一个词
3.3 标准配置(N=6的Transformer Base)
| Encoder层数 N | 6 | Encoder堆叠6层 | 平衡深度和训练效率 |
| Decoder层数 N | 6 | Decoder堆叠6层 | 与Encoder对称 |
| d_model | 512 | 模型的主维度(embedding/hidden维度) | 足够表达丰富语义 |
| d_ff | 2048 | Feed Forward内部维度 | d_model × 4 是经验值 |
| head数 | 8 | 多头注意力的头数 | 8个视角,每个64维 |
| d_k = d_v | 64 | 每个头的维度(=512/8) | 8 × 64 = 512 (拼回去) |
| dropout | 0.1 | dropout概率 | 标准正则化值 |
| 总参数量 | ~65M | 模型参数总量 | 适合单GPU训练 |
3.4 层之间的数据流
假设输入: batch_size=2, seq_len=4, d_model=512
经过Embedding: [2, 4, 512]
+ Positional Enc: [2, 4, 512] (形状不变)
→ Encoder Layer 1: [2, 4, 512] (输出形状始终不变!)
→ Encoder Layer 2: [2, 4, 512]
→ …
→ Encoder Layer 6: [2, 4, 512] ← 这个就是"memory"
传给Decoder的Cross-Attention
关键发现: 在Transformer内部,数据形状 [batch, seq, d_model]
始终不变。所有sub-layer保持输入输出的形状一致,
这使得残差连接(相加)变得非常简单。
3.5 整体架构自测问题
四、Input Embeddings
4.1 Embedding的基本原理
词嵌入(Embedding)是将离散的词索引映射为连续的稠密向量。这个向量不仅代表词本身,还编码了词的语义。
词索引 "42" → Embedding表查询 → [0.23, -0.15, 0.78, …, 0.04] (512维)
直观理解:
– 相似含义的词有相似的向量 (king和queen的向量比较接近)
– 向量中的每个维度编码某种"语义属性"
– 这些向量不是人工设定的,而是训练过程中学到的
4.2 代码实现——为什么乘以√d_model?
class Embeddings(nn.Module):
def __init__(self, d_model, vocab):
"""
d_model: 嵌入维度 = 512
vocab: 词汇表大小 (例如30000个词)
nn.Embedding(num_embeddings, embedding_dim):
– 本质上是一个大的查找表 [vocab_size, d_model]
– 输入词索引 → 返回对应的向量行
– 类似于一个字典: {0: [0.1,0.2,…], 1: [-0.1,0.3,…], …}
– 这些值初始是随机的,然后通过训练优化
"""
super(Embeddings, self).__init__()
self.d_model = d_model
self.lut = nn.Embedding(num_embeddings=vocab, embedding_dim=d_model)
def forward(self, x):
"""
x: [batch_size, seq_len] 整数索引
例如: [[2, 5, 7, 1], [3, 8, 1, 0]]
返回: [batch_size, seq_len, d_model]
"""
# ===== Step 1: 查表获取词向量 =====
# lut(x): [batch, seq] → [batch, seq, d_model]
x = self.lut(x)
# ===== Step 2: 乘以 √d_model =====
# ★ 为什么要乘 √512 ≈ 22.6? ★
#
# 原因: Embedding初始值的方差较小(nn.Embedding使用
# N(0,1)初始化),而后面要加的Positional Encoding
# 取值范围在[-1, 1]。
#
# 如果不乘以√d_model:
# embedding值 ≈ [-0.03, 0.03] (方差小)
# positional值 ≈ [-1.0, 1.0] (范围大)
# 两者相加后,位置编码信息会"淹没"词嵌入信息
#
# 乘以√d_model后:
# embedding值 ≈ [-0.7, 0.7] (放大约22.6倍)
# 现在两者在相近的数量级上
# 位置信息和语义信息得以"平等融合"
#
# math.sqrt(self.d_model) = math.sqrt(512) ≈ 22.627
x = x * math.sqrt(self.d_model)
return x
4.3 Embedding的输出形状
输入: [2, 4] (2个句子, 每个4个词, 值为词索引)
[[5, 12, 3, 1],
[8, 2, 15, 3]]
查表:
索引5 → [0.01, -0.02, …, 0.03] (512维)
索引12 → [-0.01, 0.05, …, -0.02]
…
乘√512:
每个值 × 22.627
输出: [2, 4, 512] (2个句子, 4个词, 每个词512维向量)
4.4 Embedding自测问题
五、Positional Encoding(位置编码)
5.1 核心问题:Transformer怎么知道词序?
Attention机制有一个"盲点":它只看内容,不看位置。
句子1: "Dog bites man." (狗咬人)
句子2: "Man bites dog." (人咬狗)
两个句子的词集合完全一样 {"Dog", "bites", "man", "."}
如果没有位置信息,Transformer会认为它们是一样的!
而RNN天然知道词序——因为它逐词处理,顺序本身就是信息。Transformer失去了这个天然的时序信息,所以需要人为地给每个位置注入一个"位置信号"——这就是Positional Encoding。
5.2 位置编码的设计要求
一个理想的位置编码应该满足:
5.3 正弦/余弦位置编码公式
Transformer使用正弦和余弦函数的组合来编码位置:
P
E
(
p
o
s
,
2
i
)
=
sin
(
p
o
s
10000
2
i
/
d
m
o
d
e
l
)
PE_{(pos, 2i)} = \\sin\\left(\\frac{pos}{10000^{2i/d_{model}}}\\right)
PE(pos,2i)=sin(100002i/dmodelpos)
P
E
(
p
o
s
,
2
i
+
1
)
=
cos
(
p
o
s
10000
2
i
/
d
m
o
d
e
l
)
PE_{(pos, 2i+1)} = \\cos\\left(\\frac{pos}{10000^{2i/d_{model}}}\\right)
PE(pos,2i+1)=cos(100002i/dmodelpos)
其中:
-
p
o
s
pos
pos:词在句子中的位置(0, 1, 2, 3, …) -
i
i
i:维度的索引(0, 1, 2, …, d_model/2 – 1) -
d
m
o
d
e
l
d_{model}
dmodel:嵌入维度(512)
5.4 为什么用sin和cos?——逐步理解
Step 1: 理解"10000^(2i/d_model)"
这个指数项控制不同维度的"频率"。
当 i=0: 10000^(0/512) = 10000^0 = 1 → 最低频率 (波长最长)
当 i=128: 10000^(256/512) = 10000^0.5 = 100 → 中等频率
当 i=255: 10000^(510/512) ≈ 10000^1 = 10000 → 最高频率 (波长最短)
所以:
- 低维度(i小):变化慢,波长长 → 捕捉"宏观位置"(大概在第几段)
- 高维度(i大):变化快,波长短 → 捕捉"精确位置"(具体第几个词)
Step 2: 理解为什么用sin和cos交替
偶数维度(2i): 使用 sin
奇数维度(2i+1): 使用 cos
这样的好处: 对于任意固定的偏移量k,PE(pos+k) 可以表示为 PE(pos) 的线性函数。
这使得模型可以轻松学习"相对位置关系"。
数学原理:
sin(a+b) = sin(a)cos(b) + cos(a)sin(b)
cos(a+b) = cos(a)cos(b) – sin(a)sin(b)
所以 PE(pos+k) = PE(pos) × 某个线性变换
这意味着注意力机制可以自然地学习到位置之间的相对关系
5.5 代码实现——逐行详解
class PositionalEncoding(nn.Module):
def __init__(self, d_model, dropout=0.1, max_len=5000):
"""
d_model: 嵌入维度 = 512
dropout: 正则化 = 0.1
max_len: 最大句子长度 = 5000 (预计算这么多位置的编码)
"""
super(PositionalEncoding, self).__init__()
self.dropout = nn.Dropout(p=dropout)
# ===== Step 1: 创建位置编码矩阵 [max_len, d_model] =====
pe = torch.zeros(max_len, d_model) # [5000, 512] 全零初始化
# ===== Step 2: 创建位置索引 =====
# torch.arange(0, max_len) → [0, 1, 2, …, 4999]
# .unsqueeze(1) → [[0], [1], [2], …, [4999]] 形状 [5000, 1]
position = torch.arange(0, max_len).unsqueeze(1)
# ===== Step 3: 计算 div_term (频率项) =====
# 公式: 1 / (10000^(2i/d_model))
#
# 为什么用 exp 而不是 pow?
# 因为 exp(log(x)) = x,且 exp 在GPU上更快
#
# torch.arange(0, d_model, 2): [0, 2, 4, …, 510] (256个值)
# -(math.log(10000.0) / d_model): -(9.21 / 512) ≈ -0.01799
# 相乘: [0, -0.036, -0.072, …, -9.17]
# exp(…): [1.0, 0.965, 0.931, …, 0.0001]
div_term = torch.exp(
torch.arange(0, d_model, 2) *
–(math.log(10000.0) / d_model)
)
# div_term 形状: [256] (d_model/2 = 256)
# ===== Step 4: 计算位置 × 频率 =====
# position: [5000, 1]
# div_term: [256]
# 广播相乘: [5000, 256]
#
# my_matmulres[pos][i] = pos / (10000^(2i/d_model))
my_matmulres = position * div_term
# ===== Step 5: 填充 pe 矩阵 =====
# 偶数维度 (0, 2, 4, …, 510): 填入 sin
# pe[:, 0::2] 表示: 所有行, 从第0列开始每隔2列取
pe[:, 0::2] = torch.sin(my_matmulres)
# 奇数维度 (1, 3, 5, …, 511): 填入 cos
# pe[:, 1::2] 表示: 所有行, 从第1列开始每隔2列取
pe[:, 1::2] = torch.cos(my_matmulres)
# ===== Step 6: 增加batch维度 =====
# pe: [5000, 512] → [1, 5000, 512]
# 增加batch维度方便与输入相加(广播)
pe = pe.unsqueeze(0)
# ===== Step 7: 注册为buffer(非训练参数) =====
# register_buffer: 保存到state_dict中,但不参与梯度计算
# 位置编码是固定的,不需要训练
self.register_buffer('pe', pe)
def forward(self, x):
"""
x: [batch_size, seq_len, d_model] 例如 [2, 4, 512]
返回: x + pe (位置编码直接加到词嵌入上)
"""
# Variable(self.pe[:, :x.size(1)], requires_grad=False)
# self.pe[:, :x.size(1)]: 取前 seq_len 个位置的编码
# 例如 x.size(1)=4 → 取 pe[0, 0:4, :] → [1, 4, 512]
# requires_grad=False: 位置编码不参与梯度计算
#
# x + pe: 广播相加
# x: [2, 4, 512]
# pe: [1, 4, 512] ← 广播到 [2, 4, 512]
# 结果: [2, 4, 512]
x = x + Variable(self.pe[:, :x.size(1)], requires_grad=False)
# Dropout: 训练时随机丢弃10%的神经元
return self.dropout(x)
5.6 位置编码的可视化理解
如果你把PE矩阵画出来,你会看到:
位置编码矩阵 pe[位置, 维度] 的图案:
维度0 (低频sin波):
位置: 0 1 2 3 4 5 … 99 100
值: 0.0 0.8 0.9 0.1 -0.8 -0.9 … ->
像一个非常平缓的正弦波,周期很长
维度255 (高频sin波):
位置: 0 1 2 3 4 5 … 99 100
值: 0.0 0.1 -0.2 0.3 -0.1 0.0 … ->
像一个非常快速振荡的波,周期很短
每个位置(pos)的512维编码就像该位置的一个"指纹"
位置0的指纹 ≠ 位置1的指纹 ≠ 位置2的指纹…
但位置0和位置2之间的"差异模式"是可学习的
5.7 Positional Encoding自测问题
六、Scaled Dot-Product Attention——注意力的心脏
6.1 从"查字典"理解Dot-Product Attention
让我们用一个日常生活中的类比来理解注意力机制:
场景:你在图书馆查资料
你有一个问题Q:“Transformer的注意力机制是怎么工作的?”
图书馆有很多本书,每本书前面有目录/关键词K,书里面有具体内容V。
过程:
Scaled Dot-Product Attention就是做这件事的数学公式。
6.2 核心公式——每个符号的含义
Attention
(
Q
,
K
,
V
)
=
softmax
(
Q
K
T
d
k
)
V
\\text{Attention}(Q, K, V) = \\text{softmax}\\left(\\frac{QK^T}{\\sqrt{d_k}}\\right)V
Attention(Q,K,V)=softmax(dk
QKT)V
逐个符号解释:
|
Q Q Q |
Query(查询):“我要找什么?” | [batch, heads, seq_q, d_k] |
|
K K K |
Key(键):“每个位置有什么标签?” | [batch, heads, seq_k, d_k] |
|
V V V |
Value(值):“每个位置的实际内容?” | [batch, heads, seq_k, d_v] |
|
Q K T QK^T QKT |
Q和K的内积(点积),衡量相似度 | [batch, heads, seq_q, seq_k] |
|
d k \\sqrt{d_k} dk |
缩放因子,防止点积太大 | 标量 (通常d_k=64) |
| softmax(…) | 归一化为概率分布(每行和为1) | [batch, heads, seq_q, seq_k] |
| 右乘V | 按注意力权重对V加权求和 | [batch, heads, seq_q, d_v] |
6.3 为什么要除以√d_k?——"缩放"的关键
问题:当d_k很大时(比如512),Q和K的点积值也会很大。大的点积值经过Softmax后会变得非常"尖锐"——只有一个位置的权重接近1,其他都接近0。
不缩放的情况 (d_k=64, 不除以√64=8):
Q·K^T的值:
K[0] K[1] K[2] K[3]
Q[0]: [0.3, 0.5, 0.1, 0.2] ← 差异不大,Softmax后分布合理
Q[1]: [12.3, 10.5, 8.1, 9.2] ← 差异大但其实还正常
当 d_k=512, Q和K的每个元素期望≈0,方差≈1:
Q·K^T 是512个独立随机变量的和,方差≈512
所以 Q·K^T 的值通常在 [-√512, √512] ≈ [-22, 22] 范围内
↓
Softmax(22, -15, 3, -8) ≈ [~1.0, ~0.0, ~0.0, ~0.0]
↓
梯度几乎为零!模型学不动了!
缩放后 (除以√64=8):
Q·K^T/8 的值范围缩小到 [-2.75, 2.75]
Softmax(2.75, -1.9, 0.4, -1.0) ≈ [0.8, 0.05, 0.5, 0.01]
分布合理,梯度正常流动 ✓
核心结论:除以√d_k控制点积的方差为1,使得Softmax的输出不会过于极端,保持梯度健康流动。
6.4 代码实现——逐步跟踪张量形状
def attention(query, key, value, mask=None, dropout=None):
"""
参数:
– query: [batch, heads, seq_len, d_k] 例如 [2, 8, 4, 64]
– key: [batch, heads, seq_len, d_k] 例如 [2, 8, 4, 64]
– value: [batch, heads, seq_len, d_k] 例如 [2, 8, 4, 64]
– mask: [batch, 1, seq_len, seq_len] 或 None
返回:
– output: [batch, heads, seq_len, d_k]
– p_attn: [batch, heads, seq_len, seq_len] 注意力权重
"""
# ===== Step 1: 获取d_k =====
# query.size()[-1]: 取query最后一个维度的大小
# 如果query是 [2, 8, 4, 64],则d_k = 64
d_k = query.size()[–1]
# ===== Step 2: 计算注意力分数 =====
# Q @ K^T: 矩阵乘法
# query: [2, 8, 4, 64]
# key.transpose(-2, -1): [2, 8, 64, 4] (最后两维交换)
# torch.matmul: [2, 8, 4, 4] ← 每个头一个4×4的注意力分数矩阵
#
# 除以√d_k: 缩放,防止Softmax饱和
# math.sqrt(64) = 8
scores = torch.matmul(query, key.transpose(–2, –1)) / math.sqrt(d_k)
# ===== Step 3: Mask(如果有) =====
# 将mask==0的位置的分数设为 -1e9 (极大的负数)
# Softmax(-1e9) ≈ 0 — 让被mask的位置不参与注意力计算
if mask is not None:
scores = scores.masked_fill(mask == 0, –1e9)
# ===== Step 4: Softmax归一化 =====
# dim=-1: 在最后一个维度(seq_len维度)做Softmax
# 结果: 每行的和为1,每个值在[0,1]之间
# [2, 8, 4, 4] → [2, 8, 4, 4]
p_attn = F.softmax(scores, dim=–1)
# ===== Step 5: Dropout正则化(训练时) =====
if dropout is not None:
p_attn = dropout(p_attn)
# ===== Step 6: 加权求和 =====
# p_attn: [2, 8, 4, 4] 注意力权重
# value: [2, 8, 4, 64] 原始值
# 结果: [2, 8, 4, 64] 加权后的输出
#
# 每行的含义: output[q_idx] = Σ (p_attn[q_idx][k_idx] × value[k_idx])
# 即: 每个查询位置的输出 = 所有键位置值的加权平均
return torch.matmul(p_attn, value), p_attn
6.5 计算流程的可视化——以"我爱中国"为例
输入句子: "I love China" (加上标点 ".")
词索引: [I, love, China, .]
位置: 0 1 2 3
假设 d_k = 4 (为了简化演示,实际是64)
Step 1: 计算 Q @ K^T
Q和K都是 [1, 8, 4, 4] (1个batch, 8个头, 4个词, 4维)
以某一个头为例:
Q(一个头) = [4×4]矩阵 K(一个头) = [4×4]矩阵
I love China . I love China .
Q[0]: [0.2, 0.5, -0.1, 0.3] K[0]: [0.1, -0.2, 0.4, 0.1]
Q[1]: [0.1, -0.3, 0.4, -0.2] K[1]: [-0.1, 0.3, -0.2, 0.5]
Q[2]: [-0.2, 0.1, 0.5, 0.2] K[2]: [0.3, 0.1, -0.1, 0.2]
Q[3]: [0.4, -0.1, -0.3, 0.1] K[3]: [-0.2, 0.4, 0.2, -0.1]
scores = Q @ K^T / √4:
K[0] K[1] K[2] K[3]
Q[0]: [0.3, 0.1, -0.2, 0.0]
Q[1]: [0.0, 0.4, 0.2, -0.1]
Q[2]: [-0.1, 0.2, 0.5, 0.1]
Q[3]: [0.2, -0.1, 0.0, 0.4]
scores[0][0] = 0.3 表示: Q[0]("I") 与 K[0]("I") 的相似度为0.3
Step 2: Softmax(每行)
K[0] K[1] K[2] K[3] (和为1)
Q[0]: [0.35, 0.25, 0.15, 0.25] ← "I"最关注自己(0.35)
Q[1]: [0.20, 0.35, 0.30, 0.15] ← "love"关注自己和"China"
Q[2]: [0.15, 0.25, 0.40, 0.20] ← "China"最关注自己
Q[3]: [0.25, 0.15, 0.15, 0.45] ← "."最关注自己
解读: 每个词对自己的关注度最高(这是自注意力的典型模式),
同时也关注其他相关的词。
Step 3: 加权求和
output[i] = Σ_j p_attn[i][j] × V[j]
output[0] = 0.35×V[0] + 0.25×V[1] + 0.15×V[2] + 0.25×V[3]
(这行表示: "I"的新表示包含了"I"(35%)、"love"(25%)等的信息)
output[2] = 0.15×V[0] + 0.25×V[1] + 0.40×V[2] + 0.20×V[3]
("China"的新表示主要来自自己(40%),但也吸收其他词的信息)
6.6 三种Attention的对比
| Q来源 | 自身的上一层输出 | 自身的上一层输出 | Decoder的自身输出 |
| K来源 | 自身的上一层输出 | 自身的上一层输出 | Encoder的输出(memory) |
| V来源 | 自身的上一层输出 | 自身的上一层输出 | Encoder的输出(memory) |
| Mask | 可选的Padding Mask | Sequence + Padding Mask | Padding Mask |
| 用途 | 理解句子内部关系 | Decoder防止看到未来 | Decoder关注输入信息 |
6.7 Scaled Dot-Product Attention自测问题
七、Multi-Head Attention——多个视角看问题
7.1 为什么需要"多头"?
单头Attention只有一个视角。想象一个翻译任务:
句子: "The bank is by the river bank."
单头注意力可能只关注到一种关系:
– "bank"和"river"的关系 (河岸)
– 但"bank"也有"银行"的含义
– 另外"is"和"bank"之间有语法关系
单一视角无法同时捕捉多种关系!
多头注意力让模型可以从多个子空间(不同语义角度)同时关注信息:
- Head 1: 关注"语法关系"(主语-谓语-宾语)→ “bank” ← “is”(系动词)
- Head 2: 关注"语义关系"(词的含义关联)→ “bank” ← “river”(河岸)
- Head 3: 关注"位置关系"(相邻词)→ “the” → “bank”(冠词+名词)
- Head 4: 关注"另一个含义" → “bank” ← “money”(银行,如果上下文中有的话)
- …
8个"专家"从8个不同的角度理解句子,然后综合意见。
7.2 公式
MultiHead
(
Q
,
K
,
V
)
=
Concat
(
head
1
,
head
2
,
.
.
.
,
head
h
)
W
O
\\text{MultiHead}(Q, K, V) = \\text{Concat}(\\text{head}_1, \\text{head}_2, …, \\text{head}_h)W^O
MultiHead(Q,K,V)=Concat(head1,head2,…,headh)WO
head
i
=
Attention
(
Q
W
i
Q
,
K
W
i
K
,
V
W
i
V
)
\\text{head}_i = \\text{Attention}(QW_i^Q, KW_i^K, VW_i^V)
headi=Attention(QWiQ,KWiK,VWiV)
其中
W
i
Q
,
W
i
K
,
W
i
V
W_i^Q, W_i^K, W_i^V
WiQ,WiK,WiV 是每个头的投影矩阵,
W
O
W^O
WO 是输出投影矩阵。
7.3 代码实现——分量与合体
def clones(module, N):
"""
克隆N个相同的模块(深拷贝)
为什么需要clones?
– Multi-Headed Attention中有4个Linear层(Q,K,V,O)
– 代码中用clones一次性创建4个,保持代码简洁
– copy.deepcopy确保每个副本是独立的(参数不共享)
"""
return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])
class MultiHeadedAttention(nn.Module):
def __init__(self, head, embedding_dim, dropout=0.1):
"""
head: 注意力头数 = 8
embedding_dim: 嵌入维度 = 512
dropout: 0.1
关键计算: d_k = embedding_dim / head = 512 / 8 = 64
为什么d_k=64?
– 8个头 × 64维 = 512维,拼回去刚好等于原始维度
– 这样残差连接时可以直接相加,不需要额外变换
"""
super(MultiHeadedAttention, self).__init__()
# 断言: embedding_dim必须能被head整除
# 如果512不能被8整除,就会报错
assert embedding_dim % head == 0
self.d_k = embedding_dim // head # 512 // 8 = 64
self.head = head # 8
# ===== 4个线性变换层 =====
# 为什么是4个?
# linears[0]: W^Q (Query投影)
# linears[1]: W^K (Key投影)
# linears[2]: W^V (Value投影)
# linears[3]: W^O (Output投影,用于合并多头)
#
# 每个Linear(512, 512)的作用:
# 将输入投影到一个新的空间,让模型学习"从什么角度看问题"
# 虽然输入512维、输出512维,但这不是恒等变换
# — 它学到的是一种"视角转换"
self.linears = clones(nn.Linear(embedding_dim, embedding_dim), 4)
self.attn = None # 保存最后的注意力权重(用于可视化)
self.dropout = nn.Dropout(p=dropout)
def forward(self, query, key, value, mask=None):
"""
前向传播: 多头注意力的完整计算
参数:
– query: [batch, seq_len, d_model] 例如 [2, 4, 512]
– key: [batch, seq_len, d_model] 例如 [2, 4, 512]
– value: [batch, seq_len, d_model] 例如 [2, 4, 512]
– mask: [batch, 1, seq_len, seq_len] 或 None
返回: [batch, seq_len, d_model]
=== 整体流程 ===
输入: [2, 4, 512]
|
v Linear变换 (每个头有独立的Q,K,V投影)
[2, 4, 512] → [2, 4, 8, 64] → [2, 8, 4, 64]
| (view分头) (transpose换轴)
v
Scaled Dot-Product Attention (每个头独立计算)
|
v [2, 8, 4, 64]
|
v transpose + view: 合并多头
[2, 4, 8, 64] → [2, 4, 512]
|
v Linear(W^O) 最终投影
[2, 4, 512]
"""
# ===== Mask处理: 增加头维度 =====
# mask原始形状: [batch, seq_len, seq_len] 例如 [2, 4, 4]
# 增加维度后: [batch, 1, seq_len, seq_len] 例如 [2, 1, 4, 4]
# 这样mask可以广播到所有8个头
if mask is not None:
mask = mask.unsqueeze(0) # 此处的实现略有特殊
batch_size = query.size(0)
# ===== Step 1: 线性变换 + 分头 =====
# 这个列表推导同时处理Q、K、V:
#
# 对于query:
# linears[0](query): [2, 4, 512] → [2, 4, 512] (Linear变换)
# .view(batch, -1, head, d_k): [2, 4, 512] → [2, 4, 8, 64]
# -1 表示"自动推断",这里=4(seq_len)
# 将512维拆成 [8个头 × 64维/头]
# .transpose(1,2): [2, 4, 8, 64] → [2, 8, 4, 64]
# 交换维度1和维度2,使heads维度在seq维度之前
# 这样每个头独立处理自己的[seq, d_k]数据块
#
# 对于key和value也是同样的操作
query, key, value = [
model(x).view(batch_size, –1, self.head, self.d_k).transpose(1, 2)
for model, x in zip(self.linears, (query, key, value))
]
# 现在: query=[2, 8, 4, 64], key=[2, 8, 4, 64], value=[2, 8, 4, 64]
# ===== Step 2: Scaled Dot-Product Attention =====
# 调用之前定义的attention函数
# 输入: [2, 8, 4, 64] × 3
# 输出: x=[2, 8, 4, 64], self.attn=[2, 8, 4, 4]
x, self.attn = attention(query, key, value, mask, dropout=self.dropout)
# ===== Step 3: 合并多头 (Concat) =====
# Step 3a: transpose – 将heads维度移回原位置
# [2, 8, 4, 64] → [2, 4, 8, 64]
#
# Step 3b: contiguous – 使内存连续
# transpose只是改变了"视角"(stride),不改变实际内存布局
# .contiguous() 重新排列内存,使其连续
# 这是PyTorch的要求: view()只能用于连续tensor
#
# Step 3c: view – 合并最后两个维度
# [2, 4, 8, 64] → [2, 4, 512]
# batch=2, seq_len=4, 8*64=512=d_model
x = x.transpose(1, 2).contiguous().view(
batch_size, –1, self.head * self.d_k
)
# ===== Step 4: 最终线性变换 (W^O) =====
# linears[-1] 是第4个Linear: W^O投影
# [2, 4, 512] → [2, 4, 512]
# 虽然维度没变,但这个变换混合了8个头的信息
return self.linears[–1](x)
7.4 view + transpose 的区别——一个重要的PyTorch陷阱
# 这是很多初学者会踩的坑!
# 创建一个测试张量
a = torch.randn(1, 2, 3, 4) # [1, 2, 3, 4]
# view: 只改变"形状解释",不改变内存中的数据顺序
b = a.view(1, 3, 2, 4)
# a[0,0,0,:] 和 b[0,0,0,:] 的数据一样吗?
# 是的!因为view只是重新解释同一块内存
# transpose: 交换两个维度的步长(stride)
c = a.transpose(1, 2) # [1, 3, 2, 4]
# 现在 a[0,0,1,:] 变成了 c[0,1,0,:]
# 数据在内存中的排列不同了
# view在非连续tensor上会报错!
# d = c.view(1, 3, 2, 4) ← 这会报错!
# 必须先用.contiguous()使内存连续
d = c.contiguous().view(1, 3, 2, 4) # ← 正确做法
# 简单记忆:
# view = 重塑(要求内存连续)
# transpose = 交换(打乱内存,需要contiguous后才能view)
7.5 每个头学到什么?
虽然我们无法精确控制每个头关注什么,但研究表明(通过分析注意力模式),不同的头确实自然地学到了不同的模式:
- 有的头关注"相邻位置"(局部语法关系)
- 有的头关注"远距离关系"(例如主语-谓语一致)
- 有的头关注"特定词性"(将所有名词关联起来)
- 有的头关注"自身"(保留原词信息)
这种多样性正是Multi-Head的设计目标。
7.6 Multi-Head Attention自测问题
八、Mask机制——防止作弊和信息泄漏
8.1 为什么需要Mask?
Transformer中有两种完全不同的Mask,解决两种不同的问题:
8.2 Padding Mask —— 让填充词"隐身"
场景: batch中有两个不同长度的句子:
句子1: "I love NLP" → [I, love, NLP, EOS] (4个词)
句子2: "Hello world" → [Hello, world, EOS] (3个词)
为了组成batch,需要将句子2填充到同样长度:
句子2(padded): [Hello, world, EOS, PAD] (第4个位置是填充)
填充的PAD不应该参与Attention计算!
Padding Mask:
句子1: [1, 1, 1, 1] ← 所有位置都参与
句子2: [1, 1, 1, 0] ← 最后一个位置(填充)的mask=0
在attention函数中:
if mask is not None:
scores = scores.masked_fill(mask == 0, -1e9)
# PAD位置的score变成 -1e9
# Softmax(-1e9) ≈ 0 → PAD的注意力权重≈0
代码示例:
# 被mask的位置设为-1e9,softmax后趋近于0
scores = scores.masked_fill(mask == 0, –1e9)
# 为什么是-1e9而不是负无穷?
# -float('inf') 在数值计算中可能产生NaN
# -1e9足够小,在Softmax下 ≈ 0,且数值稳定
8.3 Sequence Mask —— 阻止"时间旅行"
问题场景:在Decoder训练时,我们的输入是整个目标序列:
输入: [SOS, Ich, liebe, dich] (目标语言的完整句子)
目标: [Ich, liebe, dich, EOS] (向右偏移一位的预测目标)
如果不用mask,Decoder的自注意力就能看到:
- 位置0(“SOS”)在计算注意力时可以"偷看"位置1(“Ich”)、位置2(“liebe”)…
- 这等于"先看到答案再答题"——作弊!
解决方法:Sequence Mask(上三角掩码)
def subsequent_mask(size):
"""
生成一个下三角矩阵 (只能往过去看, 不能往未来看)
参数: size = 5
返回: [1, 5, 5] 的mask矩阵
"""
# Step 1: 创建上三角矩阵
# np.triu(np.ones((1,5,5)), k=1)
# k=1 表示从对角线之上(k=1)开始保留
#
# 结果(上三角=1, 下三角=0):
# [[0, 1, 1, 1, 1],
# [0, 0, 1, 1, 1],
# [0, 0, 0, 1, 1],
# [0, 0, 0, 0, 1],
# [0, 0, 0, 0, 0]]
subsequent_mask = np.triu(
m=np.ones((1, size, size)), k=1
).astype('uint8')
# Step 2: 取反 (下三角=1, 上三角=0)
# 1 – subsequent_mask:
# [[1, 0, 0, 0, 0], ← 位置0只能看到位置0
# [1, 1, 0, 0, 0], ← 位置1可以看到位置0和1
# [1, 1, 1, 0, 0], ← 位置2可以看到位置0,1,2
# [1, 1, 1, 1, 0], ← 位置3可以看到位置0,1,2,3
# [1, 1, 1, 1, 1]] ← 位置4可以看到所有位置
return torch.from_numpy(1 – subsequent_mask)
8.4 Sequence Mask的直观理解
翻译 "I love you" → "Je t'aime"
Decoder训练时输入: [SOS, Je, t', aime]
Sequence Mask使得:
– "SOS"的注意力: 只能看 [SOS, X, X, X] (什么也看不到)
– "Je"的注意力: 只能看 [SOS, Je, X, X] (可以看到SOS)
– "t'"的注意力: 只能看 [SOS, Je, t', X] (可以看到前面两个)
– "aime"的注意力: 只能看 [SOS, Je, t', aime] (全部看到)
这模拟了"逐词生成"的过程:
在真实推理时,生成"aime"时,前三个词已经生成了,
但"aime"后面的词还不存在,所以不应该看到。
8.5 Mask的数值实现
Mask矩阵 (1=可见, 0=不可见):
[[1, 0, 0, 0],
[1, 1, 0, 0],
[1, 1, 1, 0],
[1, 1, 1, 1]]
在attention函数中:
scores = Q @ K^T / √d_k
假设scores:
[[0.3, 0.1, 0.2, 0.4],
[0.1, 0.5, 0.1, 0.3],
[0.2, 0.1, 0.6, 0.1],
[0.0, 0.3, 0.2, 0.5]]
应用mask: scores[mask==0] = -1e9
[[0.3, -1e9, -1e9, -1e9],
[0.1, 0.5, -1e9, -1e9],
[0.2, 0.1, 0.6, -1e9],
[0.0, 0.3, 0.2, 0.5]]
Softmax后(每行归一化):
[[1.00, 0.00, 0.00, 0.00], ← SOS只能关注自己
[0.40, 0.60, 0.00, 0.00], ← Je关注SOS(0.4)和自己(0.6)
[0.24, 0.22, 0.54, 0.00], ← t'关注前三个位置
[0.12, 0.26, 0.21, 0.41]] ← aime可以看所有
注意: 被mask的位置权重为0 (不漏未来信息) ✓
8.6 Mask自测问题
九、Position-wise Feed Forward——逐位置的前馈网络
9.1 为什么需要Feed Forward?
Self-Attention负责"信息交流"(词与词之间交换信息),但它本质上是一个线性加权操作(虽然有多头和多层的非线性的叠加)。Feed Forward给每个位置独立地添加非线性变换能力:
Self-Attention: "每个词和其他词交流"
Feed Forward: "每个词独立地思考、消化刚才交流获得的信息"
9.2 公式
F
F
N
(
x
)
=
max
(
0
,
x
W
1
+
b
1
)
W
2
+
b
2
FFN(x) = \\max(0, xW_1 + b_1)W_2 + b_2
FFN(x)=max(0,xW1+b1)W2+b2
用ReLU更准确地写:
F
F
N
(
x
)
=
ReLU
(
x
W
1
+
b
1
)
W
2
+
b
2
FFN(x) = \\text{ReLU}(xW_1 + b_1)W_2 + b_2
FFN(x)=ReLU(xW1+b1)W2+b2
关键结构特点:
-
W
1
W_1
W1: 512 → 2048 (扩张4倍) -
W
2
W_2
W2: 2048 → 512 (缩回原维度) - 扩张-收缩 结构,类似一个"沙漏"或"瓶颈"
9.3 "Position-wise"是什么意思?
"Position-wise"意味着:每个位置独立地应用相同的FFN变换,位置之间不交互。
对于每个位置i (i=0,1,…,seq_len-1):
x[i] 是 [512] 维向量
FFN(x[i]) = ReLU(x[i] @ W1 + b1) @ W2 + b2
所有位置共享W1和W2参数(权重绑定)
这就像:
– 每个词独立地走进自己的"处理室"
– 都使用同样的处理规则(W1, W2)
– 处理后走出来
– 词之间在FFN阶段不交流
9.4 代码实现
class PositionwiseFeedForward(nn.Module):
def __init__(self, d_model, d_ff, dropout=0.1):
"""
d_model: 主维度 = 512
d_ff: FFN内部维度 = 2048
dropout: 0.1
为什么 d_ff = 2048 = 4 × d_model?
– 经验表明,4倍扩张是最好的平衡
– 太小:非线性表达能力不够
– 太大:参数太多,训练慢
– 512→2048→512 是标准配置
"""
super(PositionwiseFeedForward, self).__init__()
# ===== 第1层: 扩张 (512 → 2048) =====
self.w1 = nn.Linear(d_model, d_ff)
# 参数量: 512 × 2048 + 2048(偏置) ≈ 1,050,624
# ===== 第2层: 收缩 (2048 → 512) =====
self.w2 = nn.Linear(d_ff, d_model)
# 参数量: 2048 × 512 + 512(偏置) ≈ 1,049,088
self.dropout = nn.Dropout(p=dropout)
def forward(self, x):
"""
x: [batch, seq_len, d_model] 例如 [2, 4, 512]
返回: [batch, seq_len, d_model]
处理流程:
[2, 4, 512]
→ w1: Linear(512→2048) → [2, 4, 2048]
→ F.relu: 非线性激活 → [2, 4, 2048]
→ dropout: 正则化 → [2, 4, 2048]
→ w2: Linear(2048→512) → [2, 4, 512]
"""
# self.w1(x): [2, 4, 512] → [2, 4, 2048]
# F.relu(…): [2, 4, 2048] → [2, 4, 2048]
# self.dropout(…):[2, 4, 2048] → [2, 4, 2048]
# self.w2(…): [2, 4, 2048] → [2, 4, 512]
x = self.w2(self.dropout(F.relu(self.w1(x))))
return x
9.5 为什么需要扩张-收缩结构?
512 → 2048 → 512
扩张阶段(512→2048):
– 在更高维空间中"分解"信息
– 更多的神经元 = 更强的表达能力
– 类似: 用小本子记录 vs 用大黑板演算
激活(ReLU):
– 引入非线性
– 没有激活函数的话,两层Linear可以合并为一层
(Linear(512,2048) + Linear(2048,512) = Linear(512,512))
– 所以ReLU是必须的,否则二层的表达能力=一层
收缩阶段(2048→512):
– 信息压缩,提取精华
– 恢复到原始维度,保证残差连接通畅
9.6 Feed Forward自测问题
十、Layer Normalization与残差连接
10.1 为什么需要Normalization?
深层神经网络的训练面临一个挑战:每一层的输出分布会不断变化(Internal Covariate Shift)。BatchNorm和LayerNorm都是为了稳定每一层的输入分布。
10.2 BatchNorm vs LayerNorm——为什么NLP用LayerNorm?
| 归一化维度 | 在batch维度上归一化 | 在feature维度上归一化 |
| 计算方式 | 同一个特征在不同样本间求均值 | 同一个样本在不同特征间求均值 |
| 依赖batch大小 | 是(batch小→统计不准) | 否 |
| NLP适用性 | 差(变长序列+小batch) | 好 |
| 训练/推理行为 | 不同(训练用batch统计,推理用全局统计) | 一致 |
BatchNorm (对NLP不友好):
样本1: "I love cats" → [0.1, 0.2, …, 0.5] (512维)
样本2: "The quick brown fox jumps" → [0.3, -0.1, …, 0.2] (512维)
归一化: 取样本1和样本2的**同一维度**求均值
问题:
– 序列长度不同需要padding,padding会影响统计
– NLP任务batch_size通常较小(2-8),统计不准确
LayerNorm (对NLP友好):
样本1: "I love cats" → [0.1, 0.2, …, 0.5] (512维)
归一化: 在样本1的**512个维度**上求均值和方差
优点:
– 每个样本独立归一化,不受batch大小影响
– 不依赖padding
– 训练和推理行为一致
10.3 LayerNorm公式
LayerNorm
(
x
)
=
γ
⋅
x
−
μ
σ
+
ϵ
+
β
\\text{LayerNorm}(x) = \\gamma \\cdot \\frac{x – \\mu}{\\sigma + \\epsilon} + \\beta
LayerNorm(x)=γ⋅σ+ϵx−μ+β
-
μ
\\mu
μ: x在最后一个维度上的均值 -
σ
\\sigma
σ: x在最后一个维度上的标准差 -
γ
\\gamma
γ: 可学习的缩放参数(初始化为1) -
β
\\beta
β: 可学习的平移参数(初始化为0) -
ϵ
\\epsilon
ϵ: 防止除零的小数(1e-6)
10.4 代码实现
class LayerNorm(nn.Module):
def __init__(self, features, eps=1e-6):
"""
features: 归一化的特征维度 = d_model = 512
eps: 防止除零 = 1e-6
γ (a2)和β (b2) 是可学习的参数:
– 初始时γ=1, β=0 → LayerNorm输出 = 归一化后的x
– 训练过程中模型可以学习调整γ和β
– 这保持了模型的灵活性(如果归一化不合适,可以"撤销")
"""
super(LayerNorm, self).__init__()
# γ (gamma): 缩放参数
# torch.ones(features): 初始化为全1 → 不改变缩放
self.a2 = nn.Parameter(torch.ones(features))
# β (beta): 平移参数
# torch.zeros(features): 初始化为全0 → 不改变位置
self.b2 = nn.Parameter(torch.zeros(features))
self.eps = eps
def forward(self, x):
"""
x: [batch, seq_len, d_model] 例如 [2, 4, 512]
归一化步骤:
1. 计算均值: 在最后一个维度(512维)上求平均
2. 计算标准差: 在最后一个维度(512维)上求标准差
3. 归一化: (x – 均值) / (标准差 + eps)
4. 缩放和平移: γ * 归一化结果 + β
返回: [batch, seq_len, d_model]
"""
# Step 1: 计算均值 [2, 4, 1]
# keepdim=True: 保持维度以便广播
mean = x.mean(–1, keepdim=True)
# Step 2: 计算标准差 [2, 4, 1]
std = x.std(–1, keepdim=True)
# Step 3 & 4: 归一化 + 缩放平移
# (x – mean) / (std + eps): [2, 4, 512]
# * self.a2: 按元素乘以γ
# + self.b2: 按元素加上β
y = self.a2 * (x – mean) / (std + self.eps) + self.b2
return y
10.5 残差连接——梯度的高速公路
残差连接(Residual Connection)的思想很简单:
o
u
t
p
u
t
=
x
+
F
(
x
)
output = x + F(x)
output=x+F(x)
其中x是输入,F(x)是某层(如Self-Attention或FFN)的输出。
没有残差连接的网络:
输入 → Layer1 → Layer2 → … → LayerN → 输出
梯度需要穿过N层才能到达浅层,每经过一层衰减一次
问题: 深层网络的浅层很难训练
有残差连接的网络:
输入 → [Layer1 → +] → [Layer2 → +] → … → 输出
↑_____┘ ↑_____┘
残差连接 残差连接
梯度有两条路径:
路径1: 经过LayerN → LayerN-1 → … → Layer1 (长路径)
路径2: 直接跳过Layer → 前一层 (短路径,梯度不衰减)
即使F(x)学得很差(F(x)≈0),x也能直接通过
这保证了"最差情况下也能维持原有的信息"
残差连接需要输入输出形状一致——这也是为什么Transformer中所有sub-layer都保持输出形状为[batch, seq, d_model]。
10.6 Post-Norm vs Pre-Norm
Transformer原始论文使用的是Post-Norm,但现代实现通常用Pre-Norm:
class SublayerConnection(nn.Module):
"""
子层连接 = 残差 + LayerNorm
两种范式:
Post-Norm (原始论文):
x → Sublayer → Dropout → + → LayerNorm → output
| ↑
└──── x (残差) ───────┘
即: output = LayerNorm(x + Dropout(Sublayer(x)))
特点:
– LayerNorm在最后
– 训练早期可能需要warmup
– 梯度更稳定(归一化在加法之后)
Pre-Norm (现代变体):
x → LayerNorm → Sublayer → Dropout → + → output
| ↑
└────────── x (残差) ────────────┘
即: output = x + Dropout(Sublayer(LayerNorm(x)))
特点:
– LayerNorm在最前
– 训练更稳定,通常不需要warmup
– 是现代实现(如GPT)的默认选择
"""
def __init__(self, size, dropout=0.1):
super(SublayerConnection, self).__init__()
self.norm = LayerNorm(size)
self.dropout = nn.Dropout(p=dropout)
def forward(self, x, sublayer):
"""
x: 输入张量
sublayer: 一个可调用对象 (函数/lambda)
使用Pre-Norm:
1. 先对x做LayerNorm
2. 通过sublayer处理
3. Dropout正则化
4. 与原始x相加 (残差连接)
"""
# sublayer(self.norm(x)):
# LayerNorm(x) → Sublayer → 结果
# self.dropout(…):
# 在结果上做dropout
# x + …:
# 残差连接: 原始输入 + 处理后的结果
return x + self.dropout(sublayer(self.norm(x)))
10.7 残差连接和LayerNorm的协同工作
在Transformer的一个Encoder Layer中:
x [2,4,512] ─────────────────────────────┐
│ │
├─→ LayerNorm → Self-Attention → Dropout┤
│ ├─→ x + attn_output
└───────────────────────────────────────┘
│
x' [2,4,512] ──────────────────────────┐ │
│ │ │
├─→ LayerNorm → FeedForward → Dropout ─┤─┘
│ │ 残差连接2
└───────────────────────────────────────┘
│
output [2,4,512]
10.8 Normalization自测问题
十一、Encoder与Decoder的完整实现
11.1 Encoder Layer —— 编码器的基本构建块
class EncoderLayer(nn.Module):
"""
一个Encoder层包含两个子层:
1. Multi-Head Self-Attention
2. Position-wise Feed Forward
每个子层都有残差连接和LayerNorm
"""
def __init__(self, size, self_attn, feed_forward, dropout):
"""
size: d_model = 512
self_attn: MultiHeadedAttention 实例
feed_forward: PositionwiseFeedForward 实例
dropout: 0.1
"""
super(EncoderLayer, self).__init__()
self.self_attn = self_attn
self.feed_forward = feed_forward
# 2个SublayerConnection (每个子层一个)
# clones: 创建2个独立的SublayerConnection副本
self.sublayer = clones(SublayerConnection(size, dropout), 2)
self.size = size
def forward(self, x, mask):
"""
x: [batch, seq_len, d_model] Encoder输入
mask: Padding mask
处理流程:
1. Self-Attention子层:
x → SublayerConnection → Self-Attention(x, x, x, mask) → 输出
注意: Q=K=V=x (所以叫Self-Attention)
2. Feed Forward子层:
上一步输出 → SublayerConnection → Feed Forward → 输出
"""
# ===== SubLayer 1: Self-Attention + 残差 + LayerNorm =====
# lambda x: self.self_attn(x, x, x, mask)
# 表示: 对x做Self-Attention,Q=K=V=x
# self.sublayer[0](x, lambda x: …)
# 内部会执行: LayerNorm(x) → Self-Attention → Dropout → + x(残差)
x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask))
# ===== SubLayer 2: Feed Forward + 残差 + LayerNorm =====
x = self.sublayer[1](x, self.feed_forward)
return x
11.2 Decoder Layer —— 多了一个Cross-Attention
class DecoderLayer(nn.Module):
"""
一个Decoder层包含三个子层:
1. Masked Multi-Head Self-Attention (看自身,不能看未来)
2. Multi-Head Cross-Attention (Q来自Decoder, K,V来自Encoder)
3. Position-wise Feed Forward
总共有3个SublayerConnection
"""
def __init__(self, size, self_attn, src_attn, feed_forward, dropout):
"""
size: d_model = 512
self_attn: Self-Attention (会用Sequence Mask)
src_attn: Cross-Attention (Q=Decoder, K=V=Encoder memory)
feed_forward: PositionwiseFeedForward
"""
super(DecoderLayer, self).__init__()
self.size = size
self.self_attn = self_attn # Masked Self-Attention
self.src_attn = src_attn # Cross-Attention
self.feed_forward = feed_forward
# 3个SublayerConnection
self.sublayer = clones(SublayerConnection(size, dropout), 3)
def forward(self, x, memory, source_mask, target_mask):
"""
x: [batch, tgt_len, d_model] Decoder输入
memory: [batch, src_len, d_model] Encoder的输出
source_mask: 源语言的padding mask
target_mask: 目标语言的sequence mask + padding mask
处理流程:
1. Masked Self-Attention:
x → SublayerConnection → Self-Attn(x,x,x, target_mask) → 输出
target_mask 包含了sequence mask (防止看到未来)
2. Cross-Attention:
上一步输出 → SublayerConnection →
Cross-Attn(Q=上一步输出, K=memory, V=memory, source_mask) → 输出
这是Decoder和Encoder"对话"的地方
3. Feed Forward:
上一步输出 → SublayerConnection → FFN → 输出
"""
# ===== SubLayer 1: Masked Self-Attention =====
# 使用 target_mask: 包含sequence mask
# 确保位置i不能看到位置i+1,i+2,…的信息
m = memory
x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, target_mask))
# ===== SubLayer 2: Cross-Attention =====
# Q = x (Decoder的当前表示: "我要生成什么?")
# K = m (Encoder的输出: "源句子有什么信息?")
# V = m (Encoder的输出: "具体内容是什么?")
# 使用 source_mask: 只mask源句子的padding部分
x = self.sublayer[1](x, lambda x: self.src_attn(x, m, m, source_mask))
# ===== SubLayer 3: Feed Forward =====
x = self.sublayer[2](x, self.feed_forward)
return x
11.3 完整Encoder——堆叠6层
class Encoder(nn.Module):
"""
完整的Encoder = N个EncoderLayer堆叠 + 最后的LayerNorm
"""
def __init__(self, layer, N):
"""
layer: EncoderLayer实例
N: 层数 = 6
"""
super(Encoder, self).__init__()
# clones: 创建N个独立的EncoderLayer副本(深层拷贝)
# 每一层有自己的参数,互相独立
self.layers = clones(layer, N)
# 最后的LayerNorm (对所有层的输出做一次归一化)
self.norm = LayerNorm(layer.size)
def forward(self, x, mask):
"""
x: [batch, src_len, d_model] 经过Embedding+Positional的输入
mask: Padding mask
数据依次经过6个EncoderLayer:
输入 → Layer1 → Layer2 → Layer3 → Layer4 → Layer5 → Layer6 → LayerNorm → 输出
"""
# 逐层传递
for layer in self.layers:
x = layer(x, mask)
# 最后的归一化
return self.norm(x)
11.4 完整Decoder——堆叠6层
class Decoder(nn.Module):
"""
完整的Decoder = N个DecoderLayer堆叠 + 最后的LayerNorm
"""
def __init__(self, layer, N):
super(Decoder, self).__init__()
self.layers = clones(layer, N)
self.norm = LayerNorm(layer.size)
def forward(self, x, memory, source_mask, target_mask):
"""
x: [batch, tgt_len, d_model] Decoder输入
memory: [batch, src_len, d_model] Encoder输出
source_mask: 源语言padding mask
target_mask: 目标语言mask (sequence + padding)
"""
for layer in self.layers:
x = layer(x, memory, source_mask, target_mask)
return self.norm(x)
11.5 Encoder/Decoder自测问题
十二、Generator与完整模型组装
12.1 Generator —— 从向量到词
class Generator(nn.Module):
"""
将Decoder的输出(512维向量)映射为词汇概率分布
"""
def __init__(self, d_model, vocab_size):
"""
d_model: 512
vocab_size: 目标语言词汇表大小 (例如 30000)
"""
super(Generator, self).__init__()
# Linear(512 → vocab_size): 将每个位置的向量映射到词汇空间
# 参数量: 512 × vocab_size + vocab_size
# 例如 vocab=30000: 约 15,360,000 参数
self.project = nn.Linear(d_model, vocab_size)
def forward(self, x):
"""
x: [batch, seq_len, 512]
返回: [batch, seq_len, vocab_size]
每个位置的vocab_size个值是该位置每个词的对数概率
"""
# Step 1: 线性投影 […, 512] → […, vocab_size]
x = self.project(x)
# Step 2: LogSoftmax
# dim=-1: 在词汇维度上做Softmax
# 用log_softmax而非softmax的原因是配合NLLLoss
x = F.log_softmax(x, dim=–1)
return x
12.2 EncoderDecoder —— 完整模型
class EncoderDecoder(nn.Module):
"""
将Encoder、Decoder、Embedding、Generator组装成完整模型
"""
def __init__(self, encoder, decoder, source_embed, target_embed, generator):
"""
encoder: Encoder实例
decoder: Decoder实例
source_embed: 源语言Embedding + PositionalEncoding
target_embed: 目标语言Embedding + PositionalEncoding
generator: Generator实例
"""
super(EncoderDecoder, self).__init__()
self.encoder = encoder
self.decoder = decoder
self.source_embed = source_embed
self.target_embed = target_embed
self.generator = generator
def forward(self, source, target, source_mask, target_mask):
"""
完整的前向传播
参数:
– source: [batch, src_len] 源语言词索引
– target: [batch, tgt_len] 目标语言词索引
– source_mask: Padding mask
– target_mask: Sequence + Padding mask
返回: [batch, tgt_len, vocab_size] 目标词的概率分布
流程:
1. Encoder: source → Embedding+Positional → Encoder → memory
2. Decoder: target → Embedding+Positional → Decoder(memory) → output
3. Generator: output → Linear+LogSoftmax → 概率分布
"""
# ===== 1. Encode =====
# source: 例如 [2, 5] (5是源句子长度)
# self.source_embed(source): [2, 5, 512]
# self.encoder(…): [2, 5, 512]
# memory 就是"上下文表示",传给Decoder的Cross-Attention
memory = self.encoder(self.source_embed(source), source_mask)
# ===== 2. Decode =====
# target: 例如 [2, 4] (4是目标句子长度)
# self.target_embed(target): [2, 4, 512]
# self.decoder(…): [2, 4, 512]
# 每层的Cross-Attention都会用Q(Decoder)去查K,V(memory)
x = self.decoder(self.target_embed(target), memory, source_mask, target_mask)
# ===== 3. Generate =====
# [2, 4, 512] → [2, 4, vocab_size]
# 每个位置得到词汇表大小的概率分布
x = self.generator(x)
return x
12.3 make_model —— 工厂函数
def make_model(source_vocab, target_vocab, N=6, d_model=512,
d_ff=2048, head=8, dropout=0.1):
"""
工厂函数: 创建完整的Transformer模型
参数:
– source_vocab: 源语言词汇表大小
– target_vocab: 目标语言词汇表大小
– N: Encoder和Decoder的层数 = 6
– d_model: 模型维度 = 512
– d_ff: FFN内部维度 = 2048
– head: 注意力头数 = 8
– dropout: 0.1
返回: 完整的EncoderDecoder模型
"""
# c = copy.deepcopy: 用于创建独立副本
c = copy.deepcopy
# ===== 创建组件 =====
# Attention: 用于Encoder的Self-Attention和Decoder的Cross-Attention
attn = MultiHeadedAttention(head, d_model, dropout)
# Feed Forward
ff = PositionwiseFeedForward(d_model, d_ff, dropout)
# Positional Encoding
position = PositionalEncoding(d_model, dropout)
# ===== 组装模型 =====
# 注意: Decoder需要两个独立的Attention实例
# – 一个用于Masked Self-Attention (c(attn))
# – 一个用于Cross-Attention (c(attn))
# 两者参数不共享!
model = EncoderDecoder(
# Encoder: 6层, 每层有Self-Attention + FFN
Encoder(EncoderLayer(d_model, c(attn), c(ff), dropout), N),
# Decoder: 6层, 每层有Masked Self-Attn + Cross-Attn + FFN
# 注意: 这里传入了两个c(attn),分别是:
# – 第1个c(attn): Decoder的Masked Self-Attention
# – 第2个c(attn): Decoder的Cross-Attention
Decoder(DecoderLayer(d_model, c(attn), c(attn), c(ff), dropout), N),
# Source Embedding: Embeddings + PositionalEncoding
nn.Sequential(Embeddings(d_model, source_vocab), c(position)),
# Target Embedding: Embeddings + PositionalEncoding
nn.Sequential(Embeddings(d_model, target_vocab), c(position)),
# Generator: Linear + LogSoftmax
Generator(d_model, target_vocab)
)
# ===== 参数初始化 =====
# Xavier/Glorot初始化: 帮助梯度更好地流动
for p in model.parameters():
if p.dim() > 1:
nn.init.xavier_uniform_(p)
return model
12.4 完整的推理流程
训练时的推理(已在EncoderDecoder.forward中覆盖),但实际使用时的自回归生成(逐词生成)还需要额外的代码:
预测 "I love you" → 法语
Step 1: Encoder处理源句子
"I" "love" "you" → Encoder → memory
Step 2: 自回归生成
Step 2.1: [SOS] → Decoder(memory) → [256维] → Softmax → "Je"
Step 2.2: [SOS, Je] → Decoder(memory) → [256维] → Softmax → "t'aime"
Step 2.3: [SOS, Je, t'aime] → Decoder(memory) → … → EOS
t'aime 注意: 生成"Je"时看不到"t'aime" (Sequence Mask的作用)
12.5 模型组装自测问题
十三、补充内容
13.1 Transformer的核心优势总结
| 并行计算 | 不依赖时序,所有位置同时处理 | 训练时间从周减少到天 |
| 长距离依赖 | 任意位置O(1)路径连接 | 长句翻译质量大幅提升 |
| 可解释性 | 注意力权重可视化 | 可以观察模型"在看什么" |
| 通用性 | 不限于NLP | 延伸到CV(ViT)、语音、多模态 |
13.2 Transformer vs RNN/LSTM 深度对比
| 并行计算 | 否(串行,时间步依赖) | 是(所有位置同时计算) |
| 长距依赖 | 难(信息需逐词传递,有遗忘) | 易(直接连接,路径长度O(1)) |
| 计算复杂度 | O(n·d^2) | O(n^2·d) |
| 空间复杂度 | O(n·d) | O(n^2)(注意力矩阵) |
| 位置信息 | 天然编码(时序结构) | 需Positional Encoding |
| 训练速度 | 慢(GPU利用率低) | 快(GPU友好,矩阵运算密集) |
| 序列长度限制 | 无硬限制(但梯度问题) | O(n^2)内存,长序列受限 |
| 小数据表现 | 尚可 | 需更多数据(缺少归纳偏置) |
复杂度解读:
- RNN: O(n·d^2),n=序列长度,d=隐藏维度。线性依赖于n,但串行执行。
- Transformer: O(n^2·d),平方依赖于n。n长时内存和计算量大,但可以在一轮矩阵乘法中完成。
- 对于n=100的词序列,n2=10000,d=512,n2·d ≈ 5M,GPU轻松处理。
- 对于n=10000的序列(如整本书),n2=100M,d=512,n2·d ≈ 51B,普通GPU困难。这也是为什么长文本处理需要特殊技术(如Longformer, BigBird等稀疏注意力)。
13.3 自注意力的注意力矩阵解读
输入: "I love China ." (4个词)
4×4的注意力矩阵(某个头的可视化):
I love China .
I [0.5, 0.3, 0.1, 0.1] ← "I"最关注自己(0.5)
love [0.2, 0.5, 0.2, 0.1] ← "love"关注自己和"I"
China[0.1, 0.3, 0.5, 0.1] ← "China"关注自己和"love"
. [0.2, 0.2, 0.2, 0.4] ← "."关注所有词,偏向自己
每行表示该词(Query)对所有词(Key)的关注程度
行和为1 (Softmax归一化)
典型模式:
– 对角线值较高: 每个词关注自己 (保留自身信息)
– 相邻位置值较高: 关注邻近词 (局部语法关系)
– 长距离也有非零值: 捕捉远距离依赖
13.4 Transformer家族发展史
Transformer (2017)
"Attention is All You Need"
|
┌────────────────┼────────────────┐
| | |
仅用Encoder 仅用Decoder Encoder-Decoder
| | |
BERT (2018) GPT (2018) T5 (2019)
"Bidirectional" "Auto-regressive" "Text-to-Text"
预训练: MLM 预训练: LM 统一框架
| |
┌─────────┼──────┐ ┌───┼──────────┐
| | | | | |
RoBERTa ALBERT … GPT-2 GPT-3 GPT-4
(2019) (2019) (2019)(2020)(2023)
|
┌─────────┼──────────┐
| | |
LLaMA PaLM Claude
(Meta,2023)(Google) (Anthropic)
跨领域发展:
ViT (2020): Vision Transformer → 图像分类
DETR (2020): Detection Transformer → 目标检测
Whisper (2022): 语音识别
CLIP (2021): 图文多模态
13.5 训练技巧与注意事项
学习率调度(Learning Rate Schedule):
Transformer原始论文使用了一个特殊的"warmup + decay"学习率策略:
lr = d_model^(-0.5) * min(step_num^(-0.5), step_num * warmup_steps^(-1.5))
warmup阶段(前4000步): 学习率线性增加
decay阶段(4000步之后): 学习率逐步减小
为什么需要warmup?
– 训练初期,模型参数是随机的,梯度的方向和大小都不稳定
– 使用大的学习率可能导致训练崩溃
– Warmup提供了一个"热身阶段",让优化器先找到正确的方向
Label Smoothing(标签平滑):
训练时不用one-hot标签,而是给正确词0.9的概率,其余0.1均匀分配给其他词。这样可以防止模型过度自信,提高泛化能力。
Dropout的位置:
- 每个子层的输出处
- Embedding + Positional Encoding 的输出处
- Attention权重上
13.6 常见误解澄清
“Transformer完全不需要循环结构” — 对,但它仍然使用了Positional Encoding来编码位置信息。Attention本身位置无关,所以需要额外注入位置信号。
“Multi-Head Attention只是重复计算8次” — 不对。每个头有独立的Q、K、V投影矩阵,它们把输入投影到8个不同的子空间。8个头关注的是不同的语义关系,不是简单重复。
“Transformer的参数量很大” — Transformer Base只有约65M参数(对比:GPT-3有175B)。核心模块都很精简,大部分参数在Embedding和Generator中。
“Self-Attention就是矩阵乘法” — 核心计算确实是矩阵乘法,但经过Softmax非线性处理,以及Multi-Head的多视角机制,整体效果远不止是一个线性变换。
十四、自测问题
基础理解
架构理解
计算细节
实践理解
学习建议: Transformer是现代NLP的基石。建议你:
画图是非常有效的学习方式——试着画出每一步的张量形状变化!

