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整体结构

  1. 字向量与位置编码:

$$ X = EmbeddingLookup(X) + PositionalEncoding $$

$$ X \in \mathbb{R}^{batch \ size \ * \ seq. \ len. \ * \ embed. \ dim.} $$

  1. 自注意力机制:

$$ 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过高,计算量是很大的 $$

  1. 残差连接与$Layer \ Normalization$

$$ X_{attention} = LayerNorm(X_{attention}) $$

$$ X_{attention} = X + X_{attention} $$

  1. $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

End

本文标题:Transformer模型简介-PyTorch

本文链接:https://www.tzer.top/archives/25.html

除非另有说明,本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

声明:转载请注明文章来源。

最后修改:2021 年 12 月 08 日
如果觉得我的文章对你有用,请随意赞赏