400760664_wx.jpg

导语


Google BERT 模型最近横扫了各大评测任务,在多项任务中取得了最好的结果,而且很多任务比之前最好的系统都提高了非常多,可以说是深度学习最近几年在 NLP的一大突破。但它并不是凭空出现的,最近一年大家都非常关注的 UnsupervisedSentence Embedding 取得了很大的进展,包括 ELMo 和 OpenAI GPT 等模型都取得了很好的结果。而 BERT 在它们的基础上改进了语言模型单向信息流的问题,并且借助 Google 强大的工程能力和计算资源的优势,从而取得了巨大的突破。


本文从理论和编程实战角度详细的介绍 BERT 和它之前的相关的模型,包括

Transformer 模型。希望读者阅读本文之后既能理解模型的原理,同时又能很快的把模型用于解决实际问题。本文假设读者了解基本的深度学习知识包括 RNN/LSTM、Encoder-Decoder 和 Attention 等。


Sentence Embedding 简介


前面我们介绍了 Word Embedding,怎么把一个词表示成一个稠密的向量。Embedding几乎是在 NLP 任务使用深度学习的标准步骤。我们可以通过 Word2Vec、GloVe 等从未标注数据无监督的学习到词的 Embedding,然后把它用到不同的特定任务中。这种方法得到的 Embedding 叫作预训练的 (pretrained)Embedding。如果特定任务训练数据较多,那么我们可以用预训练的 Embedding 来初始化模型的 Embedding,然后用特定任务的监督数据来 fine-tuning。如果监督数据较少,我们可以固定 (fix)Embedding,只让模型学习其它的参数。这也可以看成一种 Transfer Learning。


但是 NLP 任务的输入通常是句子,比如情感分类,输入是一个句子,输出是正向或者负向的情感。我们需要一种机制表示一个句子,最常见的方法是使用 CNN 或者 RNN 对句子进行编码。用来编码的模块叫作编码器 (Encoder),编码的输出是一个向量。和词向量一样,我们期望这个向量能够很好的把一个句子映射到一个语义空间,相似的句子映射到相近的地方。编码句子比编码词更加复杂,因为词组成句子是有结构的 (我们之前的 Paring 其实就是寻找这种结构),两个句子即使词完全相同但是词的顺序不同,语义也可能相差很大。


传统的编码器都是用特定任务的监督数据训练出来的,它编码的目的是为了优化具体这个任务。因此它编码出的向量是适合这个任务的——如果这个任务很关注词序,那么它在编码的使用也会关注词序;如果这个任务关注构词法,那么学到的编码器也需要关注构词法。


但是监督数据总是很少的,获取的成本也极高。因此最近 (2018 年上半年),无监督的通用 (universal) 的句子编码器成为热点并且有了一些进展。无监督的意思是可以使用未标注的原始数据来学习编码器 (的参数),而通用的意思是学习到的编码器不需要 (太多的)fine-tuning 就可以直接用到所有 (只是是很多) 不同的任务中,并且能得到很好的效果。


评测工具


在介绍 Unsupervised Sentence Embedding 的具体算法之前我们先介绍两个评测工具(平台)。


SentEval


  • 简介


Sentence Embedding(包括 Word Embedding) 通常有两类评价方法:intrinsic 和 ex-trinsic。前者只评价 Embedding 本身,比如让人来主观评价。而后者通过下游 (Downstream) 的任务间接的来评价 Embedding 的好坏。前一种方法耗费人力,而且我们学习 Embedding 的目的也是为了解决后面的真实问题,因此 extrinsic 的评价更加重要。但是下游的任务通常很复杂,Embedding 只是其中的一个环节,因此很难说明最终效果的提高就是由于 Embedding 带来的,也许只是某个预处理或者超参数的调节带来的提高,但是却可能被作者认为是 Embedding 的功劳。另外下游任务很多,很多文章的结果也很难比较。


为了解决这些问题,Facebook 做了 SentEval 这个工具。这是一个用于评估Universal Sentence Representation 的工具,所谓的 Universal Sentence Representation是指与特定任务无关的通用的句子表示 (Embedding) 方法。为了保证公平公正,这个工具只评价句子的 Embedding,对于具体的任务,大家都使用相同的预处理,网络结构和后处理,从而能够保证比较公平的评测。


SentEval 任务分类


SentEval 任务分为如下类别:


  • 分类问题 (包括二分类和多分类)

  • Natural Language Inference

  • 语义相似度计算

  • 图像检索 (Image Retrieval)


640?wx_fmt=png

图 17.1: SentEval 的分类任务


分类很简单,输入是一个字符串 (一个句子或者文章),输出是一个分类标签。所以任务如图17.1所示。包括情感分类、句子类型分类等等任务。


Natural Language Inference(NLI) 任务也叫 recognizing textual entailment(RTE),它的输入是两个句子,需要机器判断第一个句子和第二个句子的关系。它们的关系通常有 3 种:矛盾 (contradiction)、无关 (neutral) 和蕴含 (entailment)。


SNLI(https://nlp.stanford.edu/projects/snli/) 是很常用的 NLI 数据集,示例是来自这个数据集的例子。比如下面的两个句子是矛盾的:


A man inspects the uniform of a figure in some East Asian country.

The man is sleeping.


一个人不能同时在观察和睡觉。而下面两个句子的关系是无关的:


A smiling costumed woman is holding an umbrella.

A happy woman in a fairy costume holds an umbrella.


而下面两个的第一个句子蕴含了第二个句子:

A soccer game with multiple males playing.

Some men are playing a sport.


语义相似度计算的输入是两个句子,输出是它们的相似度,一般相似度会分为几个程度,所以输出也是标签。当然最简单的是分成两类——相似与不相似,比如MRPC 就是这样的任务,这个任务又叫 Paraphrase Detection,判断两个句子是否同义复写。


Image Retrieval 的输入是一幅图片和一段文字,如果文字能很好的描述图片的内容,那么输出一个高的分值,否则输出低分。


SentEval 包括的 NLI 和图像检索任务如图17.2所示。


640?wx_fmt=png

图 17.2: SentEval 的 NLI 和 Image Retrieval 任务


SentEval 的用法


SentEval 依赖 NumPy/SciPy、PyTorch(>=0.4.0) 和 scikit-learn(>=0.18.0)。


然后从https://github.com/facebookresearch/SentEval.git clone 代码。SentEval 提供了一些baseline 系统,包括 bow、infersent 和 skipthought 等等。读者如果实现了一种新的Sentence Embedding 算法,那么可以参考 baseline 的代码用 SentEval 来评价算法的好坏。


我们这里只介绍最简单的 bow 的用法,它就是把 Pretraining 的 Word Embedding加起来得到 Sentence Embedding。


我们首先下载 fasttext 的 Embedding:


640?wx_fmt=png


然后运行:


640?wx_fmt=png


main 函数代码为:


640?wx_fmt=png

640?wx_fmt=png


首先构造 senteval.engine.SE,然后列举需要跑的 task,最后调用 se.eval 得到结果。


构造 senteval.engine.SE 需要传入 3 个参数,params_senteval, batcher 和 prepare。params_senteval 是控制 SentEval 模型训练的一些超参数。比如 bow.py 里的:


640?wx_fmt=png


而后两个参数是函数,我们先看 prepare:


640?wx_fmt=png


这个函数相当于初始化的回调函数,参数会传入 params 和 samples,samples 就是所有的句子,我们需要根据这些句子来做一些初始化的工作,结果存在 params 里,后面会用到。这里我们用 samples 构造 word2id——word 到 id 的映射,另外根据word2id,从预训练的词向量里提取需要的词向量 (因为预训练的词向量有很多词,但是在某个具体任务中用到的词是有限的,我们只需要提取需要的部分),另外把词向量的维度保持到 params 里。


batcher 函数的输入参数是前面的 params 和 batch,batch 就是句子列表,我们需要对它做 Sentence Embedding,这里的实现很简单,就是把词向量加起来求平均值得到句子向量。


640?wx_fmt=png

640?wx_fmt=png


GLUE


Facebook 搞了个标准,Google 也要来一个,所以就有了 GLUE(https://gluebenchmark.com/)。GLUE 是 General Language Understanding Evaluation 的缩写。它们之间很多的任务都是一样的,我们这里就不详细介绍了,感兴趣的读者可以参考论文”GLUE: A Multi-Task Benchmark and Analysis Platform for Natural LanguageUnderstanding”。


Transformer


  • 简介


Transformer 模型来自与论文 Attention Is All You Need(https://arxiv.org/abs/1706.03762)。


这个模型最初是为了提高机器翻译的效率,它的 Self-Attention 机制和 Position Encoding 可以替代 RNN。因为 RNN 是顺序执行的,t 时刻没有处理完成就不能处理 t+1 时刻,因此很难并行。但是后来发现 Self-Attention 效果很好,在很多其它的地方也可以是 Transformer 模型。


640?wx_fmt=png


  • 图解


我们首先通过图的方式直观的解释 Transformer 模型的基本原理,这部分内容主要来自文章 The Illustrated Transformer(http://jalammar.github.io/illustratedtransformer/)。


模型概览


我们首先把模型看成一个黑盒子,如图15.51所示,对于机器翻译来说,它的输入是源语言 (法语) 的句子,输出是目标语言 (英语) 的句子。


把黑盒子稍微打开一点,Transformer(或者任何的 NMT 系统) 都可以分成

Encoder 和 Decoder 两个部分,如图15.52所示。

640?wx_fmt=png


再展开一点,Encoder 由很多 (6 个) 结构一样的 Encoder 堆叠 (stack) 而成,Decoder 也是一样。如图15.53所示。注意:每一个 Encoder 的输入是下一层 Encoder输出,最底层 Encoder 的输入是原始的输入 (法语句子);Decoder 也是类似,但是最后一层 Encoder 的输出会输入给每一个 Decoder 层,这是 Attention 机制的要求。


每一层的 Encoder 都是相同的结构,它有一个 Self-Attention 层和一个前馈网络(全连接网络) 组成,15.54如图所示。每一层的 Decoder 也是相同的结果,它除了 Self-Attention 层和全连接层之外还多了一个普通的 Attention 层,这个 Attention 层使得 Decoder 在解码时会考虑最后一层 Encoder 所有时刻的输出。它的结构如图17.3所示。


加入 Tensor


前面的图示只是说明了 Transformer 的模块,接下来我们加入 Tensor,了解这些模块是怎么串联起来的。


输入的句子是一个词 (ID) 的序列,我们首先通过 Embedding 把它变成一个连续稠密的向量,如图17.4所示。


Embedding 之后的序列会输入 Encoder,首先经过 Self-Attention 层然后再经过全连接层,如图17.5所示。


我们在计算 zi 是需要依赖所有时刻的输入 x1, ..., xn,不过我们可以用矩阵运算一下子把所有的 zi 计算出来 (后面介绍)。而全连接网络的计算则完全是独立的,计算 i 时刻的输出只需要输入 zi 就足够了,因此很容易并行计算。


640?wx_fmt=png

640?wx_fmt=png

640?wx_fmt=png


图17.6更加明确的表达了这一点。图中 Self-Attention 层是一个大的方框,表示它的输入是所有的 x1, ..., xn,输出是 z1, ..., zn。而全连接层每个时刻是一个方框,表示计算 ri 只需要 zi。此外,前一层的输出 r1, ...,rn 直接输入到下一层。


Self-Attention 简介


比如我们要翻译如下句子”The animal didn’t cross the street because it was too tired”(这个动物无法穿越马路,因为它太类了)。这里的 it 到底指代什么呢,是animal 还是 street?要知道具体的指代,我们需要在理解 it 的时候同时关注所有的单词,重点是 animal、street 和 tired,然后根据知识 (常识) 我们知道只有 animal 才能tired,而 stree 是不能 tired 的。Self-Attention 运行 Encoder 在编码一个词的时候考虑句子中所有其它的词,从而确定怎么编码当前词。如果把 tired 换成 narrow,那么it 就指代的是 street 了。


而 LSTM(即使是双向的) 是无法实现上面的逻辑的。为什么呢?比如前向的

LSTM,我们在编码 it 的时候根本没有看到后面是 tired 还是 narrow,所有它无法把it 编码成哪个词。而后向的 LSTM 呢?当然它看到了 tired,但是到 it 的时候它还没有看到 animal 和 street 这两个单词,当然就更无法编码 it 的内容了。


当然多层的 LSTM 理论上是可以编码这个语义的,它需要下层的 LSTM 同时编码了 animal 和 street 以及 tired 三个词的语义,然后由更高层的 LSTM 来把 it 编码成 animal 的语义。但是这样模型更加复杂。


下图17.7是模型的最上一层 (下标 0 是第一层,5 是第六层)Encoder 的Attention可视化图。这是 tensor2tensor 这个工具输出的内容。我们可以看到,在编码 it 的时候有一个 Attention Head(后面会讲到) 注意到了Animal,因此编码后的 it 有 Animal的语义。


640?wx_fmt=png


Self-Attention 详细介绍


下面我们详细的介绍 Self-Attention 是怎么计算的,首先介绍向量的形式逐个时刻计算,这便于理解,接下来我们把它写出矩阵的形式一次计算所有时刻的结果。


对于输入的每一个向量 (第一层是词的 Embedding,其它层是前一层的输出),我们首先需要生成 3 个新的向量 Q、K 和 V,分别代表查询 (Query) 向量、Key 向量和 Value 向量。Q 表示为了编码当前词,需要去注意 (attend to) 其它 (其实也包括它自己) 的词,我们需要有一个查询向量。而 Key 向量可以任务是这个词的关键的用于被检索的信息,而 Value 向量是真正的内容。


我们对比一些普通的 Attention(Luong 2015),使用内积计算 energy 的情况。如图17.8所示,在这里,每个向量的 Key 和 Value 向量都是它本身,而 Q 是当前隐状态 ht,计算 energy etj 的时候我们计算 Q(ht) 和Key(barhj)。然后用 softmax 变成概率,最后把所有的 barhj 加权平均得到 context 向量。


而 Self-Attention 里的 Query 不是隐状态,并且来自当前输入向量本身,因此叫作 Self-Attention。另外 Key 和 Value 都不是输入向量,而是输入向量做了一下线性变换。


当然理论上这个线性变换矩阵可以是 Identity 矩阵,也就是使得Key=Value=输入向量。因此可以认为普通的 Attention 是这里的特例。这样做的好处是系统可以学习的,这样它可以根据数据从输入向量中提取最适合作为 Key(可以看成一种索引)和 Value 的部分。类似的,Query 也是对输入向量做一下线性变换,它让系统可以根据任务学习出最适合的 Query,从而可以注意到 (attend to) 特定的内容。


具体的计算过程如图17.9所示。比如图中的输入是两个词”thinking” 和”machines”,我们对它们进行Embedding(这是第一层,如果是后面的层,直接输入就是向量了),得到向量 x1, x2。接着我们用 3 个矩阵分别对它们进行变换,得到向量 q1, k1, v1 和q2, k2, v2。比如 q1 = x1WQ,图中 x1 的 shape 是 1x4,WQ 是 4x3,得到的 q1 是 1x3。其它的计算也是类似的,为了能够使得 Key 和 Query 可以内积,我们要求 WK 和WQ 的 shape 是一样的,但是并不要求 WV 和它们一定一样 (虽然实际论文实现是一样的)。每个时刻 t 都计算出 Qt, Kt, Vt 之后,我们就可以来计算 Self-Attention 了。以第一个时刻为了,我们首先计算 q1 和 k1, k2 的内积,得到 score,过程如图17.10所示。


640?wx_fmt=png


接下来使用 softmax 把得分变成概率,注意这里把得分除以640?wx_fmt=png之后再计算的 softmax,根据论文的说法,这样计算梯度时会更加文档 (stable)。计算过程如图17.11所示。


640?wx_fmt=png


接下来用 softmax 得到的概率对所有时刻的 V 求加权平均,这样就可以认为得到的向量根据 Self-Attention 的概率综合考虑了所有时刻的输入信息,计算过程如图17.12所示。

640?wx_fmt=png


这里只是演示了计算第一个时刻的过程,计算其它时刻的过程是完全一样的。


矩阵计算


前面介绍的方法需要一个循环遍历所有的时刻 t 计算得到 zt,我们可以把上面的向量计算变成矩阵的形式,从而一次计算出所有时刻的输出,这样的矩阵运算可以充分利用硬件资源 (包括一些软件的优化),从而效率更高。


第一步还是计算 Q、K 和 V,不过不是计算某个时刻的 qt, kt, vt 了,而是一次计算所有时刻的 Q、K 和 V。


计算过程如图17.13所示。这里的输入是一个矩阵,矩阵的第 i 行表示第 i 个时刻的输入 xi。


640?wx_fmt=png


接下来就是计算 Q 和 K 得到 score,然后除以640?wx_fmt=png,然后再 softmax,最后加权平均得到输出。全过程如图17.14所示。


640?wx_fmt=png


Multi-Head Attention


这篇论文还提出了 Multi-Head Attention 的概念。其实很简单,前面定义的一组 Q、K 和 V 可以让一个词 attend to 相关的词,我们可以定义多组 Q、K 和 V,它们分别可以关注不同的上下文。


计算 Q、K 和 V 的过程还是一样,这不过现在变换矩阵从一组640?wx_fmt=png

变成了多组640?wx_fmt=png640?wx_fmt=png,...。如图所示。


对于输入矩阵 (time_step, num_input),每一组 Q、K 和 V 都可以得到一个输

出矩阵 Z(time_step, num_features)。如图17.16所示。


640?wx_fmt=png


但是后面的全连接网络需要的输入是一个矩阵而不是多个矩阵,因此我们可以

把多个 head 输出的 Z 按照第二个维度拼接起来,但是这样的特征有一些多,因此Transformer 又用了一个线性变换 (矩阵 WO) 对它进行了压缩。这个过程如图17.17所示。


640?wx_fmt=png


上面的步骤涉及很多步骤和矩阵运算,我们用一张大图把整个过程表示出来,如图17.18所示。我们已经学习过来 Transformer 的 Self-Attention 机制,下面我们通过一个具体的例子来看看不同的 Attention Head 到底学习到了什么样的语义。


640?wx_fmt=png

图17.19是一个 Attention Head 学习到的语义,我们可以看到对于 it 一个 Head会注意到”the animal” 而另外一个 Head 会注意到”tired”。


如果把所有的 Head 混在一起,如图17.20所示,那么就很难理解它到底注意的是什么内容。从上面两图的对比也能看出使用多个 Head 的好处——每个 Head(在数据的驱动下) 学习到不同的语义。


640?wx_fmt=png

640?wx_fmt=png


位置编码 (Positional Encoding)


注意:这是原始论文使用的位置编码方法,而在 BERT 模型里,使用的是简单的可以学习的 Embedding,和 Word Embedding 一样,只不过输入是位置而不是词而已。


我们的目的是用 Self-Attention 替代 RNN,RNN 能够记住过去的信息,这可以通过 Self-Attention“实时”的注意相关的任何词来实现等价 (甚至更好) 的效果。RNN还有一个特定就是能考虑词的顺序 (位置) 关系,一个句子即使词完全是相同的但是语义可能完全不同,比如” 北京到上海的机票” 与” 上海到北京的机票”,它们的语义就有很大的差别。我们上面的介绍的 Self-Attention 是不考虑词的顺序的,如果模型参数固定了,上面两个句子的北京都会被编码成相同的向量。但是实际上我们可以期望这两个北京编码的结果不同,前者可能需要编码出发城市的语义,而后者需要包含目的城市的语义。而 RNN 是可以 (至少是可能) 学到这一点的。当然 RNN 为了实现这一点的代价就是顺序处理,很难并行。


为了解决这个问题,我们需要引入位置编码,也就是 t 时刻的输入,除了Embedding 之外 (这是与位置无关的),我们还引入一个向量,这个向量是与 t 有关的,我们把 Embedding 和位置编码向量加起来作为模型的输入。这样的话如果两个词在不同的位置出现了,虽然它们的 Embedding 是相同的,但是由于位置编码不同,最终得到的向量也是不同的。


位置编码有很多方法,其中需要考虑的一个重要因素就是需要它编码的是相对位置的关系。比如两个句子:” 北京到上海的机票” 和” 你好,我们要一张北京到上海的机票”。显然加入位置编码之后,两个北京的向量是不同的了,两个上海的向量也是不同的了,但是我们期望 Query(北京 1)*Key(上海 1) 却是等于 Query(北京 2)*Key(上海 2) 的。具体的编码算法我们在代码部分再介绍。


位置编码加入模型如图17.21所示。


640?wx_fmt=png

一个具体的位置编码的例子如图17.22所示。


640?wx_fmt=png


残差连接


每个 Self-Attention 层都会加一个残差连接,然后是一个 LayerNorm 层,如图17.23所示。


640?wx_fmt=png

640?wx_fmt=png


图17.24展示了更多细节:输入 x1, x2 经 self-attention 层之后变成 z1, z2,然后和残差连接的输入 x1, x2 加起来,然后经过 LayerNorm 层输出给全连接层。全连接层也是有一个残差连接和一个 LayerNorm 层,最后再输出给上一层。


Decoder 和 Encoder 是类似的,如图17.25所示,区别在于它多了一个EncoderDecoder Attention 层,这个层的输入除了来自 Self-Attention 之外还有 Encoder 最后一层的所有时刻的输出。


640?wx_fmt=png

Encoder-Decoder Attention 层的 Query 来自下一层,而 Key 和 Value 则来自Encoder 的输出。


代码


本节内容来自

 http://nlp.seas.harvard.edu/2018/04/03/attention.html。读者可以从https://github.com/harvardnlp/annotated-transformer.git 下载代码。这篇文章原名叫作《The Annotated Transformer》。相当于原始论文的读书笔记,但是不同之处在于它不但详细的解释论文,而且还用代码实现了论文的模型。


注意:本书并不没有完全翻译这篇文章,而是根据作者自己的理解来分析和阅读其源代码。而 Transformer 的原来在前面的图解部分已经分析的很详细了,因此这里关注的重点是代码。网上有很多 Transformer 的源代码,也有一些比较大的库包含了Transformer 的实现,比如 Tensor2Tensor 和 OpenNMT 等等。作者选择这个实现的原因是它是一个单独的 ipynb 文件,如果我们要实际使用非常简单,复制粘贴代码就行了。而 Tensor2Tensor 或者 OpenNMT 包含了太多其它的东西,做了过多的抽象。


虽然代码质量和重用性更好,但是对于理解论文来说这是不必要的,并且增加了理解的难度。

未完。环信李理:详解谷歌最强NLP模型BERT(理论+实战)上


作者:李理,环信人工智能研发中心vp,十多年自然语言处理和人工智能研发经验。主持研发过多款智能硬件的问答和对话系统,负责环信中文语义分析开放平台和环信智能机器人的设计与研发。

本文是作者正在编写的《深度学习理论与实战》的部分内容。