在 Transformer 之前,序列翻译任务(或者说与序列、时序相关的任务)通常采用 RNN、CNN 结构,其中 RNN 的缺点在于:(1)使用计算的先后次序,来表征序列中的先后信息,因此只能串行计算(2)长序列早期的信息可能会丢失;CNN 的缺点在于:捕捉相邻信息依赖卷积的窗口,因此对于长序列的信息可能需要很多层卷积。
基于上述问题,Transformer 应运而生,提出新结构,用于实现(1)更好地并行化(2)更好地建模长序列。
Transformer 模型结构
在下述模型结构中,左边为编码器 Encoder,右边为解码器 Decoder;Encoder 负责将输入 Inputs $(x_1,\ldots,x_n)$ 编码为 $\boldsymbol{z}=(z_1,\ldots,z_n)$。将 $\boldsymbol{z}$ 传入 Decoder 后,解码器将采用自回归的方式输出序列 $(y_1,\ldots,y_m)$,每一步将前面的输出 $y_{<i}$ 作为额外的输入,输出对应的 token $y_i$(即对应词表中每个 token 的概率)。接下来将依次对 Transformer 中的关键组件进行介绍。
1. Tokenization
首先需要将语言文字处理为 token 的形式,如下图所示:
Transformer 在训练前,会维护一个词表,每个 token 都是在词表中的一个元素。为了对这些 token 进行处理,Input / Output Embeddings 层分别将这些 token 转成对应的 Embedding,即在上述维护的词表中,每个 token 都各自对应一个向量 (Embedding),在 Transformer 原始论文中,这个向量的维度被设置为 512 维(后续随着模型参数的扩大,Embedding 维度也会相应地提升)。
这些 Embedding 通常以均匀分布或正态分布随机初始化,例如 nn.Embedding 默认使用区间 $[-\sqrt{1/d_{\text{model}}},\sqrt{1/d_{\text{model}}}]$ 的均匀分布进行初始化。这些 Embedding 会在训练过程中通过反向传播更新,待训练结束后,作为模型权重的一部分进行存储。下述为 embedding_layer 的代码示例。
1
2
3
4
import torch.nn as nn
d_model = 512 # 或其他模型设定的值
vocab_size = 30000 # 假设词表大小为3万
embedding_layer = nn.Embedding(vocab_size, d_model)
Byte-Pair Encoding(BPE)
具体 Tokenization 的技术在 Transformer 出现前便是 NLP 领域的重点方向,此处主要介绍一种较为常见的分词方法,即 Byte-Pair Encoding (BPE). 由于该方法与 Transformer 本身关系不大,不感兴趣的话可以跳过。
BPE 主要分为两步,第一步是如何根据大量文本数据得到词表。首先会统计所有单词的词频,并在单词结尾增加 </w>
字符,例如初始语料库为:
1
{"yes</w>": 7, "highest</w>": 3, "high</w>": 9}
根据上述语料库,将每个词拆分为字符,并统计每个相邻字节对的频率:
合并统计结果后,最高频的字节对为 h-i
,共出现了 12 次。在词表中加入 hi
后,再次统计更新后的字节对频率:
此时最高频的字节对为 g-h
,共出现了 12 次,将在词表中加入 gh
。通过不断迭代合并,直至达到停止条件(停止条件通常为词表大小或固定的迭代次数)。最终的词表如下所示(hi
和 gh
在迭代过程中被合并为了 high
):
1
{"y", "es", "</w>", "high", "t", "high</w>"}
第二步就是根据上述得到的词表,对单词序列进行子词切分,即句子编码。编码的过程就是遍历已得到的词表,从最长到最短,并尝试使用这些 token 替换给定单词序列中的子字符串,如果最终仍有训练时没有见过的词,则用 unknown token 替换它们。
Byte-Pair Encoding 本质上是一种数据压缩算法,其通过构建子词的方式,压缩整体词表的大小(比单词级的词表要紧凑很多),并且可以通过子词组合覆盖新词,甚至可以跨语言复用(拉丁语系词根),整体适用性更强。
2. Positional Encoding
得到每个 token 的 Embedding 向量后,为了表示 token 之间的位置关系,Transformer 中对每一个 token 的向量加上了一个表示当前位置信息的向量(位置编码),其具体做法如下:
其中 $PE_{(pos,2i)}$ 表示 $pos$ 位置对应向量的第 $2i$ 维度数值。相较于 RNN 中通过计算的先后次序来表征序列中先后信息的方式,Transformer 中位置编码的方式可以更好地支持数据并行。
3. Attention
Scaled Dot-Product Attention
上述式子中除以 $\sqrt{d_k}$ 的目的是缩小点积后的数值范围,确保 softmax 操作后梯度稳定。因为当向量较长时,softmax 很容易将较大的元素往 1 推,其余元素往 0 推,而当所有结果分布在 0、1 区域时,softmax 函数的梯度将变得很小,即出现梯度消失现象。
Masked Attention
由于上述的 Attention 操作是对全局做的,但在 Decoder 中计算先前输出序列 $(y_1,\ldots,y_m)$ 的 Attention 时,会采用 Masked Attention,即 $t-1$ 时刻的输出是无法看到 $t$ 时刻的输出信息的,因此对 $t-1$ 时刻之后的结果,乘以一个很大的负数,使得通过 softmax 后的权重变为 0。
具体来说,Masked Attention 后得到的 $v'_{i}$ 不再由所有的 $v_{1,\ldots,n}$ 加权求和得到,而是仅有 $v_{\leq i}$ 的部分加权求和得到。
Multi-Head Attention
在上述 Scaled Dot-Product Attention 的过程中,给定 Q、K、V 后,就可以直接得到对应的 Attention 结果 $V’$,其中并没有参数可以进行学习。因此 Transformer 中实际采用的是 Multi-Head Attention,如下所示:
其中 $Q,K,V\in \mathbb{R}^{n\times d_{\text{model}}}$($n$ 为输入序列长度),$W_i^Q,W_i^K\in \mathbb{R}^{d_{\text{model}}\times d_k}$, $W_i^V\in \mathbb{R}^{d_{\text{model}}\times d_v}$, $W_i^O\in \mathbb{R}^{hd_v\times d_{\text{model}}}$(这几个 $W$ 矩阵即为可学习的参数)。
Multi-Head Attention 的整体想法就是将 $Q,K,V$ 映射到多个子空间中,分别进行 Attention 操作后再拼接起来,具体结构如下所示:
Multi-Head Attention 模块在整个 Transformer 架构中一共出现了三处:
-
在 Encoder 处为 Self-attention,即 $V,K,Q$ 的原始输入数据一致;
-
在 Decoder 处为 Masked Self-attention,即 $V,K,Q$ 的原始输入数据一致,但 $v'_{i}$ 仅有 $v_{\leq i}$ 加权求和得到;
-
在 Encoder-Decoder Attention 层,Encoder 的输出提供 $V,K\in \mathbb{R}^{n\times d}$,而 $Q \in \mathbb{R}^{m\times d}$ 由先前 Decoder 的输出提供,最终输出的 $V'\in \mathbb{R}^{m\times d}$,其中 $v'_i$ 由 Encoder 提供的 $v_{1,\ldots,n}$ 加权求和得到。
4. Layer Normalization
-
Batch Normalization:对每一个特征 $i$,将所有样本中的特征 $i$ 进行归一化,使其均值为 0,方差为 1
-
Layer Normalization:对每一个样本,将其中所有特征做归一化,使其均值为 0,方差为 1
-
Transformer 中采用 Layer Normalization 的原因:在序列问题中,每一个样本的有效长度是不一样的(无效处通常填 0),因此若采用 BN 对每一个特征进行归一化,很容易受到训练样本有效长度的影响,例如测试时出现一个特别长的样本。
5. Feed-Forward Networks
实际上就是两层 MLP,计算过程如下:
Multi-Head Attention 代码实践
首先是手动实现 Multi-Head Attention(可以深入了解其运行过程),具体代码如下:
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
import torch
import torch.nn as nn
import torch.nn.functional as F
class MultiHeadAttention(nn.Module):
def __init__(self, d_model, num_heads, dropout=0.1):
super(MultiHeadAttention, self).__init__()
assert d_model % num_heads == 0, "d_model must be divisible by num_heads"
self.d_model = d_model
self.num_heads = num_heads
self.d_k = d_model // num_heads
# 初始化线性投影层
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)
def scaled_dot_product_attention(self, Q, K, V, mask=None):
# 计算注意力分数
scores = torch.matmul(Q, K.transpose(-2, -1)) / torch.sqrt(torch.tensor(self.d_k, dtype=torch.float32))
# 应用 mask (如果存在)
if mask is not None:
if mask.dim() == 2: # mask shape: [seq_len, seq_len]
mask = mask.unsqueeze(0).unsqueeze(0)
elif mask.dim() == 3: # mask shape: [batch_size, seq_len, seq_len]
mask = mask.unsqueeze(1)
scores = scores.masked_fill(mask == 0, -1e9)
# 计算注意力权重
attn_weights = F.softmax(scores, dim=-1)
# 应用 dropout
attn_weights = self.dropout(attn_weights)
# 计算上下文向量
output = torch.matmul(attn_weights, V)
return output, attn_weights
def split_heads(self, x):
# 将输入分割为多个头, 维度变换为 [batch_size, num_heads, seq_len, d_k]
batch_size, seq_len, _ = x.size()
return x.view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
def combine_heads(self, x):
# 合并多个头, 维度变换为 [batch_size, seq_len, d_model]
batch_size, _, seq_len, _ = x.size()
return x.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model)
def forward(self, Q, K, V, mask=None):
# 线性投影并分割头
Q = self.split_heads(self.W_q(Q))
K = self.split_heads(self.W_k(K))
V = self.split_heads(self.W_v(V))
# 计算缩放点积注意力
attn_output, attn_weights = self.scaled_dot_product_attention(Q, K, V, mask)
# 合并头并最终投影
attn_output = self.combine_heads(attn_output)
output = self.W_o(attn_output)
return output, attn_weights
if __name__ == "__main__":
# 参数设置
d_model = 512
num_heads = 8
dropout = 0.1
# 创建模拟数据
seq_len = 10
batch_size = 4
# 创建模块
model = MultiHeadAttention(d_model, num_heads, dropout)
# 生成随机输入(Q, K, V)
Q = torch.randn(batch_size, seq_len, d_model)
K = torch.randn(batch_size, seq_len, d_model)
V = torch.randn(batch_size, seq_len, d_model)
# 创建 mask(可选)
mask = torch.ones(batch_size, seq_len, seq_len) # 示例:全 1 表示无 mask
# 前向传播
output, attn_weights = model(Q, K, V, mask)
print(output.shape) # torch.Size([batch_size, seq_len, d_model])
print(attn_weights.shape) # torch.Size([batch_size, num_heads, seq_len, seq_len])
在实践中,可以直接调用 nn.MultiheadAttention
实现上述代码功能:
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
import torch
import torch.nn as nn
# 参数设置
d_model, num_heads, dropout = 512, 8, 0.1
seq_len, batch_size = 10, 4
# 初始化官方模块 (包含投影层、多头计算)
mha = nn.MultiheadAttention(
embed_dim=d_model,
num_heads=num_heads,
dropout=dropout,
batch_first=True # 输入输出格式为 [batch, seq, features]
)
# 生成随机输入 (Q/K/V 形状相同)
Q = K = V = torch.randn(batch_size, seq_len, d_model)
# 生成因果掩码 (下三角矩阵)
causal_mask = torch.triu( # 上三角为 True 表示遮蔽
torch.ones(seq_len, seq_len), diagonal=1
).bool()
# 执行注意力计算
output, attn_weights = mha(
query=Q,
key=K,
value=V,
attn_mask=causal_mask, # 遮蔽未来位置
need_weights=True # 返回注意力权重
)
print("Output shape:", output.shape) # [4, 10, 512]
print("Weights shape:", attn_weights.shape) # [4, 10, 10] 此处为各头权重平均值