TensorFlow 自然语言处理第二版(三)

原文:annas-archive.org/md5/fc366c3f6d3023ea2889a905de68763e

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:理解长短期记忆网络

本章将讨论一种更高级 RNN 变体背后的基本原理,这种变体被称为长短期记忆网络LSTMs)。在这里,我们将专注于理解 LSTM 背后的理论,以便在下一章讨论它们的实现。LSTM 广泛应用于许多顺序任务(包括股市预测、语言建模和机器翻译),并且已被证明在大量数据的支持下,比旧的顺序模型(如标准 RNN)表现更好。LSTM 旨在避免我们在上一章讨论的梯度消失问题。

梯度消失带来的主要实际限制是,它阻止了模型学习长期依赖关系。然而,通过避免梯度消失问题,LSTM 能够存储比普通 RNN 更长时间的记忆(可达数百个时间步)。与只维持单一隐藏状态的 RNN 不同,LSTM 拥有更多的参数,并能更好地控制在每个训练步骤中应该存储哪些记忆、丢弃哪些记忆。例如,RNN 无法决定存储哪些记忆以及丢弃哪些记忆,因为隐藏状态在每个训练步骤都会被强制更新。

具体来说,我们将从一个非常高层次的角度讨论 LSTM 是什么,以及 LSTM 的功能如何使其能够存储长期依赖关系。然后,我们将深入探讨 LSTM 背后的实际数学框架,并通过一个例子来强调每个计算的重要性。我们还将比较 LSTM 和普通 RNN,看到 LSTM 拥有一个更加复杂的架构,使其在顺序任务中超越普通 RNN。

通过回顾梯度消失问题,并通过一个示例来说明这一问题,我们将理解 LSTM 是如何解决该问题的。

此后,我们将讨论为提高标准 LSTM 预测结果而引入的几种技术(例如,在文本生成任务中提高生成文本的质量/多样性)。例如,一次生成多个预测,而不是逐个预测,可以帮助提高生成预测的质量。我们还将介绍双向 LSTM(BiLSTMs),这是标准 LSTM 的扩展,它比标准 LSTM 在捕捉序列中的模式方面具有更强的能力。

最后,我们将讨论两个近期的 LSTM 变种。首先,我们将介绍窥视孔连接,它向 LSTM 门引入了更多的参数和信息,从而使 LSTM 能够更好地执行任务。接下来,我们将讨论门控循环单元GRUs),由于其结构比标准 LSTM 更简单,并且不会降低性能,GRUs 正变得越来越受欢迎。

具体来说,本章将涵盖以下主要主题:

  • 理解长短期记忆网络

  • LSTM 如何解决梯度消失问题

  • 改进 LSTM

  • LSTM 的其他变种

Transformer 模型已成为一种更强大的序列学习替代方案。Transformer 模型提供了更好的性能,因为这些模型可以在给定的步骤访问序列的完整历史,而 LSTM 模型只能看到给定步骤的前一个输出。我们将在第十章《Transformers》和第十一章《使用 Transformer 进行图像描述》中详细讨论 Transformer 模型。然而,学习 LSTM 仍然值得,因为它们为下一代模型(如 Transformer)奠定了基础。此外,LSTM 在某些情况下仍被使用,尤其是在内存受限环境中的时间序列问题中。

理解长短期记忆网络

在本节中,我们将首先解释 LSTM 单元是如何工作的。我们将看到,除了隐藏状态外,还存在一个门控机制来控制单元内的信息流动。

然后,我们将通过一个详细的例子来演示,看看门控和状态如何在例子中的不同阶段帮助实现期望的行为,最终得到期望的输出。最后,我们将对比 LSTM 与标准 RNN,了解 LSTM 与标准 RNN 的区别。

什么是 LSTM?

LSTM 可以看作是 RNN 家族中更复杂、更强大的成员。尽管 LSTM 是一个复杂的系统,但 LSTM 的基本原理与 RNN 相同;它们通过按顺序处理每次输入的序列项来处理序列。LSTM 主要由五个不同的组件组成:

  • 单元状态:这是 LSTM 单元的内部单元状态(即记忆)

  • 隐藏状态:这是暴露给其他层并用于计算预测的外部隐藏状态

  • 输入门:它决定当前输入有多少被读取到单元状态中

  • 遗忘门:它决定之前的单元状态有多少被传递到当前单元状态中

  • 输出门:它决定有多少单元状态被输出到隐藏状态中

我们可以将 RNN 包装成一个单元架构,如下所示:该单元会输出某些状态(带有非线性激活函数),该状态依赖于之前的单元状态和当前输入。然而,在 RNN 中,单元状态会随着每一个输入的到来不断更新。这种行为对于存储长期依赖关系来说是非常不理想的。

LSTM 可以决定何时添加、更新或忘记存储在每个神经元中的信息。换句话说,LSTM 配备了一种机制,可以保持单元状态不变(如果有助于更好的性能),从而使它们能够存储长期依赖关系。

这是通过引入门控机制来实现的。LSTM 为单元需要执行的每个操作配备了门控。门控是连续的(通常是 sigmoid 函数),其值介于 0 和 1 之间,其中 0 表示没有信息流经该门,1 表示所有信息都流经该门。每个 LSTM 单元使用一个这样的门控来控制每个神经元。正如在介绍中所解释的,这些门控控制以下内容:

  • 当前输入写入单元状态的多少(输入门)

  • 从上一个单元状态中忘记了多少信息(遗忘门)

  • 从单元状态输出到最终隐藏状态的信息量(输出门)

图 7.1 说明了一个假设场景中的这一功能。每个门决定了各种数据(例如当前输入、上一个隐藏状态或上一个单元状态)流入状态的多少(即最终的隐藏状态或单元状态)。每条线的粗细表示从/到该门的信息流量(在某些假设场景中)。例如,在此图中,你可以看到输入门允许从当前输入流入的信息比从上一个最终隐藏状态流入的信息更多,而遗忘门则允许从上一个最终隐藏状态流入的信息比从当前输入流入的信息更多:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_01.png

图 7.1:LSTM 中数据流的抽象视图

更详细的 LSTM

在这里,我们将介绍 LSTM 的实际机制。我们将首先简要讨论 LSTM 单元的整体视图,然后开始讨论 LSTM 单元中各个计算的细节,并结合一个文本生成的示例。

正如我们之前讨论的,LSTM 具有由以下三种门控组成的门控机制:

  • 输入门:一个门,它输出的值介于 0(当前输入不会写入单元状态)和 1(当前输入完全写入单元状态)之间。使用 sigmoid 激活函数将输出压缩到 0 和 1 之间。

  • 遗忘门:一个 sigmoid 门,它输出的值介于 0(上一个单元状态在计算当前单元状态时完全被遗忘)和 1(上一个单元状态在计算当前单元状态时完全被读取)之间。

  • 输出门:一个 sigmoid 门,它输出的值介于 0(当前单元状态在计算最终状态时完全被丢弃)和 1(当前单元状态在计算最终隐藏状态时完全被使用)之间。

这可以通过 图 7.2 展示。这是一个非常高层次的图示,为了避免杂乱,一些细节被省略了。我们展示了带环路和不带环路的 LSTM,以便于理解。右侧的图显示了一个带环路的 LSTM,左侧的图则展示了相同的 LSTM,但环路已经展开,以便模型中没有环路:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_02.png

图 7.2:带有递归链接(即,循环)的 LSTM(右)和展开的递归链接的 LSTM(左)

现在,为了更好地理解 LSTM,让我们考虑一个语言建模的例子。我们将并排讨论实际的更新规则和方程式,以便更好地理解 LSTM。

让我们考虑一个从以下句子开始生成文本的例子:

John 给 Mary 一只小狗。

我们输出的故事应该是关于 JohnMarypuppy 的。假设我们的 LSTM 在给定句子后输出两个句子:

John 给 Mary 一只小狗。____________________. _____________________.

以下是我们 LSTM 输出的结果:

John 给 Mary 一只小狗。它非常大声地叫。它们给它取名为 Luna。

我们离输出像这样的真实短语还远远不够。然而,LSTM 可以学习名词和代词之间的关系。例如,itpuppy 相关,theyJohnMary 相关。接下来,它应该学习名词/代词和动词之间的关系。例如,对于 it,动词的末尾应该加上 s。我们在图 7.3中展示了这些关系/依赖关系。正如我们所看到的,短期(例如,It --> barks)和长期(例如,Luna --> puppy)的依赖关系都存在于这个短语中。实线箭头表示名词与代词之间的联系,虚线箭头表示名词/代词与动词之间的联系:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_03.png

图 7.3:LSTM 给出的句子和预测的句子,其中单词之间的各种关系被高亮显示

现在让我们考虑 LSTM 如何通过其各种操作,建模这些关系和依赖,以便在给定起始句子的情况下输出合理的文本。

输入门 (i[t]) 接收当前输入 (x[t]) 和上一个最终隐藏状态 (h[t-1]) 作为输入,并计算 i[t],计算方式如下:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_001.png

输入门 i[t] 可以理解为在标准的单隐藏层 RNN 的隐藏层中执行的计算,该 RNN 使用的是 sigmoid 激活函数。记住我们是通过以下方式计算标准 RNN 的隐藏状态的:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_002.png

因此,LSTM 的 i[t] 计算与标准 RNN 的 h[t] 计算非常相似,唯一的区别在于激活函数的变化和添加了偏置项。

经过计算,i[t]的值为 0 意味着当前输入的任何信息都不会流入单元状态,而值为 1 则意味着所有当前输入的信息都会流入单元状态。

接下来,计算另一个值(称为候选值),该值将被用于后续计算当前单元状态。这个值将被视为当前时间步长最终单元状态的潜在候选值:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_003.png

我们可以在图 7.4中可视化这些计算:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_04.png

图 7.4:i[t] 和 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_004.png(加粗)在所有 LSTM 计算(灰色部分)上下文中的计算

在我们的例子中,在学习的最初阶段,输入门需要高度激活,因为模型对任务没有任何先验知识。LSTM 输出的第一个词是。此外,为了做到这一点,LSTM 必须学会puppy也可以称作。假设我们的 LSTM 有五个神经元来存储状态。我们希望 LSTM 存储的信息是指的是puppy。我们希望 LSTM 学习的另一个信息(在不同的神经元中)是,当使用代词时,动词的现在时应加上*‘s’*。

LSTM 还需要知道的一件事是puppy barks loud图 7.5展示了这条知识如何可能被编码到 LSTM 的单元状态中。每个圆圈代表单元状态中的一个神经元(即一个隐藏单元):

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_05.png

图 7.5:应编码到单元状态中以输出第一个句子的知识

有了这些信息,我们可以输出第一个新的句子:

约翰给了玛丽一只小狗。它叫得非常大声。

接下来,忘记门的计算如下:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_005.png

忘记门的作用如下。忘记门的值为 0 意味着* c [t-1]中的信息不会传递到计算 c [t],而值为 1 则意味着 c *[t-1]的所有信息都会传递到 c [t]的计算中。这可能听起来有些反直觉,因为打开忘记门会让模型记住前一步的内容,反之亦然。但为了尊重原始命名规范和设计,我们将继续使用它们。

现在我们将看到忘记门如何帮助预测下一句话:

他们把它命名为 Luna。

如你所见,我们现在关注的新的关系是约翰玛丽以及他们之间的关系。因此,我们不再需要关于的信息,也不再需要动词bark的行为,因为主语是约翰玛丽。我们可以结合当前的主语他们和相应的动词命名来替代存储在当前主语当前主语动词神经元中的信息(见图 7.6):

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_06.png

图 7.6:第三个神经元(从左数)的知识(it --> barks)被新信息(they --> named)替代

就权重值而言,我们在图 7.7中展示了这种转化。我们不会改变保持it --> puppy关系的神经元的状态,因为puppy在最后一句话中作为一个对象出现。这是通过将连接it --> puppy的权重从c[t-1]到c[t]设置为 1 来完成的。然后我们将保持当前主语和动词信息的神经元替换为新的主语和动词。这是通过将该神经元的forget权重f[t]设置为 0 来实现的。接着,我们将连接当前主语和动词到相应状态神经元的i[t]权重设置为 1。我们可以将https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_004.png(候选值)看作是单元记忆的潜在候选者,因为它包含了来自当前输入x[t]的信息:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_07.png

图 7.7:如何使用前一个状态 c[t-1]和候选值https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_004.png来计算单元状态 c[t]

当前的单元状态将如下更新:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_008.png

换句话说,当前状态是以下内容的组合:

  • 忘记/记住来自前一个单元状态的信息

  • 添加/丢弃当前输入的信息

接下来,在图 7.8中,我们突出显示了到目前为止我们所计算的内容,涉及 LSTM 内部进行的所有计算:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_08.png

图 7.8:到目前为止的计算,包括 i[t]、f[t]、https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_004.png 和 c[t]

学习完整的单元状态后,它将像图 7.9那样:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_09.png

图 7.9:输出两句话后,完整的单元状态将如下所示

接下来,我们将看看 LSTM 单元的最终状态(h[t])是如何计算的:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_010.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_011.png

在我们的示例中,我们希望输出以下句子:

他们给它取名为 Luna。

对于这个,我们不需要倒数第二个神经元来计算这个句子,因为它包含了关于小狗叫声的信息,而这个句子是关于小狗的名字。因此,在预测最后一句话时,我们可以忽略这个神经元(包含叫声 -> 大声关系)。这正是o[t]所做的;它忽略了不必要的记忆,并且在计算 LSTM 单元的最终输出时,只从单元状态中提取相关的记忆。同时,在图 7.10中,我们展示了完整的 LSTM 单元的概览:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_10.png

图 7.10:完整的 LSTM 单元结构

在这里,我们总结了与 LSTM 单元内操作相关的所有方程式:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_001.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_005.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_003.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_008.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_010.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_011.png

现在从更大的角度来看,对于一个序列学习问题,我们可以将 LSTM 单元在时间上展开,显示它们如何相互连接,以便接收细胞的前一个状态来计算下一个状态,如图 7.11所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_11.png

图 7.11:LSTM 如何在时间上连接

然而,这还不足以完成一些有用的任务。我们通常使用机器学习模型来解决形式化为分类或回归问题的任务。正如你所看到的,我们仍然没有输出层来输出预测。但是,如果我们想要使用 LSTM 实际学到的东西,我们需要一种方法来从 LSTM 中提取最终的输出。因此,我们将在 LSTM 上方安装一个softmax层(带有权重W[s]和偏置b[s])。最终输出是通过以下公式获得的:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_018.png

现在,带有 softmax 层的 LSTM 的最终图像看起来像图 7.12

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_12.png

图 7.12:带有 softmax 输出层的 LSTM 在时间上连接

在 LSTM 上附加 softmax 头后,它现在可以执行给定的分类任务,并且能够端到端地完成。现在,让我们比较和对比 LSTM 和上一章讨论的标准 RNN 模型。

LSTM 与标准 RNN 的区别

现在让我们研究 LSTM 与标准 RNN 的比较。与标准 RNN 相比,LSTM 具有更复杂的结构。一个主要的区别是,LSTM 有两个不同的状态:细胞状态c[t]和最终隐藏状态h[t]。然而,RNN 只有一个隐藏状态h[t]。下一个主要区别是,由于 LSTM 有三个不同的门,LSTM 对如何在计算最终隐藏状态h[t]时处理当前输入和前一个细胞状态具有更大的控制权。

拥有这两个不同的状态是非常有优势的。通过这种机制,我们可以将模型的短期记忆和长期记忆解耦。换句话说,即使细胞状态在快速变化,最终的隐藏状态仍然会更慢地变化。所以,尽管细胞状态在学习短期和长期依赖关系,但最终的隐藏状态可以仅反映短期依赖、仅反映长期依赖,或者同时反映两者。

接下来,门控机制由三个门组成:输入门、遗忘门和输出门。

很明显,这是一种更加有原则的方法(特别是与标准 RNN 相比),它允许更好地控制当前输入和前一个细胞状态在当前细胞状态中的贡献。此外,输出门可以更好地控制细胞状态对最终隐藏状态的贡献。

图 7.13中,我们比较了标准 RNN 和 LSTM 的示意图,以强调这两种模型在功能上的区别:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_13.png

图 7.13:标准 RNN 与 LSTM 单元的并排比较

总结来说,通过设计保持两种不同状态,LSTM 可以学习短期和长期的依赖关系,这有助于解决我们将在下一节讨论的梯度消失问题。

LSTM 如何解决梯度消失问题

正如我们之前讨论的,尽管 RNN 从理论上是合理的,但在实践中它们存在一个严重缺陷。也就是说,当使用时间反向传播BPTT)时,梯度会迅速衰减,这使得我们只能传播几个时间步的信息。因此,我们只能存储非常少的时间步信息,从而只有短期记忆。这反过来限制了 RNN 在实际序列任务中的使用。

通常,有用且有趣的序列任务(例如股票市场预测或语言建模)需要能够学习和存储长期依赖关系。考虑以下预测下一个单词的例子:

约翰是一个有天赋的学生。他是一个 A 等生,且会打橄榄球和板球。其他所有学生都羡慕 ______。

对我们来说,这是一个非常简单的任务。答案是约翰。然而,对于 RNN 来说,这是一个困难的任务。我们正在尝试预测一个位于文本开头的答案。而且,为了解决这个任务,我们需要一种方法在 RNN 的状态中存储长期依赖关系。这正是 LSTM 设计用来解决的任务。

第六章递归神经网络中,我们讨论了在没有任何非线性函数存在的情况下,梯度消失/爆炸是如何出现的。现在我们将看到,即使有非线性项存在,梯度消失问题仍然可能发生。为此,我们将推导出标准 RNN 的项!和 LSTM 网络的项!,以理解它们之间的差异。这是导致梯度消失的关键项,正如我们在上一章所学的那样。

假设标准 RNN 的隐藏状态计算如下:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_021.png

为了简化计算,我们可以忽略当前输入相关的项,专注于递归部分,这将给出以下方程:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_022.png

如果我们计算前面方程的https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_019.png,我们将得到以下结果:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_024.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_025.png

现在让我们看看当https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_026.pnghttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_027.png(随着学习的进行,这将发生)时会发生什么。在这两种情况下,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_019.png将开始趋近于 0,从而产生消失梯度。即使在https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_029.png时,对于 sigmoid 激活函数,梯度在最大值(0.25)下,经过多次时间步长的乘积,整体梯度变得非常小。此外,项https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_030.png(可能由于初始化不当)也可能导致梯度爆炸或消失。然而,与由于https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_026.pnghttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_027.png导致的梯度消失相比,项https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_030.png所导致的梯度消失/爆炸相对较容易解决(通过仔细初始化权重和梯度裁剪)。

现在让我们来看看 LSTM 单元。更具体地,我们将查看由以下方程给出的单元状态:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_008.png

这是 LSTM 中所有忘记门应用的乘积。然而,如果你以类似的方式计算 LSTM 中的https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_020.png(也就是说,忽略https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_036.png项和* b *[f],因为它们是非递归的),我们得到以下结果:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_037.png

在这种情况下,尽管当https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_038.png时梯度会消失,另一方面,如果https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_039.png,则导数将比标准 RNN 中的下降速度慢得多。因此,我们有一个替代方法,在这种方法下梯度不会消失。此外,随着压缩函数的使用,梯度不会由于https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_020.png过大而爆炸(这通常是导致梯度爆炸的原因)。此外,当https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_027.png时,我们获得一个接近 1 的最大梯度,这意味着梯度不会像我们在 RNN 中看到的那样迅速减小(当梯度处于最大值时)。最后,推导中没有https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_030.png这样的项。然而,对于https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_043.png的推导更加棘手。让我们看看在https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_043.png的推导中是否存在这样的项。如果你计算这个的导数,你将得到以下形式的结果:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_045.png

一旦你解决了这个问题,你将得到以下形式的结果:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_046.png

我们不关心https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_047.pnghttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_048.png中的内容,因为无论其值如何,它都将被限制在(0,1)或(-1,1)之间。如果我们通过将https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_047.pnghttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_050.pnghttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_051.pnghttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_052.png项替换为公共符号,如https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_053.png,我们得到以下形式:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_054.png

或者,我们得到以下结果(假设外部https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_055.png被每个https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_055.png项吸收,这些项存在于方括号内):

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_057.png

这将给出以下结果:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_058.png

这意味着,尽管术语https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_020.png安全地避免了任何https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_030.png术语,但https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_019.png却不是。因此,我们在初始化 LSTM 的权重时必须小心,并且应该使用梯度裁剪。

注意

然而,h[t]对于 LSTM 来说并不像 RNN 那样由于梯度消失而不安全,因为c[t]仍然可以存储长期依赖性,而不受梯度消失的影响,并且h[t]如果需要的话可以从c[t]中检索长期依赖性。

改进 LSTM

拥有一个基于坚实基础的模型并不总能在实际应用中保证切实的成功。自然语言非常复杂。有时经验丰富的作家也难以创作出高质量的内容。因此,我们不能指望 LSTM 突然间就能神奇地输出有意义、写得很好的内容。拥有一个复杂的设计——使得能够更好地建模数据中的长期依赖性——确实有帮助,但我们仍然需要在推理过程中使用更多的技术来生成更好的文本。因此,已经开发出了许多扩展,以帮助 LSTM 在预测阶段表现得更好。这里我们将讨论几种改进方法:贪心采样、束搜索、使用词向量代替词的独热编码表示、以及使用双向 LSTM。需要注意的是,这些优化技术并非专门针对 LSTM 的;任何序列模型都可以从中受益。

贪心采样

如果我们总是尝试预测概率最高的单词,LSTM 往往会产生非常单调的结果。例如,由于停用词(例如the)的频繁出现,它可能会在切换到另一个单词之前重复这些停用词很多次。

解决这个问题的一种方法是使用贪心采样,即我们选择预测出的最佳n并从该集合中进行采样。这有助于打破预测的单调性。

让我们考虑前一个例子中的第一句话:

约翰给玛丽一只小狗。

假设我们从第一个单词开始,并希望预测接下来的四个单词:

约翰 ____ ____ _ _____。

如果我们尝试以确定性方式选择样本,LSTM 可能会输出如下内容:

约翰给玛丽给约翰。

然而,通过从词汇表中的子集(最有可能的词)中采样下一个单词,LSTM 被迫变化预测,可能会输出以下内容:

约翰给玛丽一只小狗。

或者,它可能会给出以下输出:

约翰给小狗了一只小狗。

然而,尽管贪心采样有助于为生成的文本增加更多的风味/多样性,但这种方法并不能保证输出的内容始终是现实的,尤其是在输出较长的文本序列时。现在,我们将看到一种更好的搜索技术,它实际上会在做出预测之前向前看几个步骤。

束搜索

束搜索是一种帮助提高 LSTM 生成的预测质量的方法。在这个过程中,预测是通过解决一个搜索问题来找到的。特别地,我们在每一步为多个候选词预测多个步骤。这就产生了一个树状结构,其中包含单词的候选序列(图 7.14)。束搜索的关键思想是一次生成b个输出(即 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_062.png),而不是生成单一的输出y[t]。这里,b 被称为束的长度,生成的b个输出被称为。更技术上来说,我们选择具有最高联合概率 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_063.png 的束,而不是选择具有最高概率的 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_064.png。我们在做出预测之前向未来预测得更远,这通常会导致更好的结果。

让我们通过前面的示例来理解束搜索:

John gave Mary a puppy.

比如,我们逐词预测,最初我们有以下内容:

John ____ ____ _ _____.

假设我们的 LSTM 通过束搜索生成了示例句子。那么每个单词的概率可能如下所示,如图 7.14所示。假设束长b = 2,我们将在搜索的每个阶段考虑n = 3个最佳候选词。

搜索树看起来如下图所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_14.png

图 7.14:束搜索的搜索空间,b=2,n=3

我们从单词John开始,并获取词汇表中所有单词的概率。在我们的示例中,由于n = 3,我们为树的下一层选择最佳的三个候选词:gaveMarypuppy。(请注意,这些可能不是实际 LSTM 找到的候选词,仅用于示例。)然后从这些选定的候选词中,树的下一层会继续扩展。接着,我们将从中选出最好的三个候选词,搜索会重复,直到我们达到树的深度b

给出最高联合概率的路径(即 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_065.png)用较粗的箭头突出显示。此外,这是一种更好的预测机制,因为它会为像John gave Mary这样的短语返回更高的概率或奖励,而不是John Mary JohnJohn John gave

请注意,在我们的示例中,通过贪婪采样和束搜索生成的输出是相同的,这是一个包含五个单词的简单句子。然而,当我们将其扩展到输出一小段文章时,情况就不一样了。那时,束搜索生成的结果将比贪婪采样生成的结果更具现实性和意义。

使用词向量

提高 LSTM 性能的另一种流行方法是使用词向量,而不是使用独热编码向量作为 LSTM 的输入。我们通过一个例子来理解这种方法的价值。假设我们想要从某个随机词开始生成文本。在我们的案例中,它将是以下内容:

约翰 ____ ____ _ _____.

我们已经在以下句子上训练过我们的 LSTM:

约翰给了玛丽一只小狗。玛丽给鲍勃送了一只小猫。

假设我们有如图 7.15所示的位置的词向量。记住,语义相似的词会有相近的词向量:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_15.png

图 7.15:假定词向量在二维空间中的拓扑

这些词的词嵌入,在其数字形式下,可能看起来像如下:

小猫: [0.5, 0.3, 0.2]

小狗: [0.49, 0.31, 0.25]

给: [0.1, 0.8, 0.9]

可以看出,distance(小猫, 小狗) < distance(小猫, 给)。然而,如果我们使用独热编码,它们将变成如下:

小猫: [ 1, 0, 0, …]

小狗: [0, 1, 0, …]

给: [0, 0, 1, …]

然后,distance(小猫, 小狗) = distance(小猫, 给)。正如我们已经看到的,独热编码向量不能捕捉词与词之间的适当关系,它们将所有词视为相等的距离。然而,词向量能够捕捉这些关系,更适合用于机器学习模型中的文本表示。

使用词向量,LSTM 将更好地学习词与词之间的关系。例如,使用词向量时,LSTM 将学到以下内容:

约翰给了玛丽一只小猫。

这与以下内容非常接近:

约翰给了玛丽一只小狗。

此外,它与以下内容有很大的不同:

约翰给了玛丽一个给。

然而,如果使用独热编码向量,情况就不一样了。

双向 LSTM(BiLSTM)

使 LSTM 变为双向 LSTM 是提高 LSTM 预测质量的另一种方法。这里的意思是用从开始到结束和从结束到开始的文本来训练 LSTM。到目前为止,在训练 LSTM 时,我们将创建如下的数据集:

考虑以下两个句子:

约翰给了玛丽一个 _____. 它叫得非常大声。

在这个阶段,有一个句子中缺失了数据,我们希望 LSTM 能合理地填充这个缺失部分。

如果我们从句子的开头读到缺失单词,它将是如下:

约翰给了玛丽一个 _____.

这并没有提供足够的信息来确定缺失单词的上下文。然而,如果我们从两个方向阅读,它将变成以下内容:

约翰给了玛丽一个 _____.

_____. 它叫得非常大声。

如果我们同时创建了这两部分数据,那么可以预测缺失的单词应该是像dogpuppy这样的词。因此,某些问题可以从双向读取数据中显著受益。BiLSTM 还帮助解决多语言问题,因为不同语言可能有非常不同的句子结构。

BiLSTM 的另一个应用是神经机器翻译,其中我们将源语言的句子翻译成目标语言。由于不同语言之间没有具体的一对一对齐关系,能够访问源语言中给定词汇的前后信息可以极大地帮助更好地理解上下文,从而生成更好的翻译。例如,考虑将菲律宾语翻译成英语。在菲律宾语中,句子的顺序通常是动词-宾语-主语,而在英语中,则是主语-动词-宾语。在这个翻译任务中,前后双向阅读句子将极大地帮助生成良好的翻译。

BiLSTM 本质上是两个独立的 LSTM 网络。一个网络从头到尾学习数据,另一个网络从尾到头学习数据。在图 7.16中,我们展示了 BiLSTM 网络的架构。

训练分为两个阶段。首先,实线网络使用从头到尾读取文本生成的数据进行训练。这个网络代表了标准 LSTM 的常规训练过程。其次,虚线网络使用从后向前读取文本生成的数据进行训练。然后,在推理阶段,我们通过连接实线和虚线的状态信息(并生成一个向量)来预测缺失的单词:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_16.png

图 7.16:双向 LSTM 的示意图

在这一部分,我们讨论了几种不同的方法来提高 LSTM 模型的性能。这包括采用更好的预测策略,引入结构性变化,如词向量和双向 LSTM(BiLSTM)。

LSTM 的其他变体

尽管我们将主要关注标准 LSTM 架构,但许多变体已经出现,它们要么简化了标准 LSTM 中的复杂架构,要么提高了性能,或者两者兼有。我们将探讨两种引入结构性修改的 LSTM 变体:窥视连接(peephole connections)和 GRU。

窥视连接

窥视连接允许门不仅查看当前输入和先前的最终隐藏状态,还可以查看先前的细胞状态。这增加了 LSTM 单元中的权重数量。已经证明,拥有这种连接可以产生更好的结果。方程式将如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_066.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_003.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_068.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_008.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_070.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_011.png

让我们简要看看这如何帮助 LSTM 表现得更好。到目前为止,门控机制只能看到当前输入和最终隐藏状态,但看不到单元状态。然而,在这种配置下,如果输出门接近零,即使单元状态包含对更好性能至关重要的信息,最终的隐藏状态也会接近零。因此,门控机制在计算时不会考虑隐藏状态。直接将单元状态包括在门控计算方程中,可以对单元状态进行更多控制,即使在输出门接近零的情况下,它也能表现良好。

我们在图 7.17中展示了具有窥视连接的 LSTM 架构。我们已将标准 LSTM 中所有现有的连接设为灰色,新增的连接则用黑色表示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_17.png

图 7.17:具有窥视连接的 LSTM(窥视连接用黑色表示,其他连接用灰色表示)

门控递归单元

GRU可以看作是标准 LSTM 架构的简化版。正如我们之前所见,LSTM 有三个不同的门和两个不同的状态。仅这一点就需要大量的参数,即使对于一个较小的状态尺寸来说也是如此。因此,科学家们研究了减少参数数量的方法。GRU 是其中一个成果。

GRU 与 LSTM 相比,有几个主要的区别。

首先,GRU 将两个状态,即单元状态和最终隐藏状态,合并成一个单一的隐藏状态h[t]。现在,由于这个简单的修改没有两个不同的状态,我们可以去除输出门。记住,输出门仅仅是决定有多少单元状态被读取到最终隐藏状态中。这个操作大大减少了单元中的参数数量。

接下来,GRU 引入了一个重置门,当它接近 1 时,在计算当前状态时会完全采纳前一个状态的信息。而当重置门接近 0 时,它会忽略前一个状态,只关注当前状态的计算:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_072.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_073.png

然后,GRU 将输入门和遗忘门合并成一个更新门。标准的 LSTM 有两个门,分别是输入门和遗忘门。输入门决定当前输入有多少被读入到单元状态中,而遗忘门决定前一个单元状态有多少被读入到当前单元状态中。数学上,这可以表示如下:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_001.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_005.png

GRU 将这两种操作合并成一个单一的门控操作,称为更新门。如果更新门为 0,则将前一单元状态的全部状态信息传递到当前单元状态,此时不会将当前输入读入状态。如果更新门为 1,则所有当前输入都会读入当前单元状态,且前一单元状态不会传递到当前单元状态。换句话说,输入门i[t]变成了遗忘门的反向,即!

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_077.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_078.png

现在让我们将所有的公式整理到一起。GRU 的计算过程如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_072.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_073.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_077.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_078.png

这比 LSTM 更加简洁。在图 7.18中,我们可以将 GRU 单元(左)和 LSTM 单元(右)并排展示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_07_18.png

图 7.18:GRU(左)和标准 LSTM(右)的并排比较

在本节中,我们学习了 LSTM 的两种变体:带窥视孔的 LSTM 和 GRU。由于其简洁性以及与更复杂的 LSTM 相当的性能,GRU 已经成为比 LSTM 更受欢迎的选择。

总结

在本章中,你学习了 LSTM 网络。首先,我们讨论了 LSTM 是什么及其高层次的架构。我们还深入探讨了 LSTM 中的详细计算,并通过一个例子讨论了这些计算。

我们看到,LSTM 主要由五个不同的部分组成:

  • 单元状态:LSTM 单元的内部单元状态

  • 隐藏状态:用于计算预测的外部隐藏状态

  • 输入门:决定多少当前输入被读取到单元状态中

  • 遗忘门:决定多少前一单元状态被发送到当前单元状态

  • 输出门:决定多少单元状态被输出到隐藏状态中

拥有如此复杂的结构,使得 LSTM 能够很好地捕捉短期和长期依赖。

我们将 LSTM 与普通 RNN 进行了比较,发现 LSTM 实际上能够学习长期依赖,这是其结构的固有部分,而 RNN 则可能无法学习长期依赖。之后,我们讨论了 LSTM 如何通过其复杂的结构解决消失梯度问题。

然后我们讨论了几种改进 LSTM 性能的扩展。首先是一个非常简单的技术,叫做贪婪采样,在这种方法中,我们并非总是输出最佳候选,而是从一组最佳候选中随机采样一个预测。我们看到这提高了生成文本的多样性。之后,我们看了一个更复杂的搜索技术,叫做束搜索。使用束搜索时,我们不是仅预测单个时间步的未来,而是预测多个时间步的未来,并选择产生最佳联合概率的候选。另一个改进是观察词向量如何帮助提升 LSTM 的预测质量。通过使用词向量,LSTM 能更有效地学习在预测时替换语义相似的词(例如,LSTM 可能会输出 cat 代替 dog),从而使生成的文本更加真实和准确。最后,我们考虑的扩展是双向 LSTM(BiLSTM)。BiLSTM 的一个流行应用是填补短语中的缺失词。BiLSTM 会从两个方向读取文本:从前往后和从后往前。这提供了更多的上下文信息,因为我们在做出预测前,既看到了过去的内容,也看到了未来的内容。

最后,我们讨论了普通 LSTM 的两种变体:窥视孔连接和 GRU。普通 LSTM 在计算门时,只查看当前输入和隐藏状态。而使用窥视孔连接时,门的计算依赖于所有内容:当前输入、隐藏状态和细胞状态。

GRU 是一种比普通 LSTM 更加优雅的变体,它简化了 LSTM,同时没有牺牲性能。GRU 只有两个门和一个状态,而普通的 LSTM 有三个门和两个状态。

在下一章,我们将看到这些不同的架构在实际应用中的表现,展示每种架构的实现,并观察它们在文本生成任务中的表现如何。

要访问本书的代码文件,请访问我们的 GitHub 页面:packt.link/nlpgithub

加入我们的 Discord 社区,与志同道合的人一起学习,和超过 1000 名成员一起进步,访问链接:packt.link/nlp

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/QR_Code5143653472357468031.png

第八章:LSTM 的应用——生成文本

现在,我们已经对 LSTM 的基本机制有了充分的理解,例如它们如何解决梯度消失问题和更新规则,我们可以看看如何在 NLP 任务中使用它们。LSTM 被用于文本生成和图像标题生成等任务。例如,语言建模是任何 NLP 任务的核心,因为有效的语言建模能力直接导致了有效的语言理解。因此,语言建模通常用于预训练下游决策支持 NLP 模型。单独使用时,语言建模可以用于生成歌曲(towardsdatascience.com/generating-drake-rap-lyrics-using-language-models-and-lstms-8725d71b1b12),电影剧本(builtin.com/media-gaming/ai-movie-script)等。

本章将介绍的应用是构建一个能够编写新民间故事的 LSTM。为此任务,我们将下载格林兄弟的一些民间故事的翻译版本。我们将使用这些故事来训练一个 LSTM,并让它输出一个全新的故事。我们将通过将文本拆分为字符级的二元组(n-gram,其中n=2)来处理文本,并用唯一的二元组构建词汇表。请注意,将二元组表示为独热编码向量对机器学习模型来说非常低效,因为它迫使模型将每个二元组视为完全不同于其他二元组的独立文本单元。而二元组之间是共享语义的,某些二元组会共同出现,而有些则不会。独热编码将忽略这一重要属性,这是不理想的。为了在建模中利用这一特性,我们将使用嵌入层,并与模型一起联合训练。

我们还将探索如何实现先前描述的技术,如贪婪采样或束搜索,以提高预测质量。之后,我们将看看如何实现除了标准 LSTM 之外的时间序列模型,如 GRU。

具体来说,本章将涵盖以下主要内容:

  • 我们的数据

  • 实现语言模型

  • 将 LSTM 与带有窥视孔连接的 LSTM 以及 GRU 进行比较

  • 改进序列模型——束搜索

  • 改进 LSTM——用单词而不是 n-gram 生成文本

我们的数据

首先,我们将讨论用于文本生成的数据以及为了清理数据而进行的各种预处理步骤。

关于数据集

首先,我们将了解数据集的样子,以便在看到生成的文本时,能够评估它是否合乎逻辑,基于训练数据。我们将从网站www.cs.cmu.edu/~spok/grimmtmp/下载前 100 本书。这些是格林兄弟的一组书籍的翻译(从德语到英语)。

一开始,我们将通过自动化脚本从网站上下载所有 209 本书,具体如下:

url = 'https://www.cs.cmu.edu/~spok/grimmtmp/'
dir_name = 'data'
def download_data(url, filename, download_dir):
    """Download a file if not present, and make sure it's the right 
    size."""

    # Create directories if doesn't exist
    os.makedirs(download_dir, exist_ok=True)

    # If file doesn't exist download
    if not os.path.exists(os.path.join(download_dir,filename)):
        filepath, _ = urlretrieve(url + filename, 
        os.path.join(download_dir,filename))
    else:
        filepath = os.path.join(download_dir, filename)

    return filepath
# Number of files and their names to download
num_files = 209
filenames = [format(i, '03d')+'.txt' for i in range(1,num_files+1)]
# Download each file
for fn in filenames:
    download_data(url, fn, dir_name)
# Check if all files are downloaded
for i in range(len(filenames)):
    file_exists = os.path.isfile(os.path.join(dir_name,filenames[i]))
    assert file_exists
print('{} files found.'.format(len(filenames))) 

现在我们将展示从两个随机挑选的故事中提取的示例文本。以下是第一个片段:

然后她说,我亲爱的本杰明,你父亲为你和你的十一位兄弟做了这些棺材,因为如果我生了一个小女孩,你们都将被杀死并埋葬在其中。当她说这些话时,她哭了起来,而儿子安慰她说,别哭,亲爱的母亲,我们会自救的,去外面吧。但她说,带着你的十一位兄弟走进森林,让其中一个始终坐在能找到的最高的树上,守望着,朝着城堡中的塔楼看。如果我生了一个小儿子,我会举白旗,然后你们可以回来。但如果我生了一个女孩,我会升起红旗,那个时候你们要尽快逃走,愿上帝保佑你们。

第二段文字如下:

红帽子并不知道自己是多么邪恶的生物,根本不怕他。

“早上好,小红帽。”他说。

“非常感谢,狼。”

“这么早去哪儿,小红帽?”

“去我奶奶家。”

“你围裙里装的是什么?”

“蛋糕和酒。昨天是烘焙日,所以可怜的生病奶奶得吃点好的,增强她的体力。”

“你奶奶住在哪里,小红帽?”

“在森林里再走四分之一里程,过了三棵大橡树,她的房子就在这三棵树下,栗树就在它们下面。你一定知道的。”小红帽回答道。

狼心想,这个小家伙多么温柔。真是一个美味的嫩肉,吃她比吃老太婆要好。

我们现在已经了解了数据的样子。通过这些理解,我们接下来将继续处理我们的数据。

生成训练集、验证集和测试集

我们将把下载的故事分成三个集合:训练集、验证集和测试集。我们将使用每个集合中文件的内容作为训练、验证和测试数据。我们将使用 scikit-learn 的train_test_split()函数来完成这项工作。

from sklearn.model_selection import train_test_split
# Fix the random seed so we get the same output everytime
random_state = 54321
filenames = [os.path.join(dir_name, f) for f in os.listdir(dir_name)]
# First separate train and valid+test data
train_filenames, test_and_valid_filenames = train_test_split(filenames, test_size=0.2, random_state=random_state)
# Separate valid+test data to validation and test data
valid_filenames, test_filenames = train_test_split(test_and_valid_filenames, test_size=0.5, random_state=random_state) 
# Print out the sizes and some sample filenames
for subset_id, subset in zip(('train', 'valid', 'test'), (train_filenames, valid_filenames, test_filenames)):
    print("Got {} files in the {} dataset (e.g. 
    {})".format(len(subset), subset_id, subset[:3])) 

train_test_split()函数接受一个iterable(例如列表、元组、数组等)作为输入,并根据定义的拆分比例将其拆分为两个集合。在此案例中,输入是一个文件名列表,我们首先按 80%-20%的比例拆分为训练数据和[验证 + 测试]数据。然后,我们进一步将test_and_valid_filenames按 50%-50%拆分,生成测试集和验证集。请注意,我们还将一个随机种子传递给train_test_split函数,以确保在多次运行中获得相同的拆分。

这段代码将输出以下文本:

Got 167 files in the train dataset (e.g. ['data\\117.txt', 'data\\133.txt', 'data\\069.txt'])
Got 21 files in the valid dataset (e.g. ['data\\023.txt', 'data\\078.txt', 'data\\176.txt'])
Got 21 files in the test dataset (e.g. ['data\\129.txt', 'data\\207.txt', 'data\\170.txt']) 

我们可以看到,从 209 个文件中,大约 80%的文件被分配为训练数据,10%为验证数据,剩下的 10%为测试数据。

分析词汇量

我们将使用二元组(即n=2的 n-gram)来训练我们的语言模型。也就是说,我们将把故事拆分为两个字符的单元。此外,我们将把所有字符转换为小写,以减少输入的维度。使用字符级的二元组有助于我们使用较小的词汇表进行语言建模,从而加速模型训练。例如:

国王正在森林中打猎。

将被分解为如下的二元组序列:

[‘th’, ‘e ‘, ‘ki’, ‘ng’, ‘ w’, ‘as’, …]

让我们找出词汇表的大小。为此,我们首先定义一个set对象。接下来,我们遍历每个训练文件,读取内容,并将其作为字符串存储在变量 document 中。

最后,我们用包含每个故事的字符串中的所有二元组更新set对象。通过每次遍历字符串两个字符来获取二元组:

bigram_set = set()
# Go through each file in the training set
for fname in train_filenames:
    document = [] # This will hold all the text
    with open(fname, 'r') as f:
        for row in f:
            # Convert text to lower case to reduce input dimensionality
            document.append(row.lower())
        # From the list of text we have, generate one long string 
        # (containing all training stories)
        document = " ".join(document)
        # Update the set with all bigrams found
        bigram_set.update([document[i:i+2] for i in range(0, 
        len(document), 2)])
# Assign to a variable and print 
n_vocab = len(bigram_set)
print("Found {} unique bigrams".format(n_vocab)) 

这将打印:

Found 705 unique bigrams 

我们的词汇表包含 705 个二元组。如果我们决定将每个单词视为一个单元,而不是字符级的二元组,词汇量会更大。

定义 tf.data 管道

我们现在将定义一个完善的数据管道,能够从磁盘读取文件,并将内容转换为可用于训练模型的格式或结构。TensorFlow 中的tf.data API 允许你定义数据管道,可以以特定的方式处理数据,以适应机器学习模型。为此,我们将定义一个名为generate_tf_dataset()的函数,它接受以下内容:

  • filenames – 包含用于模型的文本的文件名列表

  • ngram_width – 要提取的 n-gram 的宽度

  • window_size – 用于生成模型单一数据点的 n-gram 序列的长度

  • batch_size – 批量大小

  • shuffle – (默认为False)是否打乱数据

例如,假设ngram_width为 2,batch_size为 1,window_size为 5。此函数将接受字符串“国王正在森林中打猎”并输出:

Batch 1: ["th", "e ", "ki", " ng", " w"] -> ["e ", "ki", "ng", " w", "as"]
Batch 2: ["as", " h", "un", "ti", "ng"] -> [" h", "un", "ti", "ng", " i"]

每个批次中的左侧列表表示输入序列,右侧列表表示目标序列。注意右侧列表只是将左侧列表向右移了一位。还要注意,两条记录中的输入没有重叠。但在实际的函数中,我们将在记录之间保持小的重叠。图 8.1展示了高级过程:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_08_01.png

图 8.1:我们将使用 tf.data API 实现的数据转换的高级步骤

让我们讨论如何使用 TensorFlow 的tf.data API 实现管道的具体细节。我们将定义生成数据管道的代码作为可重用的函数:

def generate_tf_dataset(filenames, ngram_width, window_size, batch_size, shuffle=False):
    """ Generate batched data from a list of files speficied """
    # Read the data found in the documents
    documents = []
    for f in filenames:
        doc = tf.io.read_file(f)
        doc = tf.strings.ngrams(    # Generate ngrams from the string
            tf.strings.bytes_split(
            # Create a list of chars from a string
                tf.strings.regex_replace(
                # Replace new lines with space
                    tf.strings.lower(    # Convert string to lower case
                        doc
                    ), "\n", " "
                )
            ),
            ngram_width, separator=''
        )
        documents.append(doc.numpy().tolist())

    # documents is a list of list of strings, where each string is a story
    # From that we generate a ragged tensor
    documents = tf.ragged.constant(documents)
    # Create a dataset where each row in the ragged tensor would be a 
    # sample
    doc_dataset = tf.data.Dataset.from_tensor_slices(documents)
    # We need to perform a quick transformation - tf.strings.ngrams 
    # would generate all the ngrams (e.g. abcd -> ab, bc, cd) with
    # overlap, however for our data we do not need the overlap, so we need
    # to skip the overlapping ngrams
    # The following line does that
    doc_dataset = doc_dataset.map(lambda x: x[::ngram_width])
    # Here we are using a window function to generate windows from text
    # For a text sequence with window_size 3 and shift 1 you get
    # e.g. ab, cd, ef, gh, ij, ... -> [ab, cd, ef], [cd, ef, gh], [ef, 
    # gh, ij], ...
    # each of these windows is a single training sequence for our model
    doc_dataset = doc_dataset.flat_map(
        lambda x: tf.data.Dataset.from_tensor_slices(
            x
        ).window(
            size=window_size+1, shift=int(window_size * 0.75)
        ).flat_map(
            lambda window: window.batch(window_size+1, 
            drop_remainder=True)
        )
    )

    # From each windowed sequence we generate input and target tuple
    # e.g. [ab, cd, ef] -> ([ab, cd], [cd, ef])
    doc_dataset = doc_dataset.map(lambda x: (x[:-1], x[1:]))
    # Batch the data
    doc_dataset = doc_dataset.batch(batch_size=batch_size)
    # Shuffle the data if required
    doc_dataset = doc_dataset.shuffle(buffer_size=batch_size*10) if 
    shuffle else doc_dataset

    # Return the data
    return doc_dataset 

现在让我们更详细地讨论上述代码。首先,我们遍历filenames变量中的每个文件,并使用以下方法读取每个文件的内容:

doc = tf.io.read_file(f) 

在读取内容后,我们使用tf.strings.ngrams()函数从中生成 n-gram。然而,该函数需要的是字符列表,而不是字符串。

因此,我们使用tf.strings.bytes_split()函数将字符串转换为字符列表。此外,我们还会执行一些预处理步骤,例如:

  • 使用tf.strings.lower()将文本转换为小写

  • 将换行符(\n)替换为空格,以获得一个连续的词流

每个故事都存储在一个列表对象(documents)中。需要注意的是,tf.strings.ngrams()会为给定的 n-gram 长度生成所有可能的 n-grams。换句话说,连续的 n-grams 会有重叠。例如,序列“国王在打猎”如果 n-gram 长度为 2,将生成["Th", "he", "e ", " k", …]。因此,我们稍后需要额外的处理步骤来去除序列中的重叠 n-grams。在所有 n-grams 读取和处理完成后,我们从文档中创建一个RaggedTensor对象:

documents = tf.ragged.constant(documents) 

RaggedTensor是一个特殊类型的张量,它可以有接受任意大小输入的维度。例如,几乎不可能所有的故事在每个地方都有相同数量的 n-gram,因为它们彼此之间差异很大。在这种情况下,我们将有任意长的 n-gram 序列来表示我们的故事。因此,我们可以使用RaggedTensor来存储这些任意大小的序列。

tf.RaggedTensor对象是一种特殊类型的张量,可以具有可变大小的维度。你可以在www.tensorflow.org/api_docs/python/tf/RaggedTensor上阅读有关 ragged tensor 的更多信息。有许多方法可以定义一个 ragged tensor。

我们可以通过将包含值的嵌套列表传递给tf.ragged.constant()函数来定义一个 ragged tensor:

a = tf.ragged.constant([[1, 2, 3], [1,2], [1]]) 

我们还可以定义一个平坦的值序列,并定义在哪里拆分行:

b = tf.RaggedTensor.from_row_splits([1,2,3,4,5,6,7],
row_splits=[0, 3, 3, 6, 7]) 

在这里,row_splits参数中的每个值定义了结果张量中后续行的结束位置。例如,第一行将包含从索引 0 到 3 的元素(即 0、1、2)。这将输出:

<tf.RaggedTensor [[1, 2, 3], [], [4, 5, 6], [7]]> 

你可以使用b.shape获取张量的形状,它将返回:

[4, None] 

接下来,我们使用tf.data.Dataset.from_tensor_slices()函数从张量创建一个tf.data.Dataset

这个函数简单地生成一个数据集,其中数据集中的单个项将是提供的张量的一行。例如,如果你提供一个形状为[10, 8, 6]的标准张量,它将生成 10 个形状为[8, 6]的样本:

doc_dataset = tf.data.Dataset.from_tensor_slices(documents) 

在这里,我们仅通过每次取序列中的每个n^(th)个 n-gram 来去除重叠的 n-grams:

doc_dataset = doc_dataset.map(lambda x: x[::ngram_width]) 

然后,我们将使用tf.data.Dataset.window()函数从每个故事中创建较短的固定长度窗口序列:

doc_dataset = doc_dataset.flat_map(
    lambda x: tf.data.Dataset.from_tensor_slices(
        x
    ).window(
        size=window_size+1, shift=int(window_size * 0.75)
    ).flat_map(
        lambda window: window.batch(window_size+1, 
        drop_remainder=True)
    )
) 

从每个窗口中,我们生成输入和目标对,如下所示。我们将所有 n-gram(除了最后一个)作为输入,将所有 n-gram(除了第一个)作为目标。这样,在每个时间步,模型将根据所有先前的 n-gram 预测下一个 n-gram。shift 决定了在每次迭代时窗口的移动量。记录之间的一些重叠可以确保模型不会将故事视为独立的窗口,这可能导致性能差。我们将保持两个连续序列之间大约 25%的重叠:

doc_dataset = doc_dataset.map(lambda x: (x[:-1], x[1:])) 

我们使用tf.data.Dataset.shuffle()对数据进行洗牌,并按预定义的批量大小对数据进行分批。请注意,我们需要为shuffle()函数指定buffer_sizebuffer_size决定了洗牌前获取多少数据。你缓存的数据越多,洗牌效果会越好,但内存消耗也会越高:

doc_dataset = doc_dataset.shuffle(buffer_size=batch_size*10) if shuffle else doc_dataset
doc_dataset = doc_dataset.batch(batch_size=batch_size) 

最后,我们指定必要的超参数,并生成三个数据集:训练集、验证集和测试集:

ngram_length = 2
batch_size = 256
window_size = 128
train_ds = generate_tf_dataset(train_filenames, ngram_length, window_size, batch_size, shuffle=True)
valid_ds = generate_tf_dataset(valid_filenames, ngram_length, window_size, batch_size)
test_ds = generate_tf_dataset(test_filenames, ngram_length, window_size, batch_size) 

让我们生成一些数据,并查看这个函数生成的数据:

ds = generate_tf_dataset(train_filenames, 2, window_size=10, batch_size=1).take(5)
for record in ds:
        print(record[0].numpy(), '->', record[1].numpy()) 

这将返回:

[[b'th' b'er' b'e ' b'wa' b's ' b'on' b'ce' b' u' b'po' b'n ']] -> [[b'er' b'e ' b'wa' b's ' b'on' b'ce' b' u' b'po' b'n ' b'a ']]
[[b' u' b'po' b'n ' b'a ' b'ti' b'me' b' a' b' s' b'he' b'ph']] -> [[b'po' b'n ' b'a ' b'ti' b'me' b' a' b' s' b'he' b'ph' b'er']]
[[b' s' b'he' b'ph' b'er' b'd ' b'bo' b'y ' b'wh' b'os' b'e ']] -> [[b'he' b'ph' b'er' b'd ' b'bo' b'y ' b'wh' b'os' b'e ' b'fa']]

在这里,你可以看到目标序列只是将输入序列向右移动一个位置。字符前面的b表示这些字符作为字节存储。接下来,我们将查看如何实现模型。

实现语言模型

在这里,我们将讨论 LSTM 实现的细节。

首先,我们将讨论 LSTM 使用的超参数及其效果。

之后,我们将讨论实现 LSTM 所需的参数(权重和偏置)。然后,我们将讨论这些参数如何用于编写 LSTM 内部发生的操作。接下来,我们将理解如何按顺序将数据传递给 LSTM。接着,我们将讨论如何训练模型。最后,我们将研究如何使用训练好的模型输出预测结果,这些预测结果本质上是 bigrams,最终将构成一个有意义的故事。

定义 TextVectorization 层

我们讨论了TextVectorization层,并在第六章,递归神经网络中使用了它。我们将使用相同的文本向量化机制对文本进行分词。总结来说,TextVectorization层为你提供了一种方便的方式,将文本分词(即将字符串转换为整数 ID 表示的标记列表)集成到模型中作为一个层。

在这里,我们将定义一个TextVectorization层,将 n-gram 序列转换为整数 ID 序列:

import tensorflow.keras.layers as layers
import tensorflow.keras.models as models
# The vectorization layer that will convert string bigrams to IDs
text_vectorizer = tf.keras.layers.TextVectorization(
    max_tokens=n_vocab, standardize=None,
    split=None, input_shape=(window_size,)
) 

请注意,我们正在定义几个重要的参数,例如 max_tokens(词汇表的大小)、standardize 参数(不进行任何文本预处理)、split 参数(不进行任何分割),最后是 input_shape 参数,用于告知该层输入将是一个由 n-gram 序列组成的批次。通过这些参数,我们需要训练文本向量化层,以识别可用的 n-gram 并将其映射到唯一的 ID。我们可以直接将训练好的 tf.data 数据管道传递给该层,让它学习这些 n-gram。

text_vectorizer.adapt(train_ds) 

接下来,让我们打印词汇表中的单词,看看这一层学到了什么:

text_vectorizer.get_vocabulary()[:10] 

它将输出:

['', '[UNK]', 'e ', 'he', ' t', 'th', 'd ', ' a', ', ', ' h'] 

一旦 TextVectorization 层训练完成,我们必须稍微修改我们的训练、验证和测试数据管道。请记住,我们的数据管道将 n-gram 字符串序列作为输入和目标输出。我们需要将目标序列转换为 n-gram ID 序列,以便计算损失。为此,我们只需通过 text_vectorizer 层使用 tf.data.Dataset.map() 功能将数据集中的目标传递给该层:

train_ds = train_ds.map(lambda x, y: (x, text_vectorizer(y)))
valid_ds = valid_ds.map(lambda x, y: (x, text_vectorizer(y))) 

接下来,我们将查看我们将使用的基于 LSTM 的模型。我们将逐一介绍模型的各个组件,如嵌入层、LSTM 层和最终的预测层。

定义 LSTM 模型。

我们将定义一个简单的基于 LSTM 的模型。我们的模型将包含:

  • 之前训练过的 TextVectorization 层。

  • 一个随机初始化并与模型一起训练的嵌入层。

  • 两个 LSTM 层,分别具有 512 和 256 个节点。

  • 一个具有 1024 个节点并使用 ReLU 激活函数的全连接隐藏层。

  • 最终的预测层具有 n_vocab 个节点,并使用 softmax 激活函数。

由于模型的结构非常简单,层是顺序定义的,因此我们将使用 Sequential API 来定义该模型。

import tensorflow.keras.backend as K
K.clear_session()
lm_model = models.Sequential([
    text_vectorizer,
    layers.Embedding(n_vocab+2, 96),
    layers.LSTM(512, return_state=False, return_sequences=True),
    layers.LSTM(256, return_state=False, return_sequences=True),
    layers.Dense(1024, activation='relu'),
    layers.Dropout(0.5),
    layers.Dense(n_vocab, activation='softmax')
]) 

我们从调用 K.clear_session() 开始,这是一个清除当前 TensorFlow 会话的函数(例如,清除已定义的层、变量及其状态)。否则,如果你在笔记本中多次运行,它将创建不必要的层和变量。此外,让我们更详细地查看 LSTM 层的参数:

  • return_state – 将此设置为 False 表示该层仅输出最终输出,而如果设置为 True,则它将返回状态向量以及该层的最终输出。例如,对于一个 LSTM 层,设置 return_state=True 会得到三个输出:最终输出、单元状态和隐藏状态。请注意,在这种情况下,最终输出和隐藏状态将是相同的。

  • return_sequences – 将此设置为 True 将使得该层输出完整的输出序列,而不仅仅是最后一个输出。例如,将其设置为 False 将得到一个大小为 [b, n] 的输出,其中 b 是批次大小,n 是该层中的节点数。如果设置为 True,它将输出一个大小为 [b, t, n] 的输出,其中 t 是时间步数。

你可以通过执行以下命令查看该模型的摘要:

lm_model.summary() 

它返回的结果为:

Model: "sequential"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 text_vectorization (TextVec  multiple                 0         
 torization)                                                     

 embedding (Embedding)       (None, 128, 96)           67872     

 lstm (LSTM)                 (None, 128, 512)          1247232   

 lstm_1 (LSTM)               (None, 128, 256)          787456    

 dense (Dense)               (None, 128, 1024)         263168    

 dropout (Dropout)           (None, 128, 1024)         0         

 dense_1 (Dense)             (None, 128, 705)          722625    

=================================================================
Total params: 3,088,353
Trainable params: 3,088,353
Non-trainable params: 0
_________________________________________________________________ 

接下来,让我们看看可以用来跟踪模型性能的指标,并最终使用适当的损失函数、优化器和指标来编译模型。

定义指标并编译模型

对于我们的语言模型,我们需要定义一个性能指标,用以展示模型的优劣。我们通常看到准确度作为一种通用的评估指标,广泛应用于不同的机器学习任务。然而,准确度可能不适合这个任务,主要是因为它依赖于模型在给定时间步选择与数据集中完全相同的单词/二元组。而语言是复杂的,给定一段文本,生成下一个单词/二元组可能有多种不同的选择。因此,自然语言处理从业者依赖于一个叫做困惑度的指标,它衡量的是模型在看到 1:t二元组后,对下一个t+1 二元组的“困惑”或“惊讶”程度。

困惑度计算很简单。它只是熵的平方。熵是衡量事件的不确定性或随机性的指标。事件结果越不确定,熵值越高(想了解更多关于熵的信息,请访问machinelearningmastery.com/what-is-information-entropy/)。熵的计算公式为:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_08_001.png

在机器学习中,为了优化机器学习模型,我们会衡量给定样本的预测概率分布与目标概率分布之间的差异。为此,我们使用交叉熵,它是熵在两个分布之间的扩展:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_08_002.png

最后,我们定义困惑度为:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_08_003.png

想了解更多关于交叉熵和困惑度之间关系的信息,请访问thegradient.pub/understanding-evaluation-metrics-for-language-models/

在 TensorFlow 中,我们定义了一个自定义的tf.keras.metrics.Metric对象来计算困惑度。我们将使用tf.keras.metrics.Mean作为我们的父类,因为它已经知道如何计算和跟踪给定指标的均值:

class PerplexityMetric(tf.keras.metrics.Mean):
    def __init__(self, name='perplexity', **kwargs):
        super().__init__(name=name, **kwargs)
        self.cross_entropy = 
        tf.keras.losses.SparseCategoricalCrossentropy(
        from_logits=False, reduction='none')
    def _calculate_perplexity(self, real, pred):

        # The next 4 lines zero-out the padding from loss 
        # calculations, this follows the logic from: 
        # https://www.tensorflow.org/beta/tutorials/text/transformer#loss_
        # and_metrics 
        loss_ = self.cross_entropy(real, pred)
        # Calculating the perplexity steps: 
        step1 = K.mean(loss_, axis=-1)
        perplexity = K.exp(step1)
        return perplexity 
    def update_state(self, y_true, y_pred, sample_weight=None):
        perplexity = self._calculate_perplexity(y_true, y_pred)
        super().update_state(perplexity) 

在这里,我们只是为给定批次的预测和目标计算交叉熵损失,然后将其指数化以获得困惑度。接下来,我们将使用以下命令编译我们的模型:

  • 使用稀疏类别交叉熵作为我们的损失函数

  • 使用 Adam 作为我们的优化器

  • 使用准确度和困惑度作为我们的指标

lm_model.compile(loss='sparse_categorical_crossentropy', optimizer='adam', metrics=['accuracy', PerplexityMetric()]) 

在这里,困惑度指标将在模型训练和验证过程中被跟踪并打印出来,类似于准确度指标。

训练模型

现在是训练我们模型的时候了。由于我们已经完成了所有需要的繁重工作(例如读取文件、预处理和转换文本,以及编译模型),我们只需要调用模型的fit()函数:

lm_model.fit(train_ds, validation_data=valid_ds, epochs=60) 

这里我们将 train_ds(训练数据管道)作为第一个参数,将 valid_ds(验证数据管道)作为 validation_data 参数,并设置训练运行 60 个周期。训练完成后,我们通过简单地调用以下代码来评估模型在测试数据集上的表现:

lm_model.evaluate(test_ds) 

这会产生如下输出:

5/5 [==============================] - 0s 45ms/step - loss: 2.4742 - accuracy: 0.3968 - perplexity: 12.3155 

你可能会看到度量有所不同,但它应该大致收敛到相同的值。

定义推理模型

在训练过程中,我们训练了模型并对大双字组序列进行了评估。这对我们有效,因为在训练和评估时,我们可以使用完整的文本。然而,当我们需要生成新文本时,我们无法访问任何现有的内容。因此,我们必须对训练模型进行调整,使其能够从零开始生成文本。

我们通过定义一个递归模型来实现这一点,该模型将当前时间步的模型输出作为下一个时间步的输入。通过这种方式,我们可以无限次地预测单词/双字组。我们提供的初始种子是从语料库中随机选取的单词/双字组(或甚至一组双字组)。

图 8.2 展示了推理模型的工作原理。

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_08_02.png

图 8.2:我们将基于训练模型构建的推理模型的操作视图

我们的推理模型将会更加复杂,因为我们需要设计一个迭代过程,使用先前的预测作为输入生成文本。因此,我们将使用 Keras 的功能性 API 来实现该模型:

# Define inputs to the model
inp = tf.keras.layers.Input(dtype=tf.string, shape=(1,))
inp_state_c_lstm = tf.keras.layers.Input(shape=(512,))
inp_state_h_lstm = tf.keras.layers.Input(shape=(512,))
inp_state_c_lstm_1 = tf.keras.layers.Input(shape=(256,))
inp_state_h_lstm_1 = tf.keras.layers.Input(shape=(256,))
text_vectorized_out = lm_model.get_layer('text_vectorization')(inp)
# Define embedding layer and output
emb_layer = lm_model.get_layer('embedding')
emb_out = emb_layer(text_vectorized_out)
# Defining a LSTM layers and output
lstm_layer = tf.keras.layers.LSTM(512, return_state=True, return_sequences=True)
lstm_out, lstm_state_c, lstm_state_h = lstm_layer(emb_out, initial_state=[inp_state_c_lstm, inp_state_h_lstm])
lstm_1_layer = tf.keras.layers.LSTM(256, return_state=True, return_sequences=True)
lstm_1_out, lstm_1_state_c, lstm_1_state_h = lstm_1_layer(lstm_out, initial_state=[inp_state_c_lstm_1, inp_state_h_lstm_1])
# Defining a Dense layer and output
dense_out = lm_model.get_layer('dense')(lstm_1_out)
# Defining the final Dense layer and output
final_out = lm_model.get_layer('dense_1')(dense_out)
# Copy the weights from the original model
lstm_layer.set_weights(lm_model.get_layer('lstm').get_weights())
lstm_1_layer.set_weights(lm_model.get_layer('lstm_1').get_weights())
# Define final model
infer_model = tf.keras.models.Model(
    inputs=[inp, inp_state_c_lstm, inp_state_h_lstm, 
    inp_state_c_lstm_1, inp_state_h_lstm_1], 
    outputs=[final_out, lstm_state_c, lstm_state_h, lstm_1_state_c, 
    lstm_1_state_h]) 

我们从定义一个输入层开始,该层接收一个时间步长的输入。

请注意,我们正在定义 shape 参数。这意味着它可以接受任意大小的批量数据(只要它具有一个时间步)。我们还定义了其他几个输入,以维持 LSTM 层的状态。这是因为我们必须显式维护 LSTM 层的状态向量,因为我们正在递归地从模型中生成输出:

inp = tf.keras.layers.Input(dtype=tf.string, shape=(1,))
inp_state_c_lstm = tf.keras.layers.Input(shape=(512,))
inp_state_h_lstm = tf.keras.layers.Input(shape=(512,))
inp_state_c_lstm_1 = tf.keras.layers.Input(shape=(256,))
inp_state_h_lstm_1 = tf.keras.layers.Input(shape=(256,)) 

接下来,我们检索训练好的模型的 text_vectorization 层,并使用它将文本转换为整数 ID:

text_vectorized_out = lm_model.get_layer('text_vectorization')(inp) 

然后,我们获取训练模型的嵌入层并使用它来生成嵌入输出:

emb_layer = lm_model.get_layer('embedding')
emb_out = emb_layer(text_vectorized_out) 

我们将创建一个全新的 LSTM 层,代表训练模型中的第一个 LSTM 层。这是因为推理 LSTM 层与训练 LSTM 层之间会有一些细微差异。因此,我们将定义新的层,并稍后将训练好的权重复制过来。我们将 return_state 参数设置为 True。通过将其设置为 True,我们在调用该层时将获得三个输出:最终输出、单元状态和最终状态向量。注意,我们还传递了另一个名为 initial_state 的参数。initial_state 需要是一个张量列表:按顺序包括单元状态和最终状态向量。我们将输入层作为这些状态并将在运行时相应地填充它们:

lstm_layer = tf.keras.layers.LSTM(512, return_state=True, return_sequences=True)
lstm_out, lstm_state_c, lstm_state_h = lstm_layer(emb_out, initial_state=[inp_state_c_lstm, inp_state_h_lstm]) 

同样地,第二层 LSTM 将被定义。我们得到稠密层,并复制在训练模型中找到的全连接层。请注意,最后一层我们没有使用softmax

这是因为在推理时,softmax只是额外的开销,因为我们只需要输出具有最高输出分数的类(即不需要是概率分布):

# Defining a Dense layer and output
dense_out = lm_model.get_layer('dense')(lstm_1_out)
# Defining the final Dense layer and output
final_out = lm_model.get_layer('dense_1')(dense_out) 

不要忘记将训练好的 LSTM 层的权重复制到我们新创建的 LSTM 层:

lstm_layer.set_weights(lm_model.get_layer('lstm').get_weights())
lstm_1_layer.set_weights(lm_model.get_layer('lstm_1').get_weights()) 

最后,我们定义模型:

infer_model = tf.keras.models.Model(
    inputs=[inp, inp_state_c_lstm, inp_state_h_lstm, 
    inp_state_c_lstm_1, inp_state_h_lstm_1], 
    outputs=[final_out, lstm_state_c, lstm_state_h, lstm_1_state_c, 
    lstm_1_state_h]) 

我们的模型将 1 个二元组作为输入序列,以及两个 LSTM 层的状态向量,输出最终的预测概率和两个 LSTM 层的新状态向量。现在,让我们从模型中生成新文本。

使用模型生成新文本

我们将使用新的推理模型生成一个故事。我们将定义一个初始种子,用来生成故事。这里,我们从一个测试文件的第一句话开始。然后我们通过递归使用预测的二元组在时间t时作为时间t+1 的输入来生成文本。我们将运行 500 步:

text = ["When adam and eve were driven out of paradise, they were compelled to build a house for themselves on barren ground"]
seq = [text[0][i:i+2] for i in range(0, len(text[0]), 2)]
# build up model state using the given string
print("Making predictions from a {} element long input".format(len(seq)))
vocabulary = infer_model.get_layer("text_vectorization").get_vocabulary()
index_word = dict(zip(range(len(vocabulary)), vocabulary))
# Reset the state of the model initially
infer_model.reset_states()
# Defining the initial state as all zeros
state_c = np.zeros(shape=(1,512))
state_h = np.zeros(shape=(1,512))
state_c_1 = np.zeros(shape=(1,256))
state_h_1 = np.zeros(shape=(1,256))
# Recursively update the model by assigning new state to state
for c in seq:    
    #print(c)
    out, state_c, state_h, state_c_1, state_h_1 = infer_model.predict(
        [np.array([[c]]), state_c, state_h, state_c_1, state_h_1]
)
# Get final prediction after feeding the input string
wid = int(np.argmax(out[0],axis=-1).ravel())
word = index_word[wid]
text.append(word)
# Define first input to generate text recursively from
x = np.array([[word]])
# Code listing 10.7
for _ in range(500):

    # Get the next output and state
    out, state_c, state_h, state_c_1, state_h_1  = 
    infer_model.predict([x, state_c, state_h, state_c_1, state_h_1 ])

    # Get the word id and the word from out
    out_argsort = np.argsort(out[0], axis=-1).ravel()
    wid = int(out_argsort[-1])
    word = index_word[wid]

    # If the word ends with space, we introduce a bit of randomness
    # Essentially pick one of the top 3 outputs for that timestep 
    # depending on their likelihood
    if word.endswith(' '):
        if np.random.normal()>0.5:
            width = 5
            i = np.random.choice(list(range(-width,0)), 
            p=out_argsort[-width:]/out_argsort[-width:].sum())
            wid = int(out_argsort[i])    
            word = index_word[wid]

    # Append the prediction
    text.append(word)

    # Recursively make the current prediction the next input
    x = np.array([[word]])

# Print the final output    
print('\n')
print('='*60)
print("Final text: ")
print(''.join(text)) 

注意我们如何递归地使用变量xstate_cstate_hstate_c_1state_h_1来生成并分配新值。

 out, state_c, state_h, state_c_1, state_h_1  = 
    infer_model.predict([x, state_c, state_h, state_c_1, state_h_1 ]) 

此外,我们将使用一个简单的条件来多样化我们生成的输入:

if word.endswith(' '):
        if np.random.normal()>0.5:
            width = 5
            i = np.random.choice(list(range(-width,0)), 
            p=out_argsort[-width:]/out_argsort[-width:].sum())
            wid = int(out_argsort[i])    
            word = index_word[wid] 

本质上,如果预测的二元组以' '字符结尾,我们将随机选择下一个二元组,从前五个二元组中选择。每个二元组将根据其预测的可能性被选中。让我们看看输出文本是什么样的:

When adam and eve were driven out of paradise, they were compelled to build a house for themselves on barren groundy the king's daughter and said, i will so the king's daughter angry this they were and said, "i will so the king's daughter.  the king's daughter.' they were to the forest of the stork.  then the king's daughters, and they were to the forest of the stork, and, and then they were to the forest.  ... 

看起来我们的模型能够生成实际的单词和短语,且有意义。接下来,我们将研究从标准 LSTM 生成的文本与其他模型的比较,例如带有窥视连接的 LSTM 和 GRU。

将 LSTM 与带有窥视连接的 LSTM 和 GRU 进行比较

现在,我们将在文本生成任务中将 LSTM 与带有窥视连接的 LSTM 和 GRU 进行比较。这将帮助我们比较不同模型(带窥视连接的 LSTM 和 GRU)在困惑度方面的表现。记住,我们更看重困惑度而不是准确率,因为准确率假设给定一个先前的输入序列时只有一个正确的标记。然而,正如我们所学,语言是复杂的,给定先前的输入,生成文本有很多不同正确的方式。这个内容作为练习可以在ch08_lstms_for_text_generation.ipynb中找到,位于Ch08-Language-Modelling-with-LSTMs文件夹中。

标准 LSTM

首先,我们将重述标准 LSTM 的组件。我们不会重复标准 LSTM 的代码,因为它与我们之前讨论的完全相同。最后,我们将看到一个 LSTM 生成的文本。

回顾

在这里,我们将重新审视标准 LSTM 的结构。如前所述,一个 LSTM 包含以下组件:

  • 输入门 – 它决定当前输入有多少被写入到单元状态

  • 遗忘门 – 它决定了多少前一个单元状态将写入当前单元状态

  • 输出门 – 它决定了多少来自单元状态的信息将暴露到外部隐藏状态中

图 8.3中,我们展示了每个门、输入、单元状态和外部隐藏状态是如何连接的:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_08_03.png

图 8.3:LSTM 单元

门控递归单元(GRU)

在这里,我们将首先简要描述一个 GRU 由哪些部分组成,接着展示实现 GRU 单元的代码。最后,我们来看一些由 GRU 单元生成的代码。

回顾

让我们简要回顾一下 GRU 是什么。GRU 是 LSTM 操作的优雅简化。GRU 对 LSTM 进行了两项不同的修改(见图 8.4):

  • 它将内部单元状态和外部隐藏状态连接成一个单一的状态

  • 然后它将输入门和遗忘门结合为一个更新门

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_08_04.png

图 8.4:GRU 单元

GRU 模型采用了比 LSTM 更简单的门控机制。然而,它仍然能够捕获重要的功能,如记忆更新、遗忘等。

模型

在这里,我们将定义一个基于 GRU 的语言模型:

text_vectorizer = tf.keras.layers.TextVectorization(
    max_tokens=n_vocab, standardize=None,
    split=None, input_shape=(window_size,)
)
# Train the model on existing data
text_vectorizer.adapt(train_ds)
lm_gru_model = models.Sequential([
    text_vectorizer,
    layers.Embedding(n_vocab+2, 96),
    layers.GRU(512, return_sequences=True),
    layers.GRU(256, return_sequences=True),
    layers.Dense(1024, activation='relu'),
    layers.Dropout(0.5),
    layers.Dense(n_vocab, activation='softmax')
]) 

训练代码与我们训练基于 LSTM 的模型时相同。因此,我们在这里不再重复讨论。接下来,我们将看看 LSTM 模型的一个略有不同的变体。

带有窥视连接的 LSTM

在这里,我们将讨论带有窥视连接的 LSTM,以及它们与标准 LSTM 的不同之处。之后,我们将讨论它们的实现。

回顾

现在,让我们简要看一下带有窥视连接的 LSTM。窥视连接本质上是一种让门(输入、遗忘和输出门)直接看到单元状态的方式,而不是等待外部隐藏状态(见图 8.5):

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_08_05.png

图 8.5:带有窥视连接的 LSTM

代码

请注意,我们使用的是对角线的窥视连接实现。我们发现,非对角线窥视连接(由 Gers 和 Schmidhuber 在他们的论文《时间和计数的递归网络》,《神经网络》,2000中提出)对于这个语言建模任务的表现影响较大,反而更多是有害而非有益。因此,我们使用了不同的变体,它使用了对角线窥视连接,就像 Sak、Senior 和 Beaufays 在他们的论文《大规模声学建模的长短时记忆递归神经网络架构》,《国际语音通信协会年会会议录》中所使用的那样。

幸运的是,我们已经将此技术作为tensorflow_addons中的RNNCell对象进行了实现。因此,我们所需要做的就是将这个PeepholeLSTMCell对象包装在layers.RNN对象中,以生成所需的层。以下是代码实现:

text_vectorizer = tf.keras.layers.TextVectorization(
    max_tokens=n_vocab, standardize=None,
    split=None, input_shape=(window_size,)
)
# Train the model on existing data
text_vectorizer.adapt(train_ds)
lm_peephole_model = models.Sequential([
    text_vectorizer,
    layers.Embedding(n_vocab+2, 96),
    layers.RNN(
        tfa.rnn.PeepholeLSTMCell(512),
        return_sequences=True
    ),
    layers.RNN(
        tfa.rnn.PeepholeLSTMCell(256),
        return_sequences=True
    ),
    layers.Dense(1024, activation='relu'),
    layers.Dropout(0.5),
    layers.Dense(n_vocab, activation='softmax')
]) 

现在让我们看看不同模型的训练和验证困惑度,以及它们如何随时间变化。

训练和验证困惑度随时间变化

图 8.6 中,我们绘制了 LSTM、带窥视孔的 LSTM 和 GRU 的困惑度随时间变化的行为。我们可以看到,GRU 在性能上明显优于其他模型。这可以归因于 GRU 单元对 LSTM 单元的创新性简化。但看起来 GRU 模型确实会过拟合。因此,使用早停等技术来防止这种行为是非常重要的。我们可以看到,带窥视孔的 LSTM 在性能上并没有给我们带来太多优势。但需要记住的是,我们使用的是一个相对较小的数据集。

对于更大、更复杂的数据集,性能可能会有所不同。我们将把 GRU 单元的实验留给读者,继续讨论 LSTM 模型:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_08_06.png

图 8.6:训练数据的困惑度随时间的变化(LSTM、LSTM(窥视孔)和 GRU)

注意

当前文献表明,在 LSTM 和 GRU 之间,没有明显的胜者,很多因素取决于任务本身(参见论文 门控递归神经网络在序列建模中的经验评估Chung 等人2014 年 NIPS 深度学习工作坊2014 年 12 月arxiv.org/abs/1412.3555)。

在本节中,我们讨论了三种不同的模型:标准 LSTM、GRU 和带窥视孔的 LSTM。

结果清楚地表明,对于这个数据集,GRU 优于其他变体。在下一节中,我们将讨论可以增强序列模型预测能力的技术。

改进序列模型——束搜索

正如我们之前所看到的,生成的文本可以改进。现在,让我们看看我们在 第七章,理解长短期记忆网络 中讨论的束搜索,是否能够帮助提高性能。从语言模型进行预测的标准方法是一次预测一个步骤,并使用前一个时间步的预测结果作为新的输入。在束搜索中,我们会在选择输入之前预测多个步骤。

这使我们能够选择那些单独看可能不那么吸引人的输出序列,但作为一个整体来看会更好。束搜索的工作方式是,在给定的时间,通过预测 m^n 个输出序列或束来进行。m 被称为束宽度,n 是束的深度。每个输出序列(或束)是预测的 n 个二元组,预测到未来。我们通过将束中每个项的单独预测概率相乘来计算每个束的联合概率。然后我们选择具有最高联合概率的束作为该时间步的输出序列。请注意,这是一个贪心搜索,这意味着我们会在树的每个深度计算最佳候选项,并逐步进行,随着树的增长。需要注意的是,这种搜索不会得到全局最优的束。图 8.7 展示了一个例子。我们将用粗体字和箭头标出最佳束候选(及其概率):

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_08_07.png

图 8.7:一个束搜索示例,展示了在每一步更新束状态的需求。每个单词下方的数字表示该单词被选择的概率。对于非粗体字的单词,你可以认为它们的概率可以忽略不计。

我们可以看到,在第一步中,单词“hunting”具有最高的概率。然而,如果我们执行一个深度为 3 的束搜索,我们得到的序列是 [“king”, “was”, “hunting”],其联合概率为 0.3 * 0.5 * 0.4 = 0.06,作为最佳束。

这个概率高于从单词“hunting”开始的束(它的联合概率为 0.5 * 0.1 * 0.3 = 0.015)。

实现束搜索

我们将束搜索实现为一个递归函数。但首先,我们将实现一个执行递归函数单步操作的函数,称为 beam_one_step()。该函数简单地接受模型、输入和状态(来自 LSTM),并生成输出和新状态。

def beam_one_step(model, input_, states): 
    """ Perform the model update and output for one step"""
    out = model.predict([input_, *states])
    output, new_states = out[0], out[1:]
    return output, new_states 

接下来,我们编写执行束搜索的主要递归函数。该函数接受以下参数:

  • model – 基于推理的语言模型

  • input_ – 初始输入

  • states – 初始状态向量

  • beam_depth – 束的搜索深度

  • beam_width – 束搜索的宽度(即在给定深度下考虑的候选词数)

现在让我们讨论这个函数:

def beam_search(model, input_, states, beam_depth=5, beam_width=3):
    """ Defines an outer wrapper for the computational function of 
    beam search """
    vocabulary = 
    infer_model.get_layer("text_vectorization").get_vocabulary()
    index_word = dict(zip(range(len(vocabulary)), vocabulary))
    def recursive_fn(input_, states, sequence, log_prob, i):
        """ This function performs actual recursive computation of the 
        long string"""

        if i == beam_depth:
            """ Base case: Terminate the beam search """
            results.append((list(sequence), states, np.exp(log_prob)))
            return sequence, log_prob, states
        else:
            """ Recursive case: Keep computing the output using the 
            previous outputs"""
            output, new_states = beam_one_step(model, input_, states)

            # Get the top beam_width candidates for the given depth
            top_probs, top_ids = tf.nn.top_k(output, k=beam_width)
            top_probs, top_ids = top_probs.numpy().ravel(), 
            top_ids.numpy().ravel()
            # For each candidate compute the next prediction
            for p, wid in zip(top_probs, top_ids):
                new_log_prob = log_prob + np.log(p)

                # we are going to penalize joint probability whenever 
                # the same symbol is repeating
                if len(sequence)>0 and wid == sequence[-1]:
                    new_log_prob = new_log_prob + np.log(1e-1)

                sequence.append(wid)
                _ = recursive_fn(np.array([[index_word[wid]]]), 
                new_states, sequence, new_log_prob, i+1)
                sequence.pop()
    results = []
    sequence = []
    log_prob = 0.0
    recursive_fn(input_, states, sequence, log_prob, 0)
    results = sorted(results, key=lambda x: x[2], reverse=True)
    return results 

beam_search() 函数实际上定义了一个嵌套的递归函数(recursive_fn),每次调用时都会累积输出,并将结果存储在一个名为 results 的列表中。recursive_fn() 做如下操作。如果函数已经被调用了与 beam_depth 相等的次数,那么它会返回当前结果。如果函数调用次数尚未达到预定深度,那么对于给定的深度索引,recursive_fn() 会:

  • 使用 beam_one_step() 函数计算新的输出和状态

  • 获取前两个候选词的 ID 和概率

  • 在对数空间中计算每个束的联合概率(在对数空间中,我们可以获得更好的数值稳定性,尤其是对于较小的概率值)

  • 最后,我们使用新的输入、新的状态和下一个深度索引调用相同的函数

有了这个,你可以简单地调用 beam_search() 函数,从推理模型中获得预测的束。接下来让我们看看如何实现这一点。

使用束搜索生成文本

在这里,我们只展示我们如何通过迭代调用 beam_search() 来生成新文本的部分。完整的代码请参见 ch08_lstms_for_text_generation.ipynb

for i in range(50):
    print('.', end='')
    # Get the results from beam search
    result = beam_search(infer_model, x, states, 5, 5)

    # Get one of the top 10 results based on their likelihood
    n_probs = np.array([p for _,_,p in result[:10]])
    p_j = np.random.choice(list(range(n_probs.size)), 
    p=n_probs/n_probs.sum())                    
    best_beam_ids, states, _ = result[p_j]
    x = np.array([[index_word[best_beam_ids[-1]]]])
    text.extend([index_word[w] for w in best_beam_ids]) 

我们简单地调用函数 beam_search(),传入 infer_model、当前输入 x、当前状态 statesbeam_depthbeam_width,并更新 xstates 以反映获胜的束。然后模型将迭代使用获胜的束生成下一个束。

让我们看看 LSTM 在使用束搜索(beam search)时的表现:

When adam and eve were driven out of paradise, they were compelled to build a house for themselves on barren groundr, said the king's daughter went out of the king's son to the king's daughter, and then the king's daughter went into the world, and asked the hedgehog's daughter that the king was about to the forest, and there was on the window, and said, "if you will give her that you have been and said, i will give him the king's daughter, but when she went to the king's sister, and when she was still before the window, and said to himself, and when he said to her father, and that he had nothing and said to hi 

这是标准的 LSTM 使用贪婪采样(即一次预测一个词)时的输出:

When adam and eve were driven out of paradise, they were compelled to build a house for themselves on barren groundr, and then this they were all the third began to be able to the forests, and they were.  the king's daughter was no one was about to the king's daughter to the forest of them to the stone.  then the king's daughter was, and then the king's daughter was nothing-eyes, and the king's daughter was still, and then that had there was about through the third, and the king's daughters was seems to the king's daughter to the forest of them to the stone for them to the forests, and that it was not been to be ables, and the king's daughter wanted to be and said, ... 

与 LSTM 生成的文本相比,这段文本似乎有更多的变化,同时保持了语法的一致性。因此,实际上,束搜索(beam search)相比逐字预测能帮助生成更高质量的预测。但仍然有些情况下,词语组合在一起并没有太大意义。让我们看看如何进一步改进我们的 LSTM。

改进 LSTMs —— 使用词汇而非 n-gram 生成文本

在这里,我们将讨论如何改进 LSTM。到目前为止,我们一直使用二元组(bigrams)作为文本的基本单位。但如果使用词汇而非二元组,你将获得更好的结果。这是因为使用词汇可以减少模型的开销,避免需要学习如何从二元组中构建词汇。我们将讨论如何在代码中使用词向量,以便与使用二元组相比,生成更高质量的文本。

维度诅咒

阻止我们将词汇作为 LSTM 输入的一个主要限制是,这将大幅增加模型中的参数数量。让我们通过一个例子来理解这一点。假设我们的输入大小为500,单元状态大小为100。这将导致大约240K的参数数量(不包括 softmax 层),如图所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_08_004.png

现在我们将输入大小增加到1000。此时,总参数数目将约为440K,如图所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_08_005.png

如你所见,当输入维度增加 500 单位时,参数的数量增长了 20 万。这不仅增加了计算复杂度,还因大量的参数而增加了过拟合的风险。因此,我们需要一些方法来限制输入的维度。

Word2vec 来拯救我们

如你所记得,Word2vec 不仅能提供比独热编码(one-hot encoding)更低维度的词特征表示,还能提供语义上合理的特征。为了理解这一点,我们来看三个词:catdogvolcano。如果我们对这三个词进行独热编码,并计算它们之间的欧氏距离,结果会如下:

distance(cat,volcano) = distance(cat,dog)

然而,如果我们学习词嵌入,它将如下所示:

distance(cat,volcano) > distance(cat,dog)

我们希望我们的特征能代表后一种情况,其中相似的东西之间的距离小于不相似的东西。这样,模型将能够生成更高质量的文本。

使用 Word2vec 生成文本

模型的结构基本保持不变,我们所考虑的仅是文本单元的变化。

图 8.8 展示了 LSTM-Word2vec 的总体架构:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_08_08.png

图 8.8:使用词向量的语言建模 LSTM 结构

使用词向量时,你有几个选择。你可以:

  • 随机初始化词向量,并在任务过程中共同学习它们

  • 预先使用词向量算法(例如 Word2vec、GloVe 等)训练嵌入层

  • 使用可以自由下载的预训练词向量来初始化嵌入层

注意

下面列出了一些可以自由下载的预训练词向量。通过从包含数十亿单词的文本语料库中学习得到的词向量可以自由下载并使用:

我们在这里结束关于语言建模的讨论。

总结

在这一章中,我们研究了 LSTM 算法的实现以及其他各个重要方面,以提升 LSTM 超越标准性能。作为练习,我们在格林兄弟的故事文本上训练了我们的 LSTM,并让 LSTM 输出一个全新的故事。我们讨论了如何通过提取自练习的代码示例来实现一个 LSTM 模型。

接下来,我们进行了关于如何实现带窥视孔的 LSTM 和 GRU 的技术讨论。然后,我们对标准 LSTM 及其变种进行了性能比较。我们发现 GRU 比带窥视孔的 LSTM 和 LSTM 表现更好。

然后我们讨论了提升 LSTM 输出质量的一些改进方法。第一个改进是束搜索。我们查看了束搜索的实现,并逐步介绍了如何实现它。接着,我们研究了如何利用词嵌入来教导 LSTM 输出更好的文本。

总之,LSTM 是非常强大的机器学习模型,能够捕捉长期和短期的依赖关系。

此外,与逐个预测相比,束搜索实际上有助于生成更具现实感的文本短语。

在下一章中,我们将探讨如何使用顺序模型来解决一种更复杂的问题类型,称为序列到序列问题。具体来说,我们将研究如何将机器翻译问题转化为序列到序列问题。

要访问本书的代码文件,请访问我们的 GitHub 页面:packt.link/nlpgithub

加入我们的 Discord 社区,结识志同道合的人,与超过 1000 名成员一起学习:packt.link/nlp

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/QR_Code5143653472357468031.png

第九章:序列到序列学习 – 神经机器翻译

序列到序列学习是用于需要将任意长度序列映射到另一个任意长度序列的任务的术语。这是自然语言处理(NLP)中最复杂的任务之一,涉及学习多对多的映射。该任务的例子包括神经机器翻译NMT)和创建聊天机器人。NMT 是指我们将一个语言(源语言)的句子翻译成另一种语言(目标语言)。谷歌翻译就是一个 NMT 系统的例子。聊天机器人(即能够与人类沟通/回答问题的软件)能够以现实的方式与人类对话。这对于各种服务提供商尤其有用,因为聊天机器人可以用来解答顾客可能遇到的易于解决的问题,而不是将他们转接给人工客服。

在本章中,我们将学习如何实现一个 NMT 系统。然而,在深入探讨这些最新进展之前,我们首先会简要回顾一些统计机器翻译SMT)方法,这些方法是 NMT 之前的技术,并且在 NMT 赶超之前是当时的先进系统。接下来,我们将逐步讲解构建 NMT 所需的步骤。最后,我们将学习如何实现一个实际的 NMT 系统,从德语翻译到英语,逐步进行。

具体来说,本章将涵盖以下主要主题:

  • 机器翻译

  • 机器翻译的简短历史回顾

  • 理解神经机器翻译

  • 准备 NMT 系统的数据

  • 定义模型

  • 训练 NMT

  • BLEU 分数 – 评估机器翻译系统

  • 可视化注意力模式

  • 使用 NMT 进行推理

  • Seq2Seq 模型的其他应用 – 聊天机器人

机器翻译

人类常常通过语言彼此交流,相较于其他交流方式(例如,手势)。目前,全球有超过 6,000 种语言在使用。此外,要将一门语言学到能够被该语言的母语者轻松理解的水平,是一项难以掌握的任务。然而,交流对于分享知识、社交和扩大人际网络至关重要。因此,语言成为与世界其他地方的人进行交流的障碍。这就是机器翻译MT)发挥作用的地方。MT 系统允许用户输入他们自己的语言(称为源语言)的句子,并输出所需目标语言的句子。

MT 的问题可以这样表述:假设我们给定一个句子(或一系列单词)W[s],它属于源语言S,由以下公式定义:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_001.png

在这里,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_002.png

源语言将被翻译成一个句子!,其中T是目标语言,并由以下公式给出:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_004.png

在这里,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_005.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_006.png 通过机器翻译系统得到的输出如下:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_007.png

在这里,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_008.png 是算法为源句子找到的可能翻译候选池。此外,从候选池中选出的最佳候选翻译由以下方程给出:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_009.png

在这里,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_073.png 是模型参数。在训练过程中,我们优化模型以最大化一组已知目标翻译的概率,这些目标翻译与对应的源语言翻译(即训练数据)相对应。

到目前为止,我们已经讨论了我们感兴趣的语言翻译问题的正式设置。接下来,我们将回顾机器翻译的历史,了解早期人们是如何尝试解决这一问题的。

机器翻译的简史

在这里,我们将讨论机器翻译的历史。机器翻译的起源涉及基于规则的系统。随后,出现了更多统计学上可靠的机器翻译系统。统计机器翻译SMT)利用语言的各种统计量来生成目标语言的翻译。随后进入了神经机器翻译(NMT)时代。与其他方法相比,NMT 在大多数机器学习任务中目前保持着最先进的性能。

基于规则的翻译

神经机器翻译(NMT)是在统计机器学习之后很久才出现的,而统计机器学习已经存在超过半个世纪了。统计机器翻译方法的起源可以追溯到 1950-60 年,当时在第一次有记录的项目之一——乔治敦-IBM 实验中,超过 60 个俄语句子被翻译成了英语。为了提供一些背景,这一尝试几乎和晶体管的发明一样久远。

机器翻译的初期技术之一是基于词汇的机器翻译。该系统通过使用双语词典进行逐词翻译。然而,正如你所想的,这种方法有着严重的局限性。显而易见的局限性是,逐词翻译并不是不同语言之间的逐一映射。此外,逐词翻译可能导致不正确的结果,因为它没有考虑到给定单词的上下文。源语言中给定单词的翻译可以根据其使用的上下文而变化。为了通过一个具体的例子来理解这一点,我们来看一下图 9.1中的英法翻译示例。你可以看到,在给定的两个英语句子中,一个单词发生了变化。然而,这种变化导致了翻译的显著不同:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_01.png

图 9.1:语言之间的翻译(英法)不是逐词映射

在 1960 年代,自动语言处理咨询委员会ALPAC)发布了一份报告,《语言与机器:计算机在翻译与语言学中的应用》美国国家科学院(1966),讨论了机器翻译(MT)的前景。结论是:

没有直接或可预测的前景表明机器翻译会变得有用。

这是因为机器翻译(MT)当时比人工翻译更慢、更不准确且更昂贵。这对机器翻译的进展造成了巨大打击,几乎有十年的时间处于沉寂状态。

接下来是基于语料库的机器翻译(MT),其中一个算法通过使用源句子的元组进行训练,并通过平行语料库获得对应的目标句子,即平行语料库的格式为[(<source_sentence_1>, <target_sentence_1>), (<source_sentence_2>, <target_sentence_2>), …]。平行语料库是一个由源语言文本及其对应的翻译组成的元组形式的大型文本语料库。这个示例如表 9.1所示。需要注意的是,构建平行语料库比构建双语词典更容易,而且它们更准确,因为训练数据比逐词训练数据更丰富。此外,基于平行语料库的机器翻译可以建立双语词典(即转移模型),而不直接依赖于人工创建的双语词典。转移模型展示了给定当前源词或短语时,目标词或短语是正确翻译的可能性。除了学习转移模型外,基于语料库的机器翻译还学习了词对齐模型。词对齐模型可以表示源语言中的短语的单词如何与该短语的翻译对应。平行语料库和词对齐模型的示例如图 9.2所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_02.png

图 9.2:两种不同语言之间的词对齐

一个平行语料库的示例如表 9.1所示:

源语言句子(英语)目标语言句子(法语)
I went homeJe suis allé à la maison
John likes to play guitarJohn aime jouer de la guitare
He is from EnglandIl est d’Angleterre
….

表 9.1:英语和法语句子的平行语料库

另一种方法是跨语言机器翻译,它涉及将源语言句子翻译成一种语言中立的中介语(即元语言),然后从中介语生成翻译后的句子。更具体地说,跨语言机器翻译系统由两个重要组件组成,一个是分析器,另一个是合成器。分析器将获取源语言句子并识别出代理(例如名词)、动作(例如动词)等元素,以及它们之间的相互关系。接下来,这些识别出的元素通过跨语言词汇表进行表示。跨语言词汇表的一个例子可以通过 WordNet 中的同义词集(即共享共同意义的同义词组)来构建。然后,合成器将从这种跨语言表示中生成翻译。由于合成器通过跨语言表示了解名词、动词等,它可以通过结合特定语言的语法规则在目标语言中生成翻译。

统计机器翻译(SMT)

接下来,更多统计上更为合理的系统开始出现。这个时代的先锋模型之一是 IBM 模型 1-5,它进行的是基于单词的翻译。然而,正如我们之前讨论的,单词翻译并不是一一对应的(例如复合词和形态学)。最终,研究人员开始尝试基于短语的翻译系统,这在机器翻译领域取得了一些显著的进展。

基于短语的翻译与基于单词的翻译类似,不同之处在于它使用语言的短语作为翻译的基本单位,而不是单个单词。这是一种更合理的方法,因为它使得建模单词之间的多对一、多对多或一对多关系变得更容易。基于短语的翻译的主要目标是学习一个短语翻译模型,其中包含不同候选目标短语对于给定源短语的概率分布。如你所想,这种方法需要维护两种语言之间大量短语的数据库。由于不同语言之间的句子没有单调的词序,因此还需要对短语进行重新排序。

这一点的例子如图 9.2所示;如果单词在语言之间是单调排序的,单词映射之间就不会有交叉。

这种方法的一个局限性是解码过程(为给定源短语找到最佳目标短语)代价高昂。这是因为短语数据库的庞大,以及一个源短语通常包含多个目标语言短语。为了减轻这一负担,基于语法的翻译应运而生。

在基于语法的翻译中,源句子通过语法树来表示。在图 9.3中,NP表示名词短语,VP表示动词短语,S表示句子。然后进入重排序阶段,在这个阶段,树节点会根据目标语言的需要重新排序,以改变主语、动词和宾语的顺序。这是因为句子结构会根据语言的不同而变化(例如,英语是主语-动词-宾语,而日语是主语-宾语-动词)。重排序是根据一种叫做r 表的东西来决定的。r 表包含了树节点按照某种顺序重排的可能性概率:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_03.png

图 9.3:一个句子的语法树

然后进入插入阶段。在插入阶段,我们随机地将一个词插入到树的每个节点中。这是由于假设存在一个不可见的NULL词,它会在树的随机位置生成目标词汇。此外,插入一个词的概率由一种叫做n 表的东西决定,它是一个包含将特定词插入到树中的概率的表格。

接下来,进入翻译阶段,在这个阶段,每个叶子节点都按逐词的方式被翻译成目标词。最后,通过读取语法树中的翻译句子,构建目标句子。

神经机器翻译(NMT)

最后,在 2014 年左右,NMT 系统被引入。NMT 是一种端到端的系统,它将整个句子作为输入,进行某些转换,然后输出与源句子对应的翻译句子。

因此,NMT 消除了机器翻译所需的特征工程,例如构建短语翻译模型和构建语法树,这对 NLP 社区来说是一个巨大的胜利。此外,NMT 在非常短的时间内(仅两到三年)超越了所有其他流行的 MT 技术。在图 9.4中,我们展示了 MT 文献中报告的各种 MT 系统的结果。例如,2016 年的结果来自 Sennrich 等人在他们的论文*《爱丁堡神经机器翻译系统(WMT 16),计算语言学协会,第一个机器翻译会议论文集,2016 年 8 月:371-376》中的报告,也来自 Williams 等人在他们的论文《爱丁堡统计机器翻译系统(WMT16),计算语言学协会,第一个机器翻译会议论文集,2016 年 8 月:399-410》*中的报告。所有 MT 系统都通过 BLEU 分数进行了评估。BLEU 分数表示候选翻译与参考翻译匹配的 n-grams 数量(例如,单字和双字组合)。因此,BLEU 分数越高,MT 系统越好。我们将在本章后面详细讨论 BLEU 指标。不言而喻,NMT 无疑是赢家:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_04.png

图 9.4:统计机器翻译系统与 NMT 系统的比较。感谢 Rico Sennrich 提供。

一个评估 NMT 系统潜力的案例研究可以在*《神经机器翻译准备好部署了吗?30 种翻译方向的案例研究》中找到,作者为 Junczys-Dowmunt、Hoang 和 Dwojak,发表于第九届国际口语语言翻译研讨会*,西雅图(2016)

该研究探讨了不同系统在多种语言之间(英语、阿拉伯语、法语、俄语和中文)的翻译任务中的表现。结果还表明,NMT 系统(NMT 1.2M 和 NMT 2.4M)的表现优于 SMT 系统(PB-SMT 和 Hiero)。

图 9.5显示了 2017 年最先进的机器翻译系统的一些统计数据。这些数据来自 Konstantin Savenkov(Intento 公司联合创始人兼 CEO)制作的演示文稿《机器翻译现状,Intento 公司,2017》。我们可以看到,DeepL(www.deepl.com)所生成的机器翻译性能与其他大型机器翻译系统,包括 Google,表现得非常接近。比较包括了 DeepL(NMT)、Google(NMT)、Yandex(NMT-SMT 混合)、Microsoft(同时拥有 SMT 和 NMT)、IBM(SMT)、Prompt(基于规则)和 SYSTRAN(基于规则/SMT 混合)等机器翻译系统。图表清晰地显示了 NMT 系统目前在机器翻译技术进展中处于领先地位。LEPOR 得分用于评估不同的系统。LEPOR 是一种比 BLEU 更先进的评估指标,它尝试解决语言偏差问题。语言偏差问题指的是一些评估指标(如 BLEU)在某些语言上表现良好,但在其他语言上表现较差。

然而,也应注意,由于在这次比较中使用了平均机制,结果确实存在一定的偏差。例如,Google 翻译是基于一个更大范围的语言集合(包括较难的翻译任务)进行平均的,而 DeepL 则是基于一个较小且相对容易的语言子集进行平均的。因此,我们不应得出结论认为 DeepL 的机器翻译系统总是优于 Google 的机器翻译系统。尽管如此,整体结果仍然为当前的 NMT 和 SMT 系统提供了一个大致的性能对比:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_05.png

图 9.5:各种机器翻译系统的表现。感谢 Intento 公司提供

我们看到 NMT 在短短几年内已经超过了 SMT 系统,成为当前的最先进技术。接下来,我们将讨论 NMT 系统的细节和架构。最后,我们将从头开始实现一个 NMT 系统。

理解神经机器翻译

现在我们已经理解了机器翻译如何随着时间的推移而发展,让我们尝试理解最先进的 NMT 是如何工作的。首先,我们将看看神经机器翻译模型的架构,然后再深入了解实际的训练算法。

NMT 系统背后的直觉

首先,让我们理解 NMT 系统设计背后的直觉。假设你是一个流利的英语和德语使用者,并且被要求将以下句子翻译成德语:

我回家了

该句的翻译如下:

Ich ging nach Hause

尽管对流利的人来说,翻译这个句子可能只需要几秒钟,但翻译是有一定过程的。首先,你阅读英文句子,然后在脑海中形成一个关于这个句子的思想或概念。最后,你将句子翻译成德语。构建 NMT 系统时使用了相同的思路(见图 9.6)。编码器读取源句子(类似于你阅读英文句子的过程)。然后,编码器输出一个上下文向量(该上下文向量对应你在阅读句子后想象的思想/概念)。最后,解码器接收上下文向量并输出德语翻译:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_06.png

图 9.6:NMT 系统的概念架构

NMT 架构

现在我们将更详细地看一下架构。序列到序列的方法最初是由 Sutskever、Vinyals 和 Le 在他们的论文《Sequence to Sequence Learning with Neural Networks, Proceedings of the 27th International Conference on Neural Information Processing Systems - Volume 2: 3104-3112.》中提出的。

图 9.6的示意图中,我们可以看到 NMT 架构中有两个主要组件。它们被称为编码器和解码器。换句话说,NMT 可以看作是一个编码器-解码器架构。编码器将源语言的句子转换为思想向量(即上下文化的表示),而解码器将思想向量解码或翻译为目标语言。正如你所看到的,这与我们简要讨论过的中介语言机器翻译方法有些相似。这个解释在图 9.7中得到了说明。上下文向量的左侧表示编码器(它逐字读取源句子以训练时间序列模型)。右侧表示解码器,它逐字输出(同时使用前一个词作为当前输入)源句子的相应翻译。我们还将使用嵌入层(对于源语言和目标语言),在这些层中,单个词元的语义将被学习并作为输入传递给模型:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_07.png

图 9.7:源句子和目标句子随时间展开

在对 NMT 的基本理解之后,我们来正式定义 NMT 的目标。NMT 系统的最终目标是最大化对给定源句子 x[s] 及其对应的 y[t] 的对数似然。即,最大化以下内容:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_010.png

这里,N 指的是我们作为训练数据拥有的源句子和目标句子输入的数量。

然后,在推理过程中,对于给定的源句子, https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_011.png,我们将使用以下方法找到 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_012.png 翻译:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_013.png

在这里,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_014.pngi^(th) 时刻的预测标记,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_015.png 是可能的候选句子集合。

在我们研究 NMT 架构的每个部分之前,让我们先定义一些数学符号,以便更具体地理解这个系统。作为我们的序列模型,我们将选择 门控循环单元 (GRU),因为它比 LSTM 更简单,且表现相对较好。

让我们定义编码器 GRU 为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_016.png,解码器 GRU 为 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_017.png。在时间步长 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_018.png 处,定义一般 GRU 的输出状态为 h[t]。也就是说,将输入 x[t] 输入到 GRU 中会得到 h[t]:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_020.png

现在,我们将讨论嵌入层、编码器、上下文向量,最后是解码器。

嵌入层

我们已经看到词嵌入的强大功能。在这里,我们也可以利用嵌入来提高模型性能。我们将使用两个词嵌入层,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_021.png 用于源语言,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_022.png 用于目标语言。所以,我们将不直接将 x[t] 输入到 GRU 中,而是得到 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_023.png。然而,为了避免过多的符号表示,我们假设 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_024.png

编码器

如前所述,编码器负责生成一个思维向量或上下文向量,表示源语言的含义。为此,我们将使用基于 GRU 的网络(见 图 9.8):

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_08.png

图 9.8:一个 GRU 单元

编码器在时间步长 0 (h[0]) 处用零向量初始化。编码器接受一个词序列,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_025.png,作为输入,并计算一个上下文向量,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_026.png,其中 v 是处理序列 x[s] 的最后一个元素 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_027.png 后得到的最终外部隐藏状态。我们表示为以下内容:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_028.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_029.png

上下文向量

上下文向量 (v) 的概念是简洁地表示源语言的句子。此外,与编码器的状态初始化方式(即初始化为零)相对,上下文向量成为解码器 GRU 的初始状态。换句话说,解码器 GRU 并非以零向量作为初始状态,而是以上下文向量作为初始状态。这在编码器和解码器之间创建了联系,使整个模型成为端到端可微分的。我们将在接下来详细讨论这一点。

解码器

解码器负责将上下文向量解码为所需的翻译。我们的解码器也是一个 RNN。虽然编码器和解码器可以共享相同的权重集,但通常使用两个不同的网络分别作为编码器和解码器会更好。这增加了我们模型中的参数数量,使我们能够更有效地学习翻译。

首先,解码器的状态通过上下文向量进行初始化,即https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_030.png,如图所示:https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_031.png

在这里,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_032.png是解码器的初始状态向量(https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_033.png)。

这个(v)是连接编码器和解码器,形成端到端计算链的关键链接(见图 9.6,编码器和解码器共享的唯一内容是v)。此外,这是解码器获取源句子的唯一信息。

然后,我们将通过以下公式计算翻译句子的m^(th)个预测结果:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_034.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_035.png

带有 GRU 单元在编码器和解码器之间连接细节,并且使用 softmax 层输出预测结果的完整 NMT 系统,如图 9.9所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_09.png

图 9.9:带有 GRU 的编码器-解码器架构。编码器和解码器都有一个独立的 GRU 组件。此外,解码器还具有一个全连接(密集)层和一个 softmax 层,用于生成最终的预测结果。

在下一节中,我们将介绍为模型准备数据所需的步骤。

为 NMT 系统准备数据

在本节中,我们将了解数据,并学习如何准备数据以进行 NMT 系统的训练和预测。首先,我们将讨论如何准备训练数据(即源句子和目标句子对),以训练 NMT 系统,然后输入给定的源句子以生成该源句子的翻译。

数据集

本章中我们将使用的数据集是来自nlp.stanford.edu/projects/nmt/的 WMT-14 英德翻译数据。大约有 450 万个句子对可用。然而,由于计算可行性,我们只会使用 25 万个句子对。词汇表由最常见的 50,000 个英语单词和最常见的 50,000 个德语单词组成,词汇表中未找到的单词将被特殊标记<unk>替代。你需要下载以下文件:

  • train.de – 包含德语句子的文件

  • train.en – 包含英语句子的文件

  • vocab.50K.de – 包含德语词汇的文件

  • vocab.50K.en – 包含英语词汇的文件

train.detrain.en分别包含德语和英语的平行句子。一旦下载,我们将按照以下方式加载这些句子:

n_sentences = 250000
# Loading English sentences
original_en_sentences = []
with open(os.path.join('data', 'train.en'), 'r', encoding='utf-8') as en_file:
    for i,row in enumerate(en_file):
        if i >= n_sentences: break
        original_en_sentences.append(row.strip().split(" "))

# Loading German sentences
original_de_sentences = []
with open(os.path.join('data', 'train.de'), 'r', encoding='utf-8') as de_file:
    for i, row in enumerate(de_file):
        if i >= n_sentences: break
        original_de_sentences.append(row.strip().split(" ")) 

如果你打印刚刚加载的数据,对于这两种语言,你会看到如下的句子:

English: a fire restant repair cement for fire places , ovens , open fireplaces etc . 
German: feuerfester Reparaturkitt für Feuerungsanlagen , Öfen , offene Feuerstellen etc.
English: Construction and repair of highways and ... 
German: Der Bau und die Reparatur der Autostraßen ...
English: An announcement must be commercial character . 
German: die Mitteilungen sollen den geschäftlichen kommerziellen Charakter tragen . 

添加特殊标记

下一步是向我们的句子开始和结束添加一些特殊标记。我们将添加<s>来标记句子的开始,添加</s>来标记句子的结束。我们可以通过以下列表推导轻松实现这一点:

en_sentences = [["<s>"]+sent+["</s>"] for sent in original_en_sentences]
de_sentences = [["<s>"]+sent+["</s>"] for sent in original_de_sentences] 

这将给我们带来:

English: <s> a fire restant repair cement for fire places , ovens , open fireplaces etc . </s> 
German: <s> feuerfester Reparaturkitt für Feuerungsanlagen , Öfen , offene Feuerstellen etc. </s>
English: <s> Construction and repair of highways and ... </s> 
German: <s> Der Bau und die Reparatur der Autostraßen ... </s>
English: <s> An announcement must be commercial character . </s> 
German: <s> die Mitteilungen sollen den geschäftlichen kommerziellen Charakter tragen . </s> 

这是 Seq2Seq 模型中非常重要的一步。<s></s>标记在模型推理过程中起着极其重要的作用。正如你将看到的,在推理时,我们将使用解码器逐步预测一个单词,通过使用上一步的输出作为输入。这样,我们就可以预测任意数量的时间步。使用<s>作为起始标记使我们能够向解码器发出信号,指示它应开始预测目标语言的标记。接下来,如果我们不使用</s>标记来标记句子的结束,我们就无法向解码器发出结束句子的信号。这可能会导致模型进入无限预测循环。

划分训练、验证和测试数据集

我们需要将数据集拆分成三个部分:训练集、验证集和测试集。具体来说,我们将使用 80%的句子来训练模型,10%作为验证数据,剩下的 10%作为测试数据:

from sklearn.model_selection import train_test_split
train_en_sentences, valid_test_en_sentences, train_de_sentences, valid_test_de_sentences = train_test_split(
    np.array(en_sentences), np.array(de_sentences), test_size=0.2
)
valid_en_sentences, valid_de_sentences, test_en_sentences, test_de_sentences = train_test_split(
    valid_test_en_sentences, valid_test_de_sentences, test_size=0.5) 

为两种语言定义序列长度

我们现在必须理解的一个关键统计数据是,我们的语料库中的句子通常有多长。两种语言的句子长度很可能会有所不同。为了学习这个统计数据,我们将使用 pandas 库,具体方法如下:

pd.Series(train_en_sentences).str.len().describe(percentiles=[0.05, 0.5, 0.95]) 

在这里,我们首先将train_en_sentences转换为一个pd.Series对象。pd.Series是一个带索引的值序列(数组)。在这里,每个值是属于每个句子的标记列表。调用.str.len()将给我们每个标记列表的长度。最后,describe方法将提供重要的统计数据,如均值、标准差和百分位数。在这里,我们特别请求 5%、50%和 95%的百分位数。

请注意,我们仅使用训练数据进行此计算。如果将验证或测试数据集包括在计算中,我们可能会泄露有关验证和测试数据的信息。因此,最好仅使用训练数据集进行这些计算。

前面的代码结果给我们带来了:

Sequence lengths (English)
count    40000.000000
mean        25.162625
std         13.857748
min          6.000000
5%           9.000000
50%         22.000000
95%         53.000000
max        100.000000
dtype: float64 

我们可以通过以下方式获得德语句子的相同信息:

pd.Series(train_de_sentences).str.len().describe(percentiles=[0.05, 0.5, 0.95]) 

这给我们带来了:

Sequence lengths (German)
count    40000.000000
mean        22.882550
std         12.574325
min          6.000000
5%           9.000000
50%         20.000000
95%         47.000000
max        100.000000
dtype: float64 

在这里我们可以看到,95%的英语句子有 53 个标记,而 95%的德语句子有 47 个标记。

填充句子

接下来,我们需要填充我们的句子。为此,我们将使用 Keras 提供的pad_sequences()函数。该函数接受以下参数的值:

  • sequences – 一个字符串/ID 的列表,表示文本语料库。每个文档可以是一个字符串列表或一个整数列表。

  • maxlen – 要填充的最大长度(默认为None

  • dtype – 数据类型(默认为'int32'

  • padding – 填充短序列的方向(默认为'pre'

  • truncating – 截断长序列的方向(默认为'pre'

  • value – 用于填充的值(默认为0.0

我们将按如下方式使用这个函数:

from tensorflow.keras.preprocessing.sequence import pad_sequences
train_en_sentences_padded = pad_sequences(train_en_sentences, maxlen=n_en_seq_length, value=unk_token, dtype=object, truncating='post', padding='post')
valid_en_sentences_padded = pad_sequences(valid_en_sentences, maxlen=n_en_seq_length, value=unk_token, dtype=object, truncating='post', padding='post')
test_en_sentences_padded = pad_sequences(test_en_sentences, maxlen=n_en_seq_length, value=unk_token, dtype=object, truncating='post', padding='post')
train_de_sentences_padded = pad_sequences(train_de_sentences, maxlen=n_de_seq_length, value=unk_token, dtype=object, truncating='post', padding='post')
valid_de_sentences_padded = pad_sequences(valid_de_sentences, maxlen=n_de_seq_length, value=unk_token, dtype=object, truncating='post', padding='post')
test_de_sentences_padded = pad_sequences(test_de_sentences, maxlen=n_de_seq_length, value=unk_token, dtype=object, truncating='post', padding='post') 

我们正在对所有的训练、验证和测试句子进行填充处理,无论是英文还是德文。我们将使用最近找到的序列长度作为填充/截断长度。

反转源语言句子

我们还可以对源语言句子执行一个特殊的技巧。假设我们有一个句子ABC,我们想将其翻译成目标语言中的https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_036.png。我们将首先反转源语言句子,使得句子ABC被读取为CBA。这意味着,为了将ABC翻译为https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_036.png,我们需要输入CBA。这种方法显著提高了模型的性能,尤其是当源语言和目标语言共享相同句子结构时(例如,主语-动词-宾语)。

让我们试着理解为什么这有帮助。主要是,它有助于在编码器和解码器之间建立良好的沟通。让我们从前面的例子开始。我们将把源语言句子和目标语言句子连接起来:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_038.png

如果你计算从Ahttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_039.png或从Bhttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_040.png的距离(即,两个词之间的单词数),它们将是相同的。然而,考虑到反转源句子时的情况,如此处所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_041.png

在这里,Ahttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_039.png非常接近,以此类推。另外,为了构建好的翻译,开始时建立良好的交流非常重要。这个简单的技巧可能有助于 NMT 系统提升其性能。

请注意,反转源句子的步骤是一个主观的预处理步骤。对于某些翻译任务,这可能并不是必要的。例如,如果你的翻译任务是从日语(通常是主语-宾语-动词格式)翻译到菲律宾语(通常是动词-主语-宾语格式),那么反转源句子可能会适得其反,反而带来不利影响。这是因为通过反转日语文本,你会增加目标句子中起始元素(即动词(在日语中))与对应的源语言实体(即动词(在菲律宾语中))之间的距离。

接下来,我们来定义我们的编码器-解码器模型。

定义模型

在这一部分,我们将定义一个端到端的模型。

我们将实现一个基于编码器-解码器的 NMT 模型,并配备附加技术来提升性能。让我们从将字符串标记转换为 ID 开始。

将标记转换为 ID

在我们进入模型之前,还有一个文本处理操作剩下,那就是将处理过的文本标记转换为数字 ID。我们将使用tf.keras.layers.Layer来实现这一点。具体来说,我们将使用StringLookup层在模型中创建一个层,将每个标记转换为数字 ID。第一步,让我们加载数据中提供的词汇表文件。在此之前,我们将定义变量n_vocab来表示每种语言词汇表的大小:

n_vocab = 25000 + 1 

最初,每个词汇表包含 50,000 个标记。然而,我们将只取其中的一半,以减少内存需求。请注意,我们允许额外的一个标记,因为有一个特殊的标记<unk>表示超出词汇表OOV)的单词。使用 50,000 个标记的词汇表,由于我们最终要构建的预测层的大小,内存很容易就会耗尽。在减少词汇表大小的同时,我们必须确保保留最常见的 25,000 个单词。幸运的是,每个词汇表文件的组织方式是按单词出现的频率排序(从高到低)。因此,我们只需从文件中读取前 25,001 行文本:

en_vocabulary = []
with open(os.path.join('data', 'vocab.50K.en'), 'r', encoding='utf-8') as en_file:
    for ri, row in enumerate(en_file):
        if ri  >= n_vocab: break

        en_vocabulary.append(row.strip()) 

然后我们对德语的词汇表做相同的操作:

de_vocabulary = []
with open(os.path.join('data', 'vocab.50K.de'), 'r', encoding='utf-8') as de_file:
    for ri, row in enumerate(de_file):
        if ri >= n_vocab: break

        de_vocabulary.append(row.strip()) 

每个词汇表的第一行都包含特殊的 OOV 标记<unk>。我们将从en_vocabularyde_vocabulary列表中移除它,因为在下一步中我们需要它:

en_unk_token = en_vocabulary.pop(0)
de_unk_token = de_vocabulary.pop(0) 

下面是我们如何定义英语的StringLookup层:

en_lookup_layer = tf.keras.layers.StringLookup(
    vocabulary=en_vocabulary, oov_token=en_unk_token, 
    mask_token=pad_token, pad_to_max_tokens=False
) 

让我们理解传递给这个层的参数:

  • vocabulary – 包含在语料库中找到的单词列表(除了以下将讨论的某些特殊标记)

  • oov_token – 一个特殊的超出词汇表(out-of-vocabulary)标记,用于替换词汇表中没有列出的标记

  • mask_token – 一个特殊的标记,用于遮蔽输入(例如,不含信息的填充标记)

  • pad_to_max_tokens – 如果需要进行填充,将任意长度的序列调整为数据批次中的相同长度

同样,我们为德语定义一个查找层:

de_lookup_layer = tf.keras.layers.StringLookup(
    vocabulary=de_vocabulary, oov_token=de_unk_token, 
    mask_token=pad_token, pad_to_max_tokens=False
) 

在打好基础后,我们可以开始构建编码器。

定义编码器

我们从输入层开始构建编码器。输入层将接受一个包含标记序列的批次。每个标记序列的长度为n_en_seq_length个元素。记住,我们已经填充或截断了句子,确保它们的固定长度为n_en_seq_length

encoder_input = tf.keras.layers.Input(shape=(n_en_seq_length,), dtype=tf.string) 

接下来,我们使用之前定义的StringLookup层将字符串标记转换为词 ID。如我们所见,StringLookup层可以接受一个独特单词的列表(即词汇表),并创建一个查找操作,将给定的标记转换为数字 ID:

encoder_wid_out = en_lookup_layer(encoder_input) 

将词元转换为 ID 后,我们将生成的单词 ID 传递给词元嵌入层。我们传入词汇表的大小(从en_lookup_layerget_vocabulary()方法中获取)和嵌入大小(128),最后我们要求该层对任何零值输入进行掩蔽,因为它们不包含任何信息:

en_full_vocab_size = len(en_lookup_layer.get_vocabulary())
encoder_emb_out = tf.keras.layers.Embedding(en_full_vocab_size, 128, mask_zero=True)(encoder_wid_out) 

嵌入层的输出存储在 encoder_emb_out 中。接下来,我们定义一个 GRU 层来处理英文词元嵌入序列:

encoder_gru_out, encoder_gru_last_state = tf.keras.layers.GRU(256, return_sequences=True, return_state=True)(encoder_emb_out) 

注意,我们将 return_sequencesreturn_state 参数都设置为 True。总结一下,return_sequences 返回完整的隐藏状态序列作为输出(而不是仅返回最后一个状态),而 return_state 返回模型的最后状态作为额外的输出。我们需要这两个输出才能构建模型的其余部分。例如,我们需要将编码器的最后状态传递给解码器作为初始状态。为此,我们需要编码器的最后状态(存储在 encoder_gru_last_state 中)。我们将在后续详细讨论这个目的。现在我们已经准备好定义模型的编码器部分。它接收一批字符串词元序列,并返回完整的 GRU 隐藏状态序列作为输出。

encoder = tf.keras.models.Model(inputs=encoder_input, outputs=encoder_gru_out) 

定义好编码器后,让我们来构建解码器。

定义解码器

我们的解码器将比编码器更复杂。解码器的目标是,给定最后一个编码器状态和解码器预测的前一个词,预测下一个词。例如,对于德语句子:

ich ging zum Laden

我们定义:

输入ichgingzumLaden
输出ichgingzumLaden

这种技术被称为 教师强制。换句话说,解码器利用目标语言的前一个词元来预测下一个词元。这使得翻译任务对模型来说变得更容易。我们可以通过以下方式理解这一现象。假设老师让幼儿园的学生完成以下句子,只给出第一个词:

I ___ ____ ___ ___ ____ ____

这意味着孩子需要选择主语、动词和宾语;了解语言的语法结构;理解语言的语法规则;等等。因此,孩子生成不正确句子的可能性很高。

然而,如果我们要求孩子逐个单词地生成句子,他们可能更擅长于构造一个完整的句子。换句话说,我们要求孩子在给定以下条件的情况下生成下一个单词:

I ____

然后我们要求他们在给定的情况下填空:

I like ____

然后继续以相同的方式进行:

I like to ___, I like to fly ____, I like to fly kites ____

这样,孩子可以更好地生成一个正确且有意义的句子。我们可以采用相同的方法来减轻翻译任务的难度,如 图 9.10 所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_10.png

图 9.10:教师强制机制。输入中的深色箭头表示新引入的输入连接到解码器。右侧的图显示了解码器 GRU 单元如何变化。

为了解码器输入先前由解码器预测的标记,我们需要为解码器提供一个输入层。当以这种方式构造解码器的输入和输出时,对于长度为 n 的标记序列,输入和输出的长度是 n-1 个标记:

decoder_input = tf.keras.layers.Input(shape=(n_de_seq_length-1,), dtype=tf.string) 

接下来,我们使用之前定义的de_lookup_layer将标记转换为 ID:

decoder_wid_out = de_lookup_layer(decoder_input) 

类似于编码器,让我们为德语定义一个嵌入层:

de_full_vocab_size = len(de_lookup_layer.get_vocabulary())
decoder_emb_out = tf.keras.layers.Embedding(de_full_vocab_size, 128, mask_zero=True)(decoder_wid_out) 

我们在解码器中定义一个 GRU 层,它将接受标记嵌入并生成隐藏输出:

decoder_gru_out = tf.keras.layers.GRU(256, return_sequences=True)(decoder_emb_out, initial_state=encoder_gru_last_state) 

请注意,我们将编码器的最后状态传递给 GRU 的call()方法中的一个特殊参数initial_state。这确保了解码器使用编码器的最后状态来初始化其内存。

我们旅程的下一步将带我们走向机器学习中最重要的概念之一——“注意力”。到目前为止,解码器必须依赖编码器的最后状态作为关于源语言的“唯一”输入/信号。这就像要求用一个单词总结一个句子。通常,在这样做时,你会失去很多转换中的意义和信息。注意力缓解了这个问题。

注意力:分析编码器状态

不仅仅依赖编码器的最后状态,注意力使解码器能够分析整个状态输出历史。解码器在每一步的预测中都会这样做,并根据它在该步骤需要生成的内容创建所有状态输出的加权平均值。例如,在翻译 I went to the shop -> ich ging zum Laden 时,在预测单词 ging 时,解码器会更多地关注英文句子的前半部分,而不是后半部分。

多年来,注意力机制有许多不同的实现。正确强调注意力在神经机器翻译(NMT)系统中的重要性是非常重要的。正如你之前所学到的,位于编码器和解码器之间的上下文向量或思想向量是一个性能瓶颈(见图 9.11):

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_11.png

图 9.11:编码器-解码器架构

为了理解为什么这是一个瓶颈,让我们想象一下翻译下面的英文句子:

我去花市买花

这转换为以下内容:

Ich ging zum Blumenmarkt, um Blumen zu kaufen

如果我们要将其压缩成一个固定长度的向量,结果向量需要包含以下内容:

  • 关于主语的信息(

  • 关于动词的信息(

  • 关于宾语的信息(花市

  • 句子中主语、动词和宾语相互作用

通常,上下文向量的大小为 128 或 256 元素。依赖上下文向量来存储所有这些信息,而只使用一个小尺寸的向量是非常不切实际的,而且对于系统来说是一个极其困难的要求。因此,大多数时候,上下文向量未能提供进行良好翻译所需的完整信息。这导致解码器性能不佳,无法以最优方式翻译句子。

更糟糕的是,在解码过程中,上下文向量只能在开始时观察到。此后,解码器 GRU 必须记住上下文向量,直到翻译结束。对于长句子来说,这变得越来越困难。

注意力解决了这个问题。通过注意力机制,解码器将在每个解码时间步获得编码器的完整状态历史。这使得解码器能够访问源句子的丰富表示。此外,注意力机制引入了一个 softmax 层,允许解码器计算过去观察到的编码器状态的加权平均值,并将其作为解码器的上下文向量。这样,解码器就可以在不同的解码步骤中对不同的单词赋予不同的关注权重。

图 9.12 展示了注意力机制的概念性分解:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_12.png

图 9.12:NMT 中的概念性注意力机制

接下来,让我们看看如何计算注意力。

计算注意力

现在,让我们详细探讨注意力机制的实际实现。为此,我们将使用 Bahdanau 等人论文《通过学习联合对齐和翻译的神经机器翻译》中的 Bahdanau 注意力机制。我们将讨论原始的注意力机制。然而,由于 TensorFlow 的限制,我们将实现一个略有不同的版本。为了与论文保持一致,我们将使用以下符号:

  • 编码器的第 j^(th) 个隐藏状态:h[j]

  • i^(th) 个目标词:y[i]

  • i^(th) 时间步的解码隐藏状态:s[i]

  • 上下文向量:c[i]

我们的解码器 GRU 是输入 y[i] 和上一步隐藏状态 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_043.png 的函数。这可以表示如下:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_044.png

在这里,f 代表用于计算 y[i] 和 s[i-1] 的实际更新规则。通过引入注意力机制,我们为第 i^(th) 解码步骤引入了一个新的时间相关的上下文向量 c[i]。这个 c[i] 向量是所有展开的编码器步骤的隐藏状态的加权平均值。如果第 j^(th) 个单词在翻译第 i^(th) 个目标语言单词时更为重要,那么编码器的第 j^(th) 个隐藏状态将赋予更高的权重。这意味着模型可以学习在什么时间步,哪些单词更为重要,而不考虑两种语言的方向性或对齐不匹配的问题。现在,解码器的 GRU 模型变成了这样:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_045.png

从概念上讲,注意力机制可以被看作是一个独立的层,如图 9.13所示。正如所示,注意力作为一个层运作。注意力层负责生成解码过程中的第i^(th)时间步的c[i]。

现在让我们来看看如何计算c[i]:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_046.png

在这里,L是源句子中的单词数量,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_047.png是一个标准化权重,表示在计算第i(th)解码器预测时,第*j*(th)编码器隐藏状态的重要性。这个值是通过所谓的能量值计算的。我们将e[ij]表示为编码器在第j(th)位置的能量,用于预测解码器的第*i*(th)位置。e[ij]通过一个小型全连接网络计算,如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_048.png

换句话说,https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_049.png是通过一个多层感知机计算的,该网络的权重是v[a]、W[a]和U[a],而https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_050.png(解码器的前一个隐藏状态,来自第*(i-1)*(th)时间步)和*h*[j](编码器的第*j*(th)隐藏输出)是网络的输入。最后,我们使用 softmax 标准化对所有编码器时间步的能量值(即权重)进行标准化计算:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_051.png

注意力机制如图 9.13所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_13.png

图 9.13:注意力机制

实现注意力机制

如上所述,我们将实现 Bahdanau 注意力的一个稍微不同的变体。这是因为 TensorFlow 目前不支持可以在每个时间步骤上迭代计算的注意力机制,类似于 RNN 的工作方式。因此,我们将把注意力机制与 GRU 模型解耦,并单独计算。我们将把注意力输出与 GRU 层的隐藏输出拼接,并将其输入到最终的预测层。换句话说,我们不是将注意力输出输入到 GRU 模型,而是直接输入到预测层。这在图 9.14中有所示意:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_14.png

图 9.14:本章中采用的注意力机制

为了实现注意力机制,我们将使用 Keras 的子类化 API。我们将定义一个名为BahdanauAttention的类(该类继承自Layer类),并重写其中的两个函数:

  • __init__() – 定义层的初始化逻辑

  • call() – 定义层的计算逻辑

我们定义的类将如下所示。但不用担心,下面我们将详细讲解这两个函数:

class BahdanauAttention(tf.keras.layers.Layer):
    def __init__(self, units):
        super().__init__()
        # Weights to compute Bahdanau attention
        self.Wa = tf.keras.layers.Dense(units, use_bias=False)
        self.Ua = tf.keras.layers.Dense(units, use_bias=False)
        self.attention = 
        tf.keras.layers.AdditiveAttention(use_scale=True)
    def call(self, query, key, value, mask, 
    return_attention_scores=False):
        # Compute 'Wa.ht'.
        wa_query = self.Wa(query)
        # Compute 'Ua.hs'.
        ua_key = self.Ua(key)
        # Compute masks
        query_mask = tf.ones(tf.shape(query)[:-1], dtype=bool)
        value_mask = mask
        # Compute the attention
        context_vector, attention_weights = self.attention(
            inputs = [wa_query, value, ua_key],
            mask=[query_mask, value_mask, value_mask],
            return_attention_scores = True,
        )

        if not return_attention_scores:
            return context_vector
        else:
            return context_vector, attention_weights 

首先,我们将查看__init__()函数。

在这里,你可以看到我们定义了三个层:权重矩阵 W_a,权重矩阵 U_a,以及最终的 AdditiveAttention 层,其中包含了我们之前讨论的注意力计算逻辑。AdditiveAttention 层接受查询、值和键。查询是解码器的状态,值和键是所有由编码器产生的状态。

我们很快会更详细地讨论这一层。接下来让我们来看一下call()函数中定义的计算:

def call(self, query, key, value, mask, return_attention_scores=False):
        # Compute 'Wa.ht'
        wa_query = self.Wa(query)
        # Compute 'Ua.hs'
        ua_key = self.Ua(key)
        # Compute masks
        query_mask = tf.ones(tf.shape(query)[:-1], dtype=bool)
        value_mask = mask
        # Compute the attention
        context_vector, attention_weights = self.attention(
            inputs = [wa_query, value, ua_key],
            mask=[query_mask, value_mask, value_mask],
            return_attention_scores = True,
        )

        if not return_attention_scores:
            return context_vector
        else:
            return context_vector, attention_weights 

首先需要注意的是,这个函数接受查询、键和值这三个输入。这三个元素将驱动注意力计算。在 Bahdanau 注意力中,你可以将键和值看作是相同的东西。查询将代表每个解码器 GRU 在每个时间步的隐藏状态,值(或键)将代表每个编码器 GRU 在每个时间步的隐藏状态。换句话说,我们正在根据编码器的隐藏状态提供的值,为每个解码器位置查询一个输出。

让我们回顾一下我们需要执行的计算:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_048.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_051.png

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_046.png

首先我们计算 wa_query(代表 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_055.png)和 ua_key(代表 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_056.png)。接着,我们将这些值传递到注意力层。AdditiveAttention 层(www.tensorflow.org/api_docs/python/tf/keras/layers/AdditiveAttention)执行以下步骤:

  1. wa_query[batch_size, Tq, dim] 形状重塑为 [batch_size, Tq, 1, dim],并将 ua_key[batch_size, Tv, dim] 形状重塑为 [batch_size, 1, Tv, dim]

  2. 计算形状为 [batch_size, Tq, Tv] 的分数:scores = tf.reduce_sum(tf.tanh(query + key), axis=-1)

  3. 使用分数来计算一个形状为 [batch_size, Tq, Tv] 的分布,并通过 softmax 激活函数计算:distribution = tf.nn.softmax(scores)

  4. 使用 distribution 来创建一个形状为 [batch_size, Tq, dim] 的值的线性组合。

  5. 返回 tf.matmul(distribution, value),它代表了所有编码器状态(即 value)的加权平均值。

在这里,你可以看到步骤 2 执行了第一个方程,步骤 3 执行了第二个方程,最后步骤 4 执行了第三个方程。另一个值得注意的事项是,步骤 2 并没有提到来自第一个方程的 https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_057.pnghttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_058.png 本质上是一个权重矩阵,我们用它来计算点积。我们可以通过在定义 AdditiveAttention 层时设置 use_scale=True 来引入这个权重矩阵:

self.attention = tf.keras.layers.AdditiveAttention(use_scale=True) 

另一个重要的参数是 return_attention_scores,当调用 AdditiveAttention 层时,该参数给我们提供了步骤 3中定义的分布权重矩阵。我们将使用它来可视化模型在解码翻译时关注的部分。

定义最终模型

在理解并实现了注意力机制后,让我们继续实现解码器。我们将获得每个时间步的注意力输出序列,每个时间步有一个被关注的输出。

此外,我们还会得到注意力权重分布矩阵,用于可视化注意力模式在输入和输出之间的分布:

decoder_attn_out, attn_weights = BahdanauAttention(256)(
    query=decoder_gru_out, key=encoder_gru_out, value=encoder_gru_out,
    mask=(encoder_wid_out != 0),
    return_attention_scores=True
) 

在定义注意力时,我们还会传递一个掩码,表示在计算输出时需要忽略哪些标记(例如,填充的标记)。将注意力输出与解码器的 GRU 输出结合,创建一个单一的拼接输入供预测层使用:

context_and_rnn_output = tf.keras.layers.Concatenate(axis=-1)([decoder_attn_out, decoder_gru_out]) 

最后,预测层将拼接后的注意力上下文向量和 GRU 输出结合起来,生成每个时间步长的德语标记的概率分布:

# Final prediction layer (size of the vocabulary)
decoder_out = tf.keras.layers.Dense(full_de_vocab_size, activation='softmax')(context_and_rnn_output) 

在完全定义编码器和解码器后,我们来定义端到端模型:

seq2seq_model = tf.keras.models.Model(inputs=[encoder.inputs, decoder_input], outputs=decoder_out)
seq2seq_model.compile(loss='sparse_categorical_crossentropy', optimizer='adam', metrics='accuracy') 

我们还将定义一个名为attention_visualizer的辅助模型:

attention_visualizer = tf.keras.models.Model(inputs=[encoder.inputs, decoder_input], outputs=[attn_weights, decoder_out]) 

attention_visualizer可以为给定的输入集生成注意力模式。这是一种便捷的方式,能够判断模型在解码过程中是否关注了正确的词语。此可视化模型将在完整模型训练后使用。接下来,我们将探讨如何训练我们的模型。

训练 NMT

现在我们已经定义了 NMT 架构并预处理了训练数据,训练模型变得相当直接。在这里,我们将定义并展示(见图 9.15)用于训练的确切过程:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_15.png

图 9.15:NMT 的训练过程

对于模型训练,我们将定义一个自定义的训练循环,因为有一个特殊的度量我们想要跟踪。不幸的是,这个度量并不是一个现成的 TensorFlow 度量。但是在此之前,我们需要定义几个工具函数:

def prepare_data(de_lookup_layer, train_xy, valid_xy, test_xy):
    """ Create a data dictionary from the dataframes containing data 
    """

    data_dict = {}
    for label, data_xy in zip(['train', 'valid', 'test'], [train_xy, 
    valid_xy, test_xy]):

        data_x, data_y = data_xy
        en_inputs = data_x
        de_inputs = data_y[:,:-1]
        de_labels = de_lookup_layer(data_y[:,1:]).numpy()
        data_dict[label] = {'encoder_inputs': en_inputs, 
        'decoder_inputs': de_inputs, 'decoder_labels': de_labels}

    return data_dict 

prepare_data()函数接受源句子和目标句子对,并生成编码器输入、解码器输入和解码器标签。让我们了解一下这些参数:

  • de_lookup_layer – 德语语言的StringLookup

  • train_xy – 包含训练集中标记化的英语句子和标记化的德语句子的元组

  • valid_xy – 与train_xy类似,但用于验证数据

  • test_xy – 与train_xy类似,但用于测试数据

对于每个训练、验证和测试数据集,此函数会生成以下内容:

  • encoder_inputs – 经过分词处理的英语句子,来自预处理的数据集

  • decoder_inputs – 每个德语句子的所有标记,除去最后一个标记

  • decoder_labels – 每个德语句子的所有标记 ID,除去第一个标记 ID,标记 ID 由de_lookup_layer生成

所以,你可以看到decoder_labels将是decoder_inputs向左移动一个标记。接下来,我们定义shuffle_data()函数,用于打乱提供的数据集:

def shuffle_data(en_inputs, de_inputs, de_labels, shuffle_inds=None): 
    """ Shuffle the data randomly (but all of inputs and labels at 
    ones)"""

    if shuffle_inds is None:
        # If shuffle_inds are not passed create a shuffling 
        automatically
        shuffle_inds = 
        np.random.permutation(np.arange(en_inputs.shape[0]))
    else:
        # Shuffle the provided shuffle_inds
        shuffle_inds = np.random.permutation(shuffle_inds)

    # Return shuffled data
    return (en_inputs[shuffle_inds], de_inputs[shuffle_inds], 
    de_labels[shuffle_inds]), shuffle_inds 

这里的逻辑非常简单。我们使用encoder_inputsdecoder_inputsdecoder_labels(由prepare_data()步骤生成)以及shuffle_inds。如果shuffle_indsNone,则生成索引的随机排列。否则,我们生成提供的shuffle_inds的随机排列。最后,我们根据洗牌后的索引对所有数据进行索引。然后我们就可以训练模型:

Def train_model(model, en_lookup_layer, de_lookup_layer, train_xy, valid_xy, test_xy, epochs, batch_size, shuffle=True, predict_bleu_at_training=False):
    """ Training the model and evaluating on validation/test sets """

    # Define the metric
    bleu_metric = BLEUMetric(de_vocabulary)
    # Define the data
    data_dict = prepare_data(de_lookup_layer, train_xy, valid_xy, 
    test_xy)
    shuffle_inds = None

    for epoch in range(epochs):
        # Reset metric logs every epoch
        if predict_bleu_at_training:
            blue_log = []
        accuracy_log = []
        loss_log = []
        # ========================================================== #
        #                     Train Phase                            #
        # ========================================================== #
        # Shuffle data at the beginning of every epoch
        if shuffle:
            (en_inputs_raw,de_inputs_raw,de_labels), shuffle_inds  = 
            shuffle_data(
                data_dict['train']['encoder_inputs'],
                data_dict['train']['decoder_inputs'],
                data_dict['train']['decoder_labels'],
                shuffle_inds
            )
        else:
            (en_inputs_raw,de_inputs_raw,de_labels)  = (
                data_dict['train']['encoder_inputs'],
                data_dict['train']['decoder_inputs'],
                data_dict['train']['decoder_labels'],
            )
        # Get the number of training batches
        n_train_batches = en_inputs_raw.shape[0]//batch_size

        prev_loss = None
        # Train one batch at a time
        for i in range(n_train_batches):
            # Status update
            print("Training batch {}/{}".format(i+1, n_train_batches), 
            end='\r')
            # Get a batch of inputs (english and german sequences)
            x = [en_inputs_raw[i*batch_size:(i+1)*batch_size], 
            de_inputs_raw[i*batch_size:(i+1)*batch_size]]
            # Get a batch of targets (german sequences offset by 1)
            y = de_labels[i*batch_size:(i+1)*batch_size]

            loss, accuracy = model.evaluate(x, y, verbose=0)

            # Check if any samples are causing NaNs
            check_for_nans(loss, model, en_lookup_layer, 
            de_lookup_layer)

            # Train for a single step
            model.train_on_batch(x, y)    

            # Update the epoch's log records of the metrics
            loss_log.append(loss)
            accuracy_log.append(accuracy)

            if predict_bleu_at_training:
                # Get the final prediction to compute BLEU
                pred_y = model.predict(x)
                bleu_log.append(bleu_metric.calculate_bleu_from_
                predictions(y, pred_y))

        print("")
        print("\nEpoch {}/{}".format(epoch+1, epochs))
        if predict_bleu_at_training:
            print(f"\t(train) loss: {np.mean(loss_log)} - accuracy: 
            {np.mean(accuracy_log)} - bleu: {np.mean(bleu_log)}")
        else:
            print(f"\t(train) loss: {np.mean(loss_log)} - accuracy: 
            {np.mean(accuracy_log)}")
        # ========================================================== #
        #                     Validation Phase                       #
        # ========================================================== #

        val_en_inputs = data_dict['valid']['encoder_inputs']
        val_de_inputs = data_dict['valid']['decoder_inputs']
        val_de_labels = data_dict['valid']['decoder_labels']

        val_loss, val_accuracy, val_bleu = evaluate_model(
            model, de_lookup_layer, val_en_inputs, val_de_inputs, 
            val_de_labels, batch_size
        )

        # Print the evaluation metrics of each epoch
        print("\t(valid) loss: {} - accuracy: {} - bleu: 
        {}".format(val_loss, val_accuracy, val_bleu))

    # ============================================================== #
    #                      Test Phase                                #
    # ============================================================== #

    test_en_inputs = data_dict['test']['encoder_inputs']
    test_de_inputs = data_dict['test']['decoder_inputs']
    test_de_labels = data_dict['test']['decoder_labels']

    test_loss, test_accuracy, test_bleu = evaluate_model(
            model, de_lookup_layer, test_en_inputs, test_de_inputs, 
            test_de_labels, batch_size
    )

    print("\n(test) loss: {} - accuracy: {} - bleu: 
    {}".format(test_loss, test_accuracy, test_bleu)) 

在模型训练过程中,我们执行以下操作:

  • 使用prepare_data()函数准备编码器和解码器输入以及解码器输出

  • 对于每个周期:

    • 如果标志shuffle设置为True,则需要对数据进行洗牌

    • 对于每次迭代:

      • 从准备好的输入和输出中获取一个批次的数据

      • 使用model.evaluate评估该批次,以获取损失和准确率

      • 检查是否有样本返回nan值(这对于调试很有用)

      • 在批次数据上进行训练

      • 如果标志predict_bleu_at_training设置为True,则计算 BLEU 分数

    • 在验证数据上评估模型,以获取验证损失和准确率

    • 计算验证数据集的 BLEU 分数

  • 计算测试数据上的损失、准确率和 BLEU 分数

您可以看到,我们正在计算一个新的度量标准,称为 BLEU 分数。BLEU 是一种专门用于衡量序列到序列问题表现的指标。它试图最大化 n-gram 标记的正确性,而不是单独标记的准确性(例如,准确率)。BLEU 分数越高,效果越好。您将在下一部分了解更多关于 BLEU 分数如何计算的信息。您可以在代码中查看BLEUMetric对象定义的逻辑。

在这一步,我们主要进行文本预处理,去除无意义的标记,以避免 BLEU 分数被高估。例如,如果我们包括<pad>标记,您会看到高的 BLEU 分数,因为短句中会有长的<pad>标记序列。为了计算 BLEU 分数,我们将使用一个第三方实现,链接地址为:github.com/tensorflow/nmt/blob/master/nmt/scripts/bleu.py

注意

如果批次大小较大,您可能会看到 TensorFlow 抛出如下异常:

Resource exhausted: OOM when allocating tensor with ... 

在这种情况下,您可能需要重启笔记本内核、减少批次大小,并重新运行代码。

另外,我们还会做一件事,但尚未讨论,那就是检查是否存在NaN(即不是数字)值。看到损失值在训练周期结束时变为NaN是非常令人沮丧的。这是通过使用check_for_nan()函数完成的。该函数会打印出导致NaN值的具体数据点,这样您就能更清楚地了解原因。您可以在代码中找到check_for_nan()函数的实现。

注意

到了 2021 年,德语到英语的当前最先进的 BLEU 分数为 35.14 (paperswithcode.com/sota/machine-translation-on-wmt2014-english-german)。

一旦模型完全训练完成,你应该会看到验证和测试数据的 BLEU 分数大约为 15。考虑到我们使用的数据比例非常小(即 250,000 个句子,来自 400 多万句子),而且与最先进的模型相比我们使用的模型较为简单,这个分数已经相当不错了。

提高 NMT 性能的深度 GRU

一个显而易见的改进是增加层数,通过将 GRU 层堆叠起来,从而创建一个深度 GRU。例如,Google 的 NMT 系统使用了八层堆叠的 LSTM 层 (Google 的神经机器翻译系统:弥合人类与机器翻译之间的差距,Wu 等人,技术报告(2016 年))。尽管这会影响计算效率,但更多的层大大提高了神经网络学习两种语言语法和其他语言特征的能力。

接下来,让我们详细了解如何计算 BLEU 分数。

BLEU 分数 – 评估机器翻译系统

BLEU 代表 Bilingual Evaluation Understudy,是一种自动评估机器翻译系统的方法。该指标最早在论文 BLEU: A Method for Automatic Evaluation of Machine Translation, Papineni 等人, 第 40 届计算语言学协会年会论文集 (ACL),费城,2002 年 7 月: 311-318 中提出。我们将使用在 github.com/tensorflow/nmt/blob/master/nmt/scripts/bleu.py 上找到的 BLEU 分数实现。让我们了解在机器翻译的上下文中如何计算这个分数。

让我们通过一个例子来学习如何计算 BLEU 分数。假设我们有两个候选句子(即由我们的机器翻译系统预测的句子)和一个参考句子(即对应的实际翻译),用于某个给定的源句子:

  • 参考 1:猫坐在垫子上

  • 候选句子 1:猫在垫子上

为了评估翻译的质量,我们可以使用一个度量标准,精确度。精确度是指候选翻译中有多少词语实际上出现在参考翻译中。通常情况下,如果你考虑一个有两个类别(分别为负类和正类)的分类问题,精确度的计算公式如下:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_059.png

现在让我们计算候选句子 1 的精确度:

精确度 = 候选词语在参考中出现的次数 / 候选中的词语总数

数学上,这可以通过以下公式表示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_060.png

候选句子 1 的精确度 = 5/6

这也被称为 1-gram 精确度,因为我们一次只考虑一个单词。

现在让我们引入一个新的候选项:

  • 候选项 2:The the the cat cat cat

人类不难看出候选项 1 比候选项 2 要好得多。让我们计算精度:

候选项 2 的精度 = 6/6 = 1

如我们所见,精度分数与我们做出的判断不一致。因此,仅依靠精度不能作为翻译质量的一个可靠衡量标准。

修改后的精度

为了解决精度的局限性,我们可以使用修改后的 1-gram 精度。修改后的精度通过参考中该单词出现的次数来裁剪候选句子中每个唯一单词的出现次数:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_061.png

因此,对于候选项 1 和 2,修改后的精度如下:

Mod-1-gram-Precision 候选项 1 = (1 + 1 + 1 + 1 + 1)/ 6 = 5/6

Mod-1-gram-Precision 候选项 2 = (2 + 1) / 6 = 3/6

我们已经可以看到,这是一个很好的修改,因为候选项 2 的精度已经降低。这可以扩展到任何 n-gram,通过考虑一次n个单词,而不是单个单词。

简洁度惩罚

精度自然偏向短句子。这在评估中引发了一个问题,因为机器翻译系统可能会为较长的参考句子生成较短的句子,并且仍然具有更高的精度。因此,引入了简洁度惩罚来避免这种情况。简洁度惩罚按以下方式计算:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_074.png

这里,c是候选句子的长度,r是参考句子的长度。在我们的例子中,我们按如下方式进行计算:

最终的 BLEU 得分

接下来,为了计算 BLEU 得分,我们首先计算不同n=1,2,…,N值的几个修改后的 n-gram 精度。然后,我们将计算 n-gram 精度的加权几何平均值:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_065.png

这里,w[n]是修改后的 n-gram 精度p[n]的权重。默认情况下,所有 n-gram 值使用相等的权重。总之,BLEU 计算修改后的 n-gram 精度,并通过简洁度惩罚来惩罚修改后的 n-gram 精度。修改后的 n-gram 精度避免了给无意义句子(例如候选项 2)赋予潜在的高精度值。

可视化注意力模式

记得我们专门定义了一个叫做attention_visualizer的模型来生成注意力矩阵吗?在模型训练完成后,我们现在可以通过向模型输入数据来查看这些注意力模式。下面是模型的定义:

attention_visualizer = tf.keras.models.Model(inputs=[encoder.inputs, decoder_input], outputs=[attn_weights, decoder_out]) 

我们还将定义一个函数,以获取处理后的注意力矩阵以及标签数据,方便我们直接用于可视化:

def get_attention_matrix_for_sampled_data(attention_model, target_lookup_layer, test_xy, n_samples=5):

    test_x, test_y = test_xy

    rand_ids = np.random.randint(0, len(test_xy[0]), 
    size=(n_samples,))
    results = []

    for rid in rand_ids:
        en_input = test_x[rid:rid+1]
        de_input = test_y[rid:rid+1,:-1]

        attn_weights, predictions = attention_model.predict([en_input, 
        de_input])
        predicted_word_ids = np.argmax(predictions, axis=-1).ravel()
        predicted_words = [target_lookup_layer.get_vocabulary()[wid] 
        for wid in predicted_word_ids]

        clean_en_input = []
        en_start_i = 0
        for i, w in enumerate(en_input.ravel()):
            if w=='<pad>': 
                en_start_i = i+1
                continue

            clean_en_input.append(w)
            if w=='</s>': break
        clean_predicted_words = []
        for w in predicted_words:
            clean_predicted_words.append(w)
            if w=='</s>': break

        results.append(
            {
                "attention_weights": attn_weights[
                0,:len(clean_predicted_words),en_start_i:en_start_
                i+len(clean_en_input)
                ], 
                "input_words": clean_en_input,  
                "predicted_words": clean_predicted_words
            }
        )

    return results 

该函数执行以下操作:

  • 随机从测试数据中抽取n_samples个索引。

  • 对于每个随机索引:

    • 获取该索引处数据点的输入(en_inputde_input

    • 通过将en_inputde_input输入到attention_visualizer中(存储在predicted_words中),获取预测词

    • 清理en_input,移除任何无信息的标记(例如<pad>),并将其分配给clean_en_input

    • 清理predicted_words,通过移除</s>标记之后的词(存储在clean_predicted_words中)

    • 仅获取与清理后的输入和预测词对应的注意力权重,来源于attn_weights

    • clean_en_inputclean_predicted_words和注意力权重矩阵附加到结果中

结果包含了我们需要的所有信息,以便可视化注意力模式。你可以在笔记本Ch09-Seq2seq-Models/ch09_seq2seq.ipynb中看到用于创建以下可视化效果的实际代码。

让我们从测试数据集中选取几个样本,并可视化模型展现的注意力模式(图 9.16):

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_16.pnghttps://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_16.1.png

图 9.16:可视化几个测试输入的注意力模式

总的来说,我们希望看到一个热图,具有大致对角线方向的能量激活。这是因为两种语言在语言结构的方向上具有相似之处。我们可以清楚地看到这一点,在这两个示例中都能看到。

看第一个示例中的特定单词,你可以看到模型在预测Abends时,特别关注evening,在预测Ambiente时,特别关注atmosphere,依此类推。第二个示例中,模型特别关注单词free来预测kostenlosen,这是德语中free的意思。

接下来,我们讨论如何从训练模型中推断翻译。

使用神经机器翻译(NMT)进行推理

推理过程与 NMT 的训练过程略有不同(图 9.17)。由于在推理时没有目标句子,我们需要一种方法在编码阶段结束时触发解码器。这个过程并不复杂,因为我们已经在数据中为此做好了准备。我们只需通过使用<s>作为解码器的第一个输入来启动解码器。然后,我们通过使用预测词作为下一时间步的输入,递归地调用解码器。我们以这种方式继续,直到模型:

  • 输出</s>作为预测的标记,或者

  • 达到预定义的句子长度

为此,我们必须使用训练模型的现有权重定义一个新的模型。这是因为我们的训练模型设计为一次处理一系列解码器输入。我们需要一个机制来递归地调用解码器。以下是如何定义推理模型:

  • 定义一个编码器模型,该模型输出编码器的隐藏状态序列和最后的编码器状态。

  • 定义一个新的解码器,接受具有时间维度为 1 的解码器输入,并输入一个新的值,我们将把解码器的上一个隐藏状态值(由编码器的最后状态初始化)输入到其中。

这样,我们就可以开始输入数据,生成如下预测:

这将生成给定输入文本序列的翻译:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_17.png

图 9.17:从 NMT 推断

实际代码可以在笔记本Ch09-Seq2seq-Models/ch09_seq2seq.ipynb中找到。我们将留给读者去研究代码并理解实现。我们将在这里结束关于机器翻译的讨论。接下来,我们简要地看看序列到序列学习的另一个应用。

Seq2Seq 模型的其他应用 – 聊天机器人

另一种流行的序列到序列模型应用是创建聊天机器人。聊天机器人是一种能够与人类进行真实对话的计算机程序。这类应用对于拥有庞大客户群体的公司非常有用。应对客户提出的一些显而易见的基础问题,占据了客户支持请求的很大一部分。当聊天机器人能够找到答案时,它可以为客户解答这些基础问题。此外,如果聊天机器人无法回答某个问题,用户的请求会被转发给人工操作员。聊天机器人可以节省人工操作员回答基础问题的时间,让他们专注于更难处理的任务。

训练聊天机器人

那么,我们如何使用序列到序列(sequence-to-sequence)模型来训练一个聊天机器人呢?答案其实非常直接,因为我们已经学习过机器翻译模型。唯一的区别在于源句子和目标句子对的形成方式。

在 NMT 系统中,句子对由源句子和该句子在目标语言中的相应翻译组成。然而,在训练聊天机器人时,数据是从两个人之间的对话中提取的。源句子将是 A 人物所说的句子/短语,目标句子则是 B 人物对 A 人物所作出的回复。可以用于此目的的一个数据集是电影对白数据集,包含了人们之间的对话,可以在www.cs.cornell.edu/~cristian/Cornell_Movie-Dialogs_Corpus.html找到。

这里有一些其他数据集的链接,用于训练对话型聊天机器人:

图 9.18 显示了聊天机器人系统与神经机器翻译系统的相似性。例如,我们使用由两人对话组成的数据集来训练聊天机器人。编码器接收一个人说的句子/短语,解码器被训练以预测另一个人的回应。通过这种方式训练后,我们可以使用聊天机器人来回答给定的问题:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_18.png

图 9.18: 聊天机器人示意图

评估聊天机器人 – 图灵测试

构建聊天机器人后,评估其效果的一种方法是使用图灵测试。图灵测试由艾伦·图灵在上世纪 50 年代发明,用于衡量机器的智能程度。实验设置非常适合评估聊天机器人。实验设置如下:

有三个参与方: 一个评估者(即人类)(A), 另一个人类(B), 和一个机器人(C). 他们三个坐在三个不同的房间里,以便彼此互不见面。他们唯一的交流媒介是文本,一方将文本输入计算机,接收方在自己的计算机上看到文本。评估者与人类和机器人进行交流。在对话结束时,评估者需要区分机器人和人类。如果评估者无法做出区分,机器人被视为通过了图灵测试。这个设置如图 9.19 所示:

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/B14070_09_19.png

图 9.19: 图灵测试

这部分介绍了 Seq2Seq 模型的其他应用。我们简要讨论了创建聊天机器人的应用,这是序列模型的一种流行用途。

总结

在这一章中,我们详细讨论了 NMT 系统。机器翻译是将给定的文本语料从源语言翻译到目标语言的任务。首先,我们简要回顾了机器翻译的历史,以便培养对机器翻译发展的理解,帮助我们认识到它今天的成就。我们看到,今天表现最好的机器翻译系统实际上是 NMT 系统。接下来,我们解决了从英语到德语翻译的 NMT 任务。我们讨论了数据预处理工作,包括提取数据的重要统计信息(例如序列长度)。然后我们讲解了这些系统的基本概念,并将模型分解为嵌入层、编码器、上下文向量和解码器。我们还介绍了像教师强制(teacher forcing)和巴赫达诺注意力(Bahdanau attention)等技术,旨在提高模型性能。接着我们讨论了 NMT 系统的训练和推理过程。我们还讨论了一种名为 BLEU 的新指标,以及它是如何用来衡量机器翻译等序列到序列问题的表现的。

最后,我们简要讨论了序列到序列学习的另一个热门应用:聊天机器人。聊天机器人是能够与人类进行真实对话甚至回答问题的机器学习应用。我们看到,NMT 系统和聊天机器人工作原理相似,唯一不同的是训练数据。我们还讨论了图灵测试,这是一种可以用来评估聊天机器人的定性测试。

在下一章中,我们将介绍一种在 2016 年推出的新型模型,它在 NLP 和计算机视觉领域都处于领先地位:Transformer。

要访问本书的代码文件,请访问我们的 GitHub 页面:packt.link/nlpgithub

加入我们的 Discord 社区,结识志同道合的人,并与超过 1000 名成员一起学习:packt.link/nlp

https://github.com/OpenDocCN/freelearn-dl-pt3-zh/raw/master/docs/nlp-tf-2e/img/QR_Code5143653472357468031.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值