分享

Transformer算法完全解读

 张可卿 2023-06-15 发布于上海

 

  2017年6月谷歌发布论文《Attention is All You Need》,提出Transformer这一引燃机器学习领域的算法。数年过去,Transformer算法在计算机视觉、自然语言处理等众多应用领域展现了极为惊艳的表现。

  大家都是神经网络,为何你的腰椎间盘却如此突出?

  可以说,Transformer是完全基于自注意力机制的一个深度学习模型,且适用于并行化计算,导致它在精度和性能上都要高于之前流行的RNN循环神经网络。

  在接下来的篇幅中,我们来详细梳理Transformer算法各个细枝末节原理,并结合B站视频教程:【Transformer为什么比CNN好!】中的代码实现,展示Transformer的整个建模流程。

1 Transformer整体结构

  如图1所示,是Transformer结构图。乍一看,不明觉厉,顿时被劝退!别急!我们一步一步对图中每一个组件进行归类划分,并以汉译英模型为例结合数据流程进行说明。

  从功能角度上,我将Transformer各个组件划分到四个功能模块,对应于图1中4中颜色框框选部分:输入模块(绿色框框)、编码器模块(红色框框)、解码器模块(蓝色框框)、输出模块(黑色框框)。

  第一步,我们将词库中每一个词(中文就是字了,为了更大众化地表达,我们就称之为词吧)以及开始标志转化为嵌入词向量,词嵌入向量的维度为embedding dimension,一句话中总共的单词长度为sequence length,因此可以得到一个sequence_length × embedding_dimension大小的矩阵,其中每一行代表的是一个词。有了词嵌入矩阵还不够,还需要在矩阵中添加位置编码,随后将矩阵传入编码器模块。这一系列操作,就是输入模块负责的功能。输入模块有两个部分,功能实现是一模一样的,区别在于一个是对源数据进行编码,也就是我们现在第一步中所做的操作,另一个是对目标数据进行编码。

  第二步,编码器模块接收到输入模块传输过来的矩阵后,要完成多头注意力计算、规范化、前向传播等等一系列操作,而且这些操作还不止进行一次(8次),最终将输出结果传入解码器模块。编码器模块是Transformer模型最为核心的部分,创新使用的多头注意力机制是Transformer算法性能突出的关键。

  第三步,接下来,输出矩阵从编码器传到了解码器模块,我们姑且称这个矩阵为矩阵A吧。解码器接收到矩阵A后,解码器对应的输入模块也开始工作,这个输入模块会对目标数据进行词嵌入、添加位置编码等一些列操作,形成另一个矩阵,我也给它命一个名,叫矩阵B吧。矩阵B进入解码器后,进行掩码,再进行一次注意力计算,然后往前传递,与矩阵A“胜利会师”。会师后,两者共同进行注意力计算,生成新的矩阵,并进行前向传播和规范化。进行一系列类似操作后,将矩阵传入输出模块。从这里可以看出,解码器模块,是有两个输入的,一个来自于编码器,一个来自于输入模块对目标数据的编码。

  第四步,这是最后一步了,输出模块对解码器模块的输出矩阵进行一次线性变换,然后通过softmax层转换为概率分布矩阵,矩阵中概率最大值对应的英文词汇为这一次传输的输出结果,即翻译结果。

  上述整体流程介绍,我们提到了许多概念和操作,例如词嵌入、位置编码、多头注意力机制等等。接下来,我们再对各功能模块进行细分,对每一个组件进行说明,并对涉及到的概念,操作原理进行分析。

image

图1 Transformer结构图

 为保证后续各组件代码能够正常运行,我们先导入所有需要用到的模块:

In [1]:
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.autograd import Variable
import math
import matplotlib.pyplot as plt
import numpy as np
import copy
import warnings
warnings.filterwarnings("ignore")

2 输入模块

  Transformer有两个输入模块,两者的区别在前文已经提到,就是编码的数据有所不同,但原理、功能实现上是一模一样的,所以就不在分开介绍了。接下来,我们从最原始的词的向量表示说起。

2.1 词嵌入:Word Embedding层

  在自然语言处理模型中,需要将文本符号转化为数学表示,即特征向量。将词汇表示成特征向量的方法有多种:

  • One-hot编码

  One-hot编码使用一种常用的离散化特征表示方法,在用于词汇向量表示时,向量的列数为所有单词的数量,只有对应的词汇索引为1,其余都为0。例如,“我爱我的祖国”这句话,总长为6,但只有5个不重复字符,用One-hot表示后为6×5的矩阵,如图1所示。但是但这种数据类型十分稀疏,即便使用很高的学习率,依然不能得到良好的学习效果。

image

图2 One-hot编码
  • 数字表示

  数字表示是指用整个文本库中出现的词汇构建词典,以词汇在词典中的索引来表示词汇。所以,我认为,与其叫做“数字表示”,还不如叫“索引表示”。例如,“我爱我的祖国”这句话,就是我们整个语料库,那么整个语料库有5个字符,假设我们构建词典{'我':0, '爱':1, '的':2, '祖':3, '':4},“我爱我的祖国”这句话可以转化为向量:[0, 1, 0, 2, 3, 4]。如图3所示。这种方式存在的问题就是词汇只是用一个单纯且独立的数字表示,难以表达出词汇丰富的语义。

image

图3 词汇数字表示
  • word2vec

  word2vec将给定词表中的词映射到低维稠密的向量空间中,在这个空间中语义相同的词距离较近,例如,表示“国家”与“祖国”两个词汇的向量之间的距离要比“西红柿”与“祖国”两个词汇对应向量的距离要小的多。 从这个角度上说,One-hot编码以及数字表示方式更加侧重于对词汇字符本身的表示,而word2vec要侧重于对词汇语义的表示。这就解决了one-hot编码所带来的维度灾难和语义缺失现象。然而,word2vec仍无法解决一词多义 的表征,例如,“我一边用苹果手机看电影,一并吃着苹果”,这句话中出现两个“苹果”,但是在word2vec只会用一个词向量来表示“苹果”一词,后续的bert很好的解决了这个问题。

  本文重点不是介绍词汇向量表示,不在例举。假设我们已经完成了词汇的向量表示,例如,我们使用“数字表示”的方式,将语料库中所有文本转化为了向量,那么接下来,就要进行Embedding操作。为什么需要进行Embedding呢?其实就是为了解决上述各种词汇向量表示的不足:过于稀疏、低维向量空间不能表达语义、表达不了一次多义等。 经过Embedding后,词汇转化为指定维度的向量,且相似的语义词汇在向量空间中距离更近。关于Embedding原理,可以阅读这篇文章

  在Pytorch中,提供有专门的Embedding功能高层API,直接使用即可。

In [12]:
class Embedding(nn.Module):
    # embedding层
    def __init__(self, d_model, vocab):
        # d_model: 词嵌入的维度(转换后获得的词向量的维度)
        # vocab:词表 的大小
        super(Embedding, self).__init__()
        # 定义Embedding层
        self.lut = nn.Embedding(vocab, d_model)
        # 将参数传入类中
        self.d_model = d_model
    
    def forward(self, x):
        # x:代表输入进模型的文本通过词汇映射后的数字张量
        return self.lut(x) * math.sqrt(self.d_model)  # 陈尚d_model的开根号是为了对最后结果进行一个缩放

我们尝试使用这个Embedding类,对上文中“我爱我的祖国”这句文本数字表示后的向量[0, 1, 0, 2, 3, 4],进行Embedding。当然,因为此时的Embedding没有经过任何训练,初始化参数是完全随机的,所以基本不存在什么相似的语义表示:

In [93]:
x = Variable(torch.LongTensor([[0, 1, 0, 2, 3, 4]])) 
emb = Embedding(4, 5)  # 语料库共有5个词,每个词转化为3维向量表示
embr = emb(x)
print("embr:", embr)
print("shape:", embr.shape)
embr: tensor([[[-0.9873,  1.2457,  0.7959,  0.3183],
         [-2.2986,  1.9885,  0.4855, -2.1126],
         [-0.9873,  1.2457,  0.7959,  0.3183],
         [ 0.8325,  2.1043,  0.4781, -2.9356],
         [-1.2173,  2.6160, -0.6300, -2.0037],
         [-0.7444,  2.6920,  4.4916, -2.0474]]], grad_fn=<MulBackward0>)
shape: torch.Size([1, 6, 4])

  最终输出张量的shape为什么为[1, 6, 4]呢?[1, 6, 4]分别对应[batch size, sequence length, embedding dimension],也就是批次大小、每个句子长度(每个句子词的数量)、词嵌入维度(每个词用多少位数字表示)。在本示例中,因为只有一个句子,所以batch size为1,句子中有6个词,所以sequence length为6,embedding dimension是我们预先设置好的值4,也就是Embedding类实例化时的第一个参数。

2.2 位置编码器:Position Embedding层

  无论是那种语言,中文也好,英语也罢,词汇位置(顺序)的差异,可能导致语义上的天差地别。例如,“我欠银行一个亿”与“银行欠我一个亿”,两句话中每个单独的词汇拎出来语义上都么有任何区别,但是,整句话表达出来的意思,一个天堂,一个地狱。所以,在上文Embedding向量基础上,添加位置信息,可以进一步提升模型性能。

  添加位置信息有两种方式:

  1. 通过网络来学习;

  2. 预定义一个函数,通过函数计算出位置信息;

  在《Attention is all you need》论文原文中表示,两种方式效果基本一致,但第二种方式可以减少模型参数量,同时还能适应即使在训练集中没有出现过的句子长度,所以使用第二种方式。并给出计算位置信息的函数公式:

PE(pos,2i)=sin(pos100002id)
PE(pos,2i+1)=cos(pos100002id)

  式中,pos表示词汇在句子中的位置,d表示词向量维度,也就是上文Embedding类中的d_model,2i代表的是d中的偶数维度,2i+1则代表的是奇数维度,这种计算方式使得每一维都对应一个正余弦曲线。

  为什么使用三角函数呢?

  由于三角函数的性质: sin(a+b)=sin(a)cos(b)+cos(a)sin(b)cos(a+b)=cos(a)Cos(b)sin(a)sin(b),于是,对于位置pos+k 处的信息,可以由 pos 位置计算得到,作者认为这样可以让模型更容易地学习到位置信息。

  这种方式编码又为什么能够代表不同位置信息呢?

  由公式可知,每一维i都对应不通周期的正余弦函数:i=0时是周期为2π的正弦函数,i=1时是周期为2π的余弦函数,对于不同位置的词汇pos1pos2,若它们在某一维度i上有相同的编码值,则说明这两个位置的差值等于该维度所在的曲线的周期,即|pos1pos2|=Ti。 而对于另一个维度j(j1,由于TjTi,因此pos1pos2在这个维度j上的编码值就不会相等。因此,种编码方式保证了不同位置在所有维度上不会被编码到完全一样的值,从而使每个位置都获得独一无二的编码。

  最后,将上一Embedding步骤输出的向量与本次的PE向量(Position Embedding)相加即可,如图4所示。 image

图4 位置编码

  为什么是将PE与词向量相加,而不是拼接呢?

  其实,拼接相加都可以,一般来说词向量的维度就已经蛮大了,再拼接一个相同维度大小的位置向量,维度顿时提升一倍,这样训练起来会相对慢一些,影响效率。两者的效果是差不多地,既然效果差不多当然是选择学习习难度较小的相加了。

  关于位置编码,更加详细的介绍,可以参看这里

In [26]:
class PositionalEncoding(nn.Module):
    def __init__(self, d_model, dropout, max_len=5000):
        """
        位置编码器类的初始化函数
        d_model:词嵌入维度
        dropout:置0比率
        max_len:每个句子的最大长度
        """
        super(PositionalEncoding, self).__init__()
        
        # 实例化nn中预定义的Dropout层, 并将dropout传入其中,获得对象self.dropout
        self.dropout = nn.Dropout(p=dropout)
        
        # 初始化一个位置编码矩阵,他是一个0矩阵,矩阵的大小是  max_len * d_model  
        pe = torch.zeros(max_len, d_model)
        
        # 初始化一个绝对位置矩阵,在我们这里,词汇的绝对位置就是用它的索引去表示
        # 所以我们首先使用arange方法获得一个连续自然数向量,然后在使用unsqueeze方法拓展矩阵维度
        # 又因为参数传的是1, 代表矩阵拓展的位置,会使向量编程一个max_len * 1的矩阵。
        position = torch.arange(0, max_len).unsqueeze(1)
        # 绝对位置矩阵初始化之后,接下来就是考虑如何将这个位置信息加入到位置编码矩阵中
        # 最简单方法就是现将max_len * 1的绝对位置矩阵,变换成max_len * d_model形状, 然后直接覆盖原来的初始化位置编码矩阵即可。
        # 要实现这种矩阵变化,就需要一个1 * d_model形状的变化矩阵div_term,我们对这个变化矩阵的要求除了形状外,
        # 还希望他能够将自然数的绝对位置编码缩放层足够小的数字,有助于在之后的梯度下降过程中更快地收敛,这样我们就可以开始初始化
        # 得到一个自然数矩阵,但是我们这里并没有按预计的一样的初始化一个1 * d_model的矩阵
        # 只初始化了一半,即x*d_model/2的矩阵, 这是因为这里并不是真正意义的初始化了一半的矩阵
        # 我们可以把它看作初始化了两次,而两次初始化的变化矩阵不同的处理
        # 并把这两个矩阵分别填充在位置编码矩阵的偶数和奇数位置上,组成最终的位置编码矩阵。
        
        div_term = torch.exp(torch.arange(0, d_model, 2) * -(math.log(1000.0)/ d_model))
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)
        # 这样我们就得到了位置编码矩阵pe, pe现在还只是一个二维矩阵,要想和embeddding的输出(一个三维矩阵)
        # 就必须坨镇一个维度,所以这里使用unsequeeze拓展维度
        pe = pe.unsqueeze(0)  # 将二维蟑螂扩充为三维张量
        # 最后把pe位置编码矩阵注册层模型的buffer,什么是buffer呢
        # 我们把它认为是对模型效果有帮助的,但是却不是模型结构中超参数或者参数,不需要说着优化步骤进行更新的增益
        # 注册之后我们就可以在模型保存后重新加载时盒模型结构与参数已通被加载
        self.register_buffer('pe',  pe)
        
    def forward(self, x):
        """
        forward函数的桉树是x,表示文本序列的词嵌入表示
        """
        # 在相加之前我们对pe做一些适配工作,将这个三维张量的第二位也就是句子最大长度的那一维度切片与输入的x的第二维相同即x.size(1)
        # 因为我们默认max_len为5000,一般来讲是在太大了,很难有一个句子包含5000词汇,所以要进行与输入转给你来那个的适配
        # 最后使用Variable进行封装,使其与x的样式相同,但是他是不需要进行梯度求解的,因为这里的位置信息使用的是函数计算方式,且这一步中只是数字相加,因此把requuires_grad设置成False。
        
        x = x + Variable(self.pe[:, :x.size(1)], requires_grad=False)
        # 最后就使用self.dropout对象惊喜'丢弃’操作,并返回结果
        return self.dropout(x)
In [96]:
pe = PositionalEncoding(4, dropout, 1000)
pe_result = pe(embr)
print("pe_result:",  pe_result)
print("shape: ", pe_result.shape)
pe_result: tensor([[[-1.2341,  2.8071,  0.9948,  1.6479],
         [-1.8214,  3.1610,  0.6464, -1.3914],
         [-0.0975,  0.0000,  0.0000,  1.6454],
         [ 1.2171,  1.3929,  0.7160, -2.4252],
         [-2.4677,  0.0000, -0.6298, -1.2646],
         [-2.1292,  3.7195,  5.8113, -0.0000]]], grad_fn=<MulBackward0>)
shape:  torch.Size([1, 6, 4])

  添加位置编码的过程只是两个矩阵相加,并不会改变输出结果的shape,所以输出结果的shape依然是[1, 6, 4]。

3 解码器模块

  解码器模块是Transformer最为核心的一部分,接下来,本文将花大量篇幅对解码器模块进行介绍,其中包括的掩码张量、注意力机制、前馈全连接层、规范化层、子层连接结构等诸多组件在解码器模块中也有用到,但功能和代码实现上是一样的,所以在后续解码器章节中不在介绍,直接使用。

3.1 掩码张量

  Transformer中,掩码张量的主要作用在后续的注意力机制计算过程中,有一些生成的attention张量中的值计算有可能已知了未来信息而得到的,未来信息被看到是因为训练时会把整个输出结果都一次性进行Embedding,但是理论上解码器的的输出却不是一次就能产生最终结果的,而是一次次通过上一次结果综合得出的,因此,未来的信息可能被提前利用。所以,Attention中需要使用掩码张量掩盖未来信息。

  我们可以这么来理解掩码张量的作用:我们建模目的,就是为了达到预测的效果,所谓预测,就是利用过去的信息(此前的序列张量)对未来的状态进行推断,如果把未来需要进行推断的结果,共同用于推断未来,那叫抄袭,不是预测,当然,这么做的话训练时模型的表现会很好,但是,在测试(test)时,模型表现会很差。

  如何进行遮掩呢?我们可以创建一个与需要被遮掩张量相同size的张量,即掩码张量,掩码张量中元素一般只有0和1,代表着对应位置被遮掩或者不被遮掩,至于是0位置被遮掩还是1位置被遮掩可以自定义,因此它的作用就是让另外一个张量中的一些数值被遮掩,或者说被替换。一般来说,是被替换成一个非常小的数字(负无穷)。为什么用非常小的数字呢?掩码操作一般是在计算注意力得分后,softmax操作之前,被遮掩就是为了最小化被分配的注意力,那么被遮掩的位置替换成一个非常小的数值,下一步进行softmax操作后,被遮掩的位置就成了一个趋近于0的概率时,即分配的注意力趋近于0,也就达到了被遮掩的目的。

  下面我们尝试使用pytorch实践掩码的原理:

In [124]:
atten_data = torch.randint(low=0, high=10, size=(4, 5))
atten_data
Out[124]:
tensor([[5, 9, 0, 4, 4],
        [7, 4, 5, 5, 0],
        [4, 9, 4, 5, 6],
        [0, 2, 6, 2, 6]])
In [125]:
mask = np.ones(shape=(4, 5))
mask
Out[125]:
array([[1., 1., 1., 1., 1.],
       [1., 1., 1., 1., 1.],
       [1., 1., 1., 1., 1.],
       [1., 1., 1., 1., 1.]])
In [126]:
# 假设只遮掩下三角部分,那么将mask的下三角部分修改为0
mask = np.triu(mask, k=1)
mask = torch.from_numpy(mask)
mask 
Out[126]:
tensor([[0., 1., 1., 1., 1.],
        [0., 0., 1., 1., 1.],
        [0., 0., 0., 1., 1.],
        [0., 0., 0., 0., 1.]], dtype=torch.float64)
In [127]:
# 被遮掩后的张量
atten_data.masked_fill(mask==1,-1e9)
Out[127]:
tensor([[          5, -1000000000, -1000000000, -1000000000, -1000000000],
        [          7,           4, -1000000000, -1000000000, -1000000000],
        [          4,           9,           4, -1000000000, -1000000000],
        [          0,           2,           6,           2, -1000000000]])

  接下来,实现Transformer中的掩码:

In [65]:
def subsequent_mask(size):
    """
    生成向后遮掩的掩码张量,参数size是掩码张量最后两个维度的大小,最后两维形成一个方阵
    """
    # 在函数中,首先定义掩码张量的形状
    attn_shape = (1, size, size)
    # 然后使用np.ones方法想这个形状中添加1元素,形成上三角阵,最后为了节约空间
    # 再使用其中的数据类型变为无符号8为整型unit8
    sub_mask = np.triu(np.ones(attn_shape), k=1).astype('uint8')
    # 最后将numpy类型转化为torch中的tensor,内部做一个1 - 的操作
    # 在这个其实是做了一个三角阵的反转,subsequent_mask中的每个元素都会被1减,
    # 如果是0, subsequent_mask中的该位置由0变1
    # 如果是1, subsequent_mask中的该位置由1变0
    return torch.from_numpy(1 - sub_mask)
In [152]:
mask = subsequent_mask(4)
mask
Out[152]:
tensor([[[1, 0, 0, 0],
         [1, 1, 0, 0],
         [1, 1, 1, 0],
         [1, 1, 1, 1]]], dtype=torch.uint8)

3.2 注意力机制

  什么是注意力?

  我们观察事物时,之所以能够快速做出判断,是因为我们大脑能够将注意力能够以高分辨率接收于图片上的某个区域,并且以低分辨率感知其周边区域,并且视点能够随着时间而改变,换句话说就是人眼通过快速扫描全局图像,找到需要关注的目标区域(在阅读文本时就是快速扫描全文,然后找到关键段落、关键词),然后对这个区域分配更多注意,目的在于获取更多细节信息和抑制其他无用信息,从而快速作出判断,而并非是从头到尾的观察一遍事物后,才能有判断结果。

  正是基于人类大脑这一特点,进行仿生,从而提出了深度学习中的注意力机制。在神经网络中,注意力机制可以认为是一种资源分配的机制,可以理解为对于原本平均分配的资源根据对象的重要程度重新分配资源,重要的单位就多分一点,不重要或者不好的单位就少分一点。

  怎么将这种能力迁移到计算机上呢?

  我们通过一个类比来说明:我们需要去定做一件衣服,想好需要的衣服后,我们去到服装店,把我们对衣服的关键性描述(queryQ)告诉老板,例如“男士衬衫、格子衬衫、纯棉”,随后,老板在脑海里将我们的描述与店里所有衣服样品(valueV)的描述(keyK)进行对比,然后拿出相对更加匹配的样品给我们看,看了之后,我们就发现,有些衣服有50%(权重)符合我们的心意,有些衣服只有20%符合我们心意,难道我们选择最满意的一件吗?不是,我们告诉老板,这件样品,你取这50%的特点,那件样品,取另外20%的特点,直到凑成100%(加权平均的过程),也就是完整衣服的特征,最后那件凑成的衣服,就是我们想要的衣服(注意力值)。

  Transformer算法中的注意力机制,跟这个定做衣服的过程是很类似的。这里有三个很关键的概念,也就是上面提到的query(来自源数据)、key(来自目标数据)、value(来自目标数据),这里用“来自”这个词有些不太准确,因为querykeyvalue是通过源数据、目标数据(都是矩阵)与不同的矩阵(WQ, WK, WV)相乘得到,放在神经网络中就是经过线性层变换。querykeyvalue三者之间是存在联系的,联系越紧密(越相似),那么权重就越大,最终获得的注意力就越多,所以,怎么来评判它们之间的相似度就很关键了。最简单的,就是使用余弦相似度,但是这里,我们更多的是使用点积的方式,两个向量越相似,点积就越大。获得点积之后,进行softmax操作,然后再与value矩阵进行加权求和,就获得了最终整个序列的注意力值。整个过程如图5所示。公式表示如下:

Attention(Q,K,V)=softmax(QKTdk)

  式中,dk为输入样本维度数,除以dk是为了对最终注意力值大小进行规范化,使注意力得分贴近于正态分布,有助于后续梯度下降求解。

image

图5 注意力机制

  Transformer中的注意力机制又不仅于此,Transformer还是用了一种自注意力机制,这种注意力机制中querykeyvalue三者都是同一矩阵经过变化得来,当然,再简化一点,直接使用初始数据矩阵作为querykeyvalue也不是不行。

  这个过程很重要,是Transformer的核心,我们再用更加数学化的方式表述一遍。假设存在序列数据集X={x1,x2,x3,,xT}(可以认为x1是文本中的第一个词),X经过词嵌入和位置编码后,转为为{a1,a2,a3,,aT},我们分别使用WQ, WK, WV与之相乘,获得qikivii(1,2,3,,T)。以x1为例,如何获得x1的注意力值呢?

  • 首先,我们用x1对应的queryq1k1,k2,k3,,kT计算向量点积, 得到α11,α12,α12,,α1T。注意,这时候的,α11,α12,α12,,α1T在取值范围上,可未必在[0, 1]之间,还需要经过softmax处理;

  • 然后,将α11,α12,α12,,α1T输入softmax层,从而获取值在[0, 1]之间的注意力权重值,即α^11,α^12,α^12,,α^1T,这相当于一个概率分布矩阵;

  • 最后,将α^11,α^12,α^12,,α^1T分别与对应的v1,v2,v3,,vT相乘,然后求和,这样便获得了与输入的x1相对应的注意力值b1

  经过注意力机制层后,输出矩阵中的每个词向量都含有当前这个句子中所有词的语义信息,这对提升模型性能是极为关键的。

  注意力机制实现过程代码如下所示:

In [70]:
def attention(query, key, value, mask=None, dropout=None):
    """
    注意力机制的实现,输入分别是query,key,value,mask:掩码张量,
    dropout是nn.Dropout层的实例化对象,默认为None
    """
    # 在函数中,首先去query的最后一维的大小,一般情况下就等同于我们的词嵌入维度,命名为d_k
    d_k = query.size(-1)
    # 按照注意力公式,将query与key的转置相乘,这里面key是将最后两个维度进行转置(转置后才满足矩阵乘法),key转置之后shape为[1, 4, 6],[1, 6, 4] * [1, 4, 6] = [1, 6, 6]
    # 再除以缩放系数,就得到注意力得分张量scores
    scores = torch.matmul(query, key.transpose(-2, -1)) // math.sqrt(d_k)
    # 接着判断是否使用掩码张量
    if mask is not None:
        # 使用tensor的masked_fill方法,将掩码张量和scores张量每个位置一一比较,如果掩码张量
        # 则对应的scores张量用-1e9这个值来替换
        scores = scores.masked_fill(mask == 0, -1e9)
    # 对scores的最后一维进行softmax操作,使用F.softmax方法,第一个参数softmax对象,第二个
    # 这样获得最终的注意力张量
    p_attn = F.softmax(scores, dim=-1)
    # 之后判断是否使用dropout进行随机置零
    if dropout is not None:
        # 将p_attn传入dropout对象中进行“丢弃”处理
        p_attn = dropout(p_attn)
    #最后,根据公司将p_atten与value张量向曾获得最终的query注意力表示,同时返回注意力权重张量
    return torch.matmul(p_attn, value), p_attn
In [79]:
pe_result
Out[79]:
tensor([[[-3.1567,  2.5706, -3.2771,  1.9592],
         [ 1.1270, -0.6587, -0.0000, -1.9875],
         [-2.1464,  0.9971, -3.2068,  0.0000],
         [ 0.6572,  1.2629,  4.3154,  0.6823],
         [-4.4194, -2.4844,  0.1543,  0.0000],
         [-0.0000,  1.0162, -0.9905, -0.6031]]], grad_fn=<MulBackward0>)
In [71]:
query = key = value = pe_result
In [74]:
attn, p_attn = attention(query, key, value)
In [99]:
p_attn  # 注意力权重分布矩阵,矩阵中每一行的和为1
Out[99]:
tensor([[[9.9752e-01, 5.5889e-09, 2.4726e-03, 2.0560e-09, 6.1290e-06,
          2.2547e-06],
         [1.8057e-03, 7.2848e-01, 3.6269e-02, 9.8589e-02, 3.6269e-02,
          9.8589e-02],
         [8.7814e-01, 3.9867e-05, 1.1884e-01, 2.6862e-07, 2.1767e-03,
          8.0076e-04],
         [3.0588e-07, 4.5397e-05, 1.1253e-07, 9.9993e-01, 6.1438e-06,
          1.6701e-05],
         [1.2338e-04, 2.2598e-06, 1.2338e-04, 8.3132e-07, 9.9975e-01,
          2.2598e-06],
         [3.8420e-01, 5.1996e-02, 3.8420e-01, 1.9128e-02, 1.9128e-02,
          1.4134e-01]]], grad_fn=<SoftmaxBackward0>)
In [100]:
attn  # 注意力值
Out[100]:
tensor([[[-3.1542e+00,  2.5667e+00, -3.2769e+00,  1.9543e+00],
         [ 6.4197e-01, -3.0448e-01,  2.1117e-01, -1.4365e+00],
         [-3.0367e+00,  2.3713e+00, -3.2593e+00,  1.7199e+00],
         [ 6.5716e-01,  1.2628e+00,  4.3150e+00,  6.8218e-01],
         [-4.4189e+00, -2.4833e+00,  1.5346e-01,  2.3644e-04],
         [-2.0508e+00,  1.4568e+00, -2.5456e+00,  5.7720e-01]]],
       grad_fn=<UnsafeViewBackward0>)
In [101]:
p_attn.shape
Out[101]:
torch.Size([1, 6, 6])
In [102]:
attn.shape
Out[102]:
torch.Size([1, 6, 4])

  注意力权重矩阵p_attn的shape为[1, 6, 6],与shape为[1, 6, 4]的value相乘后,输出结果的shape回到了[1, 6, 4],并未发生变化。

3.3 多头注意力机制

  上文说到,自注意力机制中querykeyvalue都是通过同一数据做矩阵变换获得,这就会造成一个问题:模型在对当前位置的信息进行编码时,会过度的将注意力集中于自身的位置。换句话说就是自己与自己的相似度肯定很高,从而获得极高的注意力,而忽略其他内容。这显然并不合理。Transformer作者采取的一种解决方案就是采用多头注意力机制(MultiHeadAttention)。同时,使用多头注意力机制还能够给予注意力层的输出包含有不同子空间中的编码表示信息,从而增强模型的表达能力(从不同角度去观察,获得更加丰富的信息)。

  多头注意力机制为什么有效呢?我们也可以类比理解一下,让一个同学阅读一篇文章60分钟,对比让6个同学每个阅读10分钟,然后汇总多个同学的理解那种方式获取到的信息多呢?应该是第二种,每个人的思维方式、角度总是不一样的,从多种角度看问题获取到的信息更多。多头注意力自己就是这么个思路。

  那么,多头注意力机制怎么实现的呢?使用多头注意力机制后,我们需要将querykeyvalue分割为h份,h为头的数量,分割前querykeyvalue的shape为[batch size, sequence length, embedding dimension],分割后,shape为[batch size, sequence length, h, embedding dimension / h], 为了方便后续运算,我们对sequence length, h这两个维度进行转置,转置后shape为[batch size, h, sequence length, sequence length],再softmax后与value相乘,输出shape为[batch size, h, sequence length, embedding dimension / h],reshape后,shape回到[batch size, sequence length, embedding dimension],依然是最初的shape。

  这里之所以不厌其烦地多次提到不同阶段shape,是因为从shape的变换上,可以看出整个多头注意力机制的过程。很多材料上说,多头注意力机制就是使用多组querykeyvalue计算注意力,我认为这种说法不准确,应该说是对querykeyvalue进行划分,获得多组更小的querykeyvalue,获得多组注意力进行组合。可以参考这篇文

In [81]:
# 首先需要定义克隆函数,因为在多头注意力机制的实现中,用到多个结构相同的线形层
# 我们将使用clone函数将他们已通初始化在同一个网络层列表对象中,之后的结构中也会用到该函数
def clone(model, N):
    # 用于生成相同网络层的克隆函数,它的参数module表示要克隆的目标网络层,N代表需要克隆的数量
    # 在函数中,我们通过for循环对module进行N次深度拷贝,使其每个module称为独立的层
    return nn.ModuleList([copy.deepcopy(model) for _ in range(N)])
In [120]:
# 我们使用一个类来实现多头注意力机制的处理
class MultiHeadAttention(nn.Module):
    def __init__(self, head, embedding_dim, dropout=0.1):
        """
        在类的初始化时,会传入三个参数,head代表头数,embedding_dim代表词嵌入的维度,dropout代表进行dropout操作时置零比率,默认是0.1
        """
        super(MultiHeadAttention, self).__init__()
        # 在函数中,首先使用了一个测试中常用的assert语句,判断h是否能被d_model整除
        # 这是因为我们之后要给每个头分配等量的词特征,也就是embedding_dim//head个
        assert embedding_dim % head == 0
        self.head = head  # 传入头数
        self.embedding_dim = embedding_dim
        self.dropout = nn.Dropout(p=dropout)
        self.d_k = embedding_dim // head  # 得到每个头获得的分割词向量维度d_k
        # 然后获得线形层对象,通过nn的Linear实例化,它的内部变化矩阵是embedding_dim * Embedding_dim
        self.linears = clone(nn.Linear(embedding_dim, embedding_dim), 4)
        self.attn = None
    
    def forward(self, query, key, value, mask=None):
        """前向逻辑函数,它的输入参数有4个,前三个就是注意力机制需要的Q、K、V,
        最后一个是注意力机制中可能需要的mask掩码张量,默认是None"""
        if mask is not None:  # 如果存在掩码张量
            mask = mask.unsqueeze(0)  # 使用unsqueeze拓展维度,代表多头中的第n头
        # 接着,我们获得一个batch_size的变量,它是query尺寸的第1个数字,代表有多少条样本
        batch_size = query.size(0)
        # 之后就进入多头处理环节
        # 首先利用zip将输入QKV与三个线形层组到一起,然后使用for循环,将输入QKV分别传入到线形层中
        # 完成线性变换后,开始为每个头分割输入,这里使用view方法对线性变化的结果进行维度重塑
        # 这样就意味着每个头可以获得一部分词特征组成的句子,其中的-1代表自适应维度
        # 计算机会根据这种变换自动计算这里的值,然后对第二维和第三维进行转置操作
        # 为了让代表句子长度维度和词向量维度能够相邻,这样注意力机制才能找到迟疑与句子位置的关系
        # 从attention函数中可以看到,利用的是原始输入的倒数第一和第二,这样我们就得到了每个头的
        print('----------------------------------')
        print('query-before transpose: ', query.shape)
        print('key-before transpose: ', key.shape)
        print('value-before transpose: ', value.shape)
        # 此时,query, key, value的shape为[1, 6, 4], [batch size, sequence length, embedding dimension]
        query, key, value =                [model(x).view(batch_size, -1, self.head, self.d_k).transpose(1, 2) 
                for model, x in zip(self.linears, (query, key, value))]
        print('----------------------------------')
        print('query-after transpose: ', query.shape)
        print('key-after transpose: ', key.shape)
        print('value-after transpose: ', value.shape)
        print('----------------------------------')
        # 此时,query, key, value的shape修改为:[1, 2, 6, 2] , [batch size, sequence length, h, embedding dimension / h]
        # 得到每个头的输入后,接下来就是将他们传入attention中
        # 这里直接调用我们之前实现的attention函数,同时也将mask和dropout传入其中
        x, self.attn = attention(query, key, value, mask, self.dropout)
        print('x- after attention: ', x.shape)  # 经过注意力机制后,输出x的shape为:[1, 2, 6, 2], [batch size, h, sequence length, embedding dimension / h]
        # 通过多头注意力计算后,我们就得到了每个头计算结果组成的4维张量,我们需要将其转换为输入的
        # 因此这里开始进行第一步处理环节的逆操作,先对第二和第三维惊喜转置,然后使用contiguous方法
        # 这个方法的作用就是能够让转置后的张量应用view方法,否则将无法直接使用
        # 所以,下一步就是使用view方法重塑形状,变成和输入形状相同
        x = x.transpose(1, 2).contiguous().view(batch_size, -1, self.head * self.d_k)  # 对x进行reshape
        print('x', x.shape)  # x的shape回到[1, 6, 4],  [batch size, sequence length, embedding dimension]
        return self.linears[-1](x)
In [122]:
# 实例化若干参数
head = 2
embedding_dim = 4
dropout = 0.2
# 若干输入参数的初始化
query = key = value = pe_result
mha = MultiHeadAttention(head, embedding_dim, dropout)
mha_result = mha(query , key, value)
print('mha_result:', mha_result.shape)
----------------------------------
query-before transpose:  torch.Size([1, 6, 4])
key-before transpose:  torch.Size([1, 6, 4])
value-before transpose:  torch.Size([1, 6, 4])
----------------------------------
query-after transpose:  torch.Size([1, 2, 6, 2])
key-after transpose:  torch.Size([1, 2, 6, 2])
value-after transpose:  torch.Size([1, 2, 6, 2])
----------------------------------
x- after attention:  torch.Size([1, 2, 6, 2])
x torch.Size([1, 6, 4])
mha_result: torch.Size([1, 6, 4])

3.4 前馈全连接层

  完成多头注意力计算后,考虑到此前一系列操作对复杂过程的拟合程度可能不足,所以,通过增加全连接层来增强模型的拟合能力。前馈全连接层对应于图1中“Feed Forward”所标识的组件。

In [66]:
class PositionalwiseFeedForward(nn.Module):
    def __init__(self, d_model, d_ff, dropout=0.1):
        """
        d_model: 线形层的输入维,因为我们希望输入通过前馈全连接层后输入和输出的维度不变
        d_ff: 第二个线形层
        droupout:置零比率
        """
        super(PositionalwiseFeedForward, self).__init__()
        # 首先按照我们预期使用nn实例化了两个线形层对象
        # 他们的参数分别是d_model, d_ff和d_ff, d_model
        self.w1 = nn.Linear(d_model, d_ff)
        self.w2 = nn.Linear(d_ff, d_model)
        # 然后我们使用nn的Dropout实例化了对象self.dropout
        self.dropout = nn.Dropout(p=dropout)
    
    def forward(self, x):
        """输入参数为x,代表来自上一层的输出"""
        # 首先经过第一个线性层,然后使用Funtional中relu函数进行激活,
        # 之后再使用dropout进行随机置0,最后通过第二个线性层w2,返回最终结果.
        return self.w2(self.dropout(F.relu(self.w1(x))))

3.5 规范化层

  所有深层网络模型都需要用到标准网络层,因为随着网络层数的增加,通过多层的计算后参数可能开始出现过大或过小的情况,这样可能会导致学习过程出现异常,模型可能收敛非常的慢。因此都会在一定层数后接规范化层进行数值的规范化,使其特征数值在合理范围内。规范化层对应于图1中“Add & Norm ”所标识的组件。

In [70]:
class LayerNorm(nn.Module):
    def __init__(self, features, eps=1e-6):
        # features:词嵌入的维度
        # eps:一个足够小的数,在规范化公式的分母中出现,房子分母为0。
        super(LayerNorm, self).__init__()
        # 根据features的形状初始化两个参数张量a2和b2,a2为元素全为1的张量,b2为元素全为0的张量。这两个张量就是规范化层的参数
        # 因为直接对上一层得到的结果做规范化公式计算,将改变结果的正常表征,因此就需要有参数作为调节因子
        # 使其既能满足规范化要求,又能不改变针对目标的表征,最后使用nn.parameter封装,代表它们是模型的参数
        self.eps = eps
        self.a2 = nn.Parameter(torch.ones(features))
        self.b2 = nn.Parameter(torch.zeros(features))
        
    def forward(self, x):
        # x来自于上一层的输出
        # 在函数中,首先对输入变量x求其最后一个维度的均值,并保持输出维度与输入维度一致
        # 接着再求最后一个维度的标准差,然后就是根据规范化公式,用x减去均值除以标准差获得规范化的结果
        # 最后对结果乘以我们的缩放茶树,即a2, *号代表同型点乘,即对应位置进行乘法操作,加上位移参数b2返回即可
        # 
        mean = x.mean(-1, keepdim=True)
        std = x.std(-1, keepdim=True)
        return self.a2 * (x - mean) / (std + self.eps) + self.b2

3.6 子层连接结构

  输入到每个子层以及规范化层的过程中,还使用了残差连接,我们把这一部分结构整体叫做子层连接(代表子层及其连接结构),在每个编码器层中,都有两个子层,这两个子层加上周围的链接结构就形成了两个子层连接结构。如图6所示,为Transformer结构中的两个子层连接结构,这两个结构在同一个编码器层中上下相连。无论是在编码器中,还是在解码器中,子层连接结构都是重要的组件。

image

图6 子层连接结构
In [73]:
class SubLayerConnection(nn.Module):
    def __init__(self, size, dropout=0.1):
        # size: 词嵌入的维度大小
        # dropout:随机置零比率
        super(SubLayerConnection, self).__init__()
        self.size = size
        self.dropout = nn.Dropout(p=dropout)
        # 实例化了规范化对象self.norm
        self.norm = LayerNorm(size)
    
    def forward(self, x, sublayer):
        # 前向逻辑函数中,接收上一层或者子层的输入作为第一个参数
        # 将该子层连接中的子层函数作为第二个函数
        # 我们首先对输出进行规范化,然后将结果传给子层处理,之后再对子层进行dropout操作
        # 随机停止一些网络中神经元的作用,来房子过拟合,最后还有一个add操作
        # 因为存在跳跃连接,所以是将输入x与dropout后的子层输出结果相加作为最终的子层连接输出
        return x + self.dropout(sublayer(self.norm(x)))

3.7 编码器层

  作为编码器的组成单元, 每个编码器层完成一次对输入的特征提取过程, 即编码过程。编码器层结构如图7所示。

image.png

图7 编码器层
In [92]:
class EncoderLayer(nn.Module):
    def __init__(self, size, self_attn, feed_forward, dropout):
        # size:词嵌入维度的大小
        # self_attn:传入多头注意力子层实例化对象,并且是自注意力机制
        # feed_forward:前馈全连接层实例化对象
        # dropout:置零比率
        super(EncoderLayer, self).__init__()
        self.size = size
        # 首先将self_attn和feed_forward传入其中
        self.self_attn = self_attn
        self.feed_forward = feed_forward
        self.dropout = nn.Dropout(p=dropout)
        # 编码器层中有两个子层连接结构,所以使用clones函数进行克隆
        self.sublayer = clone(SubLayerConnection(size, dropout), 2)
    
    def forward(self, x, mask):
        # x:上一层输出
        # mask:掩码张量
        # 里面就是按照结构图左侧的流程,首先通过第一个子层连接结构,其中包含多头注意力子层,然后通过第二个子层连接结构,
        #其中包含前馈全连接子层,最后返回结果
        x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask))
        return self.sublayer[1](x, self.feed_forward)

3.8 编码器

  编码器用于对输入进行指定的特征提取过程, 也称为编码, 由N个编码器层堆叠而成。编码器结构如图8所示:

image

图8 编码器
In [74]:
class Encoder(nn.Module):
    def __init__(self, layer, N):
        # layer:编码器层
        # N: 编码器层的个数
        super(Encoder, self).__init__()
        # 首先使用clones函数克隆N个编码器层放在self.layers中
        self.layers = clone(layer, N)
        # 再初始化一个规范化层,将用在编码器的最后面
        self.norm = LayerNorm(layer.size)

    def forward(self, x, mask):
        # forward函数的输入和编码器层相同,x代表上一层的输出,mask代表掩码张量
        # 首先就是对我们克隆的编码器层进行循环,每次都会得到一个新的x
        # 这个循环的过程,就相当于输出的x经过N个编码器层的处理
        # 最后在通过规范化层的对象self.norm进行处理,最后返回结果
        for layer in self.layers:
            x = layer(x, mask)
        return self.norm(x)

4 解码器模块

  我们先来思考一个问题?我们为什么要添加解码器模块,编码器后面直接接全连接层或者残差结构、循环网络层不可以吗?

  不是不可以,只是性能要降低很多。一方面是在Transformer的解码器模块中,使用了多个解码器(Transformer的paper中说是6个),每个解码器都创新地使用了多头注意力机制,这里的注意力机制还比编码器模块中的多头注意力机制丰富,多个注意力机制层数据来源不同(querykeyvalue);另一方面,在Transformer的解码器训练过程中使用了“teacher-forcing”这么一种训练方式,teacher-forcing是指在训练网络过程中,每次不使用上一个阶段的输出作为下一个阶段的输入,而是直接使用训练数据的标准答案(目标数据)的对应上一项作为下一个阶段的输入。teacher-forcing不是本文的重点,不做过多讨论。

  解码器模块具体怎么运作的呢?下面我们结合解码器模块的组件结构来说明。

4.1 解码器层

  解码器模块由6个解码器层组成,每个解码器层等结构完全一样,如图9所示:

image

图9 解码器层结构

  每个解码器层由三个子层连接结构组成:

   (1) 第一个子层连接结构包括一个多头注意力子层和规范化层以及一个残差连接。在训练时,因为有目标数据可用,所以第一个解码器层多头注意力子层的输入来自于目标数据,上文提到过,这种机制叫做“teacher-force”,但是在测试时,已经没有目标数据可用了,那么,输入数据就来自于此前序列的解码器模块输出,没有预测过,那么就是起始标志的编码。同时,注意这里的注意力是自注意力,也就是说querykeyvalue都来自于目标数据矩阵变化得来,然后计算注意力,另外,这里计算注意力值时,一定使用掩码操作。后续的5个解码器层的输入数据是前一个解码器层的输出。

   (2) 第二个子层连接结构包括一个多头注意力子层和规范化层以及一个残差连接。编码器的输出的结果将会作为keyvalue传入每一个解码器层的第二个子层连接结构,而query则是当前解码器层的上一个子层连接结构的输出。注意,这里的querykeyvalue已经不同源了,所以不再是自注意力机制。完成计算后,输出结果作为第三个子层连接结构的输入。

   (3) 第三个子层连接结构包括一个前馈全连接子层和规范化层以及一个残差连接。完成计算后,输出结果作为输入进入下一个解码器层。如果是最后一个解码器层,那么输出结果就传入输出模块。

In [75]:
class DecoderLayer(nn.Module):
    def __init__(self, size, self_attn, src_attn, feed_forward, dropout=0.1):
        # size:词嵌入的维度大小,同时也代表解码器层的尺寸
        # self_attn:多头注意力对象,也就是说这个注意力机制需要Q=K=V
        # src_attn:多头注意力对象,这里Q!=K=V
        # feed_forward:前馈全连接层对象
        super(DecoderLayer, self).__init__()
        self.size = size
        self.self_attn = self_attn
        self.src_attn = src_attn
        self.feed_forward = feed_forward
        self.dropout = nn.Dropout(p=dropout)
        # 按照结构图使用clones函数克隆三个子层连接对象
        self.sublayer = clone(SubLayerConnection(size, dropout), 3)
    
    def forward(self, x, memory, source_mask, target_mask):
        # x:来自上一层的输出
        # mermory:来自编码器层的语义存储变量
        # 源数据掩码张量和目标数据掩码张量
        m = memory
        # 将x传入第一个子层结构,第一个子层结构分别是x和self-attn函数,因为是自注意力机制,所以Q,K,V都是x
        # 最后一个参数的目标数据掩码张量,这是要对目标数据进行遮掩,因为此时模型可能还没有生成任何目标数据
        # 比如扎起解码器准备生成第一个字符或者词汇时,我们其实已经传入了第一个字符以便计算损失
        # 但是我们不希望在生成第一个字符时模型还能利用这个信息,因此我们会将其遮掩,同样生成第二个字符或词汇时
        # 模型只能使用第一个字符或者词汇信息,第二个字符以及之后的信息都不允许模型使用
        x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, target_mask))
        # 接着进入第二个子层,这个职称中常规的注意力机制,q是输入x;k,v是编码层输出的memory
        # 同样也传入source_mask,但是进行源数据遮掩的原因并非是抑制信息泄露,而是这笔掉对结果没有意义的字符而陈胜的注意力值
        # 以此提升模型效果和训练速度,这样就完成了第二个子层的处理
        x = self.sublayer[1](x, lambda x: self.src_attn(x, m, m, source_mask))
        # 最后一个子层就是前馈全连接子层,经过它的处理后,就可以返回结果,这就是我们的解码器层结构。
        return self.sublayer[2](x, self.feed_forward)

4.2 解码器

  解码器就是将6个解码器层进行堆叠。第一个解码器层接受目标数据作为输入,后续的解码器使用前序一个解码器层的输出作为输入,通过这种方式将6个解码器层连接。最后一个解码器层的输出将进入输出模块。

In [153]:
class Decoder(nn.Module):
    def __init__(self, layer, N):
        # layer:解码器层layer
        # N:解码器层的个数N
        super(Decoder, self).__init__()
        # 首先使用clones方法克隆了N个layer,然后实例化了一个规范化层
        # 因为数据走过了所有的解码器层后,最后要做规范化处理
        self.N = N
        self.layers = clone(layer, N)
        self.norm = LayerNorm(layer.size)
    
    def forward(self, x, memory, source_mask, target_mask):
        # x:数据的嵌入表示
        # memory:编码器层的输出
        # source_mask:源数据掩码张量
        # target_mask:目标数据掩码张量
        
        # 对每个层进行循环,淡然这个循环就是变量x通过每一个层的处理
        # 得出最后的结果,再进行一次规范化返回即可
        for layer in self.layers:
            x = layer(x, memory, source_mask, target_mask)
        return self.norm(x)

5 输出模块

  输出模块的结构就很简单了,如图10所示。

image

图10 输出模块
  • 线形层

  通过对解码器模块的输出进行线性变化得到指定维度的输出, 也就是转换维度的作用。

  • softmax层

  使最后一维的向量中的数字缩放到0-1的概率值域内, 并满足他们的和为1。

  我们继续以汉译英模型为例,说一说输出模块是怎么将一系列的张量转化为一个个的词汇了。假设我们目标数据有1000个英文单词,线形层接收到解码器模块最终输出矩阵后,将会输出一个长度为1000的张量,其中每一个元素对应词汇表中一个单词。将这个张量继续传入softmax层中进行softmax操作,转换为概率分布矩阵。概率分布矩阵最大值所对应的单词即为此时模型翻译的输出结果。

In [76]:
# nn.functional工具包装载了网络层中那些只惊喜年计算,而没有参数的层
# 将线形层和softmax计算层一起实现,因为二者的共同目标是生成最后的结构
# 因此把类的名字叫做Generator,生成器类
class Generator(nn.Module):
    def __init__(self, d_model, vocab_size):
        # d_model:词嵌入维度
        # vocab_size:词表大小
        super(Generator, self).__init__()
        # 首先就是使用nn中的预定义线形层进行实例化,得到一个对象self.project等待使用
        # 这个线性层的参数有两个,计时初始化函数时传进来的 d_model和vocab_size
        self.vocab_size = vocab_size
        self.embedding_dim = embedding_dim
        self.project = nn.Linear(d_model, vocab_size)
    
    def forward(self, x):
        # 前向逻辑函数中输入是上一层输出张量x
        # 在函数中,首先使用上一步得到的self.project对x进行线性变化
        # 然后使用F中已经实现的log_softmax进行的softmax处理
        return F.log_softmax(self.project(x), dim=-1)

6 模型构建

通过上面的四个模块的介绍,我们已经完成了所有组成模块的原理介绍和实现 接下来就来实现完整的编码器-解码器结构。

In [77]:
# 编码器-解码器结构实现
class EncoderDecoder(nn.Module):
    def __init__(self, encoder, decoder, source_embed, target_embed, generator):
        # encoder:编码器对象
        # decoder:解码器对象
        # source_embed:源数据嵌入函数
        # target_embed:目标数据嵌入函数
        # generator:输出部分的类别生成器对象
        super(EncoderDecoder, self).__init__()
        # 将参数传入类中
        self.encoder = encoder
        self.decoder = decoder
        self.src_embed = source_embed
        self.tgt_embed = target_embed
        self.generator = generator
    
    def encode(self, source, source_mask):
        # 使用src_embed对source做处理,然后和source_mask一起传给self.encoder
        return self.encoder(self.src_embed(source), source_mask)
    
    def decode(self, memory, source_mask, target, target_mask):
        # 解码函数
        # 使用tgt_embed对target做处理,然后和source_mask、target_mask、memory一起传给self.decoder
        return self.decoder(self.tgt_embed(target), memory, source_mask, 
                            target_mask)
    
    def forward(self, source, target, source_mask, target_mask):
        # source:源数据
        # target:目标数据
        # source_mask、target_mask:对应的掩码张量
        
        # 在函数中,将source、source_mask传入编码函数,得到结果后,与source-mask、target、target_mask一同传给解码函数
        return self.decode(self.encode(source, source_mask), source_mask, 
                           target, target_mask)

  堆叠成一个完整的Transformer模型:

In [78]:
def make_model(source_vocab, target_vocab, N=6, d_model=512,
               d_ff=2048, head=8, dropout=0.1):
    # source_vocab:源数据特征(词汇)总数
    # target_vocab:目标数据特征(词汇)总数
    # N:编码器和解码器堆叠数
    # d_model:词向量映射维度
    # d_ff:前馈全连接网络中变化矩阵的维度
    # head:多头注意力机制中多头数
    # dropout:置零比率
    
    # 首先得到一个深度拷贝命令,接下来很多结构都要进行深度拷贝,从而保证它们彼此之间相互独立,不受干扰
    c = copy.deepcopy
    # 实例化了多头注意力机制类,
    attn = MultiHeadAttention(head, d_model, dropout)
    # 然后实例化前馈全连接类
    ff = PositionalwiseFeedForward(d_model, d_ff, dropout)
    # 实例化位置编码器类
    position = PositionalEncoding(d_model, dropout)
    # 根据结构图,最外层是EncoderDecode,在EncoderDecoder中
    # 分别是编码器层,解码器层,源数据Embedding层和位置编码组成的有序结构
    # 目标数据Embedding层和位置编码组成的有序结构,以及类别生成器层
    # 在编码器层中有attention子层以及前馈全连接子层
    # 在解码器层中有两个attention子层以及前馈全连接层
    model = EncoderDecoder(
            Encoder(EncoderLayer(d_model, c(attn), c(ff), dropout), N),
            Decoder(DecoderLayer(d_model, c(attn), c(attn), c(ff), dropout), N),
            nn.Sequential(Embedding(d_model, source_vocab), c(position)),
            nn.Sequential(Embedding(d_model, target_vocab), c(position)),
            Generator(d_model, target_vocab))
    # 模型构建完成后,接下来就是初始化模型中的参数,比如线形层的变化矩阵
    # 这里一旦判断参数的维度大于1,则会将其初始化成为一个服从均匀分布的矩阵
    for p in model.parameters():
        if p.dim() > 1:
            nn.init.xavier_uniform(p)
    return model

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多