0. 前言
前一章中介绍了循环神经网络的基础知识,这种网络可以更好地处理序列数据。但对于当今各种各样的序列学习问题,这些技术可能并不够用。
例如,循环神经网络在实践中一个常见问题是数值不稳定性。尽管我们已经应用了梯度裁剪等技巧来缓解这个问题,但是仍需要通过设计更复杂的序列模型可以进一步处理它。比如两个广泛使用的网络:门控循环单元(gated recurrent units,GRU)和长短期记忆网络(long short-term memory,LSTM)。然后本章将基于一个单向隐藏层来扩展循环神经网络架构,描述具有多个隐藏层的深层架构,并讨论基于前向和后向循环计算的双向设计。现代循环网络经常采用这种扩展。在解释这些循环神经网络的变体时将继续利用上一章中的语言建模问题。
事实上,语言建模只揭示了序列学习能力的冰山一角。在各种序列学习问题中,如自动语音识别、文本到语音转换和机器翻译,输入和输出都是任意长度的序列。为了阐述如何拟合这种类型的数据,我们将以机器翻译为例介绍基于循环神经网络的“编码器-解码器”架构和束搜索,并用它们来生成序列。
0.1. 小结
- 门控循环神经网络可以更好地捕获时间步距离很长的序列上的依赖关系。
- 重置门有助于捕获序列中的短期依赖关系。
- 更新门有助于捕获序列中的长期依赖关系。
- 重置门打开时,门控循环单元包含基本循环神经网络;更新门打开时,门控循环单元可以跳过子序列。
- 长短期记忆网络有三种类型的门:输入门、遗忘门和输出门。
- 长短期记忆网络的隐藏层输出包括“隐状态”和“记忆元”。只有隐状态会传递到输出层,而记忆元完全属于内部信息。
- 长短期记忆网络可以缓解梯度消失和梯度爆炸。
- 在深度循环神经网络中,隐状态的信息被传递到当前层的下一时间步和下一层的当前时间步。
- 有许多不同风格的深度循环神经网络, 如长短期记忆网络、门控循环单元、或经典循环神经网络。 这些模型在深度学习框架的高级API中都有涵盖。
- 总体而言,深度循环神经网络需要大量的调参(如学习率和修剪) 来确保合适的收敛,模型的初始化也需要谨慎。
- 在双向循环神经网络中,每个时间步的隐状态由当前时间步的前后数据同时决定。
- 双向循环神经网络与概率图模型中的“前向-后向”算法具有相似性。
- 双向循环神经网络主要用于序列编码和给定双向上下文的观测估计。
- 由于梯度链更长,因此双向循环神经网络的训练代价非常高
- 机器翻译指的是将文本序列从一种语言自动翻译成另一种语言。
- 使用单词级词元化时的词表大小,将明显大于使用字符级词元化时的词表大小。为了缓解这一问题,我们可以将低频词元视为相同的未知词元。
- 通过截断和填充文本序列,可以保证所有的文本序列都具有相同的长度,以便以小批量的方式加载。
- “编码器-解码器”架构可以将长度可变的序列作为输入和输出,因此适用于机器翻译等序列转换问题。
- 编码器将长度可变的序列作为输入,并将其转换为具有固定形状的编码状态。
- 解码器将具有固定形状的编码状态映射为长度可变的序列。
- 根据“编码器-解码器”架构的设计, 我们可以使用两个循环神经网络来设计一个序列到序列学习的模型。
- 在实现编码器和解码器时,我们可以使用多层循环神经网络。
- 可以使用屏蔽(mask)来过滤不相关的计算,例如在计算损失时。
- 在“编码器-解码器”训练中,强制教学方法将原始输出序列(而非预测结果)输入解码器。
- BLEU是一种常用的评估方法,它通过测量预测序列和标签序列之间的元语法的匹配度来评估预测。
- 序列搜索策略包括贪心搜索、穷举搜索和束搜索。
- 贪心搜索所选取序列的计算量最小,但精度相对较低。
- 穷举搜索所选取序列的精度最高,但计算量最大。
- 束搜索通过灵活选择束宽,在正确率和计算代价之间进行权衡。
1. 门控循环单元(GRU)
上一章讨论了如何在循环神经网络中计算梯度,以及矩阵连续乘积可以导致梯度消失或梯度爆炸的问题。下面简单思考一下这种梯度异常在实践中的意义:
- 可能会遇到这样的情况:早期观测值对预测所有未来观测值具有非常重要的意义。例如一个极端情况,其中第一个观测值包含一个校验和,目标是在序列的末尾辨别校验和是否正确。在这种情况下,第一个词元的影响至关重要。我们希望有某些机制能够在一个记忆元里存储重要的早期信息。如果没有这样的机制,我们将不得不给这个观测值指定一个非常大的梯度,因为它会影响所有后续的观测值。
- 可能会遇到这样的情况:一些词元没有相关的观测值。例如,在对网页内容进行情感分析时,可能有一些辅助HTML代码与网页传达的情绪无关。我们希望有一些机制来跳过隐状态表示中的此类词元。
- 可能会遇到这样的情况:序列的各个部分之间存在逻辑中断。例如,书的章节之间可能会有过渡存在,或者证券的熊市和牛市之间可能会有过渡存在。在这种情况下,最好有一种方法来重置内部状态表示。
学术界已经提出了许多方法来解决这类问题。其中最早的方法是”长短期记忆”(long-short-term memory,LSTM)[Hochreiter.Schmidhuber.1997
]。门控循环单元(gated recurrent unit,GRU)[Cho.Van-Merrienboer.Bahdanau.ea.2014
]是一个稍微简化的变体,通常能够提供同等的效果,并且计算[Chung.Gulcehre.Cho.ea.2014
]的速度明显更快。由于门控循环单元更简单,本章从它开始解读。
1.1. 门控隐状态
门控循环单元与普通的循环神经网络之间的关键区别在于: 前者支持隐状态的门控。 这意味着模型有专门的机制来确定应该何时更新隐状态, 以及应该何时重置隐状态。 这些机制是可学习的,并且能够解决了上面列出的问题。 例如,如果第一个词元非常重要, 模型将学会在第一次观测之后不更新隐状态。 同样,模型也可以学会跳过不相关的临时观测。 最后,模型还将学会在需要的时候重置隐状态。 下面将详细讨论各类门控。
1.1.1. 重置门和更新门
首先介绍重置门(reset gate)和更新门(update gate)。它们被设计成$(0, 1)$区间中的向量,这样就可以进行凸组合。重置门允许我们控制“可能还想记住”的过去状态的数量;更新门将允许我们控制新状态中有多少个是旧状态的副本。
凸组合
: 设向量 $ { x_i }, i=1,2, \ldots, n $, 如有实数 $\lambda_i \geq 0$, 且 $\sum_{i=1}^n \lambda_i=1$, 则称 $\sum_{i=1}^n \lambda_i x_i$ 为向量 $ { x_i } $ 的一个凸组合(凸线性组合)。
下图描述了门控循环单元中的重置门和更新门的输入,输入是由当前时间步的输入和前一时间步的隐状态给出。两个门的输出是由使用sigmoid激活函数的两个全连接层给出。
来看一下门控循环单元的数学表达。对于给定的时间步$t$,假设输入是一个小批量$\mathbf{X}_t \in \mathbb{R}^{n \times d}$(样本个数$n$,输入个数$d$),上一个时间步的隐状态是$\mathbf{H}_{t-1} \in \mathbb{R}^{n \times h}$(隐藏单元个数$h$)。那么,重置门$\mathbf{R}_t \in \mathbb{R}^{n \times h}$和更新门$\mathbf{Z}_t \in \mathbb{R}^{n \times h}$的计算如下所示:
其中$\mathbf{W}_{xr}, \mathbf{W}_{xz} \in \mathbb{R}^{d \times h}$和$\mathbf{W}_{hr}, \mathbf{W}_{hz} \in \mathbb{R}^{h \times h}$是权重参数,$\mathbf{b}_r, \mathbf{b}_z \in \mathbb{R}^{1 \times h}$是偏置参数。注意,在求和过程中会触发广播机制。使用sigmoid函数的目的是将输入值转换到区间$(0, 1)$。
1.1.2. 候选隐状态
接下来将重置门$\mathbf{R}_t$与前一章中的常规隐状态更新机制集成,得到在时间步$t$的候选隐状态(candidate hidden state)$\tilde{\mathbf{H}}_t \in \mathbb{R}^{n \times h}$。
其中$\mathbf{W}_{xh} \in \mathbb{R}^{d \times h}$和$\mathbf{W}_{hh} \in \mathbb{R}^{h \times h}$是权重参数,$\mathbf{b}_h \in \mathbb{R}^{1 \times h}$是偏置项,符号$\odot$是Hadamard积(按元素乘积)运算符。这里使用tanh非线性激活函数来确保候选隐状态中的值保持在区间$(-1, 1)$中。
与常规隐状态更新机制$\mathbf{H}_t = \phi(\mathbf{X}_t \mathbf{W}_{xh} + \mathbf{H}_{t-1} \mathbf{W}_{hh} + \mathbf{b}_h)$相比,上式中的$\mathbf{R}_t$和$\mathbf{H}_{t-1}$的元素相乘可以减少以往状态的影响。每当重置门$\mathbf{R}_t$中的项接近$1$时,会恢复一个常规隐状态更新的普通循环神经网络。对于重置门$\mathbf{R}_t$中所有接近$0$的项,候选隐状态是以$\mathbf{X}_t$作为输入的多层感知机的结果。因此,任何预先存在的隐状态都会被重置为默认值。下图说明了应用重置门之后的计算流程。
1.1.3. 隐状态
上述的计算结果只是候选隐状态,之后仍然需要结合更新门$\mathbf{Z}_t$的效果。这一步确定新的隐状态$\mathbf{H}_t \in \mathbb{R}^{n \times h}$在多大程度上来自旧的状态$\mathbf{H}_{t-1}$和新的候选状态$\tilde{\mathbf{H}}_t$。更新门$\mathbf{Z}_t$仅需要在$\mathbf{H}_{t-1}$和$\tilde{\mathbf{H}}_t$之间进行按元素的凸组合就可以实现这个目标。这就得出了门控循环单元的最终更新公式:
每当更新门$\mathbf{Z}_t$接近$1$时,模型就倾向只保留旧状态。此时,来自$\mathbf{X}_t$的信息基本上被忽略,从而有效地跳过了依赖链条中的时间步$t$。相反,当$\mathbf{Z}_t$接近$0$时,新的隐状态$\mathbf{H}_t$就会接近候选隐状态$\tilde{\mathbf{H}}_t$。这些设计可以帮助我们处理循环神经网络中的梯度消失问题,并更好地捕获时间步距离很长的序列的依赖关系。例如,如果整个子序列的所有时间步的更新门都接近于$1$,则无论序列的长度如何,在序列起始时间步的旧隐状态都将很容易保留并传递到序列结束。
下图说明了更新门起作用后的计算流。
总之,门控循环单元具有以下两个显著特征:
- 重置门有助于捕获序列中的短期依赖关系;
- 更新门有助于捕获序列中的长期依赖关系。
1.2. 从零实现
首先读取时间机器数据集:
1 | import torch |
1.2.1. 初始化模型参数
下一步是初始化模型参数。从标准差为$0.01$的高斯分布中提取权重,并将偏置项设为$0$,超参数num_hiddens
定义隐藏单元的数量,实例化与更新门、重置门、候选隐状态和输出层相关的所有权重和偏置。
1 | def get_params(vocab_size, num_hiddens, device): |
1.2.2. 定义模型
现在定义隐状态的初始化函数init_gru_state
。与上一章中从零实现RNN中定义的init_rnn_state
函数一样,此函数返回一个形状为(批量大小,隐藏单元个数)的张量,张量的值全部为零。
1 | def init_gru_state(batch_size, num_hiddens, device): |
现在定义门控循环单元模型,模型的架构与基本的循环神经网络单元是相同的,只是权重更新公式更为复杂。
1 | def gru(inputs, state, params): |
1.2.3. 训练与预测
训练和预测的工作方式与上一章完全相同。 训练结束后,分别打印输出训练集的困惑度, 以及前缀“time traveler”和“traveler”的预测序列上的困惑度。
1 | vocab_size, num_hiddens, device = len(vocab), 256, d2l.try_gpu() |
1.3. 框架实现
高级API包含了前文介绍的所有配置细节, 所以可以直接实例化门控循环单元模型。 这段代码的运行速度要快得多, 因为它使用的是编译好的运算符而不是Python来处理之前阐述的许多细节。
1 | num_inputs = vocab_size |
2. 长短期记忆网络(LSTM)
长期以来,隐变量模型存在着长期信息保存和短期输入缺失的问题。解决这一问题的最早方法之一是长短期存储器(long short-term memory,LSTM)[Hochreiter.Schmidhuber.1997
]。它有许多与门控循环单元一样的属性。长短期记忆网络的设计比门控循环单元稍微复杂一些,却比门控循环单元早诞生了近20年。
2.1. 门控记忆元
长短期记忆网络的设计灵感来自于计算机的逻辑门。长短期记忆网络引入了记忆元(memory cell),或简称为单元(cell)。有些文献认为记忆元是隐状态的一种特殊类型,它们与隐状态具有相同的形状,其设计目的是用于记录附加的信息。为了控制记忆元,我们需要许多门。其中一个门用来从单元中输出条目,称其为输出门(output gate)。另外一个门用来决定何时将数据读入单元,称其为输入门(input gate)。还需要一种机制来重置单元的内容,由遗忘门(forget gate)来管理,这种设计的动机与门控循环单元相同,能够通过专用机制决定什么时候记忆或忽略隐状态中的输入。下面看看这在实践中是如何运作的。
2.1.1. 输入门、忘记门和输出门
就如在门控循环单元中一样,当前时间步的输入和前一个时间步的隐状态作为数据送入长短期记忆网络的门中,如下图所示。它们由三个具有sigmoid激活函数的全连接层处理,以计算输入门、遗忘门和输出门的值。因此,这三个门的值都在$(0, 1)$的范围内。
详细了解一下长短期记忆网络的数学表达。假设有$h$个隐藏单元,批量大小为$n$,输入数为$d$。因此,输入为$\mathbf{X}_t \in \mathbb{R}^{n \times d}$,前一时间步的隐状态为$\mathbf{H}_{t-1} \in \mathbb{R}^{n \times h}$。相应地,时间步$t$的门被定义如下:输入门是$\mathbf{I}_t \in \mathbb{R}^{n \times h}$,遗忘门是$\mathbf{F}_t \in \mathbb{R}^{n \times h}$,输出门是$\mathbf{O}_t \in \mathbb{R}^{n \times h}$。它们的计算方法如下:
其中$\mathbf{W}_{xi}, \mathbf{W}_{xf}, \mathbf{W}_{xo} \in \mathbb{R}^{d \times h}$和$\mathbf{W}_{hi}, \mathbf{W}_{hf}, \mathbf{W}_{ho} \in \mathbb{R}^{h \times h}$是权重参数,$\mathbf{b}_i, \mathbf{b}_f, \mathbf{b}_o \in \mathbb{R}^{1 \times h}$是偏置参数。
2.1.2. 候选记忆元
由于还没有指定各种门的操作,所以先介绍候选记忆元(candidate memory cell)$\tilde{\mathbf{C}}_t \in \mathbb{R}^{n \times h}$。它的计算与上面描述的三个门的计算类似,但是使用$\tanh$函数作为激活函数,函数的值范围为$(-1, 1)$。下面导出在时间步$t$处的方程:
其中$\mathbf{W}_{xc} \in \mathbb{R}^{d \times h}$和
$\mathbf{W}_{hc} \in \mathbb{R}^{h \times h}$是权重参数,$\mathbf{b}_c \in \mathbb{R}^{1 \times h}$是偏置参数。如下图所示:
2.1.3. 记忆元
在门控循环单元中,有一种机制来控制输入和遗忘(或称跳过)。类似地,在长短期记忆网络中,也有两个门用于这样的目的:输入门$\mathbf{I}_t$控制采用多少来自$\tilde{\mathbf{C}}_t$的新数据,而遗忘门$\mathbf{F}_t$控制保留多少过去的记忆元$\mathbf{C}_{t-1} \in \mathbb{R}^{n \times h}$的内容。使用按元素乘法,得出:
如果遗忘门始终为$1$且输入门始终为$0$,则过去的记忆元$\mathbf{C}_{t-1}$将随时间被保存并传递到当前时间步。引入这种设计是为了缓解梯度消失问题,并更好地捕获序列中的长距离依赖关系。
这样就得到了计算记忆元的流程图,如下图。
2.1.4. 隐状态
最后需要定义如何计算隐状态$\mathbf{H}_t \in \mathbb{R}^{n \times h}$,这就是输出门发挥作用的地方。在长短期记忆网络中,它仅仅是记忆元的$\tanh$的门控版本。这就确保了$\mathbf{H}_t$的值始终在区间$(-1, 1)$内(因为输出门在0和1之间):
只要输出门接近$1$,就能够有效地将所有记忆信息传递给预测部分,而对于输出门接近$0$,则只保留记忆元内的所有信息,而不需要更新隐状态。
下图提供了数据流的图形化演示。
2.2. 从零实现
首先加载时光机器数据集。
1 | import torch |
2.2.1. 初始化模型参数
接下来需要定义和初始化模型参数。如前所述,超参数num_hiddens
定义隐藏单元的数量。按照标准差$0.01$的高斯分布初始化权重,并将偏置项设为$0$。
1 | def get_lstm_params(vocab_size, num_hiddens, device): |
2.2.2. 定义模型
在初始化函数中,长短期记忆网络的隐状态需要返回一个额外的记忆元,单元的值为0,形状为(批量大小,隐藏单元数)。因此得到以下的状态初始化。
1 | def init_lstm_state(batch_size, num_hiddens, device): |
实际模型的定义与前面讨论的一样:提供三个门和一个额外的记忆元。注意只有隐状态才会传递到输出层,而记忆元$\mathbf{C}_t$不直接参与输出计算。
1 | def lstm(inputs, state, params): |
2.2.3. 训练和预测
通过实例化上一章引入的RNNModelScratch类来训练一个长短期记忆网络,就如在上一节中所做的一样。
1 | vocab_size, num_hiddens, device = len(vocab), 256, d2l.try_gpu() |
2.3. 框架实现
使用高级API可以直接实例化LSTM模型。 高级API封装了前文介绍的所有配置细节。 这段代码的运行速度要快得多, 因为它使用的是编译好的运算符而不是Python来处理之前阐述的许多细节。
1 | num_inputs = vocab_size |
长短期记忆网络是典型的具有重要状态控制的隐变量自回归模型。 多年来已经提出了其许多变体,例如,多层、残差连接、不同类型的正则化。 然而,由于序列的长距离依赖性,训练长短期记忆网络和其他序列模型(例如门控循环单元)的成本是相当高的。 后面的内容中将讲述更高级的替代模型,如transformer。
3. 深度循环神经网络
到目前为止,我们一直专注于定义由序列输入、单个隐藏 RNN 层和输出层组成的网络。尽管在任何时间步长的输入和相应的输出之间只有一个隐藏层,但这些网络在某种意义上是很深的。第一个时间步的输入可以影响最后一个时间步$T$的输出 (通常是 100 或 1000 步之后)。这些输入通过长度为$T$的时间距离,在产生最终输出之前一直应用了循环层。但通常人们也希望保留在某时间步长上的输入与输出之间表达复杂关系的能力。因此,我们经常构建不仅在时间方向上而且在输入到输出方向上都很深的 RNN。这正是在开发 MLP 和深度 CNN 时已经遇到的深度概念。
构建这种深度 RNN 的标准方法非常简单:可以将多层循环神经网络堆叠在一起,给定一个长度序列$T$,第一个 RNN 产生一系列输出,长度也一样为$T$. 这些又构成了下一个 RNN 层的输入。通过对几个简单层的组合,产生了一个灵活的机制。特别的是数据可能与不同层的堆叠有关。例如人们可能希望保持有关金融市场状况(熊市或牛市)的宏观数据可用,而微观数据只记录较短期的时间动态。
下图描述了一个具有$L$个隐藏层的深度循环神经网络,每个隐状态都连续地传递到当前层的下一个时间步和下一层的当前时间步。
3.1. 函数依赖关系
可以将深度架构中的函数依赖关系形式化,这个架构是由上图中描述的$L$个隐藏层构成。后续的讨论主要集中在经典的循环神经网络模型上,但是这些讨论也适应于其他序列模型。
假设在时间步$t$有一个小批量的输入数据$\mathbf{X}_t \in \mathbb{R}^{n \times d}$(样本数:$n$,每个样本中的输入数:$d$)。同时,将$l^\mathrm{th}$隐藏层($l=1,\ldots,L$)的隐状态设为$\mathbf{H}_t^{(l)} \in \mathbb{R}^{n \times h}$(隐藏单元数:$h$),输出层变量设为$\mathbf{O}_t \in \mathbb{R}^{n \times q}$(输出数:$q$)。设置$\mathbf{H}_t^{(0)} = \mathbf{X}_t$,第$l$个隐藏层的隐状态使用激活函数$\phi_l$,则:
其中,权重$\mathbf{W}_{xh}^{(l)} \in \mathbb{R}^{h \times h}$,$\mathbf{W}_{hh}^{(l)} \in \mathbb{R}^{h \times h}$和偏置$\mathbf{b}_h^{(l)} \in \mathbb{R}^{1 \times h}$都是第$l$个隐藏层的模型参数。
最后,输出层的计算仅基于第$l$个隐藏层最终的隐状态:
其中,权重$\mathbf{W}_{hq} \in \mathbb{R}^{h \times q}$和偏置$\mathbf{b}_q \in \mathbb{R}^{1 \times q}$都是输出层的模型参数。
与多层感知机一样,隐藏层数目$L$和隐藏单元数目$h$都是超参数,它们可以被人为调整。另外,用门控循环单元或长短期记忆网络的隐状态来代替深度循环网络中的隐状态进行计算,可以得到深度门控循环神经网络或深度长短期记忆神经网络。
3.2. 框架实现
实现多层循环神经网络所需的许多逻辑细节在高级API中都是现成的。 简单起见这里仅示范使用此类内置函数的使用方式。 以长短期记忆网络模型为例, 该代码与上节中使用的代码非常相似,唯一的区别是我们指定了层的数量,而不是使用单一层这个默认值。从加载数据集开始。
1 | import torch |
像选择超参数这类架构决策也跟上节中的决策非常相似。因为我们有不同的词元,所以输入和输出都选择相同数量(why?),即vocab_size
。隐藏单元的数量仍然是$256$。唯一的区别是现在通过num_layers
的值来设定隐藏层数。
1 | vocab_size, num_hiddens, num_layers = len(vocab), 256, 2 |
3.3. 训练与预测
由于使用了长短期记忆网络模型来实例化两个层,因此训练速度被大大降低了。
1 | num_epochs, lr = 500, 2 |
4. 双向循环神经网络(Bidirectional RNN)
在序列学习中,以往假设的目标是:在给定观测的情况下(例如,在时间序列的上下文中或在语言模型的上下文中),对下一个输出进行建模。虽然这是一个典型情景,但不是唯一的。还可能发生什么其它的情况呢?考虑以下三个在文本序列中填空的任务。
- 我
___
。 - 我
___
饿了。 - 我
___
饿了,我可以吃半头猪。
根据可获得的信息量,可以用不同的词填空,如“很高兴”(”happy”)、“不”(”not”)和“非常”(”very”)。很明显,每个短语的“下文”传达了重要信息(如果有的话),而这些信息关乎到选择哪个词来填空,所以无法利用这一点的序列模型将在相关任务上表现不佳。例如,如果要做好命名实体识别(例如,识别“Green”指的是“格林先生”还是绿色),不同长度的上下文范围重要性是相同的。为了获得一些解决问题的灵感,让我们先迂回到概率图模型。
4.1. 隐马尔可夫模型中的动态规划
这一小节是用来说明动态规划问题的,具体的技术细节对于理解深度学习模型并不重要,但有助于思考为什么要使用深度学习,以及为什么要选择特定的架构。
如果想用概率图模型来解决这个问题,可以设计一个隐变量模型:在任意时间步$t$,假设存在某个隐变量$h_t$,通过概率$P(x_t \mid h_t)$控制我们观测到的$x_t$。此外,任何$h_t \to h_{t+1}$转移都是由一些状态转移概率$P(h_{t+1} \mid h_{t})$给出。这个概率图模型就是一个隐马尔可夫模型(hidden Markov model,HMM),如下图所示。
因此,对于有$T$个观测值的序列,在观测状态和隐状态上具有以下联合概率分布:
现在假设观测到了所有的$x_i$,除了$x_j$,并且我们的目标是计算$P(x_j \mid x_{-j})$,其中$x_{-j} = (x_1, \ldots, x_{j-1}, x_{j+1}, \ldots, x_{T})$。由于$P(x_j \mid x_{-j})$中没有隐变量,因此我们考虑对$h_1, \ldots, h_T$选择构成的所有可能的组合进行求和。如果任何$h_i$可以接受$k$个不同的值(有限的状态数),则意味着需要对$k^T$个项求和,这个任务显然难于登天。幸运的是,有个巧妙的解决方案:动态规划(dynamic programming)。
为了解动态规划的工作方式,考虑对隐变量$h_1, \ldots, h_T$的依次求和。根据上式,将得出:
通常我们将前向递归(forward recursion)写为:
递归被初始化为$\pi_1(h_1) = P(h_1)$。符号简化,也可以写成$\pi_{t+1} = f(\pi_t, x_t)$,其中$f$是一些可学习的函数。这看起来就像循环神经网络中讨论的隐变量模型中的更新方程。
与前向递归(或称正向)一样,也可以使用后向递归对同一组隐变量求和。这将得到:
因此可以将后向递归(backward recursion)写为:
初始化$\rho_T(h_T) = 1$。前向和后向递归都允许我们对$T$个隐变量在$\mathcal{O}(kT)$(线性而不是指数)时间内对$(h_1, \ldots, h_T)$的所有值求和。这是使用图模型进行概率推理的巨大好处之一。它也是通用消息传递算法[Aji.McEliece.2000
]的一个非常特殊的例子。结合前向和后向递归,我们能够计算
∝,数学符号,表示与某个量成正比例。A∝B也可表示有一个从 𝐴 到 𝐵 的多项式变换,当A、B为集合或表示$\text { set }{(a, b) \in A \times B: a=k b} \text { for some constant } k$
因为符号简化的需要,后向递归也可以写为$\rho_{t-1} = g(\rho_t, x_t)$,其中$g$是一个可以学习的函数。这同样看起来像一个更新方程,只是不像在循环神经网络中看到的那样前向运算,而是后向计算。事实上,知道未来数据何时可用对隐马尔可夫模型是有益的。信号处理学家将是否知道未来观测这两种情况区分为内插和外推,有关更多详细信息,请参阅 [Doucet.De-Freitas.Gordon.2001
]。
4.2. 双向模型
如果我们希望在循环神经网络中拥有一种机制,使之能够提供与隐马尔可夫模型类似的前瞻能力,就需要修改循环神经网络的设计。这在概念上很容易,只需要增加一个“从最后一个词元开始从后向前运行”的循环神经网络,而不是只有一个在前向模式下“从第一个词元开始运行”的循环神经网络。双向循环神经网络(bidirectional RNNs)添加了反向传递信息的隐藏层,以便更灵活地处理此类信息。下图描述了具有单个隐藏层的双向循环神经网络的架构。
事实上这与隐马尔可夫模型中的动态规划的前向和后向递归没有太大区别。主要区别是在隐马尔可夫模型中的方程具有特定的统计意义。双向循环神经网络没有这样容易理解的解释,我们只能把它们当作通用的、可学习的函数。这种转变集中体现了现代深度网络的设计原则:首先使用经典统计模型的函数依赖类型,然后将其参数化为通用形式。(就是模仿结构但是没有数学理论支持?)
4.2.1. 定义
双向循环神经网络是由[Schuster.Paliwal.1997
]提出的,关于各种架构的详细讨论请参阅[Graves.Schmidhuber.2005
]。来看看这样一个网络的细节。
对于任意时间步$t$,给定一个小批量的输入数据$\mathbf{X}_t \in \mathbb{R}^{n \times d}$(样本数$n$,每个示例中的输入数$d$),并且令隐藏层激活函数为$\phi$。在双向架构中,设该时间步的前向和反向隐状态分别为$\overrightarrow{\mathbf{H}}_t \in \mathbb{R}^{n \times h}$和$\overleftarrow{\mathbf{H}}_t \in \mathbb{R}^{n \times h}$,其中$h$是隐藏单元的数目。前向和反向隐状态的更新如下:
其中,权重$\mathbf{W}_{xh}^{(f)} \in \mathbb{R}^{d \times h}, \mathbf{W}_{hh}^{(f)} \in \mathbb{R}^{h \times h}, \mathbf{W}_{xh}^{(b)} \in \mathbb{R}^{d \times h}, \mathbf{W}_{hh}^{(b)} \in \mathbb{R}^{h \times h}$和偏置$\mathbf{b}_h^{(f)} \in \mathbb{R}^{1 \times h}, \mathbf{b}_h^{(b)} \in \mathbb{R}^{1 \times h}$都是模型参数。
接下来,将前向隐状态$\overrightarrow{\mathbf{H}}_t$和反向隐状态$\overleftarrow{\mathbf{H}}_t$连接起来,获得需要送入输出层的隐状态$\mathbf{H}_t \in \mathbb{R}^{n \times 2h}$。在具有多个隐藏层的深度双向循环神经网络中,该信息作为输入传递到下一个双向层。最后,输出层计算得到的输出为$\mathbf{O}_t \in \mathbb{R}^{n \times q}$($q$是输出单元的数目):
这里,权重矩阵$\mathbf{W}_{hq} \in \mathbb{R}^{2h \times q}$和偏置$\mathbf{b}_q \in \mathbb{R}^{1 \times q}$是输出层的模型参数。事实上,这两个方向可以拥有不同数量的隐藏单元。
4.2.2. 模型的计算代价及其应用
双向循环神经网络的一个关键特性是:使用来自序列两端的信息来估计输出。也就是说,我们使用来自过去和未来的观测信息来预测当前的观测。但是在对下一个词元进行预测的情况中,这样的模型并不是我们所需的。因为在预测下一个词元时我们无法知道下一个词元的下文是什么,所以将不会得到很好的精度。具体地说,在训练期间,我们能够利用过去和未来的数据来估计现在空缺的词;而在测试期间,我们只有过去的数据,因此精度将会很差。下面的实验将说明这一点。
另一个严重问题是,双向循环神经网络的计算速度非常慢。其主要原因是网络的前向传播需要在双向层中进行前向和后向递归,并且网络的反向传播还依赖于前向传播的结果。因此,梯度求解将有一个非常长的链。
双向层的使用在实践中非常少,并且仅仅应用于部分场合。例如,填充缺失的单词、词元注释(例如,用于命名实体识别)以及作为序列处理流水线中的一个步骤对序列进行编码(例如,用于机器翻译)。本书未来将介绍如何使用双向循环神经网络编码文本序列。
4.3. 双向循环神经网络的错误应用
由于双向循环神经网络使用了过去的和未来的数据,所以不能盲目地将这一语言模型应用于任何预测任务。 尽管模型产出的困惑度是合理的,该模型预测未来词元的能力却可能存在严重缺陷。 这里用下面的示例代码引以为戒,以防在错误的环境中使用它们。
1 | import torch |
最终结果虽然困惑度降低了,但预测效果非常差。关于如何更有效地使用双向循环神经网络的讨论,请参阅情感分类应用。
5. 机器翻译与数据集
语言模型是自然语言处理的关键,而机器翻译是语言模型最成功的基准测试。因为机器翻译正是将输入序列转换成输出序列的序列转换模型(sequence transduction)的核心问题。序列转换模型在各类现代人工智能应用中发挥着至关重要的作用,因此将其做为本章剩余部分和下一章的重点。本节将介绍机器翻译问题及其后文需要使用的数据集。
机器翻译(machine translation)指的是将序列从一种语言自动翻译成另一种语言。这个研究领域可以追溯到数字计算机发明后不久的20世纪40年代,特别是在第二次世界大战中使用计算机破解语言编码。几十年来,在使用神经网络进行端到端学习的兴起之前,统计学方法在这一领域一直占据主导地位。因为统计机器翻译(statisticalmachine translation)涉及了翻译模型和语言模型等组成部分的统计分析,因此基于神经网络的方法通常被称为神经机器翻译(neuralmachine translation),用于将两种翻译模型区分开来。
本书的关注点是神经网络机器翻译方法,强调的是端到端的学习。与上一章中语料库是单一语言的语言模型问题存在不同,机器翻译的数据集是由源语言和目标语言的文本序列对组成的。因此需要一种完全不同的方法来预处理机器翻译数据集,而不是复用语言模型的预处理程序。下面看一下如何将预处理后的数据加载到小批量中用于训练。
1 | import os |
5.1. 下载和预处理数据集
首先,下载一个由Tatoeba项目的双语句子对组成的“英-法”数据集,数据集中的每一行都是制表符分隔的文本序列对,序列对由英文文本序列和翻译后的法语文本序列组成。请注意,每个文本序列可以是一个句子,也可以是包含多个句子的一个段落。在这个将英语翻译成法语的机器翻译问题中,英语是源语言(source language),法语是目标语言(target language)。
1 | d2l.DATA_HUB['fra-eng'] = (d2l.DATA_URL + 'fra-eng.zip', |
下载数据集后,原始文本数据需要经过几个预处理步骤。例如:用空格代替不间断空格(non-breaking space),使用小写字母替换大写字母,并在单词和标点符号之间插入空格。
关于空格的种类(带u的是unicode编码):
1、半角空格:\0x20
,占位符为一个半角字符,日常英文数学和代码编写使用。
2、全角空格:\u3000
,中文输入空格,两个半角空格。
3、不间断空格:\u00A0
或\xa0
,在word、html等中大量使用。
4、零宽度空格:\u200B
,不可见非打印字符,可以替换html中的<wbr/>
标签。
5、零宽度非中断空格:\u2060
,结合了 non-breaking space 和 零宽度空格的特点。既会自动换行,宽度又是0。
6、还有一些html宽度度量下的其他空格字符,如\u202f
就是一种窄不间断空格,不同语种中不太一样。
1 | def preprocess_nmt(text): |
5.2. 词元化
与上一章中的字符级词元化不同,在机器翻译中,更喜欢做单词级词元化(最先进的模型可能使用更高级的词元化技术)。下面的tokenize_nmt
函数对前num_examples
个文本序列对进行词元,其中每个词元要么是一个词,要么是一个标点符号。此函数返回两个词元列表:source
和target
:source[i]
是源语言(这里是英语)第$i$个文本序列的词元列表,target[i]
是目标语言(这里是法语)第$i$个文本序列的词元列表。
1 | def tokenize_nmt(text, num_examples=None): |
绘制每个文本序列所包含的词元数量的直方图。在这个简单的“英-法”数据集中,大多数文本序列的词元数量少于$20$个。
1 | def show_list_len_pair_hist(legend, xlabel, ylabel, xlist, ylist): |
5.3. 词表
由于机器翻译数据集由语言对组成,则可以分别为源语言和目标语言构建两个词表。使用单词级词元化时,词表大小将明显大于使用字符级词元化时的词表大小。为了缓解这一问题,这里将出现次数少于2次的低频率词元视为相同的未知(“<unk>”)词元。除此之外,还指定了额外的特定词元,例如在小批量时用于将序列填充到相同长度的填充词元(“<pad>”),以及序列的开始词元(“<bos>”)和结束词元(“<eos>”)。这些特殊词元在自然语言处理任务中比较常用。
1 | src_vocab = d2l.Vocab(source, min_freq=2, |
5.4. 加载数据集
语言模型中的序列样本都有一个固定的长度,无论这个样本是一个句子的一部分还是跨越了多个句子的一个片断。这个固定长度是上一章中第3节中的num_steps
(时间步数或词元数量)参数指定的。在机器翻译中,每个样本都是由源和目标组成的文本序列对,其中的每个文本序列可能具有不同的长度。
为了提高计算效率,我们仍然可以通过截断(truncation)和填充(padding)方式实现一次只处理一个小批量的文本序列。假设同一个小批量中的每个序列都应该具有相同的长度num_steps
,那么如果文本序列的词元数目少于num_steps
时,我们将继续在其末尾添加特定的“<pad>”词元,直到其长度达到num_steps
;反之,我们将截断文本序列时,只取其前num_steps
个词元,并且丢弃剩余的词元。这样,每个文本序列将具有相同的长度,以便以相同形状的小批量进行加载。
下面的truncate_pad
函数将截断或填充文本序列。
1 | def truncate_pad(line, num_steps, padding_token): |
现在定义一个函数,可以将文本序列转换成小批量数据集用于训练。我们将特定的“<eos>”词元添加到所有序列的末尾,用于表示序列的结束。当模型通过一个词元接一个词元地生成序列进行预测时,生成的“<eos>”词元说明完成了序列输出工作。此外,我们还记录了每个文本序列的长度,统计长度时排除了填充词元,之后介绍的一些模型会需要这个长度信息。
1 | def build_array_nmt(lines, vocab, num_steps): |
5.5. 训练模型
最后定义load_data_nmt
函数来返回数据迭代器,以及源语言和目标语言的两种词表。
1 | def load_data_nmt(batch_size, num_steps, num_examples=600): |
下面读出“英语-法语”数据集中的第一个小批量数据。
1 | train_iter, src_vocab, tgt_vocab = load_data_nmt(batch_size=2, num_steps=8) |
6. 编码器-解码器架构
机器翻译是序列转换模型的一个核心问题,其输入和输出都是长度可变的序列。为了处理这种类型的输入和输出,可以设计一个包含两个主要组件的架构:第一个组件是一个编码器(encoder):它接受一个长度可变的序列作为输入,并将其转换为具有固定形状的编码状态。第二个组件是解码器(decoder):它将固定形状的编码状态映射到长度可变的序列。这被称为编码器-解码器(encoder-decoder)架构,如下图所示。
以英语到法语的机器翻译为例:给定一个英文的输入序列:“They”“are”“watching”“.”。首先,这种“编码器-解码器”架构将长度可变的输入序列编码成一个“状态”,然后对该状态进行解码,一个词元接着一个词元地生成翻译后的序列作为输出:“Ils”“regordent”“.”。由于“编码器-解码器”架构是形成后续章节中不同序列转换模型的基础,因此本节将把这个架构转换为接口方便后面的代码实现。
6.1. 编码器
在编码器接口中,我们只指定长度可变的序列作为编码器的输入X
。
任何继承这个Encoder
基类的模型将完成代码实现。
1 | from torch import nn |
这里解析一下super(Encoder, self).__init__()
这个写法。self指的是实例(instance)本身,Python的类中规定:类中的方法的第一个参数一定要是self,而且不能省略。 所以构造函数也就是__init__ ()
方法必须包含一个self参数,而且要是第一个参数。
super()
是Python的内置函数,用于调用父类。在Python3中我们通常使用super().xxx
代替super(Class, self).xxx
。super(Encoder, self).__init__()
的工作原理是首先找到Encoder的父类nn.Module,然后把实例self转化为父类的对象,再去调用该实例的构造方法,实际上也就是调用了父类的构造方法。
6.2. 解码器
在下面的解码器接口中,新增一个init_state
函数,用于将编码器的输出(enc_outputs
)转换为编码后的状态。注意,此步骤可能需要额外的输入,例如:输入序列的有效长度。为了逐个地生成长度可变的词元序列,解码器在每个时间步都会将输入(例如:在前一时间步生成的词元)和编码后的状态映射成当前时间步的输出词元。
1 | class Decoder(nn.Module): |
6.3. 合并编码器和解码器
总而言之,“编码器-解码器”架构包含了一个编码器和一个解码器, 并且还拥有可选的额外的参数。 在前向传播中,编码器的输出用于生成编码状态, 这个状态又被解码器作为其输入的一部分。
1 | class EncoderDecoder(nn.Module): |
“编码器-解码器”体系架构中的术语状态会启发人们使用具有状态的神经网络来实现该架构。下一节将学习如何应用循环神经网络,来设计基于“编码器-解码器”架构的序列转换模型。
7. 序列到序列学习(seq2seq)
机器翻译中的输入序列和输出序列都是长度可变的。为了解决这类问题,我们设计了一个通用的”编码器-解码器“架构。本节将使用两个循环神经网络的编码器和解码器,并将其应用于序列到序列(sequence to sequence,seq2seq)类的学习任务[Sutskever.Vinyals.Le.2014,Cho.Van-Merrienboer.Gulcehre.ea.2014
]。
遵循编码器-解码器架构的设计原则,循环神经网络编码器使用长度可变的序列作为输入,将其转换为固定形状的隐状态。换言之,输入序列的信息被编码到循环神经网络编码器的隐状态中。为了连续生成输出序列的词元,独立的循环神经网络解码器是基于输入序列的编码信息和输出序列已经看见的或者生成的词元来预测下一个词元。下图演示了如何在机器翻译中使用两个循环神经网络进行序列到序列学习。
图中,特定的“<eos>”表示序列结束词元。一旦输出序列生成此词元,模型就会停止预测。在循环神经网络解码器的初始化时间步,有两个特殊的设计:第一,特定的“<bos>”表示序列开始词元,它是解码器的输入序列的第一个词元。第二,使用循环神经网络编码器最终的隐状态来初始化解码器的隐状态。例如,在[Sutskever.Vinyals.Le.2014
]的设计中,正是基于这种设计将输入序列的编码信息送入到解码器中来生成输出序列的。在其他一些设计中[Cho.Van-Merrienboer.Gulcehre.ea.2014
],如上图所示,编码器最终的隐状态在每一个时间步都作为解码器的输入序列的一部分。类似于上一章中语言模型的训练,可以允许标签成为原始的输出序列,从源序列词元“<bos>”“Ils”“regardent”“.”到新序列词元“Ils”“regardent”“.”“<eos>”来移动预测的位置。
下面来动手构建以上的设计,并将基于“英-法”数据集来训练这个机器翻译模型。
1 | import collections |
7.1. 编码器
从技术上讲,编码器将长度可变的输入序列转换成形状固定的上下文变量$\mathbf{c}$,并且将输入序列的信息在该上下文变量中进行编码。如前图所示,可以使用循环神经网络来设计编码器。
对于由一个序列组成的样本(批量大小是$1$)。假设输入序列是$x_1, \ldots, x_T$,其中$x_t$是输入文本序列中的第$t$个词元。在时间步$t$,循环神经网络将词元$x_t$的输入特征向量$\mathbf{x}_t$和$\mathbf{h} _{t-1}$(即上一时间步的隐状态)转换为$\mathbf{h}_t$(即当前步的隐状态)。使用一个函数$f$来描述循环神经网络的循环层所做的变换:
而编码器通过选定的函数$q$,将所有时间步的隐状态转换为上下文变量:
比如在上面的图中,指定$q(\mathbf{h}_1, \ldots, \mathbf{h}_T) = \mathbf{h}_T$后,上下文变量则仅是输入序列在最后时间步的隐状态$\mathbf{h}_T$。
目前为止,我们使用的是一个单向循环神经网络来设计编码器,其中隐状态只依赖于输入子序列,这个子序列是由输入序列的开始位置到隐状态所在的时间步的位置(包括隐状态所在的时间步)组成。当然也可以使用双向循环神经网络构造编码器,其中隐状态依赖于两个输入子序列,两个子序列是由隐状态所在的时间步的位置之前的序列 和 之后的序列(包括隐状态所在的时间步),因此隐状态对整个序列的信息都进行了编码。
现在来实现循环神经网络编码器。注意这里使用了嵌入层(embedding layer)来获得输入序列中每个词元的特征向量。嵌入层的权重是一个矩阵,其行数等于输入词表的大小(vocab_size
),其列数等于特征向量的维度(embed_size
)。对于任意输入词元的索引$i$,嵌入层获取权重矩阵的第$i$行(从$0$开始)以返回其特征向量。另外,本文选择了一个多层门控循环单元来实现编码器。
1 | class Seq2SeqEncoder(Encoder): |
循环层返回变量的说明可以参考上一章“循环神经网络的框架实现”。
下面实例化上述编码器的实现:使用一个两层门控循环单元编码器,其隐藏单元数为$16$。给定一小批量的输入序列X
(批量大小为$4$,时间步为$7$)。在完成所有时间步后,最后一层的隐状态的输出是一个张量(output
由编码器的循环层返回),其形状为(时间步数,批量大小,隐藏单元数)。
1 | encoder = Seq2SeqEncoder(vocab_size=10, embed_size=8, num_hiddens=16, |
由于这里使用的是门控循环单元,所以在最后一个时间步的多层隐状态的形状是 (隐藏层的数量,批量大小,隐藏单元的数量)。 如果使用长短期记忆网络,state中还将包含记忆单元信息。
7.2. 解码器
如上文所说,编码器输出的上下文变量$\mathbf{c}$对整个输入序列$x_1, \ldots, x_T$进行编码。来自训练数据集的输出序列$y_1, y_2, \ldots, y_{T’}$,对于每个时间步$t’$(与输入序列或编码器的时间步$t$不同),解码器输出$y_{t’}$的概率取决于先前的输出子序列$y_1, \ldots, y_{t’-1}$和上下文变量$\mathbf{c}$,即$P(y_{t’} \mid y_1, \ldots, y_{t’-1}, \mathbf{c})$。
为了在序列上模型化这种条件概率,可以使用另一个循环神经网络作为解码器。在输出序列上的任意时间步$t^\prime$,循环神经网络将来自上一时间步的输出$y_{t^\prime-1}$和上下文变量$\mathbf{c}$作为其输入,然后在当前时间步将它们和上一隐状态$\mathbf{s}_{t^\prime-1}$转换为隐状态$\mathbf{s}_{t^\prime}$。可以使用函数$g$来表示解码器的隐藏层的变换:
在获得解码器的隐状态之后,我们可以使用输出层和softmax操作来计算在时间步$t^\prime$时输出$y_{t^\prime}$的条件概率分布$P(y_{t^\prime} \mid y_1, \ldots, y_{t^\prime-1}, \mathbf{c})$。
根据一开始给出的图,当实现解码器时,我们直接使用编码器最后一个时间步的隐状态来初始化解码器的隐状态。这就要求使用循环神经网络实现的编码器和解码器具有相同数量的层和隐藏单元(批量大小是一样的)。为了进一步包含经过编码的输入序列的信息,上下文变量在所有的时间步与解码器的输入进行拼接(concatenate)。为了预测输出词元的概率分布,在循环神经网络解码器的最后一层使用全连接层来变换隐状态。
1 | class Seq2SeqDecoder(Decoder): |
下面用与前面提到的编码器中相同的超参数来实例化解码器。解码器的输出形状变为(批量大小,时间步数,词表大小), 其中张量的最后一个维度存储预测的词元分布。
1 | decoder = Seq2SeqDecoder(vocab_size=10, embed_size=8, num_hiddens=16, |
上述循环神经网络“编码器-解码器”模型中的各层如下图所示:
7.3. 损失函数
在每个时间步,解码器预测了输出词元的概率分布。类似于语言模型,可以使用softmax来获得分布,并通过计算交叉熵损失函数来进行优化。第五节中,特定的填充词元被添加到序列的末尾,因此不同长度的序列可以以相同形状的小批量加载。但是,我们应该将填充词元的预测排除在损失函数的计算之外。
为此可以使用下面的sequence_mask
函数通过零值化屏蔽不相关的项,以便后面任何不相关预测的计算都是与零的乘积,结果都等于零。例如,如果两个序列的有效长度(不包括填充词元)分别为$1$和$2$,则第一个序列的第一项和第二个序列的前两项之后的剩余项将被清除为零。
1 | def sequence_mask(X, valid_len, value=0): |
可以使用此函数屏蔽最后几个轴上的所有项。也可以通过指定value
参数使用非零值来替换这些项。
1 | X = torch.ones(2, 3, 4) |
现在可以通过扩展softmax交叉熵损失函数来遮蔽不相关的预测。最初,所有预测词元的掩码都设置为1。一旦给定了有效长度,与填充词元对应的掩码将被设置为0。最后,将所有词元的损失乘以掩码,以过滤掉损失中填充词元产生的不相关预测。
1 | class MaskedSoftmaxCELoss(nn.CrossEntropyLoss): |
可以创建三个相同的序列来进行代码健全性检查,然后分别指定这些序列的有效长度为$4$、$2$和$0$。结果就是,第一个序列的损失应为第二个序列的两倍,而第三个序列的损失应为零。
1 | loss = MaskedSoftmaxCELoss() |
7.4. 训练
在下面的循环训练过程中,如前图所示,特定的序列开始词元(“<bos>”)和原始的输出序列(不包括序列结束词元“<eos>”)拼接在一起作为解码器的输入。这被称为强制教学(teacher forcing),因为原始的输出序列(词元的标签)被送入解码器。或者,将来自上一个时间步的预测得到的词元作为解码器的当前输入。
1 | def train_seq2seq(net, data_iter, lr, num_epochs, tgt_vocab, device): |
现在可以在 机器翻译数据集 上创建和训练一个循环神经网络“编码器-解码器”模型用于序列到序列的学习。
1 | embed_size, num_hiddens, num_layers, dropout = 32, 32, 2, 0.1 |
7.5. 预测
为了采用一个接着一个词元的方式预测输出序列,每个解码器当前时间步的输入都将来自于前一时间步的预测词元。与训练类似,序列开始词元(“<bos>”)在初始时间步被输入到解码器中。该预测过程如下图所示,当输出序列的预测遇到序列结束词元(“<eos>”)时,预测就结束了。
下一节中将介绍不同的序列生成策略。
1 | def predict_seq2seq(net, src_sentence, src_vocab, tgt_vocab, num_steps, |
7.6. 预测序列的评估
我们可以通过与真实的标签序列进行比较来评估预测序列。虽然[Papineni.Roukos.Ward.ea.2002
]提出的BLEU(bilingual evaluation understudy)最先是用于评估机器翻译的结果,但现在它已经被广泛用于测量许多应用的输出序列的质量。原则上说,对于预测序列中的任意$n$元语法(n-grams),BLEU的评估都是这个$n$元语法是否出现在标签序列中。
我们将BLEU定义为:
其中$\mathrm{len}_{\text{label}}$表示标签序列中的词元数和$\mathrm{len}_{\text{pred}}$表示预测序列中的词元数,$k$是用于匹配的最长的$n$元语法。另外,用$p_n$表示$n$元语法的精确度,它是两个数量的比值:第一个是预测序列与标签序列中匹配的$n$元语法的数量,第二个是预测序列中$n$元语法的数量的比率。具体地说,给定标签序列$A$、$B$、$C$、$D$、$E$、$F$和预测序列$A$、$B$、$B$、$C$、$D$,我们有$p_1 = 4/5$、$p_2 = 3/4$、$p_3 = 1/3$和$p_4 = 0$。
这里解释一下,先说$p_1$,首先明确一点,序列是有方向有顺序的,我们把序列都从左向右看,那么预测序列中的1元语法分别为:$P(A)$、$P(B)$、$P(B)$、$P(C)$、$P(D)$,标签序列同理。可知预测标签与标签序列中匹配的1元语法数量为4,分别为:$P(A)$、$P(B)$、$P(C)$、$P(D)$,注意这里是一一对应,所以要去重。而预测序列中1元语法的数量为5,故有$p_1 = 4/5$。对于$p_2$,预测序列中的2元语法分别为:$P(B \mid A)$、$P(B \mid B)$、$P(C \mid B)$、$P(D \mid C)$,后面同理。
根据上述BLEU的定义,当预测序列与标签序列完全相同时,BLEU为$1$。此外,由于$n$元语法越长则匹配难度越大,所以BLEU为更长的$n$元语法的精确度分配更大的权重。具体来说,当$p_n$固定时,$p_n^{1/2^n}$会随着$n$的增长而增加(原始论文使用$p_n^{1/n}$)。而且由于预测的序列越短获得的$p_n$值越高,所以上式中乘法项之前的系数 $\exp\left(\min\left(0, 1 - \frac{\mathrm{len}_{\text{label}}}{\mathrm{len}_{\text{pred}}}\right)\right)$ 用于惩罚较短的预测序列。例如,当$k=2$时,给定标签序列$A$、$B$、$C$、$D$、$E$、$F$和预测序列$A$、$B$,尽管$p_1 = p_2 = 1$,惩罚因子$\exp(1-6/2) \approx 0.14$会降低BLEU。BLEU的代码实现如下。
1 | def bleu(pred_seq, label_seq, k): |
最后,利用训练好的循环神经网络“编码器-解码器”模型, 将几个英语句子翻译成法语,并计算BLEU的最终结果。
1 | engs = ['go .', "i lost .", 'he\'s calm .', 'i\'m home .'] |
第一次阅读,只对我注意到的细节部分做一些解释,实际上在更大的层面上,我完全是一知半解,甚至一窍不通,由于没有决定是否选择NLP方向,所以并没有追求一次性完全弄懂训练、预测过程中的所有细节,留待以后回看吧。 — SilenceZheng于22.09.07
8. 束搜索
上节中,我们逐个预测输出序列,直到预测序列中出现特定的序列结束词元“<eos>”。本节将首先介绍贪心搜索(greedy search)策略,并探讨其存在的问题,然后对比其他替代策略:穷举搜索(exhaustive search)和束搜索(beam search)。
在正式介绍贪心搜索之前,使用与上节中相同的数学符号定义搜索问题。在任意时间步$t’$,解码器输出$y_{t’}$的概率取决于时间步$t’$之前的输出子序列$y_1, \ldots, y_{t’-1}$和对输入序列的信息进行编码得到的上下文变量$\mathbf{c}$。为了量化计算代价,用$\mathcal{Y}$表示输出词表,其中包含“<eos>”,所以这个词汇集合的基数$\left|\mathcal{Y}\right|$就是词表的大小。再将输出序列的最大词元数指定为$T’$。则我们的目标是从所有$\mathcal{O}(\left|\mathcal{Y}\right|^{T’})$个可能的输出序列中寻找理想的输出。这种计算方式略微高估了可能输出的数量,因为对于所有输出序列,在“<eos>”之后的部分将在实际输出中丢弃。但大体上这个数字反应了搜索空间的大小。
8.1. 贪心搜索
首先看一个简单的策略:贪心搜索,该策略已用于上节的序列预测。对于输出序列的每一时间步$t’$,我们都将基于贪心搜索从$\mathcal{Y}$中找到具有最高条件概率的词元,即:
一旦输出序列包含了“<eos>”或者达到其最大长度$T’$,则输出完成。
如图,假设输出中有四个词元“A”“B”“C”和“<eos>”。每个时间步下的四个数字分别表示在该时间步生成“A”“B”“C”和“<eos>”的条件概率。在每个时间步,贪心搜索选择具有最高条件概率的词元。因此图中预测输出序列为“A”“B”“C”和“<eos>”。这个输出序列的条件概率是$0.5\times0.4\times0.4\times0.6 = 0.048$。
那么贪心搜索存在的问题是什么呢?现实中,最优序列(optimal sequence)应该是最大化$\prod_{t’=1}^{T’} P(y_{t’} \mid y_1, \ldots, y_{t’-1}, \mathbf{c})$值的输出序列,这是基于输入序列生成输出序列的条件概率。贪心搜索无法保证得到最优序列。
上图中的另一个例子阐述了这个问题。与第一种情况不同,在时间步$2$中,我们选择词元“C”,它具有第二高的条件概率。由于时间步$3$所基于的时间步$1$和$2$处的输出子序列已从 第一种情况中的“A”和“B”改变为上图中的“A”和“C”,因此时间步$3$处的每个词元的条件概率也在上图中改变。假设我们在时间步$3$选择词元“B”,于是当前的时间步$4$基于前三个时间步的输出子序列“A”“C”和“B”为条件,这与第一种情况中的“A”“B”和“C”不同。此时上图中的时间步$4$生成每个词元的条件概率也不同于第一种情况中的条件概率。结果,上图中的输出序列“A”“C”“B”和“<eos>”的条件概率为$0.5\times0.3 \times0.6\times0.6=0.054$,大于第一种情况中的贪心搜索的条件概率。这个例子说明:贪心搜索获得的输出序列“A”“B”“C”和“<eos>”不一定是最佳序列。
8.2. 穷举搜索
如果目标是获得最优序列,可以考虑使用穷举搜索(exhaustive search):穷举地列举所有可能的输出序列及其条件概率,然后计算输出条件概率最高的一个。
虽然我们可以使用穷举搜索来获得最优序列,但其计算量$\mathcal{O}(\left|\mathcal{Y}\right|^{T’})$高的惊人。例如,当$|\mathcal{Y}|=10000$和$T’=10$时,我们需要评估$10000^{10} = 10^{40}$序列,这是一个极大的数,现有的计算机几乎不可能计算它。然而贪心搜索的计算量$\mathcal{O}(\left|\mathcal{Y}\right|T’)$要显著地小于穷举搜索。例如,当$|\mathcal{Y}|=10000$和$T’=10$时,我们只需要评估$10000\times10=10^5$个序列。
8.3. 束搜索
那么该选取哪种序列搜索策略呢?如果精度最重要,则显然是穷举搜索。如果计算成本最重要,则显然是贪心搜索。而束搜索的实际应用则介于这两个极端之间。
束搜索(beam search)是贪心搜索的一个改进版本。它有一个超参数,名为束宽(beam size)$k$。在时间步$1$,我们选择具有最高条件概率的$k$个词元。这$k$个词元将分别是$k$个候选输出序列的第一个词元。在随后的每个时间步,基于上一时间步的$k$个候选输出序列,我们将继续从$k\left|\mathcal{Y}\right|$个可能的选择中挑出具有最高条件概率的$k$个候选输出序列。
上图演示了束搜索的过程。假设输出的词表只包含五个元素:$\mathcal{Y} = {A, B, C, D, E}$,其中有一个是“<eos>”。设置束宽为$2$,输出序列的最大长度为$3$。在时间步$1$,假设具有最高条件概率$P(y_1 \mid \mathbf{c})$的词元是$A$和$C$。在时间步$2$,我们计算所有$y_2 \in \mathcal{Y}$为:
从这十个值中选择最大的两个,比如$P(A, B \mid \mathbf{c})$和$P(C, E \mid \mathbf{c})$。然后在时间步$3$,我们计算所有$y_3 \in \mathcal{Y}$为:
从这十个值中选择最大的两个,即$P(A, B, D \mid \mathbf{c})$和$P(C, E, D \mid \mathbf{c})$,我们会得到六个候选输出序列:
(1)$A$;(2)$C$;(3)$A,B$;(4)$C,E$;(5)$A,B,D$;(6)$C,E,D$。
最后,基于这六个序列(例如,丢弃包括“<eos>”和之后的部分),我们获得最终候选输出序列集合。然后我们选择其中条件概率乘积最高的序列作为输出序列:
其中$L$是最终候选序列的长度,$\alpha$通常设置为$0.75$。因为一个较长的序列在上式的求和中会有更多的对数项,因此分母中的$L^\alpha$用于惩罚长序列。
束搜索的计算量为$\mathcal{O}(k\left|\mathcal{Y}\right|T’)$,这个结果介于贪心搜索和穷举搜索之间。实际上,贪心搜索可以看作一种束宽为$1$的特殊类型的束搜索。通过灵活地选择束宽,束搜索可以在正确率和计算代价之间进行权衡。