深度学习从入门到精通 - 注意力机制革命:Transformer架构改变AI格局
各位!今天我们要拆解的,是彻底颠覆自然语言处理乃至整个AI格局的Transformer架构。别被那些花哨的名词吓退——咱们今天的目标,就是手把手带你从零吃透注意力机制的核心原理,亲手搭建Transformer模型,顺便聊聊我趟过的那些坑。准备好了吗?这场革命远比你想象的更精彩。
一、 抛弃RNN的执念:注意力机制凭什么行?
回想做NLP那会儿,谁没被RNN和LSTM的梯度消失折磨过?长文本建模简直是场噩梦。注意力机制(Attention Mechanism)的横空出世,本质上是在解决一个关键问题:如何让模型自主决定该重点关注输入序列的哪些部分? 这可不是简单的加权平均,而是一种动态的、与任务目标紧密耦合的信息筛选机制。
1.1 核心思想与数学骨架
想象你在翻译句子。读到"apple"时,你自然会关注"苹果"而非"桌子"。注意力机制通过计算Query(查询) 与 Key(键) 的相似度,决定从 Value(值) 中提取多少信息。数学表达为:
Attention(Q,K,V)=softmax(QKTdk)VAttention(Q, K, V) = softmax(\frac{QK^T}{\sqrt{d_k}})VAttention(Q,K,V)=softmax(dkQKT)V
- 符号拆解:
Q(Query): 当前需要生成输出的位置向量 (e.g., 解码器当前时刻的隐状态)K(Key): 所有输入位置的标识向量 (e.g., 编码器所有时刻的隐状态)V(Value): 实际承载信息的向量 (e.g., 与K同源的编码器隐状态,但可投影变换)d_k: Key向量的维度,用于缩放(关键!防softmax饱和)softmax: 将相似度分数转化为概率分布
推导过程(为什么这样设计?):
- 相似度计算:
QK^T计算查询向量Q与每个键向量K_i的点积。点积越大,表示两者相关性越强。 - 缩放(Scaling):除以
√d_k。这点太容易忽略!当d_k较大时,点积结果绝对值会很大,导致 softmax 函数梯度极小(饱和区)。缩放使梯度更稳定,我强烈推荐你加上这个操作,否则训练初期 loss 可能纹丝不动。 - 概率化:
softmax将缩放后的分数转换为一个概率分布(α₁, α₂, ..., αₙ),表示每个 Value 的重要程度。 - 加权求和:最终的注意力输出就是所有
Value向量(V₁, V₂, ..., Vₙ)的加权和:Σ α_i * V_i。模型借此聚焦于最相关的信息。
import torch
import torch.nn as nn
import torch.nn.functional as F
class ScaledDotProductAttention(nn.Module):
def __init__(self, d_k):
super().__init__()
self.d_k = d_k
def forward(self, Q, K, V, mask=None):
# Q, K, V shape: (batch_size, seq_len, d_k) or (batch_size, n_heads, seq_len, d_k)
# 1. 计算相似度 + 缩放
scores = torch.matmul(Q, K.transpose(-2, -1)) / (self.d_k ** 0.5) # (..., seq_len_q, seq_len_k)
# 2. 可选:处理解码器未来信息掩码 (mask future tokens in decoder)
if mask is not None:
scores = scores.masked_fill(mask == 0, -1e9) # 用极小值替换被掩蔽位置
# 3. Softmax 获取注意力权重
attn_weights = F.softmax(scores, dim=-1) # (..., seq_len_q, seq_len_k)
# 4. 加权求和 Value
output = torch.matmul(attn_weights, V) # (..., seq_len_q, d_v)
return output, attn_weights
# 踩坑记录 1:忘记缩放(d_k)!
# 后果:初始训练阶段,softmax 梯度极小,模型几乎不更新,loss 下降极其缓慢甚至停滞。
# 修复:务必记得除以 √d_k
Mermaid 流程图:注意力计算流程
graph LR
A[Query Q] --> B[Dot Product with Keys K]
C[Keys K] --> B
B --> D[Scale by √d_k]
D --> E[Apply Mask?]
E --> F[Softmax]
F --> G[Probability Distribution]
H[Values V] --> I[Weighted Sum]
G --> I
I --> J[Output]
二、 Transformer 总览:一个完全基于注意力的工厂
Transformer 彻底抛弃了循环结构,只依赖自注意力(Self-Attention) 和 前馈神经网络(Feed-Forward Network) 堆叠而成的编码器-解码器架构。其核心魔力在于并行化处理整个序列,效率碾压RNN。
2.1 全局架构图 (Mermaid)
2.2 编码器解剖:单层在做什么?
一个编码器层 = 多头自注意力 + 前馈网络,辅以残差连接和层归一化。
class EncoderLayer(nn.Module):
def __init__(self, d_model, n_heads, d_ff, dropout=0.1):
super().__init__()
# 1. 核心是多头注意力
self.self_attn = MultiHeadAttention(d_model, n_heads, dropout) # 后面会实现
# 2. 简单但强大的前馈层
self.ffn = nn.Sequential(
nn.Linear(d_model, d_ff),
nn.ReLU(),
nn.Linear(d_ff, d_model)
)
# 3. 层归一化:每个子层输出前
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
# 4. Dropout 防过拟合
self.dropout = nn.Dropout(dropout)
def forward(self, src, src_mask=None):
# 子层 1: 自注意力 + Add & Norm
# 注意残差连接:原始输入跳过注意力层直接加到输出上
attn_output, _ = self.self_attn(src, src, src, src_mask) # Q=K=V=src
src = src + self.dropout(attn_output) # 残差连接(Add)
src = self.norm1(src) # 归一化(Norm)
# 子层 2: 前馈网络 + Add & Norm
ffn_output = self.ffn(src)
src = src + self.dropout(ffn_output)
src = self.norm2(src)
return src
关键点 & 踩坑记录 2:
- 为什么用 LayerNorm 而不是 BatchNorm? NLP数据序列长度变化大。BatchNorm依赖batch内统计量,对短序列padding敏感,效果不稳定。LayerNorm在单个样本内计算统计量,更鲁棒。我强烈推荐LayerNorm用于序列模型。
- 残差连接的位置? 一定是 Add BEFORE Norm (
x + Sublayer(x)再 Norm)。反之会导致归一化破坏残差信息流,效果大打折扣。这个顺序吧——我见过太多人调换位置后百思不得其解为什么模型不收敛。 - 前馈网络的宽度
d_ff:通常设为d_model的 2-4 倍。过小则模型容量不足,过大容易过拟合且计算开销剧增。
三、 多头注意力:并行挖掘不同角度的关联
单头注意力如同一个专家视角。多头注意力(Multi-Head Attention) 则是让多组独立的注意力“专家”并行工作,各自关注输入的不同子空间,然后将结果拼接融合。这极大提升了模型捕捉不同层面语义关系的能力。
3.1 数学公式与实现
MultiHead(Q,K,V)=Concat(head1,...,headh)WOwhere headi=Attention(QWiQ,KWiK,VWiV)MultiHead(Q, K, V) = Concat(head_1, ..., head_h)W^O \\ where \ head_i = Attention(QW_i^Q, KW_i^K, VW_i^V)MultiHead(Q,K,V)=Concat(head1,...,headh)WOwhere headi=Attention(QWiQ,KWiK,VWiV)
W_i^Q, W_i^K, W_i^V: 第i个头对应的可学习投影矩阵 (d_model->d_k/d_v)W^O: 合并各头输出后的投影矩阵 (h * d_v->d_model)h: 头的数量,通常d_k = d_v = d_model / h
class MultiHeadAttention(nn.Module):
def __init__(self, d_model, n_heads, dropout=0.1):
super().__init__()
assert d_model % n_heads == 0, "d_model must be divisible by n_heads"
self.d_k = d_model // n_heads # 每头的维度
self.n_heads = n_heads
self.d_model = d_model
# 投影矩阵: 输入d_model -> h个d_k/d_v
self.W_q = nn.Linear(d_model, d_model) # 实际内部计算会分拆
self.W_k = nn.Linear(d_model, d_model)
self.W_v = nn.Linear(d_model, d_model)
self.W_o = nn.Linear(d_model, d_model) # 合并输出
self.dropout = nn.Dropout(dropout)
self.attention = ScaledDotProductAttention(self.d_k) # 前面实现的单头注意力
def forward(self, Q, K, V, mask=None):
batch_size = Q.size(0)
# 1. 线性投影 + 分头 (reshape)
# [batch, seq_len, d_model] -> [batch, seq_len, n_heads, d_k] -> [batch, n_heads, seq_len, d_k]
Q = self.W_q(Q).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2)
K = self.W_k(K).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2)
V = self.W_v(V).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2)
# 踩坑记录 3:维度分拆/合并错误!务必检查transpose/view后各维度顺序。常见错误:批次维度和序列维度弄反。
# 2. 每组投影后的 Q, K, V 送入单头注意力
# attn_output: [batch, n_heads, seq_len_q, d_k]
# attn_weights: [batch, n_heads, seq_len_q, seq_len_k]
attn_output, attn_weights = self.attention(Q, K, V, mask=mask)
attn_output = self.dropout(attn_output)
# 3. 合并多头输出: [batch, n_heads, seq_len_q, d_k] -> [batch, seq_len_q, d_model]
# 先转置回 [batch, seq_len_q, n_heads, d_k]
attn_output = attn_output.transpose(1, 2).contiguous()
# 再合并最后两维: [batch, seq_len_q, n_heads * d_k] = [batch, seq_len_q, d_model]
attn_output = attn_output.view(batch_size, -1, self.d_model)
# 4. 最终线性投影
output = self.W_o(attn_output)
return output, attn_weights
为什么多头比单头强?
- 类比卷积神经网络的多通道。不同头可以学习关注:
- 语法关系(主谓宾)
- 语义角色(施事/受事)
- 长距离依赖(代词指代)
- 实体关联(同义/反义)
- 实验表明,多头的表示能力显著优于单头,尤其是在复杂语义建模任务上。
四、 位置编码:给无序的Attention注入顺序感
自注意力本身是置换不变(Permutation Invariant) 的——打乱输入单词顺序,输出不变(忽略掩码)。这显然不符合语言特性!Transformer 的解决之道是位置编码(Positional Encoding, PE)。
4.1 正弦/余弦公式:绝对位置信息
原始论文使用的绝对位置编码:
PE(pos,2i)=sin(pos/100002i/dmodel)PE_{(pos, 2i)} = \sin(pos / 10000^{2i / d_{model}})PE(pos,2i)=sin(pos/100002i/dmodel)
PE(pos,2i+1)=cos(pos/100002i/dmodel)PE_{(pos, 2i+1)} = \cos(pos / 10000^{2i / d_{model}})PE(pos,2i+1)=cos(pos/100002i/dmodel)
pos: 单词在序列中的位置 (0, 1, 2, …, seq_len-1)i: 维度索引 (0 <=i<d_model/2)d_model: 模型嵌入维度
为什么用三角函数?
- 相对位置可学习:
sin(a+b) = sin(a)cos(b) + cos(a)sin(b),模型可学习到相对位置b的线性变换。 - 值域有界:
[-1, 1],与嵌入向量范围匹配,避免过大干扰。 - 可外推:对训练时未见过的长序列有一定泛化能力(虽然有限)。
class PositionalEncoding(nn.Module):
def __init__(self, d_model, max_len=5000, dropout=0.1):
super().__init__()
self.dropout = nn.Dropout(p=dropout)
# 1. 计算位置编码矩阵 PE (max_len x d_model)
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) # (max_len, 1)
div_term = torch.exp(torch.arange(0, d_model, 2).float() *
(-math.log(10000.0) / d_model)) # (d_model/2, )
# 踩坑记录 4:div_term 计算错误(指数部分),导致频率不对。务必检查对数项。
pe[:, 0::2] = torch.sin(position * div_term) # 偶数列 sin
pe[:, 1::2] = torch.cos(position * div_term) # 奇数列 cos
pe = pe.unsqueeze(0) # (1, max_len, d_model) 方便后续广播
self.register_buffer('pe', pe) # 不参与训练,但随模型保存/加载
def forward(self, x):
# x: (batch_size, seq_len, d_model)
# 只取前 seq_len 个位置编码加到 x 上
x = x + self.pe[:, :x.size(1), :]
return self.dropout(x)
替代方案:可学习位置嵌入(Learned Positional Embedding)
- 直接为每个位置
pos学习一个d_model维的向量E_pos。 - 优点:更灵活,不依赖特定函数形式。
- 缺点:
- 无法外推到比训练时更长的序列(需要重新学习或插值)。
- 在小数据集上可能不如正弦编码泛化好。
- 个人偏好:我强烈推荐初学者先用正弦编码理解原理。对于超长序列或特定领域任务,再考虑可学习嵌入或更高级的编码(如ALiBi)。
五、 解码器:自回归生成与掩码自注意力
解码器负责根据编码器输出 memory 和已生成的部分输出,一步步预测下一个词。它的结构比编码器多一层编码-解码注意力(Encoder-Decoder Attention)。
5.1 解码器层详解
class DecoderLayer(nn.Module):
def __init__(self, d_model, n_heads, d_ff, dropout=0.1):
super().__init__()
# 第一层:掩码多头自注意力(只看过去信息)
self.masked_self_attn = MultiHeadAttention(d_model, n_heads, dropout)
# 第二层:编码-解码多头注意力 (Q来自解码器,K,V来自编码器输出)
self.enc_dec_attn = MultiHeadAttention(d_model, n_heads, dropout)
# 第三层:前馈网络
self.ffn = ... # 同Encoder
# 三个归一化层
self.norm1, self.norm2, self.norm3 = [nn.LayerNorm(d_model) for _ in range(3)]
self.dropout = nn.Dropout(dropout)
def forward(self, tgt, memory, tgt_mask=None, memory_mask=None):
# tgt: 当前解码器输入 (shifted right)
# memory: 编码器最终输出
# 子层1: 掩码自注意力 + Add&Norm
attn1, _ = self.masked_self_attn(tgt, tgt, tgt, tgt_mask)
tgt = tgt + self.dropout(attn1)
tgt = self.norm1(tgt)
# 子层2: 编码-解码注意力 + Add&Norm
# Q = tgt (解码器当前状态), K, V = memory (编码器输出)
attn2, _ = self.enc_dec_attn(tgt, memory, memory, memory_mask)
tgt = tgt + self.dropout(attn2)
tgt = self.norm2(tgt)
# 子层3: 前馈网络 + Add&Norm
ffn_output = self.ffn(tgt)
tgt = tgt + self.dropout(ffn_output)
tgt = self.norm3(tgt)
return tgt
5.2 掩码(Masking)的双重使命
- Padding Mask (内存掩码):
- 作用:忽略输入序列中的
<pad>符号,避免无效padding影响注意力权重。 - 实现:在
src或tgt输入中,为<pad>位置置 0 (False),非<pad>位置置 1 (True)。计算 softmax 前,将无效位置的分数加一个极大负值(-1e9)使其概率趋近 0。
# 示例: src_key_padding_mask (batch_size, src_seq_len), 0表示pad位置 scores = scores.masked_fill(src_key_padding_mask.unsqueeze(1).unsqueeze(2), -1e9) - 作用:忽略输入序列中的
- Sequence Mask / Causal Mask (序列掩码 / 因果掩码):
- 作用(解码器独有):确保预测第
i个位置时,模型只能看到第0到i-1位置的输出。这是自回归生成的核心约束。 - 实现:创建一个下三角矩阵(主对角线及以下为1,以上为0)。应用到
tgt在masked_self_attn的注意力分数上。
# 生成一个下三角矩阵 (seq_len, seq_len),右上角(未来位置)为False(0) def generate_square_subsequent_mask(sz): mask = (torch.triu(torch.ones(sz, sz)) == 1).transpose(0, 1) mask = mask.float().masked_fill(mask == 0, float('-inf')).masked_fill(mask == 1, float(0.0)) return mask # (sz, sz) # 在解码器自注意力中使用: attn_output = self_attn(tgt, tgt, tgt, attn_mask=tgt_mask) - 作用(解码器独有):确保预测第
踩坑记录 5:掩码应用错误!
- 位置混淆:Padding Mask 应用在
src_key_padding_mask或tgt_key_padding_mask参数。因果掩码应用在attn_mask参数(解码器自注意力)。 - 值混淆:Padding Mask 中,
1表示 保留,0表示 屏蔽。Causal Mask是计算前加到分数矩阵上的一个布尔矩阵(或用-inf填充)。把两者混用或逻辑弄反会直接导致模型崩溃性失效。
六、 组装Transformer:从词嵌入到输出概率
6.1 完整模型搭建
class Transformer(nn.Module):
def __init__(self, src_vocab_size, tgt_vocab_size, d_model=512, n_heads=8,
num_encoder_layers=6, num_decoder_layers=6, d_ff=2048, dropout=0.1, max_len=100):
super().__init__()
# 1. 词嵌入层 (输入输出共享词表可选)
self.src_embed = nn.Embedding(src_vocab_size, d_model)
self.tgt_embed = nn.Embedding(tgt_vocab_size, d_model)
# 2. 位置编码
self.positional_encoding = PositionalEncoding(d_model, max_len, dropout)
# 3. 编码器堆叠
self.encoder = nn.ModuleList([
EncoderLayer(d_model, n_heads, d_ff, dropout)
for _ in range(num_encoder_layers)
])
# 4. 解码器堆叠
self.decoder = nn.ModuleList([
DecoderLayer(d_model, n_heads, d_ff, dropout)
for _ in range(num_decoder_layers)
])
# 5. 最终线性层 + Softmax (输出概率)
self.fc_out = nn.Linear(d_model, tgt_vocab_size)
def forward(self, src, tgt, src_mask=None, tgt_mask=None, src_padding_mask=None,
tgt_padding_mask=None, memory_padding_mask=None):
# 编码器部分
src_emb = self.positional_encoding(self.src_embed(src))
memory = src_emb
for enc_layer in self.encoder:
memory = enc_layer(memory, src_mask=None, src_padding_mask=src_padding_mask) # Encoder通常无attn_mask
# 解码器部分
tgt_emb = self.positional_encoding(self.tgt_embed(tgt))
output = tgt_emb
for dec_layer in self.decoder:
output = dec_layer(
output, memory,
tgt_mask=tgt_mask, # 因果掩码 (防止看未来)
memory_mask=memory_padding_mask # 通常是src_padding_mask (遮蔽编码器pad)
)
# 输出层
logits = self.fc_out(output) # (batch, tgt_seq_len, tgt_vocab_size)
return logits
踩坑记录 6:维度对齐!
- 整个流程中张量形状变化频繁。务必在关键节点打印
shape(如嵌入后、位置编码后、每层输入输出)。常见错误:- 词嵌入维度
d_model与位置编码、注意力层等不匹配。 batch_size或seq_len在分头/合并时出错。- 解码器输出
(batch, tgt_len, d_model)与 线性层(d_model, tgt_vocab_size)匹配。
- 词嵌入维度
七、 Transformer 的威力与挑战:不止于NLP
Transformer 的成功早已超越翻译:
- BERT/GPT:预训练语言模型的基石。
- ViT (Vision Transformer):将图像分块送入Transformer,在CV领域媲美CNN。
- 语音识别/生成:处理音频序列。
- 强化学习:建模决策序列。
挑战与优化方向:
- 计算与内存开销:序列长度
n-> 注意力计算量O(n²)。解决方案:- 局部窗口注意力 (Swin Transformer)
- 稀疏注意力 (Longformer, BigBird)
- 线性注意力近似 (Linformer, Performer)
- 长程依赖建模:尽管优于RNN,但绝对位置编码对极长序列效果下降。解决方案:
- 相对位置编码 (Transformer-XL, T5)
- 旋转位置编码 (RoPE, 广泛应用于LLaMA, GPT等大模型)
- 训练稳定性:学习率 warmup、自适应优化器 (AdamW)、梯度裁剪不可或缺。
结语: 从 Seq2Seq 的瓶颈到 Transformer 的崛起,注意力机制彻底改写了 AI 处理序列数据的范式。理解其核心——动态加权聚合信息、并行计算能力、位置编码的妙处——是掌握现代深度学习的关键一步。动手实现一遍,踩过那些维度、缩放、掩码的坑,你才能真正感受到它的精妙与力量。

被折叠的 条评论
为什么被折叠?



