Transformer的PyTorch实现
Attention is all your need
**直观认识**
在seq2seq任务中,对于一个句子来言,RNN的学习方式是一个字一个字的来。而transformer训练时并行,所有字同时训练。
**整体结构**
transformer模型主要分为两大部分:
1. Encoder:将自然语言序列映射为隐藏层
2. Decoder:再将隐藏层映射为自然语言序列
seq2seq + Attention的训练方式,将注意力集中在解码端,transformer将注意力放在输入序列上,对输入序列做Attention,寻找序列内部的联系。
这篇文章通过PyTorch来实现Transformer模型中的一些细节,
模型结构介绍
通常Transformer网络结构采用6层的Encoder和Decoder,先将Encoder和Decoder看作为一个方块,我们可以将模型定义为如下:
这里留下一个思考,为什么Decoder中的每一层都和Encoder的最后一层相连?
一步步深入下去,对于每一层的Encoder和Decoder,它们的概要组成和详细结构分别如下图:
我们可以观察出Encoder层的组成部分: Self-Attention + Feed Forward
Decoder层的组成部分有三个:Self-Attention + Encoder-Decoder Attention + Feed Forward
Decoder比Encoder中多一个部件,而这个部件就是之前总结构图中Encoder和Decoder中相连的部分,代表着Decoder中的Encoder-Decoder Attention部件会使用Encoder的输出作为K,V。在机器翻译的场景中,Decoder部分,我们通常认为self attention是当前翻译和已经翻译的前文之间的关系,encoder decoder attention是当前翻译和编码的特征向量之间的关系(具体的详细参数会在self attention中说明)
Decoder和Encoder相差不多,所以主要来分析一下Encoder的具体组成
**Encoder的具体处理流程,从输入到输出**
用图中的句子为例子,"Thinking Machines" 句子长度为2,通过对句子每个单词进行embedding得到一个矩阵(词编码成),加上batch size这一维度,那么这个输入就是 (1, 2, embedding dimension)
1. 输入: (batch size, sequence length, embedding dimension)
2. Positional Encoding: 位置编码的维度是(1, sequence length, embedding dimension),将位置编码和输入相加
3. Self Attention: 这一步骤的具体流程在下文进行分析,这里我们只需要了解,它的输入为(batch size, sequence length, embedding dimension),输出会通过一个全连接层(Feed Forward)进行维度的调整。
4. Add & Normalize: 这里就是常见的残差和layer normalizate
5. Feed forward:在经过一个feed forward层,也就是线性层
6. Add & Normalize
接下来通过对每一个网络层的编写来实现Encoder层。
模型参数的定义
通常实现一个深度学习模型,都需要定义一些固定的参数,比如迭代次数,隐藏层数,损失函数,优化器等。
EPOCH = 10 # epoch数量
LAYERS = 6 # encoder decoder的层数
BATCH_SIZE = 64 # 每一个batch的大小
H_NUM = 8 # multihead attention hidden个数
D_MODEL = 256 # embedding维数
D_FF = 1024 # feed forward第一个全连接层维数
DROPOUT = 0.1
"""
我们需要将每一个batch的句子大小设为统一,将短句子填充0为长句子,具体见代码
"""
MAX_LENGTH = 60 # 最大句子长度
PAD = 0
UNK = 0 # 这里将填充PAD和未登陆词都设置为0,直观意思就是让他们对网络不产生影响
数据预处理
数据预处理的结果要输入Embedding层,所以我们需要先了解,Embedding层的输入,大概说一说,Embedding的输入只能是编号。也就是word对应的id号,所以我们在做nlp部分任务时,会生成word2id,和id2word的字典。
输入目标:(batch size, max sequence length),输入时一个batch的数据,batch的每一行代表一个句子对应的单词id向量。
假设最大序列长度为10 ,句子为 $”这是一个测试用例“$ ,8个单词,最后两位要填充0,结果就是:
$$ [2,4,6,8,1,23,54,21,0,0] $$
padding在这里的操作时具有局限性的。Encoder 在进行self attention操作时,有对矩阵的softmax操作,$softmax$函数$\sigma ({z})_{i}={\frac {e^{z_{i}}}{\sum _{j=1}^{K}e^{z_{j}}}}$当$z_i$为0时,$e^0$为1,而我们的本意时让这些区域不参加运算,所以会存在很大的隐患,根据指数函数的图像,给无效区域添加一个很大的负数的偏置。
这样的操作为encoder层中的mask,在decoder中还存在一个问题,我们生成单词的时候,只应该注意到已生成单词的信息,而对于句子右边的单词应该mask掉,所以在decoder中也存在一个mask矩阵,如图
Reference:苏剑林. (2019, Sep 18). 《从语言模型到Seq2Seq:Transformer如戏,全靠Mask 》[Blog post]. Retrieved from https://kexue.fm/archives/6933
单向语言模型,每一个单词的预测与已生成单词相关,所以$x_1$与$<s>$,$x_2$与$<s>,<x_1>$相关,当然还会有乱序语言模型,我们只需要将mask方式改变,如下,将单词之间的相关性改变
总的来说,我们预处理的结果,就是要生成batch的训练数据,和输入,输出分别对应的mask矩阵。
根据网络结构和实现方法,这里分下面几个方面介绍:
- Positional Encoding
- Self Attention --Multi Head Attention (这里包含上面谈到的Attention Mask)
- Layer Normalization ,Residual
- Feed Forward (两层线性映射并用激活函数激活)
- Layer Normalization,Residual
Positional Encoding
transformer的输入不像是RNN之类的输入是一个单词一个单词有序的,而是直接输入一个$(batch_size, max_sentence_length,embedding_dim)$的矩阵,所以我们需要提供每个字的位置信息给Transformer,这就是positional encoding。位置嵌入的维度是(max sequence length, embedding dimension)
我们通常的直觉是将位置的embedding和输入进行拼接而不是相加,但是从FaceBook和Google论文中来看(文章谈及论文都是其他大牛的观点),相加是更好的方案。
$$ PE_{(pos,2i)} = sin(pos / 10000^{2i/d_{\text{model}}}) \quad \quad PE_{(pos,2i+1)} = cos(pos / 10000^{2i/d_{\text{model}}})\tag{eq.1}$$ $i$代表词向量的维度,$2i,2i+1$表示词向量的奇数和偶数位对应的位置 $$
$i$代表词向量的维度,$2i,2i+1$表示词向量的奇数和偶数位对应的位置
# 实现代码
# 1. 首先声明一个max len的维度的全零矩阵
pe = torch.zeros(max_len, d_model, device=DEVICE)
# 2. 生成一个列表存储2i的值 (max_len,1)
position = torch.arange(0, max_len, device=DEVICE).unsqueeze(1)
# 2. 计算公式中的分母项 # torch.Size(d_model/2)
div_term = torch.exp(torch.arange(0,d_model,2,device=DEVICE) *
(-math.log(10000) / d_model))
# 3. 填充矩阵
pe[:,0::2] = torch.sin(position * div_term)# 这里的计算是torch中的广播机制
pe[:,1::2] = torch.cos(position * div_term)
计算分母(div_term)的机制是: $1/10000^{2i/d_model}=e^{log10000^{-2i/d_model}}=e^{2i(-log10000/d_{model}})$
可广播的一对张量满足一下规则:
- 每个张量至少有一个维度
- 迭代维度尺寸,从尾部维度开始,存在三种情况,两个张量维度相等;其中一个张量的维度尺寸为1;其中一个张量不存在这个维度
进行广播之后,结果是两个张量对应维度尺寸的较大者
ex: $[10,1]*[5]=[10,5]*[5]=[10,5]$在进行维度为$[10,1]$与$[5]$的矩阵相乘时,从尾部维度开始,二维矩阵(10,1)最后一个维度为1,通过广播机制将为[10,1]的列向量复制五次,使得第二维度为5,然后与[5]相乘
位置嵌入函数的周期从$2\pi$到$10000*2\pi$变化,每一个位置在embedding dimension维度上都会得到不同周期
的sin,cos函数的取值组合而产生独一的位置信息,之后以要用sin,cos可能取决于一个位置可以由其他位置线性变换得到,$sin(\alpha + \beta)=sin{\alpha}cos\beta+cos{\alpha}sin\beta$
$cos(\alpha+\beta)=cos{\alpha}sin\beta-sin{\alpha}cos\beta$
两个位置向量的点积,可以表示两个字之间的距离。但是这里多提一点,t位置和t-k位置点积是等于t位置和t+k位置向量的点积,这说明这种位置编码的向量是没有方向的。并且邱锡鹏在论文中证明这种距离的表示能够被self attention打破。所以Bert中的位置编码没有采用这种方式,而是通过训练的。
从图中可以看到一定的周期信息
接下来将输入矩阵和position embedding相加,进入self attention计算模块
x = x + torch.tensor(self.pe[:,x.size(1)],requires_grad=False)
Self Attention
输入的单词的embedding分别通过$W^Q,W^K,W^V$的线性变换生成$q,k,v$,对于每一个单词query,分别和每个单词对应的k相乘得到一个常量,图中乘机代表着score(也称为注意力),将score除以一个常数,再经过softmax就可以得到一个概率分布,使用得到的概率对v进行加权求和得到self attention层的输出z。
为什么要除以$\sqrt{d_k}$: 假设 $q$ 和 $k$ 是独立的随机变量,平均值为 0,方差 1,这样他们的点积后形成的注意力矩阵为 $q⋅k=\sum_{i=1}^{d_k}{q_i k_i}$,均值为 0 但方差放大为 $d_k$ 。为了抵消这种影响,我们用$\sqrt{d_k}$来缩放点积,可以使得Softmax归一化时结果更稳定,太大的话不是0就是1,以便反向传播时获取平衡的梯度
所以最后总结self attention进行的操作就是:$softmax(\frac{QK^T}{\sqrt{dk}}V)$
在数据预处理时,我们分别介绍了encoder,decoder的mask技术。Encoder层我们做的就是将注意力矩阵中的0值替换为一个很小的负数值。attention计算的代码如下:
def attention(query, key, value, mask=None, dropout=None):
# 将query矩阵的最后一个维度值作为d_k
d_k = query.size(-1)
# 将key的最后两个维度互换(转置),才能与query矩阵相乘,乘完了还要除以d_k开根号
scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)
# 如果存在要进行mask的内容,则将那些为0的部分替换成一个很大的负数
if mask is not None:
scores = scores.masked_fill(mask==0, -1e9) #TODO mask_fill是进行mask的函数
# mask和scores的矩阵维度必须相同,这个mask矩阵在数据预处理的时候就生成好了
# 将mask后的attention矩阵按照最后一个维度进行softmax
p_attn = F.softmax(scores, dim=-1)
# 如果dropout参数设置为非空,则进行dropout操作
if dropout is not None:
p_attn = dropout(p_attn)
# 最后返回注意力矩阵跟value的乘积,和注意力矩阵
return torch.matmul(p_attn, value), p_attn
Multi-head Attention
Attention机制的完善。
多头注意力机制,我们在生成$Q,K,V$矩阵之后,再将三个矩阵映射一下,然后再Attention。重复做h次,最后将结果拼接起来。公式如下:
$$ head_i=Attention(QW_i^Q,KW_i^K,VW_i^v) $$
$$ MultiHead(Q,K,V)=Concat(head_1,...head_h)$$多头就是一件事做了很多遍,最后将结果拼接起来 但是我们代码实现不是按照这种方式,生成QkV矩阵之后,将Q,K,V矩阵分割成h份,每一份也就是一个head, 用矩阵维度来表示就是[sequence length, embedding dimension / h] $$
但是我们代码实现不是按照这种方式,生成QkV矩阵之后,将Q,K,V矩阵分割成h份,每一份也就是一个head,
用矩阵维度来表示就是[sequence length, embedding dimension / h]
注意力矩阵中的每一行就是单词C1与C1-C6的相似度,代表着第一个字与这六个字的哪个比较相关。
矩阵V的每一行代表每个字向量的数学表达,上面的操作正是用注意力权重进行这些数学表达的加权线性组合,从而使每个字向量都含有当前句子内所有字向量的信息
class MultiHeadedAttention(nn.Module):
def __init__(self, h, d_model, dropout=0.1):
super(MultiHeadedAttention, self).__init__()
# 保证可以整除
assert d_model % h == 0
# 得到一个head的attention表示维度
self.d_k = d_model // h
# head数量
self.h = h
# 定义4个全连接函数,供后续作为WQ,WK,WV矩阵和最后h个多头注意力矩阵concat之后进行变换的矩阵
self.linears = clones(nn.Linear(d_model, d_model), 4)
self.attn = None
self.dropout = nn.Dropout(p=dropout)
def forward(self, query, key, value, mask=None):
if mask is not None:
mask = mask.unsqueeze(1)
# query的第一个维度值为batch size
nbatches = query.size(0)
# 将embedding层乘以WQ,WK,WV矩阵(均为全连接)
# 并将结果拆成h块,然后将第二个和第三个维度值互换(具体过程见上述解析)
query, key, value = [l(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2)
for l, x in zip(self.linears, (query, key, value))]
# 调用上述定义的attention函数计算得到h个注意力矩阵跟value的乘积,以及注意力矩阵
x, self.attn = attention(query, key, value, mask=mask, dropout=self.dropout)
# 将h个多头注意力矩阵concat起来(注意要先把h变回到第三维的位置)
x = x.transpose(1, 2).contiguous().view(nbatches, -1, self.h * self.d_k)
# 使用self.linears中构造的最后一个全连接函数来存放变换后的矩阵进行返回
return self.linears[-1](x)
Layer Normalization 和 残差连接
关于Normalization 可以去看https://zhuanlan.zhihu.com/p/33173246,这里不做过多讲解代码实现也比较简单,pytorch也有集成好的。
class LayerNorm(nn.Module):
def __init__(self, features, eps=1e-6):
super(LayerNorm, self).__init__()
# 初始化α为全1, 而β为全0
self.a_2 = nn.Parameter(torch.ones(features))
self.b_2 = nn.Parameter(torch.zeros(features))
# 平滑项
self.eps = eps
def forward(self, x):
# 按最后一个维度计算均值和方差
mean = x.mean(-1, keepdim=True)
std = x.std(-1, keepdim=True)
# return self.a_2 * (x - mean) / (std + self.eps) + self.b_2
# 返回Layer Norm的结果
return self.a_2 * (x - mean) / torch.sqrt(std ** 2 + self.eps) + self.b_2
残差连接:每经过一个模块的运算,都要把运算之前的值和运算之后的值相加。训练的时候可以使梯度走捷径
Transformer Encoder整体结构
- 字向量与位置编码:
$$ X = EmbeddingLookup(X) + PositionalEncoding $$
$$ X \in \mathbb{R}^{batch \ size \ * \ seq. \ len. \ * \ embed. \ dim.} $$
- 自注意力机制:
$$ Q = Linear(X) = XW_{Q} $$
$$ K = Linear(X) = XW_{K} $$
$$ V = Linear(X) = XW_{V} 对X做线性映射 $$
$$ X_{attention} = SelfAttention(Q, \ K, \ V) 这里两次矩阵乘法计算复杂度都是O(n^2),如果序列长度n过高,计算量是很大的 $$
- 残差连接与$Layer \ Normalization$
$$ X_{attention} = LayerNorm(X_{attention}) $$
$$ X_{attention} = X + X_{attention} $$
- $FeedForward$,其实就是两层线性映射并用激活函数(比如说$ReLU$)激活:
$$ X_{hidden} = Linear(Activate(Linear(X_{attention}))) $$
Decoder
Decoder中encoder decoder context-attention的输入使用encoder的输出作为K和V,并且在decoder中mask矩阵所要做的是只保留已经翻译的单词的score,所以我们需要生成一个decoder的mask矩阵:
通过encoder的mask矩阵,我们了解到:注意力矩阵中存储的是每个单词与其他所有单词的相似度,比如第一行第一列代表着,第一个单词与第一个单词的相似度,第一行第二列是第一个单词与第二个单词的相似度,通过计算这些相似度的softmax来得到注意力权重的概率分布,与V相乘,从而使每个字向量都含有当前句子内所有字向量的信息。,但在decoder中,我们只能注意已经翻译的(输出的)位置的信息,比如在输出第一个词的时候,我们只能注意到第一行第一列的信息(第一个单词和第一个单词的注意力),其余的信息是无法看到的。如上图所示,第一行只有第一个位置的信息,以此类推。总结的来说,我们通过一个下三角矩阵来实现这样的mask方式。
# 代码仍然是attention函数中的代码
scores = scores.masked_fill(mask==0,-1e9)
LabelSmoothing
decode的输出维度是词典的length,
label smoothing的目标就是提高困惑度,对于输出的分布,从原始的one-hot分布转为在groudtruth使用一个confidence值。
代码中通过LabelSmoothing类实现
optimizer
优化器是以$\beta_1=0.9、\beta_2=0.98$ 和 $\epsilon = 10^{−9}$ 的 $Adam$ 为基础,而后使用一种warmup的学习率调整方式来进行调节。
具体公式如下:
$$ lrate = d^{−0.5}_{model}⋅min(step\_num^{−0.5},\; step\_num⋅warmup\_steps^{−1.5} $$
固定一个warmup_steps先进行学习率的线性增长,达到warmup_steps后,会随着step_num的增长,以step的反平方跟成比例的逐渐减小。
可视化如下:
直观的理解min函数里面两个函数的大小关系
# 对优化器min函数的直观理解
# warump_step 这里设置成4000,变化即为上图的蓝线
warmup_step = 4000
def fst_compont(step):
return step ** (-0.5)
def sec_compont(step):
return step * warmup_step ** (-1.5)
# plt.subplot(1,2,1)
plt.plot(np.arange(1, 20000), [fst_compont(i) for i in range(1, 20000)])
# plt.subplot(1,2,2)
plt.plot(np.arange(1, 20000), [sec_compont(i) for i in range(1, 20000)])
预测
预测首先要使用encoder层学出句子的编码,然后将encoder学出的向量作为decoder的输出,同时还有输出句子的mask矩阵。
评价BELU
https://www.cnblogs.com/by-dream/p/7679284.html
https://pytorch.org/docs/stable/generated/torch.nn.Transformer.html