在上一篇文章中我们详细了解了Encoder内部三种重要机制的原理,包括mask、Embedding、scaled。这篇文章我们主要从Transformer的Encoder源码入手,读懂Encoder的结构。
考虑到本次编码的主要目的在于对Transformer的编码器结构进行全面理解,所以对于模拟数据和超参数进行了比较简单的设计,模型参数解释及对应设置如表1.1所示
编码器端的模拟数据输入主要是字典序的三句话,并且输入时就已经对输入序列进行了对齐(使用P占位),其主体内容如代码片段1.1所示。
# 介绍:将文本数据及转化为字典序列
def make_data(src_vocab,sentences):
enc_inputs = []
for i in range(len(sentences)):
enc_input = [[src_vocab[n] for n in sentences[i].split()]]
enc_inputs.extend(enc_input)
return torch.LongTensor(enc_inputs)
# 构造输入
sentences = ['我 是 学 生 P',
'我 喜 欢 学 习',
'我 是 男 生 P']
src_vocab = {'P': 0, '我': 1, '是': 2, '学': 3, '生': 4, '喜': 5, '欢': 6, '习': 7, '男': 8} # 词源字典 字:索引
# 将输入转为字典索引
enc_inputs=make_data(src_vocab,sentences)
于是,我们可以利用原始的sentences、src_vocab以make_data()函数构造出输入序列,其内容与其在字典中的顺序有关。随后便可以利用Dataset函数定义模拟出的数据,使其可被DataLoader加载,其内容如代码片段1.2所示。
# 介绍:将模拟数据转化为可被Dataloader加载的格式
# 定义Dataset
class MyDataSet(Data.Dataset):
def __init__(self, enc_inputs):
super(MyDataSet, self).__init__()
self.enc_inputs = enc_inputs
def __len__(self):
return self.enc_inputs.shape[0]
def __getitem__(self, idx):
return self.enc_inputs[idx]
使用Dataloader加载,其输出如下:
Position embedding主要目的是为了弥补Attension机制中词序列信息丢失的问题,把词序信号加到词向量上帮助模型学习这些序列信息。
Padding mask作用是掩盖不参与运算的元素,还有一个作用是在softmax之前将填充元素mask掉使其不参与权重分配(还有另一种mask方式:seq mask,是为了在decoder阶段保证模型看不到答案而设置的,其表现形式为三角矩阵)。
对于任何一门语言,单词在句子中的位置以及排列顺序是非常重要的,它们不仅是一个句子的语法结构的组成部分,更是表达语义的重要概念。一个单词在句子的位置或排列顺序不同,可能整个句子的意思就发生了偏差,例如:
I do not like the story of the movie, but I do like the cast.
I do like the story of the movie, but I do not like the cast.
上面两句话所使用的的单词完全一样,但是所表达的句意却截然相反,因此考虑引入词序信息来区别这两句话的意思。
我们知道,循环神经网络本身就是一种顺序结构,天生就包含了词在序列中的位置信息。而Transformer模型抛弃了RNN、CNN作为序列学习的基本模型,,完全采用Attention取而代之,这些词序信息就会丢失,模型就没有办法知道每个词在句子中的相对和绝对的位置信息。因此,有必要把词序信号加到词向量上帮助模型学习这些信息,位置编码(Positional Encoding)就是用来解决这种问题的方法,其数学表达式如下:
其中,𝑝𝑜𝑠是词在词表中出现的位置序号,𝑖是维度序号。我们可以先生成相同维度的用0填充的张量𝑝𝑒_𝑒𝑚𝑏𝑒𝑑𝑑𝑖𝑛𝑔,再用上述规则进行填充,其实现如下所示。
# 定义Position Embedding
class PositionalEncoding(nn.Module):
def __init__(self, d_model, dropout=0.1, max_len=5000):
super(PositionalEncoding, self).__init__()
self.dropout = nn.Dropout(p=dropout)
pos_table = np.array([
[pos / np.power(10000, 2 * i / d_model) for i in range(d_model)]
if pos != 0 else np.zeros(d_model) for pos in range(max_len)])
pos_table[1:, 0::2] = np.sin(pos_table[1:, 0::2])# 字嵌入维度为偶数时
pos_table[1:, 1::2] = np.cos(pos_table[1:, 1::2])# 字嵌入维度为奇数时
self.pos_table = torch.FloatTensor(pos_table).cuda()# enc_inputs: [seq_len, d_model]
def forward(self, enc_inputs):# enc_inputs: [batch_size, seq_len, d_model]
enc_inputs += self.pos_table[:enc_inputs.size(1), :]
return self.dropout(enc_inputs.cuda())
前面已经提到,padding mask的主要目的是让非定长序列对齐,但其实这一步在构造模拟数据时就考虑到了,所以这里padding mask主要就是生成训练时用的矩阵张量,其实现如下所示。
# 定义padding mask函数
def get_attn_pad_mask(seq_q, seq_k):# seq_q: [batch_size, seq_len] ,seq_k: [batch_size, seq_len]
batch_size, len_q = seq_q.size()
batch_size, len_k = seq_k.size()
pad_attn_mask = seq_k.data.eq(0).unsqueeze(1)# 判断 输入那些含有P(=0),用1标记 ,[batch_size, 1, len_k]
return pad_attn_mask.expand(batch_size, len_q, len_k)# 扩展成多维度
MHA是Transformer的核心部分,主要目的是获取序列不同位置上的权重。由图3.1MHA的网络结构图可以看出,其主要步骤分为两步:QKV的切分映射与自注意力计算。这里自注意力(self-attention)引入了Query、Key、Value的概念:Query是查询对象;Key是键,用来和要查询的Query计算相关性,得到一个权重(相关性或者相似度);Value是操作的值,将QK计算后的权重乘以Value得到最终的结果。
首先,利用自注意力机制通过对原始输入X进行变换得到QKV,也就是将原始输入X进行不同的线性映射来得到Q、K、V,其计算过程如下:
Q
=
X
∗
W
Q
K
=
X
∗
W
k
V
=
X
∗
W
v
Q=X*W_Q\\ K=X*W_k\\ V=X*W_v
Q=X∗WQK=X∗WkV=X∗Wv
得到QKV后,接下来就要进行multi-head切分,本质上就是在embedding的维度上将矩阵切分为多个张量切分完后的维度应该是:
b
a
t
c
h
S
i
z
e
∗
s
e
q
L
e
n
∗
h
e
a
d
s
∗
(
e
m
b
e
d
d
i
n
g
D
i
m
/
h
e
a
d
s
)
batchSize*seqLen*heads*(embeddingDim/heads)
batchSize∗seqLen∗heads∗(embeddingDim/heads)
每一个张量对应一个head的输入,如图下图所示。
前面说MHA是Transformer的核心部分,而Scaled Dot-Product Attention则是MHA的核心部分。得到切分后的QKV后,每个heads进行注意力的计算,如图3.1左图所示。假设Q、K、V为切分完后的矩阵(其中一个头),根据两个向量的点积越大越相似,我们通过
Q
K
T
QK^T
QKT求出注意力矩阵,再根据注意力矩阵来给V进行加权,即:
A
t
t
e
n
t
i
o
n
(
Q
,
K
,
V
)
=
s
o
f
t
m
a
x
(
Q
K
T
d
k
)
V
Attention(Q,K,V)=softmax(\frac{QK^T}{\sqrt{d_k }})V
Attention(Q,K,V)=softmax(dkQKT)V
其中
d
k
\sqrt{d_k }
dk是为了把注意力矩阵变成标准正态分布,
s
o
f
t
m
a
x
softmax
softmax进行归一化,使每个字与其他字的注意力权重之和为1,这一操作使得每一个字的嵌入都包含当前句子内所有字的信息,并且
A
t
t
e
n
t
i
o
n
(
Q
,
K
,
V
)
Attention(Q,K,V)
Attention(Q,K,V)的维度和
V
V
V的维度保持一致。上述过程的主体代码如下所示。
class ScaledDotProductAttention(nn.Module):
def __init__(self):
super(ScaledDotProductAttention, self).__init__()
def forward(self, Q, K, V, attn_mask):
scores = torch.matmul(Q, K.transpose(-1, -2)) / np.sqrt(d_k)
scores.masked_fill_(attn_mask, -1e9)
attn = nn.Softmax(dim=-1)(scores)
context = torch.matmul(attn, V)
return context, attn
基于前面的多头划分和自注意力,可以很自然地理解Transformer的MHA部分,其主体代码如下所示。
class MultiHeadAttention(nn.Module):
def __init__(self):
super(MultiHeadAttention, self).__init__()
self.W_Q = nn.Linear(d_model, d_k * n_heads, bias=False)
self.W_K = nn.Linear(d_model, d_k * n_heads, bias=False)
self.W_V = nn.Linear(d_model, d_v * n_heads, bias=False)
self.fc = nn.Linear(n_heads * d_v, d_model, bias=False)
def forward(self, input_Q, input_K, input_V, attn_mask):
residual, batch_size = input_Q, input_Q.size(0)
Q = self.W_Q(input_Q).view(batch_size, -1, n_heads, d_k).transpose(1, 2) # Q: [batch_size, n_heads, len_q, d_k]
K = self.W_K(input_K).view(batch_size, -1, n_heads, d_k).transpose(1, 2) # K: [batch_size, n_heads, len_k, d_k]
V = self.W_V(input_V).view(batch_size, -1, n_heads, d_v).transpose(1,2) # V: [batch_size, n_heads, len_v(=len_k), d_v]
attn_mask = attn_mask.unsqueeze(1).repeat(1, n_heads, 1,1) # attn_mask : [batch_size, n_heads, seq_len, seq_len]
context, attn = ScaledDotProductAttention()(Q, K, V, attn_mask) # context: [batch_size, n_heads, len_q, d_v]
# attn: [batch_size, n_heads, len_q, len_k]
context = context.transpose(1, 2).reshape(batch_size, -1,n_heads * d_v) # context: [batch_size, len_q, n_heads * d_v]
output = self.fc(context) # [batch_size, len_q, d_model]
return nn.LayerNorm(d_model).cuda()(output + residual), attn
前馈网络也就是简单的两层线性映射再利用激活函数做非线性映射,没有太复杂的地方,其主体代码如下所示
class PoswiseFeedForwardNet(nn.Module):
def __init__(self):
super(PoswiseFeedForwardNet, self).__init__()
self.fc = nn.Sequential(
nn.Linear(d_model, d_ff, bias=False),
nn.ReLU(),
nn.Linear(d_ff, d_model, bias=False))
def forward(self, inputs):
residual = inputs
output = self.fc(inputs)
return nn.LayerNorm(d_model).cuda()(output + residual)
这一步主要是进行跳连接Shortcut和层归一化LayerNorm。
首先是跳连接,将原始输入X和经过MHA的输入进行sum,得到融合后的特征,其作用类似残差连接,能够缓解梯度消失问题。
然后是LayerNorm(作用是把神经网络中隐藏层归一为标准正态分布,加速收敛),具体操作是将每一行的每一个元素减去这行的均值, 再除以这行的标准差, 从而得到归一化后的数值,其公式如下:
y
=
x
−
E
[
x
]
V
a
r
[
x
]
+
ε
∗
γ
+
β
y=\frac{x-E[x]}{\sqrt{Var[x]+ε}}*γ+β
y=Var[x]+εx−E[x]∗γ+β
class EncoderLayer(nn.Module):
def __init__(self):
super(EncoderLayer, self).__init__()
self.enc_self_attn = MultiHeadAttention()# 多头注意力机制
self.pos_ffn = PoswiseFeedForwardNet()# 前馈神经网络
def forward(self, enc_inputs, enc_self_attn_mask):# enc_inputs: [batch_size, src_len, d_model]
# 输入3个enc_inputs分别与W_q、W_k、W_v相乘得到Q、K、V
enc_outputs, attn = self.enc_self_attn(enc_inputs, enc_inputs, enc_inputs,
enc_self_attn_mask)# attn: [batch_size, n_heads, src_len, src_len]
enc_outputs = self.pos_ffn(enc_outputs)# enc_outputs: [batch_size, src_len, d_model]
return enc_outputs, attn
class Encoder(nn.Module):
def __init__(self):
super(Encoder, self).__init__()
self.src_emb = nn.Embedding(src_vocab_size, d_model)# 把字转换字向量
self.pos_emb = PositionalEncoding(d_model)# 加入位置信息
self.layers = nn.ModuleList([EncoderLayer() for _ in range(n_layers)])
def forward(self, enc_inputs):# enc_inputs: [batch_size, src_len]
enc_outputs = self.src_emb(enc_inputs)# enc_outputs: [batch_size, src_len, d_model]
enc_outputs = self.pos_emb(enc_outputs)# enc_outputs: [batch_size, src_len, d_model]
enc_self_attn_mask = get_attn_pad_mask(enc_inputs, enc_inputs)
enc_self_attns = []
for layer in self.layers:
enc_outputs, enc_self_attn = layer(enc_outputs, enc_self_attn_mask)
enc_self_attns.append(enc_self_attn)
return enc_outputs
loader = Data.DataLoader(MyDataSet(enc_inputs), 2, True)
for encoder_input in loader:
enc_input=encoder_input.cuda()
encoder=Encoder().cuda()
output=encoder(enc_input)
print(output)
print(output.shape)