文章目录

  • 写在最前边
  • 正文
    • 从高层面看
    • 图解张量
    • 现在我们来看一下编码器
      • 自注意力
      • 细说自注意力机制
      • 用矩阵计算self-attention
      • 多头注意力
      • 使用位置编码表示序列的位置
      • 残差
    • 解码器
    • 最后的线性层和softmax层
    • 训练过程概述
    • 损失函数
    • 更多内容
  • Jay Alammar’s Blog

写在最前边

看transformer相关文章的时候发现很多人用了相同的图。直到我搜到原作……于是去申请翻译了。

翻译讲究:信、达、雅。要在保障意思准确的情况下传递作者的意图,并且尽量让文本优美。
但是大家对我一个理工科少女的语言要求不要太高,本文只能保证在尽量通顺的情况下还原原文。

注意本文的组成部分:翻译 + 我的注释。

添加注释是因为在阅读的过程中,我感觉有的地方可能表述的并不是特别详细,对于一些真正的小白,像我一样傻的来说,可能不太好理解。

翻译的其他文章:

  • 图解GPT-2 | The Illustrated GPT-2 (Visualizing Transformer Language Models)

  • 图解BERT、ELMo(NLP中的迁移学习)| The Illustrated BERT, ELMo, and co.


正文

在之前的文章中,我们讲了现代神经网络常用的一种方法——Attention机制。

本文章我们来介绍一下Transformer——用注意力机制来提高模型训练速度的模型。
Transformer在某些特定任务上性能比谷歌的机器翻译模型更为优异。其优点在于并行化计算。并且谷歌云也推荐使用transformer作为参考模型来运行他们的TPU云服务。所以让我们来把它拆解开看一下它是如何运行的。

Transformer是在这篇论文中提出的:Attention is All You Need.

官方版TensorFlow实现:https://github.com/tensorflow/tensor2tensor

哈佛大学NLP组Pytorch实现:http://nlp.seas.harvard.edu/2018/04/03/attention.html

在本文中,我们会逐个概念进行介绍,希望能帮助没接触过Transformer的人能够更容易的理解。

从高层面看

我们先把整个Transformer模型看作是一个黑盒。在机器翻译中,它可以把句子从一种语言翻译成另一种语言。

打开这个黑盒,我们首先可以看到一个编码器(encoder)模块和一个解码器(decoder)模块,以及二者之间存在某种关联。

再往里看一下,编码器模块是6个encoder组件堆在一起,同样解码器模块也是6个decoder组件堆在一起。(为什么选6个呢?没有什么原因,论文原文就是这么写的,你也可以换成别的层数)


6个编码器组件的结构是相同的(但是他们之间的权重是不共享的),每个编码器都可以分为2个子层。

编码器的输入首先会进入一个自注意力层,这个注意力层的作用是:当要编码某个特定的词汇的时候,它会帮助编码器关注句子中的其他词汇。之后会进行详细讲解。

自注意力层的输出会传递给一个前馈神经网络,每个编码器组件都是在相同的位置使用结构相同的前馈神经网络。

解码器组件也含有前面编码器中提到的两个层,区别在于这两个层之间还夹了一个注意力层,多出来的这个自注意力层的作用是让解码器能够注意到输入句子中相关的部分(和seq2seq中的attention一样的作用)。

图解张量

现在我们要开始了解整个模型了。

在一个已经训练好的Transformer模型中,输入是怎么变为输出的呢?
首先我们要知道各种各样的张量或者向量是如何在这些组件之间变化的。

与其他的NLP项目一样,我们首先需要把输入的每个单词通过词嵌入(embedding)转化为对应的向量。

在原文中每个词的嵌入向量是512维,这里为了便于理解,就用这几个格子进行表示。

所有编码器接收一组向量作为输入,论文中的输入向量的维度是512。最底下的那个编码器接收的是嵌入向量,之后的编码器接收的是前一个编码器的输出。

向量长度这个超参数是我们可以设置的,一般来说是我们训练集中最长的那个句子的长度。

当我们的输入序列经过词嵌入之后得到的向量会依次通过编码器组件中的两个层。

在这里,我们开始看到Transformer的一个关键属性,即每个位置上的单词在编码器中有各自的流通方向。在自注意力层中,这些路径之间存在依赖关系。 然而,前馈神经网络中没有这些依赖关系,因此各种路径可以在流过前馈神经网络层的时候并行计算。

接下来,我们用一个短句(Thinking Machine)作为例子,看看在编码器的每个子层中发生了什么。

现在我们来看一下编码器

上边我们已经说了,每个编码器组件接受一组向量作为输入。在其内部,输入向量先通过一个自注意力层,再经过一个前馈神经网络,最后将其将输出给下一个编码器组件。

不同位置上的单词都要经过自注意力层的处理,之后都会经过一个完全相同的前馈神经网络。

自注意力

不要一看“self-attention”就觉得这是个每个人都很很熟悉的词,其实我个人感觉,在看《Attention is all you need》之前我都没有真正理解自注意力机制。现在让我们看一下自注意力机制。

假设我们要翻译下边这句话:
”The animal didn't cross the street because it was too tired”

这里it指的是什么?是street还是animal?人理解起来很容易,但是对算法来讲就不那么容易了。

当模型处理it这个词的时候,自注意力会让itanimal关联起来。

当模型编码每个位置上的单词的时候,自注意力的作用就是:看一看输入句子中其他位置的单词,试图寻找一种对当前单词更好的编码方式。

如果你熟悉RNNs模型,回想一下RNN如何处理当前时间步的隐藏状态:将之前的隐藏状态与当前位置的输入结合起来。
在Transformer中,自注意力机制也可以将其他相关单词的“理解”融入到我们当前处理的单词中。

当我们在最后一个encoder组建中对it进行编码的时候,注意力机制会更关注The animal,
并将其融入到it的编码中。

可以去Tensor2Tensor ,自己体验一下上图的可视化。

细说自注意力机制

先画图用向量解释一下自注意力是怎么算的,之后再看一下实际实现中是怎么用矩阵算的。

第一步 对编码器的每个输入向量都计算三个向量,就是对每个输入向量都算一个query、key、value向量。
怎么算的?
把输入的词嵌入向量与三个权重矩阵相乘。权重矩阵是模型训练阶段训练出来的。

注意,这三个向量维度是64,比嵌入向量的维度小,嵌入向量、编码器的输入输出维度都是512。这三个向量不是必须比编码器输入输出的维数小,这样做主要是为了让多头注意力的计算更稳定。

x 1x_{1} x1 W Q{W}^{Q} WQ 权重矩阵相乘得到 q 1{q}_{1} q1, 就得到与该单词 ( x 1)\left({x}_{1}\right) (x1) 相关的查询(query)。
按这样的方法,最终我们给输入的每一个单词都计算出一个“query”、一个 “key”和一个 “value”。

什么是 “query”、“key”、“value” 向量?

这三个向量是计算注意力时的抽象概念,继续往下看注意力计算过程,看完了就懂了。

第二步 计算注意力得分。

假设我们现在在计算输入中第一个单词Thinking的自注意力。我们需要使用自注意力给输入句子中的每个单词打分,这个分数决定当我们编码某个位置的单词的时候,应该对其他位置上的单词给予多少关注度。

这个得分是query和key的点乘积得出来的。

举个栗子,我们要算第一个位置的注意力得分的时候就要将第一个单词的query和其他的key依次相乘,在这里就是 q 1q_1 q1 ⋅· k 1k_1 k1 q 1q_1 q1 ⋅· k 2k_2 k2

第三步 将计算获得的注意力分数除以8。

为什么选8?是因为key向量的维度是64,取其平方根,这样让梯度计算的时候更稳定。默认是这么设置的,当然也可以用其他值。

第四步 除8之后将结果扔进softmax计算,使结果归一化,softmax之后注意力分数相加等于1,并且都是正数。

这个softmax之后的注意力分数表示 在计算当前位置的时候,其他单词受到的关注度的大小。显然在当前位置的单词肯定有一个高分,但是有时候也会注意到与当前单词相关的其他词汇。

第五步 将每个value向量乘以注意力分数。
这是为了留下我们想要关注的单词的value,并把其他不相关的单词丢掉。

在第一个单词位置得到新的 v 1v_1 v1

第六步 将上一步的结果相加,输出本位置的注意力结果。

第一个单词的注意力结果就是 z 1z_1 z1

这就是自注意力的计算。计算得到的向量直接传递给前馈神经网络。但是为了处理的更迅速,实际是用矩阵进行计算的。接下来我们看一下怎么用矩阵计算。

用矩阵计算self-attention

计算Query, Key, Value矩阵。直接把输入的向量打包成一个矩阵XX X,再把它乘以训练好的 W QW^Q WQ W KW^K WK W VW^V WV

XX X矩阵中的一行相当于输入句子中的一个单词。
我们看一下维度的差异:原文中嵌入矩阵的长度为 512 , q、k、vq、k、v qkv 矩阵的长度为 64 ;
在这里我们分别用 4 个格子表示和3个格子表示。

因为我们现在用矩阵处理,所以可以直接将之前的第二步到第六步压缩到一个公式中一步到位获得最终的注意力结果ZZ Z

多头注意力

论文进一步改进了自注意力层,增加了一个机制,也就是多头注意力机制。这样做有两个好处:

  1. 它扩展了模型专注于不同位置的能力。

    在上面例子里只计算一个自注意力的的例子中,编码“Thinking”的时候,虽然最后 Z 1Z_1 Z1或多或少包含了其他位置单词的信息,但是它实际编码中还是被“Thinking”单词本身所支配。

    如果我们翻译一个句子,比如“The animal didn’t cross the street because it was too tired”,我们会想知道“it”指的是哪个词,这时模型的“多头”注意力机制会起到作用。

  2. 它给了注意层多个“表示子空间”。

    就是在多头注意力中同时用多个不同的 W QW^Q WQ W KW^K WK W VW^V WV权重矩阵(Transformer使用8个头部,因此我们最终会得到8个计算结果),每个权重都是随机初始化的。经过训练每个 W QW^Q WQ W KW^K WK W VW^V WV都能将输入的矩阵投影到不同的表示子空间。

在多头注意力中, 我们给每个头单独的权重矩阵, 从而产生不同的Q、 K 、 矩阵。

Transformer中的一个多头注意力(有8个head)的计算,就相当于用自注意力做8次不同的计算,并得到8个不同的结果ZZ Z

但是这会存在一点问题,多头注意力出来的结果会进入一个前馈神经网络,这个前馈神经网络可不能一下接收8个注意力矩阵,它的输入需要是单个矩阵(矩阵中每个行向量对应一个单词),所以我们需要一种方法把这8个压缩成一个矩阵。

怎么做呢?我们将这些矩阵连接起来,然后将乘以一个附加的权重矩阵 W OW^O WO

以上就是多头自注意力的全部内容。让我们把多头注意力上述内容 放到一张图里看一下子:

现在我们已经看过什么是多头注意力了,让我们回顾一下之前的一个例子,再看一下编码“it”的时候每个头的关注点都在哪里:

编码it,用两个head的时候:其中一个更关注the animal,另一个更关注tired。
此时该模型对it的编码。除了it本身的表达之外,同时也包含了the animal和tired的相关信息

如果我们把所有的头的注意力都可视化一下,就是下图这样,但是看起来事情好像突然又复杂了。

使用位置编码表示序列的位置

强烈安利一个详细解释位置编码的文章:Transforme 结构:位置编码详解。

到现在我们还没提到过如何表示输入序列中词汇的位置。

Transformer在每个输入的嵌入向量中添加了位置向量。这些位置向量遵循某些特定的模式,这有助于模型确定每个单词的位置或不同单词之间的距离。将这些值添加到嵌入矩阵中,一旦它们被投射到Q、K、V中,就可以在计算点积注意力时提供有意义的距离信息。

为了让模型能知道单词的顺序,我们添加了位置编码,位置编码是遵循某些特定模式的。

位置编码向量和嵌入向量的维度是一样的,比如下边都是四个格子:

举个例子,当嵌入向量的长度为4的时候,位置编码长度也是4

一直说位置向量遵循某个模式,这个模式到底是什么。

在下面的图中,每一行对应一个位置编码。所以第一行就是我们输入序列中第一个单词的位置编码,之后我们要把它加到词嵌入向量上。

看个可视化的图:

这里表示的是一个句子有20个词,词嵌入向量的长度为512。
可以看到图像从中间一分为二,因为左半部分是由正弦函数生成的。右半部分由余弦函数生成。
然后将它们二者拼接起来,形成了每个位置的位置编码。

你可以在get_timing_signal_1d()中看到生成位置编码的代码。
这不是位置编码的唯一方法。但是使用正余弦编码有诸多好处,具体可以看这里:Transforme 结构:位置编码详解

但是需要注意注意一点,上图的可视化是官方Tensor2Tensor库中的实现方法,将sin和cos拼接起来。但是和论文原文写的不一样,论文原文的3.5节写了位置编码的公式,论文不是将两个函数concat起来,而是将sin和cos交替使用。论文中公式的写法可以看这个代码:transformer_positional_encoding_graph,其可视化结果如下:

这里表示的是一个句子有10个词,词嵌入向量的长度为64。

残差

在继续往下讲之前,我们还需再提一下编码器中的一个细节:每个编码器中的每个子层(自注意力层、前馈神经网络)都有一个残差连接,之后是做了一个层归一化(layer-normalization)。

将过程中的向量相加和layer-norm可视化如下所示:

当然在解码器子层中也是这样的。

我们现在画一个有两个编码器和解码器的Transformer,那就是下图这样的:

解码器

现在我们已经介绍了编码器的大部分概念,(因为encoder的decoder组件差不多)我们基本上也知道了解码器的组件是如何工作的。那让我们直接看看二者是如何协同工作的。

编码器首先处理输入序列,将最后一个编码器组件的输出转换为一组注意向量K和V。每个解码器组件将在“encoder-decoder attention”层中使用编码器传过来的K和V,这有助于解码器将注意力集中在输入序列中的适当位置:

完成编码阶段后,我们开始进行解码阶段。
在解码阶段每一轮计算都只往外蹦一个输出,在本例中是输出一个翻译之后的英语单词。

输出步骤会一直重复,直到遇到句子结束符 表明transformer的解码器已完成输出。

每一步的输出都会在下一个时间步喂给给底部解码器,解码器会像编码器一样运算并输出结果(每次往外蹦一个词)。

跟编码器一样,在解码器中我们也为其添加位置编码,以指示每个单词的位置。

解码器中的自注意力层和编码器中的不太一样:

在解码器中,自注意力层只允许关注已输出位置的信息。实现方法是在自注意力层的softmax之前进行mask,将未输出位置的信息设为极小值。

“encoder-decoder attention”层的工作原理和前边的多头自注意力差不多,但是Q、K、V的来源不用,Q是从下层创建的(比如解码器的输入和下层decoder组件的输出),但是其K和V是来自编码器最后一个组件的输出结果。

最后的线性层和softmax层

Decoder输出的是一个浮点型向量,如何把它变成一个词?

这就是最后一个线性层和softmax要做的事情。

线性层就是一个简单的全连接神经网络,它将解码器生成的向量映射到logits向量中。
假设我们的模型词汇表是10000个英语单词,它们是从训练数据集中学习的。那logits向量维数也是10000,每一维对应一个单词的分数。

然后,softmax层将这些分数转化为概率(全部为正值,加起来等于1.0),选择其中概率最大的位置的词汇作为当前时间步的输出。

这张图从下往上看,假设具体上的那个向量是解码器的输出,然后将其转换为最终输出的单词。

训练过程概述

现在我们已经了解了Transformer的整个前向传播的过程,那我们继续看一下训练过程。
在训练期间,未经训练的模型会进行相同的前向传播过程。由于我们是在有标记的训练数据集上训练它,所以我们可以将其输出与实际的输出进行比较。

为了便于理解,我们假设预处理阶段得到的词汇表只包含六个单词(“a”, “am”, “i”, “thanks”, “student”, “”)。

注意这个词汇表是在预处理阶段就创建的,在训练之前就已经得到了。

一旦我们定义好了词汇表,我们就可以使用长度相同的向量(独热码,one-hot 向量)来表示词汇表中的每个单词。例如,我们可以用以下向量表示单词“am”:

接下来让我们讨论一下模型的损失函数,损失函数是我们在训练阶段优化模型的指标,通过损失函数,可以帮助我们获得一个准确的、我们想要的模型。

损失函数

假设我们正要训练我们的模型。

假设现在是训练阶段的第一步,我们用一个简单的例子(一个句子就一个词)来训练模型:把 “merci” 翻译成 “thanks”
这意味着,我们希望输出是表示“谢谢”的概率分布。但由于这个模型还没有经过训练,所以目前还不太可能实现。

由于模型的参数都是随机初始化的,未经训练的模型为每个单词生成任意的概率分布。
我们可以将其与实际输出进行比较,然后使用反向传播调整模型的权重,使输出更接近我们所需要的值。

如何比较两种概率分布?在这个例子中我们只是将二者相减。实际应用中的损失函数请查看交叉熵损失和Kullback–Leibler散度。

上述只是最最简单的一个例子。现在我们来使用一个短句子(一个词的句子升级到三三个词的句子了),比如输入 “je suis étudiant” 预期的翻译结果为: “i am a student”

所以我们希望模型不是一次输出一个词的概率分布了,能不能连续输出概率分布,最好满足下边要求:

  • 每个概率分布向量长度都和词汇表长度一样。我们的例子中词汇表长度是6,实际操作中一般是30000或50000。
  • 在我们的例子中第一个概率分布应该在与单词“i”相关的位置上具有最高的概率
  • 第二种概率分布在与单词“am”相关的单元处具有最高的概率
  • 以此类推,直到最后输出分布指示“”符号。除了单词本身之外,单词表中也应该包含诸如“”的信息,这样softmax之后指向“”位置,标志解码器输出结束。 对应上面的单词表,我们可以看出这里的one-hot向量是我们训练之后想要达到的目标。

在足够大的数据集上训练模型足够长的时间后,我们希望生成的概率分布如下所示:

这个是我们训练之后最终得到的结果。当然这个概率并不能表明某个词是否是训练集之中的词汇。
在这里你可以看到softmax的一个特性,就是即使其他单词并不是本时间步的输出,
也会有一丁点的概率存在,这一特性有助于帮助模型进行训练。

模型一次产生一个输出,在这么多候选中我们如何获得我们想要的输出呢?现在有两种处理结果的方法:

一种是贪心算法(greedy decoding):模型每次都选择分布概率最高的位置,输出其对应的单词。

另一种方法是束搜索(beam search):保留概率最高前两个单词(例如,“I”和“a”),然后在下一步继续选择两个概率最高的值,以此类推,在这里我们把束搜索的宽度设置为2,当然你也可以设置其他的束搜索宽度。

更多内容

如果你想更深入了解Transformer:

  • 阅读论文原文Attention Is All You Need、 谷歌相关博客Transformer: A Novel Neural Network Architecture for Language Understanding和Tensor2Tensor announcement。

  • 观看Łukasz Kaiser的授课视频。

  • 自己尝试一下代码:Jupyter Notebook provided as part of the Tensor2Tensor repo

  • 探究其源码https://github.com/tensorflow/tensor2tensor


Jay Alammar’s Blog


作者博客:@Jay Alammar
原文链接:The Illustrated Transformer