结论速递
这次学习的内容较多,最核心的知识点其实在于理解attention的作用,其实现的关键步骤,self-attention的诞生及其作用。理解了之后Transformer的搭建就显得容易理解很多。在这次笔记中,第一部分简单介绍了问题背景,第二部分首先介绍了处理自然语言的Seq2seq结构,并引入了Attention机制,在这部分介绍中讲述了在Seq2seq中引入Attention的目的,键值对Attention的含义及作用(可以帮助QKV的理解),及其各部分(打分,挑选,聚合)的作用;也引出了self-attention在自然语言处理中的作用(结合了RNN和CNN在处理自然语言上的优点,并规避了不足);第三、四部分梳理教程逻辑,详细介绍了Transformer的实现细节及实现代码。
1 自然语言处理和Transformers
1.1 自然语言处理
1.1.1 自然语言处理任务
自然语言处理(Natural Language Processing, NLP)是一种重要的人工智能(Artificial Intelligence, AI)技术,它是指对计算机进行编程以处理和分析大量的自然语言数据,自然语言是指人类自然进化中使用的各类语言,形式可包括语音也可包括文字。自然语言处理的目标是使计算机能够理解文档的内容,包括其中语言的上下文细微差别,并可以准确地提取文档中包含的信息和见解,并对文档本身进行分类和组织。
常见的自然语言处理任务包括:
- 文本分类
对单个、两个或者多段文本进行分类。
举例:“这个教程真棒!”这段文本的情感倾向是正向的,“我在学习transformer”和“如何学习transformer”这两段文本是相似的。
- 序列标注
对文本序列中的token、字或者词进行分类。
举例:“我在国家图书馆学transformer。”这段文本中的国家图书馆是一个地点,可以被标注出来方便机器对文本的理解。
- 问答任务
分为抽取式问答和多选问答。
抽取式问答根据问题从一段给定的文本中找到答案,答案必须是给定文本的一小段文字。举例:问题“小学要读多久?”和一段文本“小学教育一般是六年制。”,则答案是“六年”。
多选式问答,从多个选项中选出一个正确答案。举例:“以下哪个模型结构在问答中效果最好?“和4个选项”A、MLP,B、cnn,C、lstm,D、transformer“,则答案选项是D。
- 生成任务
语言模型、机器翻译和摘要生成.
根据已有的一段文字生成(generate)一个字通常叫做语言模型,根据一大段文字生成一小段总结性文字通常叫做摘要生成,将源语言比如中文句子翻译成目标语言比如英语通常叫做机器翻译。
1.1.2 预训练和微调
在自然语言处理中,有一种常见的模式叫Pre-Train+Fine-Turning,就是预训练+微调,它是迁移学习的一种。采用这种模式的原因是
- 预训练模型已经在与微调数据集有一些相似之处的数据集上进行了训练。因此,微调过程能够利用初始模型在预训练期间获得的知识(例如,对于 NLP 问题,预训练模型将对用于任务的语言有某种统计理解)。
- 由于预训练模型已经在大量数据上进行了训练,因此微调需要较少的数据就能获得不错的结果。
- 出于同样的原因,获得良好结果所需的时间和资源要少得多。
预训练是用于应对训练数据不足而诞生的。与常规训练相比,经过预训练的模型,其参数不再是随机初始化。
这种预训练通常是在非常大量的数据上完成的。因此,它需要非常大的数据语料库,并且训练可能需要长达数周的时间。
“预训练“的做法一般是将大量低成本收集的训练数据放在一起,经过某种预训方法去学习其中的共性,然后将其中的共性“移植”到特定任务的模型中,再使用相关特定领域的少量标注数据进行“微调”,这样的话,模型只需要从”共性“出发,去“学习”该特定任务的“特殊”部分即可。
——教你深入理解“预训练”
微调是在模型经过预训练后进行的训练,要进行微调,现需要获得一个预训练好的模型,然后用训练集进行额外的训练。
微调模型具有更低的时间、数据、财务和环境成本。迭代不同的微调方案也更快、更容易,因为训练比完全预训练的约束更少。这个过程也会比从头开始训练获得更好的结果(除非有大量数据)。
常见的预训练包含以下几种情况:
- 无监督+大规模数据预训练(例如BERT、Roberta、XLNet等);
- 无监督+domian数据预训练(例如我们要对wikipedia的数据做问答,那可以先用wikipedia的数据预训练一下模型);
- 有监督+相似任务预训练(例如我们要对句子做2分类,那么我们可以先用短语2分类、文档2分类的数据进行预训练);
- 有监督+相关数据/任务预训练(例如我们要对数据X做句法分析,由于这个数据X同时还标注实体,那么我们可以用实体标注进行预训练);
- 多任务学习 ,多任务学习进行预训练的常规方法是:将多个相关的有监督/无监督任务放在一起对模型参数进行预训练。
在多多的知乎专栏2021年如何科学的“微调”预训练模型?中介绍了NLP中常见的预训练+微调的训练方式。
1.2 Transformers的发展
2017年,Attention Is All You Need论文首次提出了Transformer模型结构并在机器翻译任务上取得了The State of the Art(SOTA, 最好)的效果。
Attention Is All You Need这篇论文的发布可以说是Attention机制和Transformer模型开始广泛使用的重要节点。这篇论文展示了Google的几位工程师的工作成果,提出了Transformer这个模型结构。之后下面这个图,也就是Transformer的模型图,开始非常广泛的出现在人们的视野里。
2018年,BERT: Pre-training of Deep Bidirectional Transformers for
Language Understanding使用Transformer模型结构进行大规模语言模型(language model)预训练(Pre-train),再在多个NLP下游(downstream)任务中进行微调(Finetune),一举刷新了各大NLP任务的榜单最高分,轰动一时。
BERT: Pre-training of Deep Bidirectional Transformers for
Language Understanding这篇文章依然由Google发布,提出了BERT模型,并且使用它进行了Pre-trainig和Fine-Turning。
2019年-2021年,研究人员将Transformer这种模型结构和预训练+微调这种训练方式相结合,提出了一系列Transformer模型结构、训练方式的改进(比如transformer-xl,XLnet,Roberta等等)。
A Survey of Transformers这篇文章小结了到2021年为止,各类Transformer的改进,包括模块层面的,结构层面的,预训练层面的,应用层面的等。
另外,由于Transformer优异的模型结构,使得其参数量可以非常庞大从而容纳更多的信息,因此Transformer模型的能力随着预训练不断提升。随着近几年计算能力的提升,越来越大的预训练模型以及效果越来越好的Transformers不断涌现
2 Seq2seq结构与Attention的引入
2.1 Seq2seq结构
2.1.1 Seq2seq结构
Seq2seq指的是将一个序列通过特定的方法转换为另一个序列,即进行序列变换,主要组件是一个编码器(encoder)和一个解码器(decoder)网络。编码器将每个项目转换为包含项目及其上下文的相应隐藏向量。解码器反转该过程,将向量转换为输出项,使用前一个输出作为输入上下文。
seq2seq模型结构在很多任务上都取得了成功,如:机器翻译、文本摘要、图像描述生成。谷歌翻译在 2016 年年末开始使用这种模型。有2篇开创性的论文:Sutskever等2014年发表的Sequence to Sequence Learning
with Neural Networks和Cho等2014年发表的Learning Phrase Representations using RNN Encoder–Decoder
for Statistical Machine Translation都对这些模型进行了解释。
上图展示了Seq2seq的形式,编码器和解码器通常对应神经网络模型,中间语义编码C(也叫做上下文context)可以看做是所有的输入内容的一个集合,所有的输入内容都应包括在C里面。
更具体地可以通过下面动图理解过程
编码器会处理输入序列中的每个元素,把这些信息转换为一个向量(称为上下文(context))。当我们处理完整个输入序列后,编码器把上下文(context)发送给解码器,解码器开始逐项生成输出序列中的元素。
2.1.2 上下文context
在机器翻译任务中,上下文(context)是一个向量(基本上是一个数字数组)。编码器和解码器在Transformer出现之前一般采用的是循环神经网络。
上下文context对应一个浮点数向量,可以在编写的时候设置长度,它和编码器RNN隐藏层神经元的数量一致。在实际应用中,上下文向量的长度常为256,512或1024(为什么?)。上下文向量的生成过程,也叫上下文词表征,常属于word2vec任务。word2vec是一种将单词转换为向量的算法,因此具有相似含义的单词最终会彼此靠近。也可以叫word embedding,如下图所示。
根据设计,RNN 在每个时间步接受 2 个输入:
- 输入序列中的一个元素(在解码器的例子中,输入是指句子中的一个单词,最终被转化成一个向量)
- 一个 hidden state(隐藏层状态,也对应一个向量)
如何把每个单词都转化为一个向量呢?我们使用一类称为 “word embedding” 的方法。这类方法把单词转换到一个向量空间,这种表示能够捕捉大量单词之间的语义信息(例如,king - man + woman = queen例子来源)。
教程以长度为4的向量为例,展示了一一个word2vec的结果。下图左边的每个单词对应中间的一个4维向量。
2.1.3 循环神经网络
在Transformer出现前,常用的用于自然语言处理中编码器和解码器的基础网络模型是循环神经网络(Recurrent neural network,RNN)。
循环神经网络可以在不同时间的输入间建立联系,从而有助于处理如自然语言这一类的序列信息。基本的循环神经网络结构包含一个输入层、一个隐藏层和一个输出层。其中
U
U
U是输入层到隐藏层的权重矩阵,
V
V
V是隐藏层到输出层的权重矩阵。
教程里头的动图动态展示了循环神经网络的运行。
具体到一个Seq2seq结构中,如果编码器和解码器都是RNN,RNN会根据当前步的输入和上一步的隐藏层状态,更新当前步的隐藏层状态。
解码器也有隐藏层,而且也需要把隐藏层从一个时间步传递到下一个时间步。
下面的动画会让我们更加容易理解模型。这种方法称为展开视图。其中,我们不只是显示一个解码器,而是在时间上展开,每个时间步都显示一个解码器。通过这种方式,我们可以看到每个时间步的输入和输出。
2.2 Attention
2.2.1 为什么需要Attention机制
为什么需要attention机制,一个最主要的观点就是,上下文context向量对长文本的处理存在困难。
事实证明,上下文context向量是这类模型的瓶颈。这使得模型在处理长文本时面临非常大的挑战。
一个博客也对这个问题进行了描述
解码器的输入只有一个向量,该向量就是输入序列经过编码器的上下文向量c 。
这种固定长度的上下文向量设计的一个关键而明显的缺点是无法记住长句子。通常,一旦完成了对整个输入的处理,便会忘记第一部分。
此外,在应对一些语言翻译问题的时候,使用循环神经网络作为解码器关注的是全局信息,无法关注到具体位置的信息。而自然语言常常是存在重要位置和非重要位置的。例如:I am a student. 应该更关注主语 I 和名词 student。
在 Bahdanau等2014发布的Neural Machine Translation by Jointly Learning to Align and Translate 和 Luong等2015年发布的Effective Approaches to Attention-based Neural Machine Translation
两篇论文中,提出了一种解决方法。
这 2 篇论文提出并改进了一种叫做注意力attention的技术,它极大地提高了机器翻译的质量。
2.2.2 在Seq2seq中引入Attention机制
Attention机制是将有限的注意力集中在重点信息上,从而节省资源,快速获得最有效的信息,它具有参数少、速度快、效果好的特点。在自然语言处理中,注意力机制挑重点,就算数据比较长,也能从中抓住重点,不丢失重要的信息。Attention机制在很多地方都有应用,在自然语言处理的Seq2seq中应用时,可以表示下图所示。此时,编码器不再将整个输入序列编码为固定长度的上下文向量Context,而是编码成一个向量的序列,序列中的每一个值(即C1,C2,C3)都是注意力机制得到的结果,这样,在产生每一个输出的时候,都能够做到充分利用输入序列携带的信息。
将Attention机制引入Decoder
教程里的动态图展示了引入Attention机制后,Seq2seq模型的变化。
一个注意力模型不同于经典的序列到序列(Seq2seq)模型,主要体现在 2 个方面:
- 编码器会把更多的数据传递给解码器。
编码器把所有时间步的hidden state传递给解码器,而不是只传递最后一个 hidden state - 解码器在产生输出之前,做了一个额外的处理(为了把注意力集中在与该时间步相关的输入部分)
- 查看所有接收到的编码器的 hidden state。其中,编码器中每个 hidden state都对应到输入句子中一个单词。
- 给每个 hidden state一个分数(我们先忽略这个分数的计算过程,score函数)。
- 将每个 hidden state乘以经过 softmax 的对应的分数,从而,高分对应的 hidden state会被放大,而低分对应的 hidden state会被缩小。
在个人理解中,上述编码器的步骤保证了单词的逐个关注,而解码器部分的打分和softmax过程实现了对编码器单词的关注,同时不拘泥于单词原来所在的位置,和每个解码步骤所关注的单词的数量。
上述文字所对应的动态图如下所示:
softmax后加权平均的操作在每一个时刻都会完成。
那么,融入注意力机制后的Seq2seq模型如下运行,主要关注decoder的不同:
- 输入:注意力模型的解码器 RNN 的输入包括:一个embedding 向量,和一个初始化好的解码器 hidden state(隐藏层状态)。
- RNN:RNN 处理上述的 2 个输入,产生一个输出和一个新的 hidden state(隐藏层状态 h4 向量),其中输出会被忽略。
- 注意力的步骤:我们使用编码器的 hidden state(隐藏层状态)和 h4 向量来计算这个时间步的上下文向量(C4)。
- 向量拼接:把 h4 和 C4 拼接起来,得到一个向量。
- 输入全连接网络:我们把这个向量输入一个前馈神经网络(这个网络是和整个模型一起训练的)。
- 生成单词结果:前馈神经网络的输出的输出表示这个时间步输出的单词。
- 在下一个时间步重复这个步骤。
下面的两张图可以为attention对单词的关注提供一个直观的认知(关注,顺序非对应,非一一对应)
2.2.3 Attention关键步骤理解及分类
attention有非常多种形式,注意力机制总结这篇博客提供了一些小结。
首先根据向量拼接阶段的不同(对应上文decoder那段的向量拼接,注意attention的输出并不是后面信息获取的唯一输入),Attention可分为普通模式和键值对模式。
- 普通模式
普通模式比较好理解,就是一个输入不仅输入到attention模块,同时也输入到后面的连接(和attention的输出相互拼接)中。
用 X = [ x 1 , ⋅ ⋅ ⋅ , x N ] X=[x_1,⋅⋅⋅,x_N] X=[x1,⋅⋅⋅,xN]表示N组输入信息,其中每个向量 x i , i ∈ [ 1 , N ] x_{i,i}∈[1,N] xi,i∈[1,N]都表示 一组输入信息。为了节省计算资源,不需要将所有信息都输入到神经网络,只需要从X中选择一些和任务相关的信息。
- 键值对模式
键值对模式通俗理解的话,就是输入attention的是键key,而后面和attention输出结果拼接的是值value。
使用键值对(key-value pair) 格式来表示输入信息,其中 “键key”用来计算注意力分布 α i \alpha_i αi,“值value”用来计算聚合信息。假设输入为query q q q,Memory 中以 ( k , v ) (k,v) (k,v)形式存储需要的上下文。(Transformer就是采取的这种模式)
可以借助Q&A任务来理解这种模式, k k k是question, v v v是answer, q q q是新来的question,看看历史memory中 q q q和哪个 k k k更相似,然后依葫芦画瓢,根据相似 k k k对应的 v v v,合成当前question的answer。
接着,我们需要熟悉Attention的几个关键的步骤(图片来源:注意力机制总结)
-
score function, s c o r e ( s i − 1 , h t ) score(s_{i-1},h_t) score(si−1,ht)主要对应在给前面的hidden state一个分数。
计算score有多种计算方法,其实本质就是度量两个向量的相似度,因为我们希望能表示出相似的向量和不相似的向量,然后才能进行关注。不同score function计算方法代表不同的attention模型。- 如果两个向量在同一个空间,那么可以使用 dot 点乘方式(或者 scaled dot product,scaled 背后的原因是为了减小数值,softmax 的梯度大一些,学得更快一些),简单好使。
- 如果不在同一个空间,需要一些变换(在一个空间也可以变换),additive 对输入分别进行线性变换后然后相加,multiplicative 是直接通过矩阵乘法来变换。
-
alignment function, α i , t \alpha_{i,t} αi,t用于刻画在对第 i i i个输出进行解码时,第 t t t个输入的重要程度,对应于前面decoder那里的softmax那一步(就是挑选)。
常用的计算权重的方法有,首先计算 s i − 1 s_{i−1} si−1和 h t h_t ht的相关性,然后对所有的 t = 1 , 2 , . . . , n t=1,2,...,n t=1,2,...,n归一化即可得到权重系数。即
e i , t = s c o r e ( s i − 1 , h t ) e_{i,t}=score(s_{i-1},h_t) ei,t=score(si−1,ht) α i , t = exp ( e i , t ) ∑ t = 1 n exp ( e i , t ) , t = 1 , 2 , . . . , n \alpha_{i,t} = \dfrac{\exp(e_{i,t}) }{\sum_{t=1}^{n}{\exp(e_{i,t})}}, t=1,2,...,n αi,t=∑t=1nexp(ei,t)exp(ei,t),t=1,2,...,n -
generate context vector function,上下文向量 c i c_i ci和所有隐向量间的关系,通俗地讲,即挑完怎么聚合(因为可能不止挑到了一个)
一般为加权得到,
c i = ∑ t = 1 n α i , t h t c_i = \sum_{t=1}^{n}{\alpha_{i,t}h_t} ci=t=1∑nαi,tht
其中 ∑ t = 1 n α i , t = 1 , α i , t ≥ 0 \sum_{t=1}^{n}{\alpha_{i,t}}=1,\alpha_{i,t} \geq 0 ∑t=1nαi,t=1,αi,t≥0。
在个人理解中,上述score function步骤通过打分的方式,为单词的关注程度提供依据,而 alignment function对单词进行挑选,从而可以使每一步落到具体的单词上,因为挑选出来的单词不止一个,所以使用generate context vector function实现了多个单词信息的合并。
Attention可以按上述三个关键步骤的不同进行分类,即按score函数的不同进行分类,也可以按照alignment function进行分类(分为global,local),还可以按照generate context vector进行分类(hard,soft)。
下图出自注意力机制总结,小结了attention机制的分类及常见例子。
2.2.4 Dot-product Attention
下面举例一个常见的Attention模型:Dot-product Attention,来进一步补充理解。
在Dot-product Attention当中使用的是键值对的形式(图片来源:注意力机制总结)
所使用的function分别是:
- score function:在memory中找相似
e i = a ( q , k i ) e_i = a(q,k_i) ei=a(q,ki) - alignment function:全部算完后,要挑选,使用softmax进行归一化计算attention权重
α i = s o f t m a x ( e i ) \alpha_i = softmax(e_i) αi=softmax(ei) - generate context vector function:根据attention weight,得到输出向量
c = ∑ i α i v i c=\sum_i \alpha_i v_i c=i∑αivi
上图中的公式是三个步骤结合。
从矩阵角度看:
回到框架上,query,key,value分别是这么运作的。
2.3 Self-Attention 和 Muti-Head Attention
2.3.1 为什么需要self-attention?
可变长文本的向量表示方法一般有两种:
- Basic Combination:average,sum
- Neural Combination:RNN、CNN
但是RNN无法对有层次结构的信息进行很好地表达,同时又并行化困难,且不能解决长距离依赖的问题。
在 RNN 中,每一个 time step 的计算都依赖于上一个 time step 的输出,这就使得所有的 time step 必须串行化,无法并行计算。
而CNN则需要很多层才能解决长距离依赖。
面对我们需要解决的问题,我们想要找到一种方法,
- 相对于 CNN,要 constant path length 不要 logarithmic path length , 要 variable-sized perceptive field,不要固定 size 的 perceptive field(就是要更灵活一些,没有非常固定的邻域,然后长度也没有那么受限);
- 相对于 RNN,考虑长距离依赖,还要可以并行!
由此,self-attention就这样诞生了,它和convolution有点相似,但摒弃了CNN的局部假设,想要寻找长距离的关联依赖。
- 相对于CNN,其任意两个位置(特指远距离)的关联不再需要通过 Hierarchical perceptive field 的方式,它的 perceptive field 是整个句子,所以任意两个位置建立关联是常数时间内的。
- 相对于RNN,没有了递归的限制,就像 CNN 一样可以在每一层内实现并行。
下图可以看到self-attention和convolution的相似点
其中self-attention 借鉴 CNN中 multi-kernel 的思想,进一步进化成为 Multi-Head attention。每一个不同的 head 使用不同的线性变换,学习不同的关系。
2.3.2 Transformer里的self-attention和multi-head attention
假设我们想要翻译的句子是:
The animal didn’t cross the street because it was too tired.
这个句子中的 it 是一个指代词,那么 it 指的是什么呢?它是指 animal 还是street?这个问题对人来说,是很简单的,但是对算法来说并不是那么容易。
当模型在处理(翻译)it 的时候,Self Attention机制能够让模型把it和animal关联起来。
同理,当模型处理句子中的每个词时,Self Attention机制使得模型不仅能够关注这个位置的词,而且能够关注句子中其他位置的词,作为辅助线索,进而可以更好地编码当前位置的词。
而 Transformer 使用Self Attention机制,会把其他单词的理解融入处理当前的单词。
在Transformer里头,self-attention计算三种attention:
- 在encoder 端计算自身的 attention,捕捉input之间的依赖关系。
- 在 decoder 端计算自身的 attention,捕捉output之间的依赖关系。
- 将 encoder 端得到的 self attention 加入到 decoder 端得到的 attention中,捕捉输入序列的每个 input 和输出序列的每个 output 之间的依赖关系。
而多头注意力(Multi-Head Attention)是利用多个查询
Q
=
[
q
1
,
⋅
⋅
⋅
,
q
M
]
Q = [q_1, · · · , q_M]
Q=[q1,⋅⋅⋅,qM],来平行地计算从输入信息中选取多组信息。
3 Transformer结构
3.1 Transformer简介
以下部分均参考了The Illustrated Transformer一文及datawhale提供的教程翻译。
Transformer 依赖于 Self Attention 的知识。Attention 是一种在深度学习中广泛使用的方法,Attention的思想提升了机器翻译的效果。
2017 年,Google 提出了 Transformer 模型,用 Self Attention 的结构,取代了以往 NLP 任务中的 RNN 网络结构,在 WMT 2014 Englishto-German 和 WMT 2014 English-to-French两个机器翻译任务上都取得了当时 SOTA 的效果。
如同之前所述,由于引入了self-attention,Transformer模型在训练过程中能够实现并行计算。
Transformer 使用了 Seq2Seq任务中常用的结构——包括两个部分:Encoder 和 Decoder。一般的结构图如下(前面也出现过,这里再来一次):
3.2 宏观理解Transformer
从最最顶层,Transformer要实现的东西和别的Seq2seq相同:接受一种语言作为输入,然后将其翻译为别的语言输出。
拆成Encoder和Decoder的形式。
其中编码部分是多层的编码器(Encoder)组成(Transformer 的论文中使用了 6 层编码器,这里的层数 6 并不是固定的,你也可以根据实验效果来修改层数)。同理,解码部分也是由多层的解码器(Decoder)组成(论文里也使用了 6 层的解码器)。
Transformer的Encoder由多层编码器组成,每层编码器在结构上都是一样的,但是不同的编码器的权重参数不同。
在每层编码器中,主要由以下两部分组成
- Self-Attention Layer
- Feed Forward Neural Network(前馈神经网络,缩写为 FFNN)
下图展示了每一层编码器的内部结构
输入编码器的文本数据,首先会经过一个 Self Attention 层,这个层处理一个词的时候,不仅会使用这个词本身的信息,也会使用句子中其他词的信息(你可以类比为:当我们翻译一个词的时候,不仅会只关注当前的词,也会关注这个词的上下文的其他词的信息)。
接下来,Self Attention 层的输出会经过前馈神经网络。
Transformer的Decoder也由多层解码器组成,每一个解码器也和编码器一样,有Self-Attention和FFNN,但中间还插入了一个 Encoder-Decoder Attention 层,这个层能帮助解码器聚焦于输入句子的相关部分(类似于第二节引入Attention的Seq2seq 模型 中的 Attention)。
3.3 细节理解Transformer(张量化的Transformer)
3.3.1 Transformer的输入
和通常的 NLP 任务一样,我们首先会使用词嵌入算法(embedding algorithm),将每个词转换为一个词向量。实际中向量一般是 256 或者 512 维。为了简化起见,这里将每个词的转换为一个 4 维的词向量。
那么整个输入的句子是一个向量列表,其中有 3 个词向量。在实际中,每个句子的长度不一样,我们会取一个适当的值,作为向量列表的长度。如果一个句子达不到这个长度,那么就填充全为 0 的词向量;如果句子超出这个长度,则做截断。句子长度是一个超参数,通常是训练集中的句子的最大长度,你可以尝试不同长度的效果。
3.3.2 Encoder
前面提到,Transformer的encoder部分是由若干个单独的Encoder堆叠而成。每个Encoder接收的输入都是一个向量列表,输出也是大小同样的向量列表,然后接着输入下一个Encoder。
其中,第一个Encoder的输入是词向量,而后续的每一个Encoder的输入都是上一层Encoder的输出。
在每一个Encoder中,向量像下图这样流动,维度并不会发生改变。
值得注意的是FFNN与Self-Attention层不同,FFNN是每一个单词对应一个单独的网络结构,而Self-Attention是一个整体。下面这个两个单词的例子比较明显。
每个位置的词都经过 Self Attention 层,得到的每个输出向量都单独经过前馈神经网络层,每个向量经过的前馈神经网络都在结构上一致(原文的“一样”不太合适)
3.3.2.1 Self-Attention 的细节
主要分为几步(这个跟前面的dot-product attention部分其实有对应关系,为了对上,对教程的编号进行了修改)
- 计算Query 向量,Key 向量,Value 向量(键值对初始化)
- 计算Attention Score(注意力分数),然后Scaled,同时除以一个数 (score function)
- Softmax计算 (alignment function)
- 连接Softmax后结果和Value向量 (键值对拼接)
- 聚合结果,将值相加 (generate context vector function)
下面分步骤讲解
第一步,计算Query 向量,Key 向量,Value 向量。
对输入编码器的每个词向量,都创建 3 个向量,分别是:Query 向量,Key 向量,Value 向量。这 3 个向量是词向量分别和 3 个矩阵相乘得到的,而这个矩阵是我们要学习的参数。
这里提到,一般来说三个新向量都比原来的词向量长度更小,但是最终输出的向量长度与新向量的长度有倍数关系。比如原始长度是512, 新向量长度是64,输出向量长度是512)。在此处,我们记向量长度为
d
k
e
y
d_{key}
dkey。
第二步,计算Attention Score(注意力分数)。
假设我们现在计算第一个词 Thinking 的 Attention Score(注意力分数),需要根据 Thinking 这个词,对句子中的其他每个词都计算一个分数。这些分数决定了我们在编码Thinking这个词时,需要对句子中其他位置的每个词放置多少的注意力。
这些分数,是通过计算 “Thinking” 对应的 Query 向量和其他位置的每个词的 Key 向量的点积,而得到的。如果我们计算句子中第一个位置单词的 Attention Score(注意力分数),那么第一个分数就是 q 1 q_1 q1 和 k 1 k_1 k1 的内积,第二个分数就是 q 1 q_1 q1 和 k 2 k_2 k2 的点积。
计算每一个当前单词对应的Query向量与其他单词对应的 Key 向量的点积。
随后,把每个分数除以 d k e y \sqrt{d_{key}} dkey ( d k e y d_{key} dkey是 Key 向量的长度)。你也可以除以其他数,除以一个数是为了在反向传播时,求取梯度更加稳定。
这一块其实对应了一种叫Scaled Dot-Product,就是前面提到的减小数值,使得后面结果的梯度更大,学得更快。
第三步,Softmax计算。
接着把这些分数经过一个 Softmax 层,Softmax可以将分数归一化,这样使得分数都是正数并且加起来等于 1。
这些分数决定了在编码当前位置(这里的例子是第一个位置)的词时,对所有位置的词分别有多少的注意力。很明显,在上图的例子中,当前位置(这里的例子是第一个位置)的词会有最高的分数,但有时,关注到其他位置上相关的词也很有用。
第四步,连接Softmax后结果和Value向量。
得到每个位置的分数后,将每个分数分别与每个 Value 向量相乘。这种做法背后的直觉理解就是:对于分数高的位置,相乘后的值就越大,我们把更多的注意力放到了它们身上;对于分数低的位置,相乘后的值就越小,这些位置的词可能是相关性不大的,这样我们就忽略了这些位置的词。其实是一个把attention附加到Value上的过程。
第五步,聚合结果,将值相加。
是把上一步得到的向量相加,就得到了 Self Attention 层在这个位置(这里的例子是第一个位置)的输出。
上述的全部过程如下图所示
值得注意的是,为了方便理解,上述所有例子其实都是以向量的形式表述的。在实际中,为了加速计算,我们需要通过矩阵来实现(避免循环!!!)(神经网络基本操作,坐下)
那么此时, Query,Key,Value 的矩阵这样来得到:首先,我们把所有词向量放到一个矩阵
X
X
X 中,然后分别和3 个权重矩阵
W
Q
W^Q
WQ,
W
K
W^K
WK,
W
V
W^V
WV 相乘,得到
Q
Q
Q,
K
K
K,
V
V
V 矩阵。
显然
W
Q
W^Q
WQ,
W
K
W^K
WK,
W
V
W^V
WV的维度是有讲究的,应该是
d
k
e
y
×
输
入
长
度
d_{key}×输入长度
dkey×输入长度,而
X
X
X的维度是
输
入
长
度
×
词
数
量
输入长度×词数量
输入长度×词数量
矩阵 X 中的每一行,表示句子中的每一个词的词向量,长度是 512。Q,K,V 矩阵中的每一行表示 Query 向量,Key 向量,Value 向量,向量长度是 64。
接着,由于我们使用了矩阵来计算,我们可以把上面的第 2 步到第 5 步压缩为一步,直接得到 Self Attention 的输出。
3.3.2.2 Multi-Head attention
在Transformer中,attention层不止有一组注意力,而是有多组。一组注意力称为一个 attention head,使用多组注意力,即多头注意力机制(Multi-Head attention)。
- 它扩展了模型关注不同位置的能力。在上面的例子中,第一个位置的输出 z1 包含了句子中其他每个位置的很小一部分信息,但 z1 可能主要是由第一个位置的信息决定的。当我们翻译句子:
The animal didn’t cross the street because it was too tired
时,我们想让机器知道其中的it指代的是什么。这时,多头注意力机制会有帮助。- 多头注意力机制赋予 attention 层多个“子表示空间”。下面我们会看到,多头注意力机制会有多组 W Q , W K W V W^Q, W^K W^V WQ,WKWV 的权重矩阵(在 Transformer 的论文中,使用了 8 组注意力(attention heads)。因此,接下来我也是用 8 组注意力头 (attention heads))。每一组注意力的 的权重矩阵都是随机初始化的。经过训练之后,每一组注意力 W Q , W K W V W^Q, W^K W^V WQ,WKWV 可以看作是把输入的向量映射到一个”子表示空间“。
它有点像convolution的多个基。
在多头注意力机制中,我们为每组注意力维护单独的 W Q W_Q WQ, W K W_K WK, W V W_V WV权重矩阵。将输入 X 和每组注意力的 W Q W_Q WQ, W K W_K WK, W V W_V WV 相乘,得到 8 组 Q Q Q, K K K, V V V 矩阵。
接着,我们把每组
K
K
K,
Q
Q
Q,
V
V
V 计算得到每组的
Z
Z
Z 矩阵,就得到 8 个
Z
Z
Z 矩阵。
由于前馈神经网络层需要接受的是一个矩阵,所以需要把8个矩阵整合为一个。
我们先拼接,然后和另一个权重矩阵
W
Q
W^Q
WQ相乘,这个得到的最终的矩阵
Z
Z
Z,包含了所有attention heads(注意力头) 的信息。
下面这张图小结了所有信息!划重点
3.3.2.3 使用位置编码表示序列顺序
前面都考虑一个一个单词单独处理,但是Transformer需要应对的任务与RNN相同,是需要处理序列数据的,所以需要一个表示单词顺序的方法。
为了解决这个问题,Transformer 模型对每个输入的向量都添加了一个向量。这些向量遵循模型学习到的特定模式,有助于确定每个单词的位置,或者句子中不同单词之间的距离。
这种做法背后的直觉是:将这些表示位置的向量添加到词向量中,得到了新的向量,这些新向量映射到 Q/K/V,然后计算点积得到 attention 时,可以提供有意义的信息。
为了让模型了解单词的顺序,我们添加了带有位置编码的向量——这些向量的值遵循特定的模式。
如果我们假设词向量的维度是 4,那么带有位置编码的向量可能如下所示:
有很多种生成位置编码的方法,Tranformer2Transformer
中的get_timing_signal_1d()
上提供了一种方法,优点是可以扩展到未知的序列长度。例如:当我们的模型需要翻译一个句子,而这个句子的长度大于训练集中所有句子的长度,这时,这种位置编码的方法也可以生成一样长的位置编码向量。
3.3.2.4 残差连接
编码器结构中有一个需要注意的细节是:编码器的每个子层(Self Attention 层和 FFNN)都有一个残差连接和层标准化(layer-normalization)。
decoder那边也有,在这里先提一下
3.3.3 Decoder及Encoder-Decoder Attention
最后一个Encoder输出的内容会输入到每个解码器的Encoder-Decoder Attention层,这有助于解码器把注意力集中中输入序列的合适位置。
随后是Decoding,decoding 阶段的每一个时间步都输出一个翻译后的单词(这里的例子是英语翻译)。
该过程不断重复直到输出一个结束符,Transformer 就完成了所有的输出。
Decoder的计算和Encoder很类似,从下往上一层一层地输出结果。正对如编码器的输入所做的处理,我们把解码器的输入向量,也加上位置编码向量,来指示每个词的位置。
Decoder与Encoder在以下这点不同(就是会把之前的输出也作为输入,然后来实现attention)
- Self-Attention层,只允许关注到输出序列中早于当前位置之前的单词
具体做法是:在 Self Attention 分数经过 Softmax 层之前,屏蔽当前位置之后的那些位置。
Encoder-Decoder Attention层在以下这点不同
- Multi-Headed Self Attention中,Encoder-Decoder Attention层的Query,Key和Value和先前的不同
具体做法是:使用前一层的输出来构造 Query 矩阵,而 Key 矩阵和 Value 矩阵来自于解码器最终的输出。
3.3.4 最后的线性层和 Softmax 层
Decoder的输出是一个向量,我们需要把向量转换为单词,我们使用 Softmax 层后面的线性层来完成。
线性层就是一个普通的全连接神经网络,可以把解码器输出的向量,映射到一个更长的向量,这个向量称为 logits 向量。
现在假设我们的模型有 10000 个英语单词(模型的输出词汇表),这些单词是从训练集中学到的。因此 logits 向量有 10000 个数字,每个数表示一个单词的分数。我们就是这样去理解线性层的输出。
然后,Softmax 层会把这些分数转换为概率(把所有的分数转换为正数,并且加起来等于 1)。然后选择最高概率的那个数字对应的词,就是这个时间步的输出单词。
3.4 Transformer的训练和优化
前面网络结构,实际上就是Transformer的前向传播过程。Transformer的反向传播过程首先需要定义损失函数。
损失函数用于衡量模型在每个词输出的概率和正确的输出概率间的差距,可以简单地用一个概率分布减去另一个概率分布,如交叉熵(cross-entropy)和KL 散度(Kullback–Leibler divergence)。
而我们使用的句子往往不止有一个单词,如输入是:“je suis étudiant” ,输出是:“i am a student”。这意味着,我们的模型需要输出多个概率分布,满足如下条件:
- 每个概率分布都是一个向量,长度是 vocab_size(我们的例子中,向量长度是 6,但实际中更可能是 30000 或者 50000)
- 第一个概率分布中,最高概率对应的单词是 “i”
- 第二个概率分布中,最高概率对应的单词是 “am”
- 以此类推,直到第 5 个概率分布中,最高概率对应的单词是 “”,表示没有下一个单词了
下图展示了我们经过一段时间的训练后希望得到的输出。需要注意的是:概率分布向量中,每个位置都会有一点概率,即使这个位置不是输出对应的单词——这是 Softmax 中一个很有用的特性,有助于帮助训练过程。
关于结果的保留,有几种方法
- 贪婪解码(greedy decoding):从概率分布中选择概率最大的词,并且丢弃其他词
- 集束搜索(beam search):每个时间步保留
beam_size
个最高概率的输出词,然后在下一个时间步,重复执行这个过程,最后返回top_beams
个结果。
4 Transformer前向传播的代码实现
本部分将会按照构建一个Transformer,主要参考教程的代码。(暂不涉及训练)
首先需要导入必需的库
import torch
import torch.nn as nn
from torch.nn.parameter import Parameter
from torch.nn.init import xavier_uniform_
from torch.nn.init import constant_
from torch.nn.init import xavier_normal_
import torch.nn.functional as F
from typing import Optional, Tuple, Any
from typing import List, Optional, Tuple
import math
import warnings
4.1 部件构建
4.1.1 输入信息:词嵌入和位置编码
词嵌入可以直接调用PyTorch的现有嵌入模块nn.Embedding
(官方文档)。
可以生成一个简单的查找表,用于存储固定字典和大小的嵌入。该模块通常用于存储词嵌入并使用索引检索它们。该模块的输入是索引列表,输出是相应的词嵌入。
假设我们有2个单词
X = torch.zeros((2,4),dtype=torch.long)
embed = nn.Embedding(10,8)
print(embed(X).shape)
其中embed
是一个torch.nn.modules.sparse.Embedding
对象,对应一个查找表,嵌入数量为10(词表长度),嵌入维度为8。将X
(两个例子,每个例子长度是4个单词)输入后,可以得到一个[2, 4, 8]
的张量。在上述代码中,由于X
是全0,所以得到的张量每个嵌入结果都相同。
词嵌入之后紧接着就是位置编码,位置编码用以区分不同词以及同词不同特征之间的关系。
Tensor = torch.Tensor
def positional_encoding(X, num_features, dropout_p=0.1, max_len=512) -> Tensor:
r'''
给输入加入位置编码
参数:
- num_features: 输入进来的维度
- dropout_p: dropout的概率,当其为非零时执行dropout
- max_len: 句子的最大长度,默认512
形状:
- 输入: [batch_size, seq_length, num_features]
- 输出: [batch_size, seq_length, num_features]
例子:
>>> X = torch.randn((2,4,10))
>>> X = positional_encoding(X, 10)
>>> print(X.shape)
>>> torch.Size([2, 4, 10])
'''
dropout = nn.Dropout(dropout_p)
P = torch.zeros((1,max_len,num_features))
X_ = torch.arange(max_len,dtype=torch.float32).reshape(-1,1) / torch.pow(
10000,
torch.arange(0,num_features,2,dtype=torch.float32) /num_features)
P[:,:,0::2] = torch.sin(X_)
P[:,:,1::2] = torch.cos(X_)
X = X + P[:,:X.shape[1],:].to(X.device)
return dropout(X)
上述代码中P
为存储位置编码的矩阵,X_
是根据长度初始化的位置信息,位置编码P
由X_
计算得到,然后叠加到输入的X
上面,最后经过dropout
处理。
X = torch.randn((2,4,10))
X = positional_encoding(X, 10)
print(X.shape)
4.1.2 注意力机制:多头注意力
实现结构大概是这样的
- 参数初始化
就是初始化 W q W_q Wq、 W k W_k Wk、 W v W_v Wv,如果需要,再初始化 b q b_q bq、 b k b_k bk、 b v b_v bv,但是初始化的都是empty,即预留空间 - multi_head_attention_forward
- query, key, value通过_in_projection_packed变换得到q,k,v
- 遮挡机制:这个是针对decoder设立的,因为在decoder解码的时候,只能看该位置和它之前的,如果看后面就犯规了,所以需要attn_mask遮挡住
- 点积注意力:就是前面的dot-product
则整个代码如下
先定义多头注意力
Tensor = torch.Tensor
def multi_head_attention_forward(
query: Tensor,
key: Tensor,
value: Tensor,
num_heads: int,
in_proj_weight: Tensor,
in_proj_bias: Optional[Tensor],
dropout_p: float,
out_proj_weight: Tensor,
out_proj_bias: Optional[Tensor],
training: bool = True,
key_padding_mask: Optional[Tensor] = None,
need_weights: bool = True,
attn_mask: Optional[Tensor] = None,
use_seperate_proj_weight = None,
q_proj_weight: Optional[Tensor] = None,
k_proj_weight: Optional[Tensor] = None,
v_proj_weight: Optional[Tensor] = None,
) -> Tuple[Tensor, Optional[Tensor]]:
r'''
形状:
输入:
- query:`(L, N, E)`
- key: `(S, N, E)`
- value: `(S, N, E)`
- key_padding_mask: `(N, S)`
- attn_mask: `(L, S)` or `(N * num_heads, L, S)`
输出:
- attn_output:`(L, N, E)`
- attn_output_weights:`(N, L, S)`
'''
tgt_len, bsz, embed_dim = query.shape
src_len, _, _ = key.shape
head_dim = embed_dim // num_heads
q, k, v = _in_projection_packed(query, key, value, in_proj_weight, in_proj_bias)
if attn_mask is not None:
if attn_mask.dtype == torch.uint8:
warnings.warn("Byte tensor for attn_mask in nn.MultiheadAttention is deprecated. Use bool tensor instead.")
attn_mask = attn_mask.to(torch.bool)
else:
assert attn_mask.is_floating_point() or attn_mask.dtype == torch.bool, \
f"Only float, byte, and bool types are supported for attn_mask, not {attn_mask.dtype}"
if attn_mask.dim() == 2:
correct_2d_size = (tgt_len, src_len)
if attn_mask.shape != correct_2d_size:
raise RuntimeError(f"The shape of the 2D attn_mask is {attn_mask.shape}, but should be {correct_2d_size}.")
attn_mask = attn_mask.unsqueeze(0)
elif attn_mask.dim() == 3:
correct_3d_size = (bsz * num_heads, tgt_len, src_len)
if attn_mask.shape != correct_3d_size:
raise RuntimeError(f"The shape of the 3D attn_mask is {attn_mask.shape}, but should be {correct_3d_size}.")
else:
raise RuntimeError(f"attn_mask's dimension {attn_mask.dim()} is not supported")
if key_padding_mask is not None and key_padding_mask.dtype == torch.uint8:
warnings.warn("Byte tensor for key_padding_mask in nn.MultiheadAttention is deprecated. Use bool tensor instead.")
key_padding_mask = key_padding_mask.to(torch.bool)
# reshape q,k,v将Batch放在第一维以适合点积注意力
# 同时为多头机制,将不同的头拼在一起组成一层
q = q.contiguous().view(tgt_len, bsz * num_heads, head_dim).transpose(0, 1)
k = k.contiguous().view(-1, bsz * num_heads, head_dim).transpose(0, 1)
v = v.contiguous().view(-1, bsz * num_heads, head_dim).transpose(0, 1)
if key_padding_mask is not None:
assert key_padding_mask.shape == (bsz, src_len), \
f"expecting key_padding_mask shape of {(bsz, src_len)}, but got {key_padding_mask.shape}"
key_padding_mask = key_padding_mask.view(bsz, 1, 1, src_len). \
expand(-1, num_heads, -1, -1).reshape(bsz * num_heads, 1, src_len)
if attn_mask is None:
attn_mask = key_padding_mask
elif attn_mask.dtype == torch.bool:
attn_mask = attn_mask.logical_or(key_padding_mask)
else:
attn_mask = attn_mask.masked_fill(key_padding_mask, float("-inf"))
# 若attn_mask值是布尔值,则将mask转换为float
if attn_mask is not None and attn_mask.dtype == torch.bool:
new_attn_mask = torch.zeros_like(attn_mask, dtype=torch.float)
new_attn_mask.masked_fill_(attn_mask, float("-inf"))
attn_mask = new_attn_mask
# 若training为True时才应用dropout
if not training:
dropout_p = 0.0
attn_output, attn_output_weights = _scaled_dot_product_attention(q, k, v, attn_mask, dropout_p)
attn_output = attn_output.transpose(0, 1).contiguous().view(tgt_len, bsz, embed_dim)
attn_output = nn.functional.linear(attn_output, out_proj_weight, out_proj_bias)
if need_weights:
# average attention weights over heads
attn_output_weights = attn_output_weights.view(bsz, num_heads, tgt_len, src_len)
return attn_output, attn_output_weights.sum(dim=1) / num_heads
else:
return attn_output, None
q,k,v的计算
def _in_projection_packed(
q: Tensor,
k: Tensor,
v: Tensor,
w: Tensor,
b: Optional[Tensor] = None,
) -> List[Tensor]:
r"""
用一个大的权重参数矩阵进行线性变换
参数:
q, k, v: 对自注意来说,三者都是src;对于seq2seq模型,k和v是一致的tensor。
但它们的最后一维(num_features或者叫做embed_dim)都必须保持一致。
w: 用以线性变换的大矩阵,按照q,k,v的顺序压在一个tensor里面。
b: 用以线性变换的偏置,按照q,k,v的顺序压在一个tensor里面。
形状:
输入:
- q: shape:`(..., E)`,E是词嵌入的维度(下面出现的E均为此意)。
- k: shape:`(..., E)`
- v: shape:`(..., E)`
- w: shape:`(E * 3, E)`
- b: shape:`E * 3`
输出:
- 输出列表 :`[q', k', v']`,q,k,v经过线性变换前后的形状都一致。
"""
E = q.size(-1)
# 若为自注意,则q = k = v = src,因此它们的引用变量都是src
# 即k is v和q is k结果均为True
# 若为seq2seq,k = v,因而k is v的结果是True
if k is v:
if q is k:
return F.linear(q, w, b).chunk(3, dim=-1)
else:
# seq2seq模型
w_q, w_kv = w.split([E, E * 2])
if b is None:
b_q = b_kv = None
else:
b_q, b_kv = b.split([E, E * 2])
return (F.linear(q, w_q, b_q),) + F.linear(k, w_kv, b_kv).chunk(2, dim=-1)
else:
w_q, w_k, w_v = w.chunk(3)
if b is None:
b_q = b_k = b_v = None
else:
b_q, b_k, b_v = b.chunk(3)
return F.linear(q, w_q, b_q), F.linear(k, w_k, b_k), F.linear(v, w_v, b_v)
# q, k, v = _in_projection_packed(query, key, value, in_proj_weight, in_proj_bias)
点积注意力的计算
from typing import Optional, Tuple, Any
def _scaled_dot_product_attention(
q: Tensor,
k: Tensor,
v: Tensor,
attn_mask: Optional[Tensor] = None,
dropout_p: float = 0.0,
) -> Tuple[Tensor, Tensor]:
r'''
在query, key, value上计算点积注意力,若有注意力遮盖则使用,并且应用一个概率为dropout_p的dropout
参数:
- q: shape:`(B, Nt, E)` B代表batch size, Nt是目标语言序列长度,E是嵌入后的特征维度
- key: shape:`(B, Ns, E)` Ns是源语言序列长度
- value: shape:`(B, Ns, E)`与key形状一样
- attn_mask: 要么是3D的tensor,形状为:`(B, Nt, Ns)`或者2D的tensor,形状如:`(Nt, Ns)`
- Output: attention values: shape:`(B, Nt, E)`,与q的形状一致;attention weights: shape:`(B, Nt, Ns)`
例子:
>>> q = torch.randn((2,3,6))
>>> k = torch.randn((2,4,6))
>>> v = torch.randn((2,4,6))
>>> out = scaled_dot_product_attention(q, k, v)
>>> out[0].shape, out[1].shape
>>> torch.Size([2, 3, 6]) torch.Size([2, 3, 4])
'''
B, Nt, E = q.shape
q = q / math.sqrt(E)
# (B, Nt, E) x (B, E, Ns) -> (B, Nt, Ns)
attn = torch.bmm(q, k.transpose(-2,-1))
if attn_mask is not None:
attn += attn_mask
# attn意味着目标序列的每个词对源语言序列做注意力
attn = F.softmax(attn, dim=-1)
if dropout_p:
attn = F.dropout(attn, p=dropout_p)
# (B, Nt, Ns) x (B, Ns, E) -> (B, Nt, E)
output = torch.bmm(attn, v)
return output, attn
最后是完整的多头注意力类的定义
class MultiheadAttention(nn.Module):
r'''
参数:
embed_dim: 词嵌入的维度
num_heads: 平行头的数量
batch_first: 若`True`,则为(batch, seq, feture),若为`False`,则为(seq, batch, feature)
例子:
>>> multihead_attn = MultiheadAttention(embed_dim, num_heads)
>>> attn_output, attn_output_weights = multihead_attn(query, key, value)
'''
def __init__(self, embed_dim, num_heads, dropout=0., bias=True,
kdim=None, vdim=None, batch_first=False) -> None:
# factory_kwargs = {'device': device, 'dtype': dtype}
super(MultiheadAttention, self).__init__()
self.embed_dim = embed_dim
self.kdim = kdim if kdim is not None else embed_dim
self.vdim = vdim if vdim is not None else embed_dim
self._qkv_same_embed_dim = self.kdim == embed_dim and self.vdim == embed_dim
self.num_heads = num_heads
self.dropout = dropout
self.batch_first = batch_first
self.head_dim = embed_dim // num_heads
assert self.head_dim * num_heads == self.embed_dim, "embed_dim must be divisible by num_heads"
if self._qkv_same_embed_dim is False:
self.q_proj_weight = Parameter(torch.empty((embed_dim, embed_dim)))
self.k_proj_weight = Parameter(torch.empty((embed_dim, self.kdim)))
self.v_proj_weight = Parameter(torch.empty((embed_dim, self.vdim)))
self.register_parameter('in_proj_weight', None)
else:
self.in_proj_weight = Parameter(torch.empty((3 * embed_dim, embed_dim)))
self.register_parameter('q_proj_weight', None)
self.register_parameter('k_proj_weight', None)
self.register_parameter('v_proj_weight', None)
if bias:
self.in_proj_bias = Parameter(torch.empty(3 * embed_dim))
else:
self.register_parameter('in_proj_bias', None)
self.out_proj = nn.Linear(embed_dim, embed_dim, bias=bias)
self._reset_parameters()
def _reset_parameters(self):
if self._qkv_same_embed_dim:
xavier_uniform_(self.in_proj_weight)
else:
xavier_uniform_(self.q_proj_weight)
xavier_uniform_(self.k_proj_weight)
xavier_uniform_(self.v_proj_weight)
if self.in_proj_bias is not None:
constant_(self.in_proj_bias, 0.)
constant_(self.out_proj.bias, 0.)
def forward(self, query: Tensor, key: Tensor, value: Tensor, key_padding_mask: Optional[Tensor] = None,
need_weights: bool = True, attn_mask: Optional[Tensor] = None) -> Tuple[Tensor, Optional[Tensor]]:
if self.batch_first:
query, key, value = [x.transpose(1, 0) for x in (query, key, value)]
if not self._qkv_same_embed_dim:
attn_output, attn_output_weights = multi_head_attention_forward(
query, key, value, self.num_heads,
self.in_proj_weight, self.in_proj_bias,
self.dropout, self.out_proj.weight, self.out_proj.bias,
training=self.training,
key_padding_mask=key_padding_mask, need_weights=need_weights,
attn_mask=attn_mask, use_separate_proj_weight=True,
q_proj_weight=self.q_proj_weight, k_proj_weight=self.k_proj_weight,
v_proj_weight=self.v_proj_weight)
else:
attn_output, attn_output_weights = multi_head_attention_forward(
query, key, value, self.num_heads,
self.in_proj_weight, self.in_proj_bias,
self.dropout, self.out_proj.weight, self.out_proj.bias,
training=self.training,
key_padding_mask=key_padding_mask, need_weights=need_weights,
attn_mask=attn_mask)
if self.batch_first:
return attn_output.transpose(1, 0), attn_output_weights
else:
return attn_output, attn_output_weights
教程提供了一个简单的实例化,来直观感觉维度变化
# 因为batch_first为False,所以src的shape:`(seq, batch, embed_dim)`
src = torch.randn((2,4,100))
src = positional_encoding(src,100,0.1)
print(src.shape)
multihead_attn = MultiheadAttention(100, 4, 0.1)
attn_output, attn_output_weights = multihead_attn(src,src,src)
print(attn_output.shape, attn_output_weights.shape)
输出是
torch.Size([2, 4, 100])
torch.Size([2, 4, 100]) torch.Size([4, 2, 2])
可以发现加入位置编码和进行多头注意力的前后形状都是不会变的。
4.1.3 Encoder和Decoder
4.1.3.1 Encoder的搭建
首先搭建Encoder Layer,其实就是把self-attention层和全连接层堆叠起来。
class TransformerEncoderLayer(nn.Module):
r'''
参数:
d_model: 词嵌入的维度(必备)
nhead: 多头注意力中平行头的数目(必备)
dim_feedforward: 全连接层的神经元的数目,又称经过此层输入的维度(Default = 2048)
dropout: dropout的概率(Default = 0.1)
activation: 两个线性层中间的激活函数,默认relu或gelu
lay_norm_eps: layer normalization中的微小量,防止分母为0(Default = 1e-5)
batch_first: 若`True`,则为(batch, seq, feture),若为`False`,则为(seq, batch, feature)(Default:False)
例子:
>>> encoder_layer = TransformerEncoderLayer(d_model=512, nhead=8)
>>> src = torch.randn((32, 10, 512))
>>> out = encoder_layer(src)
'''
def __init__(self, d_model, nhead, dim_feedforward=2048, dropout=0.1, activation=F.relu,
layer_norm_eps=1e-5, batch_first=False) -> None:
super(TransformerEncoderLayer, self).__init__()
self.self_attn = MultiheadAttention(d_model, nhead, dropout=dropout, batch_first=batch_first)
self.linear1 = nn.Linear(d_model, dim_feedforward)
self.dropout = nn.Dropout(dropout)
self.linear2 = nn.Linear(dim_feedforward, d_model)
self.norm1 = nn.LayerNorm(d_model, eps=layer_norm_eps)
self.norm2 = nn.LayerNorm(d_model, eps=layer_norm_eps)
self.dropout1 = nn.Dropout(dropout)
self.dropout2 = nn.Dropout(dropout)
self.activation = activation
def forward(self, src: Tensor, src_mask: Optional[Tensor] = None, src_key_padding_mask: Optional[Tensor] = None) -> Tensor:
src = positional_encoding(src, src.shape[-1])
src2 = self.self_attn(src, src, src, attn_mask=src_mask,
key_padding_mask=src_key_padding_mask)[0]
src = src + self.dropout1(src2)
src = self.norm1(src)
src2 = self.linear2(self.dropout(self.activation(self.linear1(src))))
src = src + self.dropout(src2)
src = self.norm2(src)
return src
然后再把多个单层组合成Encoder,num_layers
参数代表层数
class TransformerEncoder(nn.Module):
r'''
参数:
encoder_layer(必备)
num_layers: encoder_layer的层数(必备)
norm: 归一化的选择(可选)
例子:
>>> encoder_layer = TransformerEncoderLayer(d_model=512, nhead=8)
>>> transformer_encoder = TransformerEncoder(encoder_layer, num_layers=6)
>>> src = torch.randn((10, 32, 512))
>>> out = transformer_encoder(src)
'''
def __init__(self, encoder_layer, num_layers, norm=None):
super(TransformerEncoder, self).__init__()
self.layer = encoder_layer
self.num_layers = num_layers
self.norm = norm
def forward(self, src: Tensor, mask: Optional[Tensor] = None, src_key_padding_mask: Optional[Tensor] = None) -> Tensor:
output = positional_encoding(src, src.shape[-1])
for _ in range(self.num_layers):
output = self.layer(output, src_mask=mask, src_key_padding_mask=src_key_padding_mask)
if self.norm is not None:
output = self.norm(output)
return output
4.1.3.2 Decoder的搭建
同样的逻辑,先搭建单层Decoder Layer,需要注意的是,此时有两个多头注意力层,一个是Decoder的Self-attention,一个是Encoder-Decoder attention。
class TransformerDecoderLayer(nn.Module):
r'''
参数:
d_model: 词嵌入的维度(必备)
nhead: 多头注意力中平行头的数目(必备)
dim_feedforward: 全连接层的神经元的数目,又称经过此层输入的维度(Default = 2048)
dropout: dropout的概率(Default = 0.1)
activation: 两个线性层中间的激活函数,默认relu或gelu
lay_norm_eps: layer normalization中的微小量,防止分母为0(Default = 1e-5)
batch_first: 若`True`,则为(batch, seq, feture),若为`False`,则为(seq, batch, feature)(Default:False)
例子:
>>> decoder_layer = TransformerDecoderLayer(d_model=512, nhead=8)
>>> memory = torch.randn((10, 32, 512))
>>> tgt = torch.randn((20, 32, 512))
>>> out = decoder_layer(tgt, memory)
'''
def __init__(self, d_model, nhead, dim_feedforward=2048, dropout=0.1, activation=F.relu,
layer_norm_eps=1e-5, batch_first=False) -> None:
super(TransformerDecoderLayer, self).__init__()
self.self_attn = MultiheadAttention(d_model, nhead, dropout=dropout, batch_first=batch_first)
self.multihead_attn = MultiheadAttention(d_model, nhead, dropout=dropout, batch_first=batch_first)
self.linear1 = nn.Linear(d_model, dim_feedforward)
self.dropout = nn.Dropout(dropout)
self.linear2 = nn.Linear(dim_feedforward, d_model)
self.norm1 = nn.LayerNorm(d_model, eps=layer_norm_eps)
self.norm2 = nn.LayerNorm(d_model, eps=layer_norm_eps)
self.norm3 = nn.LayerNorm(d_model, eps=layer_norm_eps)
self.dropout1 = nn.Dropout(dropout)
self.dropout2 = nn.Dropout(dropout)
self.dropout3 = nn.Dropout(dropout)
self.activation = activation
def forward(self, tgt: Tensor, memory: Tensor, tgt_mask: Optional[Tensor] = None,
memory_mask: Optional[Tensor] = None,tgt_key_padding_mask: Optional[Tensor] = None, memory_key_padding_mask: Optional[Tensor] = None) -> Tensor:
r'''
参数:
tgt: 目标语言序列(必备)
memory: 从最后一个encoder_layer跑出的句子(必备)
tgt_mask: 目标语言序列的mask(可选)
memory_mask(可选)
tgt_key_padding_mask(可选)
memory_key_padding_mask(可选)
'''
tgt2 = self.self_attn(tgt, tgt, tgt, attn_mask=tgt_mask,
key_padding_mask=tgt_key_padding_mask)[0]
tgt = tgt + self.dropout1(tgt2)
tgt = self.norm1(tgt)
tgt2 = self.multihead_attn(tgt, memory, memory, attn_mask=memory_mask,
key_padding_mask=memory_key_padding_mask)[0]
tgt = tgt + self.dropout2(tgt2)
tgt = self.norm2(tgt)
tgt2 = self.linear2(self.dropout(self.activation(self.linear1(tgt))))
tgt = tgt + self.dropout3(tgt2)
tgt = self.norm3(tgt)
return tgt
那么整体的Decoder由多个layer组成。
class TransformerDecoder(nn.Module):
r'''
参数:
decoder_layer(必备)
num_layers: decoder_layer的层数(必备)
norm: 归一化选择
例子:
>>> decoder_layer =TransformerDecoderLayer(d_model=512, nhead=8)
>>> transformer_decoder = TransformerDecoder(decoder_layer, num_layers=6)
>>> memory = torch.rand(10, 32, 512)
>>> tgt = torch.rand(20, 32, 512)
>>> out = transformer_decoder(tgt, memory)
'''
def __init__(self, decoder_layer, num_layers, norm=None):
super(TransformerDecoder, self).__init__()
self.layer = decoder_layer
self.num_layers = num_layers
self.norm = norm
def forward(self, tgt: Tensor, memory: Tensor, tgt_mask: Optional[Tensor] = None,
memory_mask: Optional[Tensor] = None, tgt_key_padding_mask: Optional[Tensor] = None,
memory_key_padding_mask: Optional[Tensor] = None) -> Tensor:
output = tgt
for _ in range(self.num_layers):
output = self.layer(output, memory, tgt_mask=tgt_mask,
memory_mask=memory_mask,
tgt_key_padding_mask=tgt_key_padding_mask,
memory_key_padding_mask=memory_key_padding_mask)
if self.norm is not None:
output = self.norm(output)
return output
4.2 整个Transformer的实现
src
为输入语言序列,tgt
为输出语言序列,Encoder和Decoder的输出形状分别与src
和tgt
形状一致。
搭建顺序也就是,先Encoder,再Decoder。
class Transformer(nn.Module):
r'''
参数:
d_model: 词嵌入的维度(必备)(Default=512)
nhead: 多头注意力中平行头的数目(必备)(Default=8)
num_encoder_layers:编码层层数(Default=8)
num_decoder_layers:解码层层数(Default=8)
dim_feedforward: 全连接层的神经元的数目,又称经过此层输入的维度(Default = 2048)
dropout: dropout的概率(Default = 0.1)
activation: 两个线性层中间的激活函数,默认relu或gelu
custom_encoder: 自定义encoder(Default=None)
custom_decoder: 自定义decoder(Default=None)
lay_norm_eps: layer normalization中的微小量,防止分母为0(Default = 1e-5)
batch_first: 若`True`,则为(batch, seq, feture),若为`False`,则为(seq, batch, feature)(Default:False)
例子:
>>> transformer_model = Transformer(nhead=16, num_encoder_layers=12)
>>> src = torch.rand((10, 32, 512))
>>> tgt = torch.rand((20, 32, 512))
>>> out = transformer_model(src, tgt)
'''
def __init__(self, d_model: int = 512, nhead: int = 8, num_encoder_layers: int = 6,
num_decoder_layers: int = 6, dim_feedforward: int = 2048, dropout: float = 0.1,
activation = F.relu, custom_encoder: Optional[Any] = None, custom_decoder: Optional[Any] = None,
layer_norm_eps: float = 1e-5, batch_first: bool = False) -> None:
super(Transformer, self).__init__()
if custom_encoder is not None:
self.encoder = custom_encoder
else:
encoder_layer = TransformerEncoderLayer(d_model, nhead, dim_feedforward, dropout,
activation, layer_norm_eps, batch_first)
encoder_norm = nn.LayerNorm(d_model, eps=layer_norm_eps)
self.encoder = TransformerEncoder(encoder_layer, num_encoder_layers)
if custom_decoder is not None:
self.decoder = custom_decoder
else:
decoder_layer = TransformerDecoderLayer(d_model, nhead, dim_feedforward, dropout,
activation, layer_norm_eps, batch_first)
decoder_norm = nn.LayerNorm(d_model, eps=layer_norm_eps)
self.decoder = TransformerDecoder(decoder_layer, num_decoder_layers, decoder_norm)
self._reset_parameters()
self.d_model = d_model
self.nhead = nhead
self.batch_first = batch_first
def forward(self, src: Tensor, tgt: Tensor, src_mask: Optional[Tensor] = None, tgt_mask: Optional[Tensor] = None,
memory_mask: Optional[Tensor] = None, src_key_padding_mask: Optional[Tensor] = None,
tgt_key_padding_mask: Optional[Tensor] = None, memory_key_padding_mask: Optional[Tensor] = None) -> Tensor:
r'''
参数:
src: 源语言序列(送入Encoder)(必备)
tgt: 目标语言序列(送入Decoder)(必备)
src_mask: (可选)
tgt_mask: (可选)
memory_mask: (可选)
src_key_padding_mask: (可选)
tgt_key_padding_mask: (可选)
memory_key_padding_mask: (可选)
形状:
- src: shape:`(S, N, E)`, `(N, S, E)` if batch_first.
- tgt: shape:`(T, N, E)`, `(N, T, E)` if batch_first.
- src_mask: shape:`(S, S)`.
- tgt_mask: shape:`(T, T)`.
- memory_mask: shape:`(T, S)`.
- src_key_padding_mask: shape:`(N, S)`.
- tgt_key_padding_mask: shape:`(N, T)`.
- memory_key_padding_mask: shape:`(N, S)`.
[src/tgt/memory]_mask确保有些位置不被看到,如做decode的时候,只能看该位置及其以前的,而不能看后面的。
若为ByteTensor,非0的位置会被忽略不做注意力;若为BoolTensor,True对应的位置会被忽略;
若为数值,则会直接加到attn_weights
[src/tgt/memory]_key_padding_mask 使得key里面的某些元素不参与attention计算,三种情况同上
- output: shape:`(T, N, E)`, `(N, T, E)` if batch_first.
注意:
src和tgt的最后一维需要等于d_model,batch的那一维需要相等
例子:
>>> output = transformer_model(src, tgt, src_mask=src_mask, tgt_mask=tgt_mask)
'''
memory = self.encoder(src, mask=src_mask, src_key_padding_mask=src_key_padding_mask)
output = self.decoder(tgt, memory, tgt_mask=tgt_mask, memory_mask=memory_mask,
tgt_key_padding_mask=tgt_key_padding_mask,
memory_key_padding_mask=memory_key_padding_mask)
return output
def generate_square_subsequent_mask(self, sz: int) -> Tensor:
r'''产生关于序列的mask,被遮住的区域赋值`-inf`,未被遮住的区域赋值为`0`'''
mask = (torch.triu(torch.ones(sz, sz)) == 1).transpose(0, 1)
mask = mask.float().masked_fill(mask == 0, float('-inf')).masked_fill(mask == 1, float(0.0))
return mask
def _reset_parameters(self):
r'''用正态分布初始化参数'''
for p in self.parameters():
if p.dim() > 1:
xavier_uniform_(p)
可以使用下面小例子来测试输出维度
transformer_model = Transformer(nhead=16, num_encoder_layers=12)
src = torch.rand((10, 32, 512))
tgt = torch.rand((20, 32, 512))
out = transformer_model(src, tgt)
print(out.shape)
输出维度为torch.Size([20, 32, 512])
。
篇章小测
- 问题1: Transformer中的softmax计算为什么需要除以
d
k
d_k
dk?
这个问题描述得没有那么清楚,应该是softmax计算前为什么要除以 d k d_k dk,除以 d k d_k dk的过程也叫scaled。这一步的目的其实在前文有简单提及到,是为了后面训练的时候方便计算梯度,除以 d k d_k dk是为了减小数值,softmax 的梯度大一些,学得更快一些。
这里结合softmax函数就更好理解了
s o f t m a x ( z i ) = e z i ∑ c = 1 C e z i softmax(z_i) = \dfrac{e^{z_i}}{\sum^{C}_{c=1}e^{z_i}} softmax(zi)=∑c=1Ceziezi
当 z i z_i zi,也就是输入softmax的值都是很大的值时, s o f t m a x ( z i ) softmax(z_i) softmax(zi)的梯度变化会很小,训练会很慢。
这里可以类比 s i g m o i d sigmoid sigmoid的乘以一个很小的系数,使其梯度大些。 - 问题2: Transformer中attention score计算时候如何mask掉padding位置?
padding mask 是用来对齐输入序列的。因为每次的输入序列长度不同,为了对齐,我们会在较短序列后填0。
但在训练过程中,attention不应该把注意力放在这些位置上。所以我们引入padding mask,给这些填充位置加上 − I n f -Inf −Inf,使得softmax后这些位置的概率接近为0。 - 问题3: 为什么Transformer中加入了positional embedding?
Transformer解决了RNN长期依赖的问题,但是同时也丢失了序列的顺序信息,加入positional embedding补回顺序信息,可以使得模型可以关注到序列上下文信息。