自然语言处理(鱼书)读书笔记

一天速通

Posted by Welt Xing on April 6, 2022

引言

《深度学习进阶:自然语言处理》(也称“鱼书”,该名源于该书在O’REILLY出版社出版的封面有一条鱼)是日本作者斋藤康毅所著的书籍,它承接《深度学习入门:基于Python的理论和实践》,介绍了深度学习在NLP领域的贡献。由于作者是在企业进行研究和开发,因此本书的学术味会少一些,应用会多一些,这对想要快速获取知识的我们是一个好消息。

在本文中,笔者打算“速通”这本书,用最短的时间快速解读这本书。事实上笔者花了一个下午就能读完全面六章(全书共八章),后来就没碰过这本书。所以这次下定决心读完整本书。

神经网络复习

这一章是对基础神经网络知识的复习,主要是教读者使用Numpy构建自己的神经网络,它能够学习较为复杂的决策边界。虽然内容比较经典,但有几点细节值得记录:

  1. 用计算图的思想解释正向/反向传播。笔者虽然了解过计算图,但没有学习过针对多元(向量和矩阵)数据的计算图;
  2. Numpy默认用64位浮点数进行计算。而实际上,采用32位浮点数,甚至16位浮点数进行计算,并不会对网络精度造成过大影响。基于这样的思想,我们可以加快训练,以及缩小模型存储空间;
  3. Numpy加速,第三方库Cupy允许在GPU上运行Numpy,进一步加速训练。

自然语言和单词的分布式表示

这一章正式介绍NLP,在简单讲述NLP任务以及NLP的难点后,作者列出三种表达单词含义的方法,本章讲的是前两个:

  1. 基于同义词词典的方法;
  2. 基于计数的方法;
  3. 基于推理的方法(word2vec)。

同义词词典(Thesaurus)

在同义词词典中,具有相同含义的单词(同义词)或含义类似的单词(近义词)被归类到同一个组中。比如,使用同义词词典,我们可以知道 car 的同义词有automobile、motorcar 等:

image-20220328154847928

当然,在自然语言处理中用到的同义词词典有时会定义单词之间的粒度更细的关系,比如“上位 - 下位”关系、“整体 - 部分”关系:

image-20220328154958537

这种方法比较偏知识性,而不是data-drive,强调让机器理解单词间的联系。

在自然语言处理领域,最著名的同义词词典是 WordNet。WordNet是普林斯顿大学于 1985 年开始开发的同义词词典,迄今已用于许多研究,并活跃于各种自然语言处理应用中。使用 WordNet,可以获得单词的近义词,或者利用单词网络。使用单词网络,可以计算单词之间的相似度。

同义词词典的问题

这种基于知识的自然语言处理的缺陷也是显然的:

  1. 需要不断将新词汇加入词典;
  2. 制作和维护词典的成本高;
  3. 相同含义的词仍具有微妙的区别。

因此,使用同义词词典,即人工定义单词含义的方法存在很多问题。

基于计数的方法

采用这种方法,我们可以将单词表示为向量。先介绍几个名词:

  • 分布式表示:将单词转化为能够准确把握单词含义的向量形式。
  • 分布式假设:某个单词的含义由它周围的单词(上下文)形成。

总的来说,分布式表示是我们的目标,基于的思想是分布式假设。来考虑如何基于分布式假设使用向量表示单词,最直截了当的实现方法是对周围单词的数量进行计数,这就是基于计数的方法。“周围”的大小是人为规定的,太小会遗漏重要的上下文信息,太大会造成维数灾难。

这样形成的是一个矩阵,行和列都是单词,矩阵元素表示行单词的上下文包含列单词的频数。比如”You say goodbye and I say hello”。单词you的上下文(设窗口为1)中,只出现的say,且频数为1,因此矩阵中you对应的行应当是

image-20220328160707633

这也意味着我们可以用向量$[0,1,0,0,0,0,0]$来表示单词you。基于这样的方法,我们得到完整的矩阵:

image-20220328160821184

该矩阵也称作“共现矩阵”。我们借此也得到了单词的向量化表示。

衡量相似度

单词间的相似度可以转换为向量间的相似度,余弦相似度是一个不错的度量:

\[\text{similarity}(\pmb x,\pmb y)=\dfrac{\pmb x\cdot\pmb y}{\Vert\pmb x\Vert\Vert\pmb{y}\Vert}\]

基于计数方法的改进

点互信息

上面将频数作为向量化表示的元素存在不足,即共现次数多不一定表示相关性强,比如”the car”经常出现不代表”the”和“car”关系紧密,仅仅是因为”the”出现次数多。因此引入点互信息(PMI):

\[\text{PMI}(x,y)=\log_2\dfrac{P(x,y)}{P(x)P(y)}\]

除了要求共现概率$P(x,y)$大,我们还应该限制$P(x)$和$P(y)$不能太大。将PMI作为向量化表示的元素显然比频次更为合理。对于单词$x$和$y$,$P(x),P(y)$和$P(x,y)$分别表示各自出现的次数,和共同出现的次数:

\[\begin{aligned} \text{PMI}(x,y)&=\log_2\dfrac{P(x,y)}{P(x)P(y)}\\ &=\log_2\dfrac{\frac{C(x,y)}{N}}{\frac{C(x)}{N}\frac{C(y)}{N}}\\ &=\log_2\dfrac{N\cdot C(x,y)}{C(x)C(y)} \end{aligned}\]

由于当两单词共现次数为0,PMI为$-\infty$,因此实践上我们选择正的点互信息(PPMI)作为向量化的元素

\[\text{PPMI}(x,y)=\max(0, \text{PMI}(x,y))\]

降维

由于上面的向量表示是稀疏的,我们本能地想对数据进行降维,缓解维数灾难。书里仅提到了SVD分解,而目前有更多的降维方法。笔者之前已经进行了总结,可阅读https://welts.xyz/2022/03/17/rd/.

Word2Vec

这里讨论基于推理的分布式表示,这里的推理机制用的是神经网络。

基于计数的方法的问题

对于单词数量$n$,共现矩阵的大小是$O(n^2)$的,SVD的复杂度是$O(n^3)$​的,对如此庞大的矩阵执行SVD显然是不现实的。

而基于推理的方法使用神经网络,通常在 mini-batch 数据上进行学习。这意味着神经网络一次只需要看一部分学习数据(mini-batch),并反复更新权重:

image-20220328163110712

基于计数的方法一次性处理全部学习数据;反之,基于推理的方法使用部分学习数据逐步学习。这意味着,在词汇量很大的语料库中,即使 SVD 等的计算量太大导致计算机难以处理,神经网络也可以在部分数据上学习。并且,神经网络的学习可以使用多台机器、多个 GPU 并行执行,从而加速整个学习过程。在这方面,基于推理的方法更有优势。

基于推理的方法

基于推理的方法和基于计数的方法一样,也基于分布式假设。我们希望根据上下文,找到最有可能出现的单词,比如”You ? goodbye and I say hello“,我们的目标是学出下面的模型:

image-20220328163508274

基于推理的方法引入了某种模型,我们将神经网络用于此模型。这个模型接收上下文信息作为输入,并输出(可能出现的)各个单词的出现概率。在这样的框架中,使用语料库来学习模型,使之能做出正确的预测。另外,作为模型学习的产物,我们得到了单词的分布式表示。这就是基于推理的方法的全貌。

独热向量表示

我们先考虑将什么输入神经网络,显然不是单词字符串,而是一个定长的向量。这里考虑将所有单词转换为独热向量(One-hot vector):只有一个元素为1,其余是0。显然向量的长度就是语料库中单词的数目,神经网络的输入神经元个数就可以固定:

image-20220328164230755

全连接层可以将独热向量投影成低维向量,比如这里是将维数由7降为3:

image-20220328164400935

对于独热向量表示,这样的投影实际上是从矩阵$W$中取行:

image-20220328164522981

简单的word2vec

word2vec是一种产生词向量的技术,其下衍生出不同的模型,本书介绍了最主要的两种模型:

  1. CBOW(Continuous Bag-Of-Words)模型;
  2. skip-gram模型。

CBOW模型

先介绍CBOW,我们在前面已经介绍过CBOW的目标,就是依靠上下文预测出出现概率最大的单词。CBOW的输入是其上下文,也就是两个单词的独热表示:

image-20220328165619094

这里,因为我们对上下文仅考虑两个单词,所以输入层有两个。如果对上下文考虑 N 个单词,则输入层会有 N 个。

此时,中间层的神经元是各个输入层经全连接层变换后得到的值的“平均”。就上面的例子而言,经全连接层变换后,第1个输入层转化为$\pmb h_1$,第2个输入层转化为$\pmb h_2$,那么中间层的神经元是$\frac12(\pmb h_1+\pmb h_2)$ 。经过softmax层处理后,我们将神经网络的输出理解为当前位置为某单词的概率:

image-20220328170621877

至于网络的学习,对于两个输入$\pmb x_1,\pmb x_2$和正确解单词$\pmb y$损失函数可写做

\[\text{CrossEntropy}(\text{Softmax}(\frac12(\pmb x_1+\pmb x_2)W_\text{in}W_{\text{out}}), \pmb y)\]

所以反向传播只要使用链式法则求导就行。在训练完成后输入侧和输出测的权重实际上都可以作为单词的分布式表示:

image-20220328171835123

可以只采用输入侧,也可以只采用输出测,还可以同时使用两个权重。就word2vec(特别是 skip-gram 模型)而言,最受欢迎的是第一种方案。

CBOW模型和概率

对于一个长度为$T$的语句,可以建模成一个序列

\[w_1w_2,\cdots w_{t-1}w_tw_{t+1}\cdots w_{T-1}w_T\]

而CBOW建模的是下面的条件概率

\[P(w_t\vert w_{t-1},w_{t+1})\]

也就是:给定上下文,求该位置词语的概率分布。考虑神经网络的损失函数,即交叉熵函数,那么损失就是

\[L=-\log P(w_t\vert w_{t-1},w_{t+1})\]

这也被称作负对数似然。若扩展到整个语料库,损失函数可以写作

\[L=-\frac1 T\sum_{i=1}^T\log P(w_t\vert w_{t-1},w_{t+1})\]

CBOW模型的目标就是极小化上式。注意这里我们指定了上下文窗口为1,但其他窗口大小的情况也很容易写出来。

skip-gram模型

前面已经提过一次skip-gram模型,这里正式引入skip-gram。它是CBOW模型的翻转,即:给定中心词,预测出合理的上下文。其网络结构如下图所示:

image-20220328221504109

skip-gram的输入是一个词,输出层的数量与上下文的单词个数相等。因此,首先要分别求出各个输出层的损失,然后将它们加起来作为最后的损失。

类似上一节的分析方法,skip-gram实际上建模的是下面的概率:

\[P(w_{t-1},w_{t+1}\vert w_t)\]

此时加入假定,上下文间的词之间没有相关性,那么对上式进行分解:

\[P(w_{t-1},w_{t+1}\vert w_t)=P(w_{t-1}\vert w_t)P(w_{t+1}\vert w_t)\]

代入交叉熵损失,即

\[\begin{aligned} L &=-\log P(w_{t-1},w_{t+1}\vert w_t)\\ &=-\bigg(\log P(w_{t-1}\vert w_t)+\log P(w_{t+1}\vert w_t)\bigg) \end{aligned}\]

对应我们上面所说,对各个输出层的损失相加作为最终的损失函数。考虑整个语料库,那么skip-gram模型的损失函数:

\[L=-\frac 1T\sum_{t=1}^T\bigg(\log P(w_{t-1}\vert w_t)+\log P(w_{t+1}\vert w_t)\bigg)\]

CBOW or skip-gram?

显然,CBOW和skip-gram模型都能生成单词的稠密向量表示,那么我们该选哪一个模型呢?答案应该是 skip-gram 模型。这是因为,从单词的分布式表示的准确度来看,在大多数情况下,skip-gram模型的结果更好。特别是随着语料库规模的增大,在低频词和类推问题的性能方面,skip-gram 模型往往会有更好的表现。此外,就学习速度而言,CBOW 模型比 skip-gram 模型要快。这是因为 skip-gram 模型需要根据上下文数量计算相应个数的损失,计算成本变大。

Count or Reasoning?

本章介绍的是基于计数的方法和基于推理的方法。我们这里来对两种方法进行总结。

  • 考虑加入新词汇的情景,基于计数的方法需要重新构建共现矩阵,重新进行SVD分解;而基于推理的方法在该情景下等价于神经网络的增量学习,比前者高效。

  • 再考虑两种方法得到的单词的分布式表示的性质和准确度。基于计数的方法在单词相似性上表现不错,而word2vec能够解决经典的“king−man+woman=queen”这样的类推问题。

  • 有研究表明,就单词相似性的定量评价而言,基于推

    理的方法和基于计数的方法难分上下。

word2vec的高速化

这一章主要是将word2vec的实现细节,通过嵌入层(Embedding)和负采样(Negative Sampling)来改善word2vec模型。

通过Embedding层加速矩阵乘积

回顾前面CBOW的损失函数:

\(\text{CrossEntropy}(\text{Softmax}(\frac12(\pmb x_1+\pmb x_2)W_\text{in}W_{\text{out}}), \pmb y)\) 这里的$\pmb x_1$和$\pmb x_2$是独热向量,当词汇量极大(比如100万),$W_{\text{in}}$的行数,以及$W_{\text{out}}$的列数都是极大的,一般的矩阵乘积的计算负担是极大的。而我们前面也提到,独热向量下的矩阵左乘,等价于取矩阵的对应行:

image-20220328224354727

因此,直觉上将单词转化为 one-hot 向量的处理和全连接层中的矩阵乘法似乎没有必要。

于是,我们创建一个从权重参数中抽取“单词ID对应行(向量)”的层,这里我们称之为 Embedding 层。

单词的密集向量表示称为词嵌入(word embedding)或者单词的分布式表示(distributed representation)。过去,将基于计数的方法获得的单词向量称为distributional representation,将使用神经网络的基于推理的方法获得的单词向量称为 distributed representation。不过,中文里二者都译为“分布式表示“。

Embedding层的正向传播是将矩阵的指定行抽出,相对应的,反向传播是将求得的梯度嵌入到矩阵中:

image-20220328230048257

使用负采样替代Softmax

在优化了输入侧之后,现在是输出侧的优化问题:

  1. 中间层神经元和$W_{\text{out}}$的乘积;
  2. Softmax层的计算。

这里由于中间层神经元不是稀疏的,因此无法像之前那样用嵌入层简化。当词汇量很多时(仍以100万为例),考虑Softmax层的输出:

\[y_k=\dfrac{\exp(s_k)}{\sum_{i=1}^n\exp(s_i)}\]

需要进行100万次$\exp$计算,及其耗时。

负采样的核心思想是用二分类拟合多分类,多分类的问题是:是哪个,而二分类的问题则是:是不是这个。此时输出神经元只需要一个。此时CBOW模型的结构变成了下面这样:

image-20220329102825643

这时,我们只关心特定位置的输出,因此向量-矩阵乘积相当于向量与矩阵某一特定列(列向量)的内积:

image-20220329103013850

注意神经网络末端的Softmax改成了Sigmoid函数,这是因为Softmax本身就是Sigmoid的多类推广。考虑两类的Softmax:

\[\dfrac{e^{x_1}}{e^{x_1}+e^{x_2}}=\dfrac{1}{1+e^{-(x_1-x_2)}}\]

其实就是Sigmoid函数。同时,交叉熵损失也发生了退化:

\[L=-(t\log y+(1-t)\log(1-y))\]

通过两种改进方式,进行二分类的CBOW模型如下图所示:

image-20220329103656469

注意到输入侧和输出侧的全连接层都是用嵌入层代替。

负采样

对于上述改进模型的学习,我们不能仅仅将正确答案作为训练样本(即让输出尽可能接近1),还要将错误样本送入网络训练(输出接近0)。但我们也不需要将所有的负例送入网络训练,这样样本量会暴涨。本章介绍了一种近似方法,只使用少数负例,这就是“负采样”。

总而言之,负采样方法既可以求将正例作为目标词时的损失,同时也可以采样若干个负例,对这些负例求损失。然后,将这些数据(正例和采样出来的负例)的损失加起来,将其结果作为最终的损失。比如下图就是选择两个负例时的结构示意图:

image-20220329105346924

至于负例的采样方法,基于语料库的统计数据进行采样的方法比随机抽样要好。通俗点说,就是抽取在语料库中出现次数多的单词。

word2vec的应用

在训练好一个word2vec之后,我们能够挑选出语料库的单词,进行类推能力的测试。一个经典的类推问题就是前面提到的“king−man+woman=queen”。我们希望等号左边的单词对应的向量进行相应运算后,其结果应该与queen对应的向量距离很小。

word2vec的迁移学习能力非常重要,在解决自然语言处理任务时,一般不会使用 word2vec 从零开始学习单

词的分布式表示,而是先在大规模语料库(Wikipedia、Google News 等文本数据)上学习,然后将学习好的分布式表示应用于某个单独的任务。比如,在文本分类、文本聚类、词性标注和情感分析等自然语言处理任务中,第一步的单词向量化工作就可以使用学习好的单词的分布式表示。在几乎所有类型的自然语言处理任务中,单词的分布式表示都有很好的效果。

RNN

这一章介绍循环神经网络(Recurrent Neural Network, RNN),以及一个著名的衍生模型LSTM(Long Short Term Memory ),它能缓解RNN的梯度消失问题,但参数太多。GRU是它的改进版本,书中是将它列在附录中,我准备将其放在这一章介绍。

概率与语言模型

概率模型

在上一章,我们解读了CBOW模型的建模对象,也就是条件概率$P(w_t\vert w_{t-1},w_{t+1})$。但在一些任务,比如文本生成中,后面的文本是不知道的:

image-20220329111206525

因此我们的建模对象应当只有“上文”,也就是

\[P(w_t\vert w_{t-2},w_{t-1})\]

在这种条件下,CBOW学习的也是上式的对数似然:

\[L=-\log P(w_t\vert w_{t-2},w_{t-1})\]

语言模型

语言模型给出了单词序列发生的概率。也就是说,一个符合日常语法规范的单词序列,发生概率高;不合法的单词序列,其发生的单词低。比如“you say goodbye”的概率应当比“you say good die”的概率高。

考虑$m$个单词的句子,上面所述的概率就是联合概率

\[P(w_1,\cdots,w_m)\]

通过条件概率公式,将上式分解为

\[\begin{aligned} &P(w_m\vert w_1,\cdots,w_{m-1})P(w_{m-1}\vert w_{1},\cdots,w_{m-2})\cdots P(w_2|w_1)P(w_1)\\ =&\prod_{t=1}^mP(w_t\vert w_1,\cdots,w_{t-1}) \end{aligned}\]

$P(w_t\vert w_1,\cdots,w_{t-1})$其实就是给定了前$t-1$个词,第$t$个单词的概率分布:

image-20220329112906994

CBOW与语言模型

前面提到的只有”上文“的CBOW模型可以视作$P(w_t\vert w_1,\cdots,w_{t-1})$的简化,即只考虑前面$n$个单词,而不是整句($n$阶马尔科夫链)。当然,$n$取过大或过小都不是好事。

回顾CBOW模型,其输入为$n$个单词的独热向量表示的平均,也就是说,$n$个单词的排列顺序,不会影响CBOW模型的输出。这注定CBOW无法成为一个好的语言模型。

有一种方法可以缓解这个局限性,即将向量有序拼接成长向量,然后再进行传播:

image-20220329114142303

但这样的问题就是权重参数的数量将与上下文大小成比例地增加。显然,这是我们不愿意看到的。

RNN

RNN的特征就在于拥有一个环路(或回路)。这个环路可以使数据不断循环。通过数据的循环,RNN 一边记住过去的数据,一边更新到最新的数据:

image-20220329135903480

对于第$t$时刻,数据$\pmb x_t$被输入到RNN中,网络的输出是$\pmb h_t$,同时该输出被存储在RNN中,作用于下一次输入。因此,RNN的展开结构是

image-20220329162117909

值得注意的是,这里的展开实际上仍是同一层,这一点与之前的神经网络不一样。

RNN在第$t$时刻进行如下的计算

\[\pmb h_t=\tanh(\pmb h_{t-1}W_h+\pmb x_t W_x+\pmb b)\]

其中$\pmb h_{t-1}$是上一轮的输出,$\pmb x_t$是这一轮的输入,而$W_h,W_x$和$\pmb b$​都是RNN的参数。现在的输出$\pmb h_t$是由前一个输出$\pmb h_{t-1}$计算出来的。从另一个角度看,这可以解释为,RNN 具有“状态”h,并以上式的形式被更新。这就是说RNN层是“具有状态的层”或“具有存储(记忆)的层”的原因。

BPTT

在知道RNN的前向传播方法后,我们应当关注其反向传播。将 RNN 层展开后,就可以视为在水平方向上延伸的神经网络,因此RNN的学习可以用与普通神经网络的学习相同的方式进行:

image-20220329224132942

由于这里的误差反向传播算法是“按时间顺序展开的神经网络的误差反向传播法”,所以称为 Backpropagation Through Time(基于时间的反向传播),简称 BPTT。输入时序数据的长度,决定了反向传播的长度。随着时序数据的时跨度的增大,BPTT 消耗的计算机资源也会成比例地增大。另外,反向传播的梯度也会变得不稳定。

要基于 BPTT 求梯度,必须在内存中保存各个时刻的 RNN 层的中间数据(RNN 层的反向传播将在后文中说明)。因此,随着时序数据变长,计算机的内存使用量(不仅仅是计算量)也会增加。

Truncated BPTT

在处理长时序数据时,通常的做法是将网络连接截成适当的长度。具体来说,就是将时间轴方向上过长的网络在合适的位置进行截断,从而创建多个小型网络,然后对截出来的小型网络执行误差反向传播法,这个方法称为Truncated BPTT(截断的 BPTT)。

假如RNN要处理长度为1000的时序数据,那么RNN要进行1000轮的正向传播和反向传播。此时考虑适当截断反向传播的过程:

image-20220329232549462

上图中,我们我们截断了反向传播的连接,以使学习可以以10个RNN 层为单位进行。像这样,只要将反向传播的连接截断,就不需要再考虑块范围以外的数据了,因此可以以各个块为单位(和其他块没有关联)完成误差反向传播法。

注意我们截断的只有反向传播,正向传播必须完整连续地进行。现在考虑截断BPTT来学习RNN,假设是以10个RNN层为单位进行反向传播,那么先输入训练时序序列的前10个元素$(x_0,\cdots,x_9)$进行正向传播:

image-20220329233023726

然后基于这个块的输入进行反向传播:

image-20220329233648241

接着考虑下一个块的前向传播和反向传播:

image-20220329233659933

就这样前向传播+反向传播一直到序列结束。RNN的学习流程如下图所示:

image-20220329233949059

BPTT的mini-batch学习

上面提到的前向传播和反向传播都是针对单个时间序列(具体到NLP上,就是语句),这里考虑mini-batch学习,即多条数据的前向反向传播。

对长度为1000的时序数据,以时间长度10为单位进行截断。考虑批大小为2的情形,第一笔数据当然是从头开始输入,第 2 笔数据从第 500 个数据开始按顺序输入。也就是说,将开始位置平移 500:

image-20220329234727984

接下来是时序数据的第 10 ~ 19 个数据和第 510 ~ 519 个数据。像这样,在进行 mini-batch 学习时,平移各批次输入数据的开始位置,按顺序输入。此外,如果在按顺序输入数据的过程中遇到了结尾,则需要设法返回头部。

RNN的实现

这里不会贴很多代码,而是尽量用文字讲清楚作者是如何实现一个RNN层的。RNN层实际上是一个水平方向上长度固定的网络序列:

image-20220330093121662

我们将这个网络序列视作一个层,以便模块化:

image-20220330093426519

于是,$\pmb x_s=(x_0,x_1,\cdots,x_{T-1})$视作整体的输入,而$\pmb h_s=(h_0,\cdots,h_{T-1})$捆绑为整体作为输出。

回顾RNN的正向传播:

\[\pmb h_t=\tanh(\pmb h_{t-1}W_h+\pmb x_tW_x+\pmb b)\]

我们要整理为mini-batch进行处理,假设批大小是$N$,输入向量的维数是$D$,隐藏状态向量的维数是$H$,那么矩阵形状检查:

image-20220330094000626

再考虑反向传播:

image-20220330094022960

属于比较简单的复合函数,求导推导不难。这是单个RNN的传播,再考虑整个RNN层(书中将单个RNN称作RNN层,多个RNN构成的层称作Time RNN层):

image-20220330094316684

从上游传来的梯度记为$dhs$,将流向下游的梯度为$dxs$​,因为这里我们进行的是Truncated BPTT,所以不需要流向这个块上一时刻的反向传播。

注意到对于该层中的每一个RNN,其接受的梯度都是由两部分组成:

  1. 上游的梯度$dh_t$;
  2. 上一个RNN的梯度$dh_{\text{next}}$。

如下图所示:

image-20220330102818898

这里展示一段书中的Time RNN层的反向传播源码:

def backward(self, dhs):
    Wx, Wh, b = self.params
    N, T, H = dhs.shape
    D, H = Wx.shape
    dxs = np.empty((N, T, D), dtype='f')
    dh = 0
    grads = [0, 0, 0]
    for t in reversed(range(T)):
        layer = self.layers[t]
        dx, dh = layer.backward(dhs[:, t, :] + dh) # 求和后的梯度
        dxs[:, t, :] = dx
        for i, grad in enumerate(layer.grads):
            grads[i] += grad
    for i, grad in enumerate(grads):
        self.grads[i][...] = grad
        self.dh = dh
    return dxs

这里,首先创建传给下游的梯度的“容器”(dxs)。接着,按与正向传播相反的方向,调用 RNN 层的 backward() 方法,求得各个时刻的梯度 dx,并存放在 dxs 的对应索引处。另外,关于权重参数,需要求各个 RNN 层的权重梯度的和,并通过“…”用最终结果覆盖成员变量 self.grads。

在 Time RNN 层中有多个 RNN 层。另外,这些 RNN 层使用相同的权重。因此,Time RNN 层的(最终)权重梯度是各个 RNN层的权重梯度之和。

RNNLM

我们将基于RNN的语言模型称为RNNLM(RNN Language Model,RNN 语言模型)。一个简单的RNNLM如下图所示,其中右边是其在时间维度上的展开:

image-20220330141920459

注意到第一层是Embedding层,该层将单词 ID 转化为单词的分布式表示(单词向量)。

“Affine”是仿射层,作者将这一层定义为一个线性变换,即对行向量数据执$x$行$xW+b$的操作。

考虑网络的前向传播,比如输入“you say goodbye and i say hello”,此时网络的处理:

image-20220330142318077

序列的第一个元素,即”you“输入网络后,观察Softmax层的输出,现实”say“的概率,即下一个单词为”say“的概率最大。这里需要注意的是,RNN 层“记忆”了“you say”这一上下文。更准确地说,RNN 将“you say”这一过去的信息保存为了简短的隐藏状态向量。RNN 层的工作是将这个信息传送到上方的 Affine 层和下一时刻的 RNN 层。

从上图可以发现,RNNLM不仅要基于RNN层构建Time RNN,还需要类似的构建Time Embedding层,Time Affine层等来实现整体处理时序数据的层。

Time 层的实现很简单。比如,在Time Affine层的情况下,只需要准备 T 个 Affine 层分别处理各个时刻的数据即可:

image-20220330152152846

类似的方法可以用于实现Time Embedding。至于损失函数层,书中是将多个时刻的损失进行求和作为最终损失:

image-20220330152315695

\[L=\frac1T\sum_{i=0}^{T-1}L_i\]

以上就是RNNLM的基本组件,基于此,我们可以实现一个简单的RNNLM模型。详细的代码请参考原书。

书中在这里提到了神经网络权值的初始化,作者在这里采用的Xavier初始化。

语言模型的评价

如何评价一个语言模型的好坏?作者指出困惑度(perplexity)常被用作评价语言模型的预测性能的指标。作者在这里举了不少例子,又引入“分叉度”之类的概念。我们这里直接给出结论:困惑度是交叉熵的指数形式,即

\[\begin{aligned} L&=-\frac1N\sum_n\sum_kt_{nk}\log y_{nk}\\ \text{Perplexity}&=\exp(L) \end{aligned}\]

其中$N$是数据量,$\pmb t_{n}$是独热向量形式的正确解标签,$\pmb y_n$表示神经网络预测出来的概率分布。困惑度越小,表示模型越好。

总结

这一章的主要内容:

  • RNN结构,以及RNN层,TIme RNN层;
  • 使用RNN构建语言模型:RNNLM。

但实际上RNN存在一些问题,比如容易发生梯度消失和梯度爆炸,容易过拟合面对这些问题,更强大的RNN被提出,比如LSTM和GRU,下一章就是它们的介绍。

Gated RNN

LSTM 和 GRU 中增加了一种名为“门”的结构。基于这个门,可以学习到时序数据的长期依赖关系。本章我们将指出上一章的 RNN 的问题,介绍代替它的 LSTM 和 GRU 等“Gated RNN”。特别是我们将花很多时间研究 LSTM 的结构,并揭示它实现“长期记忆”的机制。此外,我们将使用LSTM创建语言模型,并展示它可以在实际数据上很好地学习。

RNN的问题

RNN的反向传播的反向是从序列的尾部到头部,如果这个梯度在中途变弱,那么权重参数将不会更新。也就是说,RNN 层无法学习长期的依赖关系。

梯度消失与梯度爆炸

至于RNN梯度消失/爆炸的原因,也很容易解释,当然这是一般神经网络在层数增加后的通病。RNN的激活函数为$\tanh$,其求导满足

\[\tanh'(x)=1-\tanh(x)^2\in(0,1]\]

原函数与导数的图像:

image-20220330161148924

从图中可以看出,它的值小于1.0,并且随着$x$远离$0$,它的值在变小。这意味着,当反向传播的梯度经过$\tanh$节点时,它的值会越来越小。因此,如果经过$\tanh$函数$T$次,则梯度也会减小$T$次。

有论文通过将ReLU替换$\tanh$​作为RNN的激活函数,缓解了梯度消失问题,实现了性能改善:《Improving performance of recurrent neural network with relu nonlinearity》。

梯度消失是激活函数的性质导致的,而梯度爆炸则是由矩阵乘法的性质导致。作者通过计算矩阵连乘之后的范数增长趋势来反映这一事实:

import numpy as np
import matplotlib.pyplot as plt

N = 2 # mini-batch的大小
H = 3 # 隐藏状态向量的维数
T = 20 # 时序数据的长度
dh = np.ones((N, H))
np.random.seed(3) # 为了复现,固定随机数种子
Wh = np.random.randn(H, H)
norm_list = []
for t in range(T):
    dh = np.dot(dh, Wh.T)
    norm = np.sqrt(np.sum(dh**2)) / N
    norm_list.append(norm)

最后表现出指数增长的趋势:

image-20220330163431952

可知梯度的大小随时间步长呈指数级增加,这就是梯度爆炸(exploding gradients)。如果发生梯度爆炸,最终就会导致溢出,出现 NaN(Not a Number,非数值)之类的值。如此一来,神经网络的学习将无法正确运行。当然,矩阵乘法也将导致梯度消失,比如我们将上面代码的Wh进行修改:

# Wh = np.random.randn(H, H) # before
Wh = np.random.randn(H, H) * 0.5 # after

由于多了一个小于1的正常数项,导致矩阵范数呈指数级减小:

image-20220330163712821

如果发生梯度消失,梯度将迅速变小。一旦梯度变小,权重梯度不能被更新,模型就会无法学习长期的依赖关系。

梯度爆炸的对策

解决梯度爆炸问题有一个挺travail的方法,就是对梯度进行截取:

\[\begin{aligned} &\text{if}\quad\Vert g\Vert\geq\text{threshold}:\\ &\quad g=\dfrac{\text{threshold}}{\Vert g\Vert}g. \end{aligned}\]

虽然这个方法很简单,但是在许多情况下效果都不错。

梯度消失和LSTM

要解决RNN中的梯度消失,需要从根本上改变RNN的结构,Gated RNN应运而生,这里先讲LSTM。

这里直接摆出LSTM的结构,后面一一解说($\sigma$表示Sigmoid函数):

image-20220330164838122

首先,RNN的输入只有$\pmb h_{t-1}$和$\pmb x_t$,输出是$\pmb h_t$,而LSTM相比于RNN,则多出一个$\pmb c_{t-1}$输入和$\pmb c_t$输出:

image-20220330165353781

这里的$\pmb c$​称作记忆单元,相当于LSTM专用的记忆部门。记忆单元的特点是,仅在 LSTM 层内部接收和传递数据。也就是说,记忆单元在 LSTM 层内部结束工作,不向其他层输出。而 LSTM 的隐藏状态$\pmb h$和 RNN 层相同,会被(向上)输出到其他层。

输出门

image-20220330201510481

先看输出,也就是$\pmb h_t$,它先是基于$\pmb h_{t-1}$和$\pmb x_t$算出$\pmb o$:

\[\pmb o=\sigma(\pmb x_tW_x^{(o)}+\pmb h_{t-1}W_h^{(o)}+\pmb b^{(o)})\]

而$\pmb o$需要和$\tanh(\pmb c_t)$进行元素级乘积后(element-wise multiplication,或者叫阿达玛积)才能成为最后的输出$\pmb h_t$:

\[\pmb h_t=\pmb o\odot\tanh(\pmb c_t)\]

这一操作被看做是$\pmb o$经过了一个“输出门”。$\tanh(\pmb c_t)$的元素都在$[-1, 1]$之间,我们将其视作信息的强弱,或者理解为$\pmb c_t$选择对$\pmb o$的信息进行激活还是抑制。

遗忘门

image-20220330201525678

再看最左边的$\pmb f$分支,$\pmb h_{t-1}$和$\pmb x_t$通过Sigmoid函数进行组合,其输出值域为$(0,1)$,它对$\pmb c_{t-1}$进行元素级乘法:

\[\pmb f=\sigma(\pmb x_tW_x^{(f)}+\pmb h_{t-1}W_h^{(f)}+\pmb b^{(f)}),\\ \pmb c_t=\pmb f\odot\pmb c_{t-1}.\]

这样将原来的$\pmb c_{t-1}$的元素进行缩小,可以理解为丢失了部分信息,因此这部分叫做“遗忘门”,图中的$\pmb f$表示forget。

新的记忆单元

image-20220330201540077

再看遗忘门右边的部分,也就是$\pmb g$分支,它负责将该神经元信息加入记忆单元中,因为$\pmb f$只负责遗忘,但我们也需要记忆。这个$\tanh$​节点的作用不是门,而是将新的信息添加到记忆单元中。因此,它不用Sigmoid函数作为激活函数,而是使用$\tanh$函数:

\[\pmb g=\tanh(\pmb x_tW_x^{(g)}+\pmb h_{t-1}W_h^{(g)}+\pmb b^{(g)})\\ \pmb c_t\gets\pmb c_t+\pmb g\]

输入门

记忆单元是将所有信息不加筛选地记忆,但LSTM加入了一个结构判断$\pmb g$中信息的价值,对它们进行加权:

image-20220330201721951

写成数学表达式,也就是

\[\pmb i=\sigma(\pmb x_tW_x^{(i)}+\pmb h_{t-1}W_h^{(i)}+\pmb b^{(i)})\\\]

由于这里是对信息进行筛选,本质上还是控制信息的流出,因此使用的是Sigmoid函数。

以上就是LSTM的四个组件,我们进行总结。

名称 作用 激活函数
遗忘门 让输入的记忆单元$\pmb c_{t-1}$遗忘部分信息 Sigmoid
记忆门 让新的记忆单元$\pmb c_t$记住该LSTM的信息 $\tanh$
输入门 控制记忆门中信息的权重 Sigmoid
输出门 计算出$\pmb o$,以和记忆单元$\pmb c_t$结合形成真正的输出$\pmb h_t$ Sigmoid

值得注意的是,本书中并没有出现“记忆门”这一叫法,这是因为作者秉持着“门”是类似控制水流的闸口的结构,控制信息流动的比例的观点,因此门结构的激活函数都是Sigmoid。这里笔者为了统一,故将$\pmb g$分支以不少资料上写的那样称作“记忆门”。

总结LSTM中的计算

\[\begin{aligned} \pmb f_t&=\sigma(\pmb x_tW_x^{(f)}+\pmb h_{t-1}W_h^{(f)}+\pmb b^{(f)}),\\ \pmb g_t&=\tanh(\pmb x_tW_x^{(g)}+\pmb h_{t-1}W_h^{(g)}+\pmb b^{(g)})\\ \pmb i_t&=\sigma(\pmb x_tW_x^{(i)}+\pmb h_{t-1}W_h^{(i)}+\pmb b^{(i)}),\\ \pmb o_t&=\sigma(\pmb x_tW_x^{(o)}+\pmb h_{t-1}W_h^{(o)}+\pmb b^{(o)}),\\ \pmb c_t&=\pmb f_t\odot\pmb c_{t-1}+\pmb g\odot\pmb i,\\ \pmb h_t&=\pmb o\odot\tanh(\pmb c_t). \end{aligned}\]

LSTM的梯度

回到我们一开始的命题:LSTM能够缓解梯度消失的问题。为什么它不会引起梯度消失呢?其原因可以通过观察记忆单元$\pmb c$的反向传播来了解:

image-20220330203902655

可以发现,在反向传播过程中,$\pmb c$​涉及到的运算只有两种:加法和阿达玛乘积。加法节点将上游传来的梯度原样流出,所以梯度没有变化;至于阿达玛积,因为它是元素对元素的乘积,因此可以缓解了前面的矩阵乘法带来的梯度爆炸或消失问题;此外,它的计算由遗忘门控制(每次输出不同的门值)。遗忘门认为“应该忘记”的记忆单元的元素,其梯度会变小;而遗忘门认为“不能忘记”的元素,其梯度在向过去的方向流动时不会退化。因此,可以期待记忆单元的梯度(应该长期记住的信息)能在不发生梯度消失的情况下传播。

LSTM的实现

考虑LSTM的公式

\[\begin{aligned} \pmb f_t&=\sigma(\pmb x_tW_x^{(f)}+\pmb h_{t-1}W_h^{(f)}+\pmb b^{(f)}),\\ \pmb g_t&=\tanh(\pmb x_tW_x^{(g)}+\pmb h_{t-1}W_h^{(g)}+\pmb b^{(g)})\\ \pmb i_t&=\sigma(\pmb x_tW_x^{(i)}+\pmb h_{t-1}W_h^{(i)}+\pmb b^{(i)}),\\ \pmb o_t&=\sigma(\pmb x_tW_x^{(o)}+\pmb h_{t-1}W_h^{(o)}+\pmb b^{(o)}),\\ \pmb c_t&=\pmb f_t\odot\pmb c_{t-1}+\pmb g\odot\pmb i,\\ \pmb h_t&=\pmb o\odot\tanh(\pmb c_t). \end{aligned}\]

里面大量出现仿射变换:$\pmb xW_x+\pmb hW_h+\pmb b$。因此可以将其整合成一个式子:

image-20220403232332497

通过将权重/偏置整合在一起,我们可以加速矩阵计算。所以LSTM的计算图可修改成

image-20220403232554004

LSTM先一起执行 4 个仿射变换。然后,基于slice节点,取出4个结果。这个slice节点很简单,它将仿射变换的结果(矩阵)均等地分成 4 份,然后取出内容。在 slice 节点之后,数据流过激活函数(sigmoid函数或$\tanh$函数),进行上一节介绍的计算。

现在考虑LSTM的反向传播。slice节点将矩阵分成了4份,因此它的反向传播需要整合4个梯度:

image-20220403233225876

上图是slice节点的正向传播(上)和反向传播(下)。

Time LSTM层的实现

Time LSTM层是整体处理$T$个时序数据的层,由$T$个LSTM层构成:

image-20220403233518781

RNN中使用Truncated BPTT 进行学习。Truncated BPTT以适当的长度截断反向传播的连接,但是需要维持正向传播的数据流。通过将隐藏状态和记忆单元保存在成员变量中,就可以在前向传播时继承上一时刻的隐藏状态(和记忆单元):

image-20220403233716325

因此Time LSTM的实现和Time RNN无太大差异。

LSTM语言模型

只需要将Time RNN层替换成Time LSTM层,我们就可以将RNN语言模型变成LSTM语言模型:

image-20220403234132869

这里省略书中关于LSTM语言模型的实现和实验。

RNNLM的进一步改进

书中介绍了三种进一步提升RNNLM性能的方式:

  1. 多层化LSTM层;
  2. Dropout;
  3. 权值共享。

LSTM层的多层化

在使用 RNNLM 创建高精度模型时,加深 LSTM 层(叠加多个 LSTM层)的方法往往很有效。之前我们只用了一个 LSTM 层,通过叠加多个层,可以提高语言模型的精度。比如下图采用的是两个LSTM层:

image-20220404112952803

至于到底该叠加几个层,这个问题等价于全连接神经网络几层效果最佳,需要依靠经验和试错。在 PTB 数据集上学习语言模型的情况下,当LSTM 的层数为 2 ~ 4 时,可以获得比较好的结果;而谷歌翻译中使用的 GNMT 模型是叠加了 8 层 LSTM 的网络。

基于Dropout抑制过拟合

RNN比常规的前馈神经网络更容易发生过拟合,再加上LSTM层的叠加,使得它十分容易过拟合,因此RNN的过拟合对策非常重要。

Dropout选择在训练是随机忽略层的一部分(常常是50%)神经元:

image-20220404113532469

Dropout 随机选择一部分神经元,然后忽略它们,停止向前传递信号。这种“随机忽视”是一种制约,可以提高神经网络的泛化能力。在激活函数后插入Dropout 层将有助于抑制过拟合,比如下图的前馈神经网络:

image-20220404113734367

对于RNN,Dropout层的插入有两种选择,一是时序方向,也就是横向;二是深度方向,也就是纵向。如果选择方案一,在时序方向上插入 Dropout,那么当模型学习时,随着时间的推移,信息会渐渐丢失。也就是说,因 Dropout 产生的噪声会随时间成比例地积累。考虑到噪声的积累,最好不要在时间轴方向上插入 Dropout。因此我们在深度方向(垂直方向)上插入 Dropout 层:

image-20220404114003570

这样一来,无论沿时间方向(水平方向)前进多少,信息都不会丢失。Dropout 与时间轴独立,仅在深度方向(垂直方向)上起作用。

“常规的 Dropout”不适合用在时间方向上。但是,最近的研究提出了多种方法来实现时间方向上的 RNN 正则化。比如,“变分 Dropout”(variational dropout)就被成功地应用在了时间方向上。

除了深度方向,变分 Dropout 也能用在时间方向上,从而进一步提高语言模型的精度。如图 6-34 所示,它的机制是同一层的 Dropout 使用相同的 mask。这里所说的 mask 是指决定是否传递数据的随机布尔值:

image-20220404114112073

通过同一层的 Dropout 共用 mask,mask 被“固定”。如此一来,信息的损失方式也被“固定”,所以可以避免常规 Dropout 发生的指数级信息损失。

权值共享

权值共享,即使将两个不同层的参数设置为相同,这样能够减少参数数量,但仍能提高性能。比如下面的网络

image-20220404114600402

就是设置Embedding层的权重矩阵和Softmax层之前的仿射层的权重矩阵相同。

GRU

LSTM的缺点是的参数太多,计算需要很长时间。这里,我们介绍一下 GRU(Gated Recurrent Unit,门控循环单元)这个有名的 Gated RNN。GRU 保留了 LSTM使用门的理念,但是减少了参数,缩短了计算时间。

我们来介绍下它的结构。相比于LSTM,GRU只使用隐藏状态,和RNN相同:

image-20220404120959958

GRU中进行的计算如下

\[\begin{aligned} \pmb z&=\sigma(\pmb x_tW_x^{(z)}+\pmb h_{t-1}W_{\pmb h}^{(z)}+\pmb b^{(z)})\\ \pmb r&=\sigma(\pmb r_tW_x^{(r)}+\pmb h_{t-1}W_{\pmb h}^{(r)}+\pmb b^{(r)})\\ \bar{\pmb h}&=\tanh(\pmb x_tW_x+(\pmb r\odot\pmb h_{t-1})W_h+\pmb b)\\ \pmb h_t&=(1-\pmb z)\odot\pmb h_{t-1}+\pmb z\odot\bar{\pmb h} \end{aligned}\]

对应的计算图:

image-20220404121720711

GRU 没有记忆单元,只有一个隐藏状态$\pmb h$在时间方向上传播。这里使用和$\pmb r$和$z$共两个门(LSTM 使用 3 个门),$\pmb r$称为reset门,$\pmb z$称为update门。

reset门决定在多大程度上“忽略”过去的隐藏状态。根据上式,如果$\pmb r$是$\pmb 0$,那么新的隐藏状态$\bar{\pmb h}$仅取决于输入$\pmb x_t$。也就是说,此时过去的隐藏状态将完全被忽略。

update门是更新隐藏状态的门,它扮演了LSTM的forget门和input门两个角色。上式中的$(1-\pmb z)\odot\pmb h_{t-1}$充当遗忘门的角色,而$\pmb z\odot\bar{\pmb h}$​则充当input门的功能,对新增的信息进行加权。综上,GRU 是简化了 LSTM 的架构,与 LSTM 相比,可以减少计算成本和参数。

基于RNN生成文本

本章将使用语言模型进行文本生成。具体来说,就是使用在语料库上训练好的语言模型生成新的文本。然后,我们将了解如何使用改进过的语言模型生成更加自然的文本。通过这项工作,我们可以(简单地)体验基于 AI 的文本创作。

另外,本章还会介绍一种结构名为 seq2seq 的新神经网络。seq2seq 是 “(from) sequence to sequence”(从时序到时序)的意思,即将一个时序数据转换为另一个时序数据。本章我们将看到,通过组合两个 RNN,可以轻松实现 seq2seq。seq2seq 可以应用于多个应用,比如机器翻译、聊天机器人和邮件自动回复等。通过理解这个简单但聪明强大的 seq2seq,应用深度学习的可能性将进一步扩大。

使用语言模型生成文本

生成文本的本质,就是根据已经生成的文本,语言模型会输出下一个出现的单词的概率分布:

image-20220404130336118

生成的新单词可以是概率分布中出现概率最高的单词,在这种情况下,因为选择的是概率最高的单词,所以结果能唯一确定。也就是说,这是一种“确定性的”方法。另一种方法是“概率性地”进行选择。根据概率分布进行选择,这样概率高的单词容易被选到,概率低的单词难以被选到。在这种情况下,被选到的单词(被采样到的单词)每次都不一样。

这里我们想让每次生成的文本有所不同,这样一来,生成的文本富有变化,会更有趣。因此,我们通过后一种方法(概率性地选择的方法)来选择单词。依照概率选出单词后,再根据这个概率分布采样下一个出现的单词:

image-20220404130650179

之后根据需要重复此过程即可(或者直到出现<eos>这一结尾记号)。

这里需要注意的是,像上面这样生成的新文本是训练数据中没有的新生成的文本。因为语言模型并不是背诵了训练数据,而是学习了训练数据中单词的排列模式。如果语言模型通过语料库正确学习了单词的出现模式,我们就可以期待该语言模型生成的文本对人类而言是自然的、有意义的。

seq2seq模型

世界上存在许多输入输出都是时序数据的任务,比如机器翻译。从现在开始,我们会考察将时序数据转换为其他时序数据的模型。作为它的实现方法,我们将介绍使用两个 RNN 的 seq2seq 模型。

seq2seq的原理

seq2seq 模型也称为 Encoder-Decoder 模型。顾名思义,这个模型有两个模块——Encoder(编码器)和 Decoder(解码器)。编码器对输入数据进行编码,解码器对被编码的数据进行解码。

考虑将日语翻译成英语的任务(因为作者是日本人,所以例子都是日语),比如将“吾輩は猫である”翻译为“I am a cat”。此时,seq2seq 基于编码器和解码器进行时序数据的转换:

image-20220404131423436

编码器首先对“吾輩は猫である”这句话进行编码,然后将编码好的信息传递给解码器,由解码器生成目标文本。此时,编码器编码的信息浓缩了翻译所必需的信息,解码器基于这个浓缩的信息生成目标文本。

编码器和解码器内部都可以使用RNN。先来看编码器,它就是简单的LSTM层:

image-20220404131528411

注意到这里已经对日语进行了分词后再输入。上图编码器输出的向量$\pmb h$​是LSTM层最后一个隐藏状态,其中编码了翻译输入文本所需的信息。这里的重点是,LSTM 的隐藏状态$\pmb h$是一个固定长度的向量。说到底,编码就是将任意长度的文本转换为一个固定长度的向量:

image-20220404131828228

现在考虑解码器部分,事实上解码器就是一个文本生成模型:

image-20220404132131292

唯一的区别在于,文本生成模型的输出态是零向量,可以理解为不接受任何信息;而seq2seq的解码器需要接受一个来自编码器的信息$\pmb h$​。这个唯一的、微小的改变使得普通的语言模型进化为可以驾驭翻译的解码器

seq2seq的整体结构如下图所示

image-20220404132338640

seq2seq 由两个 LSTM 层构成,即编码器的 LSTM 和解码器的LSTM。此时,LSTM层的隐藏状态是编码器和解码器的“桥梁”。在正向传播时,编码器的编码信息通过 LSTM 层的隐藏状态传递给解码器;在反向传播时,解码器的梯度通过这个“桥梁”传递给编码器。

时序数据的预处理

在实现seq2seq之前,我们需要对时序数据进行预处理。由于时序数据常常是长度可变的,为了支持mini-batch学习,最简单的预处理方法就是填充(padding)以对数据进行对齐:

image-20220404133238651

其中下划线“_”是分隔符,这个分隔符作为通知解码器开始生成文本的信号使用。。上面的数据对齐来源于书中的例子:让seq2seq“学会”加法,即输入是一个加法表达式,输出是对应的结果:

image-20220404133752938

注意这里的输入将不以单词为单位,而是以字符为单位进行分割。比如“57+5”将会被分割为[‘5’, ‘7’, ‘+’, ‘5’]这样的列表。

padding解决了数据长度不一致的问题,但需要处理原本不存在的填充用字符,所以如果追求严谨,使用填充时需要向 seq2seq 添加一些填充专用的处理。比如,

在解码器中输入填充时,不应计算其损失(这可以通过向 Softmax with Loss 层添加 mask 功能来解决)。再比如,在编码器中输入填充时,LSTM层应按原样输出上一时刻的输入。这样一来,LSTM 层就可以像不存在填充一样对输入数据进行编码。

为了方便起见,本书中仍是把填充字符作为一般数据处理。

seq2seq的实现

本书的前置章节已经完成了RNN的实现,这里将RNN分别实现为Encoder类和Decoder类,然后将这两个类组合起来,来实现 seq2seq类。我们先从 Encoder 类开始介绍。

Encoder类

Encoder类接受字符串,转化为向量$\pmb h$:

image-20220404134603527

我们用LSTM层实现上图的Encoder内部结构:

image-20220404134655812

编码器只将 LSTM 的隐藏状态传递给解码器。尽管也可以把 LSTM 的记忆单元传递给解码器,但我们通常不太会把 LSTM 的记忆单元传递给其他层。这是因为,LSTM 的记忆单元被设计为只给自身使用。

若采用Time层写法,则上图可用简单的Time LSTM层和Time Embedding层:

image-20220404134853103

Decoder类

Decoder类接受Encoder类输出的$\pmb h$,输出目标字符串,其层结构如下图所示

image-20220404135324760

值得注意的是,和之前提到的基于概率分布的随机文本生成不同,这里我们只选择得分最高的字符,也就是“确定性”选择:

image-20220404140324786

上图不再适用Softmax,而是直接取仿射层输出的最大值对应的词语ID。因此,Softmax with Loss 层交给此后实现的 Seq2seq 类处理。Decoder 类仅承担 Time Softmax with Loss 层之前的部分:

image-20220404140531785

这样我们能够实现一个seq2seq类。这里提及一下seq2seq的反向传播,Softmax with Loss层的梯度传到Decoder,然后Decoder将梯度通过$\mathrm d\pmb h$转到Encoder:

def backward(self, dout=1):
    dout = self.softmax.backward(dout)
    dh = self.decoder.backward(dout)
    dout = self.encoder.backward(dh)
    return dout

seq2seq的评价

书中用加法问题作为评价,绘制出训练轮次-正确率曲线:

image-20220404141001413

随着学习的积累,正确率稳步提高。本次的实验只进行了 25 次,最后的正确率约为 10%。从图中的变化趋势可知,如果继续学习,正确率应该还会进一步上升。但总的来数学习效果是非常差的,书中在下一节提出对seq2seq进行改进。

seq2seq的改进

这一节介绍两种提升seq2seq性能的技术:

  1. 反转输入数据;
  2. 偷窥。

反转输入数据(Reverse)

第一个改进方案是非常简单的技巧:

image-20220404141519979

将数据反转后训练的效果:

image-20220404141701469

在 25 个 epoch 时,正确率为 50% 左右。再次重复一遍,这里和上一次(图中的 baseline)的差异只是将数据反转了一下。

为什么反转数据后,学习进展变快,精度提高了呢?虽然理论上不是很清楚,但是直观上可以认为,反转数据后梯度的传播可以更平滑。比如,考虑将“吾輩 は 猫 で ある”翻译成“I am a cat”这一问题,单词“吾輩”和单词“I”之间有转换关系。此时,从“吾輩”到“I”的路程必须经过“は”“猫”“で”“ある”这 4 个单词的 LSTM 层。因此,在反向传播时,梯度从“I”抵达“吾輩”,也要受到这个距离的影响。

那么,如果反转输入语句,也就是变为“ある で 猫 は 吾輩”,结果会怎样呢?此时,“吾輩”和“I”彼此相邻,梯度可以直接传递。如此,因为通过反转,输入语句的开始部分和对应的转换后的单词之间的距离变近(这样的情况变多),所以梯度的传播变得更容易,学习效率也更高。不过,在反转输入数据后,单词之间的“平均”距离并不会发生改变,即有些单词与译文间的距离反而增大。

偷窥(Peeky)

如前所述,编码器将输入语句转换为固定长度的向量$\pmb h$,这个$\pmb h$集中了解码器所需的全部信息。也就是说,它是解码器唯一的信息源。但实际上我们可以更充分利用$\pmb h$。

偷窥机制将$\pmb h$分配给所有时刻的Affine层和LSTM层:

image-20220404142138646

这个改进了的解码器称为 Peeky Decoder。同理,将使用了Peeky Decoder 的 seq2seq 称 为 Peeky seq2seq。

上图中,有两个向量同时被输入到了 LSTM 层和 Affine 层,这实际上表示两个向量的拼接(concatenate)。因此,在刚才的图中,如果使用 concat 节点拼接两个向量,那么计算图就是

image-20220404142728931

加入两种改进方法后,seq2seq的学习效果:

image-20220404142822383

加上了 Peeky 的 seq2seq 的结果大幅变好。刚过 10 个 epoch 时,正确率已经超过 90%,最终的正确率接近 100%。

从上述实验结果可知,Reverse 和 Peeky 都有很好的效果。借助反转输入语句的 Reverse 和共享编码器信息的 Peeky,我们获得了令人满意的结果!

seq2seq的应用

seq2seq 将某个时序数据转换为另一个时序数据,这个转换时序数据的框架可以应用在各种各样的任务中,比如以下几个例子:

  • 机器翻译:将“一种语言的文本”转换为“另一种语言的文本”;
  • 自动摘要:将“一个长文本”转换为“短摘要”;
  • 问答系统:将“问题”转换为“答案”;
  • 邮件自动回复:将“接收到的邮件文本”转换为“回复文本”。

除此以外,seq2seq应用在了聊天机器人中,回答用户的问题;seq2seq除了学习加法,还可以学习代码中的算法:

image-20220404143213487

自动图像描述是通过给定图像,输出一段对图像进行描述的文本。这里的编码器改成了适合图像处理的CNN:

image-20220404143324734

将 CNN 的特征图扁平化到一维,并基于全连接的 Affine 层进行转换。之后,再将转换后的数据传递给解码器,就可以像之前一样生成文本了。

Attention

本章我们将进一步探索 seq2seq 的可能性(以及 RNN 的可能性)。这里,Attention 这一强大而优美的技术将登场。Attention 毫无疑问是近年来深度学习领域最重要的技术之一。

Attention的结构

基于 Attention 机制,seq2seq 可以像我们人类一样,将“注意力”集中在必要的信息上。此外,使用 Attention 可以解决当前 seq2seq 面临的问题。首先我们需要指出seq2seq存在的问题,尽管它在上一章能很好解决加法学习问题。

seq2seq存在的问题

回顾上一章的内容,seq2seq的编码器的输出是固定长度的向量。也就是说,无论输入序列的长度如何,都会被转换成长度相同的向量:

image-20220404164003977

当输入序列足够长,信息足够多的时候,固定长度的$\pmb h$​将会无法容纳那么多的信息,有用的信息会从向量中溢出。

编码器的改进

为了解决上述问题,我们考虑将各个时刻LSTM层的隐藏状态:

image-20220404164150888

在上图中,输入了 5 个单词,此时编码器输出 5 个向量。这样一来,编码器就摆脱了“一个固定长度的向量”的制约。各个时刻的 LSTM 层的隐藏状态包含了大量当前时刻的输入单词的信息。比如输入“猫”时 LSTM 层的输出(隐藏状态)受此时输入的单词“猫”的影响最大。。因此,可以认为这个隐藏状态向量蕴含许多“猫的成分”。所以矩阵$\pmb h_s$可视作各个单词对应的向量集合:

image-20220404164741503

因为编码器是从左向右处理的,所以严格来说,刚才的“猫”向量中含有“吾輩”“は”“猫”这3个单词的信息。考虑整体的平衡性,最好均衡地含有单词“猫”周围的信息。在这种情况下,从两个方向处理时序数据的双向RNN(或者双向LSTM)比较有效。

在改进了编码器后,我们考虑解码器的修改。

解码器的改进(1)

编码器的输出从向量变成了矩阵。原来的解码器相当于从$\pmb h_s$中抽取最后一行作为输出:

image-20220404164941476

现在我们想要解码器用到所有的$\pmb h_s$。

我们在进行翻译时,大脑做了什么呢?比如,在将“吾輩は猫である”这句话翻译为英文时,肯定要用到诸如“吾輩 = I”、“猫 = cat”这样的知识。也就是说,可以认为我们是专注于某个单词(或者单词集合),随时对这个单词进行转换的。这其实就是“注意力”,我们希望seq2seq也具有这样的能力。

在机器翻译的历史中,很多研究都利用“猫 =cat”这样的单词对应关系的知识。这样的表示单词(或者词组)对应关系的信息称为对齐(alignment)。到目前为止,对齐主要是手工完成的,而我们将要介绍的 Attention 技术则成功地将对齐思想自动引入到了 seq2seq 中。这也是从“手工操作”到“机械自动化”的演变。

因此,我们的目标是找到与“翻译目标词”有对应关系的“翻译源词”的信息,然后利用这个信息进行翻译。也就是说,我们的目标是仅关注必要的信息,并根据该信息进行时序转换。这个机制称为 Attention,是本章的主题。我们先给出它的整体框架:

image-20220404165722130

其中“某种计算”层接受$\pmb h_s$​,然后从中选出必要的信息,并输出到 Affine 层。与之前一样,编码器的最后的隐藏状态向量传递给解码器最初的 LSTM 层。

我们希望“某种计算”层实现一种选择,比如让解码器输出“I”的时候,从$\pmb h_s$选出”吾輩“的对应向量。由于简单的选择是不可微的,因此我们将选择操作转化为赋权,权值越大,说明在该单词上的注意力越多:

image-20220404170139888

其中$\pmb a$​​表示各个单词重要度(贡献值)的权重。计算这个表示各个单词重要度的权重和单词向量$\pmb h_s$的加权和,可以获得目标向量(上下文向量)$\pmb c$:

image-20220404170341136

这里提及一下上面获取上下文向量的实现技巧:

c = (hs * a.reshape(-1, 1)).sum(0)

通过numpy数组的广播乘法快速求出$\pmb c$。后面主要讲的是矩阵乘积中“轴”的问题,由于和Attention没有太大联系,我们就此略过。

解码器的改进(2)

我们可以将权重向量$\pmb a$理解为注意力,现在考虑如何求出每个单词对应的$\pmb a$。对于解码器第一个LSTM层的隐藏状态向量:

image-20220406151524920

我们可以用$\pmb h$和$\pmb{hs}$的每一行向量的相似程度作为$\pmb a$。用向量的内积作为相似度独立是一个简单而有效的方法:

image-20220406151806458

然后自然而然地用Softmax将$\pmb s$正规化为非负且和为1的权重向量:

image-20220406151911227

到此为止,计算上下文向量$\pmb c$的计算图表示如下:

image-20220406152216621

进一步,我们可以将这一系列的计算的层称作Attention层

image-20220406152301748

注意到$\pmb{hs}$的梯度有两个来源,WeightSum层和AttentionWeight层。Attention层被放在LSTM层和Affine层中间:

image-20220406152652517

可以说,Attention机制是被加到了seq2seq上,而没有修改seq2seq的初始结构:

image-20220406152756279

带Attention的seq2seq

上一节讲的是Attention层的实现,而这一节讲的是Time Attention,从而实现一个基于Attention的seq2seq。其结构如下:

image-20220406153014282

这里将所有的层都抽象为了Time层。

Attention的评价

在上一节,作者带我们实现了基于Attention的seq2seq。这一节我们想用它来研究“日期格式转换问题”,即

image-20220406153239241

学习效果与前面一般的seq2seq的对比:

image-20220406153335033

发现Attention在保持好的学习效果的同时提高的收敛速度。

Attention的可视化

Attention机制的一个优点就在于能够可视化模型,使模型的可理解性增强。使用学习好的模型,对进行时序转换时的Attention权重进行可视化。横轴是模型的输入语句,纵轴是模型的输出语句。地图中的元素越接近白色,其值越大:

image-20220406154053161

我们可以看到,当 seq2seq 输出第 1 个“1”时,注意力集中在输入语句的“1”上。这里需要特别注意年月日的对应关系。仔细观察图中的结果,纵轴(输出)的“1983”和“26”恰好对应于横轴(输入)的“1983”和“26”。另外,输入语句的“AUGUST”对应于表示月份的“08”,这一点也很令人惊讶。这表明 seq2seq 从数据中学习到了“August”和“8 月”的对应关系。下图给出了其他一些例子,从中也可以很清楚地看到年月日的对应关系:

image-20220406154159892

关于Attention的其他话题

这里讲几个之前未涉及的话题。

双向RNN

前面提到,数据的时序输入,使得中间单词的对应向量编码了它之前所有单词的信息。考虑整体平衡性,我们希望向量能更均衡地包含该单词周围的信息。也就是说,我们希望RNN可以从左往右读一次文本,同时从右向左再读一次。

为此,可以让 LSTM 从两个方向进行处理,这就是名为双向 LSTM 的技术:

image-20220406154539479

Attention层的使用方法

我们之前使用Attention层,都是将其插入在LSTM层和Affine层之间:

image-20220406155102538

不过使用 Attention 层的方式并不一定非得像上面那样。实际上,使用Attention 的模型还有其他好几种方式,比如横着连:

image-20220406155155046

通过这种结构,LSTM 层得以使用上下文向量的信息。相对地,我们实现的模型则是 Affine 层使用了上下文向量。从实现的角度来看,前者的结构(在 LSTM 层和 Affine 层之间插入Attention 层)更加简单。这是因为在前者的结构中,解码器中的数据是从下往上单向流动的,所以 Attention 层的模块化会更加简单。

seq2seq的深层化和 skip connection

在面对更复杂的任务时,加深RNN层是一个有效的做法:

image-20220406155359014

随之而来的就是梯度爆炸和梯度消失的问题,这里就要引入残差连接(skip connection,也称为 residual connection 或 shortcut)的思想:

image-20220406155509570

所谓残差连接,就是指“跨层连接”。此时,在残差连接的连接处,有两个输出被相加。请注意这个加法(确切地说,是对应元素的加法)非常重要。因为加法在反向传播时“按原样”传播梯度,所以残差连接中的梯度可以不受任何影响地传播到前一个层。这样一来,即便加深了层,梯度也能正常传播,而不会发生梯度消失(或者梯度爆炸),学习可以顺利进行。

我们在之前提到过缓解梯度消失/爆炸的方法:LSTM、GRU 等 Gated RNN去应对梯度消失,梯度裁剪去应对梯度爆炸。但这些是横向的,而对于深度方向上的梯度消失,这里介绍的残差连接很有效。

Attention的应用

这里简单介绍Attention的几种应用。前面只是将其与seq2seq结合,但Attention的思想却不仅限于此。

GNMT

神经机器翻译(Neural Machine Translation)取代了过去的“基于规则的翻译”、“基于用例的翻译”和“基于统计的翻译”,获得了广泛关注。

从 2016 年开始,谷歌翻译就开始将神经机器翻译用于实际的服务,其机器翻译系统称为 GNMT(Google Neural Machine Translation,谷歌神经机器翻译系统):

image-20220406160442875

GNMT 和本章实现的带 Attention 的 seq2seq 一样,由编码器、解码器和 Attention 构成。不过,与我们的简单模型不同,这里可以看到许多为了提高翻译精度而做的改进,比如 LSTM 层的多层化、双向 LSTM(仅编码器的第 1 层)和 skip connection 等。另外,为了提高学习速度,还进行了多个 GPU 上的分布式学习。

除了上述在架构上下的功夫之外,GNMT 还进行了低频词处理、用于加速推理的量化(quantization)等工作。利用这些技巧,GNMT 获得了非常好的结果。

Transformer

在《Attention is all you need》一文中,Transformer作为一种只使用Attention机制,而不用RNN的技术被提出。

Transformer 是基于 Attention 构成的,其中使用了 Self-Attention 技巧,这一点很重要。Self-Attention 直译为“自己对自己的 Attention”,也就是说,这是以一个时序数据为对象的 Attention,旨在观察一个时序数据中每个元素与其他元素的关系。用 Time Attention 层来说明的话,自注意力如下面的右图所示,左图是常规的Attention:

image-20220406160709241

在此之前,我们用 Attention 求解了翻译这种两个时序数据之间的对应关系。如图 8-37 的左图所示,Time Attention 层的两个输入中输入的是不同的时序数据。与之相对,如上面的右图所示,Self-Attention 的两个输入中输入的是同一个时序数据。像这样,可以求得一个时序数据内各个元素之间的对应关系。

Transformer的结构图如下:

image-20220406161007390

可以发现Transformer的编码器和解码器都用到了Self-Attention。Feed Forward层表示前馈神经网络(在时间方向上独立的网络)。具体而言,使用具有一个隐藏层、激活函数为 ReLU 的全连接的神经网络。另外,图中的$Nx$表示灰色背景包围的元素被堆叠了$N$次。

全书总结

《深度学习进阶:自然语言处理》这本书对NLP领域以及深度学习在其中的应用作了详述和实现方法的介绍。笔者认为本书的重点还是实践,理论次之,但仍不失为一本NLP入门的好书。这篇文章是读书笔记式的,所以一些对于笔者来说简单,但对读者来说不是那么浅显的细节会进行skip;而笔者觉得需要详述的知识点,可能对不少读者来说是显而易见的。因此,笔者主要是将本文作为自用。当然,如果你在阅读时没有发现障碍感,那你在知识基础上必然是在笔者之上的,希望你喜欢这篇文章。