本文主要参考transformers教程, 包括了一些自己的思考。
Attention
NLP 神经网络模型的本质就是对输入文本进行编码,常规的做法是首先对句子进行分词,然后将每个词语 (token) 都转化为对应的词向量 (token embeddings),这样文本就转换为一个由词语向量组成的矩阵
X
=
(
x
1
,
x
2
,
…
,
x
n
)
\boldsymbol{X}=\left(\boldsymbol{x}_1, \boldsymbol{x}_2, \ldots, \boldsymbol{x}_n\right)
X=(x1,x2,…,xn) ,其中
x
i
\boldsymbol{x}_i
xi就表示第
i
i
i 个词语的词向量,维度为
d
d
d ,故
X
∈
R
n
×
d
\boldsymbol{X} \in \mathbb{R}^{n \times d}
X∈Rn×d 。
在 Transformer 模型提出之前,对 token 序列
X
\boldsymbol{X}
X 的常规编码方式是通过循环网络 (RNNs) 和卷积网络 (CNNs)。
- RNN (例如 LSTM) 的方案很简单,每一个词语
x
t
\boldsymbol{x}_t
xt 对应的编码结果
y
t
\boldsymbol{y}_t
yt 通过递归地计算得到:
y t = f ( y t − 1 , x t ) \boldsymbol{y}_t=f\left(\boldsymbol{y}_{t-1}, \boldsymbol{x}_t\right) yt=f(yt−1,xt)
RNN 的序列建模方式虽然与人类阅读类似,但是递归的结构导致其无法并行计算,因此速度较慢。而且 RNN 本质是一个马尔科夫决策过程,难以学习到全局的结构信息;
- CNN 则通过滑动窗口基于局部上下文来编码文本,例如核尺寸为 3 的卷积操作就是使用每一个词自身以及前一个和后一个词来生成嵌入式表示:
y t = f ( x t − 1 , x t , x t + 1 ) \boldsymbol{y}_t=f\left(\boldsymbol{x}_{t-1}, \boldsymbol{x}_t, \boldsymbol{x}_{t+1}\right) yt=f(xt−1,xt,xt+1)
Google《Attention is All You Need》提供了第三个方案:直接使用 Attention 机制编码整个文本。相比 RNN 要逐步递归才能获得全局信息(因此一般使用双向 RNN),而 CNN 实际只能获取局部信息,需要通过层叠来增大感受野,Attention 机制一步到位获取了全局信息:
y t = f ( x t , A , B ) \boldsymbol{y}_t=f\left(\boldsymbol{x}_t, \boldsymbol{A}, \boldsymbol{B}\right) yt=f(xt,A,B)
其中 A , B \boldsymbol{A}, \boldsymbol{B} A,B 是另外的词语序列(矩阵),如果取 A = B = X \boldsymbol{A}=\boldsymbol{B}=\boldsymbol{X} A=B=X 就称为 Self-Attention,即直接将 x t \boldsymbol{x}_t xt 与自身序列中的每个词语进行比较,最后算出 y t \boldsymbol{y}_t yt 。
Scaled Dot-product Attention
虽然 Attention 有许多种实现方式,但是最常见的还是 Scaled Dot-product Attention。
形式化表示为:
head
i
=
Attention
(
Q
W
i
Q
,
K
W
i
K
,
V
W
i
V
)
MultiHead
(
Q
,
K
,
V
)
=
Concat
(
head
1
,
…
,
head
h
)
\begin{gathered} \text { head }_i=\operatorname{Attention}\left(\boldsymbol{Q} \boldsymbol{W}_i^Q, \boldsymbol{K} \boldsymbol{W}_i^K, \boldsymbol{V} \boldsymbol{W}_i^V\right) \\ \operatorname{MultiHead}(\boldsymbol{Q}, \boldsymbol{K}, \boldsymbol{V})=\operatorname{Concat}\left(\text { head }_1, \ldots, \text { head }_h\right) \end{gathered}
head i=Attention(QWiQ,KWiK,VWiV)MultiHead(Q,K,V)=Concat( head 1,…, head h)
其中
W
i
Q
∈
R
d
k
×
d
~
k
,
W
i
K
∈
R
d
k
×
d
~
k
,
W
i
V
∈
R
d
v
×
d
~
v
\boldsymbol{W}_i^Q \in \mathbb{R}^{d_k \times \tilde{d}_k}, \boldsymbol{W}_i^K \in \mathbb{R}^{d_k \times \tilde{d}_k}, \boldsymbol{W}_i^V \in \mathbb{R}^{d_v \times \tilde{d}_v}
WiQ∈Rdk×d~k,WiK∈Rdk×d~k,WiV∈Rdv×d~v 是映射矩阵,
h
h
h 是注意力头的数量。最后,将多头的结果拼接起来就得到最终
m
×
h
d
~
v
m \times h \tilde{d}_v
m×hd~v 的结果序列。所谓的“多头” (Multi-head),其实就是多做几次 Scaled Dot-product Attention,然后把结果拼接。
下面我们首先实现一个注意力头:
from torch import nn
class AttentionHead(nn.Module):
def __init__(self, embed_dim, head_dim):
super().__init__()
self.q = nn.Linear(embed_dim, head_dim)
self.k = nn.Linear(embed_dim, head_dim)
self.v = nn.Linear(embed_dim, head_dim)
def forward(self, query, key, value, query_mask=None, key_mask=None, mask=None):
attn_outputs = scaled_dot_product_attention(
self.q(query), self.k(key), self.v(value), query_mask, key_mask, mask)
return attn_outputs
每个头都会初始化三个独立的线性层,负责将
Q
,
K
,
V
Q, K, V
Q,K,V 序列映射到尺寸为 [batch_size, seq_len, head_dim]
的张量,其中 head_dim
是映射到的向量维度。
实践中一般将
head_dim
设置为embed_dim
的因数,这样 token 嵌入式表示的维度就可以保持不变,例如 BERT 有 12 个注意力头,因此每个头的维度被设置为 768 / 12 = 64 768/12=64 768/12=64。****
最后只需要拼接多个注意力头的输出就可以构建出 Multi-head Attention 层了(这里在拼接后还通过一个线性变换来生成最终的输出张量):
class MultiHeadAttention(nn.Module):
def __init__(self, config):
super().__init__()
embed_dim = config.hidden_size
num_heads = config.num_attention_heads
head_dim = embed_dim // num_heads
self.heads = nn.ModuleList(
[AttentionHead(embed_dim, head_dim) for _ in range(num_heads)]
)
self.output_linear = nn.Linear(embed_dim, embed_dim)
def forward(self, query, key, value, query_mask=None, key_mask=None, mask=None):
x = torch.cat([
h(query, key, value, query_mask, key_mask, mask) for h in self.heads
], dim=-1)
x = self.output_linear(x)
return x
这里使用 BERT-base-uncased 模型的参数初始化 Multi-head Attention 层,并且将之前构建的输入送入模型以验证是否工作正常:
from transformers import AutoConfig
from transformers import AutoTokenizer
model_ckpt = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
text = "time flies like an arrow"
inputs = tokenizer(text, return_tensors="pt", add_special_tokens=False)
config = AutoConfig.from_pretrained(model_ckpt)
token_emb = nn.Embedding(config.vocab_size, config.hidden_size)
inputs_embeds = token_emb(inputs.input_ids)
multihead_attn = MultiHeadAttention(config)
query = key = value = inputs_embeds
attn_output = multihead_attn(query, key, value)
print(attn_output.size())
Transformer Encoder
回忆一下上一章中介绍过的标准 Transformer 结构,Encoder 负责将输入的词语序列转换为词向量序列,Decoder 则基于 Encoder 的隐状态来迭代地生成词语序列作为输出,每次生成一个词语。
其中,Encoder 和 Decoder 都各自包含有多个 building blocks。下图展示了一个翻译任务的例子:
可以看到:
- 输入的词语首先被转换为词向量。由于注意力机制无法捕获词语之间的位置关系,因此还通过 positional embeddings 向输入中添加位置信息;
- Encoder 由一堆 encoder layers (blocks) 组成,类似于图像领域中的堆叠卷积层。同样地,在 Decoder 中也包含有堆叠的 decoder layers;
- Encoder 的输出被送入到 Decoder 层中以预测概率最大的下一个词,然后当前的词语序列又被送回到 Decoder 中以继续生成下一个词,重复直至出现序列结束符 EOS 或者超过最大输出长度。
The Feed-Forward Layer
Transformer Encoder/Decoder 中的前馈子层实际上就是两层全连接神经网络,它单独地处理序列中的每一个词向量,也被称为 position-wise feed-forward layer。常见做法是让第一层的维度是词向量大小的 4 倍,然后以 GELU 作为激活函数。
下面实现一个简单的 Feed-Forward Layer:
class FeedForward(nn.Module):
def __init__(self, config):
super().__init__()
self.linear_1 = nn.Linear(config.hidden_size, config.intermediate_size)
self.linear_2 = nn.Linear(config.intermediate_size, config.hidden_size)
self.gelu = nn.GELU()
self.dropout = nn.Dropout(config.hidden_dropout_prob)
def forward(self, x):
x = self.linear_1(x)
x = self.gelu(x)
x = self.linear_2(x)
x = self.dropout(x)
return x
将前面注意力层的输出送入到该层中以测试是否符合我们的预期:
feed_forward = FeedForward(config)
ff_outputs = feed_forward(attn_output)
print(ff_outputs.size())
至此创建完整 Transformer Encoder 的所有要素都已齐备,只需要再加上 Skip Connections 和 Layer Normalization 就大功告成了。
Layer Normalization
Layer Normalization 负责将一批 (batch) 输入中的每一个都标准化为均值为零且具有单位方差;Skip Connections 则是将张量直接传递给模型的下一层而不进行处理,并将其添加到处理后的张量中。
向 Transformer Encoder/Decoder 中添加 Layer Normalization 目前共有两种做法:
本章采用第二种方式来构建 Transformer Encoder 层:
class TransformerEncoderLayer(nn.Module):
def __init__(self, config):
super().__init__()
self.layer_norm_1 = nn.LayerNorm(config.hidden_size)
self.layer_norm_2 = nn.LayerNorm(config.hidden_size)
self.attention = MultiHeadAttention(config)
self.feed_forward = FeedForward(config)
def forward(self, x, mask=None):
# Apply layer normalization and then copy input into query, key, value
hidden_state = self.layer_norm_1(x)
# Apply attention with a skip connection
x = x + self.attention(hidden_state, hidden_state, hidden_state, mask=mask)
# Apply feed-forward layer with a skip connection
x = x + self.feed_forward(self.layer_norm_2(x))
return x
Positional Embeddings
前面讲过,由于注意力机制无法捕获词语之间的位置信息,因此 Transformer 模型还使用 Positional Embeddings 添加了词语的位置信息。
Positional Embeddings 基于一个简单但有效的想法:使用与位置相关的值模式来增强词向量。
如果预训练数据集足够大,那么最简单的方法就是让模型自动学习位置嵌入。下面本章就以这种方式创建一个自定义的 Embeddings 模块,它同时将词语和位置映射到嵌入式表示,最终的输出是两个表示之和:
class Embeddings(nn.Module):
def __init__(self, config):
super().__init__()
self.token_embeddings = nn.Embedding(config.vocab_size,
config.hidden_size)
self.position_embeddings = nn.Embedding(config.max_position_embeddings,
config.hidden_size)
self.layer_norm = nn.LayerNorm(config.hidden_size, eps=1e-12)
self.dropout = nn.Dropout()
def forward(self, input_ids):
# Create position IDs for input sequence
seq_length = input_ids.size(1)
position_ids = torch.arange(seq_length, dtype=torch.long).unsqueeze(0)
# Create token and position embeddings
token_embeddings = self.token_embeddings(input_ids)
position_embeddings = self.position_embeddings(position_ids)
# Combine token and position embeddings
embeddings = token_embeddings + position_embeddings
embeddings = self.layer_norm(embeddings)
embeddings = self.dropout(embeddings)
return embeddings
embedding_layer = Embeddings(config)
print(embedding_layer(inputs.input_ids).size())
除此以外,Positional Embeddings 还有一些替代方案:
绝对位置表示:使用由调制的正弦和余弦信号组成的静态模式来编码位置。 当没有大量训练数据可用时,这种方法尤其有效;
相对位置表示:在生成某个词语的词向量时,一般距离它近的词语更为重要,因此也有工作采用相对位置编码。因为每个词语的相对嵌入会根据序列的位置而变化,这需要在模型层面对注意力机制进行修改,而不是通过引入嵌入层来完成,例如 DeBERTa 等模型。
下面将所有这些层结合起来构建完整的 Transformer Encoder:
class TransformerEncoder(nn.Module):
def __init__(self, config):
super().__init__()
self.embeddings = Embeddings(config)
self.layers = nn.ModuleList([TransformerEncoderLayer(config)
for _ in range(config.num_hidden_layers)])
def forward(self, x, mask=None):
x = self.embeddings(x)
for layer in self.layers:
x = layer(x, mask=mask)
return x
同样地,我们对该层进行简单的测试:
encoder = TransformerEncoder(config)
print(encoder(inputs.input_ids).size())
torch.Size([1, 5, 768])
Transformer Decoder
Transformer Decoder 与 Encoder 最大的不同在于 Decoder 有两个注意力子层,如下图所示:
Masked multi-head self-attention layer:确保在每个时间步生成的词语仅基于过去的输出和当前预测的词,否则 Decoder 相当于作弊了;
Encoder-decoder attention layer:以解码器的中间表示作为 queries,对 encoder stack 的输出 key 和 value 向量执行 Multi-head Attention。通过这种方式,Encoder-Decoder Attention Layer 就可以学习到如何关联来自两个不同序列的词语,例如两种不同的语言。 解码器可以访问每个 block 中 Encoder 的 keys 和 values。
与 Encoder 中的 Mask 不同,Decoder 的 Mask 是一个下三角矩阵:
seq_len = inputs.input_ids.size(-1)
mask = torch.tril(torch.ones(seq_len, seq_len)).unsqueeze(0)
print(mask[0])
tensor([[1., 0., 0., 0., 0.],
[1., 1., 0., 0., 0.],
[1., 1., 1., 0., 0.],
[1., 1., 1., 1., 0.],
[1., 1., 1., 1., 1.]])
这里使用 PyTorch 自带的 tril()
函数来创建下三角矩阵,然后同样地,通过 Tensor.masked_fill()
将所有零替换为负无穷大来防止注意力头看到未来的词语而造成信息泄露:
scores.masked_fill(mask == 0, -float("inf"))
tensor([[[26.8082, -inf, -inf, -inf, -inf],
[-0.6981, 26.9043, -inf, -inf, -inf],
[-2.3190, 1.2928, 27.8710, -inf, -inf],
[-0.5897, 0.3497, -0.3807, 27.5488, -inf],
[ 0.5275, 2.0493, -0.4869, 1.6100, 29.0893]]],
grad_fn=<MaskedFillBackward0>)
更具体的学习Transformer模型结构可以参考nano GPT项目。