PyTorch-1-x-自然语言处理实用指南-全-

PyTorch 1.x 自然语言处理实用指南(全)

原文:zh.annas-archive.org/md5/da825e03093e3d0e5022fb90bb0f3499

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在互联网时代,每天从社交媒体和其他平台生成大量文本数据,理解和利用这些数据是一项至关重要的技能。本书将帮助您构建用于自然语言处理NLP)任务的深度学习模型,帮助您从文本中提取有价值的见解。

我们将从了解如何安装 PyTorch 和使用 CUDA 加速处理速度开始。接着,您将通过实际示例探索 NLP 架构的工作原理。后续章节将指导您掌握诸如词嵌入、CBOW 和 PyTorch 中的分词等重要原则。您还将学习一些处理文本数据以及如何利用深度学习进行 NLP 任务的技巧。接下来,我们将演示如何实现深度学习和神经网络架构,构建可以分类、翻译文本和进行情感分析的模型。最后,您将学习如何构建高级 NLP 模型,如会话式聊天机器人。

通过本书,您将了解如何使用 PyTorch 进行深度学习解决不同的 NLP 问题,以及如何构建模型来解决这些问题。

适合读者

这本 PyTorch 书籍适合 NLP 开发人员、机器学习和深度学习开发人员,或者任何希望利用传统 NLP 方法和深度学习架构构建智能语言应用程序的人士。如果您希望采用现代 NLP 技术和模型来开发项目,那么这本书适合您。需要具备 Python 编程的工作知识和 NLP 任务的基础知识。

这本书涵盖了什么内容

第一章**,机器学习和深度学习基础,概述了机器学习和神经网络的基本方面。

第二章**,开始使用 PyTorch 1.x 进行 NLP,向您展示如何下载、安装和启动 PyTorch。我们还将介绍包的基本功能。

第三章**,NLP 和文本嵌入,向您展示如何为 NLP 创建文本嵌入,并将其用于基础语言模型中。

第四章**,文本预处理、词干提取和词形还原,向您展示如何为 NLP 深度学习模型预处理文本数据。

第五章**,递归神经网络和情感分析,深入讲解了递归神经网络的基础,并向您展示如何使用它们从头开始构建情感分析模型。

第六章**, 用于文本分类的卷积神经网络, 介绍了卷积神经网络的基础知识,并展示了如何使用它们构建一个用于文本分类的工作模型。

第七章**, 使用序列到序列神经网络进行文本翻译, 引入了用于深度学习的序列到序列模型的概念,并演示了如何使用它们构建一个将文本翻译成另一种语言的模型。

第八章**, 使用基于注意力的神经网络构建聊天机器人, 讨论了在序列到序列深度学习模型中使用注意力的概念,还展示了如何使用它们从头开始构建一个完全工作的聊天机器人。

第九章**, 未来的道路, 讨论了目前在 NLP 深度学习中使用的一些最先进的模型,并探讨了该领域未来面临的一些挑战和问题。

要充分利用本书

您需要在计算机上安装 Python 的某个版本。所有代码示例都已使用版本 3.7 进行测试。您还需要一个用于本书深度学习组件的工作 PyTorch 环境。所有深度学习模型均使用版本 1.4 构建;但是,大多数代码应该可以与更高版本一起使用。

本书中的代码使用了几个 Python 库;但是,这些内容将在相关章节中介绍。

如果您使用本书的数字版,建议您自行输入代码或通过 GitHub 存储库(链接在下一节中提供)访问代码。这样做可以帮助您避免与复制粘贴代码相关的任何潜在错误。

下载示例代码文件

您可以从您在www.packt.com的账户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support,注册并直接通过电子邮件接收文件。

您可以按照以下步骤下载代码文件:

  1. www.packt.com上登录或注册。

  2. 选择支持选项卡。

  3. 点击代码下载

  4. 搜索框中输入书名,并按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的解压缩文件夹:

  • 适用于 Windows 的 WinRAR/7-Zip

  • 适用于 Mac 的 Zipeg/iZip/UnRarX

  • 适用于 Linux 的 7-Zip/PeaZip

本书的代码包也托管在 GitHub 上:github.com/PacktPublishing/Hands-On-Natural-Language-Processing-with-PyTorch-1.x。如果代码有更新,将在现有的 GitHub 仓库中更新。

我们还有来自丰富书籍和视频目录的其他代码包可供使用:github.com/PacktPublishing/。请查看!

下载彩色图像

我们还提供了一份包含本书中使用的屏幕截图/图示的彩色图像的 PDF 文件。您可以在这里下载:static.packt-cdn.com/downloads/9781789802740_ColorImages.pdf

使用的约定

本书中使用了多种文本约定。

文本中的代码:表示文本中的代码字词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。以下是一个例子:“将下载的 WebStorm-10*.dmg 磁盘映像文件挂载为系统中的另一个磁盘。”

代码块如下所示:

import torch

当我们希望引起您对代码块的特定部分的注意时,相关行或条目将以粗体显示:

word_1 = ‘cat'
word_2 = ‘dog'
word_3 = ‘bird'

任何命令行输入或输出均写成以下格式:

$ mkdir flaskAPI
$ cd flaskAPI

粗体:表示新术语、重要词汇或屏幕上显示的字词。例如,菜单或对话框中的字词会以这种方式出现在文本中。以下是一个例子:“从管理面板中选择系统信息。”

提示或重要注意事项

显示如此。

联系我们

我们始终欢迎读者的反馈。

customercare@packtpub.com

勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误是难免的。如果您在本书中发现了错误,请向我们报告。请访问 www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

copyright@packt.com,附带材料的链接。

如果您有兴趣成为作者:如果您在某个专题上有专业知识,并且有意撰写或为书籍做贡献,请访问 authors.packtpub.com

评价

请留下您的评价。阅读并使用本书后,请在您购买书籍的网站上留下评价。潜在的读者可以通过您的公正意见做出购买决策,我们在 Packt 可以了解您对我们产品的看法,而我们的作者也可以看到您对他们书籍的反馈。谢谢!

欲了解更多有关 Packt 的信息,请访问 packt.com

第一部分:PyTorch 1.x 在 NLP 中的基本要素

在这一部分,您将学习关于自然语言处理NLP)背景下的 PyTorch 1.x 基本概念。您还将学习如何在您的计算机上安装 PyTorch 1.x,并如何使用 CUDA 加速处理速度。

本节包括以下章节:

  • 第一章机器学习和深度学习基础

  • 第二章开始使用 PyTorch 1.x 进行 NLP

第一章:机器学习和深度学习基础

我们的世界充满了自然语言数据。在过去的几十年里,我们彼此之间的沟通方式已经转变为数字领域,因此这些数据可以用来构建能够改进我们在线体验的模型。从在搜索引擎中返回相关结果,到在电子邮件中自动完成下一个输入的词语,能够从自然语言中提取洞察力的好处是显而易见的。

尽管我们人类理解语言的方式与模型或人工智能理解的方式有显著区别,但通过揭示机器学习及其用途,我们可以开始理解这些深度学习模型如何理解语言,以及模型从数据中学习时发生的基本情况。

本书中,我们将探讨人工智能和深度学习在自然语言处理中的应用。通过使用 PyTorch,我们将逐步学习如何构建模型,从而进行情感分析、文本分类和序列翻译,这将使我们能够构建基本的聊天机器人。通过涵盖每个模型背后的理论,并演示如何实际实施它们,我们将揭开自然语言处理NLP)领域的神秘面纱,并为您提供足够的背景知识,让您可以开始构建自己的模型。

在我们的第一章中,我们将探讨一些机器学习的基本概念。然后,我们将进一步深入研究深度学习、神经网络以及深度学习方法相对于基本机器学习技术的优势。最后,我们将更详细地探讨深度学习,特别是在处理自然语言相关任务时,以及我们如何利用深度学习模型从自然语言中获取洞察力。具体来说,我们将涵盖以下主题:

  • 机器学习概述

  • 神经网络简介

  • 机器学习的自然语言处理(NLP)

机器学习概述

从根本上讲,机器学习是用于从数据中识别模式和提取趋势的算法过程。通过在数据上训练特定的机器学习算法,机器学习模型可能会学习到人眼不容易察觉的洞察力。医学成像模型可能会学习从人体图像中检测癌症,而情感分析模型可能会学习到包含“好”、“优秀”和“有趣”的书评更可能是正面评价,而包含“坏”、“糟糕”和“无聊”的书评更可能是负面评价。

广义而言,机器学习算法可以分为两大类:监督学习和无监督学习。

监督学习

监督学习涵盖任何我们希望使用输入来预测输出的任务。假设我们希望训练一个模型来预测房屋价格。我们知道较大的房屋通常售价更高,但我们不知道价格与大小之间的确切关系。机器学习模型可以通过查看数据来学习这种关系:

图 1.1 – 显示房屋数据的表格

图 1.1 – 显示房屋数据的表格

在这里,我们已经得到了最近售出的四栋房屋的大小,以及它们售出的价格。鉴于这四栋房屋的数据,我们能否利用这些信息对市场上的新房屋进行预测?一个简单的机器学习模型,即回归,可以估计这两个因素之间的关系:

图 1.2 – 房屋数据的输出

图 1.2 – 房屋数据的输出

鉴于这些历史数据,我们可以利用这些数据来估计大小(X)和价格(Y)之间的关系。现在我们已经估计出大小和价格之间的关系,如果我们得到一座新房屋的大小信息,我们可以使用这些信息来预测其价格,使用已学到的函数:

图 1.3 – 预测房屋价格

图 1.3 – 预测房屋价格

因此,所有监督学习任务的目标是学习模型输入的某些函数以预测输出,在给定许多示例的情况下,说明输入如何与输出相关:

给定许多(X, y),学习:

F (X) = y

您的数字输入可以包含任意数量的特征。我们简单的房价模型仅包含一个特征(大小),但我们可能希望添加更多特征以获得更好的预测(例如,卧室数量,花园大小等)。因此,更具体地说,我们的监督模型学习一种函数,以便将多个输入映射到输出。这由以下方程给出:

给定许多([X0, X1, X2,…,Xn], y),学习:

f(X**0, X1, X2,…,Xn) = y

在前面的例子中,我们学到的函数如下:

在这里,x轴截距,是直线的斜率。

模型可以由数百万,甚至数十亿个输入特征组成(尽管在特征空间过大时可能会遇到硬件限制)。模型的输入类型也可能各不相同,模型可以从图像中学习:

图 1.4 – 模型训练

图 1.4 – 模型训练

正如我们稍后将详细探讨的那样,它们还可以从文本中学习:

我喜欢这部电影 -> 正面

这部电影太糟糕了 -> 负面

我今年看过的最好的电影 -> ?

无监督学习

无监督学习与监督学习不同之处在于,无监督学习不使用输入和输出(X, y)的配对来学习。相反,我们只提供输入数据,模型将学习输入数据的结构或表示。无监督学习的最常见方法之一是聚类

例如,我们拿到了来自四个不同国家的温度和降雨量测量数据集,但没有标签说明这些测量数据来自哪里。我们可以使用聚类算法识别数据中存在的不同簇(国家):

图 1.5 – 聚类算法的输出

图 1.5 – 聚类算法的输出

聚类在自然语言处理中也有应用。如果我们有一个电子邮件数据集,并且想确定这些电子邮件中使用了多少种不同的语言,聚类的形式可以帮助我们确定这一点。如果英语单词在同一封电子邮件中经常与其他英语单词一起出现,并且西班牙语单词也经常与其他西班牙语单词一起出现,我们将使用聚类来确定我们的数据集中有多少个不同的单词簇,从而确定语言的数量。

模型如何学习?

为了使模型学习,我们需要一些评估模型表现的方法。为此,我们使用了一个称为损失的概念。损失是衡量我们的模型预测与实际值有多接近的指标。对于数据集中的某个房屋来说,损失的一种度量可以是真实价格(y)与我们模型预测的价格()之间的差异。我们可以通过计算数据集中所有房屋的这种损失的平均值来评估系统内的总损失。然而,正损失理论上可能会抵消负损失,因此更常见的损失度量是均方误差

虽然其他模型可能使用不同的损失函数,但回归通常使用均方误差。现在,我们可以计算整个数据集的损失度量,但我们仍然需要一种算法上达到最低可能损失的方法。这个过程称为梯度下降

梯度下降

在这里,我们绘制了我们的损失函数与我们房价模型中的单个学习参数的关系,。我们注意到当设置得太高时,均方误差损失也很高,而当设置得太低时,均方误差损失同样很高。损失被最小化的“甜点”,或者说损失最小的点,位于中间某处。为了算法地计算这一点,我们使用梯度下降。当我们开始训练自己的神经网络时,我们将更详细地看到这一点:

图 1.6 – 梯度下降

图 1.6 – 梯度下降

我们首先用一个随机值初始化。为了达到使损失最小化的点,我们需要从损失函数的下坡处向中间移动。为了做到这一点,我们首先需要知道向哪个方向移动。在我们的初始点,我们使用基本的微积分来计算初始斜坡的梯度:

在我们的前述示例中,初始点处的梯度是正的。这告诉我们我们的的值大于最优值,所以我们更新我们的的值,使其低于先前的值。我们逐步迭代这个过程,直到越来越接近使均方误差最小化的值的点。这发生在梯度等于零的点。

过拟合和欠拟合

考虑以下情况,基本线性模型在我们的数据上拟合得很差。我们可以看到我们的模型,由方程表示,似乎不是一个很好的预测器:

图 1.7 – 欠拟合和过拟合示例

图 1.7 – 欠拟合和过拟合示例

当我们的模型由于特征不足、数据不足或模型规格不足而无法很好地拟合数据时,我们称之为欠拟合。我们注意到数据的梯度逐渐增加,并怀疑如果使用多项式,例如,模型可能会更好地拟合;我们稍后将看到,由于神经网络的复杂结构,欠拟合很少成为问题:

考虑以下示例。在这里,我们使用我们的房价模型来拟合一个函数,不仅仅使用房屋大小(X),还使用了二次和三次多项式(X2, X3)。在这里,我们可以看到我们的新模型完美地拟合了我们的数据点。然而,这并不一定会导致一个好的模型:

图 1.8 – 过拟合的样本输出

图 1.8 – 过拟合的样本输出

现在我们有一栋110 平方米的房子来预测价格。根据我们的直觉,因为这栋房子比100 平方米的房子大,我们预计这栋房子的价格会更高,大约$340,000。然而,使用我们拟合的多项式模型,我们发现预测的价格实际上低于较小的房子,大约$320,000。我们的模型很好地拟合了训练数据,但对新的、未见过的数据点泛化能力不强。这被称为过拟合。因为过拟合的原因,重要的是不要在模型训练的数据上评估模型的性能,因此我们需要生成一个单独的数据集来评估我们的数据。

训练与测试

通常,在训练模型时,我们将数据分为两部分:一个训练数据集和一个较小的测试数据集。我们使用训练数据集训练模型,并在测试数据集上评估其性能。这样做是为了衡量模型在未见过的数据集上的表现。正如前面提到的,要使模型成为一个良好的预测器,它必须很好地推广到模型之前没有见过的新数据集,这正是评估测试数据集的作用。

评估模型

虽然我们努力在模型中最小化损失,但这本身并不能提供有关我们的模型在实际预测中表现如何的信息。考虑一个反垃圾邮件模型,它预测接收的电子邮件是否为垃圾邮件,并自动将垃圾邮件发送到垃圾文件夹。评估性能的一个简单指标是准确率

要计算准确率,我们只需将正确预测为垃圾邮件/非垃圾邮件的电子邮件数量除以我们总共进行的预测数量。如果我们在 1,000 封邮件中正确预测了 990 封,那么我们的准确率就是 99%。然而,高准确率并不一定意味着我们的模型很好:

图 1.9 – 显示预测为垃圾邮件/非垃圾邮件的数据表

图 1.9 – 显示预测为垃圾邮件/非垃圾邮件的数据表

在这里,我们可以看到,尽管我们的模型正确预测了 990 封邮件不是垃圾邮件(称为真负),但它还预测了 10 封垃圾邮件不是垃圾邮件(称为假负)。我们的模型假定所有邮件都不是垃圾邮件,这根本不是一个好的反垃圾邮件过滤器!除了准确率之外,我们还应该使用精确率和召回率来评估我们的模型。在这种情况下,我们的模型召回率为零(意味着没有返回任何正结果),这将是一个立即的红旗:

神经网络

在我们之前的示例中,我们主要讨论了形式为 的回归。我们已经涉及使用多项式来拟合诸如 这样更复杂的方程。然而,随着我们向模型添加更多特征,何时使用原始特征的变换成为一个试错的过程。使用神经网络,我们能够将更复杂的函数 y = f(X) 拟合到我们的数据中,而无需对现有特征进行工程化或转换。

神经网络的结构

当我们在学习 的最优值时,这实际上等同于一个单层神经网络

图 1.10 – 单层神经网络

图 1.10 – 单层神经网络

在这里,我们将每个特征作为输入,这里用节点表示。我们希望学习参数,这在图中表示为连接。我们最终的所有之间的乘积的总和给出了我们的最终预测y

一个神经网络简单地建立在这个初始概念之上,向计算中添加额外的层,从而增加复杂性和学习的参数,给我们像这样的东西:

图 1.11 – 全连接网络

图 1.11 – 全连接网络

每个输入节点都连接到另一层中的每个节点。这被称为全连接层。全连接层的输出然后乘以它自己的额外权重,以预测y。因此,我们的预测不再只是的函数,而是包括每个参数的多个学习权重。特征不再仅仅受到的影响。现在,它还受到的影响。

由于全连接层内的每个节点都将X的所有值作为输入,神经网络能够学习输入特征之间的交互特征。可以将多个全连接层串联在一起,以学习更复杂的特征。在本书中,我们将看到,我们构建的所有神经网络都将使用这一概念;将不同类型的多层串联在一起,以构建更复杂的模型。然而,在我们完全理解神经网络之前,还有一个额外的关键要素需要覆盖:激活函数。

激活函数

尽管将各种权重串联在一起使我们能够学习更复杂的参数,但最终,我们的最终预测仍然是权重和特征的线性乘积的组合。如果我们希望我们的神经网络学习一个真正复杂的非线性函数,那么我们必须在我们的模型中引入非线性元素。这是通过使用激活函数来实现的:

图 1.12 – 神经网络中的激活函数

图 1.12 – 神经网络中的激活函数

我们在每个全连接层的每个节点应用一个激活函数。这意味着全连接层中的每个节点都将特征和权重的和作为输入,将非线性函数应用于结果值,并输出转换后的结果。虽然有许多不同的激活函数,但最近最常用的是ReLU,或修正线性单元

图 1.13 – ReLU 输出的表示

图 1.13 – ReLU 输出的表示

ReLU 是一个非常简单的非线性函数,在时返回y = 0,在X > 0时返回y = X。在我们的模型中引入这些激活函数后,我们的最终学习函数变得非线性,这意味着我们可以比仅使用传统回归和特征工程组合创建更多的模型。

神经网络如何学习?

使用神经网络从我们的数据中学习的过程,比使用基本回归时稍微复杂一些。尽管我们仍然使用之前的梯度下降,但我们需要区分的实际损失函数变得显著复杂。在没有激活函数的单层神经网络中,我们可以轻松计算损失函数的导数,因为我们可以清楚地看到损失函数在每个参数变化时的变化。然而,在具有激活函数的多层神经网络中,情况就复杂得多了。

我们必须首先执行前向传播,这是使用模型的当前状态计算预测值y并将其与真实值y进行评估以获取损失度量的过程。利用这个损失,我们向网络反向传播,计算网络中每个参数的梯度。这使我们能够知道应该朝哪个方向更新我们的参数,以便我们能够朝着最小化损失的点移动。这就是所谓的反向传播。我们可以使用链式法则计算损失函数相对于每个参数的导数:

在这里, 是网络中每个给定节点的输出。因此,总结一下,在神经网络上执行梯度下降时我们采取的四个主要步骤如下:

  1. 使用您的数据执行前向传播,计算网络的总损失。

  2. 使用反向传播,计算网络中每个节点处损失相对于每个参数的梯度。

  3. 更新这些参数的值,朝着最小化损失的方向移动。

  4. 直到收敛为止。

神经网络中的过拟合

我们发现,在回归的情况下,可以添加很多特征,这样就可能对网络进行过度拟合。这导致模型完全适合训练数据,但对未见过的测试数据集的泛化能力不强。这在神经网络中是一个常见问题,因为模型复杂度增加意味着往往可以将函数拟合到训练数据集,但这不一定具有泛化能力。以下是在每次数据集的前向和后向传播(称为一个 epoch)之后训练和测试数据集的总损失的图表:

图 1.14 – 测试和训练轮次

图 1.14 – 测试和训练轮次

在我们继续训练网络的过程中,随着时间的推移,训练损失会逐渐减小,直到我们接近最小化总损失的点。尽管这在一定程度上对测试数据集泛化良好,但是一段时间后,测试数据集上的总损失开始增加,因为我们的函数对训练集中的数据过拟合了。解决这个问题的一个方法是早停法。因为我们希望模型能够在未见过的数据上做出良好的预测,我们可以在测试损失最小化的点停止训练模型。一个完全训练好的自然语言处理模型可能能够轻松分类它之前见过的句子,但真正学到东西的模型的衡量标准是它在未见数据上的预测能力。

机器学习的自然语言处理

与人类不同,计算机并不以我们理解的方式理解文本。为了创建能够从数据中学习的机器学习模型,我们必须首先学会以计算机能够处理的方式表示自然语言。

当我们讨论机器学习的基本原理时,你可能已经注意到损失函数都处理数值数据,以便能够最小化损失。因此,我们希望将文本表示为一个能够成为神经网络输入基础的数值格式。在这里,我们将介绍几种基本的文本数值表示方法。

词袋模型

表示文本最简单也是最简单的方法之一是使用词袋模型表示。这种方法简单地计算给定句子或文档中的单词,并计算所有单词的数量。然后,这些计数被转换为向量,向量的每个元素是语料库中每个单词在句子中出现的次数。语料库简单来说就是分析的所有句子/文档中出现的所有单词。看下面的两个句子:

猫坐在垫子上

狗坐在猫上

我们可以将每个句子表示为单词计数:

![图 1.15 – 单词计数表

[img/B12365_01_15.jpg)

图 1.15 – 单词计数表

然后,我们可以将这些转换为单个向量:

猫坐在垫子上 -> [2,1,0,1,1,1]

狗坐在猫上 -> [2,1,1,1,1,0]

然后,这种数值表示可以用作机器学习模型的输入特征向量,其中特征向量是

顺序表示法

我们将在本书后面看到,更复杂的神经网络模型,包括循环神经网络(RNNs)和长短期记忆网络(LSTMs),不仅仅接受单个向量作为输入,而是可以接受整个向量序列形式的矩阵。因此,为了更好地捕捉单词的顺序和句子的意义,我们可以以一系列独热编码向量的形式表示这些内容:

![图 1.16 – 独热编码向量

[img/B12365_01_16.jpg)

图 1.16 – 独热编码向量

总结

在本章中,我们介绍了机器学习和神经网络的基础知识,以及转换文本以供这些模型使用的简要概述。在下一章中,我们将简要介绍 PyTorch 及其如何用于构建这些模型。

第二章:开始使用 PyTorch 1.x 进行自然语言处理

PyTorch 是一个基于 Python 的机器学习库。它主要有两个特点:能够高效地使用硬件加速(使用 GPU)进行张量运算,以及能够构建深度神经网络。PyTorch 还使用动态计算图而不是静态计算图,这使其与 TensorFlow 等类似库有所不同。通过展示如何使用张量表示语言以及如何使用神经网络从自然语言处理中学习,我们将展示这两个特点对于自然语言处理特别有用。

在本章中,我们将向您展示如何在计算机上安装和运行 PyTorch,并演示其一些关键功能。然后,我们将比较 PyTorch 与一些其他深度学习框架,然后探索 PyTorch 的一些自然语言处理功能,如其执行张量操作的能力,并最后演示如何构建一个简单的神经网络。总之,本章将涵盖以下主题:

  • 安装 PyTorch

  • 将 PyTorch 与其他深度学习框架进行比较

  • PyTorch 的自然语言处理功能

技术要求

本章需要安装 Python。建议使用最新版本的 Python(3.6 或更高版本)。还建议使用 Anaconda 包管理器安装 PyTorch。需要 CUDA 兼容的 GPU 来在 GPU 上运行张量操作。本章所有代码可以在 github.com/PacktPublishing/Hands-On-Natural-Language-Processing-with-PyTorch-1.x 找到。

安装和使用 PyTorch 1.x

与大多数 Python 包一样,PyTorch 安装非常简单。有两种主要方法。第一种是在命令行中使用 pip 直接安装。只需输入以下命令:

pip install torch torchvision

虽然这种安装方法很快,但建议改用 Anaconda 安装,因为它包含了 PyTorch 运行所需的所有依赖项和二进制文件。此外,后续需要使用 Anaconda 来启用 CUDA 在 GPU 上进行模型训练。可以通过在命令行中输入以下内容来通过 Anaconda 安装 PyTorch:

conda install torch torchvision -c pytorch

要检查 PyTorch 是否正确工作,我们可以打开 Jupyter Notebook 并运行几个简单的命令:

  1. 要在 PyTorch 中定义一个 Tensor,我们可以这样做:

    import torch
    x = torch.tensor([1.,2.])
    print(x)
    

    这将导致以下输出:

    ![Figure 2.1 – Tensor output ](https://gitee.com/OpenDocCN/freelearn-dl-pt2-zh/raw/master/docs/hsn-nlp-pt1x/img/B12365_02_1.png)

    图 2.1 – 张量输出

    这表明 PyTorch 中的张量被保存为它们自己的数据类型(与 NumPy 中保存数组的方式类似)。

  2. 我们可以使用标准的 Python 运算符进行基本操作,比如乘法:

    x = torch.tensor([1., 2.])
    y = torch.tensor([3., 4.])
    print(x * y)
    

    这将导致以下输出:

    Figure 2.2 – Tensor multiplication output

    图 2.2 – 张量乘法输出

  3. 我们还可以按如下方式选择张量中的单个元素:

    x = torch.tensor([[1., 2.],[5., 3.],[0., 4.]])
    print(x[0][1])
    

    这会产生以下输出:

图 2.3 – 张量选择输出

图 2.3 – 张量选择输出

但请注意,与 NumPy 数组不同,从张量对象中选择单个元素会返回另一个张量。为了从张量中返回单个值,您可以使用.item()函数:

print(x[0][1].item())

这会产生以下输出:

图 2.4 – .item() 函数输出

图 2.4 – .item() 函数输出

张量

在继续之前,您必须充分了解张量的属性是非常重要的。张量有一个称为的属性,它基本上确定了张量的维数。阶为一的张量是具有单个维度的张量,等同于一个向量或数字列表。阶为 2 的张量是具有两个维度的张量,相当于矩阵,而阶为 3 的张量包含三个维度。在 PyTorch 中,张量的最大阶数没有限制:

图 2.5 – 张量矩阵

图 2.5 – 张量矩阵

你可以通过输入以下内容来检查任何张量的大小:

x.shape

这会产生以下输出:

图 2.6 – 张量形状输出

图 2.6 – 张量形状输出

这显示这是一个 3x2 的张量(阶为 2)。

使用 CUDA 加速 PyTorch

PyTorch 的主要好处之一是通过图形处理单元GPU)实现加速能力。深度学习是一种易于并行化的计算任务,这意味着可以将计算任务分解为较小的任务,并在许多较小的处理器上计算。这意味着与在单个 CPU 上执行任务相比,在 GPU 上执行计算更为高效。

GPU 最初是为高效渲染图形而创建的,但随着深度学习的流行,GPU 因其同时执行多个计算的能力而经常被使用。传统 CPU 可能由大约四到八个核心组成,而 GPU 由数百个较小的核心组成。由于可以在所有这些核心上同时执行计算,GPU 可以快速减少执行深度学习任务所需的时间。

考虑神经网络中的单次传递。我们可以取一小批数据,通过网络传递以获取损失,然后进行反向传播,根据梯度调整参数。如果我们有多批数据要处理,在传统 CPU 上,我们必须等到批次 1 完成后才能为批次 2 计算:

图 2.7 – 神经网络中的一次传递

图 2.7 – 神经网络中的一次传递

然而,在 GPU 上,我们可以同时执行所有这些步骤,这意味着在批次 1 完成之前没有批次 2 的要求。我们可以同时计算所有批次的参数更新,然后一次性执行所有参数更新(因为结果是彼此独立的)。并行方法可以极大地加速机器学习过程:

图 2.8 – 并行执行传递的方法

图 2.8 – 并行执行传递的方法

CUDA(Compute Unified Device Architecture)是专为 Nvidia GPU 设计的技术,可以在 PyTorch 上实现硬件加速。为了启用 CUDA,首先必须确保系统上的显卡兼容 CUDA。可以在此处找到支持 CUDA 的 GPU 列表:developer.nvidia.com/cuda-gpus。如果您有兼容 CUDA 的 GPU,则可以从此链接安装 CUDA:developer.nvidia.com/cuda-downloads。我们将使用以下步骤来激活它:

  1. 首先,为了在 PyTorch 上实际启用 CUDA 支持,您必须从源代码构建 PyTorch。有关如何执行此操作的详细信息可以在此处找到:github.com/pytorch/pytorch#from-source

  2. 然后,在我们的 PyTorch 代码中实际使用 CUDA,我们必须在 Python 代码中输入以下内容:

    cuda = torch.device('cuda') 
    

    这将设置我们默认的 CUDA 设备名称为'cuda'

  3. 然后,我们可以通过在任何张量操作中手动指定设备参数来在此设备上执行操作:

    x = torch.tensor([5., 3.], device=cuda)
    

    或者,我们可以通过调用cuda方法来实现:

    y = torch.tensor([4., 2.]).cuda()
    
  4. 我们可以运行一个简单的操作来确保这个工作正常:

    x*y
    

    这将导致以下输出:

图 2.9 – 使用 CUDA 进行张量乘法输出

图 2.9 – 使用 CUDA 进行张量乘法输出

在这个阶段,由于我们只是创建一个张量,所以速度上的变化并不明显,但当我们稍后开始规模化训练模型时,我们将看到使用 CUDA 并行化计算可以带来速度上的好处。通过并行训练我们的模型,我们能够大大缩短这个过程所需的时间。

将 PyTorch 与其他深度学习框架进行比较

PyTorch 是今天深度学习中使用的主要框架之一。还有其他广泛使用的框架,如 TensorFlow、Theano 和 Caffe。尽管在许多方面它们非常相似,但它们在操作方式上有一些关键区别。其中包括以下内容:

  • 模型计算的方式

  • 计算图编译的方式

  • 能够创建具有可变层的动态计算图的能力

  • 语法上的差异

可以说,PyTorch 与其他框架的主要区别在于其模型计算方式的不同。PyTorch 使用一种称为autograd的自动微分方法,允许动态定义和执行计算图。这与 TensorFlow 等静态框架形成对比。在这些静态框架中,必须先定义和编译计算图,然后才能最终执行。虽然使用预编译模型可能会导致在生产环境中实现高效,但在研究和探索性项目中,它们不提供同样级别的灵活性。

诸如 PyTorch 之类的框架在模型训练之前不需要预编译计算图。PyTorch 使用的动态计算图意味着在执行时编译图形,这允许在执行过程中动态定义图形。在 NLP 领域,动态模型构建方法尤其有用。让我们考虑两个我们希望进行情感分析的句子:

图 2.10 – PyTorch 中的模型构建

图 2.10 – PyTorch 中的模型构建

我们可以将这些句子表示为单词向量的序列,这些向量将成为我们神经网络的输入。然而,正如我们所看到的,我们的每个输入大小不同。在固定的计算图内,这些不同的输入大小可能是一个问题,但是对于像 PyTorch 这样的框架,模型能够动态调整以适应输入结构的变化。这也是为什么 PyTorch 在与 NLP 相关的深度学习中经常被优先选择的原因之一。

PyTorch 与其他深度学习框架的另一个主要区别在于语法。对于有 Python 经验的开发者来说,PyTorch 通常更受欢迎,因为它在性质上被认为非常符合 Python 风格。PyTorch 与 Python 生态系统的其他方面集成良好,如果你具备 Python 的先验知识,学习起来非常容易。现在我们将通过从头开始编写我们自己的神经网络来演示 PyTorch 的语法。

在 PyTorch 中构建简单的神经网络

现在,我们将介绍如何在 PyTorch 中从头开始构建神经网络。这里,我们有一个包含来自 MNIST 数据集中几个图像示例的小.csv文件。MNIST 数据集包含一系列手绘的 0 到 9 之间的数字,我们希望尝试对其进行分类。以下是来自 MNIST 数据集的一个示例,其中包含一个手绘的数字 1:

图 2.11 – MNIST 数据集的示例图像

图 2.11 – MNIST 数据集的示例图像

这些图像的尺寸为 28x28:总共 784 个像素。我们的训练数据集train.csv包含 1,000 个这样的图像,每个图像由 784 个像素值组成,以及数字(在本例中为 1)的正确分类。

加载数据

我们将从加载数据开始,如下所示:

  1. 首先,我们需要加载我们的训练数据集,如下所示:

    train = pd.read_csv("train.csv")
    train_labels = train['label'].values
    train = train.drop("label",axis=1).values.reshape(len(train),1,28,28)
    

    注意,我们将输入重塑为(1, 1, 28, 28),这是一个包含 1,000 个图像的张量,每个图像由 28x28 像素组成。

  2. 接下来,我们将训练数据和训练标签转换为 PyTorch 张量,以便它们可以被馈送到神经网络中。

    X = torch.Tensor(train.astype(float))
    y = torch.Tensor(train_labels).long()
    

注意这两个张量的数据类型。一个浮点张量包含 32 位浮点数,而长张量包含 64 位整数。我们的X特征必须是浮点数,以便 PyTorch 能够计算梯度,而我们的标签必须是整数,这在这个分类模型中是合理的(因为我们试图预测 1、2、3 等的值),因此预测 1.5 没有意义。

构建分类器

接下来,我们可以开始构建实际的神经网络分类器:

class MNISTClassifier(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(784, 392)
        self.fc2 = nn.Linear(392, 196)
        self.fc3 = nn.Linear(196, 98)
        self.fc4 = nn.Linear(98, 10)

我们构建分类器时,就像构建 Python 中的普通类一样,从 PyTorch 的nn.Module继承。在我们的init方法中,我们定义了神经网络的每一层。在这里,我们定义了不同大小的全连接线性层。

我们的第一层接受784个输入,因为这是每个图像的大小(28x28)。然后我们看到一个层的输出必须与下一个层的输入具有相同的值,这意味着我们的第一个全连接层输出392个单元,我们的第二层接受392个单元作为输入。这样的过程对每一层都重复进行,每次单元数减半,直到我们达到最终的全连接层,其输出10个单元。这是我们分类层的长度。

我们的网络现在看起来像这样:

图 2.12 – 我们的神经网络

图 2.12 – 我们的神经网络

在这里,我们可以看到我们的最终层输出10个单元。这是因为我们希望预测每个图像是否是 0 到 9 之间的数字,总共有 10 种不同的可能分类。我们的输出是长度为10的向量,并包含对图像的每个可能值的预测。在做最终分类时,我们将具有最高值的数字分类作为模型的最终预测。例如,对于给定的预测,我们的模型可能以 10%的概率预测图像是类型 1,以 10%的概率预测图像是类型 2,以 80%的概率预测图像是类型 3。因此,我们将类型 3 作为预测结果,因为它以最高的概率进行了预测。

实施dropout

在我们的MNISTClassifier类的init方法中,我们还定义了一个 dropout 方法,以帮助正则化网络。

self.dropout = nn.Dropout(p=0.2)

Dropout 是一种正则化神经网络的方法,用于防止过拟合。在每个训练 epoch 中,对于每个应用了 dropout 的层中的节点,存在一定的概率(这里定义为 p = 20%),使得该层中的每个节点在训练和反向传播过程中都不被使用。这意味着在训练过程中,我们的网络变得对过拟合更加健壮,因为每个节点都不会在每次迭代中都被使用。这样一来,我们的网络就不会过度依赖网络中特定节点的预测。

定义前向传播

接下来,我们在分类器中定义前向传播:

    def forward(self, x):
        x = x.view(x.shape[0], -1)
        x = self.dropout(F.relu(self.fc1(x)))
        x = self.dropout(F.relu(self.fc2(x)))
        x = self.dropout(F.relu(self.fc3(x)))
        x = F.log_softmax(self.fc4(x), dim=1)

在我们的分类器中的 forward() 方法是我们应用激活函数并定义网络中 dropout 的地方。我们的 forward 方法定义了输入将如何通过网络。首先接收我们的输入 x,并将其重塑为网络中使用的一维向量。然后,我们通过第一个全连接层,并使用 ReLU 激活函数使其非线性化。我们还在 init 方法中定义了 dropout。我们将这个过程在网络的所有其他层中重复进行。

对于我们的最终预测层,我们将其包裹在一个对数 softmax 层中。我们将使用这个层来轻松计算我们的损失函数,接下来我们会看到。

设置模型参数

接下来,我们定义我们的模型参数:

model = MNISTClassifier()
loss_function = nn.NLLLoss()
opt = optim.Adam(model.parameters(), lr=0.001)

我们将 MNISTClassifier 类实例化为模型的一个实例。我们还将我们的损失定义为 负对数似然损失

Loss(y) = -log(y)

假设我们的图像是数字 7。如果我们以概率 1 预测类别 7,我们的损失将是 -log(1) = 0,但如果我们只以概率 0.7 预测类别 7,我们的损失将是 -log(0.7) = 0.3。这意味着我们的损失会随着预测偏离正确答案而无限增加:

图 2.13 – 我们网络的损失表示

图 2.13 – 我们网络的损失表示

然后,我们对数据集中所有正确类别求和,计算总损失。注意,在构建分类器时,我们定义了对数 softmax 函数,因此已经应用了 softmax 函数(将预测输出限制在 0 到 1 之间)并取了对数。这意味着 log(y) 已经计算好了,所以我们计算网络的总损失只需计算输出的负和。

我们还将我们的优化器定义为 Adam 优化器。优化器控制模型内的学习率。模型的学习率定义了训练的每个周期中参数更新的大小。学习率越大,梯度下降中参数更新的大小越大。优化器动态控制这个学习率,因此当模型初始化时,参数更新很大。但是,随着模型的学习并接近最小化损失的点,优化器控制学习率,使参数更新变小,可以更精确地定位局部最小值。

训练我们的网络

最后,我们实际开始训练我们的网络:

  1. 首先,创建一个循环,每个训练周期运行一次。在这里,我们将运行我们的训练循环共 50 个周期。我们首先取出图像的输入张量和标签的输出张量,并将它们转换为 PyTorch 变量。variable 是一个 PyTorch 对象,其中包含一个 backward() 方法,我们可以用它来执行网络的反向传播:

    for epoch in range(50): 
        images = Variable(X)
        labels = Variable(y)
    
  2. 接下来,在我们的优化器上调用 zero_grad() 来将计算得到的梯度设置为零。在 PyTorch 中,梯度是在每次反向传播时累积计算的。虽然这对于某些模型(如训练 RNNs 时)很有用,但对于我们的例子,我们希望在每次通过后从头开始计算梯度,所以确保在每次通过后将梯度重置为零:

    opt.zero_grad()
    
  3. 接下来,我们使用模型的当前状态在数据集上进行预测。这实际上是我们的前向传递,因为我们使用这些预测来计算我们的损失:

    outputs = model(images)
    
  4. 使用数据集的输出和真实标签,我们使用定义的损失函数计算我们模型的总损失,本例中为负对数似然。计算完损失后,我们可以调用 backward() 来通过网络反向传播我们的损失。然后,我们使用我们的优化器的 step() 方法来相应地更新模型参数:

    loss = loss_function(outputs, labels)
    loss.backward()
    opt.step()
    
  5. 最后,在每个周期完成后,我们打印出总损失。我们可以观察这一点以确保我们的模型在学习:

    print ('Epoch [%d/%d] Loss: %.4f' %(epoch+1, 50,         loss.data.item()))
    

一般来说,我们期望损失在每个周期后都会减少。我们的输出将看起来像这样:

图 2.14 – 训练周期

图 2.14 – 训练周期

进行预测

现在我们的模型已经训练好,我们可以用它来对未见过的数据进行预测。我们首先读入我们的测试数据集(这些数据集未用于训练我们的模型):

test = pd.read_csv("test.csv")
test_labels = test['label'].values
test = test.drop("label",axis=1).values.reshape(len(test),                  1,28,28)
X_test = torch.Tensor(test.astype(float))
y_test = torch.Tensor(test_labels).long()

在这里,我们执行与加载训练数据集时相同的步骤:我们重塑我们的测试数据,并将其转换为 PyTorch 张量。接下来,要使用我们训练过的模型进行预测,我们只需运行以下命令:

preds = model(X_test)

就像我们在模型的前向传播中计算训练数据的输出一样,我们现在通过模型传递测试数据并得到预测。我们可以查看其中一张图像的预测结果如下:

print(preds[0])

这导致以下输出:

图 2.15 – 预测输出

图 2.15 – 预测输出

在这里,我们可以看到我们的预测是一个长度为 10 的向量,每个可能类别(0 到 9 之间的数字)有一个预测值。具有最高预测值的那个是我们模型选择作为预测的那个。在这种情况下,它是向量的第 10 个单元,对应于数字 9。请注意,由于我们之前使用了对数 softmax,我们的预测是对数而不是原始概率。要将其转换回概率,我们可以简单地使用 x 进行转换。

现在我们可以构建一个包含真实测试数据标签以及我们模型预测标签的总结 DataFrame:

_, predictionlabel = torch.max(preds.data, 1)
predictionlabel = predictionlabel.tolist()
predictionlabel = pd.Series(predictionlabel)
test_labels = pd.Series(test_labels)
pred_table = pd.concat([predictionlabel, test_labels], axis=1)
pred_table.columns =['Predicted Value', 'True Value']
display(pred_table.head())

这导致以下输出:

图 2.16 – 预测表格

图 2.16 – 预测表格

注意,torch.max() 函数会自动选择具有最高值的预测值。我们可以看到,在我们的数据的小部分选择中,我们的模型似乎在做出一些好的预测!

评估我们的模型

现在我们从模型得到了一些预测结果,我们可以用这些预测结果来评估我们模型的好坏。评估模型性能的一个简单方法是准确率,正如前一章节讨论的那样。在这里,我们简单地计算我们正确预测的百分比(即预测图像标签等于实际图像标签的情况):

preds = len(predictionlabel)
correct = len([1 for x,y in zip(predictionlabel, test_labels)               if x==y])
print((correct/preds)*100)

这导致以下输出:

图 2.17 – 准确率分数

图 2.17 – 准确率分数

恭喜!你的第一个神经网络能够正确识别近 90%的未见数字图像。随着我们的进展,我们将看到更复杂的模型可能会导致性能的提升。然而,目前我们已经证明,使用 PyTorch 创建简单的深度神经网络非常简单。这可以用几行代码实现,并且能够超越基本的机器学习模型如回归。

PyTorch 的自然语言处理

现在我们已经学会了如何构建神经网络,我们将看到如何使用 PyTorch 为 NLP 构建模型。在这个例子中,我们将创建一个基本的词袋分类器,以便对给定句子的语言进行分类。

分类器的设置

对于这个例子,我们将选取一些西班牙语和英语的句子:

  1. 首先,我们将每个句子拆分为单词列表,并将每个句子的语言作为标签。我们从中取一部分句子来训练我们的模型,并保留一小部分作为测试集。我们这样做是为了在模型训练后评估其性能:

    ("This is my favourite chapter".lower().split(),\
     "English"),
    ("Estoy en la biblioteca".lower().split(), "Spanish")
    

    注意,我们还将每个单词转换为小写,这样可以防止在我们的词袋中重复计数。如果我们有单词book和单词Book,我们希望它们被视为相同的单词,因此我们将它们转换为小写。

  2. 接下来,我们构建我们的词索引,这只是我们语料库中所有单词的字典,并为每个单词创建一个唯一的索引值。这可以通过简短的for循环轻松完成:

    word_dict = {}
    i = 0
    for words, language in training_data + test_data:
        for word in words:
            if word not in word_dict:
                word_dict[word] = i
                i += 1
    print(word_dict)
    

    这将导致以下输出:

    图 2.18 – 设置分类器

    图 2.18 – 设置分类器

    注意,在这里,我们循环遍历了所有的训练数据和测试数据。如果我们仅在训练数据上创建了我们的词索引,那么在评估测试集时,我们可能会有新的单词,这些单词在原始训练数据中没有出现,因此我们无法为这些单词创建真正的词袋表示。

  3. 现在,我们按照前一节中构建神经网络的方式构建我们的分类器;也就是说,通过构建一个从nn.Module继承的新类。

    在这里,我们定义我们的分类器,使其包含一个具有 log softmax 激活函数的单个线性层,用来近似逻辑回归。我们可以通过在此处添加额外的线性层轻松扩展为神经网络,但是一个参数的单层将满足我们的目的。请特别注意我们线性层的输入和输出大小:

    corpus_size = len(word_dict)
    languages = 2
    label_index = {"Spanish": 0, "English": 1}
    class BagofWordsClassifier(nn.Module):  
        def __init__(self, languages, corpus_size):
            super(BagofWordsClassifier, self).__init__()
            self.linear = nn.Linear(corpus_size, languages)
        def forward(self, bow_vec):
            return F.log_softmax(self.linear(bow_vec), dim=1)
    

    输入的长度为corpus_size,这只是我们语料库中唯一单词的总数。这是因为我们模型的每个输入将是一个词袋表示,其中包含每个句子中单词的计数,如果给定单词在我们的句子中不存在,则计数为 0。我们的输出大小为 2,这是我们要预测的语言数。我们最终的预测将包括一个句子是英语的概率与句子是西班牙语的概率,最终预测将是概率最高的那个。

  4. 接下来,我们定义一些实用函数。首先定义make_bow_vector,它接受句子并将其转换为词袋表示。我们首先创建一个全零向量。然后循环遍历句子中的每个单词,递增词袋向量中该索引位置的计数。最后,我们使用with .view()来重塑这个向量以输入到我们的分类器中:

    def make_bow_vector(sentence, word_index):
        word_vec = torch.zeros(corpus_size)
        for word in sentence:
            word_vec[word_dict[word]] += 1
        return word_vec.view(1, -1)
    
  5. 类似地,我们定义make_target,它简单地接受句子的标签(西班牙语或英语)并返回其相关的索引(01):

    def make_target(label, label_index):
        return torch.LongTensor([label_index[label]])
    
  6. 现在我们可以创建我们模型的一个实例,准备进行训练。我们还将我们的损失函数定义为负对数似然,因为我们使用了对数 softmax 函数,然后定义我们的优化器以使用标准的随机梯度下降SGD):

    model = BagofWordsClassifier(languages, corpus_size)
    loss_function = nn.NLLLoss()
    optimizer = optim.SGD(model.parameters(), lr=0.1)
    

现在,我们准备训练我们的模型。

训练分类器

首先,我们设置了一个循环,其包含我们希望模型运行的轮数。在这个实例中,我们将选择 100 轮次。

在这个循环中,我们首先将梯度归零(否则,PyTorch 会累积计算梯度),然后对于每个句子/标签对,我们分别将其转换为词袋向量和目标。然后,通过当前模型状态的数据进行前向传播,计算出这个特定句子对的预测输出。

利用此预测,我们接着将预测值和实际标签传入我们定义的loss_function,以获取这个句子的损失度量。调用backward()来通过我们的模型反向传播这个损失,再调用优化器的step()来更新模型参数。最后,在每 10 个训练步骤后打印出我们的损失:

for epoch in range(100):
    for sentence, label in training_data:
        model.zero_grad()
        bow_vec = make_bow_vector(sentence, word_dict)
        target = make_target(label, label_index)
        log_probs = model(bow_vec)
        loss = loss_function(log_probs, target)
        loss.backward()
        optimizer.step()

    if epoch % 10 == 0:
        print('Epoch: ',str(epoch+1),', Loss: ' +                         str(loss.item()))

这导致了以下输出:

图 2.19 – 训练损失

图 2.19 – 训练损失

在这里,我们可以看到随着模型学习,我们的损失随时间递减。尽管这个示例中的训练集非常小,我们仍然可以展示出我们的模型学到了一些有用的东西,如下所示:

  1. 我们在一些测试数据的几个句子上评估我们的模型,这些句子我们的模型没有进行训练。在这里,我们首先设置torch.no_grad(),这将关闭autograd引擎,因为我们不再需要计算梯度,我们不再训练我们的模型。接下来,我们将测试句子转换为词袋向量,并将其馈送到我们的模型中以获得预测。

  2. 接着我们简单地打印出句子、句子的真实标签,然后是预测的概率。注意,我们将预测值从对数概率转换回概率。对于每个预测,我们得到两个概率,但是如果我们回顾标签索引,可以看到第一个概率(索引 0)对应于西班牙语,而另一个对应于英语:

    def make_predictions(data):
        with torch.no_grad():
            sentence = data[0]
            label = data[1]
            bow_vec = make_bow_vector(sentence, word_dict)
            log_probs = model(bow_vec)
            print(sentence)
            print(label + ':')
            print(np.exp(log_probs))
    
    make_predictions(test_data[0])
    make_predictions(test_data[1])
    

    这导致了以下输出:

    图 2.20 – 预测输出

    图 2.20 – 预测输出

    在这里,我们可以看到对于我们的预测,我们的模型预测了正确的答案,但是为什么呢?我们的模型到底学到了什么?我们可以看到,我们的第一个测试句子包含了单词estoy,这在我们的训练集中之前出现在一个西班牙语句子中。类似地,我们可以看到单词book在我们的训练集中出现在一个英语句子中。由于我们的模型由单层组成,我们每个节点上的参数易于解释。

  3. 在这里,我们定义了一个函数,该函数以单词作为输入,并返回层内每个参数的权重。对于给定的单词,我们从字典中获取其索引,然后从模型中选择这些参数的同一索引。请注意,我们的模型返回两个参数,因为我们进行了两次预测;即,模型对西班牙语预测的贡献和模型对英语预测的贡献:

    def return_params(word): 
        index = word_dict[word]
        for p in model.parameters():
            dims = len(p.size())
            if dims == 2:
                print(word + ':')
                print('Spanish Parameter = ' +                    str(p[0][index].item()))
                print('English Parameter = ' +                    str(p[1][index].item()))
                print('\n')
    
    return_params('estoy')
    return_params('book')
    

    这导致了以下输出:

图 2.21 – 更新函数的预测输出

图 2.21 – 更新函数的预测输出

在这里,我们可以看到对于单词estoy,这个参数对于西班牙语的预测是正的,对于英语则是负的。这意味着在我们的句子中每出现一次单词"estoy",这个句子变得更可能是西班牙语。同样地,对于单词book,我们可以看到它对于预测这个句子是英语有正面贡献。

我们可以展示,我们的模型仅基于其训练过的内容进行学习。如果我们尝试预测一个模型未经训练的词汇,我们可以看到它无法做出准确的决定。在这种情况下,我们的模型认为英文单词"not"是西班牙语:

new_sentence = (["not"],"English")
make_predictions(new_sentence)

这导致了以下输出:

图 2.22 – 最终输出

图 2.22 – 最终输出

总结

在本章中,我们介绍了 PyTorch 及其一些关键特性。希望现在你对 PyTorch 与其他深度学习框架的区别有了更好的理解,以及它如何用于构建基本的神经网络。虽然这些简单的例子只是冰山一角,但我们已经说明了 PyTorch 是 NLP 分析和学习的强大工具。

在接下来的章节中,我们将展示如何利用 PyTorch 的独特特性来构建用于解决非常复杂的机器学习任务的高度复杂的模型。

第二部分:自然语言处理基础

在本节中,你将学习构建自然语言处理NLP)应用的基础知识。你还将学习如何在 PyTorch 中使用各种 NLP 技术,如词嵌入、CBOW 和分词。

本节包括以下章节:

  • 第三章, NLP 和文本嵌入

  • 第四章, 词干提取和词形归并

第三章:自然语言处理与文本嵌入

在深度学习中有许多不同的文本表示方式。尽管我们已经涵盖了基本的词袋(bag-of-words)BoW)表示法,但毫不奇怪,还有一种更复杂的文本表示方式,称为嵌入。虽然词袋向量仅作为句子中单词的计数,嵌入则帮助数值化定义了某些单词的实际含义。

在本章中,我们将探讨文本嵌入,并学习如何使用连续词袋模型创建嵌入。然后我们将讨论 n-gram,以及它们如何在模型中使用。我们还将涵盖各种标注、分块和分词方法,以将自然语言处理拆分为其各个组成部分。最后,我们将看看 TF-IDF 语言模型及其在加权模型中对不经常出现的单词的有用性。

本章将涵盖以下主题:

  • 词嵌入

  • 探索 CBOW

  • 探索 n-gram

  • 分词

  • 词性标注和分块

  • TF-IDF

技术要求

GLoVe 向量可以从 nlp.stanford.edu/projects/glove/ 下载。建议使用glove.6B.50d.txt文件,因为它比其他文件要小得多,并且处理起来更快。后续章节将需要 NLTK。本章的所有代码可以在 github.com/PacktPublishing/Hands-On-Natural-Language-Processing-with-PyTorch-1.x 找到。

NLP 中的嵌入

单词没有自然的方式来表示它们的含义。在图像中,我们已经有了富向量表示(包含图像中每个像素的值),所以显然将单词表示为类似富向量的表示是有益的。当语言部分以高维向量格式表示时,它们被称为嵌入。通过对单词语料库的分析,并确定哪些单词经常在一起出现,我们可以为每个单词获取一个n长度的向量,这更好地表示了每个单词与所有其他单词的语义关系。我们之前看到,我们可以轻松地将单词表示为单热编码向量:

图 3.1 – 单热编码向量

图 3.1 – 单热编码向量

另一方面,嵌入是长度为n的向量(在以下示例中,n=3),可以取任何值:

图 3.2 – n=3 的向量

图 3.2 – n=3 的向量

这些嵌入代表了n维空间中单词的向量(其中n是嵌入向量的长度),在这个空间中具有相似向量的单词被认为在意义上更相似。虽然这些嵌入可以是任何尺寸,但它们通常比 BoW 表示的尺寸要低得多。 BoW 表示需要的向量长度是整个语料库的长度,当考虑整个语言时,可能会非常快速地变得非常大。尽管嵌入足够高维度以表示单词,但它们通常不比几百维大。此外,BoW 向量通常非常稀疏,大部分由零组成,而嵌入富含数据,每个维度都有助于单词的整体表示。低维度和非稀疏性使得在嵌入上执行深度学习比在 BoW 表示上执行更加高效。

GLoVe

我们可以下载一组预先计算的单词嵌入来演示它们的工作原理。为此,我们将使用全球词向量表示GLoVe)嵌入,可以从这里下载:nlp.stanford.edu/projects/glove/ 。这些嵌入是在一个非常大的 NLP 数据语料库上计算的,并且是基于单词共现矩阵进行训练的。这是基于这样的概念:一起出现的单词更有可能具有相似的含义。例如,单词sun更可能与单词hot一起频繁出现,而不是与单词cold一起,因此sunhot更可能被认为是更相似的。

我们可以通过检查单个 GLoVe 向量来验证这一点:

  1. 首先,我们创建一个简单的函数来从文本文件中加载我们的 GLoVe 向量。这只是构建一个字典,其中索引是语料库中的每个单词,值是嵌入向量:

    def loadGlove(path):
        file = open(path,'r')
        model = {}
        for l in file:
            line = l.split()
            word = line[0]
            value = np.array([float(val) for val in                           line[1:]])
            model[word] = value
        return model
    glove = loadGlove('glove.6B.50d.txt')
    
  2. 这意味着我们可以通过从字典中调用来访问单个向量:

    glove['python']
    

    这导致以下输出:

    图 3.3 – 向量输出

    图 3.3 – 向量输出

    我们可以看到,这返回了 Python 这个词的 50 维向量嵌入。现在我们将介绍余弦相似度的概念,以比较两个向量的相似度。如果n维空间中它们之间的角度为 0 度,则向量将具有相似度为 1。具有高余弦相似度的值可以被认为是相似的,即使它们不相等。可以使用以下公式计算这一点,其中 A 和 B 是要比较的两个嵌入向量:

  3. 我们可以在 Python 中使用Sklearncosine_similarity()函数轻松计算这个。我们可以看到catdog作为动物具有相似的向量:

    cosine_similarity(glove['cat'].reshape(1, -1), glove['dog'].reshape(1, -1))
    

    这导致以下输出:

    图 3.4 – cat 和 dog 的余弦相似度输出

    图 3.4 – cat 和 dog 的余弦相似度输出

  4. 然而,catpiano是非常不同的,因为它们是两个看似不相关的物品:

    cosine_similarity(glove['cat'].reshape(1, -1), glove['piano'].reshape(1, -1))
    

    这导致以下输出:

图 3.5 – cat 和 piano 的余弦相似度输出

图 3.5 – cat 和 piano 的余弦相似度输出

嵌入操作

由于嵌入是向量,我们可以对它们执行操作。例如,假设我们取以下类型的嵌入并计算以下内容:

Queen-Woman+Man

通过这个,我们可以近似计算king的嵌入。这实质上是将QueenWoman向量部分替换为Man向量,以获得这个近似。我们可以用图形方式说明如下:

图 3.6 – 示例的图形表示

图 3.6 – 示例的图形表示

请注意,在这个例子中,我们以二维图形方式进行了说明。在我们的嵌入中,这发生在一个 50 维空间中。虽然这不是精确的,我们可以验证我们计算的向量确实与King的 GLoVe 向量相似:

predicted_king_embedding = glove['queen'] - glove['woman'] + glove['man']
cosine_similarity(predicted_king_embedding.reshape(1, -1), glove['king'].reshape(1, -1))

这导致以下输出:

图 3.7 – GLoVe 向量输出

图 3.7 – GLoVe 向量输出

虽然 GLoVe 嵌入非常有用且预先计算的嵌入,但我们实际上可以计算自己的嵌入。当我们分析特别独特的语料库时,这可能非常有用。例如,Twitter 上使用的语言可能与维基百科上使用的语言不同,因此在一个语料库上训练的嵌入可能对另一个语料库无效。我们将展示如何使用连续词袋来计算自己的嵌入。

探索 CBOW

连续词袋模型(CBOW):这是 Word2Vec 的一部分,由 Google 创建,用于获取单词的向量表示。通过在非常大的语料库上运行这些模型,我们能够获得详细的单词表示,这些表示代表它们在语义和上下文上的相似性。Word2Vec 模型包含两个主要组成部分:

  • CBOW:这个模型试图在文档中预测目标词,给定周围的单词。

  • 跳字模型(Skip-gram):这是 CBOW 的相反,这个模型试图根据目标词来预测周围的单词。

由于这些模型执行类似的任务,我们现在只关注其中一个,具体来说是 CBOW 模型。这个模型旨在预测一个词(目标词),给定其周围的其他单词(称为上下文单词)。一种考虑上下文单词的方法可以简单到只使用目标词前面的单词来预测目标词,而更复杂的模型可以使用目标词前后的几个单词。考虑以下句子:

PyTorch 是一个深度学习框架

假设我们想预测deep这个词,给定上下文单词:

PyTorch is a {target_word} learning framework

我们可以从多个角度来看待这个问题:

图 3.8 – 上下文和表示表

图 3.8 – 上下文和表示表

对于我们的 CBOW 模型,我们将使用长度为 2 的窗口,这意味着对于我们模型的 (X, y) 输入/输出对,我们使用 ([n-2, n-1, n+1, n+2, n]),其中 n 是我们要预测的目标单词。

使用这些作为我们模型的输入,我们将训练一个包括嵌入层的模型。这个嵌入层会自动形成我们语料库中单词的 n 维表示。然而,起初,这一层会用随机权重进行初始化。这些参数是我们模型学习的内容,以便在模型训练完成后,这个嵌入层可以被用来将我们的语料库编码成嵌入向量表示。

CBOW 架构

现在我们将设计我们模型的架构,以便学习我们的嵌入。在这里,我们的模型输入四个单词(目标单词之前两个和之后两个),并将其与输出(我们的目标单词)进行训练。以下是这个过程可能看起来的一个示例:

图 3.9 – CBOW 架构

图 3.9 – CBOW 架构

我们的输入单词首先通过一个嵌入层进行处理,表示为大小为 (n,l) 的张量,其中 n 是我们嵌入的指定长度,l 是语料库中的单词数。这是因为语料库中的每个单词都有其独特的张量表示。

使用我们四个上下文单词的组合(求和)嵌入,然后将其馈送到全连接层,以便学习目标单词的最终分类,根据我们上下文单词的嵌入表示。请注意,我们预测的/目标单词被编码为与我们语料库长度相同的向量。这是因为我们的模型有效地预测每个单词成为目标单词的概率,而最终分类是具有最高概率的那个单词。然后,我们计算损失,通过网络反向传播,更新全连接层的参数以及嵌入本身。

这种方法有效的原因是,我们学习到的嵌入表示语义相似性。假设我们在以下内容上训练我们的模型:

X = ["is", "a", "learning", "framework"]; y = "deep"

我们的模型本质上学习的是,目标单词的组合嵌入表示在语义上与我们的目标单词相似。如果我们在足够大的单词语料库上重复这个过程,我们会发现我们的单词嵌入开始类似于我们之前见过的 GLoVe 嵌入,即语义相似的单词在嵌入空间中彼此接近。

构建 CBOW

现在我们将展示如何从头开始构建一个 CBOW 模型,从而演示如何学习我们的嵌入向量:

  1. 我们首先定义一些文本并执行一些基本的文本清理,删除基本的标点并将其全部转换为小写:

    text = text.replace(',','').replace('.','').lower().                            split()
    
  2. 我们首先定义我们的语料库及其长度:

    corpus = set(text)
    corpus_length = len(corpus)
    
  3. 注意,我们使用集合而不是列表,因为我们只关注文本中的唯一单词。然后,我们构建我们的语料库索引和逆语料库索引。我们的语料库索引将允许我们获取给定单词本身时的单词索引,这在将我们的单词编码输入到我们的网络时将会很有用。我们的逆语料库索引允许我们根据索引值获取单词,这将用于将我们的预测转换回单词:

    word_dict = {}
    inverse_word_dict = {}
    for i, word in enumerate(corpus):
        word_dict[word] = i
        inverse_word_dict[i] = word
    
  4. 接下来,我们对数据进行编码。我们遍历我们的语料库,对于每个目标单词,我们捕获上下文单词(前两个单词和后两个单词)。我们将目标单词本身追加到我们的数据集中。请注意,我们从我们的语料库的第三个单词(索引=2)开始此过程,并在语料库末尾停止两步。这是因为开头的两个单词不会有两个单词在它们前面,类似地,结尾的两个单词也不会有两个单词在它们后面:

    data = []
    for i in range(2, len(text) - 2):
        sentence = [text[i-2], text[i-1],
                    text[i+1], text[i+2]]
        target = text[i]
        data.append((sentence, target))
    
    print(data[3])
    

    这导致以下输出:

    图 3.10 – 编码数据

    图 3.10 – 编码数据

  5. 然后我们定义我们的嵌入长度。虽然这个长度在技术上可以是任意你想要的数字,但是有一些需要考虑的权衡。虽然更高维度的嵌入可以导致单词更详细的表示,但特征空间也会变得更稀疏,这意味着高维度的嵌入只适用于大型语料库。此外,更大的嵌入意味着更多的参数需要学习,因此增加嵌入大小可能会显著增加训练时间。由于我们只在一个非常小的数据集上进行训练,因此我们选择使用大小为20的嵌入:

    embedding_length = 20
    

    接下来,我们在 PyTorch 中定义我们的CBOW模型。我们定义我们的嵌入层,以便它接受一个语料库长度的向量并输出一个单一的嵌入。我们将我们的线性层定义为一个全连接层,它接受一个嵌入并输出一个64维的向量。我们将我们的最终层定义为一个与我们的文本语料库长度相同的分类层。

  6. 我们通过获取和汇总所有输入上下文单词的嵌入来定义我们的前向传播。然后,这些嵌入通过具有 ReLU 激活函数的全连接层,并最终进入分类层,该层预测在语料库中哪个单词与上下文单词的汇总嵌入最匹配:

    class CBOW(torch.nn.Module):
        def __init__(self, corpus_length, embedding_dim):
            super(CBOW, self).__init__()
    
            self.embeddings = nn.Embedding(corpus_length,                             embedding_dim)
            self.linear1 = nn.Linear(embedding_dim, 64)
            self.linear2 = nn.Linear(64, corpus_length)
    
            self.activation_function1 = nn.ReLU()
            self.activation_function2 = nn.LogSoftmax                                        (dim = -1)
        def forward(self, inputs):
            embeds = sum(self.embeddings(inputs)).view(1,-1)
            out = self.linear1(embeds)
            out = self.activation_function1(out)
            out = self.linear2(out)
            out = self.activation_function2(out)
            return out
    
  7. 我们还可以定义一个get_word_embedding()函数,这将允许我们在模型训练后提取给定单词的嵌入:

    def get_word_emdedding(self, word):
    word = torch.LongTensor([word_dict[word]])
    return self.embeddings(word).view(1,-1)
    
  8. 现在,我们准备训练我们的模型。我们首先创建我们模型的一个实例,并定义损失函数和优化器:

    model = CBOW(corpus_length, embedding_length)
    loss_function = nn.NLLLoss()
    optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
    
  9. 然后,我们创建一个帮助函数,它接受我们的输入上下文词,并为每个词获取单词索引,并将它们转换为长度为 4 的张量,这将成为我们神经网络的输入:

    def make_sentence_vector(sentence, word_dict):
        idxs = [word_dict[w] for w in sentence]
        return torch.tensor(idxs, dtype=torch.long)
    print(make_sentence_vector(['stormy','nights','when','the'], word_dict))
    

    这导致以下输出:

    图 3.11 – 张量值

    图 3.11 – 张量值

  10. 现在,我们开始训练我们的网络。我们循环执行 100 个 epochs,每次通过所有上下文词(即目标词对)。对于每一个这样的对,我们使用 make_sentence_vector() 加载上下文句子,并使用当前模型状态进行预测。我们将这些预测与实际目标进行评估,以获得损失。我们进行反向传播以计算梯度,并通过优化器更新权重。最后,我们将整个 epoch 的所有损失求和并打印出来。在这里,我们可以看到我们的损失正在减少,显示出我们的模型正在学习:

    for epoch in range(100):
        epoch_loss = 0
        for sentence, target in data:
            model.zero_grad()
            sentence_vector = make_sentence_vector                               (sentence, word_dict)  
            log_probs = model(sentence_vector)
            loss = loss_function(log_probs, torch.tensor(
            [word_dict[target]], dtype=torch.long))
            loss.backward()
            optimizer.step()
            epoch_loss += loss.data
        print('Epoch: '+str(epoch)+', Loss: ' + str(epoch_loss.item()))
    

    这导致以下输出:

    图 3.12 – 训练我们的网络

    图 3.12 – 训练我们的网络

    现在我们的模型已经训练好了,我们可以进行预测了。我们定义了几个函数来实现这一点。get_predicted_result() 从预测数组中返回预测的单词,而我们的 predict_sentence() 函数则基于上下文词进行预测。

  11. 我们将我们的句子拆分为单词,并将它们转换为输入向量。然后,通过将其输入模型并使用 get_predicted_result() 函数,我们创建我们的预测数组,并通过使用上下文获得我们最终预测的单词。我们还打印预测目标单词前后的两个单词以提供上下文。我们可以运行一些预测来验证我们的模型是否工作正常:

    def get_predicted_result(input, inverse_word_dict):
        index = np.argmax(input)
        return inverse_word_dict[index]
    def predict_sentence(sentence):
        sentence_split = sentence.replace('.','').lower().                              split()
        sentence_vector = make_sentence_vector(sentence_                      split, word_dict)
        prediction_array = model(sentence_vector).data.                             numpy()
        print('Preceding Words: {}\n'.format(sentence_           split[:2]))
        print('Predicted Word: {}\n'.format(get_predicted_            result(prediction_array[0], inverse_            word_dict)))
        print('Following Words: {}\n'.format(sentence_           split[2:]))
    predict_sentence('to see leap and')
    

    这导致以下输出:

    图 3.13 – 预测值

    图 3.13 – 预测值

  12. 现在我们有了一个训练好的模型,我们可以使用 get_word_embedding() 函数来返回语料库中任何单词的 20 维词嵌入。如果我们需要为另一个 NLP 任务提取嵌入,我们实际上可以从整个嵌入层提取权重,并在我们的新模型中使用它们:

    print(model.get_word_emdedding('leap'))
    

    这导致以下输出:

图 3.14 – 编辑模型后的张量值

图 3.14 – 编辑模型后的张量值

在这里,我们展示了如何训练 CBOW 模型来创建词嵌入。实际上,要为语料库创建可靠的嵌入,我们需要一个非常大的数据集,才能真正捕捉所有单词之间的语义关系。因此,使用已经在非常大的数据语料库上训练过的预训练嵌入(如 GLoVe)可能更可取,但也可能存在某些情况,例如分析不符合正常自然语言处理的数据语料库时(例如,用户可能使用简短缩写而不是完整句子的 Twitter 数据),最好从头开始训练全新的嵌入。

探索 n-gram

在我们的 CBOW 模型中,我们成功地展示了单词的意义与其周围上下文的关系。不仅是上下文单词影响了句子中单词的含义,而且单词的顺序也很重要。考虑以下句子:

猫坐在狗上

狗坐在猫上

如果你将这两个句子转换成词袋表示法,我们会发现它们是相同的。然而,通过阅读句子,我们知道它们有完全不同的含义(事实上,它们是完全相反的!)。这清楚地表明,一个句子的含义不仅仅是它包含的单词,而是它们出现的顺序。试图捕捉句子中单词顺序的一种简单方法是使用 n-gram。

如果我们对句子进行计数,但不是计算单个单词,而是计算句子内出现的不同的两个词组,这被称为使用二元组

图 3.15 – 二元组的表格表示

图 3.15 – 二元组的表格表示

我们可以如下表示这一点:

猫坐在狗上 -> [1,1,1,0,1,1]

狗坐在猫上 -> [1,1,0,1,1,1]

这些单词对试图捕捉单词在句子中出现的顺序,而不仅仅是它们的频率。我们的第一个句子包含二元组猫坐,而另一个句子包含狗坐。这些二元组显然比仅使用原始词频更能为我们的句子增加更多上下文。

我们不仅限于单词对。我们还可以看不同的三个单词组成的三元组,称为三元组,或者任何不同数量的单词组。我们可以将 n-gram 作为深度学习模型的输入,而不仅仅是一个单词,但是当使用 n-gram 模型时,值得注意的是,您的特征空间可能会迅速变得非常大,并且可能会使机器学习变得非常缓慢。如果字典包含英语中所有单词,那么包含所有不同的单词对的字典将大几个数量级!

n-gram 语言建模

n-gram 帮助我们理解自然语言是如何形成的一件事。如果我们将语言表示为较小单词对(二元组)的部分,而不是单个单词,我们可以开始将语言建模为一个概率模型,其中单词出现在句子中的概率取决于它之前出现的单词。

一元模型中,我们假设所有单词都有出现的有限概率,基于语料库或文档中单词的分布。让我们以一个只包含一句话的文档为例:

My name is my name

基于这个句子,我们可以生成单词的分布,其中每个单词出现的概率取决于它在文档中的频率:

图 3.16 – 一元表达的表现

图 3.16 – 一元表达的表现

然后,我们可以从这个分布中随机抽取单词以生成新的句子:

Name is Name my my

但是正如我们所见,这个句子毫无意义,说明了使用一元模型的问题。因为每个单词出现的概率独立于句子中所有其他单词,所以对单词出现的顺序或上下文没有考虑。这就是 n-gram 模型有用的地方。

现在我们将考虑使用二元语言模型。这种计算考虑到一个单词出现的概率,给定它前面出现的单词:

这意味着一个单词出现的概率,给定前一个单词的概率,就是单词 n-gram 出现的概率除以前一个单词出现的概率。假设我们试图预测以下句子中的下一个单词:

My favourite language is ___

除此之外,我们还给出了以下 n-gram 和单词概率:

图 3.17 – 概率的表格表示

图 3.17 – 概率的表格表示

借此,我们可以计算出 Python 出现的概率,假设前一个单词is出现的概率仅为 20%,而English出现的概率仅为 10%。我们可以进一步扩展这个模型,使用三元组或任何我们认为适当的 n-gram 单词表示。我们已经证明了 n-gram 语言建模可以用来引入关于单词彼此关系的更多信息到我们的模型中,而不是天真地假设单词是独立分布的。

标记化

接下来,我们将学习用于 NLP 的标记化,这是对文本进行预处理以输入到我们的模型中的一种方式。标记化将我们的句子分解成更小的部分。这可能涉及将句子分割成单独的单词,或将整个文档分割成单独的句子。这是 NLP 的一个基本预处理步骤,可以在 Python 中相对简单地完成:

  1. 我们首先使用 NLTK 中的词分词器将基本句子分割成单独的单词:

    text = 'This is a single sentence.'
    tokens = word_tokenize(text)
    print(tokens)
    

    这导致以下输出:

    图 3.18 – 分割句子

    图 3.18 – 分割句子

  2. 注意句号(.)如何被视为一个标记,因为它是自然语言的一部分。根据我们想要对文本进行的处理,我们可能希望保留或丢弃标点符号:

    no_punctuation = [word.lower() for word in tokens if word.isalpha()]
    print(no_punctuation)
    

    这导致以下输出:

    图 3.19 – 移除标点符号

    图 3.19 – 移除标点符号

  3. 我们还可以使用句子分词器将文档分割成单独的句子:

    text = "This is the first sentence. This is the second sentence. A document contains many sentences."
    print(sent_tokenize(text))
    

    这导致以下输出:

    图 3.20 – 将多个句子分割为单个句子

    图 3.20 – 将多个句子分割成单个句子

  4. 或者,我们可以将两者结合起来,将其分割成单词的单个句子:

    print([word_tokenize(sentence) for sentence in sent_tokenize(text)])
    

    这导致以下输出:

    图 3.21 – 将多个句子分割成单词

    图 3.21 – 将多个句子分割成单词

  5. 在分词过程中的另一个可选步骤是移除停用词。停用词是一些非常常见的词,它们不会对句子的整体含义做出贡献。这些词包括像aIor等词。我们可以使用以下代码从 NLTK 中打印出完整的停用词列表:

    stop_words = stopwords.words('english')
    print(stop_words[:20])
    

    这导致以下输出:

    图 3.22 – 显示停用词

    图 3.22 – 显示停用词

  6. 我们可以使用基本的列表推导来轻松地从我们的单词中移除这些停用词:

    text = 'This is a single sentence.'
    tokens = [token for token in word_tokenize(text) if token not in stop_words]
    print(tokens)
    

    这导致以下输出:

图 3.23 – 移除停用词

图 3.23 – 移除停用词

虽然某些 NLP 任务(如预测句子中的下一个单词)需要使用停用词,而其他任务(如判断电影评论的情感)则不需要,因为停用词对文档的整体含义贡献不大。在这些情况下,移除停用词可能更为可取,因为这些常见词的频率意味着它们可能会不必要地增加我们的特征空间,这会增加模型训练的时间。

词性标注和块句法分析

到目前为止,我们已经涵盖了几种表示单词和句子的方法,包括词袋模型、嵌入和 n-gram。然而,这些表示方法未能捕捉到任何给定句子的结构。在自然语言中,不同的单词在句子中可能具有不同的功能。考虑以下例子:

大狗正在床上睡觉

我们可以“标记”文本中的各个单词,具体取决于每个单词在句子中的功能。因此,前述句子变成如下所示:

The -> big -> dog -> is -> sleeping -> on -> the -> bed

限定词 -> 形容词 -> 名词 -> 动词 -> 动词 -> 介词 -> 限定词 -> 名词

这些词性包括但不限于以下内容:

图 3.24 – 词性

图 3.24 – 词性

这些不同的词性可以帮助我们更好地理解句子的结构。例如,形容词在英语中通常在名词前面。我们可以在模型中使用这些词性及其彼此之间的关系。例如,如果我们正在预测句子中的下一个词,而上下文词是形容词,我们就知道下一个词很可能是名词。

标记

词性标注是将这些词性标签分配给句子中各个单词的行为。幸运的是,NLTK 具有内置的标注功能,因此我们无需训练自己的分类器即可执行此操作:

sentence = "The big dog is sleeping on the bed"
token = nltk.word_tokenize(sentence)
nltk.pos_tag(token)

这将产生以下输出:

图 3.25 – 词性分类

图 3.25 – 词性分类

在这里,我们只需对文本进行分词,并调用pos_tag()函数对句子中的每个单词进行标记。这将返回每个单词的标记。我们可以通过在代码中调用upenn_tagset()来解码该标记的含义。在这种情况下,我们可以看到"VBG"对应于动词:

nltk.help.upenn_tagset("VBG")

这将产生以下输出:

图 3.26 – VBG 的解释

图 3.26 – VBG 的解释

使用预训练的词性标注工具是有益的,因为它们不仅充当查找句子中各个单词的字典,还利用单词在句子中的上下文来确定其含义。考虑以下句子:

他喝水

我会给我们买一些饮料

在这些句子中,单词drinks代表两种不同的词性。在第一个句子中,drinks指的是动词;动词drink的现在时。在第二个句子中,drinks指的是名词;单数drink的复数形式。我们的预训练标注器能够确定这些单词的上下文并进行准确的词性标注。

切块

切块扩展了我们最初的词性标注,旨在将句子结构化成小块,其中每个块代表一个小词性。

我们可能希望将文本分成实体,其中每个实体是一个单独的对象或物品。例如,红色的书不是指三个单独的实体,而是指由三个单词描述的一个实体。我们可以再次使用 NLTK 轻松实现切块。我们首先必须定义一个使用正则表达式匹配的语法模式。所考虑的模式查找名词短语NP),其中名词短语被定义为限定词DT),后跟可选形容词JJ),再跟一个名词NN):

expression = ('NP: {<DT>?<JJ>*<NN>}')

使用RegexpParser()函数,我们可以匹配此表达式的出现并将其标记为名词短语。然后,我们能够打印出生成的树,显示标记的短语。在我们的例句中,我们可以看到大狗被标记为两个单独的名词短语。我们能够根据需要使用正则表达式定义任何文本块进行匹配:

tagged = nltk.pos_tag(token)
REchunkParser = nltk.RegexpParser(expression)
tree = REchunkParser.parse(tagged)
print(tree)

这导致以下输出:

图 3.27 – 树表示

图 3.27 – 树表示

TF-IDF

TF-IDF是另一种我们可以学习的技术,用于更好地表示自然语言。它经常用于文本挖掘和信息检索,以基于搜索术语匹配文档,但也可以与嵌入结合使用,以更好地以嵌入形式表示句子。让我们看看以下短语:

这是一只小长颈鹿

假设我们想要一个单一的嵌入来表示这个句子的含义。我们可以做的一件事是简单地平均每个单词的个体嵌入:

图 3.28 – 词嵌入

图 3.28 – 词嵌入

然而,这种方法将所有单词分配相等的权重。您认为所有单词对句子的含义贡献相等吗?Thisa是英语中非常常见的词,但giraffe非常少见。因此,我们可能希望给较稀有的单词分配更高的权重。这种方法被称为词频 - 逆文档频率TF-IDF)。接下来我们将展示如何计算我们文档的 TF-IDF 权重。

计算 TF-IDF

正如名称所示,TF-IDF 包括两个单独的部分:词频和逆文档频率。词频是一个文档特定的度量,计算在正在分析的文档中给定单词的频率:

请注意,我们将此度量值除以文档中的总字数,因为长文档更有可能包含任何给定单词。如果一个单词在文档中出现多次,它将获得较高的词频。然而,这与我们希望 TF-IDF 加权的相反,因为我们希望给予文档中稀有单词出现的更高权重。这就是 IDF 发挥作用的地方。

文档频率衡量的是在整个文档语料库中分析的单词出现的文档数量,而逆文档频率计算的是总文档数与文档频率的比率:

如果我们有一个包含 100 个文档的语料库,并且我们的单词在这些文档中出现了五次,那么我们的逆文档频率将是 20。这意味着对于在所有文档中出现次数较少的单词,给予了更高的权重。现在,考虑一个包含 100,000 个文档的语料库。如果一个单词只出现一次,它的 IDF 将是 100,000,而出现两次的单词的 IDF 将是 50,000。这些非常大且不稳定的 IDF 对于我们的计算不是理想的,因此我们必须首先通过对数对其进行归一化。请注意,在我们的计算中添加 1 是为了防止在我们的语料库中计算 TF-IDF 时出现除以零的情况:

这使得我们最终的 TF-IDF 方程如下所示:

现在我们可以演示如何在 Python 中实现这一点,并将 TF-IDF 权重应用于我们的嵌入向量。

实现 TF-IDF

在这里,我们将使用 NLTK 数据集中的 Emma 语料库来实现 TF-IDF。该数据集由简·奥斯汀的书 Emma 中的若干句子组成,我们希望为每个句子计算嵌入向量表示:

  1. 我们首先导入我们的数据集,并循环遍历每个句子,删除任何标点符号和非字母数字字符(如星号)。我们选择保留数据集中的停用词,以演示 TF-IDF 如何考虑这些词,因为这些词出现在许多文档中,因此其 IDF 非常低。我们创建一个解析后的句子列表和我们语料库中不同单词的集合:

    emma = nltk.corpus.gutenberg.sents('austen-emma.txt')
    emma_sentences = []
    emma_word_set = []
    for sentence in emma:
        emma_sentences.append([word.lower() for word in          sentence if word.isalpha()])
        for word in sentence:
            if word.isalpha():
                emma_word_set.append(word.lower())
    emma_word_set = set(emma_word_set)
    
  2. 接下来,我们创建一个函数,该函数将返回给定文档中给定单词的词频。我们获取文档的长度以获取单词数,并计算文档中该单词的出现次数,然后返回比率。在这里,我们可以看到单词 ago 在句子中出现了一次,而该句子共有 41 个单词,因此我们得到了词频为 0.024:

    def TermFreq(document, word):
        doc_length = len(document)
        occurances = len([w for w in document if w == word])
        return occurances / doc_length
    TermFreq(emma_sentences[5], 'ago')
    

    这导致以下输出:

    图 3.29 – TF-IDF 分数

    图 3.29 – TF-IDF 分数

  3. 接下来,我们计算我们的文档频率。为了有效地做到这一点,我们首先需要预先计算一个文档频率字典。这会遍历所有数据,并计算我们语料库中每个单词出现在多少个文档中。我们预先计算这个字典,这样我们就不必每次想要计算给定单词的文档频率时都进行循环:

    def build_DF_dict():
        output = {}
        for word in emma_word_set:
            output[word] = 0
            for doc in emma_sentences:
                if word in doc:
                    output[word] += 1
        return output
    
    df_dict = build_DF_dict()
    df_dict['ago']
    
  4. 在这里,我们可以看到单词 ago 在我们的文档中出现了 32 次。利用这个字典,我们可以非常容易地通过将文档总数除以文档频率并取其对数来计算逆文档频率。请注意,在文档频率为零时,我们将文档频率加一以避免除以零错误:

    def InverseDocumentFrequency(word):
        N = len(emma_sentences)
        try:
            df = df_dict[word] + 1
        except:
            df = 1
        return np.log(N/df)
    InverseDocumentFrequency('ago')
    
  5. 最后,我们只需将词频和逆文档频率结合起来,即可获得每个单词/文档对的 TF-IDF 权重:

    def TFIDF(doc, word):
        tf = TF(doc, word)
        idf = InverseDocumentFrequency(word)
        return tf*idf
    print('ago - ' + str(TFIDF(emma_sentences[5],'ago')))
    print('indistinct - ' + str(TFIDF(emma_sentences[5],'indistinct')))
    

    这导致以下输出:

图 3.30 – ago 和 indistinct 的 TF-IDF 分数

图 3.30 – ago 和 indistinct 的 TF-IDF 分数

在这里,我们可以看到,尽管单词 agoindistinct 在给定文档中只出现一次,但 indistinct 在整个语料库中出现的频率较低,意味着它获得了较高的 TF-IDF 加权。

计算 TF-IDF 加权嵌入

接下来,我们可以展示如何将这些 TF-IDF 加权应用到嵌入中:

  1. 我们首先加载我们预先计算的 GLoVe 嵌入,为我们语料库中单词提供初始的嵌入表示:

    def loadGlove(path):
        file = open(path,'r')
        model = {}
        for l in file:
            line = l.split()
            word = line[0]
            value = np.array([float(val) for val in                           line[1:]])
            model[word] = value
        return model
    glove = loadGlove('glove.6B.50d.txt')
    
  2. 我们接着计算文档中所有单个嵌入的无权平均值,以得到整个句子的向量表示。我们简单地遍历文档中的所有单词,从 GLoVe 字典中提取嵌入,并计算所有这些向量的平均值:

    embeddings = []
    for word in emma_sentences[5]:
        embeddings.append(glove[word])
    mean_embedding = np.mean(embeddings, axis = 0).reshape      (1, -1)
    print(mean_embedding)
    

    这导致以下输出:

    图 3.31 – 平均嵌入

    图 3.31 – 平均嵌入

  3. 我们重复这个过程来计算我们的 TF-IDF 加权文档向量,但这次,在我们对它们求平均之前,我们将我们的向量乘以它们的 TF-IDF 加权:

    embeddings = []
    for word in emma_sentences[5]:
        tfidf = TFIDF(emma_sentences[5], word)
        embeddings.append(glove[word]* tfidf) 
    
    tfidf_weighted_embedding = np.mean(embeddings, axis =                               0).reshape(1, -1)
    print(tfidf_weighted_embedding)
    

    这导致以下输出:

    图 3.32 – TF-IDF 嵌入

    图 3.32 – TF-IDF 嵌入

  4. 然后,我们可以比较 TF-IDF 加权嵌入和我们的平均嵌入,看它们有多相似。我们可以使用余弦相似度来进行此操作,如下所示:

    cosine_similarity(mean_embedding, tfidf_weighted_embedding)
    

    这导致以下输出:

图 3.33 – TF-IDF 和平均嵌入的余弦相似度

图 3.33 – TF-IDF 和平均嵌入的余弦相似度

在这里,我们可以看到我们的两种不同表示非常相似。因此,虽然使用 TF-IDF 可能不会显著改变我们对给定句子或文档的表示,但它可能会偏向于感兴趣的词语,从而提供更有用的表示。

摘要

在本章中,我们深入探讨了词嵌入及其应用。我们展示了如何使用连续词袋模型训练它们,并如何结合 n-gram 语言建模以更好地理解句子中词语之间的关系。然后,我们查看了如何将文档拆分为个别标记以便于处理,以及如何使用标记和块分析来识别词性。最后,我们展示了如何使用 TF-IDF 权重来更好地表示文档的嵌入形式。

在下一章中,我们将看到如何使用 NLP 进行文本预处理、词干化和词形还原。

第四章:文本预处理,词干化和词形归并

文本数据可以从许多不同的来源收集,并采用许多不同的形式。文本可以整洁可读,也可以原始混乱,还可以以许多不同的样式和格式出现。能够对此数据进行预处理,使其能够在到达我们的 NLP 模型之前转换为标准格式,这是我们将在本章中探讨的内容。

词干化和词形归并,类似于分词,是 NLP 预处理的其他形式。然而,与将文档减少为单个词语的分词不同,词干化和词形归并试图进一步将这些词语减少到它们的词汇根。例如,英语中几乎任何动词都有许多不同的变体,取决于时态:

他跳跃了

他正在跳跃

他跳跃

尽管所有这些单词不同,它们都与相同的词根词 – jump 相关。词干化和词形归并都是我们可以使用的技术,用于将单词变体减少到它们的共同词根。

在本章中,我们将解释如何对文本数据进行预处理,以及探索词干化和词形归并,并展示如何在 Python 中实现这些技术。

在本章中,我们将涵盖以下主题:

  • 文本预处理

  • 词干化

  • 词形归并

  • 词干化和词形归并的用途

技术要求

对于本章中的文本预处理,我们将主要使用 Python 内置函数,但也会使用外部的BeautifulSoup包。对于词干化和词形归并,我们将使用 NLTK Python 包。本章的所有代码可以在github.com/PacktPublishing/Hands-On-Natural-Language-Processing-with-PyTorch-1.x/tree/master/Chapter4找到。

文本预处理

文本数据可以以多种格式和样式出现。文本可能以结构化、可读的格式或更原始、非结构化的格式存在。我们的文本可能包含我们不希望在模型中包含的标点符号和符号,或者可能包含 HTML 和其他非文本格式。这在从在线源获取文本时尤为重要。为了准备我们的文本以便能够输入到任何 NLP 模型中,我们必须进行预处理。这将清洁我们的数据,使其处于标准格式。在本节中,我们将详细说明一些这些预处理步骤。

移除 HTML

当从在线源中抓取文本时,您可能会发现您的文本包含 HTML 标记和其他非文本性的工件。通常我们不希望将这些内容包含在我们的 NLP 输入中供我们的模型使用,因此默认应删除这些内容。例如,在 HTML 中,<b>标签指示其后的文本应为粗体字体。然而,这并未包含有关句子内容的任何文本信息,因此我们应该将其删除。幸运的是,在 Python 中有一个名为BeautifulSoup的包,可以让我们用几行代码轻松删除所有 HTML:

input_text = "<b> This text is in bold</br>, <i> This text is in italics </i>"
output_text =  BeautifulSoup(input_text, "html.parser").get_text()
print('Input: ' + input_text)
print('Output: ' + output_text)

这将返回以下输出:

图 4.1 – 删除 HTML

图 4.1 – 删除 HTML

前面的截图显示已成功删除了 HTML。这在原始文本数据中存在 HTML 代码的任何情况下可能很有用,例如在从网页上抓取数据时。

将文本转换为小写

在预处理文本时,将所有内容转换为小写是标准做法。这是因为任何两个相同的单词应该被认为在语义上是相同的,无论它们是否大写。 'Cat','cat'和'CAT'都是相同的单词,只是元素大小写不同。我们的模型通常会将这三个单词视为不同实体,因为它们并不相同。因此,将所有单词转换为小写是标准做法,这样这些单词在语义上和结构上都是相同的。在 Python 中,可以通过以下几行代码很容易地完成这个过程:

input_text = ['Cat','cat','CAT']
output_text =  [x.lower() for x in input_text]
print('Input: ' + str(input_text))
print('Output: ' + str(output_text))

这将返回以下输出:

图 4.2 – 将输入转换为小写

图 4.2 – 将输入转换为小写

这显示输入已全部转换为相同的小写表示。有几个例子,大写实际上可能提供额外的语义信息。例如,May(月份)和may(表示“可能”)在语义上是不同的,May(月份)始终大写。然而,这种情况非常罕见,将所有内容转换为小写比试图考虑这些罕见例子要有效得多。

大写在某些任务中可能很有用,例如词性标注,其中大写字母可能表明单词在句子中的角色,以及命名实体识别,其中大写字母可能表明单词是专有名词而不是非专有名词替代词;例如,Turkey(国家)和turkey(鸟)。

删除标点符号

有时,根据正在构建的模型类型,我们可能希望从输入文本中删除标点符号。这在像词袋表示法这样的模型中特别有用,我们在这些模型中聚合词频。句子中的句号或逗号并不会增加关于句子语义内容的有用信息。然而,在考虑标点符号位置的复杂模型中,实际上可以使用标点符号的位置来推断不同的含义。一个经典的例子如下:

熊猫吃饭开枪和离开

熊猫吃饭,开枪和离开

在这里,通过添加逗号,将描述熊猫饮食习惯的句子转变为描述熊猫抢劫餐馆的句子!然而,为了保持一致性,能够从句子中删除标点符号仍然很重要。我们可以通过使用 re 库来实现这一点,在正则表达式中匹配任何标点符号,并使用 sub() 方法将任何匹配的标点符号替换为空字符来完成这一操作:

input_text = "This ,sentence.'' contains-£ no:: punctuation?"
output_text = re.sub(r'[^\w\s]', '', input_text)
print('Input: ' + input_text)
print('Output: ' + output_text)

这返回以下输出:

图 4.3 – 从输入中删除标点符号

图 4.3 – 从输入中删除标点符号

这表明输入句子中的标点已被移除。

可能存在我们不希望直接删除标点符号的情况。一个很好的例子是使用和符号 (&),几乎在每个实例中都可以与单词 "and" 交换使用。因此,与其完全删除和符号,我们可能会选择直接用单词 "and" 替换它。我们可以在 Python 中使用 .replace() 函数轻松实现这一点:

input_text = "Cats & dogs"
output_text = input_text.replace("&", "and")
print('Input: ' + input_text)
print('Output: ' + output_text)

这返回以下输出:

图 4.4 – 删除和替换标点符号

图 4.4 – 删除和替换标点符号

同样值得考虑的是特定情况下标点符号可能对句子的表达至关重要。一个关键的例子是电子邮件地址。从电子邮件地址中删除 @ 不会使地址更易读:

name@gmail.com

删除标点符号返回如下结果:

namegmailcom

因此,在这种情况下,根据您的 NLP 模型的要求和目的,可能更倾向于完全删除整个项目。

替换数字

同样地,对于数字,我们也希望标准化我们的输出。数字可以用数字(9、8、7)或实际单词(九、八、七)来表示。值得将这些统一转换为单一的标准表示形式,以便 1 和 one 不被视为不同实体。我们可以使用以下方法在 Python 中实现这一点:

def to_digit(digit):
    i = inflect.engine()
    if digit.isdigit():
        output = i.number_to_words(digit)
    else:
        output = digit
    return output
input_text = ["1","two","3"]
output_text = [to_digit(x) for x in input_text]
print('Input: ' + str(input_text))
print('Output: ' + str(output_text))

这返回以下输出:

图 4.5 – 用文本替换数字

图 4.5 – 用文本替换数字

这表明我们已成功将数字转换为文本。

然而,类似于处理电子邮件地址,处理电话号码可能不需要与常规数字相同的表示形式。以下示例说明了这一点:

input_text = ["0800118118"]
output_text = [to_digit(x) for x in input_text]
print('Input: ' + str(input_text))
print('Output: ' + str(output_text))

这返回以下输出:

图 4.6 – 将电话号码转换为文本

图 4.6 – 将电话号码转换为文本

显然,在上述示例中输入的是电话号码,因此完整的文本表示未必适合特定用途。在这种情况下,可能更倾向于从我们的输入文本中删除任何较长的数字。

词干提取和词形还原

在语言中,屈折变化是通过修改一个共同的根词来表达不同的语法类别,如时态、语气或性别。这通常涉及改变单词的前缀或后缀,但也可能涉及修改整个单词。例如,我们可以修改动词以改变其时态:

Run -> Runs(添加 "s" 后缀以使其现在时)

Run -> Ran(修改中间字母为 "a" 以使其过去时)

但在某些情况下,整个单词会发生变化:

To be -> Is(现在时)

To be -> Was(过去时)

To be -> Will be(将来时 – 添加情态动词)

名词也可以存在词汇变化:

Cat -> Cats(复数)

Cat -> Cat's(所有格)

Cat -> Cats'(复数所有格)

所有这些单词都与根词 cat 相关。我们可以计算句子中所有单词的根,以将整个句子简化为其词汇根:

"他的猫的毛色不同" -> "他 猫 毛色 不同"

词干提取词形还原是通过这些根词来达到这些根词的过程。词干提取是一种算法过程,在这种过程中,单词的结尾被切掉以得到一个共同的词根,而词形还原则使用真实的词汇和对单词本身的结构分析,以得到单词的真正词根或词元。我们将在接下来的部分详细介绍这两种方法。

词干提取

词干提取是通过裁剪单词的末尾来到达它们的词汇根或词干的算法过程。为此,我们可以使用不同的词干提取器,每个都遵循特定的算法以返回单词的词干。在英语中,最常见的词干提取器之一是 Porter Stemmer。

Porter Stemmer 是一个具有大量逻辑规则的算法,用于返回单词的词干。我们将首先展示如何使用 NLTK 在 Python 中实现 Porter Stemmer,然后进一步讨论该算法的详细内容:

  1. 首先,我们创建一个 Porter Stemmer 的实例:

    porter = PorterStemmer()
    
  2. 然后我们简单地在单词上调用这个词干提取器的实例并打印结果。在这里,我们可以看到 Porter Stemmer 返回的词干示例:

    word_list = ["see","saw","cat", "cats", "stem", "stemming","lemma","lemmatization","known","knowing","time", "timing","football", "footballers"]
    for word in word_list:
        print(word + ' -> ' + porter.stem(word))
    

    这导致以下输出:

    图 4.7 – 返回单词的词干

    图 4.7 – 返回单词的词干

  3. 我们还可以将词干提取应用于整个句子,首先将句子进行标记化,然后逐个提取每个词项:

    def SentenceStemmer(sentence):
        tokens=word_tokenize(sentence)
        stems=[porter.stem(word) for word in tokens]
        return " ".join(stems)
    SentenceStemmer('The cats and dogs are running')
    

这将返回以下输出:

图 4.8 – 将词干提取应用于句子

图 4.8 – 将词干提取应用于句子

在这里,我们可以看到如何使用 Porter Stemmer 提取不同的单词。一些单词,如 stemmingtiming,会缩减为它们期望的词干 stemtime。然而,一些单词,如 saw,并不会缩减为它们的逻辑词干(see)。这展示了 Porter Stemmer 的局限性。由于词干提取对单词应用一系列逻辑规则,定义一组可以正确提取所有单词的规则是非常困难的。特别是在英语中,一些词根据时态变化完全不同(is/was/be),因此没有通用的规则可以应用于这些单词,将它们全部转换为相同的根词。

我们可以详细研究一些 Porter Stemmer 应用的规则,以了解转换为词干的确切过程。虽然实际的 Porter 算法有许多详细步骤,但在这里,我们将简化一些规则以便于理解:

图 4.9 – Porter Stemmer 算法的规则

图 4.9 – Porter Stemmer 算法的规则

虽然理解 Porter Stemmer 内的每条规则并非必需,但我们理解其局限性至关重要。尽管 Porter Stemmer 在语料库中表现良好,但总会有些词汇无法正确还原为其真实的词干。由于 Porter Stemmer 的规则集依赖于英语单词结构的惯例,总会有些词汇不符合传统的单词结构,无法通过这些规则正确变换。幸运的是,通过词形还原,我们可以克服其中一些限制。

词形还原

ran 将仅仅是 ran,它的词形还原是这个单词的真实词根,即 run

词形还原过程利用预先计算的词形和相关单词,以及单词在句子中的上下文来确定给定单词的正确词形。在这个例子中,我们将介绍如何在 NLTK 中使用 WordNet Lemmatizer。WordNet 是一个包含英语单词及其词汇关系的大型数据库。它包含了对英语语言关系的最强大和最全面的映射,特别是单词与它们词形关系的映射。

我们首先创建一个词形还原器的实例,并对一些单词进行调用:

wordnet_lemmatizer = WordNetLemmatizer()
print(wordnet_lemmatizer.lemmatize('horses'))
print(wordnet_lemmatizer.lemmatize('wolves'))
print(wordnet_lemmatizer.lemmatize('mice'))
print(wordnet_lemmatizer.lemmatize('cacti'))

这导致以下输出:

图 4.10 – 词形还原输出

图 4.10 – 词形还原输出

在这里,我们已经可以开始看到使用词形还原法比使用词干提取法的优势。由于 WordNet 词形还原器建立在包含所有英语单词的数据库上,它知道 micemouse 的复数形式。使用词干提取法我们无法达到相同的词根。尽管在大多数情况下词形还原法效果更好,因为它依赖于内置的单词索引,但它无法泛化到新的或虚构的单词:

print(wordnet_lemmatizer.lemmatize('madeupwords'))
print(porter.stem('madeupwords'))

这导致以下输出:

图 4.11 – 虚构单词的词形还原输出

图 4.11 – 虚构单词的词形还原输出

在这里,我们可以看到,在这种情况下,我们的词干提取器能够更好地泛化到以前未见过的单词。因此,在词形还原化不一定与真实英语语言相匹配的源语言,例如人们可能经常缩写语言的社交媒体网站上使用词形还原器可能会有问题。

如果我们对两个动词调用我们的词形还原器,我们会发现这并没有将它们减少到预期的共同词形还原形式:

print(wordnet_lemmatizer.lemmatize('run'))
print(wordnet_lemmatizer.lemmatize('ran'))

这导致以下输出:

图 4.12 – 对动词进行词形还原

图 4.12 – 对动词进行词形还原

这是因为我们的词形还原器依赖于单词的上下文来返回词形还原形式。回顾我们的词性分析,我们可以轻松地返回句子中单词的上下文,并确定给定单词是名词、动词还是形容词。现在,让我们手动指定我们的单词是动词。我们可以看到,现在它能够正确返回词形还原形式:

print(wordnet_lemmatizer.lemmatize('ran', pos='v'))
print(wordnet_lemmatizer.lemmatize('run', pos='v'))

这导致以下输出:

图 4.13 – 在函数中实现词性标注

图 4.13 – 在函数中实现词性标注

这意味着为了返回任意给定句子的正确词形还原,我们必须首先执行词性标注以获取句子中单词的上下文,然后通过词形还原器获取句子中每个单词的词形还原形式。我们首先创建一个函数,用于返回句子中每个单词的词性标注:

sentence = 'The cats and dogs are running'
def return_word_pos_tuples(sentence):
    return nltk.pos_tag(nltk.word_tokenize(sentence))
return_word_pos_tuples(sentence)

这导致以下输出:

图 4.14 – 句子的词性标注输出

图 4.14 – 句子的词性标注输出

注意这如何返回句子中每个单词的 NLTK 词性标签。我们的 WordNet 词形还原器需要稍微不同的输入以获取词性标签。这意味着我们首先创建一个函数,将 NLTK 词性标签映射到所需的 WordNet 词性标签:

def get_pos_wordnet(pos_tag):
    pos_dict = {"N": wordnet.NOUN,
                "V": wordnet.VERB,
                "J": wordnet.ADJ,
                "R": wordnet.ADV}
    return pos_dict.get(pos_tag[0].upper(), wordnet.NOUN)
get_pos_wordnet('VBG')

这导致以下输出:

图 4.15 – 将 NLTK 词性标签映射到 WordNet 词性标签

图 4.15 – 将 NLTK 词性标签映射到 WordNet 词性标签

最后,我们将这些函数组合成一个最终函数,将对整个句子进行词形还原:

def lemmatize_with_pos(sentence):
    new_sentence = []
    tuples = return_word_pos_tuples(sentence)
    for tup in tuples:
        pos = get_pos_wordnet(tup[1])
        lemma = wordnet_lemmatizer.lemmatize(tup[0], pos=pos)
        new_sentence.append(lemma)
    return new_sentence
lemmatize_with_pos(sentence)

这导致以下输出:

图 4.16 – 最终词形还原函数的输出

图 4.16 - 最终词形还原函数的输出

在这里,我们可以看到,总体而言,词形还原一般提供了比词干更好的词根表示,但也有一些显著的例外。我们何时决定使用词干化和词形还原取决于手头任务的需求,其中一些我们现在将进行探索。

使用词干化和词形还原

词干化和词形还原都是一种可以用于从文本中提取信息的自然语言处理形式。这被称为文本挖掘。文本挖掘任务有各种类别,包括文本聚类、分类、文档摘要和情感分析。词干化和词形还原可以与深度学习结合使用来解决其中一些任务,我们将在本书后面看到。

通过使用词干化和词形还原的预处理,再加上去除停用词,我们可以更好地减少句子以理解其核心含义。通过去除对句子含义贡献不大的词汇,并将词汇还原为其词根或词形还原形式,我们可以在深度学习框架内高效分析句子。如果我们能将一个由 10 个词组成的句子缩减为包含多个核心词形还原形式而非多个类似词汇变化的五个词,那么我们需要馈送到神经网络的数据量就大大减少了。如果我们使用词袋表示法,我们的语料库会显著减小,因为多个词都可以还原为相同的词形还原形式,而如果我们计算嵌入表示法,所需的维度则更小,用于表示我们的词汇的真实表示形式。

单词的词形还有提取

现在我们已经看到词形还原和词干化的应用,问题仍然是在什么情况下我们应该使用这两种技术。我们看到这两种技术都试图将每个词减少到它的根本。在词干化中,这可能只是目标词的简化形式,而在词形还原中,它则减少到一个真正的英语单词根。

因为词形还原需要在 WordNet 语料库内交叉参考目标词,以及执行词性分析来确定词形还原的形式,如果需要词形还原大量单词,这可能需要大量的处理时间。这与词干化相反,词干化使用了详细但相对快速的算法来词干化单词。最终,就像计算中的许多问题一样,这是一个在速度与详细度之间权衡的问题。在选择这些方法之一来结合我们的深度学习管道时,权衡可能在速度和准确性之间。如果时间紧迫,那么词干化可能是更好的选择。另一方面,如果您需要模型尽可能详细和准确,那么词形还原可能会产生更优越的模型。

概述

在本章中,我们详细讨论了词干提取和词形还原,通过探索这两种方法的功能、使用案例以及它们的实施方式。现在,我们已经掌握了深度学习和自然语言处理预处理的所有基础知识,可以开始从头开始训练我们自己的深度学习模型了。

在下一章中,我们将探讨自然语言处理的基础知识,并展示如何在深度自然语言处理领域内构建最常用的模型:循环神经网络。

第三部分:使用 PyTorch 1.x 的真实世界 NLP 应用

在本节中,我们将使用 PyTorch 中提供的各种自然语言处理NLP)技术来构建各种真实世界的应用程序。使用 PyTorch 进行情感分析、文本摘要、文本分类以及构建聊天机器人应用程序是本节将涵盖的一些任务。

本节包含以下章节:

  • 第五章递归神经网络与情感分析

  • 第六章用于文本分类的卷积神经网络

  • 第七章使用序列到序列神经网络进行文本翻译

  • 第八章使用基于注意力的神经网络构建聊天机器人

  • 第九章前方的道路

第五章:递归神经网络和情感分析

在本章中,我们将研究递归神经网络RNNs),这是 PyTorch 中的基本前馈神经网络的变体,我们在第一章**,机器学习基础中学习了如何构建。通常情况下,RNNs 可以用于数据可以表示为序列的任何任务。这包括诸如股票价格预测的事情,使用时间序列的历史数据表示为序列。我们通常在 NLP 中使用 RNNs,因为文本可以被视为单词的序列并且可以建模为这样的序列。虽然传统神经网络将单个向量作为输入模型,但是 RNN 可以接受整个向量序列。如果我们将文档中的每个单词表示为向量嵌入,我们可以将整个文档表示为向量序列(或三阶张量)。然后,我们可以使用 RNNs(以及称为长短期记忆LSTM)的更复杂形式的 RNN)从我们的数据中学习。

本章将涵盖 RNN 的基础知识以及更高级的 LSTM。然后,我们将看看情感分析,并通过一个实际例子演示如何使用 PyTorch 构建 LSTM 来对文档进行分类。最后,我们将在 Heroku 上托管我们的简单模型,这是一个简单的云应用平台,可以让我们使用我们的模型进行预测。

本章涵盖以下主题:

  • 构建 RNNs

  • 使用 LSTM

  • 使用 LSTM 构建情感分析器

  • 在 Heroku 上部署应用程序

技术要求

本章中使用的所有代码都可以在 github.com/PacktPublishing/Hands-On-Natural-Language-Processing-with-PyTorch-1.x/tree/master/Chapter5 找到。Heroku 可以从 www.heroku.com 安装。数据来自 archive.ics.uci.edu/ml/datasets/Sentiment+Labelled+Sentences

构建 RNNs

RNN 由递归层组成。虽然在许多方面与标准前馈神经网络中的全连接层相似,但这些递归层包含一个在顺序输入的每个步骤中更新的隐藏状态。这意味着对于任何给定的序列,模型都以隐藏状态初始化,通常表示为一维向量。然后,我们的序列的第一步被送入模型,隐藏状态根据一些学习参数进行更新。然后再将第二个单词送入网络,隐藏状态再次根据其他学习参数进行更新。重复这些步骤,直到整个序列被处理完毕,我们得到最终的隐藏状态。这个计算循环,隐藏状态从先前的计算中传递并更新,是我们称之为递归的网络的原因。然后将这个最终的隐藏状态连接到更进一步的全连接层,并预测最终的分类。

我们的递归层大致如下所示,其中h为隐藏状态,x为我们序列中各个时间步的输入。对于每次迭代,我们更新每个时间步的隐藏状态x

图 5.1 – 循环层

图 5.1 – 循环层

或者,我们可以将其扩展到整个时间步骤序列,如下所示:

图 5.2 – 时间步骤序列

图 5.2 – 时间步骤序列

此层适用于n个时间步骤的输入。我们的隐藏状态在状态h0 中初始化,然后使用第一个输入x1 来计算下一个隐藏状态h1。还有两组权重矩阵需要学习——矩阵U学习隐藏状态在时间步骤之间的变化,矩阵W学习每个输入步骤如何影响隐藏状态。

我们还对结果乘积应用tanh激活函数,将隐藏状态的值保持在-1 到 1 之间。计算任意隐藏状态ht 的方程如下:

然后对输入序列中的每个时间步骤重复此过程,该层的最终输出是我们的最后一个隐藏状态hn。当网络学习时,我们执行网络的前向传播,计算最终的分类。然后根据这个预测计算损失,并像以前一样反向传播,计算梯度。这个反向传播过程发生在递归层内的所有步骤中,学习每个输入步骤和隐藏状态之间的参数。

后面我们将看到,实际上我们可以获取每个时间步的隐藏状态,而不是使用最终的隐藏状态,这对于自然语言处理中的序列到序列翻译任务非常有用。然而,目前我们将隐藏层的输出作为网络的其余部分的输出。

使用 RNN 进行情感分析

在情感分析的背景下,我们的模型是在一个评论情感分析数据集上训练的,该数据集包含多个文本评论和一个标签,标签为 0 或 1,取决于评论是负面还是正面。这意味着我们的模型成为一个分类任务(其中两个类别是负面/正面)。我们的句子通过一个学习到的词嵌入层,形成包含多个向量(每个单词一个向量)的句子表示。然后,这些向量按顺序馈送到我们的 RNN 层,最终隐藏状态通过另一个全连接层。我们模型的输出是一个介于 0 和 1 之间的单个值,取决于我们的模型是否预测从句子中获得负面或正面情感。这意味着我们完整的分类模型看起来像这样:

图 5.3 – 分类模型

图 5.3 – 分类模型

现在,我们将重点介绍 RNN 中的一个问题——梯度爆炸和梯度消失——以及我们如何使用梯度裁剪来解决这个问题。

梯度爆炸和梯度消失

在 RNN 中经常遇到的一个问题是梯度爆炸或梯度消失。我们可以将递归层视为一个非常深的网络。在计算梯度时,我们在每次隐藏状态迭代时进行。如果在任何给定位置,损失相对于权重的梯度变得非常大,这将在通过递归层所有迭代时产生乘性效应。这可能会导致梯度因快速增长而爆炸。如果我们有大的梯度,这可能会导致网络不稳定。另一方面,如果我们的隐藏状态中的梯度非常小,这将再次产生乘性效应,并且梯度将接近 0。这意味着梯度可能变得太小,无法通过梯度下降准确更新我们的参数,从而导致我们的模型无法学习。

我们可以使用的一种技术来防止梯度爆炸是使用梯度裁剪。这种技术限制了我们的梯度,防止它们变得太大。我们只需选择一个超参数 C,可以计算我们的裁剪梯度如下:

下图显示了两个变量之间的关系:

图 5.4 – 梯度裁剪比较

图 5.4 – 梯度裁剪比较

另一种防止梯度爆炸或消失的技术是缩短输入序列长度。我们的递归层的有效深度取决于输入序列的长度,因为序列长度决定了我们在隐藏状态上需要执行多少次迭代更新。在这个过程中步骤越少,隐藏状态之间的梯度累积的乘法效应就越小。通过在模型中智能地选择最大序列长度作为超参数,我们可以帮助防止梯度爆炸和消失。

引入 LSTMs

虽然 RNN 允许我们将单词序列作为模型的输入,但它们远非完美。RNN 有两个主要缺陷,可以通过使用更复杂版本的 RNN——LSTM——来部分解决。

RNN 的基本结构意味着它们很难长期保留信息。考虑一个有 20 个单词的句子。从句子的第一个单词影响初始隐藏状态到句子的最后一个单词,我们的隐藏状态被更新了 20 次。从句子开头到最终的隐藏状态,对于 RNN 来说,很难保留句子开头单词的信息。这意味着 RNN 不擅长捕捉序列中的长期依赖关系。这也与前面提到的梯度消失问题相关联,即通过长而稀疏的向量序列反向传播非常低效。

考虑一个长段落,我们试图预测下一个单词。句子以I study math…开头,以my final exam is in…结束。直觉上,我们期望下一个单词是math或某个与数学相关的领域。然而,在一个长序列的 RNN 模型中,我们的隐藏状态可能在到达句子末尾时难以保留句子开头的信息,因为它经历了多次更新步骤。

我们还应该注意,RNN 不擅长捕捉句子内单词的整体上下文。我们之前看到,在 n-gram 模型中,句子中的单词的含义取决于它在句子中的上下文,这由它之前出现的单词和之后出现的单词决定。在 RNN 中,我们的隐藏状态仅向一个方向更新。在单向传递中,我们的隐藏状态被初始化,并且序列中的第一个单词被传递给它。然后,这个过程被所有后续单词依次重复,直到我们留下最终的隐藏状态。这意味着对于句子中的任何给定单词,我们仅考虑到了在该点之前出现的单词的累积效应。我们不考虑其后的任何单词,这意味着我们未能捕捉到句子中每个单词的完整上下文。

在另一个例子中,我们再次想预测句子中的缺失词语,但这次出现在句子的开头而不是结尾。我们有这样的句子 I grew up in…so I can speak fluent Dutch。在这里,我们可以直观地猜测这个人在荷兰长大了,因为他们说荷兰语。然而,因为 RNN 顺序解析信息,它只会使用 I grew up in… 来做预测,错过了句子中的其他关键上下文。

这两个问题可以部分通过 LSTM 得到解决。

使用长短期记忆网络(LSTM)

LSTM 是 RNN 的更高级版本,并包含两个额外的属性 —— 更新门遗忘门。这两个增加使得网络更容易学习长期依赖性。考虑以下电影评论:

这部电影太棒了。我周二下午和妻子以及女儿们一起去看的。虽然我没抱太大的期望,但结果却非常有趣。如果有机会的话,我们一定会再去看的。

在情感分析中,显然句子中并非所有词语都对判断评论是积极还是消极有帮助。我们将重复这个句子,但这次突出显示对评估评论情感有帮助的词语:

这部电影太棒了。我周二下午和妻子以及女儿们一起去看的。虽然我没抱太大的期望,但结果却非常有趣。如果有机会的话,我们一定会再去看的。

LSTM 尝试正是这样做 —— 在遗忘所有无关信息的同时记住句子中相关的词语。通过这样做,它阻止无关信息稀释相关信息,从而更好地学习长序列中的长期依赖性。

LSTM 在结构上与 RNN 非常相似。虽然在 LSTM 内部存在一个在步骤之间传递的隐藏状态,但 LSTM 细胞本身的内部工作与 RNN 不同:

图 5.5 – LSTM 细胞

图 5.5 – LSTM 细胞

LSTM 细胞

而 RNN 细胞只需使用前一个隐藏状态和新的输入步骤,并使用一些学习参数计算下一个隐藏状态,LSTM 细胞的内部工作则复杂得多:

图 5.6 – LSTM 细胞的内部工作原理

图 5.6 – LSTM 细胞的内部工作原理

虽然看起来比 RNN 复杂得多,但我们会逐步解释 LSTM 细胞的每个组成部分。我们首先来看 遗忘门(用粗体矩形标示):

图 5.7 – 遗忘门

图 5.7 – 遗忘门

遗忘门基本上学习了要忘记序列中哪些元素。将前一个隐藏状态 ht-1 和最新的输入步骤 x1 连接在一起,通过遗忘门上的学习权重矩阵和将值压缩在 0 到 1 之间的 Sigmoid 函数进行处理。得到的矩阵 ft 与前一步的单元状态 ct-1 逐点相乘。这有效地对前一单元状态应用了一个蒙版,以便仅将前一单元状态中的相关信息带入下一个时间步。

接下来,我们将看看输入门

图 5.8 – 输入门

图 5.8 – 输入门

输入门再次接受连接的上一个隐藏状态 ht-1 和当前序列输入 xt,并通过一个带有学习参数的 Sigmoid 函数进行处理,输出另一个矩阵 it,其值在 0 到 1 之间。连接的隐藏状态和序列输入还会经过一个 tanh 函数,将输出压缩在 -1 到 1 之间。然后将其与 it 矩阵相乘。这意味着生成 it 所需的学习参数有效地学习了在当前时间步中哪些元素应该被保留在我们的单元状态中。然后将此结果加到当前单元状态中,以获得我们的最终单元状态,该状态将传递到下一个时间步。

最后,我们来看 LSTM 单元的最后一个元素——输出门

图 5.9 – 输出门

图 5.9 – 输出门

输出门计算 LSTM 单元的最终输出——包括单元状态和传递到下一步的隐藏状态。单元状态 ct 与前两步相同,是遗忘门和输入门的产物。最终的隐藏状态 ht 是通过取连接的前一个隐藏状态 ht-1 和当前时间步输入 xt,并通过带有一些学习参数的 Sigmoid 函数进行处理以获得输出门输出 ot 来计算的。最终的单元状态 ct 经过一个 tanh 函数并与输出门输出 ot 相乘,以计算最终的隐藏状态 ht。这意味着输出门上的学习参数有效地控制了前一个隐藏状态和当前输出的哪些元素与最终单元状态结合,以作为新的隐藏状态传递到下一个时间步。

在我们的前向传播中,我们简单地迭代模型,初始化我们的隐藏状态和单元状态,并在每个时间步使用 LSTM 单元来更新它们,直到我们得到一个最终的隐藏状态,然后将其输出到神经网络的下一层。通过所有 LSTM 层的反向传播,我们可以计算相对于网络损失的梯度,因此我们知道通过梯度下降更新我们的参数的方向。我们得到几个矩阵或参数——一个用于输入门,一个用于输出门,一个用于遗忘门。

由于我们比简单 RNN 获得了更多的参数,且计算图更复杂,通过网络进行反向传播并更新权重的过程可能会比简单 RNN 更耗时。然而,尽管训练时间较长,我们已经证明 LSTM 在许多方面都比传统的 RNN 表现出显著优势,因为输出门、输入门和遗忘门的结合赋予了模型确定哪些输入元素用于更新隐藏状态,哪些隐藏状态元素在前进时应被遗忘的能力,这意味着模型更能形成长期依赖关系并保留以前序列步骤的信息。

双向 LSTMs

我们之前提到简单 RNN 的一个缺点是它们无法捕捉单词在句子中的完整上下文,因为它们只能向后看。在 RNN 的每个时间步中,只考虑先前看到的单词,并且不考虑在句子中紧接在后面的单词。尽管基本的 LSTMs 同样是向后看的,但我们可以使用一种改进版的 LSTM,称为双向 LSTM,它在每个时间步内考虑单词的前后两侧。

双向 LSTMs 同时以正常顺序和反向顺序处理序列,保持两个隐藏状态。我们称前向隐藏状态为ft,并使用rt 表示反向隐藏状态:

图 5.10 – 双向 LSTM 处理过程

图 5.10 – 双向 LSTM 处理过程

这里,我们可以看到在整个过程中我们保持了这两个隐藏状态,并使用它们来计算最终的隐藏状态,ht。因此,如果我们希望计算时间步t处的最终隐藏状态,我们使用已看到包括输入xt 的所有单词的前向隐藏状态ft,以及已看到包括xt 之后所有单词的反向隐藏状态rt。因此,我们的最终隐藏状态ht 包括了看到整个句子中所有单词的隐藏状态,而不仅仅是在时间步t之前发生的单词。这意味着可以更好地捕捉句子中任何给定单词的上下文。双向 LSTM 已被证明在几个自然语言处理任务中比传统的单向 LSTM 表现更好。

使用 LSTMs 构建情感分析器

现在我们将看看如何构建我们自己简单的 LSTM 来根据它们的情感分类句子。我们将在一个包含 3000 条评论的数据集上训练我们的模型,这些评论已被分类为积极或消极。这些评论来自三个不同的来源——电影评论、产品评论和地点评论,以确保我们的情感分析器是稳健的。数据集是平衡的,由 1500 条积极评论和 1500 条消极评论组成。我们将从导入数据集并检查它开始:

with open("sentiment labelled sentences/sentiment.txt") as f:
    reviews = f.read()

data = pd.DataFrame([review.split('\t') for review in                      reviews.split('\n')])
data.columns = ['Review','Sentiment']
data = data.sample(frac=1)

这将返回以下输出:

图 5.11 – 数据集的输出

图 5.11 – 数据集的输出

我们从文件中读取数据集。我们的数据集是用制表符分隔的,所以我们通过制表符和换行符将其拆分开来。我们重新命名列,然后使用样本函数对数据进行随机重排。查看我们的数据集,我们需要做的第一件事是预处理我们的句子以输入到我们的 LSTM 模型中。

数据预处理

首先,我们创建一个函数来标记我们的数据,将每个评论拆分为单独的预处理单词列表。我们遍历我们的数据集,对每个评论,我们去除任何标点符号,将字母转换为小写,并移除任何尾随空白。然后我们使用 NLTK 分词器从这个预处理文本创建单独的标记:

def split_words_reviews(data):
    text = list(data['Review'].values)
    clean_text = []
    for t in text:
        clean_text.append(t.translate(str.maketrans('', '',                   punctuation)).lower().rstrip())
    tokenized = [word_tokenize(x) for x in clean_text]
    all_text = []
    for tokens in tokenized:
        for t in tokens:
            all_text.append(t)
    return tokenized, set(all_text)
reviews, vocab = split_words_reviews(data)
reviews[0]

这导致以下输出:

图 5.12 – NTLK 分词的输出

图 5.12 – NTLK 分词的输出

我们返回评论本身,以及所有评论中的所有单词的集合(即词汇/语料库),我们将使用它们创建我们的词汇字典。

为了充分准备我们的句子以输入到神经网络中,我们必须将我们的单词转换为数字。为了做到这一点,我们创建了一些字典,这些字典将允许我们从单词转换为索引,从索引转换为单词。为此,我们简单地循环遍历我们的语料库,并为每个唯一单词分配一个索引:

def create_dictionaries(words):
    word_to_int_dict = {w:i+1 for i, w in enumerate(words)}
    int_to_word_dict = {i:w for w, i in word_to_int_dict.                            items()}
    return word_to_int_dict, int_to_word_dict
word_to_int_dict, int_to_word_dict = create_dictionaries(vocab)
int_to_word_dict

这给出以下输出:

图 5.13 – 为每个单词分配索引

图 5.13 – 为每个单词分配索引

我们的神经网络将接受固定长度的输入;然而,如果我们探索我们的评论,我们会发现我们的评论长度各不相同。为了确保所有的输入都是相同长度的,我们将对我们的输入句子进行填充。这基本上意味着我们在较短的句子中添加空令牌,以便所有句子的长度都相同。我们必须首先决定我们希望实施的填充长度。我们首先计算我们输入评论中句子的最大长度,以及平均长度:

print(np.max([len(x) for x in reviews]))
print(np.mean([len(x) for x in reviews]))

这给出以下结果:

图 5.14 – 长度数值

图 5.14 – 长度数值

我们可以看到,最长的句子有70个词,平均句子长度为11.78个词。为了捕获所有句子的信息,我们希望所有的句子长度都为 70。然而,使用更长的句子意味着更长的序列,这会导致我们的 LSTM 层变得更深。这意味着模型训练时间更长,因为我们必须通过更多层进行梯度反向传播,但也意味着我们的大部分输入会变得稀疏并充满空标记,这使得从我们的数据中学习变得不那么高效。这一点可以通过我们的最大句子长度远远大于平均句子长度来说明。为了捕获大部分句子信息而又不必要地填充我们的输入并使其过于稀疏,我们选择使用输入大小为50。您可能希望尝试使用介于2070之间的不同输入大小,看看这如何影响您的模型性能。

我们将创建一个函数,允许我们对句子进行填充,使它们的大小都相同。对于比序列长度短的评论,我们用空标记进行填充。对于比序列长度长的评论,我们简单地丢弃超过最大序列长度的任何标记:

def pad_text(tokenized_reviews, seq_length):

    reviews = []

    for review in tokenized_reviews:
        if len(review) >= seq_length:
            reviews.append(review[:seq_length])
        else:
            reviews.append(['']*(seq_length-len(review)) +                    review)

    return np.array(reviews)
padded_sentences = pad_text(reviews, seq_length = 50)
padded_sentences[0]

我们的填充句子看起来像这样:

图 5.15 – 对句子进行填充

图 5.15 – 对句子进行填充

我们必须进行进一步的调整,以允许在我们的模型中使用空标记。目前,我们的词汇字典不知道如何将空标记转换为整数以在我们的网络中使用。因此,我们手动将它们添加到我们的字典中,索引为0,这意味着当输入到我们的模型中时,空标记将被赋予值0

int_to_word_dict[0] = ''
word_to_int_dict[''] = 0

现在我们几乎可以开始训练我们的模型了。我们进行最后一步预处理,将所有填充后的句子编码为数值序列,以输入我们的神经网络。这意味着先前的填充句子现在看起来像这样:

encoded_sentences = np.array([[word_to_int_dict[word] for word in review] for review in padded_sentences])
encoded_sentences[0]

我们编码的句子表示如下:

图 5.16 – 对句子进行编码

图 5.16 – 对句子进行编码

现在我们已经将所有输入序列编码为数值向量,我们可以开始设计我们的模型架构了。

模型架构

我们的模型将由几个主要部分组成。除了输入和输出层外,这些层对许多神经网络都是通用的,我们首先需要一个嵌入层。这样,我们的模型就能学习到它所训练的单词的向量表示。我们可以选择使用预先计算的嵌入(如 GloVe),但为了演示目的,我们将训练自己的嵌入层。我们的输入序列通过输入层,并出现为向量序列。

这些向量序列然后被送入我们的LSTM 层。正如本章前面详细解释的那样,LSTM 层从我们的嵌入序列中逐步学习,并输出一个代表 LSTM 层最终隐藏状态的单个向量输出。这个最终隐藏状态最终通过进一步的隐藏层,然后再通过最终输出节点预测一个值(介于 0 和 1 之间),指示输入序列是正面还是负面评价。这意味着我们的模型架构看起来像这样:

图 5.17 – 模型架构

图 5.17 – 模型架构

现在我们将演示如何使用 PyTorch 从头开始编写这个模型。我们创建一个名为SentimentLSTM的类,它继承自nn.Module类。我们定义我们的init参数为我们词汇表的大小,模型将具有的 LSTM 层数量,以及我们模型隐藏状态的大小:

class SentimentLSTM(nn.Module):

    def __init__(self, n_vocab, n_embed, n_hidden, n_output,    n_layers, drop_p = 0.8):
        super().__init__()

        self.n_vocab = n_vocab  
        self.n_layers = n_layers 
        self.n_hidden = n_hidden 

然后,我们定义网络的每一层。首先,我们定义嵌入层,它的长度为词汇表中单词的数量,嵌入向量的大小作为一个可以指定的n_embed超参数。我们使用从嵌入层输出的向量大小定义我们的 LSTM 层,模型隐藏状态的长度以及我们 LSTM 层将具有的层数。我们还添加了一个参数来指定我们的 LSTM 可以在数据批次上进行训练,并允许我们通过 dropout 实现网络正则化。我们定义了一个进一步的 dropout 层,具有概率drop_p(一个在模型创建时指定的超参数),以及我们的最终全连接层和输出/预测节点(带有 sigmoid 激活函数):

       self.embedding = nn.Embedding(n_vocab, n_embed)
        self.lstm = nn.LSTM(n_embed, n_hidden, n_layers,                        batch_first = True, dropout = drop_p)
        self.dropout = nn.Dropout(drop_p)
        self.fc = nn.Linear(n_hidden, n_output)
        self.sigmoid = nn.Sigmoid()

接下来,我们需要在我们的模型类中定义我们的前向传播。在这个前向传播中,我们将一个层的输出链接到下一个层的输入。在这里,我们可以看到我们的嵌入层以input_words作为输入,并输出嵌入的单词。然后,我们的 LSTM 层以嵌入的单词作为输入,并输出lstm_out。这里唯一的微妙之处在于,我们使用view()来重塑我们的张量,从 LSTM 输出到与我们的全连接层的输入正确大小相匹配。对于重塑隐藏层输出以匹配我们输出节点的大小也适用相同的方法。注意,我们的输出将返回一个对class = 0class = 1的预测,因此我们切片输出以仅返回class = 1的预测——也就是说,我们的句子是正面的概率:

 def forward (self, input_words):

        embedded_words = self.embedding(input_words)
        lstm_out, h = self.lstm(embedded_words) 
        lstm_out = self.dropout(lstm_out)
        lstm_out = lstm_out.contiguous().view(-1,                             self.n_hidden)
        fc_out = self.fc(lstm_out)                  
        sigmoid_out = self.sigmoid(fc_out)              
        sigmoid_out = sigmoid_out.view(batch_size, -1)  

        sigmoid_last = sigmoid_out[:, -1]

        return sigmoid_last, h

我们还定义了一个名为 init_hidden() 的函数,它用我们的批量大小来初始化我们的隐藏层。这允许我们的模型同时训练和预测多个句子,而不仅仅是一次训练一个句子,如果我们愿意的话。请注意,在这里我们将 device 定义为 "cpu",以在本地处理器上运行它。然而,如果您有一个支持 CUDA 的 GPU,也可以将其设置为在 GPU 上进行训练:

    def init_hidden (self, batch_size):

        device = "cpu"
        weights = next(self.parameters()).data
        h = (weights.new(self.n_layers, batch_size,\
                 self.n_hidden).zero_().to(device),\
             weights.new(self.n_layers, batch_size,\
                 self.n_hidden).zero_().to(device))

        return h

然后,我们通过创建 SentimentLSTM 类的一个新实例来初始化我们的模型。我们传递我们词汇表的大小、嵌入的大小、隐藏状态的大小、以及输出的大小和我们 LSTM 中的层数:

n_vocab = len(word_to_int_dict)
n_embed = 50
n_hidden = 100
n_output = 1
n_layers = 2
net = SentimentLSTM(n_vocab, n_embed, n_hidden, n_output, n_layers)

现在我们已经完全定义了我们的模型架构,是时候开始训练我们的模型了。

训练模型

要训练我们的模型,我们必须首先定义我们的数据集。我们将使用训练数据集来训练我们的模型,在每一步中评估我们训练过的模型在验证数据集上的表现,然后最终,使用未见过的测试数据集来衡量我们模型的最终性能。我们之所以使用一个与验证训练分开的测试集,是因为我们可能希望基于对验证集的损失来微调我们的模型超参数。如果我们这样做,我们可能会选择在性能上仅对特定验证数据集最优的超参数。我们最后一次评估未见过的测试集,以确保我们的模型对其以前在训练循环的任何部分都没有见过的数据泛化良好。

我们已经将我们的模型输入 (x) 定义为 encoded_sentences,但我们还必须定义我们的模型输出 (y)。我们可以简单地这样做:

labels = np.array([int(x) for x in data['Sentiment'].values])

接下来,我们定义我们的训练和验证比例。在这种情况下,我们将在 80% 的数据上训练我们的模型,在额外的 10% 的数据上验证,最后在剩下的 10% 的数据上测试:

train_ratio = 0.8
valid_ratio = (1 - train_ratio)/2

然后,我们使用这些比例来切分我们的数据,并将它们转换为张量,然后再转换为数据集:

total = len(encoded_sentences)
train_cutoff = int(total * train_ratio)
valid_cutoff = int(total * (1 - valid_ratio))
train_x, train_y = torch.Tensor(encoded_sentences[:train_cutoff]).long(), torch.Tensor(labels[:train_cutoff]).long()
valid_x, valid_y = torch.Tensor(encoded_sentences[train_cutoff : valid_cutoff]).long(), torch.Tensor(labels[train_cutoff : valid_cutoff]).long()
test_x, test_y = torch.Tensor(encoded_sentences[valid_cutoff:]).long(), torch.Tensor(labels[valid_cutoff:])
train_data = TensorDataset(train_x, train_y)
valid_data = TensorDataset(valid_x, valid_y)
test_data = TensorDataset(test_x, test_y)

然后,我们使用这些数据集创建 PyTorch DataLoader 对象。 DataLoader 允许我们使用 batch_size 参数批处理我们的数据集,可以轻松地将不同的批次大小传递给我们的模型。在这个例子中,我们将保持简单,设置 batch_size = 1,这意味着我们的模型将在单个句子上进行训练,而不是使用更大的数据批次。我们还选择随机打乱我们的 DataLoader 对象,以便数据以随机顺序通过我们的神经网络,而不是每个 epoch 使用相同的顺序,可能会从训练顺序中移除任何偏倚结果:

batch_size = 1
train_loader = DataLoader(train_data, batch_size = batch_size,                          shuffle = True)
valid_loader = DataLoader(valid_data, batch_size = batch_size,                          shuffle = True)
test_loader = DataLoader(test_data, batch_size = batch_size,                         shuffle = True)

现在,我们为我们的三个数据集中的每一个定义了DataLoader对象之后,我们定义我们的训练循环。我们首先定义了一些超参数,这些参数将在我们的训练循环中使用。最重要的是,我们将我们的损失函数定义为二元交叉熵(因为我们正在预测一个单一的二元类),并将我们的优化器定义为使用学习率为0.001Adam。我们还定义了我们的模型来运行一小部分时期(以节省时间),并设置clip = 5以定义我们的梯度裁剪:

print_every = 2400
step = 0
n_epochs = 3
clip = 5  
criterion = nn.BCELoss()
optimizer = optim.Adam(net.parameters(), lr = 0.001)

我们训练循环的主体如下所示:

for epoch in range(n_epochs):
    h = net.init_hidden(batch_size)

    for inputs, labels in train_loader:
        step += 1  
        net.zero_grad()
        output, h = net(inputs)
        loss = criterion(output.squeeze(), labels.float())
        loss.backward()
        nn.utils.clip_grad_norm(net.parameters(), clip)
        optimizer.step()

在这里,我们只是训练我们的模型一定数量的时期,对于每个时期,我们首先使用批量大小参数初始化我们的隐藏层。在这种情况下,我们将batch_size = 1设置为一次只训练我们的模型一句话。对于我们的训练加载器中的每批输入句子和标签,我们首先将梯度清零(以防止它们累积),并使用模型当前状态的前向传递计算我们的模型输出。然后,使用模型的预测输出和正确标签来计算我们的损失。接着,我们通过网络进行反向传播,计算每个阶段的梯度。接下来,我们使用grad_clip_norm()函数裁剪我们的梯度,因为这将阻止我们的梯度爆炸,正如本章前面提到的。我们定义了clip = 5,这意味着在任何给定节点的最大梯度为5。最后,我们通过调用optimizer.step()来使用反向传播计算出的梯度更新我们的权重。

如果我们单独运行这个循环,我们将训练我们的模型。然而,我们希望在每个时期之后评估我们模型在验证数据集上的表现,以确定其性能。我们按照以下步骤进行:

if (step % print_every) == 0:            
            net.eval()
            valid_losses = []
            for v_inputs, v_labels in valid_loader:

                v_output, v_h = net(v_inputs)
                v_loss = criterion(v_output.squeeze(),                                    v_labels.float())
                valid_losses.append(v_loss.item())
            print("Epoch: {}/{}".format((epoch+1), n_epochs),
                  "Step: {}".format(step),
                  "Training Loss: {:.4f}".format(loss.item()),
                  "Validation Loss: {:.4f}".format(np.                                     mean(valid_losses)))
            net.train()

这意味着在每个时期结束时,我们的模型调用net.eval()来冻结我们模型的权重,并像以前一样使用我们的数据进行前向传递。请注意,在评估模式下,我们不应用 dropout。然而,这一次,我们不是使用训练数据加载器,而是使用验证加载器。通过这样做,我们可以计算模型在当前状态下在验证数据集上的总损失。最后,我们打印我们的结果,并调用net.train()来解冻我们模型的权重,以便我们可以在下一个时期再次训练。我们的输出看起来像这样:

图 5.18 – 训练模型

图 5.18 – 训练模型

最后,我们可以保存我们的模型以供将来使用:

torch.save(net.state_dict(), 'model.pkl')

在为我们的模型训练了三个 epochs 之后,我们注意到了两个主要的事情。我们先说好消息——我们的模型正在学习!我们的训练损失不仅下降了,而且我们还可以看到,每个 epoch 后验证集上的损失也在下降。这意味着我们的模型在仅仅三个 epochs 后在未见过的数据集上的情感预测能力有所提高!然而,坏消息是,我们的模型严重过拟合了。我们的训练损失远远低于验证损失,这表明虽然我们的模型学会了如何在训练数据集上进行很好的预测,但这并不太适用于未见过的数据集。这是预料之中的,因为我们使用了一个非常小的训练数据集(只有 2400 个训练句子)。由于我们正在训练一个整个嵌入层,许多单词可能仅在训练集中出现一次,而在验证集中从未出现,反之亦然,这使得模型实际上不可能对语料库中所有不同的单词类型进行泛化。实际上,我们希望在更大的数据集上训练我们的模型,以使其能够更好地学会泛化。我们还在非常短的时间内训练了这个模型,并且没有执行超参数调整来确定我们模型的最佳迭代次数。请随意尝试更改模型中的某些参数(如训练时间、隐藏状态大小、嵌入大小等),以提高模型的性能。

尽管我们的模型出现了过拟合,但它仍然学到了一些东西。现在我们希望在最终的测试数据集上评估我们的模型。我们使用之前定义的测试加载器对数据进行最后一次遍历。在这一遍历中,我们循环遍历所有的测试数据,并使用我们的最终模型进行预测:

net.eval()
test_losses = []
num_correct = 0
for inputs, labels in test_loader:
    test_output, test_h = net(inputs)
    loss = criterion(test_output, labels)
    test_losses.append(loss.item())

    preds = torch.round(test_output.squeeze())
    correct_tensor = preds.eq(labels.float().view_as(preds))
    correct = np.squeeze(correct_tensor.numpy())
    num_correct += np.sum(correct)

print("Test Loss: {:.4f}".format(np.mean(test_losses)))
print("Test Accuracy: {:.2f}".format(num_correct/len(test_loader.dataset)))    

我们在测试数据集上的表现如下:

图 5.19 – 输出数值

图 5.19 – 输出数值

然后,我们将我们的模型预测与真实标签进行比较,得到correct_tensor,这是一个向量,评估了我们模型的每个预测是否正确。然后我们对这个向量进行求和并除以其长度,得到我们模型的总准确率。在这里,我们得到了 76%的准确率。虽然我们的模型显然还远非完美,但考虑到我们非常小的训练集和有限的训练时间,这已经不错了!这只是为了说明在处理自然语言处理数据时,LSTM 可以有多么有用。接下来,我们将展示如何使用我们的模型对新数据进行预测。

使用我们的模型进行预测

现在我们已经有了一个训练好的模型,应该可以重复我们的预处理步骤来处理一个新的句子,将其传递到我们的模型中,并对其情感进行预测。我们首先创建一个函数来预处理我们的输入句子以进行预测:

def preprocess_review(review):
    review = review.translate(str.maketrans('', '',                    punctuation)).lower().rstrip()
    tokenized = word_tokenize(review)
    if len(tokenized) >= 50:
        review = tokenized[:50]
    else:
        review= ['0']*(50-len(tokenized)) + tokenized

    final = []

    for token in review:
        try:
            final.append(word_to_int_dict[token])

        except:
            final.append(word_to_int_dict[''])

    return final

我们去除标点符号和尾随空格,将字母转换为小写,并像之前一样对我们的输入句子进行分词。我们将我们的句子填充到长度为50的序列中,然后使用我们预先计算的字典将我们的标记转换为数值。请注意,我们的输入可能包含我们的网络以前未见过的新词。在这种情况下,我们的函数将这些视为空标记。

接下来,我们创建我们实际的predict()函数。我们预处理输入评论,将其转换为张量,并传递到数据加载器中。然后,我们循环通过这个数据加载器(即使它只包含一个句子),将我们的评论通过网络以获取预测。最后,我们评估我们的预测并打印出它是正面还是负面评论:

def predict(review):
    net.eval()
    words = np.array([preprocess_review(review)])
    padded_words = torch.from_numpy(words)
    pred_loader = DataLoader(padded_words, batch_size = 1,                             shuffle = True)
    for x in pred_loader:
        output = net(x)[0].item()

    msg = "This is a positive review." if output >= 0.5 else           "This is a negative review."
    print(msg)
    print('Prediction = ' + str(output))

最后,我们只需调用predict()对我们的评论进行预测:

predict("The film was good")

这导致以下输出:

图 5.20 – 正值上的预测字符串

图 5.20 – 正值上的预测字符串

我们还尝试在负值上使用predict()

predict("It was not good")

这导致以下输出:

图 5.21 – 负值上的预测字符串

图 5.21 – 负值上的预测字符串

我们现在已经从头开始构建了一个 LSTM 模型来执行情感分析。虽然我们的模型还远未完善,但我们已经演示了如何采用一些带有情感标签的评论来训练模型,使其能够对新评论进行预测。接下来,我们将展示如何将我们的模型托管在 Heroku 云平台上,以便其他人可以使用您的模型进行预测。

在 Heroku 上部署应用程序

我们现在在本地机器上训练了我们的模型,并可以使用它进行预测。然而,如果您希望其他人能够使用您的模型进行预测,这可能并不好。如果我们将我们的模型托管在 Heroku 等云平台上,并创建一个基本 API,其他人就能够调用 API 来使用我们的模型进行预测。

引入 Heroku

Heroku 是一个基于云的平台,您可以在上面托管自己的基本程序。虽然 Heroku 的免费版上传大小最大为 500 MB,处理能力有限,但这应足以让我们托管我们的模型并创建一个基本 API,以便使用我们的模型进行预测。

第一步是在 Heroku 上创建一个免费账户并安装 Heroku 应用程序。然后,在命令行中,输入以下命令:

heroku login

使用您的帐户详细信息登录。然后,通过键入以下命令创建一个新的heroku项目:

heroku create sentiment-analysis-flask-api

请注意,所有项目名称必须是唯一的,因此您需要选择一个不是sentiment-analysis-flask-api的项目名称。

我们的第一步是使用 Flask 构建一个基本 API。

使用 Flask 创建 API – 文件结构

使用 Flask 创建 API 非常简单,因为 Flask 包含了制作 API 所需的默认模板:

首先,在命令行中,为您的 Flask API 创建一个新文件夹并导航到其中:

mkdir flaskAPI
cd flaskAPI

然后,在文件夹中创建一个虚拟环境。这将是您的 API 将使用的 Python 环境:

python3 -m venv vir_env

在您的环境中,使用pip安装所有您将需要的软件包。这包括您在模型程序中使用的所有软件包,例如 NLTK、pandas、NumPy 和 PyTorch,以及您运行 API 所需的软件包,例如 Flask 和 Gunicorn:

pip install nltk pandas numpy torch flask gunicorn

然后,我们创建一个我们的 API 将使用的需求列表。请注意,当我们将其上传到 Heroku 时,Heroku 将自动下载并安装此列表中的所有软件包。我们可以通过输入以下内容来实现这一点:

pip freeze > requirements.txt

我们需要做的一个调整是将requirements.txt文件中的torch行替换为以下内容:

**https://download.pytorch.org/whl/cpu/torch-1.3.1%2Bcpu-cp37-cp37m-linux_x86_64.whl**

这是 PyTorch 版本的 wheel 文件的链接,它仅包含 CPU 实现。完整版本的 PyTorch 包括完整的 GPU 支持,大小超过 500 MB,因此无法在免费的 Heroku 集群上运行。使用这个更紧凑的 PyTorch 版本意味着您仍然可以在 Heroku 上使用 PyTorch 运行您的模型。最后,我们在我们的文件夹中创建了另外三个文件,以及用于我们模型的最终目录:

touch app.py
touch Procfile
touch wsgi.py
mkdir models

现在,我们已经创建了所有我们在 Flash API 中将需要的文件,并且我们准备开始对我们的文件进行调整。

创建使用 Flask 的 API 文件

在我们的app.py文件中,我们可以开始构建我们的 API:

  1. 我们首先进行所有的导入并创建一个predict路由。这允许我们使用predict参数调用我们的 API 以运行 API 中的predict()方法:

    import flask
    from flask import Flask, jsonify, request
    import json
    import pandas as pd
    from string import punctuation
    import numpy as np
    import torch
    from nltk.tokenize import word_tokenize
    from torch.utils.data import TensorDataset, DataLoader
    from torch import nn
    from torch import optim
    app = Flask(__name__)
    @app.route('/predict', methods=['GET'])
    
  2. 接下来,在我们的app.py文件中定义我们的predict()方法。这在很大程度上是我们模型文件的重新整理,为了避免重复的代码,建议您查看本章节技术要求部分链接的 GitHub 存储库中的完成的app.py文件。您将看到还有几行额外的代码。首先,在我们的preprocess_review()函数中,我们将看到以下几行:

    with open('models/word_to_int_dict.json') as handle:
    word_to_int_dict = json.load(handle)
    

    这需要我们在主要的模型笔记本中计算的word_to_int字典,并将其加载到我们的模型中。这样做是为了保持我们的输入文本与我们训练过的模型的一致的单词索引。然后,我们使用此字典将我们的输入文本转换为编码序列。确保从原始笔记本输出中获取word_to_int_dict.json文件,并将其放置在models目录中。

  3. 类似地,我们还必须从我们训练过的模型中加载权重。我们首先定义我们的SentimentLSTM类,并使用torch.load加载我们的权重。我们将使用来自原始笔记本的.pkl文件,请确保将其放置在models目录中:

    model = SentimentLSTM(5401, 50, 100, 1, 2)
    model.load_state_dict(torch.load("models/model_nlp.pkl"))
    
  4. 我们还必须定义 API 的输入和输出。我们希望我们的模型从 API 接收输入,并将其传递给我们的preprocess_review()函数。我们使用request.get_json()来实现这一点:

    request_json = request.get_json()
    i = request_json['input']
    words = np.array([preprocess_review(review=i)])
    
  5. 为了定义我们的输出,我们返回一个 JSON 响应,其中包含来自我们模型的输出和一个响应码200,这是我们预测函数返回的内容:

    output = model(x)[0].item()
    response = json.dumps({'response': output})
    	return response, 200
    
  6. 随着我们应用程序主体的完成,我们还需要添加两个额外的内容以使我们的 API 运行。首先,我们必须将以下内容添加到我们的wsgi.py文件中:

    from app import app as application
    if __name__ == "__main__":
        application.run()
    
  7. 最后,将以下内容添加到我们的 Procfile 中:

    web: gunicorn app:app --preload
    

这就是使应用程序运行所需的全部内容。我们可以通过首先使用以下命令在本地启动 API 来测试我们的 API 是否运行:

gunicorn --bind 0.0.0.0:8080 wsgi:application -w 1

一旦 API 在本地运行,我们可以通过向其传递一个句子来请求 API 以预测结果:

curl -X GET http://0.0.0.0:8080/predict -H "Content-Type: application/json" -d '{"input":"the film was good"}'

如果一切正常,您应该从 API 收到一个预测结果。现在我们已经让我们的 API 在本地进行预测,是时候将其托管到 Heroku,这样我们就可以在云端进行预测了。

使用 Flask 创建 API - 在 Heroku 上托管

我们首先需要以类似于在 GitHub 上提交文件的方式将我们的文件提交到 Heroku。我们通过简单地运行以下命令来将我们的工作flaskAPI目录定义为git文件夹:

git init

在这个文件夹中,我们将以下代码添加到.gitignore文件中,这将阻止我们向 Heroku 存储库添加不必要的文件:

vir_env
__pycache__/
.DS_Store

最后,我们添加了我们的第一个commit函数,并将其推送到我们的heroku项目中:

git add . -A 
git commit -m 'commit message here'
git push heroku master

这可能需要一些时间来编译,因为系统不仅需要将所有文件从您的本地目录复制到 Heroku,而且 Heroku 还将自动构建您定义的环境,安装所有所需的软件包并运行您的 API。

现在,如果一切正常,您的 API 将自动在 Heroku 云上运行。为了进行预测,您可以简单地通过使用您的项目名称而不是sentiment-analysis-flask-api向 API 发出请求:

curl -X GET https://sentiment-analysis-flask-api.herokuapp.com/predict -H "Content-Type: application/json" -d '{"input":"the film was good"}'

您的应用程序现在将从模型返回一个预测结果。恭喜您,您现在已经学会了如何从头开始训练 LSTM 模型,将其上传到云端,并使用它进行预测!接下来,本教程希望为您训练自己的 LSTM 模型并自行部署到云端提供基础。

摘要

在本章中,我们讨论了 RNN 的基础知识及其主要变体之一,即 LSTM。然后,我们演示了如何从头开始构建自己的 RNN,并将其部署到基于云的平台 Heroku 上。虽然 RNN 经常用于 NLP 任务的深度学习,但并不是唯一适合此任务的神经网络架构。

在下一章中,我们将讨论卷积神经网络,并展示它们如何用于自然语言处理学习任务。

第六章:用于文本分类的卷积神经网络

在上一章中,我们展示了如何使用 RNNs 为文本提供情感分类。然而,RNNs 并不是唯一可以用于 NLP 分类任务的神经网络架构。卷积神经网络CNNs)是另一种这样的架构。

RNNs 依赖于顺序建模,维护隐藏状态,然后逐词遍历文本,每次迭代更新状态。CNNs 不依赖于语言的顺序元素,而是试图通过感知句子中的每个单词并学习其与句子中相邻单词的关系来从文本中学习。

虽然 CNNs 更常用于基于以下原因分类图像,但它们也被证明在文本分类上是有效的。尽管我们把文本视为序列,但我们也知道句子中每个单词的含义取决于它们的上下文及相邻单词。虽然 CNNs 和 RNNs 从文本中学习的方式不同,但它们都被证明在文本分类中是有效的,而在特定任务中选择哪种取决于任务的性质。

在本章中,我们将探讨 CNNs 的基本理论,并从头构建一个 CNN,用于文本分类。我们将涵盖以下主题:

  • 探索 CNNs

  • 构建用于文本分类的 CNN

让我们开始吧!

技术要求

本章的所有代码可以在 github.com/PacktPublishing/Hands-On-Natural-Language-Processing-with-PyTorch-1.x 找到。

探索 CNNs

CNN 的基础来自计算机视觉领域,但在概念上也可以扩展到自然语言处理。人类大脑处理和理解图像的方式并不是基于像素级别,而是将图像视为整体的映射,并理解图像中各部分的关联。

CNNs 的一个好比喻是人类大脑处理图片与处理句子的方式。考虑句子 This is a sentence about a cat。当你阅读这句话时,你读取第一个词,然后是第二个词,依此类推。现在,考虑一张猫的图片。通过查看第一个像素,然后是第二个像素来同化图片中的信息是愚蠢的。相反,当我们看东西时,我们一次性地感知整个图像,而不是作为一个序列。

例如,如果我们将图像的黑白表示(在这种情况下是数字 1),我们可以将其转换为向量表示,其中每个像素的颜色由 0 或 1 表示:

图 6.1 – 图像的向量表示

图 6.1 – 图像的向量表示

然而,如果我们从机器学习的角度思考并将该向量视为模型的特征,单个像素是黑色还是白色会使照片更有可能是特定数字吗?右上角的白色像素会使照片更有可能是四还是七吗?想象一下,如果我们尝试检测更复杂的事物,比如一张照片是狗还是猫。屏幕中央的褐色像素会使照片更有可能是猫还是狗吗?直觉上,我们看到单个像素值在图像分类方面并不意味着太多。然而,我们感兴趣的是像素之间的关系。

在数字表示的情况下,我们知道一个长竖线很有可能是一个一,而任何带有闭环的照片更有可能是零、六、八或九。通过识别和学习图像中的模式,而不仅仅是查看单个像素,我们可以更好地理解和识别这些图像。这正是 CNN 的目标所在。

卷积

CNN 的基本概念是卷积。卷积本质上是一个应用于矩阵的滑动窗口函数,以捕获周围像素的信息。在以下图表中,我们可以看到卷积的示例运作:

图 6.2 – 卷积的运作

图 6.2 – 卷积的运作

在左侧,我们有正在处理的图像,而在顶部,我们有希望应用的卷积核。对于我们图像中的每个 3x3 块,我们将其乘以我们的核,得到我们在底部的卷积矩阵。然后我们对卷积矩阵求和(或平均),以获得我们在初始图像中这个 3x3 块的单个输出值。请注意,在我们的 5x5 初始图像中,我们可以叠加到九种可能的 3x3 块。当我们为初始图像中的每个 3x3 块应用此卷积过程时,我们得到最终处理的卷积结果为 3x3。

在大图像中(或在自然语言处理中的复杂句子),我们还需要实现池化层。在我们前面的示例中,将 3x3 卷积应用于 5x5 图像会得到 3x3 的输出。但是,如果我们将 3x3 卷积应用于 100x100 像素图像,则仅将输出减少至 98x98。这并没有降低图像的维度以有效地进行深度学习(因为我们必须为每个卷积层学习 98x98 个参数)。因此,我们应用池化层来进一步降低层的维度。

池化层对卷积层的输出应用一个函数(通常是最大函数),以降低其维度。这个函数是在一个滑动窗口上应用的,类似于我们执行卷积的方式,只是现在我们的卷积不重叠。假设我们的卷积层输出为 4x4,并且我们对输出应用一个 2x2 的最大池化函数。这意味着对于我们层内的每个较小的 2x2 网格,我们应用一个最大函数并保留生成的输出。我们可以在以下图中看到这一点:

图 6.3 - 池化层

图 6.3 - 池化层

这些池化层已被证明可以有效地降低数据的维度,同时仍保留了卷积层中大部分基本信息。

这种卷积和池化层的组合基本上是 CNN 从图像中学习的方式。我们可以看到,通过应用许多这些卷积过程(也称为卷积层),我们能够捕捉任给像素与其邻近像素的关系的信息。在 CNN 中,我们试图学习的参数是卷积核本身的值。这意味着我们的模型有效地学习如何应该在图像上进行卷积以提取必要的信息以进行分类。

在这种情况下,使用卷积的两个主要优点。首先,我们能够将一系列低级特征组合成一个高级特征;也就是说,我们初始图像上的一个 3x3 块被组合成一个单个值。这实际上起到了一种特征减少的作用,使我们能够仅从图像中提取相关信息。使用卷积的另一个优点是,它使我们的模型具有位置不变性。在我们的数字检测器示例中,我们不关心数字出现在图像的右侧还是左侧;我们只想要能够检测到它。由于我们的卷积将在图像中检测特定模式(即边缘),这使得我们的模型在任何地方检测到相同的特征都会被理论上通过卷积捕捉到,从而使我们的模型具有位置不变性。

尽管这些原则对于理解卷积在图像数据中的工作方式很有用,但也可以应用到自然语言处理数据中。我们将在下一节中进行讨论。

自然语言处理的卷积

正如我们在本书中很多次看到的,我们可以将单词表示为向量,将整个句子和文档表示为向量序列。当我们将我们的句子表示为向量序列时,我们可以将其表示为一个矩阵。如果我们有一个给定句子的矩阵表示,我们立即注意到这与我们在图像卷积中卷积过的图像相似。因此,我们可以类似地将卷积应用到自然语言处理中,只要我们能够将我们的文本表示为矩阵。

让我们首先考虑使用这种方法的基础。当我们之前研究 n 元组时,我们看到句子中的一个词的上下文取决于它前面的词和后面的词。因此,如果我们能以一种允许我们捕捉单词与周围单词关系的方式对句子进行卷积,我们可以在理论上检测语言中的模式,并用此来更好地分类我们的句子。

我们的卷积方法与图像上的卷积略有不同,值得注意。在我们的图像矩阵中,我们希望捕获单个像素相对于周围像素的上下文,而在句子中,我们希望捕获整个词向量相对于周围向量的上下文。因此,在自然语言处理中,我们希望跨整个词向量执行卷积,而不是在词向量内部执行。以下图表展示了这一点。

我们首先将我们的句子表示为单个词向量:

图 6.4 – 词向量

图 6.4 – 词向量

然后我们在矩阵上应用 (2 x n) 的卷积(其中 n 是我们词向量的长度;在这个例子中,n = 5)。我们可以使用 (2 x n) 的滤波器进行四次卷积,从而得到四个输出。您会注意到,这类似于一个二元组模型,在一个五个词的句子中可以有四个可能的二元组:

图 6.5 – 将词向量卷积成二元组

图 6.5 – 将词向量卷积成二元组

同样地,我们可以针对任意数量的 n 元组执行此操作;例如,n=3:

图 6.6 – 将词向量卷积成 n 元组

图 6.6 – 将词向量卷积成 n 元组

这类卷积模型的一个好处是我们可以无限制地对 n 元组进行卷积。我们还能同时对多个不同的 n 元组进行卷积。因此,为了捕获二元组和三元组,我们可以设置我们的模型如下:

图 6.7 – 将词向量卷积成二元组和三元组

图 6.7 – 将词向量卷积成二元组和三元组

尽管卷积神经网络在自然语言处理中具有如前文所述的优势,但它们也有其局限性。

在图像的卷积神经网络中,假设某个像素可能与周围像素相关联是合理的。当应用于自然语言处理时,尽管这种假设部分正确,但词语可以在语义上相关,即使它们不直接接近。句子开头的词可能与句子结尾的词相关。

尽管我们的循环神经网络模型可以通过长期记忆依赖来检测这种关系,但我们的卷积神经网络可能会遇到困难,因为卷积神经网络只能捕获周围单词的上下文。

话虽如此,尽管我们的语言假设不一定成立,但 CNN 在 NLP 中已被证明在某些任务中表现非常好。可以说,使用 CNN 进行 NLP 的主要优势是速度和效率。卷积可以在 GPU 上轻松实现,允许进行快速并行计算和训练。

捕捉单词之间关系的方式也更加高效。在真正的 n-gram 模型中,模型必须学习每个单独 n-gram 的表示,而在我们的 CNN 模型中,我们只需学习卷积核,它将自动提取给定单词向量之间的关系。

现在我们已经定义了我们的 CNN 将如何从我们的数据中学习,我们可以开始从头编写一个模型的代码。

为文本分类构建 CNN

现在我们已经了解了 CNN 的基础知识,我们可以开始从头构建一个。在上一章中,我们为情感预测构建了一个模型,其中情感是一个二元分类器;1表示积极,0表示消极。然而,在这个例子中,我们的目标是构建一个用于多类文本分类的 CNN。在多类问题中,一个特定的例子只能被分类为多个类别之一。如果一个例子可以被分类为许多不同的类别,那么这是多标签分类。由于我们的模型是多类的,这意味着我们的模型将致力于预测我们的输入句子被分类为几个类别中的哪一个。虽然这个问题比我们的二元分类任务要困难得多(因为我们的句子现在可以属于多个,而不是两个类别之一),我们将展示 CNN 在这个任务上可以提供良好的性能。我们首先开始定义我们的数据。

定义一个多类别分类数据集

在上一章中,我们查看了一些评论,并学习了基于评论是积极的还是消极的二元分类。对于这个任务,我们将查看来自 TREC (trec.nist.gov/data/qa.html) 数据集的数据,这是一个常用的数据集,用于评估模型文本分类任务的性能。该数据集包含一系列问题,每个问题都属于我们训练模型将学习分类的六个广泛语义类别之一。这六个类别如下:

图 6.8 – TREC 数据集中的语义类别

图 6.8 – TREC 数据集中的语义类别

这意味着与我们之前的分类类不同,我们的模型输出不是在01之间的单一预测,而是我们的多类预测模型现在为每个六个可能类别之一返回一个概率。我们假设所做的预测是针对具有最高预测的类别:

图 6.9 – 预测值

图 6.9 – 预测值

这样一来,我们的模型现在能够在多个类别上执行分类任务,不再局限于之前看到的 0 或 1 的二元分类。由于多类模型需要区分更多不同的类别,因此在预测方面可能会受到影响。

在一个二分类模型中,假设我们有一个平衡的数据集,如果仅进行随机猜测,我们预期模型的准确率为 50%,而具有五个不同类别的多类模型的基准准确率仅为 20%。这意味着,仅仅因为多类模型的准确率远低于 100%,并不意味着模型本身在进行预测时存在问题。这在训练需要预测数百种不同类别的模型时尤为真实。在这些情况下,准确率仅为 50%的模型被认为是表现非常良好的。

现在,我们已经定义了我们的多类分类问题,需要加载我们的数据以便训练模型。

创建用于加载数据的迭代器

在上一章节的 LSTM 模型中,我们简单地使用了一个包含所有用于训练模型的数据的.csv文件。然后,我们手动将这些数据转换为输入张量,并逐个将它们馈送到网络中进行训练。虽然这种方法完全可以接受,但并不是最有效的方法。

在我们的 CNN 模型中,我们将考虑从我们的数据中创建数据迭代器。这些迭代器对象允许我们从输入数据中轻松生成小批量数据,从而允许我们使用小批量而不是将输入数据逐个馈送到网络中进行训练。这意味着网络内部的梯度是跨整个数据批次计算的,并且参数调整发生在每个批次之后,而不是在每个数据行通过网络之后。

对于我们的数据,我们将从 TorchText 包中获取我们的数据集。这不仅包含了用于模型训练的多个数据集,还允许我们使用内置函数轻松地对句子进行标记化和向量化。

按照以下步骤进行操作:

  1. 首先,我们从 TorchText 导入数据和数据集函数:

    from torchtext import data
    from torchtext import datasets
    
  2. 接下来,我们创建一个字段和标签字段,这些字段可以与TorchText包一起使用。这些定义了模型处理数据的初始步骤:

    questions = data.Field(tokenize = ‘spacy’, batch_first = True)
    labels = data.LabelField(dtype = torch.float)
    

    在这里,我们将 tokenize 设置为spacy,以设置如何对输入句子进行标记化。然后,TorchText使用spacy包自动对输入句子进行标记化。spacy包含英语语言的索引,因此任何单词都会自动转换为相关的标记。您可能需要在命令行中安装spacy才能使其工作。可以通过输入以下内容来完成这一步骤:

    pip3 install spacy
    python3 -m spacy download en
    

    这将安装spacy并下载英语词汇索引。

  3. 我们还将我们的标签数据类型定义为浮点数,这将允许我们计算我们的损失和梯度。在定义完我们的字段之后,我们可以使用它们来分割我们的输入数据。使用TorchText中的TREC数据集,我们将传递问题和标签字段以相应地处理数据集。然后,我们调用split函数自动将数据集分成训练集和验证集:

    train_data, _ = datasets.TREC.splits(questions, labels)
    train_data, valid_data = train_data.split()
    

    请注意,通常情况下,我们可以通过简单调用训练数据来查看我们的数据集:

    train_data
    

然而,在这里,我们处理的是一个TorchText数据集对象,而不是像我们可能习惯看到的加载到 pandas 中的数据集。这意味着我们从上述代码得到的输出如下所示:

图 6.10 – TorchText 对象的输出

图 6.10 – TorchText 对象的输出

我们可以查看此数据集对象中的单个数据,只需调用.examples参数。每个示例都会有一个文本和一个标签参数,我们可以像这样检查文本:

train_data.examples[0].text

这将返回以下输出:

图 6.11 – 数据集对象中的数据

图 6.11 – 数据集对象中的数据

标签代码如下运行:

train_data.examples[0].label

这为我们提供了以下输出:

图 6.12 – 数据集对象的标签

图 6.12 – 数据集对象的标签

因此,我们可以看到我们的输入数据包括一个标记化的句子,我们的标签包括我们希望分类的类别。我们还可以检查我们的训练集和验证集的大小,如下所示:

print(len(train_data))
print(len(valid_data))

这导致以下输出:

图 6.13 – 训练集和验证集的大小

图 6.13 – 训练集和验证集的大小

这显示我们的训练到验证比例大约为 70%到 30%。值得注意的是我们的输入句子是如何被标记化的,即标点符号被视为它们自己的标记。

现在我们知道我们的神经网络不会将原始文本作为输入,我们必须找到一些方法将其转换为某种嵌入表示形式。虽然我们可以训练自己的嵌入层,但我们可以使用我们在第三章**,执行文本嵌入中讨论过的预先计算的glove向量来转换我们的数据。这还有一个额外的好处,可以使我们的模型训练更快,因为我们不需要手动从头开始训练我们的嵌入层:

questions.build_vocab(train_data,
                 vectors = “glove.6B.200d”, 
                 unk_init = torch.Tensor.normal_)
labels.build_vocab(train_data)

在这里,我们可以看到通过使用 build_vocab 函数,并将我们的问题和标签作为我们的训练数据传递,我们可以构建一个由 200 维 GLoVe 向量组成的词汇表。请注意,TorchText 包将自动下载和获取 GLoVe 向量,因此在这种情况下无需手动安装 GLoVe。我们还定义了我们希望如何处理词汇表中的未知值(即,如果模型传递了一个不在预训练词汇表中的标记时模型将如何处理)。在这种情况下,我们选择将它们视为具有未指定值的普通张量,尽管稍后我们将更新这个值。

通过调用以下命令,我们现在可以看到我们的词汇表由一系列预训练的 200 维 GLoVe 向量组成:

questions.vocab.vectors

这将导致以下输出:

图 6.14 – 张量内容

图 6.14 – 张量内容

接下来,我们创建我们的数据迭代器。我们为我们的训练和验证数据分别创建单独的迭代器。我们首先指定一个设备,以便在有 CUDA 启用的 GPU 时能够更快地训练我们的模型。在我们的迭代器中,我们还指定了由迭代器返回的批次的大小,在这种情况下是64。您可能希望尝试使用不同的批次大小来进行模型训练,因为这可能会影响训练速度以及模型收敛到全局最优的速度:

device = torch.device(‘cuda’ if torch.cuda.is_available() else                       ‘cpu’)
train_iterator, valid_iterator = data.BucketIterator.splits(
    (train_data, valid_data), 
    batch_size = 64, 
    device = device)

构建 CNN 模型

现在我们已经加载了数据,准备好创建模型了。我们将使用以下步骤来完成:

  1. 我们希望构建我们的 CNN 的结构。我们像往常一样从定义我们的模型作为一个从nn.Module继承的类开始:

    class CNN(nn.Module):
        def __init__(self, vocab_size, embedding_dim,     n_filters, filter_sizes, output_dim, dropout,     pad_idx):
    
            super().__init__()
    
  2. 我们的模型被初始化为几个输入,所有这些输入很快将被覆盖。接下来,我们单独定义网络中的各个层,从我们的嵌入层开始:

    self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx = pad_idx)
    

    嵌入层将包含词汇表中每个可能单词的嵌入,因此层的大小是我们词汇表的长度和我们嵌入向量的长度。我们使用的是 200 维的 GLoVe 向量,因此在这个例子中长度将为200。我们还必须传递填充索引,该索引是我们嵌入层中用于获取填充我们句子的嵌入的索引,以便它们的长度都相同。当我们初始化我们的模型时,我们稍后将手动定义这个嵌入。

  3. 接下来,我们定义网络内部的实际卷积层:

    self.convs = nn.ModuleList([
    nn.Conv2d(in_channels = 1, 
         out_channels = n_filters, 
         kernel_size = (fs, embedding_dim)) 
         		for fs in filter_sizes
               ])
    
  4. 我们首先使用 nn.ModuleList 来定义一系列卷积层。ModuleList 接受一个模块列表作为输入,并且在您希望定义多个单独的层时使用。由于我们希望在输入数据上训练几个不同大小的卷积层,我们可以使用 ModuleList 来实现。我们可以理论上像这样分别定义每一层:

    self.conv_2 = nn.Conv2d(in_channels = 1, 
         out_channels = n_filters, 
         kernel_size = (2, embedding_dim)) 
    self.conv_3 = nn.Conv2d(in_channels = 1, 
         out_channels = n_filters, 
         kernel_size = (3, embedding_dim)) 
    

这里,滤波器的尺寸分别为23。然而,将这些操作合并到一个函数中会更加高效。此外,如果我们将不同的滤波器尺寸传递给函数,而不是每次添加新层时手动定义每一层,我们的层将会自动生成。

我们还将out_channels值定义为我们希望训练的滤波器数量;kernel_size将包含我们嵌入的长度。因此,我们可以将我们的ModuleList函数传递给我们希望训练的滤波器长度和数量,它将自动生成卷积层。以下是给定一组变量时此卷积层可能的示例:

图 6.15 – 寻找变量的卷积层

图 6.15 – 寻找变量的卷积层

我们可以看到我们的ModuleList函数适应于我们希望训练的滤波器数量和大小。接下来,在我们的 CNN 初始化中,我们定义剩余的层,即线性层,用于分类我们的数据,以及 dropout 层,用于正则化我们的网络:

self.fc = nn.Linear(len(filter_sizes) * n_filters, output_dim)
self.dropout = nn.Dropout(dropout)

请注意,过去,我们的线性层的尺寸始终为1,因为我们只需要一个单独的输出节点来执行二元分类。由于我们现在正在解决多类分类问题,我们希望对每个潜在类别进行预测,因此我们的输出维度现在是可变的,而不仅仅是1。当我们初始化网络时,我们将设置输出维度为6,因为我们正在预测句子来自六个类别中的哪一个。

接下来,与所有我们的神经网络一样,我们必须定义我们的forward传递:

def forward(self, text):
emb = self.embedding(text).unsqueeze(1)
conved = [F.relu(c(emb)).squeeze(3) for c in self.convs]
pooled = [F.max_pool1d(c, c.shape[2]).squeeze(2) 
          for c in conved]
concat = self.dropout(torch.cat(pooled, dim = 1))
return self.fc(concat)

这里,我们首先通过我们的嵌入层将输入文本传递,以获取句子中所有单词的嵌入。接下来,对于我们之前定义的每一个卷积层,我们将嵌入的句子传递,应用一个relu激活函数并挤压结果,移除结果输出的第四个维度。这对于我们所有定义的卷积层都是重复的,以便conved包含我们所有卷积层输出的列表。

对于这些输出的每一个,我们应用一个池化函数来减少我们卷积层输出的维度,如前所述。然后,我们将所有池化层的输出连接起来,并在传递到我们最终的全连接层之前应用一个 dropout 函数,这将做出我们的类预测。在完全定义了我们的 CNN 类之后,我们创建模型的一个实例。我们定义超参数,并使用它们创建 CNN 类的一个实例:

input_dimensions = len(questions.vocab)
output_dimensions = 6
embedding_dimensions = 200
pad_index = questions.vocab.stoi[questions.pad_token]
number_of_filters = 100
filter_sizes = [2,3,4]
dropout_pc = 0.5
model = CNN(input_dimensions, embedding_dimensions, number_of_filters, filter_sizes, output_dimensions, dropout_pc, pad_index)

我们的输入维度将始终是我们词汇表的长度,而输出维度将是我们希望预测的类别数。在这里,我们预测六种不同的类别,因此我们的输出向量长度将为6。我们的嵌入维度是我们的 GLoVe 向量的长度(在本例中为200)。填充索引可以手动从我们的词汇表中获取。

接下来的三个超参数可以手动调整,因此您可能希望尝试选择不同的值,以查看这如何影响您的网络的最终输出。我们传递一个过滤器大小的列表,以便我们的模型将使用大小为234的卷积训练卷积层。我们将为每种过滤器大小训练 100 个这些过滤器,因此总共将有 300 个过滤器。我们还为我们的网络定义了 50% 的丢失率,以确保它足够规范化。如果模型似乎容易过度拟合或欠拟合,可以提高/降低此值。一个一般的经验法则是,如果模型欠拟合,尝试降低丢失率,如果模型过拟合,则尝试提高丢失率。

在初始化我们的模型之后,我们需要将权重加载到我们的嵌入层中。这可以通过以下简单完成:

glove_embeddings = questions.vocab.vectors
model.embedding.weight.data.copy_(glove_embeddings)

这将产生以下输出:

图 6.16 – 降低丢失率后的张量输出

图 6.16 – 降低丢失率后的张量输出

接下来,我们需要定义我们的模型如何处理模型账户中未知的标记,这些标记不包含在嵌入层中,并且我们的模型将如何将填充应用到我们的输入句子中。幸运的是,解决这两种情况的最简单方法是使用一个由全零组成的向量。我们确保这些零值张量与我们的嵌入向量长度相同(在这个实例中为200):

unknown_index = questions.vocab.stoi[questions.unk_token]
model.embedding.weight.data[unknown_index] = torch.zeros(embedding_dimensions)
model.embedding.weight.data[pad_index] = torch.zeros(embedding_dimensions)

最后,我们定义我们的优化器和准则(损失)函数。请注意,我们选择使用交叉熵损失而不是二元交叉熵,因为我们的分类任务不再是二元的。我们还使用.to(device)来使用指定的设备训练我们的模型。这意味着如果有可用的 CUDA 启用的 GPU,我们的训练将在其上进行:

optimizer = torch.optim.Adam(model.parameters())
criterion = nn.CrossEntropyLoss().to(device)
model = model.to(device)

现在我们的模型结构已经完全定义,我们准备开始训练模型。

训练 CNN

在我们定义训练过程之前,我们需要计算一个性能指标,以说明我们希望我们模型的性能(希望如此!)随时间增加。在我们的二元分类任务中,准确度是我们用来衡量性能的一个简单指标。对于我们的多分类任务,我们将再次使用准确度,但是计算它的过程略微复杂,因为我们现在必须弄清楚我们模型预测了哪一个六个类别中的哪一个,并且哪一个六个类别是正确的。

首先,我们定义一个名为multi_accuracy的函数来计算这个:

def multi_accuracy(preds, y):
    pred = torch.max(preds,1).indices
    correct = (pred == y).float()
    acc = correct.sum() / len(correct)
    return acc

在这里,对于我们的预测,我们的模型使用 torch.max 函数为每个预测返回最高值的索引。对于每个预测,如果此预测的索引与标签的索引相同,则将其视为正确预测。然后我们计算所有这些正确预测的数量,并将其除以总预测数量以得到多类别准确度的度量。我们可以在训练循环中使用此函数来测量每个 epoch 的准确度。

接下来,我们定义我们的训练函数。我们最初将该 epoch 的损失和准确度设置为 0,并调用 model.train() 以允许在训练模型时更新模型内部的参数:

def train(model, iterator, optimizer, criterion):

    epoch_loss = 0
    epoch_acc = 0

    model.train()

接下来,我们在迭代器中循环每个数据批次并执行训练步骤。我们首先将梯度清零,以防止从先前批次计算的累积梯度。然后,我们使用当前批次中句子的模型当前状态进行预测,然后与我们的标签进行比较以计算损失。使用我们在前面部分定义的准确度函数,我们可以计算给定批次的准确度。然后我们反向传播我们的损失,通过梯度下降更新我们的权重并通过我们的优化器进行步进:

for batch in iterator:

optimizer.zero_grad()

preds = model(batch.text).squeeze(1)
loss = criterion(preds, batch.label.long())

acc = multi_accuracy(preds, batch.label)

loss.backward()

optimizer.step()

最后,我们将这一批次的损失和准确度加到整个 epoch 的总损失和准确度上。当我们遍历完整个 epoch 中的所有批次后,我们计算该 epoch 的总损失和准确度,并返回它:

epoch_loss += loss.item()
epoch_acc += acc.item()

total_epoch_loss = epoch_loss / len(iterator)
total_epoch_accuracy = epoch_acc / len(iterator)

return total_epoch_loss, total_epoch_accuracy

类似地,我们可以定义一个名为 eval 的函数,在我们的验证数据上调用它,以计算我们训练过的模型在我们尚未训练的一组数据上的性能。虽然这个函数与我们之前定义的训练函数几乎相同,但我们必须做两个关键的添加:

model.eval()

with torch.no_grad():

这两个步骤将我们的模型设置为评估模式,忽略任何 dropout 函数,并确保不计算和更新梯度。这是因为我们希望在评估性能时冻结模型中的权重,并确保我们的模型不会使用验证数据进行训练,因为我们希望将其与用于训练模型的数据分开保留。

现在,我们只需在与数据迭代器结合的循环中调用我们的训练和评估函数,以训练模型。我们首先定义我们希望模型训练的 epoch 数量。我们还定义到目前为止模型已经达到的最低验证损失。这是因为我们只希望保留验证损失最低的训练模型(即性能最佳的模型)。这意味着如果我们的模型训练了多个 epoch 并开始过拟合,只会保留这些模型中表现最佳的一个,这样选择较高数量的 epoch 将会减少后果。

我们将最低验证损失初始化为无穷大:

epochs = 10
lowest_validation_loss = float(‘inf’)

接下来,我们定义我们的训练循环,一次处理一个 epoch。我们记录训练的开始和结束时间,以便计算每个步骤的持续时间。然后,我们简单地使用训练数据迭代器在我们的模型上调用训练函数,计算训练损失和准确率,并在此过程中更新我们的模型。接着,我们使用验证数据迭代器上的评估函数,计算验证数据上的损失和准确率,但不更新我们的模型:

for epoch in range(epochs):
    start_time = time.time()

    train_loss, train_acc = train(model, train_iterator,                            optimizer, criterion)
    valid_loss, valid_acc = evaluate(model, valid_iterator,                            criterion)

    end_time = time.time()

之后,我们确定我们的模型在当前 epoch 之后是否优于迄今为止表现最佳的模型:

if valid_loss < lowest_validation_loss:
    lowest_validation_loss = valid_loss
    torch.save(model.state_dict(), ‘cnn_model.pt’)

如果这个 epoch 之后的损失低于迄今为止最低的验证损失,我们将验证损失设置为新的最低验证损失,并保存当前模型权重。

最后,我们只需在每个 epoch 之后打印结果。如果一切正常,我们应该看到每个 epoch 后训练损失下降,希望验证损失也跟随下降:

print(f’Epoch: {epoch+1:02} | Epoch Time: {int(end_time -       start_time)}s’)
print(f’\tTrain Loss: {train_loss:.3f} | Train Acc: {train_      acc*100:.2f}%’)
print(f’\t Val. Loss: {valid_loss:.3f} |  Val. Acc: {valid_      acc*100:.2f}%’)

这导致以下输出:

图 6.17 – 测试模型

图 6.17 – 测试模型

幸运的是,我们看到情况似乎确实如此。每个 epoch 后,训练损失和验证损失都在下降,准确率上升,显示我们的模型确实在学习!经过多个训练 epoch 后,我们可以使用最佳模型进行预测。

使用训练好的 CNN 进行预测

幸运的是,使用我们完全训练好的模型进行预测是一个相对简单的任务。我们首先使用load_state_dict函数加载我们的最佳模型:

model.load_state_dict(torch.load(‘cnn_model.pt’))

我们的模型结构已经定义好,所以我们只需从之前保存的文件中加载权重。如果一切正常,您将看到以下输出:

图 6.18 – 预测输出

图 6.18 – 预测输出

接下来,我们定义一个函数,该函数将以句子作为输入,对其进行预处理,将其传递给我们的模型,并返回预测:

def predict_class(model, sentence, min_len = 5):

    tokenized = [tok.text for tok in nlp.tokenizer(sentence)]
    if len(tokenized) < min_len:
        tokenized += [‘<pad>’] * (min_len - len(tokenized))
    indexed = [questions.vocab.stoi[t] for t in tokenized]
    tensor = torch.LongTensor(indexed).to(device)
    tensor = tensor.unsqueeze(0)

我们首先将输入句子传递给我们的分词器,以获取标记列表。如果句子长度低于最小句子长度,我们然后对这个句子进行填充。然后,我们使用我们的词汇表获取所有这些单个标记的索引,最后创建一个张量,其中包含这些索引的向量。如果 GPU 可用,我们将其传递给 GPU,然后展开输出,因为我们的模型期望三维张量输入而不是单个向量。

接下来,我们进行预测:

model.eval()
prediction = torch.max(model(tensor),1).indices.item()
pred_index = labels.vocab.itos[prediction]
    return pred_index

首先,我们将模型设置为评估模式(与我们的评估步骤相同),以便不计算模型的梯度并且不调整权重。然后,我们将句子张量传递给我们的模型,并获取长度为6的预测向量,其中包含每个六类别的单独预测。然后,我们取最大预测值的索引,并在标签索引中使用此索引返回预测类别的名称。

为了进行预测,我们只需在任何给定的句子上调用predict_class函数。让我们使用以下代码:

pred_class = predict_class(model, “How many roads must a man                            walk down?”)
print(‘Predicted class is: ‘ + str(pred_class))

这返回以下预测:

图 6.19 – 预测值

图 6.19 – 预测值

这个预测是正确的!我们的输入问题包含How many,表明这个问题的答案是一个数字值。这正是我们的模型也预测到的!你可以继续在任何其他你想测试的问题上验证模型,希望能获得类似的积极结果。恭喜!你现在已经成功训练了一个能够定义任何给定问题类别的多类 CNN。

总结

在本章中,我们展示了 CNN 如何从 NLP 数据中学习,以及如何使用 PyTorch 从头开始训练 CNN。虽然深度学习方法与 RNN 中使用的方法非常不同,但在概念上,CNN 以算法方式使用 n-gram 语言模型背后的动机,以从上下文中的相邻单词中提取单词的隐含信息。现在我们已经掌握了 RNN 和 CNN,我们可以开始扩展这些技术,以构建更先进的模型。

在下一章中,我们将学习如何构建既利用卷积神经网络又利用递归神经网络元素的模型,并将它们用于序列以执行更高级的功能,如文本翻译。这些被称为序列到序列网络。

第七章:使用序列到序列神经网络进行文本翻译

在前两章中,我们使用神经网络来分类文本并执行情感分析。这两项任务都涉及接收 NLP 输入并预测某个值。在情感分析中,这是一个介于 0 和 1 之间的数字,表示我们句子的情感。在句子分类模型中,我们的输出是一个多类别预测,表示句子属于的几个类别之一。但如果我们希望不仅仅是进行单一预测,而是预测整个句子呢?在本章中,我们将构建一个序列到序列模型,将一个语言中的句子作为输入,并输出这个句子在另一种语言中的翻译。

第五章**,递归神经网络和情感分析中,我们已经探讨了用于 NLP 学习的几种类型的神经网络架构,即递归神经网络,以及第六章**,使用 CNN 进行文本分类中的卷积神经网络。在本章中,我们将再次使用这些熟悉的 RNN,但不再仅构建简单的 RNN 模型,而是将 RNN 作为更大、更复杂模型的一部分,以执行序列到序列的翻译。通过利用我们在前几章学到的 RNN 基础知识,我们可以展示如何扩展这些概念,以创建适合特定用途的各种模型。

在本章中,我们将涵盖以下主题:

  • 序列到序列模型的理论

  • 为文本翻译构建序列到序列神经网络

  • 后续步骤

技术要求

本章的所有代码都可以在github.com/PacktPublishing/Hands-On-Natural-Language-Processing-with-PyTorch-1.x找到。

序列到序列模型的理论

到目前为止,序列到序列模型与我们迄今所见的传统神经网络结构非常相似。其主要区别在于,对于模型的输出,我们期望得到另一个序列,而不是一个二进制或多类别预测。这在翻译等任务中特别有用,我们希望将一个完整的句子转换成另一种语言。

在以下示例中,我们可以看到我们的英语到西班牙语翻译将单词映射到单词:

图 7.1 – 英语到西班牙语翻译

图 7.1 – 英语到西班牙语翻译

我们输入句子中的第一个单词与输出句子中的第一个单词非常匹配。如果所有语言都是这种情况,我们可以简单地通过我们训练过的模型逐个传递我们句子中的每个单词来获得一个输出句子,那么就不需要进行任何序列到序列建模,如本例所示:

图 7.2 – 英语到西班牙语的单词翻译

](https://gitee.com/OpenDocCN/freelearn-dl-pt2-zh/raw/master/docs/hsn-nlp-pt1x/img/B12365_07_02.jpg)

图 7.2 – 英语到西班牙语单词的翻译

然而,我们从自然语言处理的经验中知道,语言并不像这么简单!一种语言中的单词可能映射到另一种语言中的多个单词,并且这些单词在语法正确的句子中出现的顺序可能不同。因此,我们需要一个能够捕获整个句子上下文并输出正确翻译的模型,而不是直接翻译单个单词的模型。这就是序列到序列建模变得至关重要的地方,正如在这里所看到的:

图 7.3 – 用于翻译的序列到序列建模

图 7.3 – 用于翻译的序列到序列建模

要训练一个序列到序列模型,捕捉输入句子的上下文并将其转换为输出句子,我们基本上会训练两个较小的模型来实现这一点:

  • 一个编码器模型,它捕获我们句子的上下文并将其输出为单个上下文向量

  • 一个解码器,它接受我们原始句子的上下文向量表示,并将其翻译为另一种语言

所以,实际上,我们的完整序列到序列翻译模型看起来会像这样:

图 7.4 – 完整的序列到序列模型

图 7.4 – 完整的序列到序列模型

通过将我们的模型拆分为单独的编码器和解码器元素,我们有效地模块化了我们的模型。这意味着,如果我们希望训练多个模型从英语翻译成不同的语言,我们不需要每次重新训练整个模型。我们只需训练多个不同的解码器来将我们的上下文向量转换为输出句子。然后,在进行预测时,我们可以简单地替换我们希望用于翻译的解码器:

图 7.5 – 详细模型布局

图 7.5 – 详细模型布局

接下来,我们将检查序列到序列模型的编码器和解码器组件。

编码器

我们序列到序列模型的编码器元素的目的是能够完全捕获我们输入句子的上下文,并将其表示为向量。我们可以通过使用循环神经网络或更具体地说是长短期记忆网络来实现这一点。正如您可能从我们之前的章节中记得的那样,循环神经网络接受顺序输入并在整个序列中维护隐藏状态。序列中的每个新单词都会更新隐藏状态。然后,在序列结束时,我们可以使用模型的最终隐藏状态作为我们下一层的输入。

在我们的编码器的情况下,隐藏状态代表了我们整个句子的上下文向量表示,这意味着我们可以使用 RNN 的隐藏状态输出来表示整个输入句子:

图 7.6 – 检查编码器

图 7.6 – 检查编码器

我们使用我们的最终隐藏状态 h^n 作为我们的上下文向量,然后使用经过训练的解码器来解码它。同时值得注意的是,在我们的序列到序列模型的背景下,我们在输入句子的开头和结尾分别附加了 "start" 和 "end" 令牌。这是因为我们的输入和输出并没有固定的长度,我们的模型需要能够学习何时结束一个句子。我们的输入句子总是以 "end" 令牌结束,这向编码器表明此时的隐藏状态将被用作该输入句子的最终上下文向量表示。类似地,在解码器步骤中,我们将看到我们的解码器将继续生成词汇,直到预测到一个 "end" 令牌。这使得我们的解码器能够生成实际的输出句子,而不是无限长度的令牌序列。

接下来,我们将看看解码器如何利用这个上下文向量学习将其翻译成输出句子。

解码器

我们的解码器接收来自我们编码器层的最终隐藏状态,并将其解码成另一种语言的句子。我们的解码器是一个 RNN,类似于我们的编码器,但是在我们的编码器更新其隐藏状态时考虑当前句子中的当前词汇,我们的解码器在每次迭代中更新其隐藏状态并输出一个令牌,考虑到当前的隐藏状态和先前预测的句子中的词汇。可以在以下图表中看到这一点:

![图 7.7 – 检查解码器 img/B12365_07_07.jpg)图 7.7 – 检查解码器首先,我们的模型将上下文向量作为我们编码器步骤的最终隐藏状态 h0。然后,我们的模型旨在预测句子中的下一个词汇,给定当前隐藏状态,然后是句子中的前一个词汇。我们知道我们的句子必须以一个 "start" 令牌开始,因此在第一步,我们的模型尝试根据先前的隐藏状态 h0 和句子中的先前词汇(在这种情况下是 "start" 令牌)预测句子中的第一个词汇。我们的模型做出预测("pienso"),然后更新隐藏状态以反映模型的新状态 h1。然后,在下一步中,我们的模型使用新的隐藏状态和上次预测的词汇来预测句子中的下一个词汇。这一过程持续进行,直到模型预测到 "end" 令牌,此时我们的模型停止生成输出词汇。这个模型背后的直觉与我们迄今为止对语言表征的理解是一致的。任何给定句子中的词汇都依赖于它之前的词汇。因此,预测句子中的任何给定词汇而不考虑其之前预测的词汇是没有意义的,因为任何给定句子中的词汇都不是彼此独立的。我们学习模型参数的方法与之前相同:通过进行前向传播,计算目标句子与预测句子的损失,并通过网络反向传播此损失,随着过程更新参数。然而,使用这种过程学习可能非常缓慢,因为起初,我们的模型预测能力很弱。由于我们目标句子中的单词预测不是独立的,如果我们错误地预测了目标句子的第一个单词,那么输出句子中的后续单词也可能不正确。为了帮助这个过程,我们可以使用一种称为教师强迫的技术。## 使用教师强迫由于我们的模型最初预测不良好,我们会发现任何初始错误都会呈指数增长。如果我们在句子中第一个预测的单词不正确,那么句子的其余部分很可能也是错误的。这是因为我们模型的预测依赖于它先前的预测。这意味着我们模型遇到的任何损失都可能会成倍增加。由于此原因,我们可能面临梯度爆炸问题,使得我们的模型很难学习任何东西:![图 7.8 – 使用教师强迫图 7.8 – 使用教师强迫然而,通过使用教师强迫,我们训练模型时使用正确的先前目标词,这样一次错误预测不会阻碍模型从正确预测中学习。这意味着如果我们的模型在句子的某一点上做出错误预测,它仍然可以使用后续单词进行正确的预测。虽然我们的模型仍然会有错误的预测单词,并且会有损失可以用来更新我们的梯度,但现在我们不再遭受梯度爆炸,我们的模型会学习得更快:图 7.9 – 更新损失

图 7.9 – 更新损失

您可以将教师强迫视为一种帮助我们的模型在每个时间步独立学习的方法。这样,由于在早期时间步骤的误预测造成的损失不会传递到后续时间步骤。

通过结合编码器和解码器步骤,并应用教师强迫来帮助我们的模型学习,我们可以构建一个序列到序列的模型,允许我们将一种语言的序列翻译成另一种语言。在接下来的部分,我们将演示如何使用 PyTorch 从头开始构建这个模型。

构建文本翻译的序列到序列模型

为了构建我们的序列到序列翻译模型,我们将实现之前概述的编码器/解码器框架。这将展示我们的模型的两个部分如何结合在一起,以通过编码器捕获数据的表示,然后使用解码器将这个表示翻译成另一种语言。为了做到这一点,我们需要获取我们的数据。

准备数据

现在我们已经了解足够多的关于机器学习的知识,知道对于这样的任务,我们需要一组带有相应标签的训练数据。在这种情况下,我们将需要 Torchtext 库,我们在前一章中使用的这个库包含一个数据集,可以帮助我们获得这些数据。

Torchtext 中的 Multi30k 数据集包含大约 30,000 个句子及其在多种语言中的对应翻译。对于这个翻译任务,我们的输入句子将是英文,输出句子将是德文。因此,我们完全训练好的模型将能够将英文句子翻译成德文

我们将开始提取和预处理我们的数据。我们将再次使用 spacy,它包含一个内置的词汇表字典,我们可以用它来标记化我们的数据:

  1. 我们首先将 spacy 标记器加载到 Python 中。我们将需要为每种语言执行一次此操作,因为我们将为此任务构建两个完全独立的词汇表:

    spacy_german = spacy.load(‘de’)
    spacy_english = spacy.load(‘en’)
    

    重要说明

    您可能需要通过以下命令行安装德语词汇表(我们在前一章中安装了英语词汇表):python3 -m spacy download de

  2. 接下来,我们为每种语言创建一个函数来对我们的句子进行标记化。请注意,我们对输入的英文句子进行标记化时会颠倒 token 的顺序:

    def tokenize_german(text):
        return [token.text for token in spacy_german.            tokenizer(text)]
    def tokenize_english(text):
        return [token.text for token in spacy_english.            tokenizer(text)][::-1]
    

    虽然反转输入句子的顺序并非强制性的,但已被证明可以提高模型的学习能力。如果我们的模型由两个连接在一起的 RNN 组成,我们可以展示在反转输入句子时,模型内部的信息流得到了改善。例如,让我们来看一个基本的英文输入句子,但不进行反转,如下所示:

    图 7.10 – 反转输入单词

    图 7.10 – 反转输入单词

    在这里,我们可以看到为了正确预测第一个输出词 y0,我们的第一个英文单词从 x0 必须通过三个 RNN 层后才能进行预测。从学习的角度来看,这意味着我们的梯度必须通过三个 RNN 层进行反向传播,同时通过网络保持信息的流动。现在,让我们将其与反转输入句子的情况进行比较:

    图 7.11 – 反转输入句子

    图 7.11 – 反转输入句子

    现在我们可以看到,输入句子中真正的第一个单词与输出句子中相应单词之间的距离仅为一个 RNN 层。这意味着梯度只需反向传播到一个层,这样网络的信息流和学习能力与输入输出单词之间距离为三层时相比要大得多。

    如果我们计算反向和非反向变体中输入单词与其输出对应单词之间的总距离,我们会发现它们是相同的。然而,我们先前已经看到,输出句子中最重要的单词是第一个单词。这是因为输出句子中的单词依赖于它们之前的单词。如果我们错误地预测输出句子中的第一个单词,那么后面的单词很可能也会被错误地预测。然而,通过正确预测第一个单词,我们最大化了正确预测整个句子的机会。因此,通过最小化输出句子中第一个单词与其输入对应单词之间的距离,我们可以增加模型学习这种关系的能力。这增加了此预测正确的机会,从而最大化了整个输出句子被正确预测的机会。

  3. 有了我们构建的分词器,现在我们需要为分词定义字段。请注意,在这里我们如何在我们的序列中添加开始和结束标记,以便我们的模型知道何时开始和结束序列的输入和输出。为了简化起见,我们还将所有的输入句子转换为小写:

    SOURCE = Field(tokenize = tokenize_english, 
                init_token = ‘<sos>’, 
                eos_token = ‘<eos>’, 
                lower = True)
    TARGET = Field(tokenize = tokenize_german, 
                init_token = ‘<sos>’, 
                eos_token = ‘<eos>’, 
                lower = True)
    
  4. 我们定义了字段后,我们的分词化变成了一个简单的一行代码。包含 30,000 个句子的数据集具有内置的训练、验证和测试集,我们可以用于我们的模型:

    train_data, valid_data, test_data = Multi30k.splits(exts = (‘.en’, ‘.de’), fields = (SOURCE, TARGET))
    
  5. 我们可以使用数据集对象的examples属性来检查单个句子。在这里,我们可以看到源(src)属性包含我们英语输入句子的反向,而目标(trg)包含我们德语输出句子的非反向:

    print(train_data.examples[0].src)
    print(train_data.examples[0].trg)
    

    这给了我们以下输出:

    ![图 7.12 – 训练数据示例

    图 7.12 – 训练数据示例

  6. 现在,我们可以检查每个数据集的大小。在这里,我们可以看到我们的训练数据集包含 29,000 个示例,而每个验证集和测试集分别包含 1,014 和 1,000 个示例。在过去,我们通常将训练和验证数据拆分为 80%/20%。然而,在像这样输入输出字段非常稀疏且训练集有限的情况下,通常最好利用所有可用数据进行训练:

    print(“Training dataset size: “ + str(len(train_data.       examples)))
    print(“Validation dataset size: “ + str(len(valid_data.       examples)))
    print(“Test dataset size: “ + str(len(test_data.       examples)))
    

    这将返回以下输出:

    图 7.13 – 数据样本长度

  7. 现在,我们可以构建我们的词汇表并检查它们的大小。我们的词汇表应包含数据集中发现的每个唯一单词。我们可以看到我们的德语词汇表比我们的英语词汇表大得多。我们的词汇表比每种语言的真实词汇表大小要小得多(英语词典中的每个单词)。因此,由于我们的模型只能准确地翻译它以前见过的单词,我们的模型不太可能能够很好地泛化到英语语言中的所有可能句子。这就是为什么像这样准确训练模型需要极大的 NLP 数据集(例如 Google 可以访问的那些)的原因:

    SOURCE.build_vocab(train_data, min_freq = 2)
    TARGET.build_vocab(train_data, min_freq = 2)
    print(“English (Source) Vocabulary Size: “ +        str(len(SOURCE.vocab)))
    print(“German (Target) Vocabulary Size: “ +        str(len(TARGET.vocab)))
    

    这将得到以下输出:

    ![图 7.14 – 数据集的词汇量 图 7.14 – 数据集的词汇量

    图 7.14 – 数据集的词汇量

  8. 最后,我们可以从我们的数据集创建数据迭代器。与以前一样,我们指定使用支持 CUDA 的 GPU(如果系统上可用),并指定我们的批量大小:

    device = torch.device(‘cuda’ if torch.cuda.is_available()                       else ‘cpu’)
    batch_size = 32
    train_iterator, valid_iterator, test_iterator = BucketIterator.splits(
        (train_data, valid_data, test_data), 
        batch_size = batch_size, 
        device = device)
    

现在我们的数据已经预处理完成,我们可以开始构建模型本身。

建立编码器

现在,我们可以开始建立我们的编码器:

  1. 首先,我们通过从我们的nn.Module类继承来初始化我们的模型,就像我们之前的所有模型一样。我们初始化一些参数,稍后我们会定义,以及我们的 LSTM 层中隐藏层的维度数和 LSTM 层的数量:

    class Encoder(nn.Module):
        def __init__(self, input_dims, emb_dims, hid_dims,     n_layers, dropout):
            super().__init__()   
            self.hid_dims = hid_dims
            self.n_layers = n_layers
    
  2. 接下来,我们在编码器内定义我们的嵌入层,这是输入维度数量和嵌入维度数量的长度:

    self.embedding = nn.Embedding(input_dims, emb_dims)
    
  3. 接下来,我们定义实际的 LSTM 层。这需要我们从嵌入层获取嵌入的句子,保持定义长度的隐藏状态,并包括一些层(稍后我们将定义为 2)。我们还实现dropout以对网络应用正则化:

    self.rnn = nn.LSTM(emb_dims, hid_dims, n_layers, dropout                    = dropout)
    self.dropout = nn.Dropout(dropout)
    
  4. 然后,我们在编码器内定义前向传播。我们将嵌入应用于我们的输入句子并应用 dropout。然后,我们将这些嵌入传递到我们的 LSTM 层,输出我们的最终隐藏状态。这将由我们的解码器用于形成我们的翻译句子:

    def forward(self, src):
        embedded = self.dropout(self.embedding(src))
        outputs, (h, cell) = self.rnn(embedded)
        return h, cell
    

我们的编码器将包括两个 LSTM 层,这意味着我们的输出将输出两个隐藏状态。这也意味着我们的完整 LSTM 层,以及我们的编码器,将看起来像这样,我们的模型输出两个隐藏状态:

图 7.15 – 具有编码器的 LSTM 模型

图 7.15 – 具有编码器的 LSTM 模型

现在我们已经建立了编码器,让我们开始建立我们的解码器。

建立解码器

我们的解码器将从编码器的 LSTM 层中获取最终的隐藏状态,并将其转化为另一种语言的输出句子。我们首先通过几乎完全相同的方式初始化我们的解码器,与编码器的方法略有不同的是,我们还添加了一个全连接线性层。该层将使用 LSTM 的最终隐藏状态来预测句子中正确的单词:

class Decoder(nn.Module):
    def __init__(self, output_dims, emb_dims, hid_dims,     n_layers, dropout):
        super().__init__()

        self.output_dims = output_dims
        self.hid_dims = hid_dims
        self.n_layers = n_layers

        self.embedding = nn.Embedding(output_dims, emb_dims)

        self.rnn = nn.LSTM(emb_dims, hid_dims, n_layers,                           dropout = dropout)

        self.fc_out = nn.Linear(hid_dims, output_dims)

        self.dropout = nn.Dropout(dropout)

我们的前向传播与编码器非常相似,只是增加了两个关键步骤。首先,我们将前一层的输入展开,以使其适合输入到嵌入层中。然后,我们添加一个全连接层,该层接收我们的 RNN 层的输出隐藏层,并用其来预测序列中的下一个单词:

def forward(self, input, h, cell):

    input = input.unsqueeze(0)

    embedded = self.dropout(self.embedding(input))

    output, (h, cell) = self.rnn(embedded, (h, cell))

    pred = self.fc_out(output.squeeze(0))

    return pred, h, cell

类似于编码器,我们在解码器内部使用了一个两层的 LSTM 层。我们取出编码器的最终隐藏状态,并用它们生成序列中的第一个单词 Y1. 然后,我们更新我们的隐藏状态,并使用它和 Y1 生成下一个单词 Y2,重复此过程,直到我们的模型生成一个结束标记。我们的解码器看起来像这样:

图 7.16 – 带有解码器的 LSTM 模型

图 7.16 – 带有解码器的 LSTM 模型

在这里,我们可以看到单独定义编码器和解码器并不特别复杂。然而,当我们将这些步骤组合成一个更大的序列到序列模型时,事情开始变得有趣:

构建完整的序列到序列模型

现在,我们必须将我们模型的两个部分连接起来,以产生完整的序列到序列模型:

  1. 我们首先创建一个新的序列到序列类。这将允许我们将编码器和解码器作为参数传递给它:

    class Seq2Seq(nn.Module):
        def __init__(self, encoder, decoder, device):
            super().__init__()
    
            self.encoder = encoder
            self.decoder = decoder
            self.device = device
    
  2. 接下来,我们在我们的 Seq2Seq 类中创建 forward 方法。这可以说是模型中最复杂的部分。我们将编码器与解码器结合起来,并使用教师强迫来帮助我们的模型学习。我们首先创建一个张量,其中存储我们的预测。我们将其初始化为一个全零张量,但随着我们生成预测,我们会更新它。全零张量的形状将是目标句子的长度、批量大小的宽度和目标(德语)词汇表大小的深度:

    def forward(self, src, trg, teacher_forcing_rate = 0.5):
        batch_size = trg.shape[1]
        target_length = trg.shape[0]
        target_vocab_size = self.decoder.output_dims
    
         outputs = torch.zeros(target_length, batch_size,                     target_vocab_size).to(self.device)
    
  3. 接下来,我们将输入句子传递到编码器中,以获取输出的隐藏状态:

    h, cell = self.encoder(src)
    
  4. 然后,我们必须循环遍历我们的解码器模型,为输出序列中的每个步骤生成一个输出预测。输出序列的第一个元素始终是 <start> 标记。我们的目标序列已将其作为第一个元素,因此我们只需将初始输入设置为这个,通过获取列表的第一个元素:

    input = trg[0,:]
    
  5. 接下来,我们循环并进行预测。我们将我们的隐藏状态(从编码器的输出中获得)传递给我们的解码器,以及我们的初始输入(仅是<start>标记)。这将为我们序列中的所有单词返回一个预测。然而,我们只对当前步骤中的单词感兴趣;也就是说,序列中的下一个单词。请注意,我们从 1 开始循环,而不是从 0 开始,因此我们的第一个预测是序列中的第二个单词(因为始终预测的第一个单词将始终是起始标记)。

  6. 此输出由目标词汇长度的向量组成,每个词汇中都有一个预测。我们使用argmax函数来识别模型预测的实际单词。

    接下来,我们需要为下一步选择新的输入。我们将我们的教师强制比例设置为 50%,这意味着有 50%的时间,我们将使用我们刚刚做出的预测作为我们解码器的下一个输入,而另外 50%的时间,我们将采用真实的目标值。正如我们之前讨论的那样,这比仅依赖于模型预测能够更快地让我们的模型学习。

    然后,我们继续这个循环,直到我们对序列中的每个单词都有了完整的预测:

    for t in range(1, target_length):
    output, h, cell = self.decoder(input, h, cell)
    
    outputs[t] = output
    
    top = output.argmax(1) 
    
    input = trg[t] if (random.random() < teacher_forcing_                   rate) else top
    
    return outputs
    
  7. 最后,我们创建一个准备好进行训练的 Seq2Seq 模型的实例。我们使用一些超参数初始化了一个编码器和一个解码器,所有这些超参数都可以稍微改变模型:

    input_dimensions = len(SOURCE.vocab)
    output_dimensions = len(TARGET.vocab)
    encoder_embedding_dimensions = 256
    decoder_embedding_dimensions = 256
    hidden_layer_dimensions = 512
    number_of_layers = 2
    encoder_dropout = 0.5
    decoder_dropout = 0.5
    
  8. 然后,我们将我们的编码器和解码器传递给我们的Seq2Seq模型,以创建完整的模型:

    encod = Encoder(input_dimensions,\
                    encoder_embedding_dimensions,\
                    hidden_layer_dimensions,\
                    number_of_layers, encoder_dropout)
    decod = Decoder(output_dimensions,\
                    decoder_embedding_dimensions,\
                    hidden_layer_dimensions,\
                    number_of_layers, decoder_dropout)
    model = Seq2Seq(encod, decod, device).to(device)
    

尝试在这里用不同的参数进行实验,并查看它们如何影响模型的性能。例如,在隐藏层中使用更大数量的维度可能会导致模型训练速度较慢,尽管最终模型的性能可能会更好。或者,模型可能会过拟合。通常来说,这是一个通过实验来找到最佳性能模型的问题。

在完全定义了我们的 Seq2Seq 模型之后,我们现在准备开始训练它。

训练模型

我们的模型将从整个模型的各个部分开始以 0 权重进行初始化。虽然理论上模型应该能够学习到没有(零)权重的情况,但已经证明使用随机权重初始化可以帮助模型更快地学习。让我们开始吧:

  1. 在这里,我们将使用从正态分布中随机抽取的随机样本的权重来初始化我们的模型,其值介于-0.1 到 0.1 之间:

    def initialize_weights(m):
        for name, param in m.named_parameters():
            nn.init.uniform_(param.data, -0.1, 0.1)
    
    model.apply(initialize_weights)
    
  2. 接下来,与我们的其他所有模型一样,我们定义我们的优化器和损失函数。我们使用交叉熵损失,因为我们正在执行多类别分类(而不是二元交叉熵损失用于二元分类):

    optimizer = optim.Adam(model.parameters())
    criterion = nn.CrossEntropyLoss(ignore_index = TARGET.               vocab.stoi[TARGET.pad_token])
    
  3. 接下来,在名为train()的函数中定义训练过程。首先,我们将模型设置为训练模式,并将 epoch 损失设置为0

    def train(model, iterator, optimizer, criterion, clip):
        model.train()
        epoch_loss = 0
    
  4. 然后,我们在我们的训练迭代器中循环遍历每个批次,并提取要翻译的句子(src)和这个句子的正确翻译(trg)。然后我们将梯度归零(以防止梯度累积),通过将我们的输入和输出传递给模型函数来计算模型的输出:

    for i, batch in enumerate(iterator):
    src = batch.src
    trg = batch.trg
    optimizer.zero_grad()
    output = model(src, trg)
    
  5. 接下来,我们需要通过比较我们的预测输出和真实的正确翻译句子来计算模型预测的损失。我们使用形状和视图函数来重塑我们的输出数据和目标数据,以便创建两个可以比较的张量,以计算损失。我们在我们的输出和 trg 张量之间计算 loss 损失标准,然后通过网络反向传播这个损失:

    output_dims = output.shape[-1]
    output = output[1:].view(-1, output_dims)
    trg = trg[1:].view(-1)
    
    loss = criterion(output, trg)
    
    loss.backward()
    
  6. 然后,我们实施梯度裁剪以防止模型内出现梯度爆炸,通过梯度下降来步进我们的优化器执行必要的参数更新,最后将批次的损失添加到 epoch 损失中。这整个过程针对单个训练 epoch 中的所有批次重复执行,最终返回每批次的平均损失:

    torch.nn.utils.clip_grad_norm_(model.parameters(), clip)
    
    optimizer.step()
    
    epoch_loss += loss.item()
    
    return epoch_loss / len(iterator)
    
  7. 之后,我们创建一个名为 evaluate() 的类似函数。这个函数将计算整个网络中验证数据的损失,以评估我们的模型在翻译它之前未见的数据时的表现。这个函数几乎与我们的 train() 函数相同,唯一的区别是我们切换到评估模式:

    model.eval()
    
  8. 由于我们不对权重进行任何更新,我们需要确保实现 no_grad 模式:

    with torch.no_grad():
    
  9. 另一个不同之处在于,我们需要确保在评估模式下关闭教师强迫。我们希望评估模型在未见数据上的表现,并且启用教师强迫将使用正确的(目标)数据来帮助我们的模型做出更好的预测。我们希望我们的模型能够完美地做出预测:

    output = model(src, trg, 0)
    
  10. 最后,我们需要创建一个训练循环,在其中调用我们的 train()evaluate() 函数。我们首先定义我们希望训练的 epoch 数量以及我们的最大梯度(用于梯度裁剪)。我们还将我们的最低验证损失设置为无穷大。稍后将使用它来选择我们表现最佳的模型:

    epochs = 10
    grad_clip = 1
    lowest_validation_loss = float(‘inf’)
    
  11. 然后,我们循环遍历每一个 epoch,在每一个 epoch 中,使用我们的 train()evaluate() 函数计算训练和验证损失。我们还通过调用 time.time() 函数在训练过程前后计时:

    for epoch in range(epochs):
    
        start_time = time.time()
    
        train_loss = train(model, train_iterator, optimizer,                       criterion, grad_clip)
        valid_loss = evaluate(model, valid_iterator,                          criterion)
    
        end_time = time.time()
    
  12. 接下来,对于每个 epoch,我们确定刚刚训练的模型是否是迄今为止表现最佳的模型。如果我们的模型在验证数据上表现最佳(如果验证损失是迄今为止最低的),我们会保存我们的模型:

    if valid_loss < lowest_validation_loss:
    lowest_validation_loss = valid_loss
    torch.save(model.state_dict(), ‘seq2seq.pt’) 
    
  13. 最后,我们简单地打印我们的输出:

    print(f’Epoch: {epoch+1:02} | Time: {np.round(end_time-start_time,0)}s’)
    print(f’\tTrain Loss: {train_loss:.4f}’)
    print(f’\t Val. Loss: {valid_loss:.4f}’)
    

    如果我们的训练工作正确,我们应该看到训练损失随时间减少,如下所示:

Figure 7.17 – 训练模型

图 7.17 – 训练模型

在这里,我们可以看到我们的训练和验证损失随时间逐渐下降。我们可以继续训练我们的模型多个 epochs,理想情况下直到验证损失达到最低可能值。现在,我们可以评估我们表现最佳的模型,看看它在进行实际翻译时的表现如何。

评估模型

为了评估我们的模型,我们将使用我们的测试数据集,将我们的英语句子通过我们的模型,得到翻译成德语的预测。然后,我们将能够将其与真实预测进行比较,以查看我们的模型是否进行了准确的预测。让我们开始吧!

  1. 我们首先创建一个translate()函数。这个函数与我们创建的evaluate()函数在功能上是一样的,用来计算验证集上的损失。但是,这一次我们不关心模型的损失,而是关心预测的输出。我们向模型传递源语句和目标语句,并确保关闭教师强制,这样我们的模型不会用它们来进行预测。然后,我们获取模型的预测结果,并使用argmax函数来确定我们预测输出句子中每个词的索引:

    output = model(src, trg, 0)
    preds = torch.tensor([[torch.argmax(x).item()] for x         in output])
    
  2. 然后,我们可以使用这个索引从我们的德语词汇表中获取实际预测的词。最后,我们将英语输入与包含正确德语句子和预测德语句子的模型进行比较。请注意,在这里,我们使用[1:-1]来删除预测中的起始和结束标记,并且我们反转了英语输入的顺序(因为输入句子在进入模型之前已经被反转):

    print(‘English Input: ‘ + str([SOURCE.vocab.itos[x] for x        in src][1:-1][::-1]))
    print(‘Correct German Output: ‘ + str([TARGET.vocab.       itos[x] for x in trg][1:-1]))
    print(‘Predicted German Output: ‘ + str([TARGET.vocab.       itos[x] for x in preds][1:-1]))
    

    通过这样做,我们可以将我们的预测输出与正确输出进行比较,以评估我们的模型是否能够进行准确的预测。从我们模型的预测中可以看出,我们的模型能够将英语句子翻译成德语,尽管远非完美。一些模型的预测与目标数据完全相同,表明我们的模型完美地翻译了这些句子:

图 7.18 – 翻译输出第一部分

图 7.18 – 翻译输出第一部分

在其他情况下,我们的模型只差一个词。在这种情况下,我们的模型预测单词hüten而不是mützen;然而,hüten实际上是mützen的可接受翻译,尽管这些词在语义上可能不完全相同:

图 7.19 – 翻译输出第二部分

图 7.19 – 翻译输出第二部分

我们还可以看到一些似乎被错误翻译的例子。在下面的例子中,我们预测的德语句子的英语等效句子是“A woman climbs through one”,这与“Young woman climbing rock face”不相等。然而,模型仍然成功翻译了英语句子的关键元素(woman 和 climbing):

图 7.20 – 翻译输出第三部分

图 7.20 – 翻译输出第三部分

在这里,我们可以看到,虽然我们的模型显然尝试着将英语翻译成德语,但远非完美,并且存在多个错误。它肯定无法欺骗一个德语母语者!接下来,我们将讨论如何改进我们的序列到序列翻译模型的几种方法。

下一步

虽然我们展示了我们的序列到序列模型在执行语言翻译方面是有效的,但我们从头开始训练的模型绝不是完美的翻译器。部分原因在于我们训练数据的相对较小规模。我们在一组 30,000 个英语/德语句子上训练了我们的模型。虽然这可能看起来非常大,但要训练一个完美的模型,我们需要一个几个数量级更大的训练集。

理论上,我们需要每个单词在整个英语和德语语言中的多个例子,才能使我们的模型真正理解其上下文和含义。就我们训练集中的情况而言,这包括仅有 6,000 个独特单词的 30,000 个英语句子。据说,一个英语人士的平均词汇量在 20,000 到 30,000 之间,这让我们对需要训练一个完美执行的模型有了一定的了解。这也许是为什么最准确的翻译工具通常由拥有大量语言数据的公司(如 Google)拥有。

总结

在本章中,我们介绍了如何从头开始构建序列到序列模型。我们学习了如何分别编码和解码组件,并如何将它们整合成一个能够将一种语言的句子翻译成另一种语言的单一模型。

尽管我们的序列到序列模型包括编码器和解码器,在序列翻译中很有用,但它已不再是最先进的技术。在过去几年中,结合序列到序列模型和注意力模型已经被用来实现最先进的性能。

在下一章中,我们将讨论注意力网络如何在序列到序列学习的背景下使用,并展示我们如何同时使用这两种技术来构建聊天机器人。

第八章:使用基于注意力的神经网络构建对话机器人

如果你看过任何未来主义科幻电影,你可能会看到人类与机器人交谈。基于机器的智能一直是小说作品中的一个长期特征;然而,由于自然语言处理和深度学习的最新进展,与计算机的对话不再是幻想。虽然我们离真正的智能可能还有很多年的距离,即使是现在,计算机至少能够进行基本的对话并给出初步的智能印象。

在上一章中,我们讨论了如何构建序列到序列模型来将句子从一种语言翻译成另一种语言。一个能进行基本交互的对话机器人工作方式类似。当我们与机器人交谈时,我们的句子成为模型的输入。输出是机器人选择回复的内容。因此,我们不是训练机器人如何解释我们的输入句子,而是教会它如何回应。

我们将在上一章的序列到序列模型基础上增加一种称为注意力的东西。这种改进使得我们的序列到序列模型学会了在输入句子中寻找需要的信息,而不是全盘使用输入句子的决定。这种改进允许我们创建具有最先进性能的高效序列到序列模型。

本章将讨论以下主题:

  • 神经网络中的注意力理论

  • 在神经网络中实现注意力以构建对话机器人

技术要求

本章的所有代码可以在 github.com/PacktPublishing/Hands-On-Natural-Language-Processing-with-PyTorch-1.x 找到。

神经网络中的注意力理论

在上一章中,在我们的序列到序列模型中进行句子翻译(未实现注意力)时,我们使用了编码器和解码器。编码器从输入句子中获得了隐藏状态,这是我们句子的表示。解码器然后使用这个隐藏状态执行翻译步骤。其基本的图形说明如下:

图 8.1 – 序列到序列模型的图形表示

图 8.1 – 序列到序列模型的图形表示

然而,在整个隐藏状态上解码并不一定是使用此任务的最有效方式。这是因为隐藏状态代表输入句子的整体;然而在某些任务中(例如预测句子中的下一个单词),我们并不需要考虑输入句子的整体,只需要考虑与我们试图做出的预测相关的部分。我们可以通过在我们的序列到序列神经网络中使用注意力来表明这一点。我们可以教导我们的模型只看输入中相关的部分来做出预测,从而得到一个更加高效和准确的模型。

考虑以下例子:

我将于 3 月 2 日去法国的首都巴黎。我的航班将从伦敦希思罗机场起飞,大约需要一个小时。

假设我们正在训练一个模型来预测句子中的下一个单词。我们可以先输入句子的开头:

法国的首都是 _____。

在这种情况下,我们希望我们的模型能够检索单词巴黎。如果我们使用基本的序列到序列模型,我们会将整个输入转换为一个隐藏状态,然后我们的模型会尝试从中提取相关的信息。这包括关于航班的所有无关信息。您可能会注意到,我们只需要查看输入句子的一个小部分即可识别完成句子所需的相关信息:

我将于 3 月 2 日去法国的首都巴黎。我的航班将从伦敦希思罗机场起飞,大约需要一个小时。

因此,如果我们可以训练我们的模型仅使用输入句子中的相关信息,我们可以做出更准确和相关的预测。我们可以在我们的网络中实现注意力来实现这一点。

我们可以实现的两种主要注意机制是本地注意力和全局注意力。

比较本地和全局注意力

我们可以在我们的网络中实现的两种注意机制非常相似,但有微妙的关键区别。我们将从本地注意力开始。

本地注意力中,我们的模型只关注来自编码器的几个隐藏状态。例如,如果我们正在执行一个句子翻译任务,并且正在计算我们翻译中的第二个单词,模型可能只希望查看与输入句子中第二个单词相关的编码器的隐藏状态。这意味着我们的模型需要查看编码器的第二个隐藏状态(h2),但可能还需要查看其之前的隐藏状态(h1)。

在以下图表中,我们可以看到这一实践:

图 8.2 – 本地注意力模型

图 8.2 – 本地注意力模型

我们首先通过计算对齐位置,pt,从我们的最终隐藏状态,hn,得知我们需要查看哪些隐藏状态来进行预测。然后我们计算我们的局部权重,并将其应用于我们的隐藏状态,以确定我们的上下文向量。这些权重可能告诉我们更多地关注最相关的隐藏状态(h2),但对前一个隐藏状态(h1)的关注较少。

然后,我们将我们的上下文向量传递给我们的解码器,以进行其预测。在我们基于非注意力的序列到序列模型中,我们只会传递我们的最终隐藏状态,hn,但我们在这里看到,相反地,我们只考虑我们的模型认为必要以进行预测的相关隐藏状态。

全局注意力模型的工作方式与局部注意力模型非常相似。但是,与仅查看少数隐藏状态不同,我们希望查看我们模型的所有隐藏状态 — 因此称为全局。我们可以在这里看到全局注意力层的图形说明:

图 8.3 – 全局注意力模型

图 8.3 – 全局注意力模型

我们可以看到在上图中,虽然这看起来与我们的局部注意力框架非常相似,但是我们的模型现在正在查看所有的隐藏状态,并计算跨所有隐藏状态的全局权重。这使得我们的模型可以查看它认为相关的输入句子的任何部分,而不限于由局部注意力方法确定的局部区域。我们的模型可能希望只关注一个小的局部区域,但这是模型的能力范围内。全局注意力框架的一个简单理解方式是,它本质上是在学习一个只允许与我们的预测相关的隐藏状态通过的掩码:

图 8.4 – 组合模型

图 8.4 – 组合模型

我们可以看到在上图中,通过学习要关注的隐藏状态,我们的模型控制着在解码步骤中使用哪些状态来确定我们的预测输出。一旦我们决定要关注哪些隐藏状态,我们可以使用多种不同的方法来结合它们,无论是通过串联还是加权点积。

使用带有注意力的序列到序列神经网络构建聊天机器人

在我们的神经网络中准确实现注意力的最简单方式是通过一个例子来进行说明。现在我们将通过使用应用注意力框架的序列到序列模型来从头开始构建聊天机器人的步骤。

与我们所有其他的自然语言处理模型一样,我们的第一步是获取和处理数据集,以用于训练我们的模型。

获取我们的数据集

为了训练我们的聊天机器人,我们需要一组对话数据,通过这些数据,我们的模型可以学习如何回应。我们的聊天机器人将接受人类输入的一行,并用生成的句子作出回应。因此,理想的数据集应包含一些对话行及其适当的响应。对于这样的任务,理想的数据集将是两个人用户之间的实际聊天记录。不幸的是,这些数据包含私人信息,很难在公共领域内获取,因此,对于这个任务,我们将使用一组电影剧本数据集。

电影剧本由两个或更多角色之间的对话组成。尽管这些数据不是我们想要的格式,但我们可以轻松地将其转换为我们需要的格式。例如,考虑两个角色之间的简单对话:

  • 第 1 行:你好,贝瑟恩。

  • 第 2 行:你好,汤姆,你好吗?

  • 第 3 行:我很好,谢谢,今晚你要做什么?

  • 第 4 行:我没有什么计划。

  • 第 5 行:你想和我一起吃晚饭吗?

现在,我们需要将这些转换为呼叫和响应的输入输出对,其中输入是剧本中的一行(呼叫),期望的输出是剧本的下一行(响应)。我们可以将包含n行的剧本转换为n-1对输入/输出:

图 8.5 – 输入输出表

图 8.5 – 输入输出表

我们可以使用这些输入/输出对来训练我们的网络,其中输入代表人类输入的代理,输出是我们期望从模型得到的响应。

构建我们模型的第一步是读取这些数据并执行所有必要的预处理步骤。

处理我们的数据集

幸运的是,提供给本示例的数据集已经被格式化,以便每行表示单个输入/输出对。我们可以首先读取数据并检查一些行:

corpus = "movie_corpus"
corpus_name = "movie_corpus"
datafile = os.path.join(corpus, "formatted_movie_lines.txt")
with open(datafile, 'rb') as file:
    lines = file.readlines()

for line in lines[:3]:
    print(str(line) + '\n')

这将打印出以下结果:

图 8.6 – 检查数据集

图 8.6 – 检查数据集

您将首先注意到我们的行按预期显示,因为第一行的后半部分成为下一行的前半部分。我们还可以注意到,每行的呼叫和响应部分由制表符(/t)分隔,每行之间由换行符(/n)分隔。在处理数据集时,我们必须考虑到这一点。

第一步是创建一个包含数据集中所有唯一单词的词汇表或语料库。

创建词汇表

在过去,我们的语料库由几个字典组成,包含语料库中唯一单词和单词与索引之间的查找。但是,我们可以通过创建一个包含所有所需元素的词汇表类的更加优雅的方式来完成这项工作:

  1. 我们首先创建我们的Vocabulary类。我们用空字典——word2indexword2count——初始化这个类。我们还用占位符初始化index2word字典,用于我们的填充标记,以及我们的句子开头SOS)和句子结尾EOS)标记。我们还保持我们词汇表中单词数的运行计数,作为我们的语料库已经包含了提到的三个标记的默认值(初始为 3)。这些是一个空词汇表的默认值;然而,随着我们读取数据,它们将被填充:

    PAD_token = 0 
    SOS_token = 1
    EOS_token = 2
    class Vocabulary:
        def __init__(self, name):
            self.name = name
            self.trimmed = False
            self.word2index = {}
            self.word2count = {}
            self.index2word = {PAD_token: "PAD", SOS_token:                           "SOS", EOS_token: "EOS"}
            self.num_words = 3
    
  2. 接下来,我们创建用于填充我们词汇表的函数。addWord接受一个单词作为输入。如果这是一个不在我们词汇表中的新单词,我们将此单词添加到我们的索引中,将此单词的计数设置为 1,并将我们词汇表中的总单词数增加 1。如果所讨论的单词已经在我们的词汇表中,则简单地将此单词的计数增加 1:

    def addWord(self, w):
        if w not in self.word2index:
            self.word2index[w] = self.num_words
            self.word2count[w] = 1
            self.index2word[self.num_words] = w
            self.num_words += 1
        else:
            self.word2count[w] += 1
    
  3. 我们还使用addSentence函数将addWord函数应用于给定句子中的所有单词:

    def addSentence(self, sent):
        for word in sent.split(' '):
            self.addWord(word)
    

    我们可以做的一件事是加快模型训练的速度,即减小词汇表的大小。这意味着任何嵌入层将会更小,模型内学习的参数总数也会减少。一个简单的方法是从我们的词汇表中移除任何低频词汇。在我们的数据集中出现一次或两次的词汇不太可能有很大的预测能力,因此从我们的语料库中移除它们,并在最终模型中用空白标记替换它们,可以减少模型训练的时间,减少过拟合的可能性,而对模型预测的负面影响不大。

  4. 要从我们的词汇表中删除低频词汇,我们可以实现一个trim函数。该函数首先遍历单词计数字典,如果单词的出现次数大于所需的最小计数,则将其添加到一个新列表中:

    def trim(self, min_cnt):
        if self.trimmed:
            return
        self.trimmed = True
        words_to_keep = []
        for k, v in self.word2count.items():
            if v >= min_cnt:
                words_to_keep.append(k)
        print('Words to Keep: {} / {} = {:.2%}'.format(
            len(words_to_keep), len(self.word2index),    
            len(words_to_keep) / len(self.word2index)))
    
  5. 最后,我们从新的words_to_keep列表重新构建我们的索引。我们将所有索引设置为它们的初始空值,然后通过循环遍历我们保留的单词使用addWord函数来重新填充它们:

        self.word2index = {}
        self.word2count = {}
        self.index2word = {PAD_token: "PAD",\
                           SOS_token: "SOS",\
                           EOS_token: "EOS"}
        self.num_words = 3
        for w in words_to_keep:
            self.addWord(w)
    

现在我们已经定义了一个词汇表类,可以轻松地填充我们的输入句子。接下来,我们实际上需要加载我们的数据集来创建我们的训练数据。

加载数据

我们将使用以下步骤开始加载数据:

  1. 读取我们数据的第一步是执行任何必要的步骤来清理数据并使其更易于阅读。我们首先将其从 Unicode 格式转换为 ASCII 格式。我们可以轻松地使用一个函数来实现这一点:

    def unicodeToAscii(s):
        return ''.join(
            c for c in unicodedata.normalize('NFD', s)
            if unicodedata.category(c) != 'Mn'
        )
    
  2. 接下来,我们希望处理我们的输入字符串,使它们全部小写,并且不包含任何尾随的空白或标点符号,除了最基本的字符。我们可以通过使用一系列正则表达式来实现这一点:

    def cleanString(s):
        s = unicodeToAscii(s.lower().strip())
        s = re.sub(r"([.!?])", r" \1", s)
        s = re.sub(r"[^a-zA-Z.!?]+", r" ", s)
        s = re.sub(r"\s+", r" ", s).strip()
        return s
    
  3. 最后,我们在更广泛的函数内应用此函数——readVocs。此函数将我们的数据文件读取为行,并对每行应用cleanString函数。它还创建了我们之前创建的Vocabulary类的实例,这意味着此函数输出了我们的数据和词汇表:

    def readVocs(datafile, corpus_name):
        lines = open(datafile, encoding='utf-8').\
            read().strip().split('\n')
        pairs = [[cleanString(s) for s in l.split('\t')]               for l in lines]
        voc = Vocabulary(corpus_name)
        return voc, pairs
    

    接下来,我们根据它们的最大长度过滤我们的输入对。再次这样做是为了减少模型的潜在维度。预测数百个单词长的句子将需要非常深的架构。为了训练时间的利益,我们希望在此限制我们的训练数据,使输入和输出都少于 10 个单词长。

  4. 为此,我们创建了一对过滤函数。第一个函数,filterPair,根据当前行的输入和输出长度是否小于最大长度返回布尔值。我们的第二个函数,filterPairs,则简单地将此条件应用于数据集中的所有对,仅保留符合条件的对:

    def filterPair(p, max_length):
        return len(p[0].split(' ')) < max_length and len(p[1].split(' ')) < max_length
    def filterPairs(pairs, max_length):
        return [pair for pair in pairs if filterPair(pair,             max_length)]
    
  5. 现在,我们只需要创建一个最终函数,将之前所有的函数整合起来,并运行它以创建我们的词汇表和数据对:

    def loadData(corpus, corpus_name, datafile, save_dir, max_length):
        voc, pairs = readVocs(datafile, corpus_name)
        print(str(len(pairs)) + " Sentence pairs")
        pairs = filterPairs(pairs,max_length)
        print(str(len(pairs))+ " Sentence pairs after           trimming")
        for p in pairs:
            voc.addSentence(p[0])
            voc.addSentence(p[1])
        print(str(voc.num_words) + " Distinct words in           vocabulary")
        return voc, pairs
    max_length = 10 
    voc, pairs = loadData(corpus, corpus_name, datafile,                       max_length)
    

    我们可以看到,我们的输入数据集包含超过 200,000 对。当我们将其过滤为输入和输出长度都小于 10 个单词的句子时,这就减少到仅有 64,000 对,包含 18,000 个不同的单词:

    图 8.7 – 数据集中句子的价值

    图 8.7 – 数据集中句子的价值

  6. 我们可以打印出我们处理过的输入/输出对的一部分,以验证我们的函数是否都运行正确:

    print("Example Pairs:")
    for pair in pairs[-10:]:
        print(pair)
    

    生成以下输出:

图 8.8 – 处理过的输入/输出对

图 8.8 – 处理过的输入/输出对

我们已成功将数据集分割成输入和输出对,用以训练我们的网络。

最后,在我们开始构建模型之前,我们必须从我们的语料库和数据对中移除稀有单词。

移除稀有单词

正如先前提到的,包括数据集中仅出现几次的单词会增加模型的维度,增加模型的复杂性以及训练模型的时间。因此,最好将它们从训练数据中移除,以保持我们的模型尽可能简洁和高效。

您可能还记得我们在词汇表中构建了一个trim函数,它允许我们从词汇表中删除不常见的单词。我们现在可以创建一个函数来删除这些稀有单词,并调用词汇表中的trim方法作为我们的第一步。您将看到这将从我们的词汇表中删除大部分单词,表明大多数词汇中的单词出现不频繁。这是预期的,因为任何语言模型中的单词分布将遵循长尾分布。我们将使用以下步骤来删除这些单词:

  1. 我们首先计算我们模型中将保留的单词百分比:

    def removeRareWords(voc, all_pairs, minimum):
        voc.trim(minimum)
    

    这导致以下输出:

    图 8.9 – 需保留的单词百分比

    图 8.9 – 需保留的单词百分比

  2. 在同一个函数中,我们循环遍历输入和输出句子中的所有单词。如果对于给定的句对,输入或输出句子中有一个单词不在我们的新修剪语料库中,我们将删除这个句对。我们打印输出并看到,尽管我们删除了超过一半的词汇,但我们只删除了大约 17% 的训练句对。这再次反映了我们的单词语料库如何分布在个别训练句对上:

    pairs_to_keep = []
    for p in all_pairs:
        keep = True
        for word in p[0].split(' '):
            if word not in voc.word2index:
                keep = False
                break
        for word in p[1].split(' '):
            if word not in voc.word2index:
                keep = False
                break
        if keep:
            pairs_to_keep.append(p)
    print("Trimmed from {} pairs to {}, {:.2%} of total".\
           format(len(all_pairs), len(pairs_to_keep),
                  len(pairs_to_keep)/ len(all_pairs)))
    return pairs_to_keep
    minimum_count = 3
    pairs = removeRareWords(voc, pairs, minimum_count)
    

    这导致以下输出:

图 8.10 – 构建数据集后的最终值

图 8.10 – 构建数据集后的最终值

现在我们有了最终的数据集,我们需要构建一些函数,将我们的数据集转换为我们可以传递给模型的张量批次。

将句子对转换为张量

我们知道,我们的模型不会接受原始文本作为输入,而是句子的张量表示。我们也不会逐句处理,而是分批次处理。为此,我们需要将输入和输出句子都转换为张量,张量的宽度表示我们希望训练的批次大小:

  1. 我们首先创建了几个辅助函数,用于将我们的句对转换为张量。我们首先创建了一个indexFromSentence函数,该函数从词汇表中获取句子中每个单词的索引,并在末尾添加一个 EOS 标记:

    def indexFromSentence(voc, sentence):
        return [voc.word2index[word] for word in\
                sent.split(' ')] + [EOS_token]
    
  2. 其次,我们创建一个zeroPad函数,它用零填充任何张量,使张量中的所有句子的长度有效相同:

    def zeroPad(l, fillvalue=PAD_token):
        return list(itertools.zip_longest(*l,\
                    fillvalue=fillvalue))
    
  3. 然后,为了生成我们的输入张量,我们应用这两个函数。首先,我们获取我们输入句子的索引,然后应用填充,然后将输出转换为LongTensor。我们还将获取每个输入句子的长度,并将其作为张量输出:

    def inputVar(l, voc):
        indexes_batch = [indexFromSentence(voc, sentence)\
                         for sentence in l]
        padList = zeroPad(indexes_batch)
        padTensor = torch.LongTensor(padList)
        lengths = torch.tensor([len(indexes) for indexes\                            in indexes_batch])
        return padTensor, lengths
    
  4. 在我们的网络中,我们通常应忽略我们的填充标记。我们不希望在这些填充标记上训练我们的模型,因此我们创建一个布尔掩码来忽略这些标记。为此,我们使用一个 getMask 函数,将其应用于我们的输出张量。这只是简单地在输出包含单词时返回 1,在包含填充标记时返回 0

    def getMask(l, value=PAD_token):
        m = []
        for i, seq in enumerate(l):
            m.append([])
            for token in seq:
                if token == PAD_token:
                    m[i].append(0)
                else:
                    m[i].append(1)
        return m
    
  5. 然后我们将其应用于我们的 outputVar 函数。这与 inputVar 函数相同,不同之处在于,除了索引化的输出张量和长度张量外,我们还返回我们输出张量的布尔掩码。这个布尔掩码在输出张量中有单词时返回 True,在存在填充标记时返回 False。我们还返回输出张量中句子的最大长度:

    def outputVar(l, voc):
        indexes_batch = [indexFromSentence(voc, sentence) 
                         for sentence in l]
        max_target_len = max([len(indexes) for indexes in
                              indexes_batch])
        padList = zeroPad(indexes_batch)
        mask = torch.BoolTensor(getMask(padList))
        padTensor = torch.LongTensor(padList)
        return padTensor, mask, max_target_len
    
  6. 最后,为了同时创建我们的输入和输出批次,我们遍历批次中的对,并为每对使用我们之前创建的函数创建输入和输出张量。然后返回所有必要的变量:

    def batch2Train(voc, batch):
        batch.sort(key=lambda x: len(x[0].split(" ")),\
                   reverse=True)
    
        input_batch = []
        output_batch = []
    
        for p in batch:
            input_batch.append(p[0])
            output_batch.append(p[1])
    
        inp, lengths = inputVar(input_batch, voc)
        output, mask, max_target_len = outputVar(output_                                   batch, voc)
    
        return inp, lengths, output, mask, max_target_len
    
  7. 此函数应该是我们将训练对转换为用于训练模型的张量所需的全部内容。我们可以通过在我们的数据的随机选择上执行我们的 batch2Train 函数的单次迭代来验证其是否工作正确。我们将我们的批量大小设为 5 并运行一次:

    test_batch_size = 5
    batches = batch2Train(voc, [random.choice(pairs) for _\                            in range(test_batch_size)])
    input_variable, lengths, target_variable, mask, max_target_len = batches
    

    在这里,我们可以验证我们的输入张量是否已正确创建。注意句子如何以填充(0 标记)结尾,其中句子长度小于张量的最大长度(在本例中为 9)。张量的宽度也与批量大小相对应(在本例中为 5):

图 8.11 – 输入张量

图 8.11 – 输入张量

我们还可以验证相应的输出数据和掩码。注意掩码中的 False 值如何与输出张量中的填充标记(零)重叠:

图 8.12 – 目标张量和掩码张量

图 8.12 – 目标张量和掩码张量

现在我们已经获取、清理和转换了我们的数据,我们准备开始训练基于注意力机制的模型,这将成为我们聊天机器人的基础。

构建模型

与我们其他的序列到序列模型一样,我们首先通过创建我们的编码器来开始。这将把我们输入句子的初始张量表示转换为隐藏状态。

构建编码器

现在我们将通过以下步骤创建编码器:

  1. 与我们所有的 PyTorch 模型一样,我们首先创建一个继承自 nn.ModuleEncoder 类。这里的所有元素应该与之前章节中使用的元素看起来很熟悉:

    class EncoderRNN(nn.Module):
        def __init__(self, hidden_size, embedding,\
                     n_layers=1, dropout=0):
            super(EncoderRNN, self).__init__()
            self.n_layers = n_layers
            self.hidden_size = hidden_size
            self.embedding = embedding
    

    接下来,我们创建我们的循环神经网络RNN)模块。在这个聊天机器人中,我们将使用门控循环单元GRU)而不是我们之前看到的长短期记忆LSTM)模型。GRUs 比 LSTMs 稍微简单一些,尽管它们仍然通过 RNN 控制信息的流动,但它们不像 LSTMs 那样有单独的遗忘和更新门。我们在这种情况下使用 GRUs 有几个主要原因:

    a) GRUs 已被证明在计算效率上更高,因为要学习的参数更少。这意味着我们的模型将比使用 LSTMs 更快地训练。

    b) GRUs 已被证明在短数据序列上具有与 LSTMs 类似的性能水平。当学习较长的数据序列时,LSTMs 更有用。在这种情况下,我们仅使用包含 10 个单词或更少的输入句子,因此 GRUs 应产生类似的结果。

    c) GRUs 已被证明在从小数据集中学习方面比 LSTMs 更有效。由于我们的训练数据相对于我们试图学习的任务的复杂性很小,我们应该选择使用 GRUs。

  2. 现在,我们定义我们的 GRU,考虑到我们输入的大小、层数,以及是否应该实施 dropout:

    self.gru = nn.GRU(hidden_size, hidden_size, n_layers,
                      dropout=(0 if n_layers == 1 else\
                               dropout), bidirectional=True)
    

    注意这里我们如何将双向性实现到我们的模型中。您会从前几章中回忆起,双向 RNN 允许我们从一个句子中顺序地向前移动,同时也可以顺序地向后移动。这使我们能够更好地捕捉每个单词在句子中相对于前后出现的单词的上下文。我们 GRU 中的双向性意味着我们的编码器看起来像这样:

    图 8.13 – 编码器布局

    图 8.13 – 编码器布局

    我们在输入句子中维护两个隐藏状态,以及每一步的输出。

  3. 接下来,我们需要为我们的编码器创建一个前向传播。我们通过首先对我们的输入句子进行嵌入,然后在我们的嵌入上使用pack_padded_sequence函数来完成这一操作。该函数“打包”我们的填充序列,使得所有的输入都具有相同的长度。然后,我们通过我们的 GRU 传递打包的序列来执行前向传播:

    def forward(self, input_seq, input_lengths, hidden=None):
        embedded = self.embedding(input_seq)
        packed = nn.utils.rnn.pack_padded_sequence(embedded,
                                          input_lengths)
        outputs, hidden = self.gru(packed, hidden)
    
  4. 在此之后,我们取消我们的填充并汇总 GRU 的输出。然后,我们可以返回这个总和输出以及我们的最终隐藏状态,以完成我们的前向传播:

    outputs, _ = nn.utils.rnn.pad_packed_sequence(outputs)
    outputs = outputs[:, :, :self.hidden_size] + a \
              outputs[:, : ,self.hidden_size:]
    return outputs, hidden
    

现在,我们将继续在下一节中创建一个注意模块。

构建注意模块

接下来,我们需要构建我们的注意模块,我们将应用它到我们的编码器上,以便我们可以从编码器输出的相关部分学习。我们将按以下方式执行:

  1. 首先,创建一个注意模型的类:

    class Attn(nn.Module):
        def __init__(self, hidden_size):
            super(Attn, self).__init__()
            self.hidden_size = hidden_size
    
  2. 然后,在这个类中创建dot_score函数。该函数简单地计算我们的编码器输出与我们的隐藏状态输出的点积。虽然有其他将这两个张量转换为单一表示的方法,但使用点积是其中最简单的之一:

    def dot_score(self, hidden, encoder_output):
        return torch.sum(hidden * encoder_output, dim=2)
    
  3. 然后,我们在我们的前向传播中使用此函数。首先,基于dot_score方法计算注意力权重/能量,然后转置结果,并返回经过 softmax 转换的概率分数:

    def forward(self, hidden, encoder_outputs):
        attn_energies = self.dot_score(hidden, \
                                       encoder_outputs)
        attn_energies = attn_energies.t()
        return F.softmax(attn_energies, dim=1).unsqueeze(1)
    

接下来,我们可以在我们的解码器中使用这个注意力模块来创建一个关注注意力的解码器。

构建解码器。

现在我们将构建解码器,如下所示:

  1. 我们首先创建我们的DecoderRNN类,继承自nn.Module并定义初始化参数:

    class DecoderRNN(nn.Module):
        def __init__(self, embedding, hidden_size, \
                     output_size, n_layers=1, dropout=0.1):
            super(DecoderRNN, self).__init__()
            self.hidden_size = hidden_size
            self.output_size = output_size
            self.n_layers = n_layers
            self.dropout = dropout
    
  2. 然后,在此模块中创建我们的层。我们将创建一个嵌入层和一个相应的丢弃层。我们再次使用 GRU 作为我们的解码器;但是,这次我们不需要使我们的 GRU 层双向,因为我们将按顺序解码我们的编码器输出。我们还将创建两个线性层——一个常规层用于计算输出,一个可用于连接的层。此层的宽度是常规隐藏层的两倍,因为它将用于两个长度为hidden_size的连接向量。我们还从上一节初始化我们注意力模块的一个实例,以便能够在我们的Decoder类中使用它:

    self.embedding = embedding
    self.embedding_dropout = nn.Dropout(dropout)
    self.gru = nn.GRU(hidden_size, hidden_size, n_layers,  dropout=(0 if n_layers == 1 else dropout))
    self.concat = nn.Linear(2 * hidden_size, hidden_size)
    self.out = nn.Linear(hidden_size, output_size)
    self.attn = Attn(hidden_size)
    
  3. 在定义了所有的层之后,我们需要为解码器创建一个前向传播。注意前向传播将逐步(单词)使用。我们首先获取当前输入单词的嵌入,并通过 GRU 层进行前向传播以获取我们的输出和隐藏状态:

    def forward(self, input_step, last_hidden, encoder_outputs):
        embedded = self.embedding(input_step)
        embedded = self.embedding_dropout(embedded)
        rnn_output, hidden = self.gru(embedded, last_hidden)
    
  4. 接下来,我们使用注意力模块从 GRU 输出中获取注意力权重。然后将这些权重与编码器输出相乘,有效地给出我们的注意力权重和编码器输出的加权和:

    attn_weights = self.attn(rnn_output, encoder_outputs)
    context = attn_weights.bmm(encoder_outputs.transpose(0,
                                                         1))
    
  5. 然后,我们将我们的加权上下文向量与我们的 GRU 输出连接起来,并应用一个tanh函数来获得我们的最终连接输出:

    rnn_output = rnn_output.squeeze(0)
    context = context.squeeze(1)
    concat_input = torch.cat((rnn_output, context), 1)
    concat_output = torch.tanh(self.concat(concat_input))
    
  6. 在我们解码器的最后一步中,我们简单地使用这个最终连接的输出来预测下一个单词并应用softmax函数。前向传播最终返回此输出,以及最终的隐藏状态。这个前向传播将迭代进行,下一个前向传播使用句子中的下一个单词和这个新的隐藏状态:

    output = self.out(concat_output)
    output = F.softmax(output, dim=1)
    return output, hidden
    

现在我们已经定义了我们的模型,我们准备定义训练过程。

定义训练过程。

训练过程的第一步是为我们的模型定义损失度量。由于我们的输入张量可能包含填充序列,因为我们的输入句子长度各不相同,我们不能简单地计算真实输出和预测输出张量之间的差异。为此,我们将定义一个损失函数,该函数在我们的输出上应用布尔掩码,并仅计算非填充标记的损失:

  1. 在以下函数中,我们可以看到我们计算整个输出张量的交叉熵损失。然而,为了得到总损失,我们只对布尔掩码选定的张量元素进行平均:

    def NLLMaskLoss(inp, target, mask):
        TotalN = mask.sum()
        CELoss = -torch.log(torch.gather(inp, 1,\                        target.view(-1, 1)).squeeze(1))
        loss = CELoss.masked_select(mask).mean()
        loss = loss.to(device)
        return loss, TotalN.item()
    
  2. 在大部分训练过程中,我们需要两个主要函数——一个函数train(),用于对训练数据的单个批次进行训练,另一个函数trainIters(),用于迭代整个数据集并在每个单独的批次上调用train()。我们首先定义train()函数以便在单个数据批次上进行训练。创建train()函数,然后将梯度置零,定义设备选项,并初始化变量:

    def train(input_variable, lengths, target_variable,\
              mask, max_target_len, encoder, decoder,\
              embedding, encoder_optimizer,\
              decoder_optimizer, batch_size, clip,\
              max_length=max_length):
        encoder_optimizer.zero_grad()
        decoder_optimizer.zero_grad()
        input_variable = input_variable.to(device)
        lengths = lengths.to(device)
        target_variable = target_variable.to(device)
        mask = mask.to(device)
        loss = 0
        print_losses = []
        n_totals = 0
    
  3. 然后,执行输入和序列长度的前向传递,通过编码器获取输出和隐藏状态:

    encoder_outputs, encoder_hidden = encoder(input_variable, lengths)
    
  4. 接下来,我们创建初始解码器输入,每个句子都以 SOS 标记开头。然后,我们将解码器的初始隐藏状态设置为与编码器相等:

    decoder_input = torch.LongTensor([[SOS_token for _ in \
                                       range(batch_size)]])
    decoder_input = decoder_input.to(device)
    decoder_hidden = encoder_hidden[:decoder.n_layers]
    

    接下来,我们实现教师强制。如果你还记得上一章节,教师强制在生成输出序列时,我们使用真实的前一个输出标记,而不是预测的前一个输出标记来生成下一个单词。使用教师强制可以帮助我们的模型更快地收敛;然而,我们必须小心,不要将教师强制比率设置得太高,否则我们的模型将过于依赖教师强制,无法独立学习生成正确的输出。

  5. 确定当前步骤是否应该使用教师强制:

    use_TF = True if random.random() < teacher_forcing_ratio else False
    
  6. 然后,如果我们确实需要实现教师强制,运行以下代码。我们通过解码器传递每个序列批次以获得输出。然后,我们将下一个输入设置为真实输出(target)。最后,我们使用我们的损失函数计算并累积损失,并将其打印到控制台:

    for t in range(max_target_len):
    decoder_output, decoder_hidden = decoder(
      decoder_input, decoder_hidden, encoder_outputs)
    decoder_input = target_variable[t].view(1, -1)
    mask_loss, nTotal = NLLMaskLoss(decoder_output, \
         target_variable[t], mask[t])
    loss += mask_loss
    print_losses.append(mask_loss.item() * nTotal)
    n_totals += nTotal
    
  7. 如果在给定批次上不实现教师强制,该过程几乎相同。但是,我们不是使用真实输出作为序列中的下一个输入,而是使用模型生成的输出:

    _, topi = decoder_output.topk(1)
    decoder_input = torch.LongTensor([[topi[i][0] for i in \
                                       range(batch_size)]])
    decoder_input = decoder_input.to(device)
    
  8. 最后,和我们所有的模型一样,最后的步骤是执行反向传播,实施梯度裁剪,并且通过我们的编码器和解码器优化器来更新权重,使用梯度下降。记住,我们剪切梯度以防止消失/爆炸梯度问题,这在前几章已经讨论过。最后,我们的训练步骤返回我们的平均损失:

    loss.backward()
    _ = nn.utils.clip_grad_norm_(encoder.parameters(), clip)
    _ = nn.utils.clip_grad_norm_(decoder.parameters(), clip)
    encoder_optimizer.step()
    decoder_optimizer.step()
    return sum(print_losses) / n_totals
    
  9. 接下来,如前所述,我们需要创建trainIters()函数,它反复调用我们的训练函数来处理不同的输入数据批次。我们首先使用我们之前创建的batch2Train函数将数据分成批次:

    def trainIters(model_name, voc, pairs, encoder, decoder,\
                   encoder_optimizer, decoder_optimizer,\
                   embedding, encoder_n_layers, \
                   decoder_n_layers, save_dir, n_iteration,\
                   batch_size, print_every, save_every, \
                   clip, corpus_name, loadFilename):
        training_batches = [batch2Train(voc,\
                           [random.choice(pairs) for _ in\
                            range(batch_size)]) for _ in\
                            range(n_iteration)]
    
  10. 然后,我们创建一些变量,这些变量将允许我们计算迭代次数并跟踪每个时代的总损失:

    print('Starting ...')
    start_iteration = 1
    print_loss = 0
    if loadFilename:
        start_iteration = checkpoint['iteration'] + 1
    
  11. 接下来,我们定义我们的训练循环。对于每个迭代,我们从我们的批次列表中获取一个训练批次。然后,我们从批次中提取相关字段,并使用这些参数运行单个训练迭代。最后,我们将这一批次的损失添加到我们的总损失中:

    print("Beginning Training...")
    for iteration in range(start_iteration, n_iteration + 1):
        training_batch = training_batches[iteration - 1]
        input_variable, lengths, target_variable, mask, \
              max_target_len = training_batch
        loss = train(input_variable, lengths,\
                     target_variable, mask, max_target_len,\
                     encoder, decoder, embedding, \
                     encoder_optimizer, decoder_optimizer,\
                     batch_size, clip)
        print_loss += loss
    
  12. 在每次迭代中,我们还确保打印我们目前的进度,跟踪我们已经完成了多少次迭代以及每个时代的损失是多少:

    if iteration % print_every == 0:
        print_loss_avg = print_loss / print_every
        print("Iteration: {}; Percent done: {:.1f}%;\
        Mean loss: {:.4f}".format(iteration,
                              iteration / n_iteration \
                              * 100, print_loss_avg))
        print_loss = 0
    
  13. 为了完成,我们还需要在每几个时代之后保存我们的模型状态。这样可以让我们重新查看我们训练过的任何历史模型;例如,如果我们的模型开始过拟合,我们可以回到之前的迭代:

    if (iteration % save_every == 0):
        directory = os.path.join(save_dir, model_name,\
                                 corpus_name, '{}-{}_{}'.\
                                 format(encoder_n_layers,\
                                 decoder_n_layers, \
                                 hidden_size))
                if not os.path.exists(directory):
                    os.makedirs(directory)
                torch.save({
                    'iteration': iteration,
                    'en': encoder.state_dict(),
                    'de': decoder.state_dict(),
                    'en_opt': encoder_optimizer.state_dict(),
                    'de_opt': decoder_optimizer.state_dict(),
                    'loss': loss,
                    'voc_dict': voc.__dict__,
                    'embedding': embedding.state_dict()
                }, os.path.join(directory, '{}_{}.tar'.format(iteration, 'checkpoint')))
    

现在我们已经完成了所有必要的步骤来开始训练我们的模型,我们需要创建函数来允许我们评估模型的性能。

定义评估过程

评估聊天机器人与评估其他序列到序列模型略有不同。在我们的文本翻译任务中,一个英文句子将直接翻译成德文。虽然可能有多个正确的翻译,但大部分情况下,从一种语言到另一种语言的翻译只有一个正确的。

对于聊天机器人,有多个不同的有效输出。以下是与聊天机器人对话中的三行内容:

输入:"Hello"

输出:"Hello"

输入:"Hello"

输出:"Hello. How are you?"

输入:"*Hello"

输出:"What do you want?"

在这里,我们有三个不同的响应,每一个都同样有效作为响应。因此,在与聊天机器人对话的每个阶段,不会有单一的“正确”响应。因此,评估要困难得多。测试聊天机器人是否产生有效输出的最直观方法是与其对话!这意味着我们需要设置我们的聊天机器人,使其能够与我们进行对话,以确定其是否工作良好:

  1. 我们将从定义一个类开始,这个类将允许我们解码编码的输入并生成文本。我们通过使用所谓的GreedyEncoder()类与我们预训练的编码器和解码器来做到这一点:

    class GreedySearchDecoder(nn.Module):
        def __init__(self, encoder, decoder):
            super(GreedySearchDecoder, self).__init__()
            self.encoder = encoder
            self.decoder = decoder
    
  2. 接下来,定义我们的解码器的前向传播。我们通过我们的编码器传递输入以获取我们编码器的输出和隐藏状态。我们将编码器的最终隐藏层作为解码器的第一个隐藏输入:

    def forward(self, input_seq, input_length, max_length):
        encoder_outputs, encoder_hidden = \
                        self.encoder(input_seq, input_length)
        decoder_hidden = encoder_hidden[:decoder.n_layers]
    
  3. 然后,使用 SOS 标记创建解码器输入,并初始化张量以附加解码的单词(初始化为单个零值):

    decoder_input = torch.ones(1, 1, device=device, dtype=torch.long) * SOS_token
    all_tokens = torch.zeros([0], device=device, dtype=torch.long)
    all_scores = torch.zeros([0], device=device)
    
  4. 然后,逐个解码序列中的单词。我们通过编码器进行前向传播,并添加一个max函数来获取最高得分的预测单词及其分数,然后将其附加到all_tokensall_scores变量中。最后,我们取这个预测的标记并将其用作我们的解码器的下一个输入。在整个序列迭代完毕后,我们返回完整的预测句子:

    for _ in range(max_length):
        decoder_output, decoder_hidden = self.decoder\
            (decoder_input, decoder_hidden, encoder_outputs)
        decoder_scores, decoder_input = \
             torch.max (decoder_output, dim=1)
        all_tokens = torch.cat((all_tokens, decoder_input),\
                                dim=0)
        all_scores = torch.cat((all_scores, decoder_scores),\
                                dim=0)
        decoder_input = torch.unsqueeze(decoder_input, 0)
    return all_tokens, all_scores
    

    所有的部分都开始串联在一起了。我们已经定义了训练和评估函数,所以最后一步是编写一个实际将我们的输入作为文本、传递给我们的模型并从模型获取响应的函数。这将是我们聊天机器人的“接口”,在这里我们实际上与我们的聊天机器人对话。

  5. 我们首先定义一个evaluate()函数,该函数接受我们的输入函数并返回预测的输出单词。我们开始通过我们的词汇表将输入句子转换为索引。然后,我们获得每个这些句子的长度的张量,并将其转置:

    def evaluate(encoder, decoder, searcher, voc, sentence,\
                 max_length=max_length):
        indices = [indexFromSentence(voc, sentence)]
        lengths = torch.tensor([len(indexes) for indexes \
                                in indices])
        input_batch = torch.LongTensor(indices).transpose(0, 1)
    
  6. 然后,我们将我们的长度和输入张量分配给相关设备。接下来,通过搜索器(GreedySearchDecoder)运行输入,以获取预测输出的单词索引。最后,我们将这些单词索引转换回单词标记,然后将它们作为函数输出返回:

    input_batch = input_batch.to(device)
    lengths = lengths.to(device)
    tokens, scores = searcher(input_batch, lengths, \
                              max_length)
    decoded_words = [voc.index2word[token.item()] for \
                     token in tokens]
    return decoded_words
    
  7. 最后,我们创建一个runchatbot函数,它作为与我们的聊天机器人的接口。这个函数接受人类输入并打印聊天机器人的响应。我们将此函数创建为一个while循环,直到我们终止函数或在输入中键入quit为止:

    def runchatbot(encoder, decoder, searcher, voc):
        input_sentence = ''
        while(1):
            try:
                input_sentence = input('> ')
                if input_sentence == 'quit': break
    
  8. 然后,我们获取输入的内容并对其进行标准化,然后将标准化的输入传递给我们的evaluate()函数,该函数从聊天机器人返回预测的单词:

    input_sentence = cleanString(input_sentence)
    output_words = evaluate(encoder, decoder, searcher,\
                            voc, input_sentence)
    
  9. 最后,我们获取这些输出单词并格式化它们,在打印聊天机器人的响应之前忽略 EOS 和填充标记。因为这是一个while循环,这允许我们无限期地与聊天机器人继续对话:

    output_words[:] = [x for x in output_words if \
                       not (x == 'EOS' or x == 'PAD')]
    print('Response:', ' '.join(output_words))
    

现在我们已经构建了训练、评估和使用我们的聊天机器人所需的所有函数,是时候开始最后一步了——训练我们的模型并与我们训练过的聊天机器人交流了。

训练模型

由于我们已经定义了所有必要的函数,训练模型只是初始化我们的超参数并调用我们的训练函数的情况:

  1. 首先我们初始化我们的超参数。虽然这些只是建议的超参数,但我们的模型已经被设置成可以适应任何传递给它们的超参数。通过尝试不同的超参数来看哪些超参数会导致最佳的模型配置是一个良好的实践。在这里,您可以尝试增加编码器和解码器的层数,增加或减少隐藏层的大小,或增加批处理大小。所有这些超参数都会影响您的模型学习效果,以及训练模型所需的时间:

    model_name = 'chatbot_model'
    hidden_size = 500
    encoder_n_layers = 2
    decoder_n_layers = 2
    dropout = 0.15
    batch_size = 64
    
  2. 之后,我们可以加载我们的检查点。如果我们以前训练过一个模型,我们可以加载以前迭代的检查点和模型状态。这样可以避免每次重新训练模型:

    loadFilename = None
    checkpoint_iter = 4000
    if loadFilename:
        checkpoint = torch.load(loadFilename)
        encoder_sd = checkpoint['en']
        decoder_sd = checkpoint['de']
        encoder_optimizer_sd = checkpoint['en_opt']
        decoder_optimizer_sd = checkpoint['de_opt']
        embedding_sd = checkpoint['embedding']
        voc.__dict__ = checkpoint['voc_dict']
    
  3. 之后,我们可以开始构建我们的模型。我们首先从词汇表中加载我们的嵌入。如果我们已经训练了一个模型,我们可以加载训练好的嵌入层:

    embedding = nn.Embedding(voc.num_words, hidden_size)
    if loadFilename:
        embedding.load_state_dict(embedding_sd)
    
  4. 接着我们对编码器和解码器进行同样的操作,使用定义好的超参数创建模型实例。如果我们已经训练过一个模型,我们只需加载训练好的模型状态到我们的模型中:

    encoder = EncoderRNN(hidden_size, embedding, \
                         encoder_n_layers, dropout)
    decoder = DecoderRNN(embedding, hidden_size, \ 
                         voc.num_words, decoder_n_layers,
                         dropout)
    if loadFilename:
        encoder.load_state_dict(encoder_sd)
        decoder.load_state_dict(decoder_sd)
    
  5. 最后但同样重要的是,我们为每个模型指定一个设备进行训练。请记住,如果您希望使用 GPU 进行训练,这是一个至关重要的步骤:

    encoder = encoder.to(device)
    decoder = decoder.to(device)
    print('Models built and ready to go!')
    

    如果一切工作正常,而且您的模型创建没有错误,您应该会看到以下内容:

    图 8.14 – 成功的输出

    图 8.14 – 成功的输出

    现在我们已经创建了编码器和解码器的实例,我们准备开始训练它们。

    我们首先初始化一些训练超参数。与我们的模型超参数一样,这些可以调整以影响训练时间和我们模型的学习方式。Clip 控制梯度裁剪,而 teacher forcing 控制我们在模型中使用 teacher forcing 的频率。请注意,我们使用了一个 teacher forcing 比率为 1,以便我们始终使用 teacher forcing。降低 teacher forcing 比率会导致我们的模型收敛时间更长;然而,从长远来看,这可能会帮助我们的模型更好地自动生成正确的句子。

  6. 我们还需要定义我们模型的学习率和解码器的学习率比。您会发现,当解码器在梯度下降过程中执行较大的参数更新时,您的模型表现会更好。因此,我们引入了一个解码器学习率比来将一个乘数应用于学习率,使得解码器的学习率比编码器的大。我们还定义了我们的模型打印和保存结果的频率,以及我们希望我们的模型运行多少个 epochs:

    save_dir = './'
    clip = 50.0
    teacher_forcing_ratio = 1.0
    learning_rate = 0.0001
    decoder_learning_ratio = 5.0
    epochs = 4000
    print_every = 1
    save_every = 500
    
  7. 接下来,像往常一样,在 PyTorch 中训练模型时,我们将模型切换到训练模式,以便更新参数:

    encoder.train()
    decoder.train()
    
  8. 接下来,我们为编码器和解码器创建优化器。我们将这些初始化为 Adam 优化器,但其他优化器同样有效。尝试不同的优化器可能会产生不同水平的模型性能。如果以前已经训练过一个模型,也可以在需要时加载优化器状态:

    print('Building optimizers ...')
    encoder_optimizer = optim.Adam(encoder.parameters(), \
                                   lr=learning_rate)
    decoder_optimizer = optim.Adam(decoder.parameters(), 
                   lr=learning_rate * decoder_learning_ratio)
    if loadFilename:
        encoder_optimizer.load_state_dict(\
                                       encoder_optimizer_sd)
        decoder_optimizer.load_state_dict(\
                                       decoder_optimizer_sd)
    
  9. 在运行训练之前的最后一步是确保 CUDA 已配置好以便进行 GPU 训练。为此,我们简单地循环遍历编码器和解码器的优化器状态,并在所有状态上启用 CUDA:

    for state in encoder_optimizer.state.values():
        for k, v in state.items():
            if isinstance(v, torch.Tensor):
                state[k] = v.cuda()
    for state in decoder_optimizer.state.values():
        for k, v in state.items():
            if isinstance(v, torch.Tensor):
                state[k] = v.cuda()
    
  10. 最后,我们准备训练我们的模型。这可以通过简单调用trainIters函数并传入所有必要的参数来完成:

    print("Starting Training!")
    trainIters(model_name, voc, pairs, encoder, decoder,\
               encoder_optimizer, decoder_optimizer, \
               embedding, encoder_n_layers, \
               decoder_n_layers, save_dir, epochs, \
                batch_size,print_every, save_every, \
                clip, corpus_name, loadFilename)
    

    如果一切正常,您将看到以下输出开始打印:

图 8.15 – 模型训练

图 8.15 – 模型训练

您的模型现在正在训练!根据多个因素(例如您为模型设置了多少个 epoch 以及是否使用 GPU 等),您的模型可能需要一些时间来训练。训练完成后,您将看到以下输出。如果一切正常,您的模型平均损失将显著低于训练开始时的水平,表明您的模型已经学到了一些有用的东西:

图 8.16 – 4,000 次迭代后的平均损失

图 8.16 – 4,000 次迭代后的平均损失

现在我们的模型已经训练完成,我们可以开始评估过程并开始使用我们的聊天机器人。

评估模型

现在我们成功创建并训练了我们的模型,是时候评估其性能了。我们将通过以下步骤来进行:

  1. 开始评估之前,我们首先将模型切换到评估模式。与所有其他 PyTorch 模型一样,这是为了防止在评估过程中发生任何进一步的参数更新:

    encoder.eval()
    decoder.eval()
    
  2. 我们还初始化了GreedySearchDecoder的一个实例,以便能够执行评估并将预测输出作为文本返回:

    searcher = GreedySearchDecoder(encoder, decoder)
    
  3. 最后,要运行聊天机器人,我们只需调用runchatbot函数,传入encoderdecodersearchervoc

    runchatbot(encoder, decoder, searcher, voc)
    

    这样做将打开一个输入提示,让您输入文本:

图 8.17 – 输入文本的用户界面元素

图 8.17 – 输入文本的用户界面元素

在此处输入您的文本并按Enter将您的输入发送给聊天机器人。使用我们训练过的模型,我们的聊天机器人将创建一个响应并将其打印到控制台:

图 8.18 – 聊天机器人的输出

图 8.18 – 聊天机器人的输出

您可以重复此过程多次,与聊天机器人进行“对话”。在简单的对话水平上,聊天机器人可以产生令人惊讶的良好结果:

图 8.19 – 聊天机器人的输出

图 8.19 – 聊天机器人的输出

然而,一旦对话变得更复杂,很明显聊天机器人无法达到与人类相同水平的对话能力:

图 8.20 – 聊天机器人的局限性

图 8.20 – 聊天机器人的局限性

在许多情况下,您的聊天机器人的回复可能是无意义的:

图 8.21 – 错误输出

图 8.21 – 错误输出

显然,我们创建了一个能够进行简单来回对话的聊天机器人。但在我们的聊天机器人能够通过图灵测试并使我们相信我们在与人类交谈之前,我们还有很长的路要走。然而,考虑到我们的模型训练的相对较小的数据语料库,我们在序列到序列模型中使用的注意力显示出了相当不错的结果,展示了这些架构有多么的多才多艺。

虽然最好的聊天机器人是在数十亿数据点的庞大语料库上训练的,但我们的模型在相对较小的数据集上证明了相当有效。然而,基本的注意力网络不再是最先进的,在下一章中,我们将讨论一些用于自然语言处理学习的最新发展,这些发展导致了更加逼真的聊天机器人。

摘要

在本章中,我们应用了我们从递归模型和序列到序列模型中学到的所有知识,并结合注意力机制构建了一个完全工作的聊天机器人。虽然与我们的聊天机器人交谈不太可能与真人交谈无异,但通过一个相当大的数据集,我们可能希望实现一个更加逼真的聊天机器人。

尽管在 2017 年,带有注意力的序列到序列模型是最先进的,但机器学习是一个快速发展的领域,自那时以来,对这些模型进行了多次改进。在最后一章中,我们将更详细地讨论一些这些最先进的模型,并涵盖用于自然语言处理的其他当代技术,其中许多仍在开发中。

第九章:未来之路

机器学习领域正在迅速扩展,几乎每年都有新的发现。NLP 机器学习领域也不例外,机器学习模型在 NLP 任务上的表现不断增长。

到目前为止,在本书中,我们已经讨论了许多机器学习方法论,使我们能够构建模型来执行诸如分类、翻译和通过聊天机器人逼近对话的 NLP 任务。然而,正如我们迄今所见,我们的模型性能相对于人类来说仍然较差。即使使用我们迄今所研究的技术,包括带有注意力机制的序列到序列网络,我们也不太可能训练出能够匹敌或超越真人的聊天机器人模型。然而,在本章中,我们将看到 NLP 领域的最新进展,这些进展使我们更接近创建与人类难以区分的聊天机器人的目标。

在本章中,我们将探讨几种最先进的自然语言处理(NLP)机器学习模型,并分析导致其优越性能的一些特征。然后,我们将转向研究目前受到广泛研究关注的几个其他 NLP 任务,以及如何利用机器学习技术来解决它们。

本章将涵盖以下主题:

  • 探索最先进的 NLP 机器学习

  • 未来的 NLP 任务

  • 语义角色标注

  • 成分句法分析

  • 文本蕴含

  • 机器理解

探索最先进的 NLP 机器学习

尽管我们在本书中学到的技术是从头开始训练我们自己的机器学习模型的高度有用的方法,但它们远非全球开发的最复杂模型。公司和研究团体不断努力创建在多个 NLP 任务上能够达到最高性能的最先进的机器学习模型。

目前有两个 NLP 模型具有最佳性能,并且可以被认为是最先进的:BERTGPT-2。这两种模型都是通用语言模型的形式。我们将在接下来的章节中详细讨论它们。

BERT

BERT,全称双向编码器表示来自 Transformers,由 Google 于 2018 年开发,被广泛认为是 NLP 领域的领先模型,在自然语言推理和问答任务中取得了领先的性能。幸运的是,这已经作为开源模型发布,因此可以下载并用于您自己的 NLP 任务。

BERT 发布为预训练模型,这意味着用户可以下载并实现 BERT,而无需每次从头开始重新训练模型。预训练模型是在几个语料库上训练的,包括整个维基百科(包含 25 亿词)和另一个包含 8 亿词的书籍语料库。然而,使 BERT 与其他类似模型不同的主要因素是它提供了深度、双向、无监督的语言表示,据显示在自然语言处理任务中提供了更复杂、更详细的表示,从而导致性能提升。

嵌入

虽然传统的嵌入层(如 GLoVe)形成一个单一的词语表示,与句子中词语的含义无关,但双向 BERT 模型试图基于其上下文形成表示。例如,在这两个句子中,词语bat具有两种不同的含义。

“蝙蝠从我的窗户飞过”

“他用球棒击打了棒球”

尽管在这两个句子中,bat是名词,但我们可以辨别出其上下文和含义显然是截然不同的,这取决于其周围的其他词语。有些词语可能在句子中也具有不同的含义,这取决于它们在句子中是名词还是动词:

“她用火柴点火”

“他的糟糕表现意味着他们别无选择,只能解雇他”

利用双向语言模型形成上下文相关的词语表示,这才是使 BERT 成为一流模型的真正原因。对于任何给定的标记,我们通过结合标记、位置和段落嵌入来获得其输入表示:

图 9.1 – BERT 架构

图 9.1 – BERT 架构

然而,理解模型如何得出这些初始的上下文相关的标记嵌入是很重要的。

掩码语言建模

为了创建这种双向语言表示,BERT 使用了两种不同的技术,第一种是掩码语言建模。这种方法通过用掩码令牌替换输入句子中的 15%单词来有效隐藏单词。然后模型试图基于句子中其他词语的上下文来预测掩码词语的真实值。这种预测是双向进行的,以捕获句子的双向上下文:

输入我们[MASK_1]在句子中[MASK_2]隐藏一些

标签MASK_1 = 随机地,MASK_2 = 词语

如果我们的模型能够学习预测正确的上下文相关词语,那么我们距离上下文相关表示更近了一步。

下一个句子预测

BERT 用于学习语言表示的另一种技术是下一个句子预测。在这种方法中,我们的模型接收两个句子,并学习预测第二个句子是否是紧随第一个句子的句子;例如:

句子 A:"我喜欢喝咖啡"

句子 B:"这是我最喜欢的饮料"

是否下一个句子?:True

句子 A:"我喜欢喝咖啡"

Sentence B:"天空是蓝色的"

是否下一个句子?:False

通过传递我们的模型句子对像这样,它可以学习确定任何两个句子是否相关并跟随彼此,或者它们只是两个随机无关的句子。学习这些句子关系在语言模型中是有用的,因为许多自然语言处理相关的任务,如问答,需要模型理解两个句子之间的关系。训练一个模型进行下一个句子预测允许模型识别一对句子之间的某种关系,即使这种关系非常基础。

BERT 使用掩码语言建模和下一个句子预测两种方法进行训练,并最小化两种技术的组合损失函数。通过使用两种不同的训练方法,我们的语言表示足够强大,可以学习句子如何形成和结构化,以及不同句子如何相互关联。

BERT–架构

该模型架构建立在我们在前几章中看到的许多原理之上,使用双向编码提供复杂的语言表示。BERT 有两种不同的变体,每种变体由不同数量的层和注意头组成:

  • BERT Base:12 个 Transformer 块(层),12 个注意头,约 1.1 亿个参数

  • BERT Large:24 个 Transformer 块(层),16 个注意头,约 3.4 亿个参数

虽然 BERT Large 只是 BERT Base 的深层版本,参数更多,我们将专注于 BERT Base 的架构。

BERT 是按照transformer的原理构建的,现在将更详细地解释。

Transformers

该模型架构建立在我们在本书中看到的许多原理之上。到目前为止,您应该熟悉编码器和解码器的概念,其中我们的模型学习一个编码器来形成输入句子的表示,然后学习一个解码器来将这个表示解码为最终输出,无论是分类还是翻译任务:

图 9.2 – Transformer 工作流程

图 9.2 – Transformer 工作流程

然而,我们的 Transformer 在这种方法中增加了另一个复杂性元素,其中 Transformer 实际上有一堆编码器和一堆解码器,每个解码器将最终编码器的输出作为其输入:

图 9.3 – 多编码器的 Transformer 工作流程

图 9.3 – 多编码器的 Transformer 工作流程

在每个编码器层内部,我们找到两个组成部分:一个自注意力层和一个前馈层。自注意力层是首先接收模型输入的层。这一层使得编码器在编码任何接收到的单词时能够检查输入句子中的其他单词,从而使得编码上下文感知。自注意力层的输出传递到前馈层,该层独立应用于每个位置。这可以通过如下图示来说明:

![图 9.4 – 前馈层

图 9.4 – 前馈层

我们的解码器层在结构上几乎与我们的编码器层相同,但它们包含一个额外的注意力层。这个注意力层帮助解码器专注于编码表示的相关部分,类似于我们在序列到序列模型中看到的注意力工作方式:

![图 9.5 – 注意力方法论

图 9.5 – 注意力方法论

我们知道我们的解码器从最终编码器接收输入,因此一个链接的编码器/解码器可能看起来像这样:

![图 9.6 – 链接的编码器/解码器数组

图 9.6 – 链接的编码器/解码器数组

这应该为您提供了关于如何将不同的编码器和解码器堆叠在更大模型中的有用概述。接下来,我们将更详细地研究各个部分。

编码器

Transformers 的独特特性在于单词通过编码器层时是单独处理的,每个位置的每个单词都有自己的路径。虽然自注意力层内部存在一些依赖关系,但在前馈层内部不存在这种依赖。单词的向量是从嵌入层获取的,然后通过自注意力层再通过前馈网络传递:

![图 9.7 – 编码器布局

图 9.7 – 编码器布局

自注意力无疑是编码器中最复杂的组成部分,因此我们将首先更详细地研究它。假设我们有一个由三个单词组成的输入句子;例如,“This is fine”。对于该句子中的每个单词,我们将其表示为来自模型嵌入层的单词向量。然后,我们从这个单词向量中提取三个向量:一个查询向量、一个键向量和一个值向量。这三个向量是通过将我们的单词向量与训练模型时获得的三个不同权重矩阵相乘而获得的。

如果我们称我们输入句子中每个单词的词嵌入为 EthisEisEfine,我们可以这样计算我们的查询、键和值向量:

查询向量

键向量

值向量

现在我们知道如何计算这些向量后,理解它们各自代表的含义就变得很重要。实际上,每一个都是注意力机制中一个概念的抽象。一旦我们看到它们如何计算,这一点就会变得明显。

让我们继续我们的工作示例。我们需要依次考虑输入句子中的每个单词。为了做到这一点,我们计算每个查询/键向量对在我们句子中的得分。这是通过获得每个输入句子中每个单词的查询向量和位置 0 处键向量的点积来完成的。我们重复这个过程,对输入句子中所有其他位置的键向量,因此我们得到了第一个单词"this"的n个得分,其中n是句子的长度:

分数("this")

接下来,我们对每个得分应用 softmax 函数,使得每个得分的值现在在 0 到 1 之间(这有助于防止梯度爆炸,并使梯度下降更有效和容易计算)。然后,我们将每个得分乘以值向量并将它们全部求和以获得一个最终向量,然后将其向前传递到编码器中:

最终向量("this")

然后,我们对输入句子中的所有单词重复这个过程,这样我们就为每个单词获得了一个最终向量,其中包含自注意力的元素,然后将其传递到前馈网络中的编码器。这个自注意力过程意味着我们的编码器知道在输入句子中查找需要的信息的位置。

在这个例子中,我们仅学习了一个权重矩阵来处理我们查询、键和值向量。然而,我们实际上可以为每个元素学习多个不同的矩阵,并同时应用这些矩阵到我们的输入句子,以获得最终的输出。这就是多头注意力,它允许我们执行更复杂的注意力计算,依赖于多个不同的学习模式,而不仅仅是单一的注意力机制。

我们知道 BERT 包含 12 个注意力头,意味着为WqWkWv学习了 12 个不同的权重矩阵。

最后,我们需要一种方法让我们的编码器考虑输入序列中单词的顺序。目前,我们的模型将输入序列中的每个单词视为独立的,但实际上,单词在输入序列中的顺序会对句子的整体意义产生重大影响。为了解决这个问题,我们使用位置编码

为了应用这一方法,我们的模型获取每个输入嵌入并为每个嵌入单独添加一个位置编码向量。这些位置向量由我们的模型学习,遵循特定的模式以帮助确定序列中每个单词的位置。理论上,将这些位置向量添加到我们的初始嵌入中应该会转化为最终向量中的有意义的距离,一旦它们被投影到单独的查询、键和值向量中:

x0 = 原始嵌入

t0 = 位置编码

E0 = 带时间信号的嵌入

x0 + t0 = E0

我们的模型为每个位置学习了不同的位置编码向量(t0、t1 等),然后在这些单词进入编码器之前应用到输入句子中的每个单词上:

图 9.8 – 添加输入到编码器

图 9.8 – 添加输入到编码器

现在我们已经介绍了编码器的主要组件,是时候看看模型的另一面,了解解码器的构建方式了。

解码器

解码器中的组件与编码器中的组件大致相同。然而,与编码器接收原始输入句子不同,我们的转换器中的解码器从编码器的输出中获取输入。

我们的堆叠编码器处理我们的输入句子,最终留下一组注意力向量,KV,这些向量在我们的解码器的编码器-解码器注意力层中使用。这使得解码器能够仅关注输入序列的相关部分:

图 9.9 – 堆叠解码器

图 9.9 – 堆叠解码器

在每个时间步骤中,我们的解码器使用前面生成的单词及 K,V 注意力向量的组合来生成句子中的下一个单词。这个过程迭代重复,直到解码器生成一个 标记,表示它已经完成生成最终输出。在转换器解码器的一个时间步骤中可能如下所示:

图 9.10 – 转换器解码器

图 9.10 – 转换器解码器

值得注意的是,解码器中的自注意层与我们的编码器中找到的自注意层的工作方式略有不同。在解码器内部,自注意层仅关注输出序列中较早的位置。通过将序列中的任何未来位置屏蔽为负无穷大来实现这一点。这意味着当分类发生时,softmax 计算始终导致预测值为 0。

编码器-解码器注意力层的工作方式与我们的编码器内的多头自注意力层相同。然而,主要区别在于它从下面的层创建一个查询矩阵,并从编码器的输出中获取键和值矩阵。

这些编码器和解码器部分构成了我们的 transformer,这也是 BERT 的基础。接下来,我们将看一些 BERT 的应用以及几种在特定任务上表现出色的变体。

BERT 的应用

作为最先进的技术,BERT 当然有许多实际应用。目前,它正在被应用于许多您可能每天都在使用的 Google 产品中,比如 Gmail 中的建议回复和智能撰写(Gmail 根据您当前输入的内容预测您预期的句子),以及 Google 搜索引擎中的自动完成(您输入想要搜索的前几个字符,下拉列表将预测您要搜索的内容)。

正如我们在前一章看到的,聊天机器人是自然语言处理深度学习中最令人印象深刻的应用之一,而 BERT 的应用确实带来了一些非常出色的聊天机器人。事实上,问答是 BERT 擅长的主要任务之一,这主要是因为它是在大量的知识库(如维基百科)上训练的,能够以语法正确的方式回答问题(因为训练时考虑了下一个句子预测)。

我们还没有达到与真人对话无法区分的聊天机器人的阶段,而 BERT 从其知识库中获取信息的能力非常有限。但是,BERT 取得的一些成果是令人鼓舞的,并且考虑到自然语言处理机器学习领域的快速进展,这表明这一可能性可能很快就会成为现实。

目前,由于训练方式的限制,BERT 只能处理非常特定类型的自然语言处理任务。但是,有许多变体的 BERT 经过微调,以在特定任务上表现出更好的性能。这些变体包括但不限于以下几种:

  • roBERTa:这是 Facebook 开发的一种 BERT 变体。去掉了 BERT 的下一个句子预测元素,但通过实施动态掩码来增强了单词掩码策略。

  • xlm/BERT:这个模型也是由 Facebook 开发的,应用了一种双语训练机制,使得 BERT 能够学习不同语言中单词之间的关系。这使得 BERT 在机器翻译任务中表现出色,比基本的序列到序列模型有了显著的提升。

  • distilBERT:这是一个比 BERT 更紧凑的版本,保留了原始模型的 95% 的性能,但是减少了学习参数的数量,从而缩小了模型的总体大小和训练时间。

  • ALBERT:这是 Google 训练的模型,采用了自己独特的训练方法——句子顺序预测。这种 BERT 变体在多个任务上表现优异,目前被认为是比标准 BERT 更先进的技术(这也展示了技术进步的速度是多么快!)。

虽然 BERT 也许是最为人知的,但还有其他一些被认为是最先进的基于 Transformer 的模型。其中一个主要的被视为 BERT 的竞争对手的模型是 GPT-2。

GPT-2

虽然 GPT-2 与 BERT 相似,但在某些细微的方式上有所不同。虽然两种模型都基于先前概述的 Transformer 架构,BERT 使用了一种称为自注意力的注意力形式,而 GPT-2 使用了掩码自注意力。两者之间的另一个细微差异是,GPT-2 被构造成每次只能输出一个标记。

这是因为 GPT-2 在工作方式上本质上是自回归的。这意味着当它生成一个输出(句子中的第一个单词)时,此输出递归地添加到输入中。然后使用此输入预测句子中的下一个单词,并重复此过程,直到生成完整的句子为止。您可以在以下示例中看到这一点:

第 1 步:

输入天空是什么颜色?

输出...

然后我们将预测的输出添加到输入的末尾并重复此步骤:

第 2 步:

输入天空是什么颜色?

输出天空

我们重复此过程,直到生成整个句子:

第 3 步:

输入天空是什么颜色?天空

输出

第 4 步:

输入天空是什么颜色?天空是

输出蓝色

在性能方面,BERT 和 GPT-2 之间的一个关键权衡之一。BERT 是双向训练的,这意味着不可能进行单词级别的生成;然而,GPT-2 不是双向的,因此在预测时只考虑句子中的前面单词,这就是为什么在预测句子中缺失单词时,BERT 优于 GPT-2 的原因。

比较自注意力和掩码自注意力

这种差异也体现在两种不同模型实现注意力的方式上。由于 BERT 是双向的,其注意力机制能够考虑整个输入句子的上下文,并确定在输入句子中确切的位置进行查找:

图 9.11 – 自注意力机制

图 9.11 – 自注意力机制

另一方面,GPT-2 不是双向的,因此掩码自注意力机制只能查看它已经看过的标记,并且不能“向前看”:

图 9.12 – 掩码自注意力机制

图 9.12 – 掩码自注意力机制

GPT-2 – 伦理

当考虑是否将模型发布给公众时,GPT-2 的一个非常有趣的考虑是作者在伦理方面的考虑。

GPT-2 的语言生成能力意味着它能够使用非常少的提示生成非常逼真和语法正确的句子。通过提供仅仅几行输入以确定一个主题,模型能够生成关于这个主题的详细信息段落,无论这些信息是否真实!一个臭名昭著的例子是“独角兽”故事,其中模型接收到一句输入:

输入: "在一个令人震惊的发现中,科学家们发现了一群生活在安第斯山脉一个偏远、以前未曾探索过的山谷中的独角兽。更让研究人员感到惊讶的是,这些独角兽讲起了完美的英语。"

模型输出如下:

输出: 科学家们将这种群体命名为“奥维德的独角兽”,因为它们独特的角。这些四角银白色的独角兽以前对科学界来说是未知的。

现在,近两个世纪之后,这个引发了奇怪现象的谜团终于得以解决。

Jorge Pérez 博士,来自拉巴斯大学的进化生物学家,和几个同伴,当他们在安第斯山脉探索时,发现了一个没有其他动物或人类的小山谷。Pérez 注意到山谷中有一个看起来像是自然泉水的地方,周围有两座岩石和银雪覆盖的山峰。

这只是一个模型从很少的输入生成的更大的九段回复的第一三段。值得注意的是,这些句子都很有逻辑(无论主题有多不可能!),段落之间的衔接流畅,模型能够从非常少的输入中生成所有这些内容。

尽管这在性能上非常令人印象深刻,展示了构建深度自然语言处理模型可以实现的可能性,但这也引发了对这些模型伦理问题及其如何使用(和滥用!)的一些担忧。

随着“假新闻”的兴起和利用互联网传播误信息的情况,这些例子说明使用这些模型生成逼真文本是多么简单。让我们考虑一个例子,代理人希望在网络上生成关于多个主题的假新闻。现在,他们甚至不需要自己编写虚假信息。理论上,他们可以训练自然语言处理模型来为他们做这些事情,然后在互联网上传播这些虚假信息。GPT-2 的作者在训练和发布模型时特别关注了这一点,指出该模型有被滥用和误用的潜力,因此一旦他们没有看到较小模型被滥用的证据,才向公众发布更大更复杂的模型。

在 NLP 深度学习前景中,这可能成为关键关注点。随着我们接近像 GPT-2 这样可以接近人类复杂水平的聊天机器人和文本生成器,需要全面理解这些模型的使用和误用。研究表明,GPT-2 生成的文本被认为几乎与纽约时报(83%)的真实人类撰写文章一样可信(72%)。随着我们未来继续开发更复杂的深度 NLP 模型,这些数字可能会趋于一致,因为模型生成的文本变得越来越逼真。

此外,GPT-2 的作者还表明,该模型可以进行误用的精细调整。通过在意识形态极端立场上对 GPT-2 进行微调并生成文本,表明可以生成支持这些意识形态的宣传文本。尽管还表明可以训练反模型来检测这些模型生成的文本,但在未来,这些模型变得更加复杂时,我们可能会再次面临更多问题。

在 NLP 模型随着时间的推移变得越来越复杂和高效的同时,这些伦理考虑也值得牢记。虽然您为自己的目的训练的模型可能没有被用于任何误用,但总有可能被用于意外的目的。始终考虑您使用的任何模型的潜在应用。

未来的 NLP 任务

虽然本书的大部分内容都集中在文本分类和序列生成上,但还有许多其他 NLP 任务我们没有真正涉及。虽然其中许多任务更多是从学术角度而不是实际角度来看更加有趣,但理解这些任务很重要,因为它们构成了语言的形成和构建的基础。作为 NLP 数据科学家,我们能够更好地理解自然语言的形成,这只会改善我们对主题的理解。在本节中,我们将更详细地讨论 NLP 未来发展的四个关键领域:

  • 成分分析

  • 语义角色标注

  • 文本蕴涵

  • 机器理解

成分分析

成分分析(也称为句法分析)是识别句子部分并为其分配句法结构的行为。这种句法结构主要由无上下文语法确定,这意味着使用句法分析,我们可以识别给定句子的基本语法结构并将其映射出来。任何句子都可以被分解成“解析树”,这是这种基础句子结构的图形表示,而句法分析是检测这种基础结构并确定如何构建此树的方法。

我们将从讨论这种基本的语法结构开始。句子中的“成分”概念在某种程度上是一种抽象,但基本假设是句子由多个“组”组成,每个组都是一个成分。语法,以其基本形式来说,可以说是一个可以出现在句子中的所有可能类型的成分的索引。

让我们首先考虑最基本的成分类型,即名词短语。句子中的名词相对简单,因为它们是定义对象或实体的词语。在以下句子中,我们可以识别出三个名词:

“厨师杰夫烹饪晚餐”

杰夫 - 专有名词,表示一个名字

厨师 - 厨师是一个实体

晚餐 - 晚餐是一个对象/事物

然而,名词短语略有不同,因为每个名词短语应该指代一个单一实体。在前面的句子中,即使杰夫厨师都是名词,短语厨师杰夫指的是一个单一的人,因此这可以被视为一个名词短语。但我们如何从句法上确定名词短语指的是一个单一实体呢?一个简单的方法是将短语放在动词前面,看看句子是否在语法上有意义。如果有意义,那么很可能这个短语就是一个名词短语:

厨师杰夫烹饪…

厨师杰夫运行…

杰夫厨师喝…

我们能够识别出各种不同的短语,以及帮助我们识别它们的一些复杂语法规则。我们首先确定每个句子可以分解成的单个语法特征:

现在我们知道句子由成分组成,而成分可以由多个单个语法组成,我们现在可以根据其结构开始绘制我们的句子。例如,看下面的例句:

“男孩打了球”

我们可以先将这个句子分成两部分:一个名词短语和一个动词短语:

图 9.13 将句子分解为其语法组成部分

图 9.13 – 将句子分解为其语法组成部分

然后我们重复这个过程,将每个短语分割成更小的语法组件。我们可以将这个名词短语分割成一个限定词和一个名词:

图 9.14 分解名词短语

图 9.14 – 分解名词短语

然后,我们可以将这个动词短语再次分解为动词和另一个名词短语:

图 9.15 – 分解动词短语

图 9.15 – 分解动词短语

我们可以一次又一次地迭代,将我们句子的各个部分分解成越来越小的片段,直到我们留下一个解析树。这个解析树传达了我们句子的整个句法结构。我们可以在这里完整地看到我们示例的解析树:

图 9.16 – 句子的解析树

一个

图 9.16 – 句子的语法树

尽管这些语法树允许我们看到句子的句法结构,但它们还远非完美。从这个结构中,我们可以清楚地看到有两个名词短语和一个动词的发生。然而,从前面的结构中,单凭语法,无法清楚地看出实际发生了什么。我们有两个对象之间的动作,但仅凭语法本身无法确定哪个方面在对谁进行动作。我们将看到,语义角色标注捕捉到了一些这种模糊性。

语义角色标注

语义角色标注是将标签分配给句子中的单词或短语的过程,指示它们在句子中的语义角色。广义上讲,这涉及识别句子的谓词,并确定句子中每个其他术语与该谓词的关系如何。换句话说,对于给定的句子,语义角色标注确定了句子中"谁对谁做了什么以及在哪里/何时"。

因此,对于给定的句子,我们通常可以将句子分解为其组成部分,如下所示:

图 9.17 将句子分解为其组成部分

图 9.17 将句子分解为其组成部分

句子的这些部分具有特定的语义角色。任何给定句子的谓词表示句子中发生的事件,而句子的所有其他部分与给定的谓词相关联。在这个句子中,我们可以将"Who"标记为谓词的代理者。代理者是导致事件发生的事物。我们也可以将"Whom"标记为我们谓词的主题。主题是句子中最受事件影响的元素:

图 9.18 – 分解角色

图 9.18 – 分解角色

理论上,句子中的每个单词或短语都可以用其特定的语义组件标记。一个几乎全面的表格如下所示:

通过执行语义角色标注,我们可以为句子的每个部分分配特定的角色。这在自然语言处理中非常有用,因为它使模型能够更好地"理解"句子,而不是句子仅仅是一堆角色的组合,而是理解成能更好地传达事件实际发生情况的语义角色的组合。

当我们阅读句子“男孩踢了球”时,我们本能地知道有一个男孩,有一个球,而且男孩正在踢球。然而,到目前为止,我们看到的所有自然语言处理模型都是通过查看句子中的单词并为它们创建一些表示来理解这个句子。目前的系统很难理解到这样一个事实,即有两个“事物”,第一个对象(男孩)正在对第二个对象(球)执行某些动作(踢)。通过向我们的模型引入语义角色的元素,我们可以更好地帮助我们的系统通过定义句子的主语和它们之间的交互形成更为现实的表示。

语义角色标注极大地帮助了一个问题,即表达相同意义但语法或句法不同的句子的识别;例如以下句子:

苹果由商店卖给了那个男人

商店卖给了那个男人一个苹果

男人从商店买了苹果

苹果是由商店卖给了那个男人

这些句子本质上意思相同,尽管它们显然没有以相同顺序包含完全相同的单词。通过对这些句子应用语义角色标注,我们可以确定谓词/代理/主题都是相同的。

我们之前看到了如何使用成分分析/句法分析来识别句子的句法结构。在这里,我们可以看到如何将简单句“我买了一只猫”分解为其组成部分 - 代词、动词、定语和名词:

图 9.19 – 成分分析

图 9.19 – 成分分析

但是,这并没有揭示句子中每个部分在语义角色中的作用。是我买了猫还是猫买了我?虽然句法角色有助于理解句子的结构,但它对语义意义的启示不多。一个有用的类比是图像标题。在训练用于标记图像的模型中,我们希望得到一个描述图像内容的标题。语义标注则相反,我们接受一个句子并试图抽象出句子中正在发生的行为的心理“图像”。

那么,语义角色标注在自然语言处理中有哪些有用的上下文呢?简而言之,任何需要“理解”文本内容的自然语言处理任务都可以通过角色的添加而得到增强。这可以是从文档摘要、问答到句子翻译等任何内容。例如,使用语义角色标注来识别我们句子的谓词和相关的语义组件,我们可以训练一个模型来识别对句子起着重要信息贡献的组件,并丢弃那些不重要的。

因此,能够训练模型以执行精确和高效的语义角色标注将对自然语言处理的其他领域有用。最早的语义角色标注系统是纯粹基于规则的,由从语法中导出的基本规则集组成。这些系统随后演变为整合统计建模方法,然后是近期的深度学习发展,这使得可以训练分类器在句子中识别相关的语义角色。

就像任何分类任务一样,这是一个需要完全标注句子的监督学习问题,以训练一个能够识别先前未见句子的语义角色的模型。然而,这样标注的句子的可用性非常有限。我们在本章前面看到的巨大语言模型(如 BERT)是在原始句子上进行训练的,并不需要标记的例子。然而,在语义角色标注的情况下,我们的模型需要正确标记的句子来执行这一任务。虽然存在此类目的数据集,但它们不足够大且多样化,无法训练出完全全面且准确的模型,以便在各种句子上表现良好。

正如你可能想象的那样,解决语义角色标注任务的最新先进方法都基于神经网络。最初的模型使用了 LSTM 和双向 LSTM,结合了 GLoVe 嵌入以执行句子的分类。还有一些变体模型结合了卷积层,这些模型也表现出色。

然而,并不奇怪的是,这些最先进的模型都是基于 BERT 的。使用 BERT 在各种自然语言处理相关任务中表现出色,语义角色标注也不例外。整合了 BERT 的模型被全面训练,以预测词性标签,执行句法分析和同时执行语义角色标注,并展现了良好的结果。

其他研究也表明,图卷积网络在语义标注中是有效的。图由节点和边构成,其中图中的节点表示语义成分,边表示父子部分之间的关系。

还有许多开源模型用于语义角色标注。Google 的 SLING 解析器经过训练,用于执行数据的语义标注。该模型使用双向 LSTM 编码句子和转移型递归单元进行解码。该模型仅仅将文本标记作为输入,并输出角色,没有进一步的符号表示:

图 9.20 – 双向 LSTM (SLING)

图 9.20 – 双向 LSTM (SLING)

值得注意的是,SLING 仍然是一个正在进行中的工作。目前,它还不够复杂,无法从任意文本中准确提取事实。这表明在真正能够创建一个真实且准确的语义角色解析器之前,这个领域还有很多工作要做。完成这项工作后,语义角色解析器可以轻松地作为集成机器学习模型的一部分,用于标记句子中的语义角色,然后在更广泛的机器学习模型中使用,以增强模型对文本的“理解”。

文本蕴含

文本蕴含也是另一种方法,通过这种方法,我们可以训练模型,试图更好地理解句子的含义。在文本蕴含中,我们尝试识别两段文本之间的方向关系。这种关系存在于一个文本的真实性能够从另一个文本中推导出来的情况下。这意味着,给定两段文本,如果第二段文本可以通过第一段文本中的信息来证明是真实的,我们可以说这两段文本之间存在着正向的方向关系。

这项任务通常以以下方式设置,我们的第一段文本标记为文本,我们的第二段文本标记为假设:

文本: 如果你给慈善机构捐款,你会感到快乐

假设: 捐款给慈善机构会产生良好的后果

这是正文本蕴含的一个例子。如果假设可以从文本中得出结论,那么这两段文本之间就可以说存在方向关系。重要的是通过文本/假设来设置例子,因为这定义了关系的方向。大部分时间,这种关系不是对称的。例如,在这个例子中,第一句蕴含第二句(我们可以根据第一句的信息推断第二句是真实的)。然而,我们不能根据第二句的信息推断第一句是真实的。虽然两个陈述可能都是真实的,但如果我们不能推断这两者之间存在方向关系,我们就不能从一个中推断另一个。

也存在负文本蕴含。这是当陈述是相互矛盾的时候;例如:

文本: 如果你给慈善机构捐款,你会感到快乐

假设: 捐款给慈善机构会产生坏后果

在这个例子中,文本并不蕴含假设;相反,文本与假设相矛盾。最后,还有可能确定两个句子之间没有文本蕴含,如果它们之间没有关系的话。这意味着这两个陈述不一定是矛盾的,而是文本不蕴含假设:

文本: 如果你给慈善机构捐款,你会感到快乐

假设: 捐款给慈善机构会使你放松

自然语言的歧义性使得这个任务在自然语言处理(NLP)的视角下变得有趣。两个句子可以有不同的句法结构、不同的语义结构,由完全不同的词组成,但仍然可能有非常相似的含义。同样地,两个句子可以由相同的词和实体组成,但含义却截然不同。

这正是使用模型来量化文本含义的地方特别有用。文本蕴涵也是一个独特的问题,因为两个句子可能并不完全具有相同的含义,然而一个句子仍然可以从另一个句子推断出来。这需要一种语言推理的元素,在大多数语言模型中并不存在。通过在我们的模型中引入语言推理的元素,我们可以更好地捕捉文本的含义,以及能够确定两个文本是否包含相同的信息,而不管它们的表现形式是否相似。

幸运的是,简单的文本蕴涵模型并不难创建,基于 LSTM 的模型已被证明是有效的。一个可能有效的设置是 Siamese LSTM 网络。

我们将我们的模型设置为一个多类分类问题,其中两个文本可以被积极或消极地蕴涵,或者没有蕴涵。我们将两个文本输入到一个双输入模型中,从而获取两个文本的嵌入,并通过双向 LSTM 层传递它们。然后对这两个输出进行某种比较(使用某种张量操作),然后它们通过最终的 LSTM 层。最后,我们使用 softmax 层对输出进行分类:

图 9.21 – Siamese LSTM 网络

图 9.21 – Siamese LSTM 网络

尽管这些模型还远非完美,它们代表了朝着创建完全准确的文本蕴涵模型迈出的第一步,并为将其整合到未来语言模型中打开了可能性。

机器理解

到目前为止,在这本书中,我们主要提到了 NLP,但能够处理语言只是一个方面。当你或我阅读一个句子时,我们不仅仅是阅读、观察和处理单个词语,还会建立起对句子实际含义的内在理解。能够训练出不仅仅理解句子,而且能够形成对其中所表达的思想理解的模型,可以说是 NLP 的下一步。这个领域的真正定义非常宽泛,但通常被称为机器理解或自然语言理解(NLU)。

在学校,我们从小就被教导阅读理解。你可能很早就学会了这项技能,现在可能认为这是理所当然的。通常情况下,你可能甚至没有意识到自己在做这件事;事实上,你现在正在做它!阅读理解简单来说就是阅读文本,理解这段文本,并能回答关于文本的问题。例如,请看下面的文本:

作为一种消毒水的方法,将水加热至其沸点 100 °C(212 °F)是最古老和最有效的方法,因为它不会影响其口感。尽管存在污染物或颗粒物,它仍然有效,并且是一种单步过程,可消灭大多数引起肠道相关疾病的微生物。水的沸点在海平面和常规气压下为 100 °C(212 °F)。

既然你理解了这段文本,现在你应该能够回答以下关于它的问题:

Q: 水的沸点是多少?

A: 100 °C (212 °F)

Q: 沸水会影响其口感吗?

A:

理解文本并能回答关于文本的问题的能力构成了我们机器理解任务的基础。我们希望能够训练一个机器学习模型,该模型不仅能够理解文本,还能够用语法正确的自然语言回答关于文本的问题。

这些好处是多方面的,但一个非常直观的用例将是构建一个充当知识库的系统。目前,搜索引擎的工作方式是我们运行搜索(在 Google 或类似的搜索引擎中),搜索引擎返回一些文档。然而,要找到特定的信息,我们仍然必须从返回的文档中推断出正确的信息。整个过程可能看起来像这样:

图 9.22 – 查找信息的过程

图 9.22 – 查找信息的过程

在这个例子中,要回答关于“水的沸点是多少?”的问题,我们首先提出我们的问题。然后,在搜索引擎上搜索主题内容。这可能是一些简化的问题表示形式;例如,“水的沸点”。我们的搜索引擎然后会返回一些相关的文档,很可能是水的维基百科条目,我们随后必须手动搜索并使用它来推断我们问题的答案。虽然这种方法是有效的,但机器理解模型可以使这个过程在某种程度上得到简化。

假设我们有一个完美的模型,能够完全理解并回答文本语料库中的问题。我们可以训练这个模型,使用像是互联网大量文本抓取或维基百科这样的大数据源,并形成一个充当大型知识库的模型。通过这样做,我们就能够用真实问题查询知识库,答案会自动返回。这样一来,我们的图表中的知识推断步骤就被移除了,因为推断已由模型处理,模型已经对主题有了理解:

图 9.23 – 使用模型的新流程

图 9.23 – 使用模型的新流程

在理想的情况下,这将简单到只需在搜索引擎中键入 "水的沸点是多少?" ,就会收到 "100 °C (212 °F)" 作为答案。

让我们假设我们有一个简化版本的这个模型。假设我们已经知道包含我们所问问题答案的文档。那么,假设我们有了关于水的维基百科页面,我们能否训练一个模型来回答 "水的沸点是多少?" 的问题。最初的简单做法,而不是整合完整语言模型的元素,是简单地返回包含答案的维基百科页面的段落。

我们可以训练的架构来实现这个任务可能看起来像这样:

图 9.24 – 模型的架构

图 9.24 – 模型的架构

我们的模型接受我们想要回答的问题和包含我们问题的文档作为输入。然后,这些输入通过嵌入层传递,以形成每个的基于张量的表示,然后通过编码层形成进一步减少的向量表示。

现在我们的问题和文档被表示为向量后,我们的匹配层尝试确定我们应该查看文档向量中的哪个部分以获取问题的答案。这是通过一种注意力机制形式完成的,我们的问题决定我们应该查看文档向量的哪些部分来回答问题。

最后,我们的融合层旨在捕捉匹配层的长期依赖关系,将来自匹配层的所有接收信息结合起来,并执行解码步骤以获取我们的最终答案。这一层采用双向 RNN 的形式,将匹配层的输出解码为最终预测。我们在这里预测两个值 – 起点和终点 – 使用多类分类。这代表了在我们的文档中包含回答初始问题的起点和终点。如果我们的文档包含 100 个单词,并且第 40 到第 50 个单词之间的句子包含了我们的答案,我们的模型理想地应该预测起点和终点的值分别为 40 和 50。这些值随后可以轻松用于返回输入文档中的相关段落。

返回目标文档的相关区域虽然是一个有用的训练模型,但这并不等同于真正的机器理解模型。为了做到这一点,我们必须融入更大语言模型的元素。

在任何机器理解任务中,实际上有三个要素在起作用。我们已经知道有问题和答案,但还有一个相关的上下文可能决定了给定问题的答案。例如,我们可以问以下问题:

今天是星期几?

答案可能会因提问时的上下文而有所不同;例如,星期一、星期二、3 月 6 日、圣诞节。

我们还必须注意问题和答案之间的关系是双向的。在给定知识库的情况下,我们可以根据问题生成答案,但我们也能根据答案生成问题:

图 9.25 – 问题和答案之间的关系

图 9.25 – 问题和答案之间的关系

真正的机器理解可能能够执行问题生成QG),以及问答QA)。对此最明显的解决方案是训练两个单独的模型,一个用于每个任务,并比较它们的结果。理论上,我们的 QG 模型的输出应该等于我们 QA 模型的输入,因此通过比较这两者,我们可以进行同时评估:

图 9.26 – QG 和 QA 模型的比较

图 9.26 – QG 和 QA 模型的比较

然而,一个更全面的模型可以同时执行这两个任务,从而从答案生成问题并回答问题,就像人类能够做的那样:

图 9.27 双模型表示

图 9.27 – 双模型表示

事实上,自然语言理解的最新进展意味着这些模型现在已成为现实。通过结合多种元素,我们能够创建一个能够执行双重模型功能的神经网络结构,正如之前所示。这被称为双问答网络。事实上,我们的模型包含了迄今为止在本书中看到的大多数神经网络组件,即嵌入层、卷积层、编码器、解码器和注意力层。问答网络的完整架构看起来类似于以下内容:

图 9.28 – 双问答网络的架构

图 9.28 – 双问答网络的架构

我们可以从这里做出以下观察:

  • 模型的输入包括问题、答案和上下文,如前所述,还包括右移的问题和答案。

  • 我们的嵌入层通过对字符和单词的 GloVe 嵌入向量进行卷积,以创建一个合并的表示。

  • 我们的编码器由应用了注意力机制的 LSTMs 组成。

  • 我们的输出也基于 RNN,并逐词解码我们的输出,生成最终的问题和答案。

虽然已存在预训练的问答网络,但你可以练习实现你新学到的 PyTorch 技能,并尝试自己构建和训练这样的模型。

像这样的语言理解模型很可能成为未来几年内自然语言处理研究的主要焦点之一,新的论文很可能会频繁出版。

总结

在本章中,我们首先研究了几种最先进的自然语言处理语言模型。特别是,BERT 似乎已被广泛接受为行业标准的最先进语言模型,BERT 及其变体广泛应用于企业自己的自然语言处理应用中。

接下来,我们详细研究了机器学习未来的几个重点领域;即语义角色标注、成分句法分析、文本蕴涵和机器理解。这些领域很可能占据当前自然语言处理研究的大部分内容。

现在,你对于 NLP 深度学习模型及其在 PyTorch 中的实现有了全面的能力和理解,或许你会有兴趣参与未来的研究。无论是在学术还是商业环境中,你现在应该有足够的知识,从零开始创建你自己的深度 NLP 项目,并能使用 PyTorch 创建你需要的模型来解决任何你需要的 NLP 任务。通过继续提升你的技能,并且保持关注并跟上领域中的最新发展,你必定会成为成功的、业界领先的 NLP 数据科学家!

posted @ 2024-07-23 14:53  绝不原创的飞龙  阅读(7)  评论(0编辑  收藏  举报