语言是人际交流的本质。如果没有构成语言的词序,文明就不会诞生。我们现在生活在数字化语言功能的世界中。我们的日常生活依赖于NLP数字化语言功能:网络搜索引擎、电子邮件、社交网络、帖子、推文、智能手机短信、翻译、网页、流媒体站点的语音转文字以获取剧本、热线服务上的文本转语音以及许多其他日常功能。
第1章"什么是变换器?"解释了RNN的限制以及云AI变换器正在占据设计和开发的很大份额。工业4.0开发者的角色是理解原始变换器的架构以及随后出现的多个变换器生态系统。
2017年12月,Google Brain和Google Research发表了具有重要意义的Vaswani等人的《注意力就是一切》论文。变换器诞生了。变换器胜过了现有的最先进的NLP模型。变换器的训练速度比以前的架构快,并获得了更高的评估结果。因此,变换器已成为NLP的关键组成部分。
变换器的注意力头的想法是放弃递归神经网络的特征。在本章中,我们将打开由Vaswani等人(2017年)描述的变换器模型的内部,审查其架构的主要组成部分。我们将探索注意力的迷人世界,并阐明变换器的关键组件。
本章涵盖以下主题:
- 变换器的架构
- 变换器的自注意力模型
- 编码和解码堆栈
- 输入和输出嵌入
- 位置嵌入
- 自注意力
- 多头注意力
- 掩蔽多头注意力
- 残差连接
- 标准化
- 前馈网络
- 输出概率
让我们直接深入原始变换器架构的结构。
变换器的崛起:注意力就是一切
在2017年12月,瓦斯瓦尼等人(2017)发表了他们的重要论文《注意力就是一切》。他们在Google Research和Google Brain进行了这项工作。在本章和本书中,我将称原始的Transformer模型为"原始Transformer模型"。
在这一部分,我们将看一下他们构建的Transformer模型的结构。在接下来的部分中,我们将探讨模型的每个组件内部的内容。
原始的Transformer模型是一个由6个层叠加而成的结构。第l层的输出是第l+1层的输入,直到达到最终的预测。左侧有一个6层的编码器堆叠,右侧有一个6层的解码器堆叠:
在左侧,输入通过一个注意力子层和一个前馈子层进入Transformer的编码器端。在右侧,目标输出通过两个注意力子层和一个前馈网络子层进入Transformer的解码器端。我们立即注意到,这里没有RNN、LSTM或CNN。在这种架构中放弃了循环结构。
注意力机制取代了需要增加参数的循环功能,因为两个单词之间的距离增加。注意力机制是一个"单词对单词"的操作。实际上,它是一个令牌对令牌的操作,但为了简化解释,我们将保持在单词级别。注意力机制将找出每个单词与序列中的所有其他单词(包括正在分析的单词本身)之间的关系。让我们来看下面的序列:
bash
The cat sat on the mat.
注意力将对单词向量进行点积运算,并确定一个单词与所有其他单词(包括它本身的情况,如"猫"和"猫")之间的最强关系:
对于每个注意力子层,原始Transformer模型并行运行八个注意力机制,以加速计算。我们将在下一节"编码器堆栈"中探讨这个架构。这个过程称为"多头注意力",提供了:
- 对序列的更广泛的深入分析
- 减少计算操作,从而排除了循环操作
- 并行化实施,减少了训练时间
- 每个注意力机制学习相同输入序列的不同视角。
我们刚刚从外部看了Transformer的结构。现在让我们深入了解Transformer的每个组件。我们将从编码器开始。
编码器堆栈
原始Transformer模型的编码器和解码器的层是层叠的。编码器堆栈的每一层具有以下结构:
原始Transformer模型的每个编码器层结构在N=6层中保持不变。每个层包含两个主要的子层:一个多头注意机制和一个完全连接的位置感知的前馈网络。
请注意,每个Transformer模型的主子层sublayer(x)周围都有一个残差连接。这些连接将子层的未处理输入x传输到层标准化函数。这样,我们确保了关键信息,如位置编码,不会在传输过程中丢失。因此,每层的标准化输出如下:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> L a y e r N o r m a l i z a t i o n ( x + S u b l a y e r ( x ) ) LayerNormalization(x + Sublayer(x)) </math>LayerNormalization(x+Sublayer(x))
尽管编码器的每个N=6层的结构相同,但每层的内容与前一层并不完全相同。
例如,嵌入子层仅存在于堆栈的底层。其他五个层不包含嵌入层,这保证了通过所有层的编码输入是稳定的。
此外,多头注意机制从第1层到第6层执行相同的功能。但它们不执行相同的任务。每一层都从前一层中学习并探索关联序列中令牌的不同方式。它寻找词汇的各种关联,就像我们在解纵横字谜时寻找字母和单词的不同关联一样。
Transformer的设计者引入了一个非常高效的约束。模型的每个子层的输出都具有恒定的维度,包括嵌入层和残差连接。这个维度是dmodel,根据您的目标可以设置为其他值。在原始Transformer架构中, <math xmlns="http://www.w3.org/1998/Math/MathML"> d m o d e l = 512 dmodel = 512 </math>dmodel=512。
<math xmlns="http://www.w3.org/1998/Math/MathML"> d m o d e l dmodel </math>dmodel有一个强大的结果。几乎所有关键操作都是点积。因此,维度保持稳定,减少了计算操作的数量,降低了机器消耗,并使跟踪信息在模型中流动变得更容易。
这种对编码器的全局视图展示了Transformer的高度优化架构。在接下来的章节中,我们将深入研究每个子层和机制。
我们将从嵌入子层开始。
输入嵌入
输入嵌入子层使用原始Transformer模型中的学习嵌入将输入标记转换为512维度的向量。输入嵌入的结构是传统的:
嵌入子层的工作原理类似于其他标准的转换模型。分词器将句子转换为标记。每个分词器都有自己的方法,例如BPE、word piece和sentence piece方法。初始的Transformer使用了BPE,但其他模型使用了其他方法。
目标是相似的,选择取决于所选择的策略。例如,应用于序列的分词器是the Transformer is an innovative NLP model!
将在某一类型的模型中产生以下标记:
css
['the', 'transform', 'er', 'is', 'an', 'innovative', 'n', 'l', 'p', 'model', '!']
您会注意到,这个分词器将字符串规范化为小写并将其截断为子部分。分词器通常会提供一个整数表示,该表示将用于嵌入过程。例如:
yaml
text = "The cat slept on the couch.It was too tired to get up."
tokenized text= [1996, 4937, 7771, 2006, 1996, 6411, 1012, 2009, 2001, 2205, 5458, 2000, 2131, 2039, 1012]
在这一点上,标记化的文本中没有足够的信息来进一步处理。标记化的文本必须进行嵌入。
Transformer 包含一个经过学习的嵌入子层。可以将许多嵌入方法应用于标记化的输入。
我选择了 Google 在 2013 年提供的 word2vec 嵌入方法的 skip-gram 架构,来说明 Transformer 的嵌入子层。skip-gram 会关注窗口中的中心词,并预测上下文词汇。例如,如果 word(i) 是窗口中的中心词,在一个两步窗口内,skip-gram 模型将分析 word(i-2)、word(i-1)、word(i+1) 和 word(i+2)。然后窗口会滑动并重复这个过程。skip-gram 模型通常包括一个输入层、权重、一个隐藏层以及包含标记化的输入词汇的单词嵌入的输出。
假设我们需要为以下句子执行嵌入:
The black cat sat on the couch and the brown dog slept on the rug.
我们将关注两个词,"black" 和 "brown"。这两个词的词嵌入向量应该是相似的。
由于我们必须为每个词生成大小为 dmodel = 512 的向量,所以我们将为每个词获得一个大小为 512 的向量嵌入:
diff
black=[[-0.01206071 0.11632373 0.06206119 0.01403395 0.09541149 0.10695464 0.02560172 0.00185677 -0.04284821 0.06146432 0.09466285 0.04642421 0.08680347 0.05684567 -0.00717266 -0.03163519 0.03292002 -0.11397766 0.01304929 0.01964396 0.01902409 0.02831945 0.05870414 0.03390711 -0.06204525 0.06173197 -0.08613958 -0.04654748 0.02728105 -0.07830904
...
0.04340003 -0.13192849 -0.00945092 -0.00835463 -0.06487109 0.05862355 -0.03407936 -0.00059001 -0.01640179 0.04123065
-0.04756588 0.08812257 0.00200338 -0.0931043 -0.03507337 0.02153351 -0.02621627 -0.02492662 -0.05771535 -0.01164199
-0.03879078 -0.05506947 0.01693138 -0.04124579 -0.03779858
-0.01950983 -0.05398201 0.07582296 0.00038318 -0.04639162
-0.06819214 0.01366171 0.01411388 0.00853774 0.02183574
-0.03016279 -0.03184025 -0.04273562]]
现在,单词 "black" 由 512 维度表示。也可以使用其他嵌入方法,并且 dmodel 可以具有更高维度的数量。
单词 "brown" 的词嵌入同样由 512 维度表示:
lua
brown=[[ 1.35794589e-02 -2.18823571e-02 1.34526128e-02 6.74355254e-02 1.04376070e-01 1.09921647e-02 -5.46298288e-02 -1.18385479e-02 4.41223830e-02 -1.84863899e-02 -6.84073642e-02 3.21860164e-02 4.09143828e-02 -2.74433400e-02 -2.47369967e-02 7.74542615e-02 9.80964210e-03 2.94299088e-02 2.93895267e-02 -3.29437815e-02 ... 7.20389187e-02 1.57317147e-02 -3.10291946e-02 -5.51304631e-02 -7.03861639e-02 7.40829483e-02 1.04319192e-02 -2.01565702e-03 2.43322570e-02 1.92969330e-02 2.57341694e-02 -1.13280728e-01 8.45847875e-02 4.90090018e-03 5.33546880e-02 -2.31553353e-02 3.87288055e-05 3.31782512e-02 -4.00604047e-02 -1.02028981e-01 3.49597558e-02 -1.71501152e-02 3.55573371e-02 -1.77437533e-02 -5.94457164e-02 2.21221056e-02 9.73121971e-02 -4.90022525e-02]]
要验证这两个单词生成的词嵌入是否相似,我们可以使用余弦相似性来查看单词 "black" 和 "brown" 的词嵌入是否相似。
余弦相似性使用欧几里得(L2)范数创建单位球中的向量。我们正在比较的向量的点积是这两个向量的点之间的余弦。有关余弦相似性理论的更多信息,您可以参考 scikit-learn 的文档,以及其他许多来源:scikit-learn.org/stable/modu...
在示例的嵌入中,大小为 dmodel = 512 的 "black" 向量与大小为 dmodel = 512 的 "brown" 向量之间的余弦相似度为:
lua
cosine_similarity(black, brown)= [[0.9998901]]
Skip-gram 生成了两个接近的向量。它检测到 "black" 和 "brown" 形成了单词字典中的颜色子集。
Transformer 的后续层不是从零开始的。它们已经学到了单词嵌入,这些嵌入已经提供了关于单词如何关联的信息。
然而,缺失了大量信息,因为没有额外的向量或信息表明单词在序列中的位置。
Transformer 的设计者提出了另一个创新性的特点:位置编码。
让我们看看位置编码是如何工作的。
位置编码
我们进入 Transformer 的这个位置编码函数时,对于单词在序列中的位置一无所知:
我们无法创建独立的位置向量,因为这会显著降低 Transformer 的训练速度,并使注意力子层过于复杂。这个想法是将位置编码值添加到输入嵌入中,而不是使用额外的向量来描述序列中标记的位置。
当我们回到在词嵌入子层使用的那个句子时,我们可以看到"black"和"brown"可能在语义上相似,但它们在句子中相隔很远:
csharp
The `black` cat sat on the couch and the `brown` dog slept on the rug.
词语"black"在位置2,pos=2,而词语"brown"在位置10,pos=10。
我们的问题是找到一种方法,可以为每个词的词嵌入添加一个值,以便它包含这一信息。但是,我们需要在dmodel = 512维度中添加一个值!对于每个词嵌入向量,我们需要找到一种方法,以提供信息给词嵌入向量"black"和"brown"的维度i(i在范围(0,512)内)。
有很多方法可以实现位置编码。本节将重点介绍设计者巧妙地使用单位球来表示位置编码的方法,该方法使用正弦和余弦值,因此这些值将保持较小但有用。
Vaswani等人(2017)提供了正弦和余弦函数,以便我们可以为每个位置和dmodel = 512的词嵌入向量的每个维度i生成不同频率的位置编码(PE):
如果我们从词嵌入向量的开始开始,我们将从一个常数(512)开始,i=0,然后结束于i=511。这意味着正弦函数将应用于偶数,余弦函数将应用于奇数。一些实现可能有所不同。在这种情况下,正弦函数的域可以是,余弦函数的域可以是。这将产生类似的结果。
在本节中,我们将按照Vaswani等人(2017)的描述使用这些函数。将其直译为Python伪代码会产生以下用于位置向量pe[0][i]的代码,其中位置为pos:
arduino
def positional_encoding(pos,pe):
for i in range(0, 512,2):
pe[0][i] = math.sin(pos / (10000 ** ((2 * i)/d_model)))
pe[0][i+1] = math.cos(pos / (10000 ** ((2 * i)/d_model)))
return pe
在继续之前,您可能希望查看正弦函数的图表,例如,对于pos=2。
您可以尝试在Google上查找以下图表,例如:
ini
plot y=sin(2/10000^(2*x/512))
只需输入绘图请求:
你会得到以下的图表:
如果我们回到本节中分析的句子,我们可以看到"black"位于位置pos=2,"brown"位于位置pos=10:
csharp
The black cat sat on the couch and the brown dog slept on the rug.
如果我们按字面意思应用正弦和余弦函数,对于pos=2,我们将获得一个大小为512的位置编码向量:
lua
PE(2)=
[[ 9.09297407e-01 -4.16146845e-01 9.58144367e-01 -2.86285430e-01
9.87046242e-01 -1.60435960e-01 9.99164224e-01 -4.08766568e-02
9.97479975e-01 7.09482506e-02 9.84703004e-01 1.74241230e-01
9.63226616e-01 2.68690288e-01 9.35118318e-01 3.54335666e-01
9.02130723e-01 4.31462824e-01 8.65725577e-01 5.00518918e-01
8.27103794e-01 5.62049210e-01 7.87237823e-01 6.16649508e-01
7.46903539e-01 6.64932430e-01 7.06710517e-01 7.07502782e-01
...
5.47683925e-08 1.00000000e+00 5.09659337e-08 1.00000000e+00
4.74274735e-08 1.00000000e+00 4.41346799e-08 1.00000000e+00
4.10704999e-08 1.00000000e+00 3.82190599e-08 1.00000000e+00
3.55655878e-08 1.00000000e+00 3.30963417e-08 1.00000000e+00
3.07985317e-08 1.00000000e+00 2.86602511e-08 1.00000000e+00
2.66704294e-08 1.00000000e+00 2.48187551e-08 1.00000000e+00
2.30956392e-08 1.00000000e+00 2.14921574e-08 1.00000000e+00]]
对于位置10,pos=10,我们还获得了一个大小为512的位置编码向量:
lua
PE(10)=
[[-5.44021130e-01 -8.39071512e-01 1.18776485e-01 -9.92920995e-01
6.92634165e-01 -7.21289039e-01 9.79174793e-01 -2.03019097e-01
9.37632740e-01 3.47627431e-01 6.40478015e-01 7.67976522e-01
2.09077001e-01 9.77899194e-01 -2.37917677e-01 9.71285343e-01
-6.12936735e-01 7.90131986e-01 -8.67519796e-01 4.97402608e-01
-9.87655997e-01 1.56638563e-01 -9.83699203e-01 -1.79821849e-01
...
2.73841977e-07 1.00000000e+00 2.54829672e-07 1.00000000e+00
2.37137371e-07 1.00000000e+00 2.20673414e-07 1.00000000e+00
2.05352507e-07 1.00000000e+00 1.91095296e-07 1.00000000e+00
1.77827943e-07 1.00000000e+00 1.65481708e-07 1.00000000e+00
1.53992659e-07 1.00000000e+00 1.43301250e-07 1.00000000e+00
1.33352145e-07 1.00000000e+00 1.24093773e-07 1.00000000e+00
1.15478201e-07 1.00000000e+00 1.07460785e-07 1.00000000e+00]]
当我们查看使用 Vaswani 等人 (2017) 的函数进行 Python 直观文字转化后获得的结果时,我们希望检查结果是否有意义。
用于单词嵌入的余弦相似性函数对于更好地可视化位置的相近程度很有用:
lua
cosine_similarity(pos(2), pos(10))= [[0.8600013]]
单词 "black" 和 "brown" 的位置之间的相似度与词汇领域(一起出现的单词组)的相似度是不同的:
lua
cosine_similarity(black, brown)= [[0.9998901]]
位置编码显示出较低的相似度值,而词嵌入的相似度较高。
位置编码已将这些单词分开。请注意,词嵌入会随着用于训练它们的语料库而变化。现在的问题是如何将位置编码添加到词嵌入向量中。
将位置编码添加到嵌入向量中
Transformer的作者们找到了一个简单的方法,只需将位置编码向量添加到词嵌入向量中:
如果我们回到并获取例如"black"的词嵌入,将其命名为y1 = black,我们准备将其添加到我们通过位置编码函数获得的位置向量pe(2)中。这样,我们就得到了输入词"black"的位置编码pc(black):
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> p c ( b l a c k ) = y 1 + p e ( 2 ) pc(black) = y1 + pe(2) </math>pc(black)=y1+pe(2)
解决方案很简单。但是,如果我们按照所示的方式应用它,可能会丢失词嵌入的信息,这将被位置编码向量最小化。
有许多可能性可以增加y1的值,以确保词嵌入层的信息在后续层中能够有效使用。
其中一种可能性是向y1,也就是"black"的词嵌入,添加一个任意值:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> y 1 ∗ m a t h . s q r t ( d m o d e l ) y1 * math.sqrt(d_model) </math>y1∗math.sqrt(dmodel)
现在,我们可以将位置向量添加到词嵌入向量"black"的中,它们都具有相同的大小(512):
ini
for i in range(0, 512,2):
pe[0][i] = math.sin(pos / (10000 ** ((2 * i)/d_model)))
pc[0][i] = (y[0][i]*math.sqrt(d_model))+ pe[0][i]
pe[0][i+1] = math.cos(pos / (10000 ** ((2 * i)/d_model)))
pc[0][i+1] = (y[0][i+1]*math.sqrt(d_model))+ pe[0][i+1]
得到的结果是最终的维度为dmodel = 512的位置编码向量:
lua
pc(black)=
[[ 9.09297407e-01 -4.16146845e-01 9.58144367e-01 -2.86285430e-01
9.87046242e-01 -1.60435960e-01 9.99164224e-01 -4.08766568e-02
...
4.74274735e-08 1.00000000e+00 4.41346799e-08 1.00000000e+00
4.10704999e-08 1.00000000e+00 3.82190599e-08 1.00000000e+00
2.66704294e-08 1.00000000e+00 2.48187551e-08 1.00000000e+00
2.30956392e-08 1.00000000e+00 2.14921574e-08 1.00000000e+00]]
同样的操作也适用于单词"brown"和序列中的所有其他单词。
我们可以对单词"black"和"brown"的位置编码向量应用余弦相似度函数:
lua
cosine_similarity(pc(black), pc(brown))= [[0.9627094]]
通过我们应用于代表单词"black"和"brown"的三个状态的三个余弦相似性函数,我们现在清楚地了解了位置编码过程:
lua
[[0.99987495]] word similarity
[[0.8600013]] positional encoding vector similarity
[[0.9627094]] final positional encoding similarity
我们看到,它们的初始词嵌入的相似度很高,值为0.99。然后,我们看到位置2和10的位置编码向两个单词拉开,余弦相似性的值降低到0.86。
最后,我们将每个单词的词嵌入向量添加到其相应的位置编码向量中。我们看到这将这两个单词的余弦相似性提高到了0.96。
现在,每个单词的位置编码包含了初始的词嵌入信息和位置编码值。
位置编码的输出导致了多头注意力子层。
子层 1:多头注意力
多头注意力子层包含八个头,之后跟着后层归一化,将在子层的输出上添加残差连接并进行归一化:
这一部分首先介绍了一个注意力层的架构。然后,在Python中实现了一个多头注意力的示例模块。最后,描述了后层归一化。
让我们从多头注意力的架构开始。
多头注意力的架构
在多头注意力子层的第一层编码器堆栈的输入是一个包含每个单词的嵌入和位置编码的向量。堆栈的后续层不会重新开始这些操作。
输入序列中每个单词xn的向量的维度为dmodel = 512:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> p e ( x n ) = [ d 1 = 9.09297407 e − 01 , d 2 = − 4.16146845 e − 01 , . . , d 512 = 1.00000000 e + 00 ] pe(xn)=[d1=9.09297407e-01, d2=-4.16146845e-01, .., d512=1.00000000e+00] </math>pe(xn)=[d1=9.09297407e−01,d2=−4.16146845e−01,..,d512=1.00000000e+00]
每个单词xn的表示已变成了一个512维的向量。
每个单词都会映射到所有其他单词,以确定它在序列中的位置。
在下面的句子中,我们可以看到它可能与序列中的"cat"和"rug"相关:
ini
Sequence =The cat sat on the rug and it was dry-cleaned.
模型将被训练以确定它是否与"cat"或"rug"相关。我们可以通过使用现在的dmodel = 512维度来训练模型进行大量的计算。
然而,通过使用一个dmodel块分析序列,我们一次只会得到一个观点。此外,要找到其他观点将需要相当长的计算时间。
更好的方法是将x(序列中的所有单词)的每个单词xn的dmodel = 512维度划分为8个dk = 64维度。
然后,我们可以并行运行这8个"头部"以加快训练速度,并获得每个单词与其他单词相关的8个不同表示子空间: