自然语言处理(NLP)是为了让计算机理解自然语言。NLP和编译器是有联系的,人类分析编译器的洞察力也可以被应用到NLP上面,不过由于编程语言是无二义性的,或者可以通过简单的规则,比如优先级,消除二义性,如此一来,研究如何设计编译器,更多的是研究精确的文法。相比较而言,自然语言的意思和形式会灵活变化。不过可以从人的思维角度设计编译器,也可以从人的思维角度设计NLP。
语言是由词语组成的,那么无论是计算机还是人,要理解自然语言都需要先理解词语,不过注意,如果意识无法掌控句子的产生过程(这是很可能的,意识无法知道一句话怎么产生,只是知道一句话被产生、被说出),那么对于意识来说,句子可能不是由词语拼接起来的,而是大脑制造的一种模式,意识对于句子的分析是事后的,而不是反映了大脑的实际运行机制。
实际上,任何一个自然语言可以变为非二义性的语言,对于意识而言,自然语言是无二义性的,意识很轻易的把语言理解为知觉的对应物,并且做出判断:词语的意义来源于经验。但这不过是大脑的无意识处理的强大而已,大脑在意识之外处理了二义性,这意味着如果要设计程序理解语言,这个程序是理解大脑,而不是意识本身,也不怎么需要意识的直觉、理解。所以不是自然语言多么巧妙,而是大脑处理语言的方式实在是巧妙,大脑自动从上下文推断出多义性的词语在一个确定的环境中代表什么意思。
另外,即使意识通过反思推断出,词语的意义来源于知觉,人类通过各种知觉学习新的词语,但是如何记忆知觉这是意识之外的,所以计算机如何实现这种直觉,这是复杂的。这种程序的设计依旧要模仿大脑,而不是意识的直觉。
但是无论如何,计算机需要表征词语,人类也需要,不过在意识看来,词语的表示和词语符号感受联系十分紧密,比如视觉、听觉、触觉。但是,词语的符号和语义的关系是很小的,意识已经在这种掌控之中丧失了对于语义的把握了。
对词语进行表示,显然需要展示符号的差异,一种自然甚至必须的表示,是为每个词语进行编号,这样每个词语对应一个实数。这种编号是合理的,因为词语的符号本身就是一种记法,记为实数就行了。当然,单个实数的表示是有精度限制的,所以,为了方便,也可以把词语表示为实数对,这可以被视为向量。向量的好处在于引入了几何结构,这对于意识而言,操作更为直观,更加容易启发人的思维。
这样一来,自然语言的处理就是单词向量的处理,抹杀了意识的理解模式,虽然这是不关键的。
怎么建立向量之间的联系?
比如同义词词典,一般词典运用一些词语定义或者说明一个词语,这可以被视为一种理解方式。同义词词典则是把意思相近的词语放在一组,与此同时,进行概念的分层,以此形成词语网络。WordNet是最为著名的同义词词典。这种方式的缺点在于:
根据人类产生的句子、文章可以自动推断词语之间的联系,比如在一句话、一段话或者一篇文章中,两个词语一起出现,这意味着词语之间的关系比较接近,这里要注意一些无含义的词语比如‘的’ 、‘是’ 、‘之’ 这样的词语,这种词语几乎可以被忽略,或者使用一种算法把词语本身出现的频率考虑进来,比如计算词语之间的关系时,计算两个词语共同出现频率除以单独出现频率。
但是这种方式会让向量维数变得很大——当词语数量很多的时候,显然不太可行,况且这和人类大脑也不相似。虽然由于向量很可能比较稀疏——毕竟一个词语不可能与所有词语有着紧密的关系,可以采取一些措施进行降维,比如SVD,但是维数依旧很大,而且对于很多维的矩阵进行降维几乎是不可能的,因为需要很长时间。
解决维数过大的问题是不难的,毕竟词语被向量表示,并不需要每一个词语占有一个维度,这实在太过于奢侈,因为一个维度是实数范围的,它可以存储的信息是远远超出一个比特,即一个词语的表征的。
所以可以直接把词语编码为低维的向量,而不是先设置为超高维,再进行降维。
此时应该怎么找到向量之间的关系呢?须知,现在一个维度不是单独属于一个词语的了,那么直接统计就不太可行。现在能够使用内积计算词语之间的距离,可以把向量视为参数,对参数进行优化。
word2vec采用两种策略构造这种优化。一种策略是,输入是上下文的两个单词,要预测中间的单词是什么(输出一个数字表示哪个词语,通过多分类衡量损失),这样可以设定一个目标,使得优化得以进行。另外一种策略是输入一个单词,预测左右两个单词。
即使如此,词语数很大时,对全部词语进行判断也需要很久时间,这可以通过直接判断词语是不是训练数据指定的,即变为二分类。于此同时要使用负采样,对于不正确的答案要给予训练,这可以通过频率采样几个不正确的词语。把损失加起来,就可以实现参数改变,最终向量十分满足这种上下文模式。
这种方式的缺点是,虽然考虑了词语之间的关系,但是只有左右相邻的几个词语,虽然这个范围可以任意大,但是过大也不好训练,并且,人类可以构造很复杂的词语,使得代词指向很远的位置的词语。另外这个方式没有考虑顺序问题,左右的词语是对称处理的。这个缺点虽然可以通过拼接词语向量来解决,但是这依旧会让复杂度变大。
实际上这种词向量已经表现很好了,不过在一些场景不那么令人满意。有人使用循环神经网络,实现编码器和解码器,然后使用Attention机制使用更多、更加专注特定的信息来解决这些问题。
除了对于词语进行编码,还可以直接对于句子进行编码,这让计算量变得更大,但是似乎是打破瓶颈的一种方式,这被成为思想向量。
虽然词向量无疑十分强大,但是词向量究竟能达到什么地步,没有人能够给出分析,毕竟词向量学习的是人类书写的句子,在句子中发现联系词语之间的关系。那么怎么知道词语的关系决定了词语本身的全部呢,有没有可能,人类使用词语的方式和这种词向量的方式并不一致?
首先,人不可能根据这么大量的数据来建立词语之间的关系——无论如何,这些数据是通过无数人创造出来的,而且就意识的体验而言,试想一下小孩如何掌握母语,虽然学会一门语言需要不断使用,但是词语是一步步掌握的,不需要一开始就掌握所有词语,词语也能慢慢扩展,与其说人类一下子建立词语之间的联系,不如说大脑掌握了一种模式,这种模式使得学过的词语之间拥有联系,而且更加容易学会一个新的词语,人类学习一门语言,掌握词语的速度会越来越快。
与其说是人学习语言,不如说人决定语言。人类觉得说话简单,看懂句子很容易,这是大脑自动处理的结果——没人知道自己想的下一句是什么话,意识也只是从经验上理解单个的词语,把它和知觉对应起来,但是实际上很有可能这是大脑分析之后给出的解释,大脑可能不是根据这种知觉意义进行分析的。
大脑产生句子容易似乎只是句子被说了很多次,这被编码了。分析句子也是如此。一个常说的句子很容易被说出,一个很新的句子会很难被说出来,说起来大脑只是组合句子——这是不精确的,但是人们却认为大脑具有弹性,这种不精确被视为自然语言的神奇,这是巨大的讽刺!
存在这样一种情况,自然语言具有歧义性不过是因为大脑产生句子也是不精确运作的,人类为什么觉得一个不严格的句子是合理的,为什么一个不合理的句子可以被别人理解?这并不难以解释,因为如果大脑解释一切,所谓合理是由大脑决定的,所以合理是不需要解释的,至于别人的大脑怎么理解这一切,这也不难解释,毕竟如果假设大脑之间的结构是类似的,大脑之间是等价的,一个大脑产生的句子被别人理解和被自己理解是一样的,所以只要一个大脑对自己产生的句子感到满意,没有什么可以阻止别人的大脑会认为这个句子很奇怪。
齐夫定律:在自然语言的语料库里,一个单词出现的频率与它在频率表里的排名成反比。所以,频率最高的单词出现的频率大约是出现频率第二位的单词的2倍,而出现频率第二位的单词则是出现频率第四位的单词的2倍。
从而可以使用词典分词。
中文需要分词,以下记录简单的分词算法。
正向最长匹配:正向匹配,输出最长的字组成的单词。
逆向最长匹配:正向匹配,输出最长的字组成的单词。
双向最长匹配:执行两种匹配,输出最短词数的方式,如果词数一样,则输出单字更少的方式(单字词语远远少于非单字的词语);如果还是一样,优先选择逆向的。
这里的大部分内容基于《深度学习进阶:自然语言处理》
基于计数的方法的目标就是从富有实践知识的语料库中,自动且高效地提取本质。**语料库(corpus)**就是大量的文本数据。不过,语料库并不是胡乱收集数据,一般收集的都是用于自然语言处理研究和应用的文本数据。语料库中包含了大量的关于自然语言的实践知识,即文章的写作方法、单词的选择方法和单词含义等。
自然语言处理领域中使用的语料库有时会给文本数据添加额外的信息。比如,可以给文本数据的各个单词标记词性。在这种情况下,为了方便计算机处理,语料库通常会被结构化(比如,采用树结构等数据形式)。
“某个单词的含义由它周围的单词形成”,称为分布式假设(distributional hypothesis)。分布式假设所表达的理念非常简单。单词本身没有含义,单词含义由它所在的上下文(语境)形成。上下文的大小(即周围的单词有多少个)称为窗口大小(window size)。
在关注某个单词的情况下,对它的周围出现了多少次什么单词进行计数,然后再汇总。这里,我们将这种做法称为“基于计数的方法”,在有的文献中也称为“基于统计的方法”。
汇总所有单词的共现单词成为一个矩阵,矩阵行和列都是单词,对应元素表示两者同时出现次数。各行对应相应单词的向量。这被称为共现矩阵(co-occurence matrix)。可以使用余弦衡量单词之间的相似度。
共现矩阵的元素表示两个单词同时出现的次数。但是,这种“原始”的次数并不具备好的性质。比如,我们来考虑某个语料库中the和car共现的情况。在这种情况下,我们会看到很多“…the car…”这样的短语。因此,它们的共现次数将会很大。另外,car和drive也明显有很强的相关性。但是,如果只看单词的出现次数,那么与drive相比,the和car的相关性更强。这意味着,仅仅因为the是个常用词,它就被认为与car有很强的相关性。
为了解决这一问题,可以使用点互信息(Pointwise Mutual Information, PMI)这一指标。对于随机变量x和y,它们的PMI定义如下: P M I ( x , y ) = l o g 2 P ( x , y ) P ( x ) P ( y ) PMI(x,y)=log_2\frac{P(x,y)}{P(x)P(y)} PMI(x,y)=log2P(x)P(y)P(x,y)。PMI也有一个问题。那就是当两个单词的共现次数为0时, l o g 2 0 log_20 log20=-∞。为了解决这个问题,实践上我们会使用正的点互信息(Positive PMI, PPMI): P P M I ( x , y ) = m a x ( 0 , P M I ( x , y ) ) PPMI(x,y)=max(0,PMI(x,y)) PPMI(x,y)=max(0,PMI(x,y))。
但是,这个PPMI矩阵还是存在很多的问题:
对于这些问题,一个常见的方法是向量降维。降维(dimensionality reduction),顾名思义,就是减少向量维度。但是,并不是简单地减少,而是在尽量保留“重要信息”的基础上减少。我们要观察数据的分布,并发现重要的“轴”。
降维的方法有很多,比如奇异值分解(Singular Value Decomposition,SVD)。可以使用NumPy的linalg模块中的svd方法。
如果矩阵大小是N,SVD的计算的复杂度将达到O($N^3 $)。这意味着SVD需要与N的立方成比例的计算量。因为现实中这样的计算量是做不到的,所以往往会使用Truncated SVD等更快的方法。Truncated SVD通过截去(truncated)奇异值较小的部分,从而实现高速化。作为另一个选择,可以使用sklearn库的Truncated SVD。
Penn Treebank语料库(以下简称为PTB)经常被用作评价提案方法的基准。PTB语料库在word2vec的发明者托马斯·米科洛夫(Tomas Mikolov)的网页上有提供。这个PTB语料库是以文本文件的形式提供的,与原始的PTB的文章相比,多了若干预处理,包括将稀有单词替换成特殊字符(unk是unknown的简称),将具体的数字替换成“N”等。
如果词汇量超过100万个,那么使用基于计数的方法就需要生成一个100万×100万的庞大矩阵,但对如此庞大的矩阵执行SVD显然是不现实的。
基于推理的方法使用了推理机制(神经网络实现),它也使用了分布式假设。基于推理的方法使用神经网络,通常在mini-batch数据上进行学习。这意味着神经网络一次只需要看一部分学习数据(mini-batch),并反复更新权重。神经网络的学习可以使用多台机器、多个GPU并行执行,从而加速整个学习过程。
基于推理的方法的主要操作是“推理”。当给出周围的单词(上下文)时,预测当前处会出现什么单词,这就是推理。基于推理的方法引入了某种模型,我们将神经网络用于此模型。这个模型接收上下文信息作为输入,并输出(可能出现的)各个单词的出现概率。在这样的框架中,使用语料库来学习模型,使之能做出正确的预测。另外,作为模型学习的产物,我们得到了单词的分布式表示。这就是基于推理的方法的全貌。
没有偏置的全连接层相当于在计算矩阵乘积。在很多深度学习的框架中,在生成全连接层时,都可以选择不使用偏置。
这里使用由原版word2vec提出的名为**continuous bag-of-words(CBOW)**的模型作为神经网络。word2vec一词最初用来指程序或者工具,但是随着该词的流行,在某些语境下,也指神经网络的模型。正确地说,CBOW模型和skip-gram模型是word2vec中使用的两个神经网络。关于这两个模型的差异之后有讨论。
CBOW模型的输入是上下文。这个上下文用[‘you’, ‘goodbye’]这样的单词列表表示。将其转换为one-hot表示,以便CBOW模型可以进行处理。
同样将语料库中的目标单词作为目标词,将其周围的单词作为上下文提取出来。我们对语料库中的所有单词都执行该操作(两端的单词除外),可以得到contexts(上下文)和target(目标词)。contexts的各行成为神经网络的输入,target的各行成为正确解标签(要预测出的单词)。上下文和目标词的数量是任意的,目标词一般为一个,所以采用单数。
例如,一个CBOW网络有两层,输入,中间,输出,最后使用Softmax函数将得分转化为概率,再求这些概率和监督标签之间的交叉熵误差,并将其作为损失进行学习。输入到中间、中间到输出都是全连接。
输入是两个1×7的单词向量(上下文两个单词one-hot,一共7个单词),从输入层到中间层的变换由全连接层(权重是Win)完成,可以把两个向量单独计算,把结果取平均形成中间层。此时,全连接层的权重Win是一个7×3的矩阵( W o u t W_{out} Wout是3×7),权重Win的各行保存着各个单词的分布式表示。通过反复学习,不断更新各个单词的分布式表示,以正确地从上下文预测出应当出现的单词。令人惊讶的是,如此获得的向量很好地对单词含义进行了编码。这就是word2vec的全貌。
中间层的神经元数量比输入层少这一点很重要。中间层需要将预测单词所需的信息压缩保存,从而产生密集的向量表示。这时,中间层被写入了我们人类无法解读的代码,这相当于“编码”工作。而从中间层的信息获得期望结果的过程则称为“解码”。这一过程将被编码的信息复原为我们可以理解的形式。
word2vec中使用的网络有两个权重,分别是输入侧的全连接层的权重(Win)和输出侧的全连接层的权重(Wout)。一般而言,输入侧的权重Win的每一行对应于各个单词的分布式表示。另外,输出侧的权重Wout也同样保存了对单词含义进行了编码的向量,只是输出侧的权重在列方向上保存了各个单词的分布式表示。
那么,我们最终应该使用哪个权重作为单词的分布式表示呢?这里有三个选项。A.只使用输入侧的权重 B.只使用输出侧的权重 C.同时使用两个权重方案
A和方案B只使用其中一个权重。而在采用方案C的情况下,根据如何组合这两个权重,存在多种方式,其中一个方式就是简单地将这两个权重相加。就word2vec(特别是skip-gram模型)而言,最受欢迎的是方案A。许多研究中也都仅使用输入侧的权重Win作为最终的单词的分布式表示。遵循这一思路,我们也使用Win作为单词的分布式表示。有人通过实验证明了word2vec的skip-gram模型中Win的有效性。另外,在与word2vec相似的GloVe方法中,通过将两个权重相加,也获得了良好的结果。
CBOW模型进行的处理是,当给定某个上下文时,输出目标词的概率。这里,我们使用包含单词 w 1 , w 2 , ⋅ ⋅ ⋅ , w T w_1, w_2,···, w_T w1,w2,⋅⋅⋅,wT的语料库。对第t个单词,考虑窗口大小为1的上下文。用数学式来表示当给定上下文 w t − 1 和 w t + 1 时目标词为 w t w_{t-1}和w_{t+1}时目标词为w_t wt−1和wt+1时目标词为wt的概率。使用后验概率表示为 P ( w t ∣ w t − 1 , w t + 1 ) P(w_t|w_{t-1}, w_{t+1}) P(wt∣wt−1,wt+1),
交叉熵误差函数是 L = − ∑ k t k l o g y k L=-\sum_kt_klog\space y_k L=−∑ktklog yk, y k y_k yk表示第k个事件发生的概率。 t k t_k tk是监督标签,它是one-hot向量的元素。这里需要注意的是,“ w t w_t wt发生”这一事件是正确解,它对应的one-hot向量的元素是1,其他元素都是0(也就是说,当 w t w_t wt之外的事件发生时,对应的one-hot向量的元素均为0)。考虑到这一点,可以推导出下式:
L = − l o g P ( w t ∣ w t − 1 , w t + 1 ) L=-log\space P(w_t|w_{t-1}, w_{t+1}) L=−log P(wt∣wt−1,wt+1)
这也称为负对数似然(negative log likelihood)。这是一笔样本数据的损失函数。如果将其扩展到整个语料库,则损失函数可以写为:
L = − 1 T ∑ t = 1 T l o g P ( w t ∣ w t − 1 , w t + 1 ) L=-\frac{1}{T}\sum_{t=1}^Tlog\space P(w_t|w_{t-1}, w_{t+1}) L=−T1∑t=1Tlog P(wt∣wt−1,wt+1)
CBOW模型学习的任务就是让这个损失函数尽可能地小。那时的权重参数就是我们想要的单词的分布式表示。这里,我们只考虑了窗口大小为1的情况,不过其他的窗口大小(或者窗口大小为m的一般情况)也很容易用数学式表示。
word2vec有两个模型:一个是我们已经讨论过的CBOW模型;另一个是被称为skip-gram的模型。CBOW模型从上下文的多个单词预测中间的单词(目标词),而skip-gram模型则从中间的单词(目标词)预测周围的多个单词(上下文)。
skip-gram模型的输入层只有一个,输出层的数量则与上下文的单词个数相等。因此,首先要分别求出各个输出层的损失(通过Softmax with Loss层等),然后将它们加起来作为最后的损失。现在,我们使用概率的表示方法来表示skip-gram模型。skip-gram可以建模为: P ( w t − 1 , w t + 1 ∣ w t ) P(w_{t-1}, w_{t+1}|w_t) P(wt−1,wt+1∣wt)
假定上下文的单词之间没有相关性(正确地说是假定“条件独立”),可以如下进行分解: P ( w t − 1 , w t + 1 ∣ w t ) = P ( w t − 1 ∣ w t ) P ( w t + 1 ∣ w t ) P(w_{t-1}, w_{t+1}|w_t)=P(w_{t-1}|w_t)P(w_{t+1}|w_t) P(wt−1,wt+1∣wt)=P(wt−1∣wt)P(wt+1∣wt)
可以推导出skip-gram模型的损失函数: L = − ( l o g P ( w t − 1 ∣ w t ) + l o g P ( w t + 1 ∣ w t ) ) L=-(log\space P(w_{t-1}|w_t)+log\space P(w_{t+1}|w_t)) L=−(log P(wt−1∣wt)+log P(wt+1∣wt))
那么应该使用CBOW模型和skip-gram模型中的哪一个呢?答案应该是skip-gram模型。这是因为,从单词的分布式表示的准确度来看,在大多数情况下,skip-gram模型的结果更好。特别是随着语料库规模的增大,在低频词和类推问题的性能方面,skip-gram模型往往会有更好的表现(单词的分布式表示的评价方法会在之后说明)。此外,就学习速度而言, CBOW模型比skip-gram模型要快。这是因为skip-gram模型需要根据上下文数量计算相应个数的损失,计算成本变大。
对于skip-gram模型的问题,存在许多候选项。因此,可以说skip-gram模型要解决的是更难的问题。经过这个更难的问题的锻炼,skip-gram模型能提供更好的单词的分布式表示。
基于计数的方法通过对整个语料库的统计数据进行一次学习来获得单词的分布式表示,而基于推理的方法则反复观察语料库的一部分数据进行学习(mini-batch学习)。这里就其他方面来对比一下这两种方法。
首先,我们考虑需要向词汇表添加新词并更新单词的分布式表示的场景。此时,基于计数的方法需要从头开始计算。即便是想稍微修改一下单词的分布式表示,也需要重新完成生成共现矩阵、进行SVD等一系列操作。相反,基于推理的方法(word2vec)允许参数的增量学习。具体来说,可以将之前学习到的权重作为下一次学习的初始值,在不损失之前学习到的经验的情况下,高效地更新单词的分布式表示。在这方面,基于推理的方法(word2vec)具有优势。
其次,两种方法得到的单词的分布式表示的性质和准确度有什么差异呢?就分布式表示的性质而言,基于计数的方法主要是编码单词的相似性,而word2vec(特别是skip-gram模型)除了单词的相似性以外,还能理解更复杂的单词之间的模式。关于这一点,word2vec因能解开“king-man+woman=queen”这样的类推问题而知名(关于类推问题之后说明)。
这里有一个常见的误解,那就是基于推理的方法在准确度方面优于基于计数的方法。实际上,有研究表明,就单词相似性的定量评价而言,基于推理的方法和基于计数的方法难分上下。
另外一个重要的事实是,基于推理的方法和基于计数的方法存在关联性。具体地说,使用了skip-gram和Negative Sampling的模型被证明与对整个语料库的共现矩阵(实际上会对矩阵进行一定的修改)进行特殊矩阵分解的方法具有相同的作用。换句话说,这两个方法论(在某些条件下)是“相通”的。此外,在word2vec之后,有研究人员提出了GloVe方法。GloVe方法融合了基于推理的方法和基于计数的方法。该方法的思想是,将整个语料库的统计数据的信息纳入损失函数,进行mini-batch学习。据此,这两个方法论成功地被融合在了一起。
不要企图无所不知,否则你将一无所知。——德谟克利特
当词汇量达到一定程度之后,CBOW模型的计算就会花费过多的时间。这里将对简单的word2vec进行两点改进:引入名为Embedding层的新层,以及引入名为Negative Sampling的新损失函数。这样一来,我们就能够完成一个“真正的”word2vec。
之前限定了上下文的窗口大小为1。这相当于只将目标词的前一个和后一个单词作为上下文。这里将给模型新增一个功能,使之能够处理任意窗口大小的上下文。
假设词汇量有100万个,CBOW模型的中间层神经元有100个。在如此多的神经元的情况下,中间的计算过程需要很长时间。具体来说,以下两个地方的计算会出现瓶颈。
第一点所做的无非是将矩阵的某个特定的行取出来。因此,直觉上将单词转化为one-hot向量的处理和MatMul层中的矩阵乘法似乎没有必要。现在,我们创建一个从权重参数中**抽取“单词ID对应行(向量)”**的层,这里我们称之为Embedding层。Embedding来自“词嵌入”(word embedding)这一术语。也就是说,在这个Embedding层存放词嵌入(分布式表示)。
Embedding层的正向传播只是从权重矩阵W中提取特定的行,并将该特定行的神经元原样传给下一层。因此,在反向传播时,从上一层(输出侧的层)传过来的梯度将原样传给下一层(输入侧的层)。不过,从上一层传来的梯度会被应用到权重梯度dW的特定行(idx)。
class Embedding:
def __init__(self, W):
self.params = [W]
self.grads = [np.zeros_like(W)]
self.idx = None
def forward(self, idx):
W, = self.params
self.idx = idx
out = W[idx]
return out
def backward(self, dout):
dW, = self.grads
dW[...] = 0
for i, word_id in enumerate(self.idx):
dW[word_id] += dout[i]
# 或者
# np.add.at(dW, self.idx, dout)
return None
这里创建了和权重W相同大小的矩阵dW,并将梯度写入了dW对应的行。但是,我们最终想做的事情是更新权重W,所以没有必要特意创建dW(大小与W相同)。相反,只需把需要更新的行号(idx)及其对应的梯度(dout)保存下来,就可以更新权重(W)的特定行。但是,这里为了兼容已经实现的优化器类(Optimizer),所以写成了现在的样子。
通常情况下,NumPy的内置方法比Python的for循环处理更快。这是因为NumPy的内置方法在底层做了高速化和提高处理效率的优化。因此,上面的代码如果使用np.add.at()来实现,效率会比使用for循环处理高得多。
我们可以将word2vec (CBOW模型)的实现中的输入侧的MatMul层换成Embedding层。这样一来,既能减少内存使用量,又能避免不必要的计算。
输入层和输出层有100万个神经元。在以下两个地方需要很多计算时间。
我们将采用名为**负采样(negative sampling)**的方法作为解决方案。使用Negative Sampling替代Softmax,无论词汇量有多大,都可以使计算量保持较低或恒定。
用二分类拟合多分类(multiclass classification),这是理解负采样的重点。到目前为止,我们处理的都是多分类问题。拿刚才的例子来说,我们把它看作了从100万个单词中选择1个正确单词的任务。那么,可不可以将这个问题处理成二分类问题呢?更确切地说,我们是否可以用二分类问题来拟合这个多分类问题呢?
让神经网络来回答“当上下文是you和goodbye时,目标词是say吗?”这个问题,这时输出层只需要一个神经元即可。可以认为输出层的神经元输出的是say的得分。因此,要计算中间层和输出侧的权重矩阵的乘积,只需要提取say对应的列(单词向量),并用它与中间层的神经元计算内积即可。
通过sigmoid函数得到概率y后,可以由概率y计算损失。与多分类一样,用于sigmoid函数的损失函数也是交叉熵误差, L = − ( t l o g y + ( 1 − t ) l o g ( 1 − y ) ) L=-(tlog\space y+(1-t)log\space (1-y)) L=−(tlog y+(1−t)log (1−y))。
y是sigmoid函数的输出,t是正确解标签,取值为0或1:取值为1时表示正确解是“Yes”;取值为0时表示正确解是“No”。因此,当t为1时,输出-log y;当t为0时,输出-log (1-y)。sigmoid函数和交叉熵误差的组合产生了y-t这样一个漂亮的结果。同样地,Softmax函数和交叉熵误差的组合,或者恒等函数和均方误差的组合也会在反向传播时传播y-t。
将中间层的神经元记为h,并计算它与输出侧权重Wout中的单词say对应的单词向量的内积。中间层的神经元h流经Embedding Dot层,传给Sigmoid with Loss层。
class EmbeddingDot:
def__init__(self, W):
self.embed = Embedding(W)
self.params = self.embed.params
self.grads = self.embed.grads
self.cache = None
def forward(self, h, idx):
target_W = self.embed.forward(idx)
out = np.sum(target_W * h, axis=1)
self.cache = (h, target_W)
return out
def backward(self, dout):
h, target_W = self.cache
dout = dout.reshape(dout.shape[0], 1)
dtarget_W = dout * h
self.embed.backward(dtarget_W)
dh = dout * target_W
return dh
至此,我们成功地把要解决的问题从多分类问题转化成了二分类问题。但是,这样问题就被解决了吗?很遗憾,事实并非如此。因为我们目前仅学习了正例(正确答案),还不确定负例(错误答案)会有怎样的结果。
我们需要以所有的负例为对象进行学习吗?答案显然是“No”。如果以所有的负例为对象,词汇量将暴增至无法处理。为此,作为一种近似方法,我们将选择若干个(5个或者10个)负例(如何选择将在下文介绍)。也就是说,只使用少数负例。这就是负采样方法的含义。总而言之,负采样方法既可以求将正例作为目标词时的损失,同时也可以采样(选出)若干个负例,对这些负例求损失。然后,将这些数据(正例和采样出来的负例)的损失加起来,将其结果作为最终的损失。
正例(say)和之前一样,向Sigmoid with Loss层输入正确解标签1;而因为负例(hello和i)是错误答案,所以要向Sigmoid with Loss层输入正确解标签0。此后,将各个数据的损失相加,作为最终损失输出。
下面我们来看一下如何抽取负例。基于语料库中单词使用频率的采样方法会先计算语料库中各个单词的出现次数,并将其表示为“概率分布”,然后使用这个概率分布对单词进行采样。
选择稀有单词作为负例结果会很糟糕。因为在现实问题中,稀有单词基本上不会出现。也就是说,处理稀有单词的重要性较低。相反,处理好高频单词才能获得更好的结果。
np.random.choice()可以用于随机抽样。如果指定size参数,将执行多次采样。如果指定replace=False,将进行无放回采样。通过给参数p指定表示概率分布的列表,将进行基于概率分布的采样。
word2vec中提出的负采样对刚才的概率分布增加了一个步骤。对原来的概率分布取0.75次方。这是为了防止低频单词被忽略。更准确地说,通过取0.75次方,低频单词的概率将稍微变高。 P ′ ( w k ) = P ( w k ) 0.75 ∑ i = 1 n P ( w i ) 0.75 P^{'}(w_k)=\frac{P(w_k)^{0.75}}{\sum_{i=1}^{n}{P(w_i)^{0.75}}} P′(wk)=∑i=1nP(wi)0.75P(wk)0.75。0.75这个值并没有什么理论依据,也可以设置成0.75以外的值。
将单词和文档转化为固定长度的向量是非常重要的。因为如果可以将自然语言转化为向量,就可以使用常规的机器学习方法(神经网络、SVM等)。
单词的分布式表示的学习和机器学习系统的学习通常使用不同的数据集独立进行。比如,单词的分布式表示使用Wikipedia等通用语料库预先学习好,然后机器学习系统(SVM等)再使用针对当前问题收集到的数据进行学习。但是,如果当前我们面对的问题存在大量的学习数据,则也可以考虑从零开始同时进行单词的分布式表示和机器学习系统的学习。
单词的分布式表示的学习和分类系统的学习有时可能会分开进行。在这种情况下,如果要调查单词的分布式表示的维数如何影响最终的精度,首先需要进行单词的分布式表示的学习,然后再利用这个分布式表示进行另一个机器学习系统的学习。换句话说,在进行两个阶段的学习之后,才能进行评价。在这种情况下,由于需要调试出对两个系统都最优的超参数,所以非常费时。因此,单词的分布式表示的评价往往与实际应用分开进行。此时,经常使用的评价指标有“相似度”和“类推问题”。
单词相似度的评价通常使用人工创建的单词相似度评价集来评估。比如,cat和animal的相似度是8,cat和car的相似度是2……类似这样,用0~10的分数人工地对单词之间的相似度打分。然后,比较人给出的分数和word2vec给出的余弦相似度,考察它们之间的相关性。
类推问题的评价是指,基于诸如“king : queen=man : ?”这样的类推问题,根据正确率测量单词的分布式表示的优劣。
基于类推问题可以在一定程度上衡量“是否正确理解了单词含义或语法问题”。因此,在自然语言处理的应用中,能够高精度地解决类推问题的单词的分布式表示应该可以获得好的结果。但是,单词的分布式表示的优劣对目标应用贡献多少(或者有无贡献),取决于待处理问题的具体情况,比如应用的类型或语料库的内容等。也就是说,不能保证类推问题的评价高,目标应用的结果就一定好。这一点请一定注意。
到目前为止,我们看到的神经网络都是前馈型神经网络。前馈(feedforward)是指网络的传播方向是单向的。具体地说,先将输入信号传给下一层(隐藏层),接收到信号的层也同样传给下一层,然后再传给下一层……像这样,信号仅在一个方向上传播。虽然前馈网络结构简单、易于理解,但是可以应用于许多任务中。不过,这种网络存在一个大问题,就是不能很好地处理时间序列数据(以下简称为“时序数据”)。更确切地说,单纯的前馈网络无法充分学习时序数据的性质(模式)。于是,RNN(Recurrent Neural Network,循环神经网络)便应运而生。
语言模型(language model)给出了单词序列发生的概率。具体来说,就是使用概率来评估一个单词序列发生的可能性,即在多大程度上是自然的单词序列。比如,对于“you say goodbye”这一单词序列,语言模型给出高概率(比如0.092);对于“you say good die”这一单词序列,模型则给出低概率(比如0.000 000 000 0032)。
语言模型可以应用于多种应用,典型的例子有机器翻译和语音识别。比如,语音识别系统会根据人的发言生成多个句子作为候选。此时,使用语言模型,可以按照“作为句子是否自然”这一基准对候选句子进行排序。语言模型也可以用于生成新的句子。因为语言模型可以使用概率来评价单词序列的自然程度,所以它可以根据这一概率分布造出(采样)单词。
使用后验概率可以将联合概率P(w1,···, wm)分解成如下形式:
P ( w 1 , ⋅ ⋅ ⋅ , w m ) = ∐ t = 1 m P ( w t ∣ w 1 , . . , w t − 1 ) P(w_1,···,w_m)=\coprod_{t=1}^{m}P(w_t|w_1,..,w_{t-1}) P(w1,⋅⋅⋅,wm)=t=1∐mP(wt∣w1,..,wt−1)
为了简化, P ( w 1 ) = P ( w 1 ∣ w 0 ) P(w_1)=P(w_1|w_0) P(w1)=P(w1∣w0)。这个后验概率是以目标词左侧的全部单词为上下文(条件)时的概率。这里我们来总结一下,我们的目标是求P(wt|w1,···, wt-1)这个概率。如果能计算出这个概率,就能求得语言模型的联合概率P(w1,···,wm)。
由P(wt|w1,···, wt-1)表示的模型称为条件语言模型(conditional language model),有时也将其称为语言模型。
如果要把word2vec的CBOW模型(强行)用作语言模型,该怎么办呢?可以通过将上下文的大小限制在某个值来近似实现,用数学式可以如下表示:
P ( w 1 , ⋅ ⋅ ⋅ , w m ) ≈ ∐ t = 1 m P ( w t ∣ w t − 2 , w t − 1 ) P(w_1,···,w_m)\approx\coprod_{t=1}^{m}P(w_t|w_{t-2},w_{t-1}) P(w1,⋅⋅⋅,wm)≈∐t=1mP(wt∣wt−2,wt−1)
我们将上下文限定为左侧的2个单词。如此一来,就可以用CBOW模型(CBOW模型的后验概率)近似表示。
马尔可夫性是指未来的状态仅依存于当前状态。此外,当某个事件的概率仅取决于其前面的N个事件时,称为“N阶马尔可夫链”。这里展示的是下一个单词仅取决于前面2个单词的模型,因此可以称为“2阶马尔可夫链”。
不过,虽说可以设定为任意长度,但必须是某个“固定”长度。比如,即便是使用左侧10个单词作为上下文的CBOW模型,其上下文更左侧的单词的信息也会被忽略,而这会导致问题,因为有些句子需要更长的上下文。
的确,CBOW模型的上下文大小可以任意设定,很可能比较长的时候,意外情况就几乎不会出现,毕竟人类理解的上下文也不可能无限长。
但是CBOW模型还存在忽视了上下文中单词顺序的问题。比如,在上下文是2个单词的情况下,CBOW模型的中间层是那2个单词向量的和,如图5-5所示。如图5-5的左图所示,在CBOW模型的中间层求单词向量的和,因此上下文的单词顺序会被忽视。比如,(you, say)和(say, you)会被作为相同的内容进行处理。
我们想要的是考虑了上下文中单词顺序的模型。为此,可以在中间层“拼接”(concatenate)上下文的单词向量。实际上,“Neural Probabilistic Language Model”中提出的模型就采用了这个方法。但是,如果采用拼接的方法,权重参数的数量将与上下文大小成比例地增加。显然,这是我们不愿意看到的。那么,如何解决这里提出的问题呢?这就轮到RNN出场了。RNN具有一个机制,那就是无论上下文有多长,都能将上下文信息记住。因此,使用RNN可以处理任意长度的时序数据。
word2vec是以获取单词的分布式表示为目的的方法,因此一般不会用于语言模型。这里,为了引出RNN的魅力,我们拓展了话题,强行将word2vec的CBOW模型应用在了语言模型上。word2vec和基于RNN的语言模型是由托马斯·米科洛夫团队分别在2013年和2010年提出的。基于RNN的语言模型虽然也能获得单词的分布式表示,但是为了应对词汇量的增加、提高分布式表示的质量,word2vec被提了出来。
**RNN(Recurrent Neural Network)**中的Recurrent源自拉丁语,意思是“反复发生”,可以翻译为“重复发生”“周期性地发生”“循环”,因此RNN可以直译为“复发神经网络”或者“循环神经网络”。
Recurrent Neural Network通常译为“循环神经网络”。另外,还有一种被称为Recursive Neural Network(递归神经网络)的网络。这个网络主要用于处理树结构的数据,和循环神经网络不是一个东西。
“循环”是什么意思呢?是“反复并持续”的意思。从某个地点出发,经过一定时间又回到这个地点,然后重复进行,从过去一直“更新”到现在,这就是“循环”一词的含义。这里要注意的是,循环需要一个“环路”。
RNN的特征就在于拥有这样一个环路(或回路)。这个环路可以使数据不断循环。通过数据的循环,RNN一边记住过去的数据,一边更新到最新的数据。
时刻t的输入是xt,这暗示着时序数据(x0, x1,···, xt,···)会被输入到层中。然后,以与输入对应的形式,输出(h0, h1,···,ht,···)。各个时刻的RNN层接收传给该层的输入和前一个RNN层的输出,然后据此计算当前时刻的输出,此时进行的计算可以用下式表示: h t = t a n h ( h t − 1 W h + x t W x + b ) h_t=tanh(h_{t-1}W_h+x_tW_x+b) ht=tanh(ht−1Wh+xtWx+b)。 t a n h ( x ) = e x − e − x e x + e − x tanh(x)=\frac{e^x-e^{-x}}{e^x+e^{-x}} tanh(x)=ex+e−xex−e−x
RNN有两个权重,分别是将输入x转化为输出h的权重Wx和将前一个RNN层的输出转化为当前时刻的输出的权重Wh。此外,还有偏置b。这里,ht-1和xt都是行向量。输出ht称为隐藏状态(hidden state)或隐藏状态向量(hidden state vector)。
循环展开后的RNN可以使用(常规的)误差反向传播法,可以通过先进行正向传播,再进行反向传播的方式求目标梯度。因为这里的误差反向传播法是“按时间顺序展开的神经网络的误差反向传播法”,所以称为Backpropagation Through Time(基于时间的反向传播),简称BPTT。
通过该BPTT,RNN的学习似乎可以进行了,但是在这之前还有一个必须解决的问题,那就是学习长时序数据的问题。因为随着时序数据的时间跨度的增大,要基于BPTT求梯度,必须在内存中保存各个时刻的RNN层的中间数据。因此,随着时序数据变长,计算机的内存使用量(不仅仅是计算量)也会增加。另外,反向传播的梯度也会变得不稳定。
**Truncated BPTT(截断的BPTT)**中,网络连接被截断,但严格地讲,只是网络的反向传播的连接被截断,正向传播的连接依然被维持,这一点很重要。也就是说,正向传播的信息没有中断地传播。与此相对,反向传播则被截断为适当的长度,以被截出的网络为单位进行学习。
在处理长度为1000的时序数据时,如果展开RNN层,它将成为在水平方向上排列有1000个层的网络。当然,无论排列多少层,都可以根据误差反向传播法计算梯度。但是,如果序列太长,就会出现计算量或者内存使用量方面的问题。此外,随着层变长,梯度逐渐变小,梯度将无法向前一层传递。因此,我们来考虑在水平方向上以适当的长度截断网络的反向传播的连接。
我们截断了反向传播的连接,以使学习可以以10个RNN层为单位进行。像这样,只要将反向传播的连接截断,就不需要再考虑块范围以外的数据了,因此可以以各个块为单位(和其他块没有关联)完成误差反向传播法。
正向传播的连接不会。因此,在进行RNN的学习时,必须考虑到正向传播之间是有关联的,这意味着必须按顺序输入数据。下面,我们来说明什么是按顺序输入数据。我们之前看到的神经网络在进行mini-batch学习时,数据都是随机选择的。但是,在RNN执行Truncated BPTT时,数据需要按顺序输入。
现在,我们考虑使用Truncated BPTT来学习RNN。我们首先要做的是,将第1个块的输入数据(x0,…, x9)输入RNN层。先进行正向传播,再进行反向传播,这样可以得到所需的梯度。接着,对下一个块的输入数据(x10, x11,···, x19)执行误差反向传播法,和第1个块一样,先执行正向传播,再执行反向传播。这里的重点是,这个正向传播的计算需要前一个块最后的隐藏状态h9,这样可以维持正向传播的连接。用同样的方法,继续学习第3个块,此时要使用第2个块最后的隐藏状态h19。像这样,在RNN的学习中,通过将数据按顺序输入,从而继承隐藏状态进行学习。
到目前为止,我们在探讨Truncated BPTT时,并没有考虑mini-batch学习。换句话说,我们之前的探讨对应于批大小为1的情况。为了执行mini-batch学习,需要考虑批数据,在输入数据的开始位置,需要在各个批次中进行“偏移”。
为了说明“偏移”,我们仍对长度为1000的时序数据,以时间长度10为单位进行截断。此时,如何将批大小设为2进行学习呢?在这种情况下,作为RNN层的输入数据,第1笔样本数据从头开始按顺序输入,第2笔数据从第500个数据开始按顺序输入。也就是说,将开始位置平移500。
批次的第1个元素是x0,···, x9,批次的第2个元素是x500,···, x509,将这个mini-batch作为RNN的输入数据进行学习。因为要输入的数据是按顺序的,所以接下来是时序数据的第10~19个数据和第510~519个数据。像这样,在进行mini-batch学习时,平移各批次输入数据的开始位置,按顺序输入。此外,如果在按顺序输入数据的过程中遇到了结尾,则需要设法返回头部。如上所述,虽然Truncated BPTT的原理非常简单,但是关于数据的输入方法有几个需要注意的地方。具体而言,一是要按顺序输入数据,二是要平移各批次(各样本)输入数据的开始位置。
可以将(x0, x1,···, xT-1)捆绑为xs作为输入,将(h0, h1,···, hT-1)捆绑为hs作为输出。这里,我们将进行Time RNN层中的单步处理的层称为“RNN层”,将一次处理T步的层称为“Time RNN层”。
像Time RNN这样,将整体处理时序数据的层以单词“Time”开头命名。之后,我们还会实现Time Affine层、Time Embedding层等。
首先,实现进行RNN单步处理的RNN类;
我们将数据整理为mini-batch进行处理。因此,xt(和ht)在行方向上保存各样本数据。在矩阵计算中,矩阵的形状检查非常重要。这里,假设批大小是N,输入向量的维数是D,隐藏状态向量的维数是H。 h t = t a n h ( h t − 1 W h + x t W x + b ) h_t=tanh(h_{t-1}W_h+x_tW_x+b) ht=tanh(ht−1Wh+xtWx+b),h形状是NxH,x形状是NxD,Wh形状是HxH,Wx的形状是DxH。
def backward(self, dh_next):
Wx, Wh, b = self.params
x, h_prev, h_next = self.cache
dt = dh_next * (1 - h_next ** 2)
db = np.sum(dt, axis=0)
dWh = np.dot(h_prev.T, dt)
dh_prev = np.dot(dt, Wh.T)
dWx = np.dot(x.T, dt)
dx = np.dot(dt, Wx.T)
self.grads[0][...] = dWx
self.grads[1][...] = dWh
self.grads[2][...] = db
return dx, dh_prev
Time RNN层是T个RNN层连接起来的网络。我们将这个网络实现为Time RNN层。这里,RNN层的隐藏状态h保存在成员变量中,在进行隐藏状态的“继承”时会用到它。
成员变量layers在列表中保存多个RNN层,另一个成员变量,h保存调用forward()方法时的最后一个RNN层的隐藏状态。另外,在调用backward()时,dh保存传给前一个块的隐藏状态的梯度(关于dh,会在反向传播的实现中说明)。
stateful为True时,Time RNN层“有状态”。这里说的“有状态”是指维持Time RNN层的隐藏状态。也就是说,无论时序数据多长,Time RNN层的正向传播都可以不中断地进行。而当stateful为False时,每次调用Time RNN层的forward()时,第一个RNN层的隐藏状态都会被初始化为零矩阵。这是没有状态的模式,称为“无状态”。在处理长时序数据时,需要维持RNN的隐藏状态,这一功能通常用“stateful”一词表示。在许多深度学习框架中,RNN层都有stateful参数,该参数用于指定是否保存上一时刻的隐藏状态。
def forward(self, xs):
Wx, Wh, b = self.params
N, T, D = xs.shape
D, H = Wx.shape
self.layers = []
hs = np.empty((N, T, H), dtype='f')
if not self.stateful or self.h is None:
self.h = np.zeros((N, H), dtype='f')
for t in range(T):
layer = RNN(*self.params)
self.h = layer.forward(xs[:, t, :], self.h)
hs[:, t, :] = self.h
self.layers.append(layer)
return hs
正向传播的forward(xs)方法从下方获取输入xs,xs囊括了T个时序数据。因此,如果批大小是N,输入向量的维数是D,则xs的形状为(N,T,D)。在首次调用时(self.h为None时),RNN层的隐藏状态h由所有元素均为0的矩阵初始化。另外,在成员变量stateful为False的情况下,h将总是被重置为零矩阵。
在主体实现中,首先通过hs=np.empty((N, T, H), dtype=‘f’)为输出准备一个“容器”。接着,在T次for循环中,生成RNN层,并将其添加到成员变量layers中。然后,计算RNN层各个时刻的隐藏状态,并存放在hs的对应索引(时刻)中。
如果调用Time RNN层的forward()方法,则成员变量h中将存放最后一个RNN层的隐藏状态。在stateful为True的情况下,在下一次调用forward()方法时,刚才的成员变量h将被继续使用。而在stateful为False的情况下,成员变量h将被重置为零向量。
将从上游(输出侧的层)传来的梯度记为dhs,将流向下游的梯度记为dxs。因为这里我们进行的是Truncated BPTT,所以不需要流向这个块上一时刻的反向传播。不过,我们将流向上一时刻的隐藏状态的梯度存放在成员变量dh中。这是因为在探讨seq2seq(sequence-to-sequence,序列到序列)时会用到它。
从上方传来的梯度dht和从将来的层传来的梯度dhnext会传到第t个RNN层。这里需要注意的是,RNN层的正向传播的输出有两个分叉。在正向传播存在分叉的情况下,在反向传播时各梯度将被求和。因此,在反向传播时,流向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层的权重梯度之和。
我们将基于RNN的语言模型称为RNNLM(RNN Language Model,RNN语言模型)。
第1层是Embedding层,该层将单词ID转化为单词的分布式表示(单词向量)。然后,这个单词向量被输入到RNN层。RNN层向下一层(上方)输出隐藏状态,同时也向下一时刻的RNN层(右侧)输出隐藏状态。RNN层向上方输出的隐藏状态经过Affine层,传给Softmax层。
现在,我们仅考虑正向传播,向神经网络传入具体的数据,并观察输出结果。这里使用的句子还是“you say goodbye and i say hello.”
作为第1个单词,单词ID为0的you被输入。此时,查看Softmax层输出的概率分布,可知say的概率最高,这表明正确预测出了you后面出现的单词为say。当然,这样的正确预测只在有“好的”(学习顺利的)权重时才会发生。
接着,我们关注第2个单词say。此时,Softmax层的输出在goodbye处和hello处概率较高。确实,“you say goodby”和“you say hello”都是很自然的句子(顺便说一下,正确答案是goodbye)。这里需要注意的是, RNN层“记忆”了“you say”这一上下文。更准确地说,RNN将“you say”这一过去的信息保存为了简短的隐藏状态向量。RNN层的工作是将这个信息传送到上方的Affine层和下一时刻的RNN层。像这样,RNNLM可以“记忆”目前为止输入的单词,并以此为基础预测接下来会出现的单词。RNN层通过从过去到现在继承并传递数据,使得编码和存储过去的信息成为可能。
之前我们将整体处理时序数据的层实现为了Time RNN层,这里也同样使用Time Embedding层、Time Affine层等来实现整体处理时序数据的层。
Time层的实现很简单。比如,在Time Affine层的情况下,只需要准备T个Affine层分别处理各个时刻的数据即可。Time Embedding层也一样,在正向传播时准备T个Embedding层,由各个Embedding层处理各个时刻的数据。
需要注意的是,Time Affine层并不是单纯地使用T个Affine层,而是使用矩阵运算实现了高效的整体处理。
我们在Softmax中一并实现损失误差Cross Entropy Error层。这里实现Time Softmax with Loss层。x0、x1等数据表示从下方的层传来的得分(得分是正规化为概率之前的值),t0、t1等数据表示正确解标签。T个Softmax with Loss层各自算出损失,然后将它们加在一起取平均,将得到的值作为最终的损失。
Softmax with Loss层计算mini-batch的平均损失。具体而言,假设mini-batch有N笔数据,通过先求N笔数据的损失之和,再除以N,可以得到单笔数据的平均损失。这里也一样,通过取时序数据的平均,可以求得单笔数据的平均损失作为最终的输出。
SimpleRnnlm类是一个堆叠了4个Time层的神经网络。
RNN层和Affine层使用了“Xavier初始值”。在上一层的节点数是n的情况下,使用标准差为1/ n \sqrt{n} n的分布作为Xavier初始值,原始论文还考虑了下一层结点数。顺便说一下,标准差可以直观地解释为表示数据分散程度的指标。
在深度学习中,权重的初始值非常重要。同样,对RNN而言,权重的初始值也很重要。通过设置好的初始值,学习的进展和最终的精度都会有很大变化。此后都将使用Xavier初始值作为权重的初始值。另外,在语言模型的相关研究中,经常使用0.01 * np.random.uniform(…)这样的经过缩放的均匀分布。
语言模型基于给定的已经出现的单词(信息)输出将要出现的单词的概率分布。**困惑度(perplexity)**常被用作评价语言模型的预测性能的指标。困惑度表示“概率的倒数”(这个解释在数据量为1时严格一致)。为了说明概率的倒数,我们仍旧考虑“you say goodbye and i say hello.”这一语料库。假设在向语言模型“模型1”传入单词you时会输出概率分布。此时,下一个出现的单词是say的概率为0.8。取这个概率的倒数,可以计算出困惑度为1.25。
比如,“模型1”能准确地预测,困惑度是1.25;“模型2”的预测未能命中,困惑度是5.0。此例表明,困惑度越小越好。那么,如何直观地解释值1.25和5.0呢?它们可以解释为“分叉度”。所谓分叉度,是指下一个可以选择的选项的数量(下一个可能出现的单词的候选个数)。在刚才的例子中,好的预测模型的分叉度是1.25,这意味着下一个要出现的单词的候选个数可以控制在1个左右。而在差的模型中,下一个单词的候选个数有5个。
在输入数据为多个的情况下,结果会怎样呢?我们可以根据下面的式子进行计算。
L = − 1 / N ∑ n , k t n k l o g y n k 困惑度 = e L L=-1/N\sum_{n,k}t_{nk}log\space y_{nk}\\ 困惑度=e^L L=−1/Nn,k∑tnklog ynk困惑度=eL
这里,假设数据量为N个。tn是one-hot向量形式的正确解标签,tnk表示第n个数据的第k个值,ynk表示概率分布(神经网络中的Softmax的输出)。顺便说一下,L是神经网络的损失,使用这个L计算出的e^L就是困惑度。这看上去有些复杂,但是前面我们介绍的数据量为1时的“概率的倒数”“分叉度”“候选个数”等在这里也通用。也就是说,困惑度越小,分叉度越小,表明模型越好。
在信息论领域,困惑度也称为“平均分叉度”。这可以解释为,数据量为1时的分叉度是数据量为N时的分叉度的平均值。
使用model和optimizer初始化RnnlmTrainer类,然后调用fit(),完成学习。此时,RnnlmTrainer类的内部将执行上一节进行的一系列操作,具体如下所示。
卸下包袱,轻装上阵。——尼采
RNN存在环路,可以记忆过去的信息,其结构非常简单,易于实现。不过,遗憾的是,这个RNN的效果并不好。原因在于,许多情况下它都无法很好地学习到时序数据的长期依赖关系。简单RNN经常被名为LSTM或GRU的层所代替。实际上,当我们说RNN时,更多的是指LSTM层,我们会特指它们为“简单RNN”或“Elman”。
语言模型的任务是根据已经出现的单词预测下一个将要出现的单词。RNNLM中的梯度是如何传播的呢?这里我们使用BPTT进行学习,因此梯度将从正确解标签出现的地方向过去的方向传播。
在学习正确解标签时,重要的是RNN层的存在。RNN层通过向过去传递“有意义的梯度”,能够学习时间方向上的依赖关系。此时梯度(理论上)包含了那些应该学到的有意义的信息,通过将这些信息向过去传递,RNN层学习长期的依赖关系。但是,如果这个梯度在中途变弱(甚至没有包含任何信息),则权重参数将不会被更新。也就是说,RNN层无法学习长期的依赖关系。不幸的是,随着时间的回溯,这个简单RNN未能避免梯度变小(梯度消失)或者梯度变大(梯度爆炸)的命运。
现在,我们深挖一下RNN层中梯度消失(或者梯度爆炸)的起因。
这里考虑长度为T的时序数据,关注从第T个正确解标签传递出的梯度如何变化。此时,关注时间方向上的梯度,可知反向传播的梯度流经tanh、“+”和MatMul(矩阵乘积)运算。“+”的反向传播将上游传来的梯度原样传给下游,因此梯度的值不变。那么,剩下的tanh和MatMul运算会怎样变化呢?我们先来看一下tanh。
当y=tanh(x)时,它的导数是 d y d x = 1 − y 2 \frac{dy}{dx}=1-y^2 dxdy=1−y2。导数的值小于1.0,并且随着x远离0,它的值在变小。这意味着,当反向传播的梯度经过tanh节点时,它的值会越来越小。因此,如果经过tanh函数T次,则梯度也会减小T次。
RNN层的激活函数一般使用tanh函数,但是如果改为ReLU函数,则有希望抑制梯度消失的问题(当ReLU的输入为x时,它的输出是max(0, x))。这是因为,在ReLU的情况下,当x大于0时,反向传播将上游的梯度原样传递到下游,梯度不会“退化”。
接下来,我们MatMul(矩阵乘积)节点。简单起见,这里我们忽略tanh节点。如此一来,RNN层的反向传播的梯度就仅取决于MatMul运算。假定从上游传来梯度dh,此时MatMul节点的反向传播通过矩阵乘积dhWh^T计算梯度。之后,根据时序数据的时间步长,将这个计算重复相应次数。
梯度的大小或者呈指数级增加,或者呈指数级减小。为什么会出现这样的指数级变化呢?因为矩阵Wh被反复乘了T次。如果Wh是标量,则问题将很简单:当Wh大于1时,梯度呈指数级增加;当Wh小于1时,梯度呈指数级减小。那么,如果Wh不是标量,而是矩阵呢?此时,矩阵的奇异值将成为指标。简单而言,矩阵的奇异值表示数据的离散程度。根据这个奇异值(更准确地说是多个奇异值中的最大值)是否大于1,可以预测梯度大小的变化。如果奇异值的最大值大于1,则可以预测梯度很有可能会呈指数级增加;而如果奇异值的最大值小于1,则可以判断梯度会呈指数级减小。但是,并不是说奇异值比1大就一定会出现梯度爆炸。也就是说,这是必要条件,并非充分条件。
解决梯度爆炸有既定的方法,称为梯度裁剪(gradients clipping)。这是一个非常简单的方法, i f ∥ g ∥ ≥ t h r e s h o l d : g = t h r e s h o l d ∥ g ∥ g if\space \Vert g\Vert \ge threshold: g=\frac{threshold}{\Vert g\Vert} g if ∥g∥≥threshold:g=∥g∥thresholdg。
这里假设可以将神经网络用到的所有参数的梯度整合成一个,并用符号g表示。另外,将阈值设置为threshold。此时,如果梯度的L2范数大于或等于阈值,就按上述方法修正梯度,这就是梯度裁剪。虽然这个方法很简单,但是在许多情况下效果都不错。g整合了神经网络中用到的所有参数的梯度。比如,当某个模型有W1和W2两个参数时,g就是这两个参数对应的梯度dW1和dW2的组合即[dW1,dW2]。
在RNN的学习中,梯度消失也是一个大问题。为了解决这个问题,需要从根本上改变RNN层的结构。
**LSTM是Long Short-Term Memory(长短期记忆)**的缩写,意思是可以长(Long)时间维持短期记忆(Short-Term Memory)。
LSTM与RNN的接口的不同之处在于,LSTM还有路径c。这个c称为记忆单元(或者简称为“单元”),相当于LSTM专用的记忆部门。
记忆单元的特点是,仅在LSTM层内部接收和传递数据。也就是说,记忆单元在LSTM层内部结束工作,不向其他层输出。而LSTM的隐藏状态h和RNN层相同,会被(向上)输出到其他层。从接收LSTM的输出的一侧来看,LSTM的输出仅有隐藏状态向量h。记忆单元c对外部不可见,我们甚至不用考虑它的存在。
LSTM有记忆单元ct。这个ct存储了时刻t时LSTM的记忆,可以认为其中保存了从过去到时刻t的所有必要信息(或者以此为目的进行了学习)。然后,基于这个充满必要信息的记忆,向外部的层(和下一时刻的LSTM)输出隐藏状态ht。
当前的记忆单元ct是基于3个输入ct-1、ht-1和xt,经过“某种计算”(后述)算出来的。这里的重点是隐藏状态ht要使用更新后的ct来计算。另外,这个计算是ht=tanh(ct),表示对ct的各个元素应用tanh函数。
到目前为止,记忆单元ct和隐藏状态ht的关系只是按元素应用tanh函数。这意味着,记忆单元ct和隐藏状态ht的元素个数相同。如果记忆单元ct的元素个数是100,则隐藏状态ht的元素个数也是100。
Gate是“门”的意思,就像将门打开或合上一样,控制数据的流动。LSTM中使用的门并非只能“开或合”,还可以根据将门打开多少来控制水的流量。可以将“开合程度”控制在0.7(70%)或者0.2(20%)。sigmoid函数用于求门的开合程度(sigmoid函数的输出范围在0.0~1.0)。
针对tanh(ct)的各个元素,调整它们作为下一时刻的隐藏状态的重要程度。由于这个门管理下一个隐藏状态ht的输出,所以称为输出门(output gate)。
输出门的开合程度(流出比例)根据输入xt和上一个状态ht-1求出
o = σ ( x t W x o + h t − 1 W h o + b o ) o=\sigma (x_tW^o_x + h_{t-1}W_h^o + b^o ) o=σ(xtWxo+ht−1Who+bo)
这里在使用的权重参数和偏置的上标上添加了output的首字母o。之后,我们也将使用上标表示门。另外, sigmoid函数用σ()表示。将这个o和tanh(ct)的对应元素的乘积作为ht输出,这里说的“乘积”是对应元素的乘积,也称为阿达玛乘积。如果用⊙表示阿达玛乘积,则此处的计算如下所示: h t = o ⊙ t a n h ( c t ) h_t=o\odot tanh(c_t) ht=o⊙tanh(ct)。
现在,我们在记忆单元ct-1上添加一个忘记不必要记忆的门,这里称为遗忘门(forget gate)。遗忘门输出 f = σ ( x t W x f + h t − 1 W h f + b f ) f=\sigma (x_tW^f_x + h_{t-1}W_h^f + b^f ) f=σ(xtWxf+ht−1Whf+bf)。
ct由这个f和上一个记忆单元ct-1的对应元素的乘积求得(ct=f⊙ct-1)。
遗忘门从上一时刻的记忆单元中删除了应该忘记的东西,但是这样一来,记忆单元只会忘记信息。现在我们还想向这个记忆单元添加一些应当记住的新信息,为此我们添加新的tanh节点。基于tanh节点计算出的结果被加到上一时刻的记忆单元ct-1上。这样一来,新的信息就被添加到了记忆单元中。这个tanh节点的作用不是门,而是将新的信息添加到记忆单元中。因此,它不用sigmoid函数作为激活函数,而是使用tanh函数。tanh节点进行的计算如下所示: g = t a n h ( x t W x g + h t − 1 W h g + b g ) g=tanh (x_tW^g_x + h_{t-1}W_h^g + b^g ) g=tanh(xtWxg+ht−1Whg+bg)。这里用g表示向记忆单元添加的新信息。通过将这个g加到上一时刻的ct-1上,从而形成新的记忆。
最后,我们给g添加门,这里将这个新添加的门称为输入门(input gate)。
输入门判断新增信息g的各个元素的价值有多大。输入门不会不经考虑就添加新信息,而是会对要添加的信息进行取舍。换句话说,输入门会添加加权后的新信息。 i = σ ( x t W x i + h t − 1 W h i + b i ) i=\sigma (x_tW^i_x + h_{t-1}W_h^i + b^i ) i=σ(xtWxi+ht−1Whi+bi)。
然后,将i和g的对应元素的乘积添加到记忆单元中。
以上就是对LSTM内部处理的说明。LSTM有多个“变体”。这里说明的LSTM是最有代表性的LSTM,也有许多在门的连接方式上稍微不同的其他LSTM。
为什么它不会引起梯度消失呢?
记忆单元的反向传播仅流过“+”和“×”节点。“+”节点将上游传来的梯度原样流出,所以梯度没有变化(退化)。而“×”节点的计算并不是矩阵乘积,而是对应元素的乘积(阿达玛积)。这里的LSTM的反向传播进行的不是矩阵乘积计算,而是对应元素的乘积计算,而且每次都会基于不同的门值进行对应元素的乘积计算。这就是它不会发生梯度消失(或梯度爆炸)的原因。
“×”节点的计算由遗忘门控制(每次输出不同的门值)。遗忘门认为“应该忘记”的记忆单元的元素,其梯度会变小;而遗忘门认为“不能忘记”的元素,其梯度在向过去的方向流动时不会退化。因此,可以期待记忆单元的梯度(应该长期记住的信息)能在不发生梯度消失的情况下传播。从以上讨论可知,LSTM的记忆单元不会(难以)发生梯度消失。因此,可以期待记忆单元能够保存(学习)长期的依赖关系。
将进行单步处理的类实现为LSTM类,将整体处理T步的类实现为TimeLSTM类。现在我们先来整理一下LSTM中进行的计算,如下所示:
f = σ ( x t W x f + h t − 1 W h f + b f ) g = t a n h ( x t W x g + h t − 1 W h g + b g ) i = σ ( x t W x i + h t − 1 W h i + b i ) o = σ ( x t W x o + h t − 1 W h o + b o ) c t = f ⊙ c t − 1 + g ⊙ i h t = o ⊙ t a n h ( c t ) f=\sigma (x_tW^f_x + h_{t-1}W_h^f + b^f )\\ g=tanh (x_tW^g_x + h_{t-1}W_h^g + b^g )\\ i=\sigma (x_tW^i_x + h_{t-1}W_h^i + b^i)\\ o=\sigma (x_tW^o_x + h_{t-1}W_h^o+ b^o)\\ c_t=f\odot c_{t-1}+g\odot i\\ h_t=o\odot tanh(c_t) f=σ(xtWxf+ht−1Whf+bf)g=tanh(xtWxg+ht−1Whg+bg)i=σ(xtWxi+ht−1Whi+bi)o=σ(xtWxo+ht−1Who+bo)ct=f⊙ct−1+g⊙iht=o⊙tanh(ct)
4个式子分别进行仿射变换,但其实可以整合为通过1个式子进行:
$x_tW_x + h_{t-1}W_h + b ,N\times D 和 D\times 4H $
Time LSTM层是整体处理T个时序数据的层,由T个LSTM层构成。
在使用RNNLM创建高精度模型时,加深LSTM层(叠加多个LSTM层)的方法往往很有效。之前我们只用了一个LSTM层,通过叠加多个层,可以提高语言模型的精度。谷歌翻译中使用的GNMT模型是叠加了8层LSTM的网络。
通过叠加LSTM层,可以期待能够学习到时序数据的复杂依赖关系。换句话说,通过加深层,可以创建表现力更强的模型,但是这样的模型往往会发生过拟合(overfitting)。更糟糕的是,RNN比常规的前馈神经网络更容易发生过拟合,因此RNN的过拟合对策非常重要。
抑制过拟合已有既定的方法:一是增加训练数据;二是降低模型的复杂度。我们会优先考虑这两个方法。除此之外,对模型复杂度给予惩罚的正则化也很有效。比如,L2正则化会对过大的权重进行惩罚。
此外,像Dropout这样,在训练时随机忽略层的一部分(比如50%)神经元,也可以被视为一种正则化。
那么,在使用RNN的模型中,应该将Dropout层插入哪里呢?首先可以想到的是插入在LSTM层的时序方向上,不过这并不是一个好的插入方式。
如果在时序方向上插入Dropout,那么当模型学习时,随着时间的推移,信息会渐渐丢失。也就是说,因Dropout产生的噪声会随时间成比例地积累。考虑到噪声的积累,最好不要在时间轴方向上插入Dropout。因此,我们在深度方向(垂直方向)上插入Dropout层。
**“变分Dropout”(variational dropout)**被成功地应用在了时间方向上。它的机制是同一层的Dropout使用相同的mask。这里所说的mask是指决定是否传递数据的随机布尔值。通过同一层的Dropout共用mask,mask被“固定”。如此一来,信息的损失方式也被“固定”,所以可以避免常规Dropout发生的指数级信息损失。
改进语言模型有一个非常简单的技巧,那就是权重共享(weight tying)。weight tying可以直译为“权重绑定”。
绑定(共享)Embedding层和Affine层的权重的技巧在于权重共享。通过在这两个层之间共享权重,可以大大减少学习的参数数量。尽管如此,它仍能提高精度。假设词汇量为V,LSTM的隐藏状态的维数为H,则Embedding层的权重形状为V×H,Affine层的权重形状为H×V。此时,如果要使用权重共享,只需将Embedding层权重的转置设置为Affine层的权重。
我们稍微改动一下将要执行的学习代码。这个改动是,针对每个epoch使用验证数据评价困惑度,在值变差时,降低学习率(比如乘以1/4)。这是一种在实践中经常用到的技巧,并且往往能有好的结果。
**GRU(Gated Recurrent Unit,门控循环单元)**保留了LSTM使用门的理念,但是减少了参数,缩短了计算时间。现在,我们来看一下GRU的内部结构。
LSTM的重点是使用门,因此学习时梯度的流动平稳,梯度消失得以缓解。GRU也继承了这一想法。不过,LSTM和GRU存在几个差异,主要区别在于层的接口。
相对于LSTM使用隐藏状态和记忆单元两条线,GRU只使用隐藏状态。顺便说一下,这和“简单RNN”的接口相同。
LSTM的记忆单元是私有存储,对其他层不可见。LSTM将必要信息记录在记忆单元中,并基于记忆单元的信息计算隐藏状态。与此相对,GRU中不需要记忆单元这样的额外存储。
现在,我们看一下GRU内部进行的计算。
z = σ ( x t W x z + h t − 1 W h z + b z ) r = σ ( x t W x r + h t − 1 W h r + b r ) h ~ = t a n h ( x t W x + ( r ⊙ h t − 1 ) W h + b ) h t = ( 1 − z ) ⊙ h t − 1 + z ⊙ h ~ z=\sigma (x_tW^z_x + h_{t-1}W_h^z + b^z )\\ r=\sigma (x_tW^r_x + h_{t-1}W_h^r + b^r)\\ \tilde{h}=tanh (x_tW_x + (r\odot h_{t-1})W_h + b )\\ h_t=(1-z)\odot h_{t-1}+z\odot \tilde{h} z=σ(xtWxz+ht−1Whz+bz)r=σ(xtWxr+ht−1Whr+br)h~=tanh(xtWx+(r⊙ht−1)Wh+b)ht=(1−z)⊙ht−1+z⊙h~
GRU没有记忆单元,只有一个隐藏状态h在时间方向上传播。这里使用r和z共两个门(LSTM使用3个门),r称为reset门, z称为update门。**r(reset门)**决定在多大程度上“忽略”过去的隐藏状态。如果r是0,则新的隐藏状态仅取决于输入xt。也就是说,此时过去的隐藏状态将完全被忽略。
而update门是更新隐藏状态的门,它扮演了LSTM的forget门和input门两个角色。(1-z)⊙ht-1部分充当forget门的功能。根据这个计算,从过去的隐藏状态中删除应该被遗忘的信息。 z ⊙ h ~ z\odot \tilde{h} z⊙h~的部分充当input门的功能,对新增的信息进行加权。
综上,GRU是简化了LSTM的架构,与LSTM相比,可以减少计算成本和参数。那么,我们应该使用LSTM和GRU中的哪一个呢?根据不同的任务和超参数设置,结论可能不同。在最近的研究中,LSTM(以及LSTM的变体)被大量使用,而GRU的人气也在稳步上升。因为GRU的超参数少、计算量小,所以特别适合用于数据集较小、设计模型需要反复实验的场景。
不存在什么完美的文章,就好像没有完美的绝望。——村上春树《且听风吟》
语言模型根据已经出现的单词输出下一个出现的单词的概率分布。它如何生成下一个新单词呢?一种可能的方法是选择概率最高的单词。在这种情况下,因为选择的是概率最高的单词,所以结果能唯一确定。也就是说,这是一种“确定性的”方法。另一种方法是**“概率性地”进行选择**。根据概率分布进行选择,这样概率高的单词容易被选到,概率低的单词难以被选到。在这种情况下,被选到的单词(被采样到的单词)每次都不一样。
RNNLM语言模型给出的回答是“人生的意义并不是一种状态良好的绘画”。虽然搞不懂什么才是“状态良好的绘画”,不过说不定这其中有什么深刻的意义。
seq2seq模型也称为Encoder-Decoder模型。顾名思义,这个模型有两个模块——Encoder(编码器)和Decoder(解码器)。编码器对输入数据进行编码,解码器对被编码的数据进行解码。
编码器利用RNN将时序数据转换为隐藏状态h。这里的RNN使用的是LSTM,不过也可以使用“简单RNN”或者GRU等。另外,这里考虑的是将句子分割为单词进行输入的情况。编码器输出的向量h是LSTM层的最后一个隐藏状态,其中编码了翻译输入文本所需的信息。这里的重点是,LSTM的隐藏状态h是一个固定长度的向量。说到底,编码就是将任意长度的文本转换为一个固定长度的向量。
解码器是如何“处理”这个编码好的向量,从而生成目标文本的呢?我们只需要直接使用进行文本生成的模型即可。
解码器的结构和上一节的神经网络完全相同。不过它和上一节的模型存在一点差异,就是LSTM层会接收向量h。在上一节的语言模型中,LSTM层不接收任何信息(硬要说的话,也可以说LSTM的隐藏状态接收“0向量”)。这个唯一的、微小的改变使得普通的语言模型进化为可以驾驭翻译的解码器。
使用这一分隔符(特殊符号)。这个分隔符被用作通知解码器开始生成文本的信号。另外,解码器采样到出现为止,所以它也是结束信号。也就是说,分隔符可以用来指示解码器的“开始/结束”。在其他文献中,也有使用、或者“_”(下划线)作为分隔符的例子。
seq2seq由两个LSTM层构成,即编码器的LSTM和解码器的LSTM。此时,LSTM层的隐藏状态是编码器和解码器的“桥梁”。在正向传播时,编码器的编码信息通过LSTM层的隐藏状态传递给解码器;在反向传播时,解码器的梯度通过这个“桥梁”传递给编码器。
我们将“加法”视为一个时序转换问题。具体来说,在seq2seq学习后,如果输入字符串“57+5”,seq2seq要能正确回答“62”。顺便说一下,这种为了评价机器学习而创建的简单问题,称为“toy problem”。“57+5”这样的输入会被处理为[‘5’, ‘7’, ‘+’, ‘5’]这样的列表。
需要注意的是,不同的加法问题(“57+5”或者“628+521”等)及其回答(“62”或者“1149”等)的字符数是不同的。比如,“57+5”共有4个字符,而“628+521”共有7个字符。如此,在加法问题中,每个样本在时间方向上的大小不同。也就是说,加法问题处理的是可变长度的时序数据。因此,在神经网络的学习中,在进行mini-batch处理时,需要想一些应对办法。
在基于mini-batch学习可变长度的时序数据时,最简单的方法是使用填充(padding)。所谓填充,就是用无效(无意义)数据填入原始数据,从而使数据长度对齐。就上面这个加法的例子来说,在多余位置插入无效字符(这里是空白字符),从而使所有输入数据的长度对齐。
像这样,通过填充对齐数据的大小,可以处理可变长度的时序数据。但是,因为使用了填充,seq2seq需要处理原本不存在的填充用字符,所以如果追求严谨,使用填充时需要向seq2seq添加一些填充专用的处理。比如,在解码器中输入填充时,不应计算其损失(这可以通过向Softmax with Loss层添加mask功能来解决)。再比如,在编码器中输入填充时,LSTM层应按原样输出上一时刻的输入。这样一来,LSTM层就可以像不存在填充一样对输入数据进行编码。
Encoder类由Embedding层和LSTM层组成。Embedding层将字符(字符ID)转化为字符向量,然后将字符向量输入LSTM层。
LSTM层向右(时间方向)输出隐藏状态和记忆单元,向上输出隐藏状态。这里,因为上方不存在层,所以丢弃LSTM层向上的输出。在编码器处理完最后一个字符后,输出LSTM层的隐藏状态h。然后,这个隐藏状态h被传递给解码器。
编码器只将LSTM的隐藏状态传递给解码器。尽管也可以把LSTM的记忆单元传递给解码器,但我们通常不太会把LSTM的记忆单元传递给其他层。这是因为,LSTM的记忆单元被设计为只给自身使用。
顺便说一下,我们已经将时间方向上进行整体处理的层实现为了Time LSTM层和Time Embedding层。
Decoder类由Time Embedding、Time LSTM和Time Affine这3个层构成。由于某些问题可以使用确定选择(比如做加法),可以忽略Softmax,Softmax with Loss层交给此后实现的Seq2seq类处理。
Seq2seq类的实现。话虽如此,这里需要做的只是将Encoder类和Decoder类连接在一起,然后使用Time Softmax with Loss层计算损失而已。
第一个改进方案是非常简单的技巧,反转输入数据的顺序。
为什么反转数据后,学习进展变快,精度提高了呢?虽然理论上不是很清楚,但是直观上可以认为,反转数据后梯度的传播可以更平滑。比如,考虑将“吾輩 は 猫 で ある”翻译成“I am a cat”这一问题,单词“吾輩”和单词“I”之间有转换关系。此时,从“吾輩”到“I”的路程必须经过“は”“猫”“で”“ある”这4个单词的LSTM层。因此,在反向传播时,梯度从“I”抵达“吾輩”,也要受到这个距离的影响。
那么,如果反转输入语句,也就是变为“ある で 猫 は 吾輩”,结果会怎样呢?此时,“吾輩”和“I”彼此相邻,梯度可以直接传递。如此,因为通过反转,输入语句的开始部分和对应的转换后的单词之间的距离变近(这样的情况变多),所以梯度的传播变得更容易,学习效率也更高。不过,在反转输入数据后,单词之间的“平均”距离并不会发生改变。
编码器将输入语句转换为固定长度的向量h,这个h集中了解码器所需的全部信息。也就是说,它是解码器唯一的信息源。但是,当前的seq2seq只有最开始时刻的LSTM层利用了h。我们能更加充分地利用这个h吗?
为了达成该目标,seq2seq的第二个改进方案就应运而生了。具体来说,就是将这个集中了重要信息的编码器的输出h分配给解码器的其他层。
将编码器的输出h分配给所有时刻的Affine层和LSTM层。之前LSTM层专用的重要信息h现在在多个层(在这个例子中有8个层)中共享了。重要的信息不是一个人专有,而是多人共享,这样我们或许可以做出更加正确的判断。
这里的改进是将编码好的信息分配给解码器的其他层,这可以解释为其他层也能“偷窥”到编码信息。因为“偷窥”的英语是peek,所以将这个改进了的解码器称为Peeky Decoder。同理,将使用了Peeky Decoder的seq2seq称为Peeky seq2seq。
有两个向量同时被输入到了LSTM层和Affine层,这实际上表示两个向量的拼接(concatenate)。
加上了Peeky的seq2seq的结果大幅变好。刚过10个epoch时,正确率已经超过90%,最终的正确率接近100%。
这里的实验有几个需要注意的地方。因为使用Peeky后,网络的权重参数会额外地增加,计算量也会增加,所以这里的实验结果必须考虑到相应地增加的“负担”。另外,seq2seq的精度会随着超参数的调整而大幅变化。虽然这里的结果是可靠的,但是在实际问题中,它的效果可能不稳定。
seq2seq将某个时序数据转换为另一个时序数据,这个转换时序数据的框架可以应用在各种各样的任务中。
仅通过用CNN替代LSTM,seq2seq就可以处理图像了。
注意力是全部。——Vaswani们的论文标题
seq2seq中使用编码器对时序数据进行编码,然后将编码信息传递给解码器。此时,编码器的输出是固定长度的向量。实际上,这个“固定长度”存在很大问题。因为固定长度的向量意味着,无论输入语句的长度如何(无论多长),都会被转换为长度相同的向量。这样做早晚会遇到瓶颈,有用的信息会从向量中溢出。
使用各个时刻(各个单词)的隐藏状态向量,可以获得和输入的单词数相同数量的向量。输入了5个单词,此时编码器输出5个向量。这样一来,编码器就摆脱了“一个固定长度的向量”的制约。
在许多深度学习框架中,在初始化RNN层(或者LSTM层、GRU层等)时,可以选择是返回“全部时刻的隐藏状态向量”,还是返回“最后时刻的隐藏状态向量”。比如,在Keras中,在初始化RNN层时,可以设置return_sequences为True或者False。
各个时刻的隐藏状态中包含了大量当前时刻的输入单词的信息。输入“猫”时的LSTM层的输出(隐藏状态)受此时输入的单词“猫”的影响最大。因此,可以认为这个隐藏状态向量蕴含许多“猫的成分”。按照这样的理解,编码器输出的hs矩阵就可以视为各个单词对应的向量集合。
因为编码器是从左向右处理的,所以严格来说,刚才的“猫”向量中含有“吾輩”“は”“猫”这3个单词的信息。考虑整体的平衡性,最好均衡地含有单词“猫”周围的信息,从两个方向处理时序数据的双向RNN(或者双向LSTM)比较有效。我们后面再介绍双向RNN,这里先继续使用单向LSTM。
编码器整体输出各个单词对应的LSTM层的隐藏状态向量hs。然后,这个hs被传递给解码器,以进行时间序列的转换。
我们在进行翻译时,大脑做了什么呢?比如,在将“吾輩は猫である”这句话翻译为英文时,肯定要用到诸如“吾輩=I”“猫=cat”这样的知识。也就是说,可以认为我们是专注于某个单词(或者单词集合),随时对这个单词进行转换的。那么,我们可以在seq2seq中重现同样的事情吗?确切地说,我们可以让seq2seq学习“输入和输出中哪些单词与哪些单词有关”这样的对应关系吗?
在机器翻译的历史中,很多研究都利用“猫=cat”这样的单词对应关系的知识。这样的表示单词(或者词组)对应关系的信息称为对齐(alignment)。到目前为止,对齐主要是手工完成的,而我们将要介绍的Attention技术则成功地将对齐思想自动引入到了seq2seq中。这也是从“手工操作”到“机械自动化”的演变。
从现在开始,我们的目标是找出与“翻译目标词”有对应关系的“翻译源词”的信息,然后利用这个信息进行翻译。也就是说,我们的目标是仅关注必要的信息,并根据该信息进行时序转换。这个机制称为Attention。
我们新增一个进行“某种计算”的层。**这个“某种计算”接收(解码器)各个时刻的LSTM层的隐藏状态和编码器的hs。然后,从中选出必要的信息,**并输出到Affine层。与之前一样,编码器的最后的隐藏状态向量传递给解码器最初的LSTM层。
网络所做的工作是提取单词对齐信息。具体来说,就是从hs中选出与各个时刻解码器输出的单词有对应关系的单词向量。比如,当解码器输出“I”时,从hs中选出“吾輩”的对应向量。也就是说,我们希望通过“某种计算”来实现这种选择操作。不过这里有个问题,就是选择(从多个事物中选取若干个)这一操作是无法进行微分的。
可否将“选择”这一操作换成可微分的运算呢?实际上,解决这个问题的思路很简单(但是,第一个想到是很难的)。这个思路就是,与其“单选”,不如“全选”。我们另行计算表示各个单词重要度(贡献值)的权重。
a表示各个单词重要度的权重。此时,a像概率分布一样,各元素是0.0~1.0的标量,总和是1。然后,计算这个表示各个单词重要度的权重和单词向量hs的加权和,可以获得目标向量。
计算单词向量的加权和,这里将结果称为上下文向量,并用符号c表示。顺便说一下,如果我们仔细观察,就可以发现“吾輩”对应的权重为0.8。这意味着上下文向量c中含有很多“吾輩”向量的成分,可以说这个加权和计算基本代替了“选择”向量的操作。假设“吾輩”对应的权重是1,其他单词对应的权重是0,那么这就相当于“选择”了“吾輩”向量。
有了表示各个单词重要度的权重a,就可以通过加权和获得上下文向量。那么,怎么求这个a呢?当然不需要我们手动指定,我们只需要做好让模型从数据中自动学习它的准备工作。
用h表示解码器的LSTM层的隐藏状态向量。此时,我们的目标是用数值表示这个h在多大程度上和hs的各个单词向量“相似”。有几种方法可以做到这一点,这里我们使用最简单的向量内积。除了内积之外,还有使用小型的神经网络输出得分的做法。
这里通过向量内积算出h和hs的各个单词向量之间的相似度,并将其结果表示为s。不过,这个s是正规化之前的值,也称为得分。接下来,使用老一套的Softmax函数对s进行正规化。
Attention Weight层关注编码器输出的各个单词向量hs,并计算各个单词的权重a;然后,Weight Sum层计算a和hs的加权和,并输出上下文向量c。我们将进行这一系列计算的层称为Attention层。我们将这个Attention层放在LSTM层和Affine层的中间。
编码器的输出hs被输入到各个时刻的Attention层。另外,这里将LSTM层的隐藏状态向量输入Affine层。
上下文向量和隐藏状态向量这两个向量被输入Affine层。如前所述,这意味着将这两个向量拼接起来,将拼接后的向量输入Affine层。
编码器:Embedding,LSTM,Attention,Affine,Softmax。
将在时序方向上扩展的多个Attention层整体实现为Time Attention层。
Time Attention层中的成员变量attention_weights保存了各个时刻的Attention权重,据此可以将输入语句和输出语句的各个单词的对应关系绘制成一张二维地图。
我们没有办法理解神经网络内部进行了什么工作(基于何种逻辑工作),而Attention赋予了模型“人类可以理解的结构和意义”。在上面的例子中,通过Attention,我们看到了单词和单词之间的关联性。由此,我们可以判断模型的工作逻辑是否符合人类的逻辑。
双向LSTM在之前的LSTM层上添加了一个反方向处理的LSTM层。然后,拼接各个时刻的两个LSTM层的隐藏状态,将其作为最后的隐藏状态向量(除了拼接之外,也可以“求和”或者“取平均”等)。通过这样的双向处理,各个单词对应的隐藏状态向量可以从左右两个方向聚集信息。这样一来,这些向量就编码了更均衡的信息。
双向LSTM的实现非常简单。一种实现方式是准备两个LSTM层(或者Time LSTM层),并调整输入各个层的单词的排列。具体而言,其中一个层的输入语句与之前相同,这相当于从左向右处理输入语句的常规的LSTM层。而另一个LSTM层的输入语句则按照从右到左的顺序输入。如果原文是“A B C D”,就改为“D C B A”。通过输入改变了顺序的输入语句,另一个LSTM层从右向左处理输入语句。之后,只需要拼接这两个LSTM层的输出,就可以创建双向LSTM层。
我们将Attention层插入了LSTM层和Affine层之间,不过使用Attention层的方式并不一定非得那样。实际上,使用Attention的模型还有其他好几种方式。
Attention层的输出(上下文向量)可以被连接到下一时刻的LSTM层的输入处。通过这种结构,LSTM层得以使用上下文向量的信息。相对地,我们实现的模型则是Affine层使用了上下文向量。
那么,Attention层的位置的不同对最终精度有何影响呢?答案要试一下才知道。实际上,这只能使用真实数据来验证。不过,在上面的两个模型中,上下文向量都得到了很好的应用。因此,在这两个模型之间,我们可能看不到太大的精度差异。从实现的角度来看,前者的结构(在LSTM层和Affine层之间插入Attention层)更加简单。这是因为在前者的结构中,解码器中的数据是从下往上单向流动的,所以Attention层的模块化会更加简单。实际上,我们轻松地将其模块化为了Time Attention层。
在诸如翻译这样的实际应用中,需要解决的问题更加复杂。在这种情况下,我们希望带Attention的seq2seq具有更强的表现力。此时,首先可以考虑到的是加深RNN层(LSTM层)。通过加深层,可以创建表现力更强的模型,带Attention的seq2seq也是如此。那么,如果我们加深带Attention的seq2seq,结果会怎样呢?
可以增加LSTM层,编码器和解码器中通常使用层数相同的LSTM层。
也可以使用多个Attention层,或者将Attention的输出输入给下一个时刻的LSTM层等。
在加深层时,避免泛化性能的下降非常重要。此时,Dropout、权重共享等技术可以发挥作用。另外,在加深层时使用到的另一个重要技巧是残差连接(skip connection,也称为residual connection或shortcut),这是一种“跨层连接”的简单技巧。
所谓残差连接,就是指“跨层连接”。在残差连接的连接处,有两个输出被相加。请注意这个加法(确切地说,是对应元素的加法)非常重要。**因为加法在反向传播时“按原样”传播梯度,所以残差连接中的梯度可以不受任何影响地传播到前一个层。**这样一来,即便加深了层,梯度也能正常传播,而不会发生梯度消失(或者梯度爆炸),学习可以顺利进行。
o u t = x + F ( x ) out=x+ F(x) out=x+F(x)
在时间方向上,RNN层的反向传播会出现梯度消失或梯度爆炸的问题。梯度消失可以通过LSTM、GRU等Gated RNN应对,梯度爆炸可以通过梯度裁剪应对。而对于深度方向上的梯度消失,这里介绍的残差连接很有效。
回看机器翻译的历史,我们可以发现主流方法随着时代的变迁而演变。具体来说,就是从“基于规则的翻译”到“基于用例的翻译”,再到“基于统计的翻译”。现在,神经机器翻译(Neural Machine Translation)取代了这些过往的技术,获得了广泛关注。神经机器翻译这个术语是出于与之前的基于统计的翻译进行对比而使用的,现在已经成为使用了seq2seq的机器翻译的统称。
GNMT和之前说的带Attention的seq2seq一样,由编码器、解码器和Attention构成。不过为了提高翻译精度而做的改进,增加了比如LSTM层的多层化、双向LSTM(仅编码器的第1层)和skip connection等。另外,为了提高学习速度,还进行了多个GPU上的分布式学习。除了上述在架构上下的功夫之外,GNMT还进行了低频词处理、用于加速推理的量化(quantization)等工作。利用这些技巧,GNMT获得了非常好的结果。
RNN需要基于上一个时刻的计算结果逐步进行计算,因此**(基本)不可能在时间方向上并行计算RNN**。在使用了GPU的并行计算环境下进行深度学习时,这一点会成为很大的瓶颈,于是我们就有了避开RNN的动机。
除了Transformer之外,还有多个研究致力于去除RNN,比如用卷积层(Convolution层)代替RNN的研究,基本上就是用卷积层代替RNN来构成seq2seq,并据此实现并行计算。
Transformer是基于Attention构成的,其中使用了Self-Attention技巧,这一点很重要。Self-Attention直译为“自己对自己的Attention”,也就是说,这是以一个时序数据为对象的Attention,旨在观察一个时序数据中每个元素与其他元素的关系。
Time Attention层的两个输入中输入的是不同的时序数据。与之相对,Self-Attention的两个输入中输入的是同一个时序数据。像这样,可以求得一个时序数据内各个元素之间的对应关系。
Transformer中用Attention代替了RNN。实际上,编码器和解码器两者都使用了Self-Attention。Feed Forward层表示前馈神经网络(在时间方向上独立的网络)。具体而言,使用具有一个隐藏层、激活函数为ReLU的全连接的神经网络。除了这个架构外,Skip Connection、Layer Normalization等技巧也会被用到。其他常见的技巧还有,(并行)使用多个Attention、编码时序数据的位置信息(Positional Encoding,位置编码)等。
使用Transformer可以控制计算量,充分利用GPU并行计算带来的好处。其结果是,与GNMT相比,Transformer的学习时间得以大幅减少。在翻译精度方面,也实现了精度提升。
我们在解决复杂问题时,经常使用纸和笔。从另一个角度来看,这可以解释为基于纸和笔这样的“外部存储装置”,我们的能力获得了延伸。同样地,利用外部存储装置,神经网络也可以获得额外的能力。我们讨论的主题就是“基于外部存储装置的扩展”。
RNN和LSTM能够使用内部状态来存储时序数据,但是它们的内部状态长度固定,能塞入其中的信息量有限。因此,可以考虑在RNN的外部配置存储装置(内存),适当地记录必要信息。
在带Attention的seq2seq中,编码器对输入语句进行编码。然后,解码器通过Attention使用被编码的信息。基于Attention,编码器和解码器实现了计算机中的“内存操作”。换句话说,这可以解释为,编码器将必要的信息写入内存,解码器从内存中读取必要的信息。
可见计算机的内存操作可以通过神经网络复现。我们可以立刻想到一个方法:在RNN的外部配置一个存储信息的存储装置,并使用Attention向这个存储装置读写必要的信息。实际上,这样的研究有好几个,**NTM (Neural Turing Machine,神经图灵机)**就是其中比较有名的一个。
NTM是DeepMind团队进行的一项研究,后来被改进成名为**DNC (Differentiable Neural Computers,可微分神经计算机)**的方法。DNC可以认为是强化了内存操作的NTM,但它们的核心技术是一样的。
“控制器”是处理信息的模块,我们假定它使用神经网络(或RNN)。数据 “0” 和 “1”一个接一个地流入这个控制器,控制器对其进行处理并输出新的数据。
在这个控制器的外侧有一张“大纸”(内存)。基于这个内存,控制器获得了计算机(图灵机)的能力。具体来说,这个能力是指,在这张“大纸”上写入必要的信息、擦除不必要的信息,以及读取必要信息的能力。顺便说一下,因为“大纸”是卷式的,所以各个节点可以在需要的地方读写数据。换句话说,就是可以移动到目标地点。像这样,NTM在读写外部存储装置的同时处理时序数据。NTM的有趣之处在于使用“可微分”的计算构建了这些内存操作。因此,它可以从数据中学习内存操作的顺序。
计算机根据人编写的程序进行动作。与此相对,NTM从数据中学习程序。也就是说,这意味着它可以从“算法的输入和输出”中学习“算法自身”(逻辑)。
简化版的NTM的层结构中,LSTM层是控制器,执行NTM的主要处理。Write Head层接收LSTM层各个时刻的隐藏状态,将必要的信息写入内存。Read Head层从内存中读取重要信息,并传递给下一个时刻的LSTM层。那么,Write Head层和Read Head层使用Attention进行内存操作呢。
重申一下,在读取(或者写入)内存中某个地址上的数据时,我们需要先“选择”数据。这个选择操作自身是不能微分的,因此先使用Attention选择所有地址上的数据,再利用权重表示每个数据的贡献,这样就能够利用可微分的计算替代选择这个操作。
为了模仿计算机的内存操作,NTM的内存操作使用了两个Attention,分别是“基于内容的Attention”和“基于位置的Attention”。基于内容的Attention和我们之前介绍的Attention一样,用于从内存中找到某个向量(查询向量)的相似向量。
而基于位置的Attention用于从上一个时刻关注的内存地址(内存的各个位置的权重)前后移动。这里我们省略对其技术细节的探讨,具体可以通过一维卷积运算实现。基于内存位置的移动功能,可以再现“一边前进(一个内存地址)一边读取”这种计算机特有的活动。
NTM的内存操作比较复杂。除了上面说到的操作以外,还包括锐化Attention权重的处理、加上上一个时刻的Attention权重的处理等。通过自由地使用外部存储装置,NTM获得了强大的能力。实际上,对于seq2seq无法解决的复杂问题,NTM取得了惊人的成绩。具体而言, NTM成功解决了长时序的记忆问题、排序问题(从大到小排列数字)等。如此,NTM借助外部存储装置获得了学习算法的能力,其中Attention作为一项重要技术而得到了应用。基于外部存储装置的扩展技术和Attention会越来越重要,今后将被应用在各种地方。