电脑基础 · 2023年4月19日

Transformer模型入门详解及代码实现

目录

前言

一.什么是Transformer

二.Encoder的组成

1.输入部分

2.注意力机制

2.1注意力机制的含义

2.2在TRM中的实现

3.前馈神经网络

3.1 前馈神经网络,BP算法,BP神经网络的区别:

3.2Encoder中的前馈神经网络

4 Encoder流程详解

三.Decoder的组成

3.1 带Mask的多头注意力机制

3.1.1 为什么需要Mask处理

3.1.2 如何进行Mask处理

3.2 Encoder与Decoder的交互层

四.Transformer的特点

4.1 并行处理

4.2Encoder与Decoder的联系

4.3 Decoder的输入与输出

五.基于pytorch实现Transformer模型

   5.1 Transformer的三类应用

   5.2 重要代码详解

5.2.1 样本格式

5.2.2 数字索引

 5.2.3 超参数设置

5.2.4 Transformer模型构建

5.2.5 Encoder实现

5.2.6 位置编码的实现 

5.2.7 多头自注意力机制

 5.2.8 前馈神经网络

5.2.9 Decoder


前言

        Transformer模型在各个领域的应用广泛,无论是脱胎于Encoder的BERT,还是从Decoder得到的GPT,都是该模型丰富适用性的体现。本文对Transformer模型的基本原理做了入门级的介绍,意在为读者描述整体思路,而并非拘泥于细微处的原理剖析,并附上了基于PYTORCH实现的Transformer模型代码及详细讲解。


一.什么是Transformer

        一种基于自注意力机制的模型,最初用来完成不规则的文本翻译任务,主体包含Encoder和Decoder部分,分别负责对提取原始句子的意义和将提取出的意义转换为对应的语言,目前应用领域极其广泛。

Transformer模型入门详解及代码实现

        图中左侧的6个Encoder在结构上相同,在具体参数上不同,因此是同时训练6个Enocder而不是只训练1个复制6份,右侧的Decoder也是如此。

二.Encoder的组成

单个Encoder共有三个组成部分。

Transformer模型入门详解及代码实现

1.输入部分

单个单词的输入表示由Embedding和位置编码两部分相加得到。Transformer模型入门详解及代码实现

Embedding是词向量表示降低维度,而位置嵌入则是因为RNN循环网络中的输入具有先后的序列顺序,而Transformer是并行处理句子成分,损失了原始句子的序列顺序,所以引入位置编码表示序列顺序特征。

Transformer模型入门详解及代码实现

2.注意力机制

2.1注意力机制的含义

注意力机制就是找出利用输入向量和原始文本,找出原始文本中对输入向量影响最大的部分。

Transformer模型入门详解及代码实现

三个矩阵Q,K,V:Q是查询序列(输入向量),K是待查序列(原始文本),V是待查序列的自身含义编码序列。Q与K矩阵点积除以根号dk是因为防止点积过大导致梯度消失。

实例如下:

Transformer模型入门详解及代码实现

如果要探究婴儿更关注图片的哪个部分,应当把婴儿(Q矩阵)依次与各个方位(K矩阵)相乘求点积表示婴儿与各个方位之间的相似度,再把这些相似度利用Softmax完成归一化,得到图中的Value部分。将Value部分(数值)分别于矩阵V进行矩阵乘法后再求和就是注意力机制的输出值(一个矩阵)。详细过程如下:

Transformer模型入门详解及代码实现

Query矩阵与K矩阵利用F中的规则求得相似度表示S,再将各个S归一化得到标准相似度a,将a与V相乘求和得到最后的输出值。

2.2在TRM中的实现

2.2.1 由词向量生成Q,K,V矩阵

对于输入向量X1,将其与三个W矩阵分别相乘后得到Q1,K1,V1矩阵。这三个W矩阵均是首先随机初始化,再由神经网络训练得到,神将网络训练的目标就是得到合适的W矩阵。

Transformer模型入门详解及代码实现

2.2.2 矩阵替代向量作为输入

在实际使用中,使用矩阵而不是单个向量作为输入(并行加快速度)

Transformer模型入门详解及代码实现

2.2.3 多头注意力机制(Multi-Head Attention)

即单个Encoder中拥有多套W矩阵,拥有多个输出,要把这些输出合在一起乘以一个矩阵就得到了最后的输出。实验证明多头的效果优秀。

Transformer模型入门详解及代码实现

Transformer模型入门详解及代码实现

2.2.3 残差层 (图中的Add)

通过残差处理,避免了梯度消失

2.2.4 BN与LN (图中的Norm)

该部分的作用是消除量纲的影响,加快模型训练时的收敛速度。

①BN

Batch Normalization是2015年一篇论文中提出的数据归一化方法,往往用在深度神经网络中激活层之前。其作用是消除量纲的影响,加快模型训练时的收敛速度,使得模型训练过程更加稳定,避免梯度爆炸或者梯度消失。并且起到一定的正则化作用,几乎代替了Dropout。但是在NLP领域中BN的适用性很差,因此采用LN。

Transformer模型入门详解及代码实现

BN的核心是对同一个Btach中所有样本的相同维度(某些情况下可表述为同一个特征)进行运算,因此在NLP领域中:

因为输入样本(自然语言语句) 的长度不一定相等,导致BN在很多时候的均值,方差等运算都是只代表了部分甚至单个样本的数据,不符合BN的意义,此时采用BN方法的效果很差。

Transformer模型入门详解及代码实现

② LN

LN的核心是单独对一个样本的所有单词(维度)进行处理。

3.前馈神经网络

3.1 前馈神经网络,BP算法,BP神经网络的区别:

前馈网络和BP网络的区别

3.1.1 前馈神经网络

一种单向多层的网络结构,信息从输入层开始,逐层向一个方向传递,一直到输出层结束。前馈是指输出入方向是前向,此过程不调整权值。神经元之间不存在跨层连接、同层连接,输入层用于数据的输入,隐含层与输出层神经元对数据进行加工。

3.1.2.反向传播算法

(英语:Backpropagation,缩写为BP)是“误差反向传播”的简称,是一种与最优化方法(如梯度下降法)结合使用的,用来训练人工神经网络的常见方法。该方法对网络中所有权重计算损失函数的梯度。这个梯度会反馈给最优化方法,用来更新权值以最小化损失函数。

3.1.3.BP神经网络:

也是前馈神经网络,只是它的参数权重值是由反向传播学习算法调整的。

3.1.4.总结:

前馈描述的是网络的结构,指的是网络的信息流是单向的,不会构成环路。它是和“递归网络”(RNN)相对的概念;

BP算法是一类训练方法,可以应用于FFNN,也可以应用于RNN,而且BP也并不是唯一的训练方法,其他可用的还有比如遗传算法(GA)等。所以BP神经网络属于前馈网络,前馈网络不一定是BP网络(还可以用别的算法训练权值参数)

3.2Encoder中的前馈神经网络

   Encoder中前馈神经网络以下图中的”Feed Forward“存在:

Transformer模型入门详解及代码实现

4 Encoder流程详解

下图比图2-1更详细的说明了Encoder部分的内容:

Transformer模型入门详解及代码实现

①首先输入词向量X1(图中绿色部分),对输入部分进行处理,为X1增加位置编码,保留词序特征。

②其次处理后的X1(浅绿色)经过注意力层后,得到Z1(浅粉红色)。

③将Z1依次进行Add(浅绿色X1与Z1做残差处理)和Norm(LN去量纲处理)后,得到Z1(深粉红色)。

④将Z1(深粉红色)放入前馈神经网络层(Feed Forward),将输出结果依次进行Add(与Z1做残差处理)和Norm后,得到Encoder的输出结果。

三.Decoder的组成

如下图,Decoder也是由多个部分组合而成:主要包括1(带Mask的多注意力机制)和2(Encoder与Decoder的交互层)。

Transformer模型入门详解及代码实现

3.1 带Mask的多头注意力机制

3.1.1 为什么需要Mask处理

因为Decoder部分的输出是Q矩阵,也就是单词的词向量构成的矩阵,这些单词都是预测出的结果。自注意力机制如下图所示,单词的预测将会考虑整个句子中的单词。

Transformer模型入门详解及代码实现

然而在实际的预测阶段中,模型并不能预先得到完整的句子,如果不进行Mask处理,则模型的预测和训练阶段的规则不同统一,模型的效果差。

3.1.2 如何进行Mask处理

如下图所示,对当前单词和之后的单词做遮盖处理(忽视)即可。

Transformer模型入门详解及代码实现

3.2 Encoder与Decoder的交互层

每个Decoder输出的Q矩阵都是经过与所有Encoder交互后得到的:

Transformer模型入门详解及代码实现

交互行为如下:

Decoder从每个Encoder中取出K,V矩阵,之后输出Q矩阵,作为预测的结果。

Transformer模型入门详解及代码实现

四.Transformer的特点

4.1 并行处理

decoder预测时一次一个字,训练的时候可以一次性计算从头到当前字符的向量。并行是相对而言的,对比rnn一次只能处理一个字符,transformer中的注意力机制一次可以处理一整句话,不过decoder的时候还是一次处理一个字符

4.2Encoder与Decoder的联系

注意encoder的输出并没直接作为decoder的直接输入。

encoder的输出包括隐向量以及K /Q /V。decoders层中相较encoders层多了一个encoder-decoder attention模块,其计算跟多头自注意力计算也类似,只是它的Q是前一个decoder层的输出乘上新的参数矩阵进行转换得来的,K, V 则来自于与encoder的输出乘上新的参数矩阵进行转换得来的。仔细想想其实可以发现,这里的交互模块就跟seq2seq with attention中的机制一样,目的就在于让Decoder端的单词(token)给予Encoder端对应的单词(token)“更多的关注(attention weight)”

4.3 Decoder的输入与输出

Transformer模型入门详解及代码实现

五.基于pytorch实现Transformer模型

  5.1 Transformer的三类应用

Transformer模型入门详解及代码实现

 5.2 重要代码详解

5.2.1 样本格式

        代码中默认batch-size为1,因此此时下图中的三句话都只是一个样本。其中P(padding)是编码端的输入,S(strat)是解码端的输入,E(end)是该样本的正确答案,并不是输入。

if __name__ == '__main__':
    #1.数据集导入
    ## 句子的输入部分,
    sentences = ['ich mochte ein bier P', 'S i want a beer', 'i want a beer E']
    #上方的列表是一个样本而不是三个样本,其中P(padding),S(start),E(end)分别代表编码端输入,解码端输入,解码端输入的真实标签(即正确答案)
    # E用来计算与解码端输出结果(训练预测答案)的损失,从而判断模型效果
    #P用来对输入矩阵进行填充,当batch-size大于1时,各样本的句子长度不一定相等,因此通过padding对矩阵的大小进行规范处理

5.2.2 数字索引

        本项目只是进行模型基本功能演示,因此直接对样例句子进行词表构建,之后将对应词语转换为数字,再由数字索引确定BERT训练出的词向量,实现原始词语到词向量的转换。

## 构建词表,将字符与数字对应,更加方便计算机识别
    #编码端词表:
    src_vocab = {'P': 0, 'ich': 1, 'mochte': 2, 'ein': 3, 'bier': 4}
    src_vocab_size = len(src_vocab)
    #解码端词表:
    tgt_vocab = {'P': 0, 'i': 1, 'want': 2, 'a': 3, 'beer': 4, 'S': 5, 'E': 6}
    tgt_vocab_size = len(tgt_vocab)
    #规定编码端和解码端的输入句子长度,也就是输入矩阵的“列”
    src_len = 5 # length of source
    tgt_len = 5 # length of target

 5.2.3 超参数设置

        对词向量维度,前馈神经网络,注意力机制中的Q,K,V矩阵的大小,Encoder和Decoder的堆叠层数,多头注意力机制的头数进行了设置。

## 模型参数设置
    d_model = 512  # Embedding Size,字符转换为词向量的维度
    d_ff = 2048  # FeedForward dimension,前馈神经网络中线性层linear映射到的维度
    d_k = d_v = 64  # dimension of K(=Q), V
    n_layers = 6  # number of Encoder of Decoder Layer,6个Encoder和Decoder
    n_heads = 8  # number of heads in Multi-Head Attention,考虑多头注意力机制时,有8个头

5.2.4 Transformer模型构建

        整体由三部分组成,构建好整体框架后再进行具体的功能实现。

## 1. 从整体网路结构来看,分为三个部分:编码层,解码层,输出层
class Transformer(nn.Module):
    def __init__(self):
        super(Transformer, self).__init__()
        self.encoder = Encoder()  ## 编码层
        self.decoder = Decoder()  ## 解码层
        self.projection = nn.Linear(d_model, tgt_vocab_size, bias=False) ## 输出层 ,d_model 是我们解码层每个token(预测结果)输出的维度大小,
        #之后会做一个 tgt_vocab_size 大小的softmax
        #把预测结果映射进解码端词表里,表现为各个词的概率大小
    ## forward接收输入
    def forward(self, enc_inputs, dec_inputs):
        ## 这里有两个数据进行输入,一个是enc_inputs 形状为[batch_size, src_len],主要是作为编码段的输入,一个dec_inputs,形状为[batch_size, tgt_len],主要是作为解码端的输入
        #编码端的输出:
        ## enc_inputs作为输入 形状为[batch_size, src_len]。
        ## 输出格式由自己的函数内部指定,想要什么指定输出什么,可以是全部tokens的输出,可以是特定每一层的输出;也可以是中间某些参数的输出;
        ## enc_outputs就是主要的输出,enc_self_attns这里没记错的是QK转置相乘之后softmax之后的矩阵值,代表的是每个单词和其他单词相关性;
        enc_outputs, enc_self_attns = self.encoder(enc_inputs)
        # 解码端的输出:
        ## dec_outputs 是decoder主要输出,用于后续的linear映射; dec_self_attns类比于enc_self_attns 是查看每个单词对decoder中输入的其余单词的相关性;
        ## dec_enc_attns是decoder中每个单词对encoder中每个单词的相关性;
        dec_outputs, dec_self_attns, dec_enc_attns = self.decoder(dec_inputs, enc_inputs, enc_outputs)
        ## 解码端输出结果到词表的映射
        ## dec_outputs做映射到词表大小
        dec_logits = self.projection(dec_outputs) # dec_logits : [batch_size x src_vocab_size x tgt_vocab_size]
        return dec_logits.view(-1, dec_logits.size(-1)), enc_self_attns, dec_self_attns, dec_enc_attns

5.2.5 Encoder实现

        按照上文流程图所示,Encoder部分主要可以分为三部分,一是原始输入转换为词向量,二是词向量增加位置编码,三是多头自注意力机制和前馈神经网络部分。

class Encoder(nn.Module):
    def __init__(self):
        super(Encoder, self).__init__()
        self.src_emb = nn.Embedding(src_vocab_size, d_model)  ## 词向量层,这个其实就是去定义生成一个矩阵,大小是 src_vocab_size * d_model
        self.pos_emb = PositionalEncoding(d_model) ## 位置编码层,这部分自己实现,表示位置编码情况,这里是固定的正余弦函数,
        # 也可以使用类似词向量的nn.Embedding获得一个可以更新学习的位置编码
        self.layers = nn.ModuleList([EncoderLayer() for _ in range(n_layers)]) ## 注意力机制和前馈神经网络,
        # 使用ModuleList对多个encoder进行堆叠,因为后续的encoder并没有使用词向量和位置编码,所以抽离出来;
    ## forward接收编码端输入
    def forward(self, enc_inputs):
        ## 这里我们的 enc_inputs(1个输入) 形状是: [batch_size x source_len]
        ## 下面这个代码通过src_emb,根据字符对应的数字进行索引定位,将数字对应的词向量提取出来,构成一个矩阵,enc_outputs输出形状是[batch_size, src_len, d_model]
        ## 总体思路是把输入词语由字符转换为数字,再由数字索引找到对应的词向量
        enc_outputs = self.src_emb(enc_inputs)
        ## 这里就是位置编码,把两者相加放入到了这个函数里面,从这里可以去看一下位置编码函数的实现;3.
        enc_outputs = self.pos_emb(enc_outputs.transpose(0, 1)).transpose(0, 1)
        ## get_attn_pad_mask是为了得到句子中pad的位置信息,并得到一个相同大小的01矩阵,0表示词语,1表示填充的pad;
        ## 之所以要这样,是为了在计算自注意力和交互注意力的时候去掉pad符号的影响,否则会把pad视作句子中的词语研究;去看一下这个函数 4.
        ## 此处是编码端的自注意力机制,因此两个输入都是enc_inputs,在编码端和解码端之间的交互注意力机制中,两个输入不相同
        enc_self_attn_mask = get_attn_pad_mask(enc_inputs, enc_inputs)
        enc_self_attns = []
        ## 多个Encoder和Decoder,因此采用for循环进行
        for layer in self.layers:
            ## 去看EncoderLayer 层函数 5.
            ## 这里的输入分别是上一轮的输出结果,标识句子中pad位置的符号矩阵
            enc_outputs, enc_self_attn = layer(enc_outputs, enc_self_attn_mask)
            enc_self_attns.append(enc_self_attn)
        return enc_outputs, enc_self_attns

5.2.6 位置编码的实现 

Transformer模型入门详解及代码实现

        一般的Transformer模型中,关于位置编码的实现步骤基本相同,首先是拆解原始公式并进行数学公式的化简,之后按照公式进行处理即可。

## 3. PositionalEncoding 代码实现,这部分的实现过程基本固定
class PositionalEncoding(nn.Module):
    ## max_len是句子的最大长度,结合padding对输入矩阵进行规范
    def __init__(self, d_model, dropout=0.1, max_len=5000):
        super(PositionalEncoding, self).__init__()
        ## 位置编码的实现其实很简单,直接对照着公式去敲代码就可以,下面这个代码只是其中一种实现方式;
        ## 从理解来讲,需要注意的就是偶数和奇数在公式上有一个共同部分,我们使用log函数把次方拿下来,方便计算;
        ## pos代表的是单词在句子中的索引,这点需要注意;比如max_len是128个,那么索引就是从0,1,2,...,127
        ##假设我的d_model是512,2i那个符号中i从0取到了255,那么2i对应取值就是0,2,4...510
        self.dropout = nn.Dropout(p=dropout)
        pe = torch.zeros(max_len, d_model)
        position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
        div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))## 表示公式中共有的部分,此处是将原公式进行了变化,利用了对数的运算,无误
        pe[:, 0::2] = torch.sin(position * div_term)## 公式中PE的求取;这里需要注意的是pe[:, 0::2]这个用法,就是从0开始到最后面,补长为2,其实代表的就是偶数位置
        pe[:, 1::2] = torch.cos(position * div_term)## 公式中PE的求取;这里需要注意的是pe[:, 1::2]这个用法,就是从1开始到最后面,补长为2,其实代表的就是奇数位置
        ## 上面代码获取之后得到的pe:[max_len*d_model]
        ## 下面这个代码之后,我们得到的pe形状是:[max_len*1*d_model]
        pe = pe.unsqueeze(0).transpose(0, 1)## transpose实现矩阵的转置,unsqueeze(0)是增加矩阵的维度,此处变为三维矩阵
        '''transpose(X,Y)函数和矩阵的转置是一个意思,相当于行为X轴,列为Y轴,X轴和Y轴调换了位置;
          X轴用0表示,Y轴用1表示;
          例如:如果transport(1,0)表示行与列调换了位置;
            此处交换了x与y轴,否则pe的形状会是[1*max_len*d_model]'''
        '''一、先看torch.squeeze() 这个函数主要对数据的维度进行压缩,去掉维数为1的的维度,比如是一行或者一列这种,一个一行三列(1,3)的数去掉第一个维数为一的维度之后就变成(3)行。
            1.squeeze(a)就是将a中所有为1的维度删掉。不为1的维度没有影响。
            2.a.squeeze(N) 就是去掉a中指定的维数为一的维度。
              还有一种形式就是b=torch.squeeze(a,N) a中去掉指定的维数N为一的维度。
            二、再看torch.unsqueeze()这个函数主要是对数据维度进行扩充。
            给指定位置加上维数为一的维度,比如原本有个三行的数据(3),在0的位置加了一维就变成一行三列(1,3)。a.unsqueeze(N) 就是在a中指定位置N加上一个维数为1的维度。
            还有一种形式就是b=torch.unsqueeze(a,N) a就是在a中指定位置N加上一个维数为1的维度'''
        self.register_buffer('pe', pe)  ## 定一个缓冲区,其实简单理解为这个参数不更新就可以

5.2.7 多头自注意力机制

        需注意,在该部分之前应对pad部分进行标识,构建01符号矩阵,用1标识出pad在K矩阵中的位置,否则多头注意力机制将会视pad为原始语句中的成分,并考虑其的影响。

## 6. MultiHeadAttention
class MultiHeadAttention(nn.Module):
    def __init__(self):
        super(MultiHeadAttention, self).__init__()
        ## 输入进来的QKV是相等的,我们会使用映射linear做一个映射得到参数矩阵Wq, Wk,Wv
        self.W_Q = nn.Linear(d_model, d_k * n_heads)
        self.W_K = nn.Linear(d_model, d_k * n_heads)
        self.W_V = nn.Linear(d_model, d_v * n_heads)
        self.linear = nn.Linear(n_heads * d_v, d_model)
        self.layer_norm = nn.LayerNorm(d_model)
    def forward(self, Q, K, V, attn_mask):
        ## 这个多头分为这几个步骤,首先映射分头,然后计算atten_scores,然后计算atten_value;
        ##输入进来的数据形状: Q: [batch_size x len_q x d_model], K: [batch_size x len_k x d_model], V: [batch_size x len_k x d_model]
        residual, batch_size = Q, Q.size(0)
        # (B, S, D) -proj-> (B, S, D) -split-> (B, S, H, W) -trans-> (B, H, S, W)
        ##下面这个就是先映射,后分头;一定要注意的是q和k分头之后维度是一致额,所以一看这里都是dk
        q_s = self.W_Q(Q).view(batch_size, -1, n_heads, d_k).transpose(1,2)  # q_s: [batch_size x n_heads x len_q x d_k]
        k_s = self.W_K(K).view(batch_size, -1, n_heads, d_k).transpose(1,2)  # k_s: [batch_size x n_heads x len_k x d_k]
        v_s = self.W_V(V).view(batch_size, -1, n_heads, d_v).transpose(1,2)  # v_s: [batch_size x n_heads x len_k x d_v]
        ## 输入进行的attn_mask形状是 batch_size x len_q x len_k,然后经过下面这个代码得到 新的attn_mask : [batch_size x n_heads x len_q x len_k],就是把pad信息重复了n个头上
        attn_mask = attn_mask.unsqueeze(1).repeat(1, n_heads, 1, 1)
        ##然后我们计算 ScaledDotProductAttention 这个函数,去7.看一下
        ## 得到的结果有两个:context: [batch_size x n_heads x len_q x d_v], attn: [batch_size x n_heads x len_q x len_k]
        context, attn = ScaledDotProductAttention()(q_s, k_s, v_s, attn_mask)
        context = context.transpose(1, 2).contiguous().view(batch_size, -1, n_heads * d_v) # context: [batch_size x len_q x n_heads * d_v]
        output = self.linear(context)
        return self.layer_norm(output + residual), attn # output: [batch_size x len_q x d_model]

 5.2.8 前馈神经网络

        该部分实现线性变换即可。

## 8. PoswiseFeedForwardNet
class PoswiseFeedForwardNet(nn.Module):
    def __init__(self):
        super(PoswiseFeedForwardNet, self).__init__()
        self.conv1 = nn.Conv1d(in_channels=d_model, out_channels=d_ff, kernel_size=1)
        self.conv2 = nn.Conv1d(in_channels=d_ff, out_channels=d_model, kernel_size=1)
        self.layer_norm = nn.LayerNorm(d_model)
    def forward(self, inputs):
        residual = inputs # inputs : [batch_size, len_q, d_model]
        output = nn.ReLU()(self.conv1(inputs.transpose(1, 2)))
        output = self.conv2(output).transpose(1, 2)
        return self.layer_norm(output + residual)

5.2.9 Decoder

        Decoder部分的具体思路和Encoder部分大致相同,按流程图理解即可,值得注意的是:在Decoder部分,进行了两次mask处理,一是对目标词之后的词语进行掩盖处理以达到更好的预测效果,二是对pad进行掩盖处理;Decoder部分的交互注意力机制的Q矩阵来自自身,而K矩阵来自Encoder部分。