Python-自然语言处理实用指南-全-
Python 自然语言处理实用指南(全)
零、前言
在互联网时代,每天从社交媒体和其他平台生成越来越多的文本数据,因此能够理解这些数据是一项至关重要的技能。 本书将帮助您为自然语言处理(NLP)任务建立深度学习模型,这将帮助您从文本中提取有价值的见解。
我们将从了解如何安装 PyTorch 以及使用 CUDA 加快处理速度开始。 然后,您将通过实际示例探索 NLP 架构的工作方式。 后面的章节将指导您完成重要的原则,例如 PyTorch 中的单词嵌入,CBOW 和分词。 然后,您将学习一些用于处理文本数据的技术,以及如何将深度学习用于 NLP 任务。 接下来,我们将演示如何实现深度学习和神经网络架构来构建模型,以使您能够对文本进行分类和翻译以及执行情感分析。 最后,您将学习如何构建高级 NLP 模型,例如会话聊天机器人。
到本书结尾,您将了解使用 PyTorch 进行深度学习如何解决不同的 NLP 问题,以及如何构建模型来解决这些问题。
这本书适合谁
这本 PyTorch 书籍适用于 NLP 开发人员,机器学习和深度学习开发人员,或致力于使用传统 NLP 方法和深度学习架构来构建智能语言应用的任何人。 如果您希望在开发项目中采用现代的 NLP 技术和模型,那么本书非常适合您。 必须具备 Python 编程的工作知识和 NLP 任务的基本工作知识。
这本书涵盖的内容
“第 1 章”,“机器学习和深度学习的基础知识”,概述了机器学习和神经网络的基本方面。
“第 2 章”,“用于 NLP 的 PyTorch 1.x 入门”,向您展示如何下载,安装和启动 PyTorch。 我们还将介绍该包的一些基本功能。
“第 3 章”,“NLP 和文本嵌入”展示了如何为 NLP 创建文本嵌入并在基本语言模型中使用它们。
“第 4 章”,“文本预处理,词干提取和词形还原”,展示了如何预处理文本数据以用于 NLP 深度学习模型。
“第 5 章”,“循环神经网络和情感分析”,介绍了循环神经网络的基础知识,并向您展示如何使用它们来建立情感神经网络分析模型。 刮。
“第 6 章”,“用于文本分类的卷积神经网络”,介绍了卷积神经网络的基础知识,并向您展示了如何使用它们来分类文本。
“第 7 章”,“使用序列到序列神经网络的文本翻译”介绍了深度学习的序列到序列模型的概念,并介绍了如何使用它们来构建将文本翻译成另一种语言的模型。
“第 8 章”,“使用基于注意力的神经网络构建聊天机器人”,涵盖了在序列到序列深度学习模型中使用的注意概念,并显示了如何使用它们从头开始构建功能全面的聊天机器人。
“第 9 章”,“前方的路”涵盖了 NLP 深度学习中当前使用的一些最新模型,并探讨了一些 NLP 领域面临的问题和挑战。
要充分利用这本书
您将需要在计算机上安装 Python 版本。 所有代码示例均已使用 3.7 版进行了测试。 您还将需要一个适用的 PyTorch 环境,以用于本书的深度学习组件。 所有深度学习模型都是使用 1.4 版构建的; 但是,大多数代码应与更高版本一起使用。

本书的代码中使用了多个 Python 库。 但是,这些将在相关章节中介绍。
如果您使用的是本书的数字版本,建议您自己键入代码或通过 GitHub 存储库访问代码(下一节提供链接)。 这样做将帮助您避免任何与代码复制和粘贴有关的潜在错误。
下载示例代码文件
您可以从 www.packt.com 的帐户中下载本书的示例代码文件。 如果您在其他地方购买了此书,则可以访问 www.packtpub.com/support 并注册以将文件直接通过电子邮件发送给您。
您可以按照以下步骤下载代码文件:
- 登录或注册 www.packt.com 。
- 选择支持选项卡。
- 点击代码下载。
- 在搜索框中输入书籍的名称,然后按照屏幕上的说明进行操作。
下载文件后,请确保使用以下最新版本解压缩或解压缩文件夹:
- Windows 的 WinRAR/7-Zip
- Mac 版 Zipeg/iZip/UnRarX
- 适用于 Linux 的 7-Zip/PeaZip
本书的代码包也托管在 GitHub 中。 如果代码有更新,它将在现有的 GitHub 存储库中进行更新。
在这个页面上,我们还提供了丰富的书籍和视频目录中的其他代码包。 去看一下!
下载彩色图像
我们还提供了 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。 您可以在此处下载。
使用的约定
本书中使用了许多文本约定。
文本中的代码:指示文本中的代码字,数据库表名称,文件夹名称,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄。 这是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件安装为系统中的另一个磁盘。”
代码块设置如下:
导入火炬
当我们希望引起您对代码块特定部分的注意时,相关行或项目以粗体显示:
word_1 = 'cat'
word_2 = 'dog'
word_3 = 'bird'
任何命令行输入或输出的编写方式如下:
$mkdir flaskAPI
$cd flaskAPI
粗体:表示新术语,重要单词或您在屏幕上看到的单词。 例如,菜单或对话框中的单词会出现在这样的文本中。 这是一个示例:“从管理面板中选择系统信息。”
提示或重要提示这样出现。
一、机器学习和深度学习的基础
我们的世界拥有丰富的自然语言数据。 在过去的几十年中,我们彼此之间的通信方式已经转变为数字领域,因此,这些数据可用于构建可改善我们在线体验的模型。 从在搜索引擎中返回相关结果,到自动完成您在电子邮件中输入的下一个单词,从自然语言中提取见解的好处显而易见。
尽管我们作为人类的语言理解方式与模型或人工智能理解语言的方式明显不同,但通过阐明机器学习及其用途,我们可以开始理解,这些深度学习模型如何理解语言,以及模型从数据中学习时发生的根本变化。
在本书中,我们将探讨人工智能和深度学习对自然语言的这种应用。 通过使用 PyTorch,我们将逐步学习如何构建模型,使我们能够执行情感分析,文本分类和序列翻译,从而使我们构建一个基本的聊天机器人。 通过介绍这些模型背后的理论,并演示如何实际实现它们,我们将使自然语言处理(NLP)的领域神秘化,并为您提供足够的背景知识,让您开始构建自己的模型。
在第一章中,我们将探讨机器学习的一些基本概念。 然后,我们将通过研究深度学习,神经网络的基础知识以及深度学习方法相对于基本机器学习技术所具有的一些优势,将这一步骤进一步向前发展。 最后,我们将更深入地研究深度学习,特别是针对特定于 NLP 的任务,以及我们如何使用深度学习模型从自然语言中获得见解。 具体来说,我们将涵盖以下主题:
- 机器学习概述
- 神经网络概述
- 用于机器学习的 NLP
机器学习概述
从根本上讲,机器学习是用于识别模式并从数据中提取趋势的算法过程。 通过在数据上训练特定的机器学习算法,机器学习模型可以学习对人眼不是立即显而易见的见解。 医学成像模型可能会学会从人体图像中检测出癌症,而情感分析模型可能会学习,与包含不好,糟糕和无聊的书评相比,包含良好,优秀和有意思的书评更可能是正面的。
广义上讲,机器学习算法分为两大类:监督学习和无监督学习。
监督学习
监督学习涵盖了所有我们希望使用输入来预测输出的任务。 假设我们希望训练一个模型来预测房价。 我们知道较大的房屋往往会卖出更多的钱,但我们不知道价格和面积之间的确切关系。 机器学习模型可以通过查看数据来学习这种关系:

图 1.1 –显示住房数据的表格
在这里,我们已经得到的信息,包括最近出售的四栋房屋的大小,以及他们出售的的价格。 根据这四所房屋的数据,我们能否使用此信息对市场上的新房屋做出预测? 一个简单的称为回归的机器学习模型可以估计以下两个因素之间的关系:

图 1.2 –外壳数据输出
给定此历史数据,我们可以使用此数据来估计大小(X)和价格(Y)之间的关系。 现在我们已经估计了大小和价格之间的关系,如果给了我们只知道房子大小的新房子,我们可以使用学习的函数使用它来预测价格:

图 1.3 –预测房价
因此,给定许多输入与输出之间的关系的示例,所有监督学习任务旨在学习模型输入的某些函数以预测输出:
给定很多(X, y),请学习F(X) = y。
您电话号码的输入可以包含任意数量的特征。 我们的简单房价模型仅包含一个特征(大小),但我们可能希望添加更多特征以提供更好的预测(例如,卧室数量,花园大小等) 上)。 因此,更具体地说,我们的监督模型学习了一个函数,以便将许多输入映射到输出。 由以下等式给出:
给定许多([X0, X1, X2, …, Xn], y),学习f(X0, X1, X2, …, Xn) = y。
在前面的示例中,我们学习的函数如下:

这里,θ[0]是x轴截距,θ[1]是直线的斜率。
模型可以包含数百万甚至数十亿个输入特征的(尽管当特征空间太大时,您可能会遇到硬件限制)。 模型的输入类型也可能有所不同,模型可以从图像中学习:

图 1.4 –模型训练
正如我们稍后将更详细地研究一样,他们也可以从文本中学习:
我喜欢这部电影 -> 好评
这部电影很糟糕 -> 差评
我今年看过的最好的电影 -> ?
无监督学习
非监督学习 与监督学习的不同之处在于非监督学习不使用输入和输出对(X, y)进行学习。 相反,我们仅提供输入数据,模型将学习有关输入数据的结构或表示的知识。 无监督学习的最常见方法之一是聚类。
例如,我们获取了来自四个不同国家/地区的温度和降雨测度读数的数据集,但没有关于这些读数取自何处的标签。 我们可以使用聚类算法来识别数据中存在的不同聚类(国家):

图 1.5 –聚类算法的输出
群集在 NLP 领域中也有用途。 如果为我们提供了电子邮件的数据集,并想确定这些电子邮件中使用了多少种不同的语言,则一种集群形式可以帮助我们识别这一点。 如果英语单词在同一封电子邮件中与其他英语单词一起频繁出现,而西班牙语单词与其他西班牙语单词一起频繁出现,我们将使用聚类确定数据集有多少个不同的单词聚类,从而确定语言的数量。
模型如何学习?
为了学习模型,我们需要某种评估模型表现的方法。 为此,我们使用称为损失的概念。 损失是衡量如何根据其真实值接近模型预测的一种度量。 对于我们数据集中的给定房屋,损失的一种度量可能是真实价格(y)与我们的模型预测的价格(y_hat)之间的差。 我们可以通过对数据集中所有房屋的平均损失进行评估,从而评估系统中的总损失。 但是,从理论上讲,正损失可以抵消负损失,因此,更常见的损失度量是均方误差:

虽然其他模型可能使用不同的损失函数,但回归通常使用均方误差。 现在,我们可以计算整个数据集的损失量度,但是我们仍然需要方法,以算法的方式实现尽可能低的损失。 此过程称为梯度下降。
梯度下降
在这里,我们绘制了损失函数,因为它与房价模型θ[1]中的单个学习参数有关。 我们注意到,当θ[1]设置得太高时,MSE 损失就很高;而当θ[1]设置得太低时,MSE 损失也就很高。 最佳点或损失最小的点位于中间位置。 为了计算该算法,我们使用梯度下降。 当我们开始训练自己的神经网络时,我们将更详细地看到这一点:

图 1.6 –梯度下降
我们首先用随机值初始化θ[1]。 为了使损失最小化,我们需要从损失函数进一步下移,到达山谷的中部。 为此,我们首先需要知道向哪个方向移动。在我们的初始点,我们使用基本演算来计算坡度的初始坡度:

在我们前面的示例中,初始点处的梯度为正。 这表明我们的θ[1]值大于最佳值,因此我们更新了θ[1]的值,使其低于我们先前的的值。 我们逐步迭代此过程,直到θ[1]越来越接近 MSE 最小化的值。 这发生在梯度等于零的点。
过拟合和欠拟合
考虑以下场景,其中基本线性模型无法很好地拟合到我们的数据。 我们可以看到,我们的模型(由方程y = θ[0] + θ[1] x表示)似乎不是很好的预测指标:

图 1.7 –欠拟合和过拟合的示例
当我们的模型由于缺乏特征,数据不足或模型规格不足而无法很好地拟合数据时,我们将其称为,欠拟合。 我们注意到我们数据的梯度越来越大,并怀疑如果使用多项式,则模型可能更合适。 例如y = θ[0] + θ[1] x + θ[2] x^2。 稍后我们将看到,由于神经网络的复杂结构,欠拟合很少成为问题:
考虑以下示例。 在这里,我们使用房价模型拟合的函数不仅适用于房屋的大小(X),而且适用于二阶和三阶多项式(X2, X3)。 在这里,我们可以看到我们的新模型非常适合我们的数据点。 但是,这不一定会产生良好的模型:

图 1.8 –过拟合的样本输出
我们现在有一个房子,其大小为 110 平方米,以预测价格。 根据我们的直觉,由于该房屋比 100 平方米房屋大,我们希望这所房屋的价格会更高,大约 340,000 美元。 使用我们的拟合多项式模型,我们可以看到预测的价格实际上低于小房子,约为 320,000 美元。 我们的模型适合我们训练过的数据,但不能很好地推广到一个新的,看不见的数据点。 这就是,称为过拟合。 由于过拟合,重要的是不要根据训练的数据评估模型的表现,因此我们需要生成单独的一组数据以评估我们的数据。
训练与测试
通常,在训练模型时,我们将数据分为两部分:训练数据集和较小的测试数据集。 我们使用训练数据集训练模型,并在测试数据集上对其进行评估。 这样做是为了在看不见的数据集上衡量模型的表现。 如前所述,要使模型成为良好的预测指标,必须将其很好地推广到该模型之前从未见过的一组新数据,而这恰恰是对一组测试数据进行评估所得出的结果。
评估模型
尽管我们试图将模型中的损失降到最低,但仅此一项并不能给我们太多有关模型在实际进行预测方面的优势的信息。 考虑一个反垃圾邮件模型,该模型可以预测收到的电子邮件是否为垃圾邮件,并自动将垃圾邮件发送到垃圾文件夹。 评估表现的一种简单方法是准确率:

为了计算准确率,我们只需将正确预测为垃圾邮件/非垃圾邮件的电子邮件数量除以我们做出的预测总数即可。 如果我们正确地预测了 1,000 封电子邮件中的 990 封电子邮件,则我们的准确率为 99%。 但是,高精度不一定意味着我们的模型是好的:

图 1.9 –该表显示了被预测为垃圾邮件/非垃圾邮件的数据
在这里,我们可以看到,尽管我们的模型正确地预测了 990 封电子邮件不是垃圾邮件(称为真实否定邮件),但它也预测了 10 封属于垃圾邮件的邮件被视为非垃圾邮件(称为错误负面邮件)。 我们的模型仅假设所有电子邮件都不是垃圾邮件,这根本不是一个很好的反垃圾邮件过滤器! 我们不仅应该使用准确率,还应该使用精度和召回评估模型。 在这种情况下,我们的模型的召回率为零(意味着未返回正结果)将立即成为危险信号:


神经网络
在前面的示例中,我们主要讨论了y = θ[0] + θ[1] x形式的回归。 我们接触过使用多项式来拟合更复杂的方程式,例如y = θ[0] + θ[1] x + θ[2] x。 但是,随着我们向模型中添加更多特征,何时使用原始特征的转换成为反复试验的案例。 使用神经网络,我们可以将更复杂的函数y = f(x)拟合到我们的数据中,而无需设计或转换我们现有的特征。
神经网络的结构
当我们学习θ[1]的最优值时,该最优值将回归中的损失降到最低,这实际上与一层神经网络相同:

图 1.10 –一层神经网络
在这里,我们将每个特征Xᵢ作为输入,在此通过节点进行说明。 我们希望学习参数Xᵢ,在此图中将其表示为连接。 我们对Xᵢ和Xᵢ之间所有乘积的最终总和为我们提供了最终预测y:

一个简单的神经网络建立在这个初始概念的基础上,在计算中增加了额外的层,从而增加了复杂性和学习到的参数,使我们得到了类似的东西:

图 1.11 –全连接网络
每个输入节点都连接到另一层中的每个节点。 这被称为全连接层。 然后,将来自的全连接层的输出乘以其自身的附加权重,以便预测y。 因此,我们的预测不再只是X[i]θ[i]的函数,而是现在包括针对每个参数的多个学习权重。 特征X₁不再仅受θ[1]影响。 现在,它也受到θ[1,1], θ[2,1], θ[2,2], θ[2,3]的影响。 参数。
由于全连接层中的每个节点都将X的所有值作为输入,因此神经网络能够学习输入特征之间的交互特征。 多个全连接层可以链接在一起,以学习更复杂的特征。 在本书中,我们将看到我们构建的所有神经网络都将使用该概念。 将不同品种的多层链接在一起,以构建更复杂的模型。 但是,在我们完全理解神经网络之前,还有另外一个关键元素要涵盖:激活函数。
激活函数
虽然将各种权重链接在一起可以使我们学习更复杂的参数,但最终,我们的最终预测仍将是权重和特征的线性乘积的组合。 如果希望神经网络学习一个真正复杂的非线性函数,则必须在模型中引入非线性元素。 这可以通过使用激活函数来完成:

图 1.12 –神经网络中的激活函数
我们将激活函数应用于全连接层中的每个节点。 这意味着全连接层中的每个节点都将特征和权重之和作为输入,对结果值应用非线性函数,并输出转换后的结果。 尽管激活函数有许多不同,但最近使用最频繁的是 ReLU 或整流线性单元:

图 1.13 – ReLU 输出的表示
ReLU 是非常简单的非线性函数,当x <= 0,返回y = 0;当x > 0时返回y = x。 在将这些激活函数引入我们的模型后,我们最终的学习函数将变为非线性,这意味着我们可以创建比单独使用传统回归和特征工程相结合的模型更多的模型。
神经网络如何学习?
使用神经网络从数据中学习的行为比使用基本回归学习时的行为稍微复杂一些。 尽管我们仍然像以前一样使用梯度下降,但是我们需要微分的实际损失函数变得更加复杂。 在没有激活函数的单层神经网络中,我们很容易计算损失函数的导数,因为很容易看到随着我们改变每个参数损失函数如何变化。 但是,在具有激活函数的多层神经网络中,这更加复杂。
我们必须首先执行正向传播,即,其中,使用模型的当前状态,我们计算y的预测值,并根据y的真实值来评估它,以便获得损失的度量。 利用这一损失,我们可以在网络中向后移动,计算网络中每个参数的梯度。 这使我们可以知道向哪个方向更新参数,以便使可以更接近损失最小的点。 这被称为反向传播。 我们可以使用链式规则计算相对于每个参数的损失函数的导数:

此处,oⱼ是网络内每个给定节点的输出。 因此,总而言之,在神经网络上执行梯度下降时我们采取的四个主要步骤如下:
- 使用您的数据执行正向传播,计算网络的总损失。
- 使用反向传播,计算每个参数相对于网络中每个节点损失的梯度。
- 更新这些参数的值,朝着使损失最小化的方向发展。
- 重复直到收敛。
神经网络中的过拟合
我们看到,在回归的情况下,可以添加太多特征,从而有可能使网络过拟合。 这样一来,模型可以很好地拟合训练数据,但不能很好地推广到看不见的测试数据集。 这是神经网络中的一个普遍问题,因为模型复杂性的提高意味着通常有可能将函数拟合到不一定要泛化的数据训练集中。 以下是数据集每次向前和反向传播(称为周期)后训练和测试数据集上的总损失的图:

图 1.14 –测试和训练周期
在这里,我们可以看到随着我们继续训练网络,随着我们向总损失最小化的方向靠近,训练损失会随着时间的推移而减小。 虽然这可以很好地推广到测试数据集,但一段时间后,由于我们的函数过度适合训练集中的数据,测试数据集上的总损失开始增加。 一种解决方案是提前停止。 因为我们希望我们的模型对之前从未见过的数据做出良好的预测,所以我们可以在测试损失最小的时候停止训练我们的模型。 经过全面训练的 NLP 模型可能能够轻松地对以前见过的句子进行分类,但是,对真正了解到某些东西的模型的衡量标准是能够对看不见的数据进行预测。
用于机器学习的 NLP
与人类不同,计算机无法理解文本-至少不能以与我们相同的方式理解文本。 为了创建能够从数据中学习的机器学习模型,我们必须首先学习以计算机能够处理的方式来表示自然语言。
当我们讨论机器学习基础知识时,您可能已经注意到损失函数都处理数值数据,以便能够最大程度地减少损失。 因此,我们希望以数字格式表示文本,这可以构成我们向神经网络输入的基础。 在这里,我们将介绍几种数值表示数据的基本方法。
词袋
表示文本的第一种也是最简单的方法是使用词袋表示。 此方法只对给定句子或文档中的单词进行计数,然后对所有单词进行计数。 然后将这些计数转换为向量,其中向量的每个元素都是语料库中每个单词出现在句子中的次数计数。 语料库是,只是出现在所有要分析的句子/文档中的所有单词。 采取以下两个句子:
The cat sat on the mat
The dog sat on the cat
我们可以将每个句子表示为单词数:

图 1.15 –字数表
然后,我们可以将它们转换为单个向量:
The cat sat on the mat -> [2,1,0,1,1,1]
The dog sat on the cat -> [2,1,1,1,1,0]
然后,该数字表示形式可用作特征向量为X[0], X[1], ... ,X[n]的机器学习模型的输入特征。
序列表示
我们将在本书的后面看到,更复杂的神经网络模型,包括 RNN 和 LSTM,不仅将一个向量作为输入,而且可以采用矩阵形式的整个向量序列。 因此,为了更好地捕获单词的顺序,从而更好地捕获任何句子的含义,我们能够以单热编码的向量序列的形式来表示它:

图 1.16 –单热编码向量
总结
在本章中,我们介绍了机器学习和神经网络的基础知识,以及对在这些模型中使用的文本转换的简要概述。 在下一章中,我们将简要概述 PyTorch 以及如何将其用于构建其中的一些模型。
二、用于 NLP 的 PyTorch 1.x 入门
PyTorch 是基于 Python 的机器学习库。 它包含两个主要功能:通过硬件加速(使用 GPU)有效执行张量操作的能力以及构建深度神经网络的能力。 PyTorch 还使用动态计算图代替静态计算图,这使其与 TensorFlow 等类似库区分开来。 通过演示如何使用张量表示语言以及如何使用神经网络向 NLP 学习,我们将显示这两个功能对于自然语言处理特别有用。
在本章中,我们将向您展示如何在计算机上启动和运行 PyTorch,以及演示其一些关键功能。 然后,在探索 PyTorch 的某些 NLP 功能(例如其执行张量运算的能力)之前,我们将把 PyTorch 与其他深度学习框架进行比较,最后演示如何构建简单的神经网络。 总之,本章将涵盖以下主题:
- 安装 PyTorch
- 将 PyTorch 与其他深度学习框架进行比较
- PyTorch 的 NLP 功能
技术要求
在本章中,需要安装 Python。 建议使用最新版本的 Python(3.6 或更高版本)。 还建议使用 Anaconda 包管理器来安装 PyTorch。 需要 CUDA 兼容 GPU 才能在 GPU 上运行张量操作。 本章的所有代码都可以在这个页面中找到。
安装和使用 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 笔记本并运行一些简单的命令:
-
要在 PyTorch 中定义一个张量,我们可以执行以下操作。
import torch x = torch.tensor([1.,2.]) print(x)结果为以下输出:
![Figure 2.1 – Tensor output]()
图 2.1 –张量输出
这表明 PyTorch 中的张量被保存为它们自己的数据类型(与 NumPy 中的数组保存方式相同)。
-
我们可以使用标准的 Python 运算符来执行乘法等基本操作。
x = torch.tensor([1., 2.]) y = torch.tensor([3., 4.]) print(x * y)结果为以下输出:
![Figure 2.2 – Tensor multiplication output]()
图 2.2 –张量乘法输出
-
我们也可以从一个张量中选择单个元素,如下。
x = torch.tensor([[1., 2.],[5., 3.],[0., 4.]]) print(x[0][1])结果为以下输出:
![Figure 2.3 – Tensor selection output]()
图 2.3 –张量选择输出
但是,请注意,与 NumPy 数组不同,从张量对象中选择单个元素会返回另一个张量。 为了从张量返回单个值,可以使用.item()函数:
print(x[0][1].item())
结果为以下输出:

图 2.4 – .item()函数的输出
张量
在我们继续之前,重要的是您充分了解张量的属性。 张量具有属性,称为阶数,该属性实质上确定张量的维数。 一阶张量是一维张量,等效于向量或数字列表。 2 阶张量是具有二维的张量,等效于矩阵,而 3 阶张量则由三个维度组成。 PyTorch 中张量可以具有的最大阶数没有限制:

图 2.5 –张量矩阵
您可以通过键入以下命令来检查任何张量的大小:
x.shape
结果为以下输出:

图 2.6 –张量形状输出
这表明这是一个3x2张量(阶数 2)。
使用 CUDA 启用 PyTorch 加速
PyTorch 的主要优点之一是它能够通过使用图形处理单元(GPU)来实现加速。 深度学习是一种易于并行化的计算任务,这意味着可以将计算分解为较小的任务,并可以在许多较小的处理器中进行计算。 意味着无需在单个 CPU 上执行任务,而是在 GPU 上执行计算更为有效。
GPU 最初是为了有效地渲染图形而创建的,但是由于深度学习在的普及中得到了发展,因此 GPU 被广泛用于同时执行多种计算的能力。 传统的 CPU 可能包含大约四个或八个内核,而 GPU 则包含数百个较小的内核。 由于可以同时在所有这些内核上执行计算,因此 GPU 可以快速减少执行深度学习任务所需的时间。
考虑神经网络内的一次通过。 我们可能会采集少量数据,将其通过我们的网络以获得损失,然后反向传播,根据梯度调整参数。 如果在传统的 CPU 上要处理大量数据,则必须等到批量 1 完成后才能计算批量 2:

图 2.7 –神经网络中的一遍
但是,在 GPU 上,我们可以同时执行所有这些步骤,这意味着不需要批量即可在批量 2 开始之前完成。 我们可以同时计算所有批量的参数更新,然后一次执行所有参数更新(因为结果彼此独立)。 并行方法可以极大地加快机器学习过程:

图 2.8 –并行执行通行证的方法
计算统一设备架构(CUDA)是 Nvidia GPU 特有的技术,可在 PyTorch 上实现硬件加速。 为了启用 CUDA,我们首先必须确保我们系统上的图形卡兼容 CUDA。 可在此处找到 CUDA 兼容 GPU 的列表。 如果您具有兼容 CUDA 的 GPU,则可以从此链接安装 CUDA。 我们将使用以下步骤激活它:
-
首先,为了在 PyTorch 上实际启用 CUDA 支持,您将必须从源代码构建 PyTorch。 有关如何完成此操作的详细信息,请参见以下网址。
-
然后,要在 PyTorch 代码中实际使用 CUDA,我们必须在 Python 代码中键入以下内容。
cuda = torch.device('cuda')这会将我们的默认 CUDA 设备的名称设置为
'cuda'。 -
然后,我们可以通过在任何张量操作中手动指定设备参数来执行对这个设备的操作。
x = torch.tensor([5., 3.], device=cuda)另外,我们可以通过调用
cuda方法来做到这一点:y = torch.tensor([4., 2.]).cuda() -
然后,我们可以运行一个简单的操作,以确保这是正确的工作。
x * y结果为以下输出:

图 2.9 –使用 CUDA 的张量乘法输出
由于我们只是在创建张量,因此速度的变化在此阶段不会很明显,但是当以后开始大规模训练模型时,我们将看到使用 CUDA 并行化计算的速度优势。 通过并行训练我们的模型,我们将能够节省大量时间。
将 PyTorch 与其他深度学习框架进行比较
PyTorch 是当今深度学习中使用的主要框架之一。 还存在其他广泛使用的框架,例如 TensorFlow,Theano 和 Caffe 等。 尽管它们在很多方面都非常相似,但是它们的操作方式还是有一些关键的区别。 其中包括:
- 如何计算模型
- 计算图的编译方式
- 创建具有可变层的动态计算图的能力
- 语法差异
可以说,PyTorch 与其他框架之间的主要区别在于模型本身的计算方式。 PyTorch 使用称为 autograd 的自动微分方法,该方法允许动态定义和执行计算图。 这与其他框架(如 TensorFlow)相反,后者是静态框架。 在这些静态框架中,必须在最终执行之前定义和编译计算图。 尽管使用预编译的模型可以提高生产效率,但在研究和探索项目中却无法提供相同水平的灵活性。
在训练模型之前,诸如 PyTorch 之类的框架无需预先编译计算图。 PyTorch 使用的动态计算图意味着在执行图时对其进行编译,从而可以随时定义图。 动态模型构建方法在 NLP 领域特别有用。 让我们考虑两个我们希望对以下内容进行情感分析的句子:

图 2.10 – PyTorch 中的模型构造
我们可以将这些句子中的每一个表示为单个单词向量的序列,然后形成我们对神经网络的输入。 但是,正如我们所看到的,我们每个输入的大小都是。 在固定计算图中,这些变化的输入大小可能是个问题,但是对于 PyTorch 这样的框架,模型能够动态调整以解决输入结构的变化。 这就是为什么 PyTorch 经常被 NLP 相关的深度学习首选的原因之一。
PyTorch 与其他深度学习框架之间的另一个主要区别是语法。 PyTorch 通常是具有 Python 经验的开发人员首选,因为它本质上被认为是非常 Python 的。 PyTorch 与 Python 生态系统的其他方面很好地集成在一起,如果您具有 Python 的先验知识,则非常容易学习。 现在,我们将从头开始编写我们自己的神经网络,以演示 PyTorch 语法。
在 PyTorch 中构建简单的神经网络
现在,我们将逐步在 PyTorch 中逐步构建神经网络。 在这里,我们有一个小的.csv文件,其中包含来自 MNIST 数据集的图像的几个示例。 MNIST 数据集由我们想要尝试分类的 0 到 9 之间的手绘数字组成。 以下是来自 MNIST 数据集的示例,其中包括手绘数字 1:

图 2.11 –来自 MNIST 数据集的样本图像
这些图像的尺寸为28x28:总共 784 像素。 我们在train.csv中的数据集由 1,000 幅这些图像组成,每幅图像均由 784 个像素值以及正确的数字分类(在这种情况下为 1)组成。
加载数据
我们将从加载数据开始,如下所示:
-
首先,我们需要加载我们的训练数据集,如下。
train = pd.read_csv("train.csv") train_labels = train['label'].values train = train.drop("label",axis=1).values.reshape(len(test),1,28,28)请注意,我们将输入重塑为
[1, 1, 28, 28],是 1,000 张图像的张量,每个图像由28x28像素组成。 -
接下来,我们将我们的训练数据和训练标签转换为 PyTorch 张量,以便它们可以被输入到神经网络中。
X = torch.Tensor(train.astype(float)) y = torch.Tensor(train_labels).long()
注意这两个张量的数据类型。 浮点张量由 32 位浮点数组成,而长张量由 64 位整数组成。 为了使 PyTorch 能够计算梯度,我们的X函数必须为浮点数,而我们的标签必须为该分类模型中的整数(因为我们正在尝试预测 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一样,在 Python 中构建普通类,从而构建分类器。 在我们的__init__方法中,我们定义了神经网络的每一层。 在这里,我们定义了大小可变的全连接线性层。
我们的第一层接受784输入,因为这是我们要分类的每个图像的大小(28x28)。 然后,我们看到一层的输出必须与下一层的输入具有相同的值,这意味着我们的第一个全连接层输出392个单元,而我们的第二层则采用392单元作为输入。 对每一层都重复一次,每次它们具有一半的单元数量,直到我们到达最终的全连接层为止,该层输出10个单元。 这是我们分类层的长度。
我们的网络现在看起来像这样:

图 2.12 –我们的神经网络
在这里,我们可以看到我们的最后一层输出了10个单元。 这是因为我们希望预测每个图像是否为 0 到 9 之间的数字,总共是 10 种不同的可能分类。 我们的输出是长度为10的向量,并且包含图像的 10 种可能值中的每一个的预测。 在进行最终分类时,我们将数值最高的数字分类作为模型的最终预测。 例如,对于给定的预测,我们的模型可能会预测图像类型为 1 的概率为 10%,类型 2 的概率为 10%,类型 3 的概率为 80%。 因此,我们将类型 3 作为预测,因为它以最高概率被预测。
实现丢弃
在我们的MNISTClassifier类的__init__方法中,我们还定义了一种丢弃方法,以帮助规范网络:
self.dropout = nn.Dropout(p=0.2)
丢弃是一种规范化我们的神经网络以防止过拟合的方法。 在每个训练周期上,对于已应用丢包的层中的每个节点,都有可能(此处定义为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()方法是我们在其中应用激活函数并定义在我们的网络中应用丢弃的位置的方法。 我们的forward()方法定义了输入将通过网络的路径。 首先,它获取我们的输入x,并将其整形以在网络中使用,并将其转换为一维向量。 然后,我们将其通过我们的第一个全连接层,并将其包装在 ReLU 激活函数中,以使其为非线性。 我们也将其包装在我们的丢弃中,如__init__方法中所定义。 我们对网络中的所有其他层重复此过程。
对于我们的最终预测层,我们将其包装在log_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 –我们网络的损失表示
然后将其汇总到数据集中所有正确的类中,以计算总损失。 请注意,我们在构建分类器时定义了对数 softmax,因为它已经应用了 softmax 函数(将预测输出限制在 0 到 1 之间)并获取了日志。 这意味着log(y)已经被计算出来,因此我们要计算网络上的总损失所需要做的就是计算输出的负和。
我们还将优化器定义为 Adam 优化器。 优化器控制模型中的学习率。 模型的学习率定义了每次训练期间参数更新的大小。 学习率的大小越大,梯度下降期间参数更新的大小越大。 优化器动态控制该学习率,以便在初始化模型时,参数更新很大。 但是,随着模型的学习和向损失最小化点的靠近,优化器将控制学习率,因此参数更新变得更小,可以更精确地定位局部最小值。
训练我们的网络
最后,我们实际上可以开始训练我们的网络了:
-
首先,创建一个循环,为我们训练的每一个周期运行一次。在这里,我们将在 50 个周期中运行我们的训练循环。我们首先将输入的图像张量和输出的标签张量转化为 PyTorch 变量。一个变量是一个 PyTorch 对象,它包含一个
backward()方法,我们可以使用该方法通过我们的网络进行反向传播。for epoch in range(50): images = Variable(X) labels = Variable(y) -
接下来,我们在优化器上调用
zero_grad(),将计算出的梯度设为零。在 PyTorch 中,梯度是在每次反向传播时累计计算的。虽然这在某些模型中很有用,例如在训练 RNNs 时,但对于我们的示例,我们希望在每个周期后从头开始计算梯度,因此我们确保在每次通过后将梯度重置为零。opt.zero_grad() -
接下来,我们使用模型的当前状态对我们的数据集进行预测。这实际上是我们的正向传播,因为我们然后使用这些预测来计算我们的损失。
outputs = model(images) -
使用输出和我们数据集的真实标签,我们使用定义的损失函数计算我们模型的总损失,在这种情况下,它是负对数似然。在计算出这个损失后,我们就可以调用
backward(),通过网络反推我们的损失。然后,我们使用step()来使用我们的优化器,以便相应地更新我们的模型参数。loss = loss_function(outputs, labels) loss.backward() opt.step() -
最后,在每个周期完成后,我们打印总损失。我们可以观察到这一点,以确保我们的模型正在学习。
print ('Epoch [%d/%d] Loss: %.4f' %(epoch+1, 50, loss.data.item()))
一般而言,我们预计损失会在每个周期减少。 我们的输出将如下所示:

图 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 –预测输出
在这里,我们可以看到我们的预测是一个长度为 10 的向量,并且对每个可能的类别(0 到 9 之间的数字)进行了预测。 预测值最高的是我们的模型选择的预测值。 在这种情况下,它是向量的第十个单元,等于数字 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 –预测表
注意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 –准确率得分
恭喜你! 您的第一个神经网络能够正确识别近 90% 的看不见的数字图像。 随着我们的前进,我们将看到可能会带来更先进的表现。 但是,到目前为止,我们已经证明使用 PyTorch 创建简单的深度神经网络非常简单。 只需几行代码就可以编写代码,从而获得超越基本机器学习模型(例如回归)所能达到的表现。
用于 PyTorch 的 NLP
现在我们已经学习了如何构建神经网络,我们将看到如何使用 PyTorch 为 NLP 构建模型。 在此示例中,我们将创建一个基本的词袋分类器,以对给定句子的语言进行分类。
设置分类器
在此示例中,我们将选择西班牙语和英语的句子:
-
首先,我们将每个句子拆分成一个单词列表,并将每个句子的语言作为标签。我们取一部分句子来训练我们的模型,并在一边保留一小部分作为我们的测试集。我们这样做是为了在训练完模型后,可以评估模型的表现。
("This is my favourite chapter".lower().split(),\ "English"), ("Estoy en la biblioteca".lower().split(), "Spanish")请注意,我们还将每个单词转换为小写,这将阻止单词在单词袋中重复计算。 如果我们有单词
book和单词Book,我们希望将它们视为相同的单词,因此将它们转换为小写。 -
接下来,我们建立我们的单词索引,它只是一个语料库中所有单词的字典,然后为每个单词创建一个唯一的索引值。这可以通过一个简短的
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)结果为以下输出:
![Figure 2.18 – Setting up the classifier]()
图 2.18 –设置分类器
请注意,在这里,我们遍历了所有训练数据和测试数据。 如果我们只是在训练数据上创建单词索引,则在评估测试集时,我们将拥有在原始训练中看不到的新单词,因此我们将无法创建这些单词的真正的词袋表示形式。
-
现在,我们以类似于上一节中构建神经网络的方式来构建我们的分类器,即通过构建一个新的类,该类继承自
nn.Module。在这里,我们定义分类器,以使其由单个线性层组成,该线性层具有对数 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,这是我们可以预测的语言数量。 我们的最终预测将包括我们的句子是英语的概率与我们的句子是西班牙文的概率,而我们的最终预测是概率最高的那个。 -
接下来,我们定义一些实用函数。我们首先定义
make_bow_vector,它将句子转化为一个词袋的表示。我们首先创建一个由所有零组成的向量。然后,我们对它们进行循环,对于句子中的每一个词,我们将该词在词袋向量中的索引数增加 1。最后,我们使用.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) -
同样,我们定义了
make_target,它只是简单地取句子(西班牙语或英语)的标签,并返回其相关索引(0或1)。def make_target(label, label_index): return torch.LongTensor([label_index[label]]) -
现在我们可以创建一个模型的实例,准备进行训练。我们还将我们的损失函数定义为负对数似然,因为我们使用的是对数 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 –训练损失
在这里,我们可以看到,随着模型的学习,我们的损失随着时间而减少。 尽管本例中的训练集很小,但是我们仍然可以证明我们的模型学到了一些有用的东西,如下所示:
-
我们根据未接受模型训练的测试数据中的几句话来评估模型。 在这里,我们首先设置
torch.no_grad(),这将停用 autograd 引擎,因为由于我们不再训练模型,因此不再需要计算梯度。 接下来,我们将测试句子转换为词袋向量,并将其输入模型以获取预测。 -
然后我们只需打印出句子、句子的真实标签,再打印出预测的概率。注意,我们将预测值从对数概率转化回概率。我们为每个预测得到两个概率,但如果我们参考标签索引,我们可以看到第一个概率(索引 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])结果为以下输出:
![Figure 2.20 – Predicted output]()
图 2.20 –预测的输出
在这里,我们可以看到,对于我们的两个预测,我们的模型都可以预测正确的答案,但是为什么会这样呢? 我们的模型究竟学到了什么? 我们可以看到我们的第一个测试句子包含单词
estoy,该单词先前在我们的训练集中的西班牙语句子中出现过。 同样,我们可以看到book一词在我们的训练集中以英语句子出现。 由于我们的模型由单层组成,因此每个节点上的参数都易于解释。 -
在这里,我们定义了一个函数,它将一个词作为输入,并返回层内每个参数的权重。对于一个给定的词,我们从我们的字典中得到这个词的索引,然后从模型内的相同索引中选择这些参数。请注意,我们的模型会返回两个参数,因为我们是在做两个预测,即模型对西班牙语预测的贡献和模型对英语预测的贡献。
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 –更新函数的预测输出
在这里,我们可以看到,对于单词estoy而言,此参数对西班牙语的预测为正,对英语的预测为负。 这意味着对于我们句子中的每个单词estoy,该句子更有可能是西班牙语句子。 类似地,对于book一词,我们可以看到这对句子为英语的预测有积极作用。
我们可以证明我们的模型仅基于对其进行了训练而学习。 如果我们尝试预测尚未训练过的单词,则可以看到它无法做出准确的决定。 在这种情况下,我们的模型认为英语单词not是西班牙语:
new_sentence = (["not"],"English")
make_predictions(new_sentence)
结果为以下输出:

图 2.22 – 最终输出
总结
在本章中,我们介绍了 PyTorch 及其一些关键功能。 希望您现在对 PyTorch 与其他深度学习框架有何不同以及如何用于构建基本神经网络有了更好的了解。 尽管这些简单的示例只是冰山一角,但我们已经说明了 PyTorch 是用于 NLP 分析和学习的强大工具。
在以后的章节中,我们将演示如何利用 PyTorch 的独特属性来构建高度复杂的模型,以解决非常复杂的机器学习任务。
三、NLP 和文本嵌入
在深度学习中,有许多种表示文本的方式。 虽然我们已经介绍了基本的词袋(BoW)表示形式,但不足为奇的是,还有一种更为复杂的表示文本数据的方式称为嵌入。 BoW 向量仅充当句子中单词的计数,而嵌入有助于从数字上定义某些单词的实际含义。
在本章中,我们将探讨文本嵌入,并学习如何使用连续 BoW 模型创建嵌入。 然后,我们将继续讨论 n 元语法以及如何在模型中使用它们。 我们还将介绍标记,分块和分词可用于将 NLP 分成其各个组成部分的各种方式。 最后,我们将研究 TF-IDF 语言模型,以及它们如何对不经常出现的单词加权我们的模型。
本章将涵盖以下主题:
- 词嵌入
- 探索 CBOW
- 探索 N 元组
- 分词
- 对词性进行标记和分块
- TF-IDF
技术要求
可以从这里下载 GLoVe 向量。 建议使用Gloves.6B.50d.txt文件,因为它比其他文件小得多,并且处理起来也快得多。 本章后面的部分将要求 NLTK。 本章的所有代码都可以在这个页面中找到。
NLP 的嵌入
单词没有表示其含义的自然方式。 在图像中,我们已经具有丰富的向量表示形式(包含图像中每个像素的值),因此显然具有单词的类似丰富的向量表示形式将是有益的。 当语言的部分以高维向量格式表示时,它们称为嵌入。 通过分析单词的语料库,并确定哪些单词经常出现在一起,我们可以获得每个单词的n长度向量,它可以更好地表示每个单词与所有其他单词的语义关系。 先前我们看到,我们可以轻松地将单词表示为单热编码的向量:

图 3.1 –单热编码向量
另一方面,嵌入是长度为n(在以下示例中为n = 3)的向量,可以采用任何值:

图 3.2 – n = 3的向量
这些嵌入表示n-维空间中的单词向量(其中n是嵌入向量的长度),并且在该空间中具有相似向量的单词被认为更相似。 含义。 尽管这些嵌入可以是任何大小,但它们的尺寸通常比 BoW 表示的尺寸低得多。 BOW 表示需要向量,该向量的长度为整个语料库的长度,当以一种整体语言查看时,它们可能很快变得非常大。 尽管嵌入的维数足够高以表示单个单词,但它们通常不超过几百个维。 此外,BOW 向量通常非常稀疏,主要由零组成,而嵌入则包含大量数据,并且每个维度都有助于单词的整体表示。 较低的维数和它们不稀疏的事实使得对嵌入执行深度学习比对 BOW 表示执行深度学习要有效得多。
GLoVe
我们可以下载一组预先计算的词嵌入,以演示它们如何工作。 为此,我们将使用用于词表示的全局向量(GLoVe)嵌入,可以从此处下载。 这些嵌入是在非常大的 NLP 数据集上计算的,并且在词共现矩阵上训练了。 这是基于这样的概念,即在一起出现的单词更有可能具有相似的含义。 例如,单词sun与单词hot相对于单词cold更有可能出现,因此sun和hot被认为更相似。
我们可以通过检查单个 GLoVe 向量来验证这是正确的:
-
我们首先创建一个简单的函数来从文本文件中加载我们的 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') -
这意味着我们只需从字典中调用一个向量就可以访问它。
glove['python']这将产生以下输出:
![Figure 3.3 – Vector output]()
图 3.3 –向量输出
我们可以看到,这将返回单词 Python 的 50 维向量嵌入。 现在,我们将引入余弦相似度的概念,以比较两个向量的相似度。 如果向量之间的
n维空间中的角度为 0 度,则向量的相似度为 1。 余弦相似度高的值即使不相等也可以被认为是相似的。 可以使用以下公式进行计算,其中A和B是要比较的两个嵌入向量:![]()
-
我们可以使用 Sklearn 中的
cosine_similarity()函数在 Python 中轻松计算。我们可以看到,猫和狗有相似的向量,因为它们都是动物。cosine_similarity(glove['cat'].reshape(1, -1), glove['dog'].reshape(1, -1))结果为以下输出:
![Figure 3.4 – Cosine similarity output for cat and dog]()
图 3.4 –猫和狗的余弦相似度输出
-
然而,猫和钢琴是完全不同的,因为它们是两个看似不相关的项目。
cosine_similarity(glove['cat'].reshape(1, -1), glove['piano'].reshape(1, -1))结果为以下输出:

图 3.5 –猫和钢琴的余弦相似度输出
嵌入操作
由于嵌入是向量,因此我们可以对它们执行操作。 例如,假设我们将嵌入用于以下类别,然后计算出以下内容:
queen - womam + man
这样,我们可以近似嵌入king的嵌入。 这基本上用Man向量替换了Queen的Woman向量分量,从而得出了这种近似值。 我们可以通过图形方式对此进行说明,如下所示:

图 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 向量的输出
尽管 GLoVe 嵌入是非常有用的预先计算的嵌入,但实际上我们可以计算自己的嵌入。 当我们分析特别独特的语料库时,这可能很有用。 例如,Twitter 上使用的语言可能与维基百科上使用的语言不同,因此在一个语言上训练的嵌入可能对另一个语言没有用。 现在,我们将演示如何使用连续的词袋来计算自己的嵌入。
探索 CBOW
连续词袋(CBOW)模型构成 Word2Vec 的一部分–由 Google 创建的模型,用于获取单词的向量表示 。 通过在非常大的语料库上运行这些模型,我们能够获得单词的详细表示,这些单词表示它们的语义和上下文相似性。 Word2Vec 模型包含两个主要组件:
- CBOW:给定周围的单词,该模型尝试预测文档中的目标单词。
- SkipGram:这与 CBOW 相反; 该模型尝试在给定目标词的情况下预测周围的词。
由于这些模型执行类似的任务,因此我们现在仅关注一个,特别是 CBOW。 该模型旨在预测单词(目标单词),并为其指定其他单词(称为上下文单词)。 解决上下文单词的一种方法可能是,就像一样简单,使用句子中目标单词之前的单词来预测目标单词,而更复杂的模型可以在目标单词之前和之后使用多个单词。 考虑以下句子:
PyTorch is a deep learning framework
假设我们要根据上下文词来预测单词deep:
PyTorch is a {target_word} learning framework
我们可以通过多种方式看待这一问题:

图 3.8 –上下文和表示表
对于我们的 CBOW 模型,我们将使用长度为 2 的窗口,这意味着对于模型的(X, y)输入/输出对,我们将使用[n-2, n-1, n+1, n+2, n],其中n是我们要预测的目标词。
使用这些作为模型输入,我们将训练一个包含嵌入层的模型。 此嵌入层自动形成我们语料库中单词的n维表示。 但是,首先,使用随机权重初始化该层。 这些参数是使用我们的模型学习的,因此,在我们的模型完成训练之后,可以使用此嵌入层来将我们的语料库编码为嵌入式向量表示形式。
CBOW 架构
现在,我们将设计模型的架构,以学习我们的嵌入。 在这里,我们的模型接受四个单词的输入(目标单词之前两个,之后两个单词),并针对输出(我们的目标单词)训练它。 下面的图示说明了它的外观:

图 3.9 – CBOW 架构
输入的单词首先通过嵌入层,表示为大小(n, l)的张量,其中n是嵌入的指定长度,l是语料库中单词的数量。 这是因为语料库中的每个单词都有其自己独特的张量表示形式。
然后使用来自四个上下文词的组合(求和)嵌入,将其馈入一个全连接层中,以学习针对目标词与上下文词的嵌入表示形式进行最终分类。 请注意,我们的预测词/目标词被编码为向量,即我们的语料库的长度。 这是因为我们的模型可以有效地预测语料库中每个单词成为目标单词的概率,而最终分类是概率最高的一个。 然后,我们得到一个损失,通过我们的网络反向传播,并更新全连接层上的参数以及嵌入本身。
该方法之所以有效,是因为我们学习到的嵌入表示语义相似性。 假设我们在以下方面训练模型:
X = ["is", "a", "learning", "framework"]; y = "deep"
我们的模型从本质上要学习的是,目标词的组合嵌入表示在语义上与目标词相似。 如果我们在足够大的单词语料库上重复此操作,我们会发现我们的单词嵌入开始类似于我们以前看到的 GLoVe 嵌入,在语义上相似的单词在嵌入空间中彼此出现。
构建 CBOW
现在,我们将贯穿,从头开始构建 CBOW 模型,从而说明如何学习嵌入向量:
-
我们首先定义一些文本,并进行一些基本的文本清理,删除基本的标点符号,并将其全部转换为小写。
text = text.replace(',','').replace('.','').lower().split() -
我们首先定义我们的语料库及其长度。
corpus = set(text) corpus_length = len(corpus) -
请注意,我们使用的是集合而不是列表,因为我们只关注文本中的唯一词汇。然后我们建立我们的语料库索引和反语料库索引。我们的语料库索引将允许我们获得给定单词本身的索引,这将在编码单词进入我们的网络时非常有用。我们的反语料库索引允许我们获得一个词,给定的索引值,这将用于将我们的预测转换回单词。
word_dict = {} inverse_word_dict = {} for i, word in enumerate(corpus): word_dict[word] = i inverse_word_dict[i] = word -
接下来,我们对我们的数据进行编码。我们在语料库中循环,对于每个目标词,我们捕捉上下文词(前面的两个词和后面的两个词)。我们将此与目标词本身附加到我们的数据集中。请注意,我们如何从语料库中的第三个词开始(索引为
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])结果为以下输出:
![Figure 3.10 – Encoding the data]()
图 3.10 –编码数据
-
然后,我们定义我们的嵌入的长度。虽然从技术上讲,这个长度可以是任何你想要的数字,但也要考虑一些权衡。虽然高维嵌入可以导致更详细的单词表示,但特征空间也会变得更稀疏,这意味着高维嵌入只适用于大型语料库。此外,更大的嵌入意味着更多的参数需要学习,所以增加嵌入大小会大大增加训练时间。我们只是在一个很小的数据集上进行训练,所以我们选择使用大小
20的嵌入。embedding_length = 20接下来,我们在 PyTorch 中定义 CBOW 模型。 我们定义嵌入层,以便它接受语料库长度的向量,并输出单个嵌入。 我们将线性层定义为一个全连接层,该层将嵌入并输出
64的向量。 我们将最后一层定义为与文本语料库相同长度的分类层。 -
我们通过获取和求和所有输入语境词的嵌入来定义我们的前向过程,然后通过 ReLU 激活函数的全连接层,最后进入分类层。然后通过 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 -
我们还可以定义一个
get_word_embedding()函数,它可以让我们在训练完我们的模型后提取给定单词的嵌入。def get_word_emdedding(self, word): word = torch.LongTensor([word_dict[word]]) return self.embeddings(word).view(1,-1) -
现在,我们已经准备好训练我们的模型。我们首先创建一个模型的实例,并定义损失函数和优化器。
model = CBOW(corpus_length, embedding_length) loss_function = nn.NLLLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.01) -
然后,我们创建一个辅助函数,将我们的输入语境词,得到每一个语境词的词索引,并将其转换为一个长度为 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))结果为以下输出:
![Figure 3.11 – Tensor value]()
图 3.11 –张量值
-
现在,我们训练我们的网络。我们循环通过 100 个周期,对于每一个通道,我们循环通过我们所有的上下文词,也就是目标词对。对于这些对,我们使用
make_sentence_vector()加载上下文句子,并使用我们当前的模型状态来获得预测。我们根据我们的实际目标评估这些预测,以获得我们的损失。我们反推计算梯度,并通过我们的优化器来更新权重。最后,我们将该周期的所有损失相加并打印出来。在这里,我们可以看到,我们的损失正在减少,这表明我们的模型正在学习。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()))这将产生以下输出:
![Figure 3.12 – Training our network]()
图 3.12 –训练我们的网络
现在我们的模型已经训练完毕,我们可以进行预测了。 我们定义了几个函数来允许我们这样做。
get_predicted_result()从预测数组中返回预测的单词,而我们的predicted_sentence()函数则根据上下文单词进行预测。 -
我们将我们的句子分割成单个单词,并将它们转化为一个输入向量。然后我们将其输入到模型中,创建我们的预测数组,并使用
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')结果为以下输出:
![Figure 3.13 – Predicted values]()
图 3.13 –预测值
-
现在我们已经有了一个训练好的模型,我们能够使用
get_word_embedding()函数,以便返回我们语料库中任何单词的 20 维单词嵌入。如果我们在另一个 NLP 任务中需要我们的嵌入,我们实际上可以从整个嵌入层中提取权重,并将其用于我们的新模型中。print(model.get_word_emdedding('leap'))结果为以下输出:

图 3.14 –编辑模型后的张量值
在这里,我们演示了如何训练 CBOW 模型来创建单词嵌入。 实际上,要为语料库创建可靠的嵌入,我们将需要非常大的数据集,才能真正捕获所有单词之间的语义关系。 因此,对于您的模型,最好使用经过预先训练的嵌入,例如 GLoVe,它们已经在非常大的数据集上进行了训练,但是在某些情况下,最好对模型进行训练。 从头开始全新的嵌入集; 例如,当分析与正常 NLP 不同的数据语料库时(例如,Twitter 数据,用户可能会使用简短的缩写而不使用完整的句子)。
探索 N 元组
在我们的 CBOW 模型中,我们成功表明单词的含义与周围单词的上下文有关。 影响句子中单词含义的不仅是我们的上下文单词,还影响了这些单词的顺序。 考虑以下句子:
The cat sat on the dog
The dog sat on the cat
如果将这两个句子转换成词袋表示法,我们将看到它们是相同的。 但是,通过阅读这些句子,我们知道它们的含义完全不同(实际上,它们是完全相反的!)。 这清楚地表明,句子的含义不仅是其包含的单词,还包括它们出现的顺序。 尝试捕获句子中单词顺序的一种简单方法是使用 N 元组。
如果我们对句子进行计数,而不是对单个单词进行计数,我们现在计算句子中出现的不同的两个单词对,这就是,即使用二元语法:

图 3.15 –二元语法的表格表示
我们可以这样表示:
The cat sat on the dog -> [1,1,1,0,1,1]
The dog sat on the cat -> [1,1,0,1,1,1]
这些单词对试图捕捉单词在句子中出现的顺序,而不仅仅是它们的出现频率。 我们的第一句话包含两字组cat sat,而另一句话包含dog sat。 这些二元组显然可以帮助增加句子的上下文,而不仅仅是使用原始单词计数。
我们不仅限于单词。 我们还可以查看称为三元组或实际上是个不同数量的单词的不同单词三元组。 我们可以使用 N 元组作为深度学习模型的输入,而不仅仅是单个单词,但是,当使用 N 元组模型时,值得注意的是,您的特征空间会很快变得很大,并且可能使机器学习变得非常慢。 如果词典包含英语中的所有单词,则包含所有不同单词对的词典将大几个数量级!
N 元组语言建模
N 元组帮助我们做的一件事是了解自然语言是如何形成的。 如果我们认为一种语言是由较小的单词对(二元图)的一部分而不是单个单词代表的,则可以开始将语言建模为概率模型,其中单词出现在句子中的概率取决于它之前出现的单词。
在一元模型中,我们假设基于单词在语料库或文档中的分布,所有单词都有出现的可能性。 我们来看一个包含一个句子的文档:
My name is my name
基于此句子,我们可以生成单词的分布,其中每个单词根据其在文档中的出现频率具有给定的出现概率:

图 3.16 –字母组合的表格表示
然后,我们可以从该分布中随机抽取单词,以生成新的句子:
Name is Name my my
但是正如我们所看到的,这句话毫无意义,说明了使用会标模型的问题。 因为每个单词出现的概率与句子中的所有其他单词无关,所以没有考虑单词出现的顺序或上下文。 这是 N 元组模型有用的地方。
现在,我们将考虑使用二元语言模型。 给定出现在单词前面的单词,此计算将考虑单词出现的概率:

这意味着,给定前一个单词,单词出现的概率是单词 N 元组出现的概率除以前一个单词出现的概率。 假设我们正在尝试预测以下句子中的下一个单词:
My favourite language is ___
随之,我们得到以下 N 元组和单词概率:

图 3.17 –概率的表格表示
有了这个,我们可以计算出出现 Python 的概率,假设前一个单词为出现的概率仅为 20%,而英语出现的概率仅为 10%。 我们可以进一步扩展此模型,以使用我们认为适当的来表示单词的三元组或任何 N 元组。 我们已经证明,可以使用 N 元组语言建模将关于单词之间的相互关系的更多信息引入我们的模型,而不必朴素地假设单词是独立分布的。
分词
接下来,我们将学习 NLP 的分词化,这是一种预处理文本的方式,可以输入到模型中。 分词将我们的句子分成较小的部分。 这可能涉及将一个句子拆分成单个单词,或者将整个文档分解成单个句子。 这是 NLP 必不可少的预处理步骤,可以在 Python 中相当简单地完成:
-
我们先接收一个基本的句子,用 NLTK 中的分词器把这个句子分割成各个词。
text = 'This is a single sentence.' tokens = word_tokenize(text) print(tokens)结果为以下输出:
![Figure 3.18 – Splitting the sentence]()
图 3.18 –拆分句子
-
请注意句号(
.)是如何被认为是一个符号,因为它是自然语言的一部分。根据我们对文本的处理,我们可能希望保留或放弃标点符号。no_punctuation = [word.lower() for word in tokens if word.isalpha()] print(no_punctuation)打印(无标点符号)
结果为以下输出:
![Figure 3.19 – Removing the punctuation]()
图 3.19 –删除标点符号
-
我们还可以使用句子分词器将文档标记为单个句子。
text = "This is the first sentence. This is the second sentence. A document contains many sentences." print(sent_tokenize(text))结果为以下输出:
![Figure 3.20 – Splitting multiple sentences into single sentences]()
图 3.20 –将多个句子拆分为单个句子
-
另外,我们也可以将两者结合起来,拆成单独的词句。
print([word_tokenize(sentence) for sentence in sent_tokenize(text)])结果为以下输出:
![Figure 3.21 – Splitting multiple sentences into words]()
图 3.21 –将多个句子分解为单词
-
在分词的过程中,还有一个可选的步骤,那就是去除停顿词。歇后语是非常常见的词,对句子的整体意思没有帮助。这些词包括
a、I和or等。我们可以使用下面的代码从 NLTK 中打印出一个完整的列表。stop_words = stopwords.words('english') print(stop_words[:20])结果为以下输出:
![Figure 3.22 – Displaying stopwords]()
图 3.22 –显示停用词
-
我们可以利用基本的列表理解,轻松地将这些停顿词从我们的单词中删除。
text = 'This is a single sentence.' tokens = [token for token in word_tokenize(text) if token not in stop_words] print(tokens)结果为以下输出:

图 3.23 –删除停用词
尽管某些 NLP 任务(例如预测句子中的下一个单词)需要停用词,但其他任务(例如判断电影评论的情感)则不需要停用词,因为停用词对文档的整体含义没有多大帮助。 在这种情况下,最好删除停用词,因为这些常用词的出现频率意味着它们可能不必要地增加了我们的特征空间,从而增加了模型训练所需的时间。
对词性进行标记和分块
到目前为止,我们已经涵盖了几种表示单词和句子的方法,包括词袋,嵌入和 N 元组。 但是,这些表示无法捕获任何给定句子的结构。 在自然语言中,不同的单词在句子中可以具有不同的功能。 考虑以下:
The big dog is sleeping on the bed
我们可以根据句子中每个单词的功能来“标记”此文本的各个单词。 因此,前面的句子变为:
The -> big -> dog -> is -> sleeping -> on -> the -> bed
Determiner -> Adjective -> Noun -> Verb -> Verb -> Preposition -> Determiner-> Noun
这些词性包括但不限于以下内容:

图 3.24 –词性
这些不同的语音部分可以用来更好地理解句子的结构。 例如,形容词通常在英语名词之前。 我们可以在模型中使用这些词性及其相互之间的关系。 例如,如果我们要预测句子中的下一个单词,并且上下文单词是形容词,则我们知道下一个单词为名词的可能性很高。
标记
词性标记是将这些词性标签分配给句子中各个单词的动作。 幸运的是,NTLK 具有内置的标记功能,因此我们不需要训练自己的分类器就能做到:
sentence = "The big dog is sleeping on the bed"
token = nltk.word_tokenize(sentence)
nltk.pos_tag(token)
结果为以下输出:

图 3.25 –分类词性
在这里,我们简单地标记我们的文本并调用pos_tag()函数以标记句子中的每个单词。 这将为句子中的每个单词返回一个标签。 我们可以通过在代码上调用upenn_tagset()来解码此标签的含义。 在这种情况下,我们可以看到VBG对应于一个动词:
nltk.help.upenn_tagset("VBG")
结果为以下输出:

图 3.26 – VBG 的说明
使用经过预先训练的语音标记器的部分是有好处的,因为它们不仅充当字典,可以查找句子中的各个单词。 他们还使用句子中单词的上下文来分配其含义。 考虑以下句子:
He drinks the water
I will buy us some drinks
这些句子中的drinks代表了两个不同的语音部分。 在的第一句话中,drinks是指动词;drink动词的现在时。在第二句中,drinks是指名词; 单数drink的复数形式。 我们的训练过的标记器能够确定这些单个单词的上下文并执行语音标记的准确部分。
分块
分块扩展了语音标记的初始部分,旨在将我们的句子分成小块,其中这些大块中的每一个都代表一小部分语音。
我们可能希望将文本拆分为实体,其中每个实体都是单独的对象或事物。 例如,the red book不是指三个单独的实体,而是由三个单词描述的单个实体。 我们可以轻松地再次使用 NLTK 实现分块。 我们必须首先定义一个语法模式以使用正则表达式进行匹配。 问题中的模式查找名词短语(NP),其中名词短语定义为确定词(DT),然后是可选形容词(JJ),然后是名词(NN):
expression = ('NP: {<DT>?<JJ>*<NN>}')
使用RegexpParser()函数,我们可以匹配此表达式的出现并将其标记为名词短语。 然后,我们可以打印结果树,显示标记的短语。 在我们的例句中,我们可以看到dog和bed被标记为两个单独的名词短语。 我们可以根据需要使用正则表达式匹配定义的任何文本块:
tagged = nltk.pos_tag(token)
REchunkParser = nltk.RegexpParser(expression)
tree = REchunkParser.parse(tagged)
print(tree)
结果为以下输出:

图 3.27 –树表示
TF-IDF
TF-IDF 是我们可以学习以更好地表示自然语言的另一种技术。 它通常用于文本挖掘和信息检索中,以基于搜索词匹配文档,但也可以与嵌入结合使用,以更好地以嵌入形式表示句子。 让我们用以下短语:
This is a small giraffe
假设我们要用一个嵌入来表示这句话的意思。 我们可以做的一件事就是简单地对这句话中五个单词中每个单词的平均嵌入进行平均:

图 3.28 –单词嵌入
但是,此方法为句子中的所有单词分配相等的权重。 您是否认为所有单词都对句子的含义有同等的贡献? This和a是英语中非常常见的单词,但是giraffe很少见。 因此,我们可能希望为稀有词分配更多权重。 这种方法被称为词频-反向文档频率(TD-IDF)。 现在,我们将演示如何计算文档的 TF-IDF 权重。
计算 TF-IDF
顾名思义,TF-IDF 由两个分开的部分组成:词频和文档反向频率。 词频是一种特定于文档的度量,用于计算要分析的文档中给定单词的频率:

请注意,由于较长的文档更可能包含任何给定的单词,因此我们将该度量除以文档中单词的总数。 如果单词在文档中出现多次,它将获得更高的词频。 但是,这与我们希望对 TF-IDF 进行加权相反,因为我们希望对文档中出现的稀有单词给予更高的加权。 这就是 IDF 发挥作用的地方。
文档频率测量要分析单词的整个文档库中文档的数量,逆文档频率计算总文档与文档频率的比率:


如果我们有一个 100 个文档的语料库,并且单词在它们之间出现 5 次,则文档的倒数频率为 20。这意味着在所有文档中出现次数较少的单词的权重较高。 现在,考虑一个 100,000 个文档的语料库。 如果一个单词仅出现一次,则 IDF 为 100,000,而出现两次的单词的 IDF 为 50,000。 这些非常大且易失的 IDF 对于我们的计算而言并不理想,因此我们必须首先使用日志对其进行归一化。 请注意,如果我们为未出现在语料库中的单词计算 TF-IDF,我们如何在计算中加 1 以防止被 0 除:

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

现在,我们可以演示如何在 Python 中实现它并将 TF-IDF 权重应用于我们的嵌入。
实现 TF-IDF
在这里,我们将使用 NLTK 数据集中的 Emma 语料对数据集实现 TF-IDF。 该数据集由 Jane Austen 的书《Emma》中的句子组成,我们希望为这些句子中的每一个计算一个嵌入式向量表示:
-
我们首先导入我们的数据集,并循环处理每一个句子,删除所有标点符号和非字母数字字符(如星号)。我们选择在我们的数据集中留下停顿词,以展示 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) -
接下来,我们创建一个函数,将返回给定文档中某个词的词频。我们以文档的长度来给出我们的词数,并计算这个词在文档中的出现次数,然后再返回比率。在这里,我们可以看到
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')结果为以下输出:
![Figure 3.29 – TF-IDF score]()
图 3.29 – TF-IDF 分数
-
接下来,我们计算我们的文档频率。为了有效地进行计算,我们首先需要预先计算一个文档频率词典。这将循环浏览所有数据,并统计语料库中每个词出现的文档数量。我们预先计算这个,这样我们就不必在每次计算某个词的文档频率时都要执行这个循环。
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'] -
在这里,我们可以看到,
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') -
最后,我们只需将词频和逆文档频率结合起来,就可以得到每个词/文档对的 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 – TF-IDF ago和indistinct的分数
在这里,我们可以看到,尽管ago和indistinct的单词在给定文档中仅出现一次,但indistinct出现在整个语料库中的频率较低, 表示它获得更高的 TF-IDF 权重。
计算 TF-IDF 加权嵌入
接下来,我们可以显示这些 TF-IDF 加权如何应用于嵌入:
-
我们首先加载我们预先计算的 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') -
然后,我们计算文档中所有单个嵌入的非加权平均数,以获得句子整体的向量表示。我们简单地循环浏览文档中的所有单词,从 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)结果为以下输出:
![Figure 3.31 – Mean embedding]()
图 3.31 –均值嵌入
-
我们重复这个过程来计算我们的 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)结果为以下输出:
![Figure 3.32 – TF-IDF embedding]()
图 3.32 – TF-IDF 嵌入
-
然后,我们可以将 TF-IDF 加权嵌入与我们的平均嵌入进行比较,看看它们的相似度。我们可以使用余弦相似度来实现,如下。
cosine_similarity(mean_embedding, tfidf_weighted_embedding)结果为以下输出:

图 3.33 – TF-IDF 和平均嵌入之间的余弦相似度
在这里,我们可以看到我们的两种不同表示形式非常相似。 因此,虽然使用 TF-IDF 可能不会显着改变我们对给定句子或文档的表示,但可能会偏重于感兴趣的单词,从而提供更有用的表示。
总结
在本章中,我们更深入地研究了词嵌入及其应用。 我们已经展示了如何使用连续词袋模型来训练它们,以及如何结合 N 元组语言模型来更好地理解句子中词之间的关系。 然后,我们研究了将文档拆分为单独的标记以进行轻松处理的方法,以及如何使用标记和分块来识别语音部分。 最后,我们展示了如何使用 TF-IDF 权重更好地以嵌入形式表示文档。
在下一章中,我们将看到如何使用 NLP 进行文本预处理,词干提取和词义化。
四、文本预处理,词干提取和词形还原
文本数据可以从许多不同的来源收集,并采用许多不同的形式。 文本可以整洁,可读或原始且混乱,也可以采用许多不同的样式和格式。 能够对这些数据进行预处理,以便可以在将其转换为 NLP 模型之前将其转换为标准格式,这就是我们将在本章中介绍的内容。
与分词相似,词干提取和词形还原是 NLP 预处理的其他形式。 但是,与将文档简化成单个单词的分词不同,词干提取和词形还原试图将这些单词进一步缩小到其词根。 例如,几乎所有英语动词都有许多不同的变体,具体取决于时态:
He jumped
He is jumping
He jumps
尽管所有这些词都不同,但它们都与相同的根词相关–jump。 词干提取和词形还原都是我们可以用来减少单词的共同词根变化的技术。
在本章中,我们将解释如何对文本数据执行预处理,并探讨词干提取和词形还原,并展示如何在 Python 中实现这些。
在本章中,我们将介绍以下主题:
- 文字预处理
- 词干提取
- 词形还原
- 词干提取和词形还原的用途
技术要求
对于本章中的文本预处理,我们将主要使用内置的 Python 函数,但也将使用外部 BeautifulSoup 包。 对于词干提取和词形还原,我们将使用 NLTK Python 包。 本章中的所有代码都可以在这个页面中找到。
文本预处理
文本数据可以采用多种格式和样式。 文本可以是结构化的可读格式,也可以是更原始的非结构化格式。 我们的文本可能包含我们不希望包含在模型中的标点符号和符号,或者可能包含 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
上面的屏幕快照显示 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 –将输入转换为小写
这表明输入已全部转换为相同的小写表示形式。 有一些示例中的大写字母实际上可以提供其他语义信息。 例如, May(五月)和may(意味着可能)在语义上有所不同, May(五月)将始终是大写。 但是,像这样的实例非常少见,将所有内容都转换为小写字母要比考虑这些少见的实例更为有效。
值得注意的是,大写可能在某些任务中很有用,例如语音标记的一部分(其中大写字母可能指示单词在句子中的作用)和命名实体识别(其中大写字母可能表明单词在句子中) 专有名词而不是非专有名词的替代; 例如Turkey(国家)和turkey(鸟类)。
删除标点符号
有时,根据所构建模型的类型,我们可能希望从输入文本中删除标点符号。 这在我们要汇总字数的模型中(例如在词袋表示中)特别有用。 句子中出现句号或逗号不会添加任何有关句子语义内容的有用信息。 但是,考虑到句子中标点符号位置的更复杂的模型实际上可能会使用标点符号的位置来推断不同的含义。 一个经典的例子如下:
The panda eats shoots and leaves
The panda eats, shoots, and leaves
在这里,加上逗号会将描述 Pandas 的饮食习惯的句子转换为描述 Pandas 武装抢劫餐馆的句子! 尽管如此,为了保持一致性,能够从句子中删除标点符号仍然很重要。 我们可以使用re库在 Python 中执行此操作,以使用正则表达式匹配任何标点符号,并使用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 –从输入中删除标点符号
这表明标点符号已从输入句子中删除。
在某些情况下,我们可能不希望直接删除标点符号。 一个很好的例子是的使用和号(&),在几乎每种情况下,它都与单词and互换使用。 因此,与其完全删除&号,不如选择直接用and一词代替。 我们可以使用.replace()函数在 Python 中轻松实现它:
input_text = "Cats & dogs"
output_text = input_text.replace("&", "and")
print('Input: ' + input_text)
print('Output: ' + output_text)
这将返回以下输出:

图 4.4 –删除和替换标点符号
同样值得考虑的特殊情况是标点符号对于句子的表示必不可少。 一个重要的例子是电子邮件地址。 从电子邮件地址中删除@不会使该地址更具可读性:
name@gmail.com
删除标点符号将返回以下内容:
namegmailcom
因此,在这种情况下,根据您的 NLP 模型的要求和目的,最好将整个项目全部删除。
替换数字
同样,对于数字,我们也想标准化我们的输出。 数字可以写为数字(9、8、7)或实际单词(九,八,七)。 可能值得将所有这些转换为一个标准化的表示形式,这样就不会将 1 和 1 视为单独的实体。 我们可以使用以下方法在 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 –用文字替换数字
这表明我们已成功将数字转换为文本。
但是,以类似于处理电子邮件地址的方式,处理电话号码可能不需要与常规电话号码相同的表示形式。 在以下示例中对此进行了说明:
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 –将电话号码转换为文本
显然,前面示例中的输入是电话号码,因此全文表示不一定适合目的。 在这种情况下,最好从输入文本中删除任何长整数。
词干提取和词形还原
在语言中,变体是如何通过修改共同的词根来表达不同的语法类别(如时态,语气或性别)的。 这通常涉及更改单词的前缀或后缀,但也可能涉及修改整个单词的。 例如,我们可以对动词进行修改以更改其时态:
Run -> Runs (Add "s" suffix to make it present tense)
Run -> Ran (Modify middle letter to "a" to make it past tense)
但是在某些情况下,整个词会发生变化:
To be -> Is (Present tense)
To be -> Was (Past tense)
To be -> Will be (Future tense – addition of modal)
名词也可能有词汇上的变化:
Cat -> Cats (Plural)
Cat -> Cat's (Possessive)
Cat -> Cats' (Plural possessive)
所有这些词都与根词cat相关。 我们可以计算句子中所有单词的词根,以将整个句子简化为词根:
"His cats' fur are different colors" -> "He cat fur be different color"
词干提取和词形还原是我们得出这些词根的过程。 词干提取是一个算法过程,其中,切掉单词的末尾以到达一个共同的词根,而词形还原使用单词本身的真实词汇和结构分析来得出它们的真正词根,即词形。 在下面的部分中,我们将详细介绍这两种方法。
词干提取
词干提取是一个算法过程,通过该算法,我们将单词的末尾切掉以达到其词根或词干。 为此,我们可以使用不同的词干提取器,每个词干都遵循特定算法,以便返回单词的词干。 用英语,最常见的词干提取器之一是 Porter 词干提取器。
Porter 词干提取器是具有大量逻辑规则的算法,可用于返回单词的词干。 在继续讨论该算法之前,我们将首先展示如何使用 NLTK 在 Python 中实现 Porter 词干提取器。
-
首先,我们创建一个 Porter 词干提取器的实例。
porter = PorterStemmer() -
然后,我们只需在单个单词上调用这个词干提取器的实例,并打印结果。在这里,我们可以看到 Porter 词干提取器返回的词干的一个例子。
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 –返回词干
-
我们也可以对整个句子应用词干提取,首先将句子符号化,然后对每个词单独进行词干提取。
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 –对句子应用词干提取
在这里,我们可以看到如何使用 Porter 词干提取器提取不同的单词。 有些词,例如stemming和timing,减少到stem和time的预期词干。 但是,某些单词,例如saw,并没有还原为它们的逻辑词干(see)。 这说明了 Porter 词干提取器的局限性。 由于词干提取器对单词应用了一系列逻辑规则,因此很难定义一组可以正确所有单词的词干的规则。 在英语单词中,根据时态(is/was/be)完全改变单词的情况下尤其如此。 这是因为没有通用规则可应用于这些单词,以将它们全部转换为相同的词根。
我们可以更详细地研究 Porter 词干提取器所应用的一些规则,以准确了解向茎的转化是如何发生的。 尽管实际的波特算法有许多详细的步骤,但是在这里,我们将简化一些规则以便于理解:

图 4.9 – Porter 词干提取器算法的规则
虽然不必了解 Porter 词干提取器中的每个规则,但关键是要了解其局限性。 虽然已经证明了 Porter 词干提取器在整个语料库中都能很好地工作,但总有人会说它不能正确地还原成它们的真实词干。 由于 Porter 词干提取器的规则集依赖于英语单词结构的约定,因此总会有一些单词不属于常规单词结构,并且不能被这些规则正确转换。 幸运的是,可以通过使用词形还原来克服这些限制中的某些限制。
词形还原
词形还原与词干提取的区别在于,它将单词减少为词形而不是词干。 虽然单词的词干可以被处理并简化为字符串,单词的词形是其真正的词根。 因此,虽然ran的词干只是ran,但它的词形是该词的真正词根,也就是run。
词义化过程使用内置的预先计算的词法和关联词以及句子中词的上下文来确定给定词的正确词法。 在此示例中,我们将研究在 NLTK 中使用 WordNet 词形还原器。 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 –最小化输出
在这里,我们已经可以开始看到使用词形还原胜于词干提取的优势。 由于 WordNet 词形还原器建立在英语所有单词的数据库上,因此知道mice是mouse的复数形式。 我们将无法使用词干提取达到相同的词根。 尽管词形还原在大多数情况下效果更好,但由于它依赖于内置的单词索引,因此无法将其推广到新单词或虚构单词:
print(wordnet_lemmatizer.lemmatize('madeupwords'))
print(porter.stem('madeupwords'))
结果为以下输出:

图 4.11 –虚词的词法输出
在这里,我们可以看到,在这种情况下,我们的词干提取器能够更好地推广到以前看不见的单词。 因此,如果我们要对来源进行词形还原,其语言不一定与真实英语匹配,则使用词形还原器可能会是一个问题,例如人们经常会缩写语言的社交媒体网站。
如果我们使用两个动词调用词形还原器,则会发现这不会将它们简化为预期的常见词形:
print(wordnet_lemmatizer.lemmatize('run'))
print(wordnet_lemmatizer.lemmatize('ran'))
结果为以下输出:

图 4.12 –在动词上进行词形还原
这是因为我们的词形还原器依靠单词的上下文来返回词形。 回想一下我们的 POS 分析,我们可以轻松地返回句子中单词的上下文并确定给定单词是名词,动词还是形容词。 现在,让我们手动指定我们的单词是动词。 我们可以看到现在可以正确返回词形:
print(wordnet_lemmatizer.lemmatize('ran', pos='v'))
print(wordnet_lemmatizer.lemmatize('run', pos='v'))
结果为以下输出:

图 4.13 –在函数中实现 POS
这意味着为了返回任何给定句子的正确词形,我们必须首先执行 POS 标记以获得句子中词的上下文,然后将其传递给词形还原器以获取句子中每个词的词形。 我们首先创建一个函数,该函数将为句子中的每个单词返回 POS 标签:
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 –句子上 POS 标签的输出
请注意,这如何为句子中的每个单词返回 NLTK POS 标签。 我们的 WordNet 词形还原器对 POS 的输入要求略有不同。 这意味着我们首先创建,该函数将 NLTK POS 标签映射到所需的 WordNet POS 标签:
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 –将 NTLK POS 标签映射到 WordNet POS 标签
最后,我们将这些函数组合为一个最终函数,该函数将对整个句子执行词形还原:
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 –最终的词形还原函数的输出
在这里,我们可以看到,与词干提取相比,词形通常可以更好地表示单词的真实词根,但有一些明显的例外。 当我们可能决定使用时,词干提取和词形还原取决于当前任务的要求,其中一些我们现在将讨论。
词干提取和词形还原的用途
词干提取和词形还原都是 NLP 的一种形式,可用于从文本中提取信息。 该被称为文本挖掘。 文本挖掘任务有多种类别,包括文本聚类,分类,文档汇总和情感分析。 可以将词干提取和词形还原与深度学习一起使用,以解决其中的一些任务,这将在本书的后面看到。
通过使用词干提取和词形还原进行预处理,再加上停用词的去除,我们可以更好地减少句子以了解其核心含义。 通过删除对句子的意义没有显着贡献的单词,并通过减少到单词的词根或词形,我们可以在深度学习框架内有效地分析句子。 如果能够将 10 个单词的句子减少为由多个核心词形而不是相似单词的多个变体组成的五个单词,则意味着我们需要通过神经网络提供的数据要少得多。 如果我们使用词袋表示法,则由于多个词都归结为相同的词形,我们的语料库会大大缩小,而如果我们计算嵌入表示法,则对于一个词,捕获我们单词的真实表示法所需的维数会更小。 减少语料库。
词干提取和词形还原的差异
现在我们已经看到了词干提取和词形还原,在的问题上,仍然存在问题,在什么情况下我们应该同时使用这两种技术。 我们看到,两种技术都试图将每个单词的根都减少。 在词干提取中,可能只是目标房间的简化形式,而在词形还原中,它会还原为真正的英语单词词根。
因为词义化需要在 WordNet 语料库中交叉引用目标词,并执行词性分析以确定词义的形式,所以如果必须使用大量词,这可能会花费大量的处理时间。 这与词干提取相反,词干提取使用详细但相对较快的算法来提取词干。 最终,与计算中的许多问题一样,这是在速度与细节之间进行权衡的问题。 在选择将这些方法中的哪一种纳入我们的深度学习流程时,要在速度和准确率之间进行权衡。 如果时间很重要,那么阻止可能是要走的路。 另一方面,如果您需要模型尽可能详细和准确,则限制词形还原可能会产生更好的模型。
总结
在本章中,我们通过探讨两种方法的功能,它们的用例以及如何实现它们,详细讨论了词干提取和词形还原。 既然我们已经涵盖了深度学习和 NLP 预处理的所有基础知识,我们就可以开始从头开始训练我们自己的深度学习模型。
在下一章中,我们将探讨 NLP 的基础知识,并演示如何在深度 NLP 领域中建立最广泛使用的模型:循环神经网络。
五、循环神经网络和情感分析
在本章中,我们将研究循环神经网络(RNN),这是 PyTorch 中基本前馈神经网络的变体,我们在第 1 章“机器学习基础”中学习了如何构建它。 通常,RNN 可用于任何可以将数据表示为序列的任务。 其中包括使用以序列表示的历史数据的时间序列进行股票价格预测之类的事情。 我们通常在 NLP 中使用 RNN,因为可以将文本视为单个单词的序列,并可以对其进行建模。 传统的神经网络将单个向量作为模型的输入,而 RNN 可以采用整个向量序列。 如果我们将文档中的每个单词表示为向量嵌入,则可以将整个文档表示为向量序列(或 3 阶张量)。 然后,我们可以使用 RNN(以及更复杂的 RNN 形式,称为长短期记忆(LSTM))从我们的数据中学习。
在本章中,我们将介绍 RNN 的基础知识和更高级的 LSTM。 然后,我们将研究情感分析,并通过一个实际的示例来研究如何使用 PyTorch 构建 LSTM 对文档进行分类。 最后,我们将在简单的云应用平台 Heroku 上托管我们的简单模型,这将使我们能够使用我们的模型进行预测。
本章涵盖以下主题:
- 建立 RNN
- 使用 LSTM
- 使用 LSTM 构建情感分析器
- 在 Heroku 上部署应用
技术要求
本章中使用的所有代码都可以在这个页面中找到。 可以从 www.heroku.com 安装 Heroku。 数据来自这里。
构建 RNN
RNN 由循环层组成。 尽管它们在许多方面类似于标准前馈神经网络中的全连接层,但这些循环层由隐藏状态组成,该隐藏状态在顺序输入的每个步骤中进行更新。 这意味着对于任何给定的序列,模型都使用隐藏状态初始化,该状态通常表示为一维向量。 然后,将序列的第一步输入模型,并根据一些学习到的参数更新隐藏状态。 然后将第二个单词馈入网络,并根据其他一些学习到的参数再次更新隐藏状态。 重复这些步骤,直到处理完整个序列,并且使我们处于最终的隐藏状态。 该计算使用从先前的计算中继承并更新的隐藏状态来循环执行,这就是为什么我们将这些网络称为循环的原因。 然后,将这个最终的隐藏状态连接到另一个全连接层,并预测最终的分类。
我们的循环层如下所示,其中h是隐藏状态,而x是我们在序列中各个时间步的输入。 对于每次迭代,我们都会在每个时间步x中更新隐藏状态:

图 5.1 –循环层
或者,我们可以将扩展到整个时间步长序列,如下所示:

图 5.2 –时间步序
该层用于n时间步长的输入。 我们的隐藏状态在状态h0中初始化,然后使用我们的第一个输入x1来计算下一个隐藏状态h1。 还学习了两组权重矩阵:矩阵U和矩阵W,矩阵U了解隐藏状态如何在时间步之间变化,矩阵W隐藏状态。
我们还将 tanh 激活函数应用于所得产品,将隐藏状态的值保持在 -1 和 1 之间。用于计算任何隐藏状态的公式hₜ变为以下:

然后在输入序列中的每个时间步重复此操作,此层的最终输出是我们的最后一个隐藏状态hₙ。 当我们的网络学习时,我们像以前一样通过网络进行正向传播,以计算最终分类。 然后,我们根据此预测来计算损失,并像以前一样通过网络反向传播,并随即计算梯度。 反向传播过程在循环层的所有步骤中进行,每个输入步骤和隐藏状态之间的参数都将被学习。
稍后我们将看到,我们实际上可以在每个时间步长都采用隐藏状态,而不是使用最终的隐藏状态,这对于 NLP 中的序列到序列翻译任务很有用。 但是,暂时来说,我们只是将隐藏层作为对网络其余部分的输出。
使用 RNN 的情感分析
在情感分析的上下文中,我们的模型是根据评论的情感分析数据集训练的,该数据集由多个文本评论和 0 或 1 的标签组成,具体取决于评论是负面还是正面 。 这意味着我们的模型成为分类任务(两个类别为负/正)。 我们的句子经过一层学习的单词嵌入,以形成包含多个向量的句子的表示(每个单词一个)。 然后将这些向量顺序地馈入我们的 RNN 层,最终的隐藏状态将通过另一个全连接层。 我们的模型输出是介于 0 到 1 之间的单个值,具体取决于我们的模型是根据句子预测的是消极情感还是正面情感。 这意味着我们完整的分类模型如下所示:

图 5.3 –分类模型
现在,我们将重点介绍 RNN 的问题之一-爆炸和收缩梯度-以及我们如何使用梯度剪切来对此进行补救。
梯度爆炸和收缩
RNN 中我们经常面临的一个问题是梯度爆炸或收缩。 我们可以将循环层视为一个非常深的网络。 在计算梯度时,我们在隐藏状态的每次迭代中都这样做。 如果在给定位置的损失相对于权重的梯度变得很大,则在循环层的所有迭代中前馈时,这将产生乘法效果。 这会导致梯度爆炸,因为它们会很快变得非常大。 如果我们的梯度较大,则可能导致网络不稳定。 另一方面,如果隐藏状态下的梯度非常小,这将再次产生乘法效果,并且梯度将接近 0。这意味着梯度可能变得太小而无法通过梯度下降准确地更新参数, 表示我们的模型无法学习。
我们可以用来防止的梯度爆炸的一种技术是使用梯度剪切。 此技术限制了我们的梯度,以防止它们变得太大。 我们只需选择一个超参数C,就可以计算出裁剪的梯度,如下所示:

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

图 5.4 –梯度裁剪的比较
我们可以用来防止梯度爆炸或消失的另一种技术是缩短输入序列的长度。 循环层的有效深度取决于输入序列的长度,因为序列长度决定了需要对隐藏状态执行多少次迭代更新。 在此过程中,步骤数越少,隐藏状态之间的梯度累积的乘法效果就越小。 通过在模型中智能地选择最大序列长度作为超参数,我们可以帮助防止梯度爆炸和消失。
LSTM 介绍
尽管 RNN 允许我们将单词序列用作模型输入,但它们远非完美。 RNN 有两个主要缺陷,可以使用更高级的 RNN 版本 LSTM 来部分弥补。
RNN 的基本结构意味着它们很难长期保留信息。 考虑一个 20 个字长的句子。 从影响初始隐藏状态的句子中的第一个单词到句子中的最后一个单词,我们的隐藏状态被更新 20 次。 从句子的开头到最终的隐藏状态,RNN 很难保留句子开头的单词信息。 这意味着 RNN 不太擅长捕获序列中的长期依赖关系。 这也与前面提到的梯度消失问题有关,在梯度问题中,通过向量的稀疏序列反向传播非常无效。
考虑一段较长的段落,我们试图预测下一个单词。 句子的开头是I study math…,而结尾处是my final exam is in….中。 直观地,我们希望下一个单词是math或某些与数学相关的字段。 但是,在较长序列的 RNN 模型中,由于需要多个更新步骤,我们的隐藏状态可能难以在到达句子结尾时保留句子开头的信息。
我们还应该注意,RNN 在捕获整个句子中单词的上下文方面很差。 在查看 N 元组模型时,我们前面已经看到,一个单词在句子中的含义取决于它在句子中的上下文,上下文取决于它之前出现的单词和之后出现的单词。 在 RNN 中,我们的隐藏状态仅在一个方向上更新。 在单个正向传播中,我们的隐藏状态被初始化,并且序列中的第一个单词被传递到其中。 然后依次对句子中的所有后续单词重复此过程,直到我们处于最终的隐藏状态。 这意味着对于句子中的任何给定单词,我们只考虑了到该点之前在句子中出现的单词的累积效果。 我们不会考虑其后的任何单词,这意味着我们不会捕获句子中每个单词的完整上下文。
在另一个示例中,我们再次希望预测句子中丢失的单词,但是现在它出现在开头而不是结尾。 I grew up in…so I can speak fluent Dutch. 在这里,我们可以凭直觉猜测该人在荷兰长大,这是因为他们会说荷兰语。 但是,由于 RNN 会顺序分析此信息,因此它将仅使用I grew up in…进行预测,而缺少句子中的其他关键上下文。
使用 LSTM 可以部分解决这两个问题。
使用 LSTM
LSTM 是 RNN 的更高级版本,并包含两个额外的属性-更新门和遗忘门。 这两个附加项使易于网络学习长期依赖性。 考虑以下电影评论:
The film was amazing. I went to see it with my wife and my daughters on Tuesday afternoon. Although I didn't expect it to be very entertaining, it turned out to be loads of fun. We would definitely go back and see it again given the chance.
在情感分析中,很明显,并不是句子中的所有单词在确定是正面评论还是负面评论时都相关。 我们将重复这句话,但是这次重点介绍与评估评论情感相关的词:
The film was amazing. I went to see it with my wife and my daughters on Tuesday afternoon. Although I didn't expect it to be very entertaining, it turned out to be loads of fun. We would definitely go back and see it again given the chance.
LSTM 试图做到这一点—在忘记所有不相关信息的同时记住句子中的相关单词。 通过这样做,它可以阻止无关信息稀释相关信息,这意味着可以更好地了解长序列的长期依赖性。
LSTM 与 RNN 的结构非常相似。 虽然 LSTM 的各个步骤之间存在一个隐藏状态,但 LSTM 单元本身的内部工作方式不同于 RNN:

图 5.5 – LSTM 单元
LSTM 单元
尽管 RNN 单元仅采用之前的隐藏状态和新的输入步骤,并使用一些学习到的参数来计算下一个隐藏状态,但 LSTM 单元的内部工作却要复杂得多:

图 5.6 – LSTM 单元的内部工作原理
尽管这看起来比 RNN 更加令人生畏,但我们将依次解释 LSTM 单元的每个组件。 我们首先来看遗忘门(由粗体矩形指示):

图 5.7 –遗忘门
遗忘门本质上是学习要忘记序列中的哪些元素。 先前的隐藏状态h[t-1]和最新的输入步骤x1被连接在一起,并通过了在遗忘门上习得的权重矩阵,以及 Sigmoid 函数,它将值压缩为 0 到 1 之间。将得到的矩阵fₜ逐点乘以上一步c[t-1]单元状态。 这有效地将掩码应用于先前的单元状态,使得仅来自先前的单元状态的相关信息被提出。
接下来,我们将查看输入门:

图 5.8 –输入门
输入门再次采用连接的先前隐藏状态h[t-1]和当前序列输入xₜ,并将其通过具有学习参数的 Sigmoid 函数,从而输出另一个矩阵iₜ,它由 0 到 1 之间的值组成。连接的隐藏状态和序列输入也通过 tanh 函数,该函数将输出压缩在 -1 和 1 之间。 通过iₜ矩阵。 这意味着生成iₜ所需的学习参数可以有效地了解应从当前时间步长将哪些元素保留在我们的单元状态中。 然后将其添加到当前单元状态以获得最终单元状态,该状态将继续进行到下一个时间步骤。
最后,我们有是 LSTM 单元的最后一个元素-输出门:

图 5.9 –输出门
输出门计算 LSTM 单元的最终输出-单元状态和隐藏状态,并继续进行下一步。 单元状态cₜ与前两个步骤相同,是遗忘门和输入门的乘积。 通过获取连接的先前隐藏状态h[t-1]和当前时间步输入xₜ,可以计算出最终隐藏状态hₜ,并通过具有一些学习参数的 Sigmoid 函数来获得输出门输出oₜ。 最终单元状态cₜ通过 tanh 函数并乘以输出门输出oₜ,以计算最终隐藏状态hₜ。 这意味着在输出门上学习到的参数可以有效地控制将先前隐藏状态和当前输出中的哪些元素与最终单元状态进行组合,以作为新的隐藏状态延续到下一个时间步长。
在我们的前向遍历中,我们简单地遍历模型,初始化我们的隐藏状态和单元状态,并在每个时间步使用 LSTM 单元对其进行更新,直到剩下最终的隐藏状态为止,该状态将输出到网络下一层的神经元。 通过在 LSTM 的所有层中进行反向传播,我们可以计算相对于网络损失的梯度,因此我们知道通过梯度下降来更新参数的方向。 我们得到几种矩阵或参数-一种用于输入门,一种用于输出门,以及一种用于遗忘门。
因为我们获得的参数要比简单的 RNN 多,并且我们的计算图更加复杂,所以与简单的 RNN 相比,通过网络反向传播和更新权重的过程可能会花费更长的时间。 但是,尽管训练时间更长,但是我们已经证明,与传统的 RNN 相比,LSTM 具有显着的优势,因为输出门,输入门和遗忘门都结合在一起,使模型能够确定应使用输入的哪些元素。 更新隐藏状态,并且以后应该忘记隐藏状态的哪些元素,这意味着该模型能够更好地形成长期依存关系并保留先前序列步骤中的信息。
双向 LSTM
前面我们曾提到,简单 RNN 的缺点是,由于它们只是向后看,它们无法捕获句子中单词的完整上下文。 在 RNN 的每个时间步骤中,仅考虑先前看到的单词,而不考虑句子中接下来出现的单词。 虽然基本 LSTM 类似地是向后的,但我们可以使用 LSTM 的改进版本,称为双向 LSTM,它在序列中的每个时间步都考虑它之前和之后的词。
双向 LSTM 同时处理规则顺序和反向顺序的序列,并保持两个隐藏状态。 我们将前向隐藏状态称为fₜ,并将rₜ用作反向隐藏状态:

图 5.10 –双向 LSTM 过程
在这里,我们可以看到我们在整个过程中都保持了这两个隐藏状态,并使用它们来计算最终的隐藏状态hₜ。 因此,如果我们希望计算时间步t的最终隐藏状态,则可以使用前向隐藏状态fₜ,该状态已看到所有单词,包括输入xₜ,以及反向隐藏状态rₜ,其中已经看到了xₜ之后的所有单词。 因此,我们最终的隐藏状态hₜ包含隐藏状态,这些状态已经看到了句子中的所有单词,而不仅仅是出现在时间步t之前的单词。 这意味着可以更好地捕获整个句子中任何给定单词的上下文。 事实证明,双向 LSTM 可以在多个 NLP 任务上提供比常规单向 LSTM 更高的表现。
使用 LSTM 构建情感分析器
现在,我们将研究如何构建自己的简单 LSTM,以根据句子的情感对句子进行分类。 我们将在 3,000 条评论被归为肯定或否定的评论的数据集上训练模型。 这些评论来自三个不同的来源(电影评论,产品评论和位置评论),以确保我们的情感分析器功能强大。 数据集是平衡的,因此包含 1,500 个正面评论和 1,500 个负面评论。 我们将从导入数据集并对其进行检查开始:
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 –数据集的输出
我们从文件中读取数据集。 我们的数据集是制表符分隔的,因此我们将其与制表符和新行字符分开。 我们重命名我们的列,然后使用示例函数随机地随机整理数据。 查看我们的数据集,我们需要做的第一件事是对句子进行预处理,以将其输入到我们的 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 分词的输出
我们返回评论本身,以及所有评论中的所有单词集(即词汇/语料库),我们将使用它们来创建词汇表。
为了充分准备将句子输入神经网络,我们必须将单词转换为数字。 为了做到这一点,我们创建了两个字典,这将使我们能够将数据从单词转换为索引以及从索引转换为单词。 为此,我们只需遍历语料库并为每个唯一单词分配一个索引:
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 –为每个单词分配一个索引
我们的神经网络将接受固定长度的输入; 但是,如果我们浏览我们的评论,我们会发现我们的评论都是不同长度的。 为了确保所有输入的长度相同,我们将填充我们的输入句子。 这实质上意味着我们将空标记添加到较短的句子中,以便所有句子的长度相同。 我们必须首先决定要实现的填充长度。 我们首先计算输入评论中句子的最大长度以及平均长度:
print(np.max([len(x) for x in reviews]))
print(np.mean([len(x) for x in reviews]))
这给出了以下内容:

图 5.14 –长度值
我们可以看到最长的句子是70个字长,平均句子长度是11.78。 为了捕获所有句子中的所有信息,我们希望填充所有句子,使它们的长度为 70。但是,使用更长的句子意味着更长的序列,这会使我们的 LSTM 层变得更深。 这意味着模型训练需要更长的时间,因为我们必须通过更多层反向传播梯度,但是这也意味着我们输入的很大一部分只是稀疏并且充满了空标记,这使得从数据中学习的效率大大降低 。 我们的最大句子长度远大于我们的平均句子长度,这说明了这一点。 为了捕获我们大部分的句子信息而不会不必要地填充我们的输入并使它们太稀疏,我们选择使用50的输入大小。 您可能希望尝试在20和70之间使用不同的输入大小,以了解这如何影响模型表现。
我们将创建一个函数,使我们能够填充句子,使它们的大小相同。 对于短于序列长度的评论,我们用空标记填充它们。 对于长度超过序列长度的评论,我们只需丢弃超过最大序列长度的所有标记:
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 –填充句子
我们必须进行进一步的调整,以允许在模型中使用空标记。 当前,我们的词汇词典不知道如何将空标记转换为整数以在我们的网络中使用。 因此,我们将它们手动添加到索引为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 –编码句子
现在我们已经将所有输入序列编码为数值向量,我们准备开始设计模型架构。
模型架构
我们的模型将由几个主要部分组成。 除了许多神经网络通用的输入和输出层之外,我们首先需要一个嵌入层。 这样我们的模型就可以学习正在训练的单词的向量表示形式。 我们可以选择使用预先计算的嵌入(例如 GLoVe),但是出于演示目的,我们将训练自己的嵌入层。 我们的输入序列通过输入层进行馈送,并作为向量序列出现。
将这些向量序列送入我们的 LSTM 层。 如本章前面所详细解释的那样,LSTM 层从我们的嵌入序列中顺序学习,并输出代表 LSTM 层最终隐藏状态的单个向量输出。 在最终输出节点预测值介于 0 到 1 之间之前,该最终隐藏状态最终通过另一个隐藏层传递,指示输入序列是肯定还是否定。 这意味着我们的模型架构看起来像这样:

图 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 进行批量数据训练,并添加一个参数以允许我们通过丢弃实现网络正则化。 我们用概率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 = 0和class = 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 的 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类的新实例来初始化模型。 我们传递 vocab 的大小,嵌入的大小,隐藏状态的大小,输出大小以及 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)定义为encode_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对象,以便数据以随机顺序(而不是每个周期相同)通过神经网络传递,从而有可能从训练顺序中消除任何有偏差的结果:
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对象,接下来我们定义训练循环。 我们首先定义许多超参数,这些超参数将在我们的训练循环中使用。 最重要的是,我们将损失函数定义为二进制交叉熵(因为我们正在处理单个二进制类别的预测),并且将优化器定义为 Adam,学习率为0.001。 我们还定义了模型以运行较短的时间(以节省时间),并设置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()冻结模型的权重,并像以前一样使用我们的数据进行正向传播。 请注意,当我们处于评估模式时,也不会应用丢弃。 但是,这次,我们使用验证加载程序,而不是使用训练数据加载程序。 通过这样做,我们可以在我们的验证数据集中计算模型当前状态的总损失。 最后,我们打印结果并调用net.train()解冻模型的权重,以便我们可以在下一个周期再次进行训练。 我们的输出看起来像,如下所示:

图 5.18 –训练模型
最后,我们可以保存我们的模型以备将来使用:
torch.save(net.state_dict(), 'model.pkl')
在为三个周期训练了我们的模型之后,我们注意到了两个主要方面。 我们将首先从好消息开始-我们的模型正在学习一些东西! 我们的训练损失不仅下降了,而且在每个周期之后,我们在验证集上的损失也下降了。 这意味着我们的模型仅在三个周期后就可以更好地预测看不见的数据的情感! 坏消息是,我们的模型过拟合。 我们的训练损失比验证损失要低得多,这表明虽然我们的模型已经学会了如何很好地预测训练数据集,但这并不能推广到看不见的数据集。 预期会发生这种情况,因为我们使用的训练数据非常少(仅 2,400 个训练语句)。 当我们训练整个嵌入层时,很可能许多单词在训练集中只出现一次,而在验证集中却没有出现,反之亦然,这使得该模型几乎不可能归纳出我们的语料库中所有不同的单词。 在实践中,我们希望在更大的数据集上训练我们的模型,以使我们的模型学习如何更好地归纳。 我们还在很短的时间内训练了该模型,并且没有执行超参数调整来确定模型的最佳迭代。 随意尝试更改模型中的某些参数(例如训练时间,隐藏状态大小,嵌入大小等),以提高模型的表现。
尽管我们的模型过拟合,但它仍然学到了一些东西。 现在,我们希望在最终的测试数据集上评估我们的模型。 我们使用之前定义的测试加载器对数据执行了最后一次传递。 在此过程中,我们遍历所有测试数据并使用最终模型进行预测:
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 –输出值
然后,我们将模型预测与真实标签进行比较,以获得correct_tensor,这是一个评估我们模型的每个预测是否正确的向量。 然后,我们对该向量求和,然后将其除以其长度,以获得模型的总精度。 在这里,我们获得了 76% 的准确率。 尽管我们的模型肯定还远非完美,但鉴于我们的训练量很小且训练时间有限,这一点也不差! 仅用于说明从 NLP 数据学习时 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 –正值上的预测字符串
我们还尝试对负值使用predict():
predict("It was not good")
结果为以下输出:

图 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
这是仅包含 CPU 实现的 PyTorch 版本的 wheel 文件的链接。 包括完整 GPU 支持的 PyTorch 完整版的大小超过 500 MB,因此它将无法在免费的 Heroku 群集上运行。 使用此更紧凑的 PyTorch 版本意味着您仍然可以在 Heroku 上使用 PyTorch 运行模型。 最后,我们在文件夹中创建另外三个文件,以及模型的最终目录:
touch app.py
touch Procfile
touch wsgi.py
mkdir models
现在,我们已经创建了 Flash API 所需的所有文件,并且准备开始对文件进行调整。
使用 Flask 创建 API-API 文件
在app.py文件中,我们可以开始构建我们的 API:
-
我们首先进行所有的导入,并创建一个
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']) -
接下来,我们在
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文件,并将其放置在model目录中。 -
同样,我们也必须从我们训练的模型中加载权重。我们首先定义我们的
SentimentLSTM类,然后使用torch.load加载我们的权重。我们将使用我们原始笔记本中的.pkl文件,所以一定要把它也放在models目录下。model = SentimentLSTM(5401, 50, 100, 1, 2) model.load_state_dict(torch.load("models/model_nlp.pkl")) -
我们还必须定义我们 API 的输入和输出。我们希望我们的模型能够从 API 中获取输入,并将其传递给我们的
precess_review()函数。我们使用request.get_json()来完成。request_json = request.get_json() i = request_json['input'] words = np.array([preprocess_review(review=i)]) -
为了定义我们的输出,我们返回一个 JSON 响应,由我们模型的输出和响应代码
200组成,这就是我们预测函数返回的内容。output = model(x)[0].item() response = json.dumps({'response': output}) return response, 200 -
随着我们的应用主体的完成,我们还必须添加两件额外的事情来使我们的 API 运行。首先,我们必须在
wsgi.py文件中添加以下内容。from app import app as application if __name__ == "__main__": application.run() -
最后,在我们的 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 上托管
我们首先需要将文件提交到 Heroku,其方式类似于使用 GitHub 提交文件的方式。 我们只需运行以下命令,即可将工作中的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 任务的深度学习,但它们绝不是唯一适合此任务的神经网络架构。
在下一章中,我们将研究卷积神经网络,并展示如何将其用于 NLP 学习任务。
六、用于文本分类的卷积神经网络
在上一章中,我们展示了如何使用 RNN 为文本提供情感分类。 但是,RNN 并不是唯一可用于 NLP 分类任务的神经网络架构。 卷积神经网络(CNN)是另一种这样的架构。
RNN 依赖顺序建模,保持隐藏状态,然后逐个单词顺序地遍历文本,并在每次迭代时更新状态。 CNN 不依赖于语言的顺序元素,而是尝试通过分别感知句子中的每个单词并了解其与句子中周围单词的关系来从文本中学习。
尽管出于此处提到的原因,CNN 更常用于对图像进行分类,但事实表明,它们也可以有效地对文本进行分类。 尽管我们确实将文本视为一个序列,但我们也知道句子中各个单词的含义取决于它们的上下文和它们旁边出现的单词。 尽管 CNN 和 RNN 以不同的方式从文本中学习,但是它们都显示出对文本分类有效的方法,并且在任何给定情况下使用哪种依赖于任务的性质。
在本章中,我们将探讨 CNN 背后的基本理论,并从头开始构建一个 CNN,该 CNN 将用于对文本进行分类。 我们将涵盖以下主题:
- 探索 CNN
- 构建用于文本分类的 CNN
让我们开始吧!
技术要求
本章的所有代码都可以在这个页面中找到。
探索 CNN
CNN 的基础来自计算机视觉领域,但可以从概念上扩展到 NLP。 人脑处理和理解图像的方式不是以像素为单位,而是作为图像的整体图以及图像的每个部分与其他部分的关系。
CNN 的一个很好的类比是人的思维如何处理图片而不是句子。 考虑句子This is a sentence about a cat.。 当您阅读该句子时,您将阅读第一个单词,然后阅读第二个单词,依此类推。 现在,考虑一张猫的照片。 通过先看第一个像素,再看第二个像素,吸收图片中的信息是愚蠢的。 取而代之的是,当我们看着某物时,我们会立即感知到整个图像,而不是一个序列。
例如,如果我们对图像进行黑白表示(在本例中为数字 1),则可以看到可以将其转换为向量表示,其中每个像素的颜色由 0 或 1 表示:

图 6.1 –图像的向量表示
但是,如果我们从机器学习的角度考虑这一问题并将此向量视为模型的特征,那么单个像素为黑色或白色的事实是否会使图像具有给定位数的可能性更大或更小? 右上角的白色像素会使图片更可能是四或七吗? 想象一下,如果我们试图检测更复杂的事物,例如图片是狗还是猫。 屏幕中间的棕色像素会使照片更有可能是猫还是狗? 直观地,我们看到,在图像分类方面,单个像素值没有多大意义。 但是,我们感兴趣的是像素彼此之间的关系。
在数字表示的情况下,我们知道一条长垂直线很可能是一条,而其中带有闭环的任何照片都更有可能是零,六,八或九。 通过识别并从图像中的图案中学习,而不仅仅是查看单个像素,我们可以更好地理解和识别这些图像。 这正是 CNN 旨在实现的目标。
卷积
CNN 背后的基本概念是卷积。 卷积本质上是一个滑动窗口函数,已应用于矩阵以从周围像素捕获信息。 在下图中,我们可以看到一个实际的卷积示例:

图 6.2 –实际的卷积
左侧是正在处理的图像,而顶部则是希望应用的卷积核。 对于图像中的每个3x3块,我们将其乘以核,以在底部处获得卷积矩阵。 然后,我们对卷积矩阵求和(或取平均值),以获得初始图像中该3x3块的单个输出值。 请注意,在我们的5x5初始图像中,我们可以覆盖 9 个可能的3x3块。 当我们对初始图像中的每个3x3块应用此卷积过程时,剩下的最终处理卷积为3x3。
在大图像中(对于 NLP,则为复杂的句子),我们还需要实现池化层。 在我们前面的示例中,将3x3卷积应用于5x5图像会产生3x3输出。 但是,如果将3x3卷积应用于100x100像素的图像,则只会将输出降低到98x98。 这还不足以降低图像的尺寸,不足以有效地执行深度学习(因为我们必须在每个卷积层学习98x98参数)。 因此,我们应用池化层以进一步减小该层的尺寸。
池化层将函数(通常为max函数)应用于卷积层的输出,以减小其维数。 此函数应用于滑动窗口,类似于执行卷积的,但现在卷积不再重叠。 假设卷积层的输出为4x4,并且对输出应用2x2的最大值函数。 这意味着对于我们层中每个较小的2x2网格,我们将应用最大函数并保留结果输出。 我们可以在下图中看到这一点:

图 6.3 –池化层
这些池化层已被证明可以有效降低数据的维数,同时仍保留卷积层中的许多基本信息。
卷积和池化层的这种结合本质上是 CNN 从图像中学习的方式。 我们可以看到,通过应用许多这些卷积过程(也称为卷积层),我们能够捕获有关任何给定像素与其相邻像素的关系的信息。 在 CNN 中,我们旨在学习的参数是卷积核本身的值。 这意味着我们的模型可以有效地学习如何对图像进行卷积,以便能够提取进行分类所需的必要信息。
在这种情况下,使用卷积有两个主要的优势。 首先,我们能够将一系列低级特征组合成高级特征; 也就是说,我们初始图像上的3x3色块是组成的单个值。 这实际上是减少特征的一种形式,仅允许我们从图像中提取相关信息。 使用卷积的另一个优点是它使我们的模型位置不变。 在我们的数字检测器示例中,我们不在乎数字是否出现在图像的右侧或左侧; 我们只是希望能够检测到它。 由于我们的卷积会检测图像中的特定图案(即边缘),因此我们的模型位置不变,因为从理论上讲,无论这些卷积出现在图像中的哪个位置,相同的特征都将被拾取。
尽管这些原理对于理解卷积如何在图像数据中起作用很有用,但它们也可以应用于 NLP 数据。 我们将在下一部分中对此进行讨论。
用于 NLP 的卷积
正如我们在本书中多次看到的那样,我们可以将单个单词在数字上表示为向量,将整个句子和文档表示为向量序列。 当我们将句子表示为向量序列时,我们可以将其表示为矩阵。 如果我们具有给定句子的矩阵表示形式,我们会立即注意到,这类似于我们在图像卷积中卷积的图像。 因此,只要我们可以将文本表示为矩阵,就可以以类似于图像的方式将卷积应用于 NLP。
让我们首先考虑使用这种方法的基础。 以前查看 N 元组时,我们发现句子中一个单词的上下文取决于其前面的单词和后面的单词。 因此,如果我们能够以允许我们捕获一个单词与其周围单词的关系的方式对一个句子进行卷积,那么我们就可以从理论上检测语言中的模式并将其用于更好地对句子进行分类。
还值得注意的是,我们的卷积方法与图像卷积略有不同。 在我们的图像矩阵中,我们希望捕获相对于周围像素的单个像素的上下文,而在句子中,我们希望捕获相对于其周围其他向量的整个单词向量的上下文。 因此,在 NLP 中,我们希望在整个单词向量上而不是在单词向量内执行卷积。 下图中演示了。
我们首先将句子表示为单个单词向量:

图 6.4 –词向量
然后,我们在矩阵上应用(2 x n)卷积(其中n是我们字向量的长度;在这种情况下,n = 5) 。 我们可以使用(2 x n)过滤器对四个不同时间进行卷积,该过滤器可减少为四个输出。 您会注意到,这类似于二元语法模型,在一个五字句子中有四个可能的二元语法:

图 6.5 –将词向量卷积为二元语法
同样,我们可以对任意数量的 N 元组执行此操作; 例如n = 3:

图 6.6 –将单词向量卷积为 n 元语法
像这样的卷积模型的好处之一是,我们可以卷积的 N 元组的数量没有限制。 我们还能够同时对多个不同的 N 元组进行卷积。 因此,要捕获二元语法和三元语法,我们可以像这样设置模型:

图 6.7 –将词向量卷积为二元语法和三元语法
尽管用于 NLP 的 CNN 具有诸如前面部分所述的优点,但它们确实有其缺点。
在图像的 CNN 中,一个给定像素可能与其周围像素有关的假设是一个合理的假设。 当应用于 NLP 时,尽管此假设部分正确,但单词可以在语义上相关,即使它们彼此之间并不紧邻。 句子开头的单词可能与句子结尾的单词相关。
虽然我们的 RNN 模型可能能够通过长期记忆依赖性来检测这种关系,但我们的 CNN 可能会遇到困难,因为 CNN 仅捕获目标单词周围单词的上下文。
话虽如此,尽管我们的语言假设不一定成立,但事实证明,用于 NLP 的 CNN 在某些任务中表现良好。 可以说,将 CNN 用于 NLP 的主要优点是速度和效率。 卷积可以在 GPU 上轻松实现,从而实现快速并行的计算和训练。
捕获单词之间的关系的方式也更加有效。 在真正的 N 元组模型中,该模型必须学习每个单个 N 元组的个体表示,而在我们的 CNN 模型中,我们仅学习卷积核,该卷积核将自动提取给定单词向量之间的关系。
既然我们已经定义了 CNN 如何从数据中学习,我们就可以开始从头开始编码模型。
构建用于文本分类的 CNN
既然我们了解了 CNN 的基础知识,我们就可以从头开始构建。 在上一章中,我们建立了情感预测模型,其中情感是二分类器;1表示正,0表示负。 但是,在此示例中,我们的目标是为多类文本分类构建 CNN。 在多类别问题中,特定示例只能分类为几种类别之一。 如果一个示例可以分类为许多不同的类别,则这就是多标签分类。 由于我们的模型是多类的,因此这意味着我们的模型将旨在预测我们的输入句子被归为几类中的哪一类。 尽管此问题比二分类任务要困难得多(因为我们的句子现在可以属于许多类别之一,而不是两个类别之一),但我们将证明 CNN 可以在此任务上提供良好的表现。 首先,我们将定义数据。
定义多类分类数据集
在上一章中,我们查看了评论的选择,并根据评论是肯定的还是负面的,学习了二分类。 对于此任务,我们将查看来自 TREC 数据集的数据,这是用于评估文本分类任务的模型表现的通用数据集。 数据集由一系列问题组成,每个问题都属于我们训练过的模型将要学习分类的六大语义类别之一。 这六个类别如下:

图 6.8 – TREC 数据集中的语义类别
这意味着,与我们之前的分类类不同,我们的模型输出是0和1之间的单个预测,我们的多类预测模型现在返回六个可能类别中的每个类别的概率。 我们假设做出的预测是针对具有最高预测的类别的:

图 6.9 –预测值
通过这种方式,我们的模型现在将能够在多个类上执行分类任务,并且我们不再局限于我们之前看过的 0 或 1 二分类。 具有更多类的模型可能会因预测而受到影响,因为有更多不同的类可以区分。
在二元分类模型中,假设我们有一个平衡的数据集,我们希望我们的模型仅执行随机猜测就可以达到 50% 的准确率,而具有五个不同类别的多类模型只能具有基线精度 20%。 这意味着仅仅因为多类模型的准确率远低于 100%,并不意味着模型本身固有地在做出预测方面就很糟糕。 当涉及从数百个不同类别进行预测的训练模型时,尤其如此。 在这些情况下,仅具有 50% 准确率的模型将被认为表现良好。
现在我们已经定义了多类分类问题,我们需要加载数据以训练模型。
创建迭代器来加载数据
在上一章的 LSTM 模型中,我们仅使用了.csv文件,其中包含用于训练模型的所有数据。 然后,我们将这些数据手动转换为输入张量,并将它们一张一张地输入到我们的网络中以进行训练。 尽管这种方法是完全可以接受的,但它并不是最有效的方法。
在我们的 CNN 模型中,我们将改为根据数据创建数据迭代器。 这些迭代器对象使我们能够轻松地从输入数据中生成小批数据,从而使我们能够使用小批数据来训练模型,而不是将输入数据一一输入到网络中。 这意味着我们网络中的梯度是针对整批数据计算的,并且参数调整是在每批数据之后进行的,而不是在每行数据通过网络传递之后进行的。
对于我们的数据,我们将从torchtext包中获取数据集。 这样的优势不仅在于包含许多用于模型训练的数据集,而且还使我们能够使用内置函数轻松地对句子进行分词和向量化。
按着这些次序:
-
我们首先从
torchtext导入数据和数据集函数。from torchtext import data from torchtext import datasets -
接下来,我们创建一个字段和标签字段,我们可以使用
TorchText包。这些定义了我们的模型将用于处理我们的数据的初始处理步骤。questions = data.Field(tokenize = 'spacy', batch_first = True) labels = data.LabelField(dtype = torch.float)在这里,我们将分词设置为等于
spacy,以设置输入句子的分词方式。TorchText然后使用spacy包自动标记输入的句子。spacy由英语索引组成,因此任何单词都会自动转换为相关标记。 为了使它有效,您可能需要安装spacy。 可以在命令行中通过键入以下命令来完成:pip3 install spacy python3 -m spacy download en这将安装
spacy并下载英语单词索引。 -
我们还将标签的数据类型定义为浮动,这将允许我们计算损失和梯度。在定义了我们的字段之后,我们可以使用这些字段来分割我们的输入数据。使用
TorchText的 TREC 数据集,我们将我们的问题和标签字段传递给这个数据集,以便对数据集进行相应的处理。然后,我们调用split函数,以便将我们的数据集自动划分为一个训练集和一个验证集。train_data, _ = datasets.TREC.splits(questions, labels) train_data, valid_data = train_data.split()请注意,通常,我们可以通过简单地调用训练数据来使用 Python 查看数据集:
train_data
但是,在这里,我们正在处理TorchText数据集对象,而不是像我们以前所看到的那样,将数据集加载到 Pandas 中。 这意味着前面代码的输出如下:

图 6.10 –torchtext对象的输出
我们可以查看此数据集对象内的单个数据; 我们只需要调用.examples参数即可。 这些示例每个都有一个文本和一个label参数,我们可以像检查文本一样检查它们:
train_data.examples[0].text
这将返回以下输出:

图 6.11 –数据集对象中的数据
标签代码如下运行:
train_data.examples[0].label
这给我们以下输出:

图 6.12 –数据集对象的标签
因此,我们可以看到输入的数据由一个分词的句子组成,而我们的标签则由我们希望分类的类别组成。 我们还可以检查训练和验证集的大小,如下所示:
print(len(train_data))
print(len(valid_data))
结果为以下输出:

图 6.13 –训练和验证集的大小
这表明我们的训练验证率约为 70% 至 30%。 值得注意的是,我们的输入句子是如何被分词的,即标点符号被当作它们自己的标记。
现在我们知道我们的神经网络不会将原始文本作为输入,我们必须找到某种方法将其转换为某种形式的嵌入表示。 虽然我们可以训练自己的嵌入层,但可以改用我们在 “第 3 章” 中讨论过的预先计算的 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 –张量内容
接下来,我们创建数据迭代器。 我们为训练和验证数据创建单独的迭代器。 我们首先指定一种设备,以便能够使用支持 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 模型
现在我们已经加载了数据,现在可以创建模型了。 我们将使用以下步骤进行操作:
-
我们希望建立我们 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__() -
我们的模型是用几个输入来初始化的,所有的输入都会在后面介绍。接下来,我们分别定义网络中的层,从嵌入层开始。
self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx = pad_idx)嵌入层将由词汇表中每个可能单词的嵌入组成,因此该层的大小是词汇表的长度和嵌入向量的长度。 我们正在使用 200 维 GLoVe 向量,因此在这种情况下,长度为
200。 我们还必须传递填充索引,该索引是我们嵌入层的索引,用于使嵌入填充我们的句子,以便它们的长度相同。 我们将在稍后初始化模型时手动定义此嵌入。 -
接下来,我们定义我们网络中的实际卷积层。
self.convs = nn.ModuleList([ nn.Conv2d( in_channels = 1, out_channels = n_filters, kernel_size = (fs, embedding_dim) ) for fs in filter_sizes
])
```
-
我们首先使用
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))
在此,过滤器尺寸分别为2和3。 但是,在单个函数中执行此操作效率更高。 此外,如果我们向函数传递不同的过滤器大小,则将自动生成我们的层,而不是每次添加新层时都必须手动定义每个层。
我们还将out_channels值定义为我们希望训练的过滤器数;kernel_size将包含我们嵌入的长度。 因此,我们可以将ModuleList函数的长度传递给我们希望训练的过滤器长度以及每个过滤器的数量,它将自动生成卷积层。 该卷积层如何查找给定变量集的示例如下:

图 6.15 –卷积层寻找变量
我们可以看到我们的ModuleList函数适应了我们想要训练的过滤器的数量和大小。 接下来,在 CNN 初始化中,我们定义其余的层,即将对数据进行分类的线性层和将对网络进行正则化的丢弃层:
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 激活函数并压缩结果,删除结果输出的第四维。 对所有定义的卷积层重复此操作,以便使transforms包含在所有卷积层的输出列表中。
对于这些输出中的每一个,我们都应用了合并函数来减小卷积层输出的维数,如前所述。 然后,我们将池化层的所有输出连接在一起,并在将其传递到最终的全连接层之前应用一个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)。 填充索引可以从我们的词汇表中手动获取。
可以手动调整接下来的三个超参数,因此您不妨尝试选择不同的值,以了解这如何影响网络的最终输出。 我们传递了一个过滤器大小列表,以便我们的模型将使用大小为2,3和4的卷积训练卷积层。 我们将针对每种过滤器尺寸训练 100 个过滤器,因此总共将有 300 个过滤器。 我们还为我们的网络定义了 50% 的丢弃率,以确保其充分正规化。 如果模型似乎容易过拟合或过拟合,则可以升高/降低此值。 一般的经验法则是,如果模型欠拟合,则尝试降低丢弃率;如果模型过拟合,则尝试提高丢弃率。
初始化模型后,我们需要将权重加载到嵌入层中。 可以很容易地完成以下操作:
glove_embeddings = questions.vocab.vectors
model.embedding.weight.data.copy_(glove_embeddings)
结果为以下输出:

图 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函数对所有预测返回具有最高预测值的索引。 对于这些预测中的每一个,如果此预测索引与标签的索引相同,则将其视为正确的预测。 然后,我们对所有这些正确的预测进行计数,然后将它们除以预测的总数,以得出多类准确率的度量。 我们可以在训练循环中使用此函数来测量每个周期的准确率。
接下来,我们定义训练函数。 最初,我们将时间段的损失和准确率设置为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_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():
这两个步骤将模型设置为评估模式,忽略任何遗漏函数,并确保未计算和更新梯度。 这是因为我们希望在评估表现时冻结模型中的权重,并确保不使用验证数据对模型进行训练,因为我们希望将其与用于训练模型的数据分开保存 。
现在,我们只需要与数据迭代器一起循环调用训练和评估函数,即可训练模型。 我们首先定义希望模型训练的周期数。 我们还定义了我们的模型迄今为止所实现的最低验证损失。 这是因为我们只希望使训练后的模型具有最低的验证损失(即表现最佳的模型)。 这意味着,如果我们的模型训练了多个周期并开始过拟合,那么只有这些模型的最佳表现将被保留,这意味着选择大量周期的后果会更少。
我们将最低的验证损失初始化为无穷大,开始于:
epochs = 10
lowest_validation_loss = float('inf')
接下来,我们定义训练循环,一次将跨一个周期。 我们记录训练的开始和结束时间,以便我们可以计算出每个步骤花费的时间。 然后,我们只需使用训练数据迭代器对模型调用训练函数来计算训练损失和准确率,并在此过程中更新模型。 然后,我们使用验证迭代器上的评估函数重复此过程,以计算验证数据的损失和准确率,而无需更新模型:
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()
此后,我们确定在当前周期之后,我们的模型是否优于目前表现最好的模型:
如果有效损失
if valid_loss < lowest_validation_loss:
lowest_validation_loss = valid_loss
torch.save(model.state_dict(), 'cnn_model.pt')
如果此周期之后的损失低于到目前为止的最低验证损失,则将验证损失设置为新的最低验证损失,并保存我们当前的模型权重。
最后,我们仅在每个周期之后打印结果。 如果一切工作正常,我们应该看到我们的训练损失在每个周期之后都有所下降,而我们的验证损失有望照此执行:
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 –测试模型
值得庆幸的是,我们发现确实存在。 训练和验证损失在每个周期都会下降,准确率也会提高,这表明我们的模型确实在学习! 经过多次训练后,我们可以采用最佳模型并进行预测。
使用经过训练的 CNN 的预测
幸运的是,使用我们训练过的模型进行预测是一个相对简单的任务。 我们首先使用load_state_dict函数加载最佳模型:
model.load_state_dict(torch.load('cnn_model.pt'))
我们的模型结构已经定义,因此我们只需从先前保存的文件中加载权重即可。 如果此操作正常,您将看到以下输出:

图 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(如果可用),然后取消压缩输出,因为我们的模型需要三维张量输入而不是单个向量。
接下来,我们做出预测:
model.eval()
prediction = torch.max(model(tensor),1).indices.item()
pred_index = labels.vocab.itos[prediction]
return pred_index
我们首先将模型设置为评估模式(与评估步骤一样),以便不计算模型的梯度并且不调整权重。 然后,我们将句子张量传递到模型中,并获得长度为6的预测向量,该预测向量由六个类别中每个类别的单独预测组成。 然后,我们获取最大预测值的索引,并在标签索引中使用该索引以返回预测类的名称。
为了进行预测,我们只需在任何给定的句子上调用Forecast_class()函数。 让我们使用以下代码:
pred_class = predict_class(model, "How many roads must a man walk down?")
print('Predicted class is: ' + str(pred_class))
这将返回以下预测:

图 6.19 –预测值
这个预测是正确的! 我们的输入问题包含how many,表明该问题的答案是一个数值。 这正是我们的模型所预测的! 您可以继续在其他可能要测试的问题上验证模型,希望能获得同样积极的结果。 祝贺您-您现在已经成功地训练了可以定义任何给定问题类别的多类 CNN。
总结
在本章中,我们展示了如何使用 CNN 从 NLP 数据中学习以及如何使用 PyTorch 从头开始训练 CNN。 虽然深度学习方法与 RNN 中使用的方法有很大不同,但从概念上讲,CNN 以算法方式使用 n 语法语言模型背后的动机,以便从其邻近单词的上下文中提取有关句子中单词的隐式信息。 现在我们已经掌握了 RNN 和 CNN,我们可以开始扩展这些技术,以构建更高级的模型。
在下一章中,我们将学习如何构建利用卷积神经网络和循环神经网络元素的模型,并在序列上使用它们来执行更高级的操作,例如文本翻译。 这些被称为序列到序列网络。
七、使用序列到序列神经网络的文本翻译
在前两章中,我们使用神经网络对文本进行分类并执行情感分析。 两项任务都涉及获取 NLP 输入并预测一些值。 就我们的情感分析而言,这是一个介于 0 和 1 之间的数字,代表我们句子的情感。 就我们的句子分类模型而言,我们的输出是一个多类预测,其中我们的句子属于多个类别。 但是,如果我们不仅希望做出单个预测,还希望做出整个句子,该怎么办? 在本章中,我们将构建一个序列到序列模型,该模型将一种语言的句子作为输入,并输出另一种语言的句子翻译。
我们已经探索了用于 NLP 学习的几种类型的神经网络架构,即 “第 5 章”,“循环神经网络和情感分析”中的循环神经网络,以及“第 6 章”,“使用 CNN 的文本分类”中的卷积神经网络。 在本章中,我们将再次使用这些熟悉的 RNN,而不仅仅是构建简单的 RNN 模型,我们还将 RNN 用作更大,更复杂的模型的一部分,以执行序列到序列的翻译。 通过使用我们在前几章中了解到的 RNN 的基础,我们可以展示如何扩展这些概念,以创建适合目的的各种模型。
在本章中,我们将介绍以下主题:
- 序列到序列模型理论
- 构建用于文本翻译的序列到序列神经网络
- 下一步
技术要求
本章的所有代码都可以在这个页面中找到。
序列到序列模型理论
序列到序列模型与到目前为止我们所看到的常规神经网络结构非常相似。 主要区别在于,对于模型的输出,我们期望使用另一个序列,而不是二进制或多类预测。 这在翻译之类的任务中特别有用,我们可能希望将整个句子转换为另一种语言。
在以下示例中,我们可以看到我们的英语到西班牙语翻译将单词映射到单词:

图 7.1 –英语到西班牙语的翻译
输入句子中的第一个单词与输出句子中的第一个单词很好地映射。 如果所有语言都是这种情况,我们可以简单地通过训练过的模型将句子中的每个单词逐个传递以获得输出句子,并且不需要任何序列到序列建模,如下所示:

图 7.2 –单词的英语到西班牙语翻译
但是,我们从的 NLP 经验中得知,语言并不像这样简单! 一种语言中的单个单词可能会映射到其他语言中的多个单词,并且这些单词在语法正确的句子中出现的顺序可能并不相同。 因此,我们需要一个可以捕获整个句子的上下文并输出正确翻译的模型,而不是旨在直接翻译单个单词的模型。 这是序列到序列建模必不可少的地方,如下所示:

图 7.3 –用于翻译的序列到序列建模
为了训练一个序列到序列模型,该模型捕获输入句子的上下文并将其转换为输出句子,我们将实质上训练两个较小的模型,使我们能够做到这一点:
- 编码器模型,其中捕获句子的上下文并将其作为单个上下文向量输出
- 解码器,它使用原始句子的上下文向量表示并将其翻译成另一种语言
因此,实际上,我们完整的序列到序列翻译模型实际上将如下所示:

图 7.4 –完整的序列到序列模型
通过将模型分成单独的编码器和解码器元素,我们可以有效地模块化我们的模型。 这意味着,如果我们希望训练多个模型以将英语翻译成不同的语言,则无需每次都重新训练整个模型。 我们只需要训练多个不同的解码器就可以将上下文向量转换为输出语句。 然后,在进行预测时,我们可以简单地交换我们希望用于翻译的解码器:

图 7.5 –详细的模型布局
接下来,我们将检查序列到序列模型的编码器和解码器组件。
编码器
我们的序列到序列模型的编码器元素的目的是能够完全捕获我们输入句子的上下文并将其表示为向量。 我们可以通过使用 RNN 或更具体地说是 LSTM 来实现。 您可能从我们前面的章节中回忆过,RNN 接受顺序输入并在整个顺序中保持隐藏状态。 序列中的每个新单词都会更新隐藏状态。 然后,在序列的最后,我们可以使用模型的最终隐藏状态作为下一层的输入。
在我们的编码器的情况下,隐藏状态表示整个句子的上下文向量表示,这意味着我们可以使用 RNN 的隐藏状态输出来表示整个输入句子:

图 7.6 –检查编码器
我们使用最终的隐藏状态hₙ作为上下文向量,然后使用训练过的解码器对其进行解码。 也值得观察,在我们的序列到序列模型的上下文中,我们分别在输入句子的开头和结尾添加了start和end标记。 这是因为我们的输入和输出没有有限的长度,并且我们的模型需要能够学习句子何时结束。 我们的输入语句将始终以end标记结尾,该标记向编码器发出信号,表明此时的隐藏状态将用作此输入语句的最终上下文向量表示形式。 类似地,在解码器步骤中,我们将看到我们的解码器将继续生成单词,直到它预测到end标记为止。 这使我们的解码器可以生成实际的输出语句,而不是无限长的标记序列。
接下来,我们将研究解码器如何获取此上下文向量,并学习将其转换为输出语句。
解码器
我们的解码器从我们的编码器层获取最终隐藏状态,并将其解码为另一种语言的句子。 我们的解码器是 RNN,类似于我们的编码器,但是我们的编码器会根据当前的隐藏状态和句子中的当前单词来更新其隐藏状态,而解码器会在每次迭代时更新其隐藏状态并输出标记, 当前隐藏状态和句子中的先前预测单词。 在下图中可以看到:

图 7.7 –检查解码器
首先,我们的模型将上下文向量作为编码器步骤h0的最终隐藏状态。 然后,我们的模型旨在根据给定的当前隐藏状态预测句子中的下一个单词,然后预测句子中的前一个单词。 我们知道我们的句子必须以“开始”标记开头,因此,在第一步中,我们的模型会尝试根据给定的先前隐藏状态h0来预测句子中的第一个单词, 句子(在这种情况下,是“开始”标记)。 我们的模型进行预测("pienso"),然后更新隐藏状态以反映模型的新状态h1。 然后,在下一步中,我们的模型将使用新的隐藏状态和最后的预测单词来预测句子中的下一个单词。 这一直持续到模型预测出end标记为止,这时我们的模型停止生成输出字。
该模型背后的直觉与到目前为止我们所学的关于语言表示的知识一致。 给定句子中的单词取决于其前面的单词。 因此,要预测句子中的任何给定单词而不考虑之前已被预测的单词,这将是没有意义的,因为任何给定句子中的单词都不是彼此独立的。
我们像以前一样学习模型参数:通过向前传递,根据预测句子计算目标句子的损失,并通过网络反向传播此损失,并随即更新参数。 但是,使用此过程进行学习可能会非常缓慢,因为首先,我们的模型具有很小的预测能力。 由于我们对目标句子中单词的预测不是彼此独立的,因此,如果我们错误地预测目标句子中的第一个单词,则输出句子中的后续单词也不太可能是正确的。 为了帮助完成此过程,我们可以使用一种称为教师强制的技术。
使用教师强制
由于我们的模型最初并未做出良好的预测,因此我们会发现任何初始误差都会成倍增加。 如果我们在句子中的第一个预测单词不正确,那么句子的其余部分也可能不正确。 这是因为我们的模型所做的预测取决于之前所做的预测。 这意味着我们的模型所遭受的任何损失都可以成倍增加。 因此,我们可能会遇到梯度爆炸问题,这使得我们的模型很难学习任何东西:

图 7.8 –使用教师强制
但是,通过使用教师强制,我们使用正确的先前目标词来训练我们的模型,以便一个错误的预测不会抑制我们的模型从正确的预测中学习的能力。 这意味着,如果我们的模型在句子中的某一点做出了错误的预测,那么它仍然可以使用后续单词来做出正确的预测。 尽管我们的模型仍然会错误地预测单词,并且会损失损失以更新梯度,但是现在,我们没有遭受梯度爆炸的困扰,并且我们的模型将更快地学习:

图 7.9 –更新损失
您可以考虑使用教师强制作为一种帮助我们的模型在每个时间步上独立于其先前预测进行学习的方式。 这样一来,早期阶段错误预测所导致的损失就不会转移到后续阶段。
通过组合编码器和解码器步骤,并应用教师强制来帮助我们的模型学习,我们可以构建一个序列到序列模型,该模型将允许我们将一种语言的序列翻译成另一种语言。 在下一节中,我们将说明如何使用 PyTorch 从头开始构建它。
构建用于文本翻译的序列到序列模型
为了建立我们的序列到序列模型进行翻译,我们将实现前面概述的编码器/解码器框架。 这将显示如何将模型的两半一起使用,以便使用编码器捕获数据的表示形式,然后使用我们的解码器将该表示形式转换为另一种语言。 为此,我们需要获取数据。
准备数据
到现在为止,我们对机器学习有了足够的了解,知道对于这样的任务,我们将需要一组带有相应标签的训练数据。 在这种情况下,我们将需要一种语言的句子以及另一种语言的相应翻译。 幸运的是,我们在上一章中使用的Torchtext库包含一个数据集,可让我们获取此信息。
Torchtext中的 Multi30k 数据集由大约 30,000 个句子以及相应的多种语言翻译组成。 对于此翻译任务,我们的输入句子将使用英语,而我们的输出句子将使用德语。 因此,我们经过全面训练的模型将允许我们将英语句子翻译成德语。
我们将从提取数据并对其进行预处理开始。 我们将再次使用spacy,其中包含内置词汇表,可用于标记数据:
-
我们首先将
spacy分词器加载到 Python 中。我们需要为每一种语言做一次,因为我们将为这个任务构建两个完全独立的词汇表。spacy_german = spacy.load('de') spacy_english = spacy.load('en')重要的提示
您可能需要通过执行以下操作从命令行安装德语词汇表(我们在上一章中安装了英语词汇表):
python3 -m spacy download de -
接下来,我们为每种语言创建一个函数来标记我们的句子。请注意,我们为输入的英语句子创建的分词器将标记的顺序颠倒了。
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 组成,则可以证明反转输入句子时模型中的信息流得到改善。 例如,让我们以英语作为基本输入句子,但不作反述,如下所示:
![Figure 7.10 – Reversing the input words]()
图 7.10 –反转输入字
在这里,我们可以看到,为了正确预测第一个输出单词
y0,我们从x0开始的第一个英语单词必须经过三个 RNN 层才能进行预测。 就学习而言,这意味着我们的梯度必须通过三个 RNN 层进行反向传播,同时保持通过网络的信息流。 现在,我们将其与的情况进行比较,在该情况下我们反转了输入句子:![Figure 7.11 – Reversing the input sentence]()
图 7.11 –反转输入语句
现在我们可以看到输入句子中第一个真正单词与输出句子中相应单词之间的距离只是一个 RNN 层。 这意味着梯度只需要反向传播到一层,这意味着与这两个词之间的距离为三层时相比,我们网络的信息流和学习能力要大得多。
如果我们要计算逆向和非逆向变体的输入单词与它们的输出对应单词之间的总距离,我们会发现它们是相同的。 但是,我们之前已经看到输出语句中最重要的单词是第一个单词。 这是因为输出句子中的单词取决于它们之前的单词。 如果我们错误地预测了输出句子中的第一个单词,那么我们句子中的其余单词很可能也会被错误地预测。 但是,通过正确预测第一个单词,我们可以最大程度地正确预测整个句子。 因此,通过最小化输出句子中第一个单词与其输入对应单词之间的距离,我们可以提高模型学习这种关系的能力。 这增加了该预测正确的机会,从而最大化了正确预测整个输出句子的机会。
-
构造好分词后,我们现在需要定义分词的字段。请注意我们如何在序列中添加开始和结束标记,以便我们的模型知道序列的输入和输出何时开始和结束。为了简单起见,我们还将所有输入句子转换为小写。
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) -
定义了我们的字段后,我们的分词就变成了简单的单行本。包含 30000 个句子的数据集内置了训练、验证和测试集,我们可以将其用于我们的模型。
train_data, valid_data, test_data = Multi30k.splits(exts = ('.en', '.de'), fields = (SOURCE, TARGET)) -
我们可以使用数据集对象的
examples属性检查单个句子。在这里,我们可以看到源(src)属性包含了我们的英语反向输入句,而我们的目标(trg)包含了我们的德语非反向输出句。print(train_data.examples[0].src) print(train_data.examples[0].trg)这给我们以下输出:
![Figure 7.12 – Training data examples]()
图 7.12 –训练数据示例
-
现在,我们可以检查我们每个数据集的大小。在这里,我们可以看到,我们的训练数据集由 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 –数据样本长度
-
现在,我们可以建立我们的词汇表并检查它们的大小。我们的词汇表应该包括在我们的数据集中找到的每一个独特的单词。我们可以看到,我们的德语词汇量比英语词汇量大得多。我们的词汇量明显小于每种语言的每个词汇的真实大小(英语词典中的每个单词)。因此,由于我们的模型只能准确地翻译它以前见过的单词,所以我们的模型不太可能很好地泛化到英语中所有可能的句子。这就是为什么要准确地训练这样的模型,需要极其庞大的 NLP 数据集(比如谷歌能够获得的数据集)。
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)))这给出以下输出:
![Figure 7.14 – Vocabulary size of the dataset]()
图 7.14 –数据集的词汇量
-
最后,我们可以从我们的数据集创建我们的数据迭代器。就像我们之前所做的那样,我们指定使用支持 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 )
现在我们的数据已经过预处理,我们可以开始构建模型本身。
构建编码器
现在,我们准备开始构建我们的编码器:
-
首先,我们通过继承我们的
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 -
接下来,我们在编码器内定义我们的嵌入层,即输入维数的长度和嵌入维数的深度。
self.embedding = nn.Embedding(input_dims, emb_dims) -
接下来,我们定义我们实际的 LSTM 层。这从嵌入层中获取我们的嵌入句子,保持一个定义长度的隐藏状态,并由若干层组成(我们稍后将定义为 2)。我们还实现了丢弃来对我们的网络进行正则化。
self.rnn = nn.LSTM(emb_dims, hid_dims, n_layers, dropout = dropout) self.dropout = nn.Dropout(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 模型
现在我们已经构建了编码器,让我们开始构建解码器。
构建解码器
我们的解码器将从我们的编码器的 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 模型
在这里,我们可以看到,分别定义编码器和解码器并不是特别复杂。 但是,当我们将这些步骤组合成一个更大的序列到序列模型时,事情开始变得有趣起来:
构建完整的序列到序列模型
现在,我们必须将模型的两半拼接在一起,以产生完整的序列到序列模型:
-
我们先创建一个新的序列对序列类。这将允许我们将编码器和解码器作为参数传递给它。
class Seq2Seq(nn.Module): def __init__(self, encoder, decoder, device): super().__init__() self.encoder = encoder self.decoder = decoder self.device = device -
接下来,我们在
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) -
接下来,我们将输入的句子输入到编码器中,得到输出的隐藏状态。
h, cell = self.encoder(src) -
然后,我们必须在解码器模型中循环,为输出序列中的每一步生成一个输出预测。我们输出序列的第一个元素将始终是
<start>标记。我们的目标序列已经包含这个作为第一个元素,所以我们只需通过取列表中的第一个元素来设置我们的初始输入等于此。input = trg[0,:] -
接下来,我们遍历并做出预测。 我们将隐藏状态(从编码器的输出)传递到解码器,以及初始输入(即
<start>标记)。 这将返回我们序列中所有单词的预测。 但是,我们只对当前步骤中的单词感兴趣。 也就是说,序列中的下一个单词。 请注意,我们是如何从 1 而不是 0 开始循环的,所以我们的第一个预测是序列中的第二个单词(因为预测的第一个单词将始终是起始标记)。 -
这个输出由一个目标词汇长度的向量组成,并对词汇中的每个单词进行预测。我们采取
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 -
最后,我们创建一个
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 -
然后我们将编码器和解码器传递给我们的
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 的初始化。 尽管理论上该模型应该能够在没有(零)权重的情况下进行学习,但事实表明,使用随机权重进行初始化可以帮助模型更快地学习。 让我们开始吧:
-
在这里,我们将用来自正态分布的随机样本的权重来初始化我们的模型,数值在 -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) -
接下来,与我们所有其他模型一样,我们定义我们的优化器和损失函数。我们正在使用交叉熵损失,因为我们正在执行多类分类(而不是二分类的二进制交叉熵损失)。
optimizer = optim.Adam(model.parameters()) criterion = nn.CrossEntropyLoss(ignore_index = TARGET.vocab.stoi[TARGET.pad_token]) -
接下来,我们在一个名为
train()的函数内定义训练过程。首先,我们将我们的模型设置为训练模式,并将epoch_loss设置为0。def train(model, iterator, optimizer, criterion, clip): model.train() epoch_loss = 0 -
然后,我们在训练迭代器内循环检查每个批次,并提取要翻译的句子(
src)和这个句子的正确翻译(trg)。然后我们将我们的梯度归零(以防止梯度累积),并通过传递我们的模型函数我们的输入和输出来计算我们模型的输出。for i, batch in enumerate(iterator): src = batch.src trg = batch.trg optimizer.zero_grad() output = model(src, trg) -
接下来,我们需要通过比较我们的预测输出和真实的、正确的翻译句子来计算我们模型预测的损失。我们使用
shape和view函数重塑我们的输出数据和目标数据,以便创建两个可以比较的张量来计算损失。我们计算我们的输出和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() -
然后,我们实现梯度剪裁以防止模型内的梯度爆炸,通过梯度下降对我们的优化器进行必要的参数更新,最后将批次的损失添加到周期损失中。整个过程对一个训练周期内的所有批次重复进行,从而返回每个批次的最终平均损失。
torch.nn.utils.clip_grad_norm_(model.parameters(), clip) optimizer.step() epoch_loss += loss.item() return epoch_loss / len(iterator) -
之后,我们创建一个类似的函数,叫做
evaluate()。这个函数将计算我们的验证数据在整个网络中的损失,以便评估我们的模型在翻译它以前没有见过的数据时的表现。这个函数与我们的train()函数几乎相同,只是我们切换到了评估模式。model.eval() -
由于我们不对我们的权重进行任何更新,我们需要确保实现
no_grad模式。with torch.no_grad(): -
唯一不同的是,我们需要确保在评估模式下关闭教师强制。我们希望评估我们的模型在未见数据上的表现,而启用教师强迫将使用我们正确的(目标)数据来帮助我们的模型做出更好的预测。我们希望我们的模型能够做出完美的、无辅助的预测。
output = model(src, trg, 0) -
最后,我们需要创建一个训练循环,在这个循环中调用
train()和evaluate()函数。我们首先定义了我们希望训练的次数和最大梯度(用于梯度剪接)。我们还将最低验证损失设置为无穷大。这将在后面用来选择我们表现最好的模型。epochs = 10 grad_clip = 1 lowest_validation_loss = float('inf') -
然后,我们循环浏览我们的每个周期,并在每个周期内,使用我们的
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()
```
-
接下来,对于每个周期,我们确定我们刚刚训练的模型是否是我们迄今为止看到的表现最好的模型。如果我们的模型在我们的验证数据上表现最好(如果验证损失是我们迄今为止看到的最低的),我们就保存我们的模型。
if valid_loss < lowest_validation_loss: lowest_validation_loss = valid_loss torch.save(model.state_dict(), 'seq2seq.pt') -
最后,我们只需打印我们的输出。
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}')如果我们的训练工作正常,我们应该看到训练损失会随着时间而减少,就像这样:

图 7.17 –训练模型
在这里,我们可以看到我们的训练和验证损失似乎都随着时间而下降。 我们可以继续训练模型很多时间,理想情况下,直到验证损失达到最低值为止。 现在,我们可以评估表现最佳的模型,以查看进行实际翻译时的表现。
评估模型
为了评估我们的模型,我们将使用我们的测试数据集并通过我们的模型运行英语句子,以获得对德语翻译的预测。 然后,我们将能够将其与真实的预测进行比较,以查看我们的模型是否做出了准确的预测。 让我们开始吧!
-
我们首先创建一个
translate()函数。这在功能上与我们创建的evaluate()函数相同,以计算验证集的损失。然而,这一次,我们不关心我们的模型的损失,而是预测的输出。我们将源句和目标句传递给模型,同时确保我们将教师强制关闭,这样我们的模型就不会使用这些句子来进行预测。然后我们把我们模型的预测结果,用argmax函数来确定我们模型预测输出句子中每个单词的索引。output = model(src, trg, 0) preds = torch.tensor([[torch.argmax(x).item()] for x in output]) -
然后,我们可以使用这个指数从我们的德语词汇中获得实际的预测词。最后,我们将英语输入与我们的模型进行比较,该模型包含正确的德语句子和预测的德语句子。请注意,在这里,我们使用
[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 –翻译输出第一部分
在其他情况下,我们的模型仅需一个词即可完成。 在这种情况下,我们的模型会预测单词hüten而不是mützen; 但是,hüten实际上是mützen可接受的翻译,尽管这些词在语义上可能并不相同:

图 7.19 –翻译输出第二部分
我们还可以看到一些似乎被误解的示例。 在下面的示例中,我们预测的德语句子的英语等同词为A woman climbs through one,这不等于Young woman climbing rock face。 但是,该模型仍然设法翻译了英语句子(女性和攀岩)的关键元素:

图 7.20 –翻译输出第三部分
在这里,我们可以看到尽管我们的模型清楚地尝试了将英语翻译成德语的尝试,但它远非完美,并且会犯一些错误。 当然,它不能愚弄以德语为母语的人! 接下来,我们将讨论几种改进序列到序列翻译模型的方法。
后续步骤
虽然我们已经证明序列到序列模型可以有效地执行语言翻译,但是从头开始训练的模型无论如何都不是完美的翻译器。 部分原因是由于我们的训练数据相对较小。 我们用 30,000 个英语/德语句子集训练了模型。 尽管这看起来可能很大,但是为了训练一个完美的模型,我们需要一个大几个数量级的训练集。
从理论上讲,我们需要为整个英语和德语中的每个单词提供几个示例,以使我们的模型真正理解其上下文和含义。 就上下文而言,我们训练中的 30,000 个英语句子仅包含 6,000 个独特单词。 据说说英语的人的平均词汇量在 20,000 到 30,000 个单词之间,这使我们了解到,要训练一个表现出色的模型,我们需要多少个例句。 这可能就是为什么最准确的翻译工具归能够访问大量语言数据的公司(例如 Google)所有的原因。
总结
在本章中,我们介绍了如何从头开始构建序列到序列模型。 我们学习了如何分别对编码器和解码器组件进行编码,以及如何将它们集成到一个模型中,该模型能够将句子从一种语言翻译成另一种语言。
尽管我们的由编码器和解码器组成的序列到序列模型对于序列翻译很有用,但它已不再是最新技术。 在过去的几年中,已经完成了将序列到序列模型与注意力模型结合起来以实现最新表现的方法。
在下一章中,我们将讨论如何在序列到序列学习的上下文中使用注意力网络,并展示如何使用两种技术来构建聊天机器人。
八、使用基于注意力的神经网络构建聊天机器人
如果您曾经看过任何未来派科幻电影,那么您很有可能会看到与机器人的人类对话。 基于机器的情报一直是小说作品中的长期特征。 但是,由于 NLP 和深度学习的最新发展,与计算机的对话不再是幻想。 虽然我们可能距离真正的智能还很多年,在这种情况下,计算机能够以与人类相同的方式理解语言的含义,但机器至少能够进行基本的对话并提供基本的智能印象。
在上一章中,我们研究了如何构建序列到序列模型以将句子从一种语言翻译成另一种语言。 能够进行基本交互的对话型聊天机器人的工作方式几乎相同。 当我们与聊天机器人交谈时,我们的句子将成为模型的输入。 输出是聊天机器人选择回复的内容。 因此,我们正在训练它如何响应,而不是训练我们的聊天机器人来学习如何解释输入的句子。
我们将在上一章中扩展序列到序列模型,在模型中增加注意力。 对序列到序列模型的这种改进意味着我们的模型可以学习输入句子中要查找的位置以获得所需信息的方式,而不是使用整个输入句子决策。 这项改进使我们能够创建具有最先进表现的效率更高的序列到序列模型。
在本章中,我们将研究以下主题:
- 神经网络中的注意力理论
- 在神经网络内实现注意力来构建聊天机器人
技术要求
本章的所有代码都可以在这个页面中找到。
神经网络中的注意力理论
在上一章中,在用于句子翻译的序列到序列模型中(没有引起注意),我们同时使用了编码器和解码器。 编码器从输入句子中获得了隐藏状态,这是我们句子的一种表示形式。 然后,解码器使用此隐藏状态执行转换步骤。 对此的基本图形说明如下:

图 8.1 –序列到序列模型的图形表示
但是,对整个隐藏状态进行解码不一定是使用此任务的最有效方法。 这是因为隐藏状态代表整个输入句子; 但是,在某些任务中(例如预测句子中的下一个单词),我们无需考虑输入句子的整体,而只考虑与我们要进行的预测相关的部分。 我们可以通过在序列到序列神经网络中使用注意力来证明这一点。 我们可以教导我们的模型仅查看输入的相关部分以进行预测,从而建立一个更加有效和准确的模型。
考虑以下示例:
I will be traveling to Paris, the capital city of France, on the 2nd of March. My flight leaves from London Heathrow airport and will take approximately one hour.
假设我们正在训练一种模型来预测句子中的下一个单词。 我们可以先输入句子的开头:
The capital city of France is _____.
在这种情况下,我们希望我们的模型能够检索单词Paris。 如果要使用基本的序列到序列模型,我们会将整个输入转换为隐藏状态,然后我们的模型将尝试从中提取相关信息。 这包括有关航班的所有无关信息。 您可能会在这里注意到,我们只需要查看输入句子的一小部分即可确定完成句子所需的相关信息:
I will be traveling to Paris, the capital city of France, on the 2nd of March. My flight leaves from London Heathrow airport and will take approximately one hour.
因此,如果我们可以训练模型以仅使用输入句子中的相关信息,则可以做出更准确和相关的预测。 为此,我们可以在网络中实现注意力。
我们可以采用两种主要的注意力机制:局部和全局注意力。
比较本地和全局注意力
我们可以在网络中通过实现的两种注意形式与非常相似,但存在细微的关键区别。 我们将从关注本地开始。
在局部注意力中,我们的模型仅查看编码器的一些隐藏状态。 例如,如果我们正在执行句子翻译任务,并且我们正在计算翻译中的第二个单词,则模型可能希望仅查看与输入句子中第二个单词相关的编码器的隐藏状态。 这意味着我们的模型需要查看编码器的第二个隐藏状态(h2),但也可能需要查看它之前的隐藏状态(h1)。
在下图中,我们可以在实践中看到这一点:

图 8.2 –本地注意力模型
我们首先从最终隐藏状态hₙ计算对齐位置pₜ。 这告诉我们需要进行观察才能发现哪些隐藏状态。 然后,我们计算局部权重并将其应用于隐藏状态,以确定上下文向量。 这些权重可能告诉我们,更多地关注最相关的隐藏状态(h2),而较少关注先前的隐藏状态(h1)。
然后,我们获取上下文向量,并将其转发给解码器以进行预测。 在我们基于非注意力的序列到序列模型中,我们只会向前传递最终的隐藏状态hₙ,但在这里我们看到的是,我们仅考虑了我们的相关隐藏状态,模型认为它对于做出预测是必要的。
全局注意力模型的运作方式与非常相似。 但是,我们不仅要查看所有隐藏状态,还希望查看模型的所有隐藏状态,因此命名为全局。 我们可以在此处看到全局注意力层的图形化图示:

图 8.3 –全局注意力模型
我们在前面的图中可以看到,尽管这看起来与我们的本地关注框架非常相似,但是我们的模型现在正在查看所有隐藏状态,并计算所有隐藏状态的全局权重。 这使我们的模型可以查看它认为相关的输入句子的任何给定部分,而不必局限于由本地关注方法确定的本地区域。 我们的模型可能只希望看到一个很小的局部区域,但这在模型的能力范围内。 考虑全局注意力框架的一种简单方法是,它实质上是学习一个掩码,该掩码仅允许通过与我们的预测相关的隐藏状态:

图 8.4 –组合模型
我们在前面的图中可以看到,通过了解要注意的隐藏状态,我们的模型可以控制解码步骤中使用哪些状态来确定我们的预测输出。 一旦确定了要注意的隐藏状态,我们就可以使用多种不同的方法将它们组合在一起-通过连接或采用加权的点积。
使用基于注意力的序列到序列神经网络构建聊天机器人
准确说明如何在神经网络中实现注意力的最简单方法是通过示例。 现在,我们将使用应用了关注框架的序列到序列模型,完成从头构建聊天机器人的所有步骤。
与所有其他 NLP 模型一样,我们的第一步是获取并处理数据集以用于训练我们的模型。
获取我们的数据集
要训练我们的聊天机器人,我们需要一个会话数据集,模型可以通过该数据集学习如何响应。 我们的聊天机器人将接受一系列人工输入,并使用生成的句子对其进行响应。 因此,理想的数据集将由多行对话和适当的响应组成。 诸如此类任务的理想数据集将是来自两个人类用户之间的对话的实际聊天记录。 不幸的是,这些数据由私人信息组成,很难在公共领域获得,因此对于此任务,我们将使用电影脚本的数据集。
电影脚本由两个或更多角色之间的对话组成。 尽管此数据不是我们希望的自然格式,但我们可以轻松地将其转换为所需的格式。 以两个字符之间的简单对话为例:
- 第 1 行:
Hello Bethan. - 第 2 行:
Hello Tom, how are you? - 第 3 行:
I'm great thanks, what are you doing this evening? - 第 4 行:
I haven't got anything planned. - 第 5 行:
Would you like to come to dinner with me?
现在,我们需要将其转换为调用和响应的输入和输出对,其中输入是脚本中的一行(调用),预期输出是脚本的下一行(响应)。 我们可以将n行的脚本转换为n-1对输入/输出:

图 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 –检查数据集
首先,您会注意到我们的行与预期的一样,因为第一行的下半部分成为下一行的前半部分。 我们还可以注意到,每行的通话和响应半部分由制表符分隔符(\t)分隔,我们的每行均由新的行分隔符(\n)。 在处理数据集时,我们必须考虑到这一点。
第一步是创建一个词汇表或语料库,其中包含我们数据集中的所有唯一单词。
创建词汇表
过去,我们的语料库由几个词典组成,这些词典由我们的语料库中的唯一单词以及在单词和索引之间的查找组成。 但是,我们可以通过创建一个包含所有必需元素的词汇表类,以一种更为优雅的方式来实现此目的:
-
我们先创建
Vocabulary类。我们用空字典--word2index和word2count来初始化这个类。我们还用填充标记的占位符以及句子开始(SOS)和句子结束(EOS)标记初始化了index2word字典。我们也会对词汇中的单词数量进行统计(首先是 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 -
接下来,我们创建我们将用来填充词汇的函数。
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 -
我们还使用
addSentence函数将addWord函数应用于给定句子中的所有单词。def addSentence(self, sent): for word in sent.split(' '): self.addWord(word)我们可以做的加快模型训练的一件事是减少词汇量。 这意味着任何嵌入层都将更小,并且模型中学习的参数总数会更少。 一种简单的方法是从我们的词汇表中删除所有低频词。 在我们的数据集中仅出现一次或两次的任何单词都不太可能具有巨大的预测能力,因此在最终模型中将它们从语料库中删除并替换为空白标记可以减少我们训练模型所需的时间并减少过拟合,而不会对我们模型的预测有很大的负面影响。
-
为了从词汇中删除低频词,我们可以实现一个
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))) -
最后,我们的索引从新的
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)
现在,我们已经定义了一个词汇类,可以很容易地用我们的输入句子填充。 接下来,我们实际上需要加载数据集以创建训练数据。
加载数据
我们将通过以下步骤开始加载数据:
-
读取我们的数据的第一步是执行任何必要的步骤来清理数据,使其更易于人类阅读。我们首先将数据从 Unicode 转换为 ASCII 格式。我们可以很容易地使用一个函数来完成这个工作。
def unicodeToAscii(s): return ''.join( c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn' ) Next, we want to process our input s -
接下来,我们要处理我们的输入字符串,使它们都是小写的,除了最基本的字符外,不包含任何尾部的空格或标点符号。我们可以通过使用一系列的正则表达式来实现。
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 -
最后,我们在一个更广泛的函数--
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 个字长的实例。
-
为此,我们创建了几个过滤函数。第一个函数,
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)] -
现在,我们只需要创建一个最后的函数,应用我们之前整理的所有函数,并运行它来创建我们的词汇和数据对。
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 个单词的句子时,这将减少为仅由 18,000 个不同单词组成的 64,000 对:
![Figure 8.7 – Value of sentences in the dataset]()
图 8.7 –数据集中句子的值
-
我们可以打印我们处理过的输入/输出对中的一部分,以验证我们的函数是否全部正确工作。
print("Example Pairs:") for pair in pairs[-10:]: print(pair)生成以下输出:

图 8.8 –处理后的输入/输出对
看来我们已经成功地将数据集分为输入和输出对,可以在上面训练网络。
最后,在开始构建模型之前,我们必须从语料库和数据对中删除稀有词。
删除稀有词
如前所述,仅在数据集中出现几次的单词会增加模型的维数,从而增加模型的复杂度以及训练模型所需的时间。 因此,最好将其从我们的训练数据中删除,以使我们的模型尽可能简化和高效。
您可能还记得我们在词汇表中内置了trim函数,这使我们能够从词汇表中删除不经常出现的单词。 现在,我们可以创建一个函数来删除这些稀有单词,并从词汇表中调用trim方法,这是我们的第一步。 您将看到,这从我们的词汇表中删除了大部分单词,这表明我们词汇表中的大多数单词很少出现。 这是可以预期的,因为任何语言模型中的单词分布都会遵循长尾分布。 我们将使用以下步骤删除单词:
-
我们首先要计算出我们将保留在模型中的词的百分比。
def removeRareWords(voc, all_pairs, minimum): voc.trim(minimum)结果为以下输出:
![Figure 8.9 – Percentage of words to be kept]()
图 8.9 –要保留的单词百分比
-
在这个函数中,我们循环检查输入和输出句子中的所有单词。如果对于一个给定的对子,无论是输入句还是输出句都有一个不在我们新修剪的语料中的单词,我们就从我们的数据集中删除这个对子。我们打印输出结果,发现即使我们放弃了一半以上的词汇,也只放弃了 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 –构建数据集后的最终值
现在我们有了完成的数据集,我们需要构建一些函数,将我们的数据集转换为成批的张量,然后将它们传递给模型。
将句子对转换为张量
我们知道我们的模型不会将原始文本作为输入,而是将句子的张量表示作为输入。 我们也不会一一处理句子,而是分批量。 为此,我们需要将输入和输出语句都转换为张量,其中张量的宽度表示我们希望在其上训练的批量的大小:
-
我们首先创建几个辅助函数,用来将我们的词对转化为时序。我们首先创建一个
indexFromSentence函数,它从词汇中抓取句子中每个单词的索引,并在句尾附加一个 EOS 标记。def indexFromSentence(voc, sentence): return [voc.word2index[word] for word in\ sent.split(' ')] + [EOS_token] -
其次,我们创建了一个
zeroPad函数,它可以将任何张量用零来填充,这样张量中的所有句子实际上都是相同的长度。def zeroPad(l, fillvalue=PAD_token): return list(itertools.zip_longest(*l,\ fillvalue=fillvalue)) -
然后,为了生成我们的输入张量,我们应用这两个函数。首先,我们得到我们输入句子的指数,然后应用填充,然后将输出转化为
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 -
在我们的网络中,我们的填充标记一般应该被忽略。我们不想在这些填充的标记上训练我们的模型,所以我们创建一个布尔掩码来忽略这些标记。为此,我们使用
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 -
然后我们将其应用于
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 -
最后,为了同时创建我们的输入和输出批次,我们循环浏览批次中的对,并使用之前创建的函数为两个对创建输入和输出时序。然后我们返回所有必要的变量。
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 -
这个函数应该是我们将训练对转化为训练模型所需的全部内容。我们可以通过在随机选择的数据上执行
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.12 –目标和模板张量
现在我们已获取,清理和转换了数据,我们准备开始训练基于注意力的模型,该模型将成为聊天机器人的基础。
构建模型
与其他序列到序列模型一样,我们通过创建编码器开始。 这会将输入句子的初始张量表示转换为隐藏状态。
构建编码器
现在,我们将通过以下步骤创建编码器:
-
与我们所有的 PyTorch 模型一样,我们首先创建一个
Encoder类,该类继承自nn.Module。这里的所有元素看起来都应该和前面章节中使用的元素一样熟悉。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)模型。 尽管 GRU 仍然控制通过 RNN 的信息流,但其的复杂度比 LSTM 小,但它们没有像 LSTM 这样的单独的门和更新门。 我们在这种情况下使用 GRU 的原因有几个:
a)由于需要学习的参数较少,因此 GRU 已被证明具有更高的计算效率。 这意味着我们的模型使用 GRU 进行训练要比使用 LSTM 进行训练更快。
b)已证明 GRU 在短数据序列上具有与 LSTM 相似的表现水平。 当学习更长的数据序列时,LSTM 更有用。 在这种情况下,我们仅使用 10 个单词或更少的输入句子,因此 GRU 应该产生相似的结果。
c)事实证明,GRU 在学习小型数据集方面比 LSTM 更有效。 由于我们的训练数据的规模相对于我们要学习的任务的复杂性而言较小,因此我们应该选择使用 GRU。
-
现在我们定义我们的 GRU,考虑到输入的大小,层数,以及是否应该实现丢弃。
self.gru = nn.GRU(hidden_size, hidden_size, n_layers, dropout=(0 if n_layers == 1 else dropout), bidirectional=True)注意这里我们如何在模型中实现双向性。 您会从前面的章节中回顾到,双向 RNN 允许我们学习从句子向前移动到句子之间以及向后顺序移动的句子。 这使我们可以更好地捕获句子中每个单词相对于前后单词的上下文。 GRU 中的双向性意味着我们的编码器如下所示:
![Figure 8.13 – Encoder layout]()
图 8.13 –编码器布局
我们在输入句子中保持两个隐藏状态以及每一步的输出。
-
接下来,我们需要为我们的编码器创建一个正向传播。我们首先将输入句子嵌入,然后使用
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) -
在这之后,我们解包我们的填充并对 GRU 输出进行求和。然后,我们可以返回这个加和后的输出,以及我们最终的隐藏状态,来完成我们的正向传播。
outputs, _ = nn.utils.rnn.pad_packed_sequence(outputs) outputs = outputs[:, :, :self.hidden_size] + a \ outputs[:, : ,self.hidden_size:] return outputs, hidden
现在,我们将在下一部分继续创建关注模块。
构建注意力模块
接下来,我们需要构建我们的注意力模块,该模块将应用于我们的编码器,以便我们可以从编码器输出的相关部分中学习。 我们将按照以下方式进行:
-
首先为注意力模型创建一个类。
class Attn(nn.Module): def __init__(self, hidden_size): super(Attn, self).__init__() self.hidden_size = hidden_size -
然后,在这个类中创建
dot_score函数。这个函数简单地计算我们的编码器输出与我们的编码器输出的隐藏状态的点积。虽然还有其他的方法可以将这两个张量转化为单一的表示方式,但使用点积是最简单的方法之一。def dot_score(self, hidden, encoder_output): return torch.sum(hidden * encoder_output, dim=2) -
然后,我们在前传内使用这个函数。首先,根据
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)
接下来,我们可以在解码器中使用此关注模块来创建关注焦点的解码器。
构建解码器
我们现在将构造解码器,如下所示:
-
我们首先创建
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 -
然后我们在这个模块中创建我们的层。我们将创建一个嵌入层和一个相应的丢弃层。我们再次为我们的解码器使用 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) -
在定义了所有的层之后,我们需要为解码器创建一个前向通道。请注意前向通证将如何一步一步(单词)地使用。我们首先得到当前输入词的嵌入,然后通过 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) -
接下来,我们使用注意力模块从 GRU 输出中获取注意力权重。然后将这些权重与编码器输出相乘,从而有效地得到我们的注意力权重和编码器输出的加权和。
attn_weights = self.attn(rnn_output, encoder_outputs) context = attn_weights.bmm(encoder_outputs.transpose(0, 1)) -
然后,我们将加权上下文向量与 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)) -
对于我们解码器内的最后一步,我们只需使用这个最终的连通输出来预测下一个词,并应用一个 softmax 函数。正向传播最后会返回这个输出,以及最终的隐藏状态。这个前向通证将被迭代,下一个前向通证将使用句子中的下一个词和这个新的隐藏状态。
output = self.out(concat_output) output = F.softmax(output, dim=1) return output, hidden
现在我们已经定义了模型,我们准备定义训练过程
定义训练过程
训练过程的第一步是为我们的模型定义损失的度量。 由于我们的输入张量可能由填充序列组成,由于我们输入的句子都具有不同的长度,因此我们不能简单地计算真实输出和预测输出张量之间的差。 为了解决这个问题,我们将定义一个损失函数,该函数将布尔掩码应用于输出,并且仅计算未填充标记的损失:
-
在下面的函数中,我们可以看到,我们计算的是整个输出张量的交叉熵损失。然而,为了得到总损失,我们只对被布尔掩码选中的张量元素进行平均。
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() -
对于我们的大部分训练,我们需要两个主要函数--一个函数
train(),它对我们的单批训练数据进行训练,另一个函数trainIters(),它遍历我们的整个数据集,并对每个单独的批次调用train()。我们先定义train(),以便对单批数据进行训练。创建train()函数,然后让梯度为 0,定义设备选项,并初始化变量。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 -
然后,通过编码器执行输入和序列长度的正向传播,得到输出和隐藏状态。
encoder_outputs, encoder_hidden = encoder(input_variable, lengths) -
接下来,我们创建我们的初始解码器输入,从每个句子的 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]接下来,我们实现教师强迫。 如果您从上一章的教师强迫中回想起,当以给定的概率生成输出序列时,我们将使用真正的上一个输出标记而不是预测的上一个输出标记来生成输出序列中的下一个单词。 使用教师强制可以帮助我们的模型更快收敛。 但是,我们必须小心,不要使教师强迫率过高,否则我们的模型将过于依赖教师强迫,并且不会学会独立产生正确的输出。
-
确定我们是否应该对当前步骤使用教师强制。
use_TF = True if random.random() < teacher_forcing_ratio else False -
然后,如果我们确实需要实现教师强制,请运行以下代码。我们将每一个序列批次通过解码器来获得我们的输出。然后,我们将下一个输入设置为真实输出(目标)。最后,我们使用我们的损失函数计算和累积损失,并将其打印到控制台。
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 -
如果我们不对给定的批次实现教师强迫,程序几乎是相同的。但是,我们不使用真实输出作为序列的下一个输入,而是使用模型生成的输出。
_, topi = decoder_output.topk(1) decoder_input = torch.LongTensor([[topi[i][0] for i in \ range(batch_size)]]) decoder_input = decoder_input.to(device) -
最后,与我们所有的模型一样,最后的步骤是执行反向传播,实现梯度剪接,并通过我们的编码器和解码器优化器来使用梯度下降更新权重。请记住,我们剪掉梯度是为了防止梯度消失/爆炸的问题,这在前面的章节中已经讨论过。最后,我们的训练步骤返回我们的平均损失。
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 -
接下来,如前所述,我们需要创建
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)] -
然后,我们创建一些变量,使我们能够计算迭代次数,并跟踪每个周期的总损失。
print('Starting ...') start_iteration = 1 print_loss = 0 if loadFilename: start_iteration = checkpoint['iteration'] + 1 -
接下来,我们定义我们的训练循环。对于每次迭代,我们从我们的批次列表中得到一个训练批次。然后,我们从我们的批次中提取相关字段,并使用这些参数运行一次训练迭代。最后,我们将这个批次的损失加入到我们的总体损失中。
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 -
在每一次迭代中,我们还确保打印出迄今为止的进度,跟踪我们已经完成了多少次迭代,以及每个周期的损失是多少。
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 -
为了完成,我们还需要在每隔几个周期后保存我们的模型状态。这让我们可以重新审视我们已经训练过的任何历史模型;例如,如果我们的模型开始过拟合,我们可以恢复到早期的迭代。
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?
在这里,我们有三个不同的响应,每个响应都同样有效。 因此,在与聊天机器人进行对话的每个阶段,都不会出现任何“正确”的响应。 因此,评估要困难得多。 测试聊天机器人是否产生有效输出的最直观方法是与之对话! 这意味着我们需要以一种使我们能够与其进行对话以确定其是否运行良好的方式来设置聊天机器人:
-
我们首先要定义一个类,让我们能够对编码输入进行解码并生成文本。我们通过使用所谓的
GreedyEncoder来实现这一目标。这简单地说,在解码器的每一步,我们的模型都将预测概率最高的词作为输出。我们先用预先训练好的编码器和解码器初始化GreedyEncoder类。class GreedySearchDecoder(nn.Module): def __init__(self, encoder, decoder): super(GreedySearchDecoder, self).__init__() self.encoder = encoder self.decoder = decoder -
接下来,为我们的解码器定义一个正向传播。我们将输入通过编码器得到我们编码器的输出和隐藏状态。我们把编码器的最后一个隐藏层作为解码器的第一个隐藏输入。
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] -
然后,用 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) -
之后,对序列进行迭代,每次解码一个词。我们对编码器进行正向传播,并添加一个
max函数,以获得得分最高的预测词及其得分,然后将其追加到all_tokens和all_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所有的部分都开始融合在一起。 我们具有已定义的训练和评估函数,因此最后一步是编写一个函数,该函数实际上会将我们的输入作为文本,将其传递给我们的模型,并从模型中获取响应。 这将是我们聊天机器人的“界面”,我们实际上在那里进行对话。
-
我们首先定义一个
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) -
然后,我们将我们的长度和输入时序分配给相关设备。接下来,通过搜索器(
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 -
最后,我们创建一个
runchatbot函数,作为我们聊天机器人的接口。这个函数接受人类输入的信息并打印聊天机器人的响应。我们将这个函数创建为一个while循环,一直到我们终止该函数或输入quit为止。def runchatbot(encoder, decoder, searcher, voc): input_sentence = '' while(1): try: input_sentence = input('> ') if input_sentence == 'quit': break -
然后,我们将输入的类型化输入进行归一化处理,再将归一化输入传给我们的
evaluate()函数,该函数返回聊天机器人的预测词。input_sentence = cleanString(input_sentence) output_words = evaluate(encoder, decoder, searcher,\ voc, input_sentence) -
最后,我们将这些输出词进行格式化,忽略 EOS 和填充标记,然后再打印聊天机器人的响应。因为这是一个
while循环,这让我们可以无限期地继续与聊天机器人对话。
output_words[:] = [x for x in output_words if \
not (x == 'EOS' or x == 'PAD')]
print('Response:', ' '.join(output_words))
现在我们已经构建了训练,评估和使用聊天机器人所需的所有函数,现在该开始最后一步了—训练模型并与训练过的聊天机器人进行对话。
训练模型
当我们定义了所有必需的函数时,训练模型就成为一种情况或初始化我们的超参数并调用我们的训练函数:
-
我们首先初始化我们的超参数。虽然这些只是建议的超参数,但我们的模型已经被设置为允许它们适应任何传递给它们的超参数的方式。用不同的超参数进行实验,看看哪些超参数能带来最佳的模型配置,这是一个很好的做法。在这里,你可以试验增加编码器和解码器的层数,增加或减少隐藏层的大小,或者增加批次大小。所有这些超参数都会对模型的学习效果产生影响,同时也会影响其他一些因素,例如训练模型所需的时间。
model_name = 'chatbot_model' hidden_size = 500 encoder_n_layers = 2 decoder_n_layers = 2 dropout = 0.15 batch_size = 64 -
之后,我们可以加载我们的检查点。如果我们之前已经训练过一个模型,我们可以加载之前迭代中的检查点和模型状态。这就节省了我们每次都要重新训练我们的模型。
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'] -
之后,我们可以开始构建我们的模型。我们首先从词汇中加载我们的嵌入。如果我们已经训练了一个模型,我们可以加载训练好的嵌入层。
embedding = nn.Embedding(voc.num_words, hidden_size) if loadFilename: embedding.load_state_dict(embedding_sd) We then do the same for our encoder and decoder, creating model instances using -
然后,我们对编码器和解码器做同样的工作,使用定义的超参数创建模型实例。同样,如果我们已经训练了一个模型,我们只需将训练好的模型状态加载到我们的模型中。
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) -
最后但并非最不重要的是,我们为我们的每个模型指定一个要训练的设备。请记住,如果你想使用 GPU 训练,这是至关重要的一步。
encoder = encoder.to(device) decoder = decoder.to(device) print('Models built and ready to go!')如果一切正常,并且创建的模型没有错误,则应该看到以下内容:
![Figure 8.14 – Successful output]()
图 8.14 –成功的输出
现在我们已经创建了编码器和解码器的实例,我们准备开始训练它们。
我们首先初始化一些训练超参数。 以与模型超参数相同的方式,可以调整这些参数以影响训练时间以及模型的学习方式。 裁剪控制梯度裁剪,教师强迫控制我们在模型中使用教师强迫的频率。 请注意,我们如何使用教师强制比 1,以便始终使用教师强制。 降低教学强迫率将意味着我们的模型需要更长的时间才能收敛。 但是,从长远来看,这可能有助于我们的模型更好地自行生成正确的句子。
-
我们还需要定义模型的学习率和解码器的学习率。你会发现,当解码器在梯度下降过程中进行较大的参数更新时,你的模型表现会更好。因此,我们引入一个解码器学习率,对学习率施加一个倍数,使解码器的学习率大于编码器的学习率。我们还定义了我们的模型打印和保存结果的频率,以及我们希望我们的模型运行多少个周期。
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 -
接下来,和以往在 PyTorch 中训练模型时一样,我们将模型切换到训练模式,以便更新参数。
encoder.train() decoder.train() -
接下来,我们为编码器和解码器创建优化器。我们将这些优化器初始化为 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) -
运行训练前的最后一步是确保 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() -
最后,我们准备好训练我们的模型。这可以通过简单地调用
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 –训练模型
您的模型正在训练中! 根据许多因素,例如您将模型设置为训练多少个周期以及是否使用 GPU,模型可能需要一些时间来训练。 完成后,您将看到以下输出。 如果一切正常,则模型的平均损失将大大低于开始训练时的损失,这表明模型已经学到了一些有用的信息:

图 8.16 – 4,000 次迭代后的平均损失
现在我们的模型已经训练完毕,我们可以开始评估过程并开始使用聊天机器人。
评估模型
既然我们已经成功创建并训练了我们的模型,那么现在该评估其表现了。 我们将通过以下步骤进行操作:
-
为了开始评估,我们首先将模型切换到评估模式。与所有其他 PyTorch 模型一样,这样做是为了防止在评估过程中发生任何进一步的参数更新。
encoder.eval() decoder.eval() -
我们还初始化了一个
GreedySearchDecoder的实例,以便能够进行评估,并将预测的输出结果作为文本返回searcher = GreedySearchDecoder(encoder, decoder) -
最后,要运行聊天机器人,我们只需调用
runchatbot函数,将encoder、decoder、searcher和voc传递给它。runchatbot(encoder, decoder, searcher, voc)这样做将打开一个输入提示,供您输入文本:

图 8.17 –用于输入文本的 UI 元素
在此处输入您的文本,然后按Enter,会将您的输入发送到聊天机器人。 使用我们训练过的模型,我们的聊天机器人将创建一个响应并将其打印到控制台:

图 8.18 –聊天机器人的输出
您可以多次重复此过程,以与聊天机器人进行“对话”。 在简单的对话级别,聊天机器人可以产生令人惊讶的良好结果:

图 8.19 –聊天机器人的输出
但是,一旦对话变得更加复杂,就很明显,聊天机器人无法进行与人类相同级别的对话:

图 8.20 –聊天机器人的局限性
在许多情况下,您的聊天机器人的响应可能没有意义:

图 8.21 –错误的输出
很明显,我们已经创建了一个聊天机器人,能够进行简单的来回对话。 但是,我们的聊天机器人要通过图灵测试并说服我们我们实际上正在与人类交谈,我们还有很长的路要走。 但是,考虑到我们训练模型所涉及的数据量相对较小,在序列到序列模型中使用注意已显示出相当不错的结果,证明了这些架构的通用性。
虽然最好的聊天机器人是在数十亿个数据点的庞大数据集上进行训练的,但事实证明,相对较小的聊天机器人,该模型是相当有效的。 但是,基本注意力网络已不再是最新技术,在下一章中,我们将讨论 NLP 学习的一些最新发展,这些发展已使聊天机器人更加逼真。
总结
在本章中,我们运用了从循环模型和序列到序列模型中学到的所有知识,并将它们与注意力机制结合起来,构建了一个可以正常工作的聊天机器人。 尽管与聊天机器人进行对话与与真实的人交谈并不太容易,但是我们可能希望通过一个更大的数据集来实现一个更加现实的聊天机器人。
尽管 2017 年备受关注的序列到序列模型是最新技术,但机器学习是一个快速发展的领域,自那时以来,对这些模型进行了多次改进。 在最后一章中,我们将更详细地讨论其中一些最先进的模型,并涵盖用于 NLP 的机器学习中的其他几种当代技术,其中许多仍在开发中。
九、前方的路
机器学习领域正在迅速扩展,几乎每年都有新的发现。 NLP 的机器学习领域也不例外,其发展迅速,并且在 NLP 任务上的机器学习模型的表现也逐渐提高。
到目前为止,在本书中,我们已经讨论了许多机器学习方法,这些方法使我们能够构建模型来执行 NLP 任务,例如分类,翻译和通过聊天机器人进行近似对话。 但是,到目前为止,我们的模型的表现相对于人类而言一直较差。 即使使用到目前为止我们已经研究过的技术,包括具有注意力机制的序列到序列网络,我们也不大可能训练一个匹配或胜过真实人的聊天机器人模型。 但是,我们将在本章中看到 NLP 领域的最新发展使我们更加接近创建与人类没有区别的聊天机器人的目标。
在本章中,我们将探索 NLP 的两个最先进的机器学习模型,并研究一些可带来卓越表现的功能。 然后,我们将转向研究其他许多当前关注的 NLP 任务,以及如何使用机器学习技术来解决它们。
在本章中,我们将介绍以下主题:
- 探索最新的 NLP 机器学习
- 未来的 NLP 任务
- 语义角色标签
- 选区解析
- 文字蕴含
- 机器理解
探索最新的 NLP 机器学习
到目前为止,虽然我们在本书中学到的技术对于从头开始训练我们自己的机器学习模型都是非常有用的方法,但与全球正在开发的最复杂的模型相距甚远。 公司和研究团队一直在努力创建最先进的机器学习模型,这些模型将在许多 NLP 任务上达到最高性能。
当前,有两种具有最佳表现并且可以被认为是最新技术的 NLP 模型: BERT 和 GPT-2 。 两种模型都是通用语言模型的形式。 我们将在接下来的部分中详细讨论这些内容。
BERT
BERT 代表转换器的双向编码器表示形式,由 Google 于 2018 年开发,被广泛认为是 NLP 领域的领先模型,在自然语言的语言推理和问答任务中已取得领先的表现。 幸运的是,它已作为开源模型发布,因此可以下载并用于您自己的 NLP 任务。
BERT 是作为预训练的模型发布的,这意味着用户可以下载和实现 BERT,而无需每次都从头开始重新训练模型。 预先训练的模型在几个语料库上进行了训练,包括整个 Wikipedia(由 25 亿个单词组成)和另一个图书集(其中还包括 8 亿个单词)。 但是,BERT 与其他类似模型不同的主要因素是它提供了一种深度,双向,无监督的语言表示形式,该语言表示形式提供了更复杂,更详细的表示形式,从而提高了 NLP 任务的表现。 。
嵌入
传统的嵌入层(例如 GLoVe)形成一个单词的单个表示形式,该表示形式与句子中该单词的含义无关,而双向 BERT 模型则尝试根据其上下文来形成表示形式。 例如,在这两个句子中,单词bat具有两种不同的含义。
"The bat flew past my window"
"He hit the baseball with the bat"
尽管单词bat在两个句子中都是名词,但我们可以辨别出单词的上下文和含义明显不同,这取决于周围的其他单词。 某些单词也可能具有不同的含义,具体取决于它们是句子中的名词还是动词:
"She used to match to light the fire"
"His poor performance meant they had no choice but to fire him"
使用双向语言模型来形成与上下文相关的单词表示形式,确实使 BERT 脱颖而出,成为最先进的模型。 对于任何给定的标记,我们通过组合标记,位置和分段嵌入来获得其输入表示:

图 9.1 – BERT 架构
但是,重要的是要了解模型如何到达这些初始的上下文相关的标记嵌入。
遮罩语言建模
为了创建这种双向语言表示,BERT 使用了两种不同的技术,第一种是遮罩语言建模。 这种方法通过用掩蔽标记代替输入句子,从而有效地隐藏了 15% 的单词。 然后,模型基于句子中其他单词的上下文,尝试预测被屏蔽单词的真实值。 双向进行此预测是为了在两个方向上捕获句子的上下文:
输入:We [MASK_1] hide some of the [MASK_2] in the sentence
标签:MASK_1 = randomly, MASK_2 = words
如果我们的模型可以学习预测正确的上下文相关词,那么我们就可以更接近上下文相关表示。
下句预测
BERT 用于学习语言表示的另一种技术是下一句预测。 在这种方法中,我们的模型接收两个句子,并且模型学习预测第二个句子是否是第一个句子之后的句子。 例如:
句子 A:I like to drink coffee
句子 B:It is my favorite drink
是下句话吗?:True
句子 A:I like to drink coffee
句子 B:The sky is blue
是下句话吗?:False
通过像这样传递我们的模型句子对,它可以学习确定是否有两个句子是相关的并且彼此遵循,或者它们仅仅是两个随机的,不相关的句子。 学习这些句子关系在语言模型中很有用,因为许多与 NLP 相关的任务(例如,问答)都需要模型理解两个句子之间的关系。 在下一句预测上训练模型可以使模型识别一对句子之间的某种关系,即使该关系是非常基本的。
BERT 使用这两种方法(遮罩语言建模和下一句预测)进行了训练,并且两种技术的组合损失函数都已最小化。 通过使用两种不同的训练方法,我们的语言表示能力足够强大,并且可以学习句子的形成和结构方式以及不同句子之间的相互关系。
BERT – 架构
模型架构建立在前几章中看到的许多原理的基础上,以使用双向编码提供复杂的语言表示。 BERT 有两种不同的变体,每种变体由不同数量的层和关注头组成:
- BERT 基本:12 个转换器块(层),12 个关注头,约 1.1 亿个参数
- BERT 大型:24 个转换器块(层),16 个关注头,约 3.4 亿个参数
虽然 BERT Large 只是具有更多参数的 BERT Base 的更深版本,但我们将专注于 BERT Base 的架构。
BERT 是按照转换器的原理构建的,现在将对其进行详细说明。
转换器
模型架构建立在我们迄今为止在本书中看到的许多原理的基础上。 到目前为止,您应该熟悉编码器和解码器的概念,其中我们的模型学习编码器以形成输入句子的表示形式,然后学习解码器将该表示形式解码为最终输出,无论是分类还是分类。 翻译任务:

图 9.2 –转换器工作流程
但是,我们的转换器为此方法添加了另一种复杂性,其中,转换器实际上具有一堆编码器和一堆解码器,每个解码器接收最终编码器的输出作为其输入:

图 9.3 –多个编码器的转换器工作流程
在每个编码器层中,我们发现两个组成部分:自我注意层和前馈层。 自我注意层是首先接收模型输入的层。 该层使编码器在对任何接收到的单词进行编码时检查输入句子中的其他单词,从而使编码上下文知道。 自我注意层的输出被转发到前馈层,该前馈层将独立地应用于每个位置。 可以这样示意地说明:

图 9.4 –前馈层
我们的解码器层在结构上与我们的编码器几乎相同,但是它们包含一个额外的注意层。 该关注层可帮助解码器将注意力集中在编码表示的相关部分上,类似于我们看到关注在序列到序列模型中的工作方式:

图 9.5 –注意方法
我们知道我们的解码器从最终编码器获取输入,因此一个链接的编码器/解码器可能看起来像这样:

图 9.6 –链接的编码器/解码器数组
这将为您提供有关大型模型中不同编码器和解码器如何堆叠的有用概述。 接下来,我们将更详细地检查各个部分。
编码器
转换器的独特属性是单词分别流过编码器层,并且每个位置的每个单词都有自己的路径。 尽管自我注意层中存在某些依赖关系,但这些不存在于前馈层中。 单个单词的向量是从嵌入层获得的,然后在通过前馈网络馈送之前通过自我关注层馈送:

图 9.7 –编码器布局
自我关注可以说是编码器中最复杂的部分,因此我们将首先对其进行更详细的研究。 假设我们有一个由三个单词组成的输入句子; 例如fine。 对于此句子中的每个单词,我们将它们表示为从模型的嵌入层获得的单个单词向量。 然后,我们从此单个单词向量中提取三个向量:查询向量,键向量和值向量。 这三个向量是通过将词向量乘以训练模型时获得的三个不同权重矩阵而获得的。
如果我们在输入句子Ethis,Eis和Efine中为每个单词调用单词嵌入,则可以计算查询,键和值向量就像这样:
查询向量:




键向量:




值向量:




既然我们知道了如何计算每个向量,那么了解每个向量代表什么就很重要。 从本质上讲,每一个都是注意力机制内概念的抽象。 一旦我们看到它们是如何计算的,这将变得显而易见。
让我们继续我们的工作示例。 我们需要依次考虑输入句子中的每个单词。 为此,我们为句子中的每对查询/关键字向量计算得分。 这是通过获取输入句子中每个单词的每个查询/关键字向量对的点积来完成的。 例如,要计算句子中第一个单词this的分数,我们计算this的查询向量与位置 0 处的键向量之间的点积。我们在输入句子中的所有其他位置对键向量重复它,因此我们获得输入句子中第一个单词的n分,其中n是句子的长度:
分数("this"):



接下来,我们对每个得分应用 softmax 函数,以使每个得分现在处于 0 到 1 之间(因为这有助于防止梯度梯度爆炸并使梯度下降更加有效且易于计算)。 然后,我们将这些分数中的每一个与值向量相乘,然后将它们总和求和,以获得最终向量,然后将其在编码器中传递:
最终向量("this"):




然后,我们对输入句子中的所有单词重复此过程,以便获得每个单词的最终向量,其中包含一个自注意元素,然后将其沿着编码器传递到前馈网络。 这种自我关注的过程意味着我们的编码器知道在输入句子中查找的位置,以获得任务所需的信息。
在此示例中,我们仅为查询,键和值向量学习了单个权重矩阵。 但是,我们实际上可以为这些元素中的每一个学习多个不同的矩阵,并将其同时应用于输入句子中以获得最终输出。 这就是,即所谓的多头注意力,它使我们能够依靠多种不同的学习模式而不仅仅是一个单独的注意力机制来执行更复杂的注意力计算。
我们知道 BERT 包含 12 个关注头,这意味着Wq,Wk和Wv学会了 12 种不同的权重矩阵。
最后,我们需要一种让编码器考虑输入序列中单词顺序的方法。 当前,我们的模型独立对待输入序列中的每个单词,但实际上,输入序列中单词的顺序将对句子的整体含义产生巨大的影响。 为了解决这个问题,我们使用位置编码。
为了应用这一点,我们的模型将每个输入嵌入并分别向每个位置添加位置编码向量。 这些位置向量是由我们的模型学习的,遵循一种特定的模式来帮助它们确定序列中每个单词的位置。 从理论上讲,将这些位置向量添加到我们的初始嵌入中后,一旦将它们投影到各个查询,键和值向量中,就应该转化为最终向量之间的有意义的距离:
x0 = Raw Embedding
t0 = Positional Encoding
E0 = Embedding with Time Signal
x0 + t0 = E0
我们的模型为每个位置学习不同的位置编码向量(t0,t1,依此类推),然后在它们进入编码器之前,我们将它应用于输入句子中的每个单词:

图 9.8 –将输入添加到编码器
既然我们已经了解了编码器的主要组成部分,现在该来看看模型的另一面并了解如何构造解码器。
解码器
解码器中的组件与编码器中的组件非常相似。 但是,我们的转换器中的解码器不是像编码器那样接收原始输入语句,而是从编码器的输出接收其输入。
我们的堆叠编码器处理我们的输入语句,然后剩下一组注意向量K和V,它们在我们解码器的编码器-解码器注意层中使用。 这使它可以仅关注输入序列的相关部分:

图 9.9 –堆叠式解码器
在每个时间步,我们的解码器都使用句子中先前生成的单词和K, V注意向量的组合来生成句子中的下一个单词。 反复重复此过程,直到解码器生成< END >标记,表明它已完成生成最终输出。 转换器解码器上的给定时间步长可能看起来像这样:

图 9.10 –转换器解码器
在这里值得注意的是,解码器中的自我注意层的运行方式与我们的编码器中的自我关注层略有不同。 在我们的解码器中,自我注意层仅关注输出序列中的较早位置。 这是通过将序列的任何未来位置设置为负无穷大来掩盖的。 这意味着当发生分类时,softmax 计算始终会得出 0 的预测。
编码器-解码器注意层的工作方式与我们编码器中的多头自我注意层相同。 但是,主要区别在于它从下面的层创建查询矩阵,并从编码器的输出获取键和值矩阵。
这些编码器和解码器部分构成了我们的转换器,构成了 BERT 的基础。 接下来,我们将研究 BERT 的某些应用,并研究一些在特定任务下表现出更高性能的变体。
BERT 的应用
作为最先进的,BERT 显然具有许多实际应用。 目前,您可能每天都会在许多 Google 产品中使用; 即 Gmail 中的建议答复和智能撰写(其中 Gmail 根据您当前输入的内容预测您的预期句子)和 Google 搜索引擎中的自动完成功能(您在其中键入要搜索的前几个字符和下拉列表) 会预测您要搜索的内容)。
正如我们在上一章中所看到的那样,聊天机器人是 NLP 深度学习可以使用的最令人印象深刻的东西之一,而 BERT 的使用确实带来了一些非常令人印象深刻的聊天机器人。 实际上,问题解答是 BERT 擅长的主要事情之一,这主要是因为它是在一个大型知识库(Wikipedia)上进行训练的,并且能够以句法正确的方式回答问题(由于受过训练) 考虑下一个句子的预测)。
我们仍处于聊天机器人无法与真实人类进行对话的阶段,并且 BERT 从其知识库中提取信息的能力非常有限。 但是,BERT 所取得的一些结果是有希望的,并且考虑到 NLP 机器学习领域的发展速度,这表明这可能很快就会成为现实。
当前,由于 BERT 的训练方式,它只能解决非常狭窄的 NLP 任务。 但是,BERT 的许多变体已经以微妙的方式进行了更改,以在特定任务上实现更高的表现。 这些包括但不限于以下内容:
- roBERTa:由 Facebook 构建的 BERT 的变体。 删除 BERT 的下一个句子预测元素,但通过实现动态屏蔽来增强单词屏蔽策略。
- xlm/BERT:也是由 Facebook 构建的,该模型对 BERT 应用了双语言训练机制,从而使它能够学习不同语言的单词之间的关系。 这使 BERT 可以有效地用于机器翻译任务,与基本的序列到序列模型相比,表现出更高的表现。
- distilBERT:BERT 的更紧凑版本,保留了原始版本的 95%,但将学习到的参数数量减半,从而减少了模型的总大小和训练时间。
- ALBERT:此经过 Google 训练的模型使用了自己独特的训练方法,称为句子顺序预测。 BERT 的这种变体在许多任务上均表现出优于标准 BERT 的表现,现在被认为是 BERT 之前的最先进技术(说明了事物可以快速改变的速度!)。
虽然 BERT 可能是最著名的,但也有其他基于转换器的模型被认为是最新技术。 通常被认为与 BERT 竞争的主要竞争对手是 GPT-2。
GPT-2
GPT-2 与 BERT 类似,但在一些细微的方面有所不同。 虽然这两种模型都基于先前概述的转换器架构,但是 BERT 使用一种称为自我注意力的注意力形式,而 GPT-2 使用屏蔽的自我注意力。 两者之间的另一个细微差别是 GPT-2 的构造方式使其可以一次输出一个标记。
这是因为 GPT-2 实际上在其工作方式上是自回归的。 这意味着当它生成输出(句子中的第一个单词)时,该输出将递归添加到输入中。 然后,此输入用于预测句子中的下一个单词,并重复进行直到生成完整的句子为止。 您可以在以下示例中看到这一点:
步骤 1:
输入:What color is the sky?
输出:...
然后,将预测的输出添加到输入的末尾并重复此步骤:
步骤 2:
输入:What color is the sky? The
输出:sky
我们重复此过程,直到生成完整的句子:
步骤 3:
输入:What color is the sky? The sky
输出:is
步骤 4:
输入:What color is the sky? The sky is
输出:blue
就 BERT 和 GPT-2 之间的表现而言,这是关键的权衡之一。 BERT 被双向训练的事实意味着这种单标记生成是不可能的。 但是,GPT-2 不是双向的,因此在进行预测时只考虑句子中的先前单词,这就是为什么 BERT 在预测句子中缺少的单词时胜过 GPT-2 的原因。
比较自我注意和遮罩的自我注意
这种差异在两个不同模型实现注意力的方式上也很明显。 由于 BERT 是双向的,因此它的注意力机制能够考虑整个输入句子的上下文,并确定输入句子中确切的位置:

图 9.11 –自我注意力机制
另一方面,不是双向的,因此屏蔽的自我关注机制只能查看已经看到的标记,而不能“向前看”:

图 9.12 –蒙面的自我关注机制
GPT-2 –道德规范
关于 GPT-2 的一个非常有趣的考虑是作者在考虑是否向公众发布该模型时的道德考虑。
GPT-2 的语言生成功能意味着它能够使用很少的提示来生成非常现实且句法正确的句子。 通过仅提供两行输入来识别主题,该模型就可以生成有关该主题的详细信息的段落,而不管其是否真实! 一个现在臭名昭著的例子是“独角兽”故事,该模型收到了一个句子输入:
输入:"In a shocking finding, scientists discovered a herd of unicorns living in a remote, previously unexplored valley, in the Andes Mountains. Even more surprising to the researchers was the fact that the unicorns spoke perfect English."
该模型提供了以下输出:
输出:
The scientist named the population, after their distinctive horn, Ovid's Unicorn. These four-horned, silver-white unicorns were previously unknown to science.
Now, after almost two centuries, the mystery of what sparked this odd phenomenon is finally solved.
Dr. Jorge Pérez, an evolutionary biologist from the University of La Paz, and several companions, were exploring the Andes Mountains when they found a small valley, with no other animals or humans. Pérez noticed that the valley had what appeared to be a natural fountain, surrounded by two peaks of rock and silver snow.
这只是模型从很少的输入中生成的更大的九段响应的前三段。 值得注意的是,这些句子都非常合理(不管不可能的主题是什么!),各段以逻辑顺序排列在一起,并且该模型能够从很小的输入中产生所有这些。
尽管这在表现以及通过构建深层 NLP 模型可以实现的功能方面给人留下深刻的印象,但确实引起了人们对此类模型的道德规范以及如何使用(和滥用它们)的担忧。
随着“假新闻”的兴起和使用互联网的错误信息的传播,类似的示例说明了使用这些模型生成逼真的文本是多么简单。 让我们考虑一个示例,其中智能体希望在线上生成有关多个主题的虚假新闻。 现在,他们甚至不需要自己编写虚假信息。 从理论上讲,他们可以训练 NLP 模型为他们执行此操作,然后再在互联网上散布这些虚假信息。 GPT-2 的作者在训练并将模型发布给公众时特别注意这一点,并指出该模型有可能被滥用和滥用,因此只有在他们看不到证据的情况下才向公众发布更大更复杂的模型。 较小模型的误用。
这可能成为 NLP 深度学习向前发展的重点。 当我们使用可以接近人类复杂程度的聊天机器人和文本生成器(例如 GPT-2)时,需要充分了解对这些模型的使用和误用。 研究表明,GPT-2 生成的文本被认为与《纽约时报》的真实人类书面文章(83%)几乎一样可信(72%)。 随着将来我们继续开发更复杂的深层 NLP 模型,随着模型生成的文本变得越来越现实,这些数字可能会收敛。
此外,GPT-2 的作者还证明可以对模型进行微调以防滥用。 通过在意识形态极端立场上微调 GPT-2 并生成文本,可以证明可以生成支持这些意识形态的宣传文本。 虽然还显示可以训练反模型来检测这些模型生成的文本,但是随着这些模型变得更加复杂,我们将来可能在这里再次面临进一步的问题。
随着时间的流逝,NLP 模型变得更加复杂和高效,这些道德考量值得牢记。 尽管您出于自己的目的训练的模型可能不是出于任何滥用目的,但始终有可能将它们用于非预期的目的。 始终考虑您使用的任何模型的潜在应用。
未来的 NLP 任务
尽管本书的大部分内容都集中在文本分类和序列生成上,但是还有许多其他 NLP 任务尚未真正涉及到。 尽管从学术的角度而不是从实践的角度来看,其中许多更有趣,但了解这些任务很重要,因为它们构成了语言构造和形成的基础。 作为 NLP 数据科学家,我们做的任何能够更好地理解自然语言形成的方法,只会增进我们对主题的理解。 在本节中,我们将更详细地讨论 NLP 未来发展的四个关键领域:
- 选区解析
- 语义角色标签
- 文字蕴含
- 机器理解
选区分析
选区分析(也称为句法分析)是识别句子的各个部分并为它分配句法结构的操作。 的语法结构在很大程度上取决于上下文无关语法的使用,这意味着使用语法分析,我们可以识别给定句子的基础语法结构并将其映射出来。 任何句子都可以分解为“语法树”,该语法树是该基础句子结构的图形表示,而语法分析是检测该基础结构并确定该树结构的方法。
我们将从讨论此基础语法结构开始。 句子中“选区”的概念有点抽象,但基本假设是句子由多个单词“组”组成,每个词组都是一个选区。 语法,以其基本形式,可以说是一个句子中可能出现的所有可能的选区类型的索引。
首先,让我们考虑最基本的类型的构成成分名词短语。 句子中的名词很容易识别,因为它们是定义对象或实体的词。 在下面的句子中,我们可以识别出三个名词:
"Jeff the chef cooks dinner"
Jeff - Proper noun, denotes a name
Chef - A chef is an entity
Dinner - Dinner is an object/thing
但是,名词短语略有不同,因为每个名词短语都应指一个单独的实体。 在前面的句子中,即使Jeff和Cook都是名词,短语Jeff Cook都指一个人,因此可以将其视为名词短语。 但是,如何从语法上确定名词短语是指单个实体? 一种简单的方法是将短语置于动词之前,看看该句子是否具有句法意义。 如果确实如此,那么该短语就是名词短语:
Jeff the chef cooks…
Jeff the chef runs…
Jeff the chef drinks…
我们可以识别各种不同的短语,以及帮助我们识别它们的许多复杂的语法规则。 我们首先确定每个句子可以分解成的个别语法特征:

现在我们知道句子是由成分组成的,并且成分可以由几个单独的语法组成,现在我们可以开始根据它们的结构来映射句子。 例如,使用以下示例语句:
"The boy hit the ball"
我们可以从将该句子分为两部分开始:名词短语和动词短语:

图 9.13 –将句子分解为语法成分
然后,我们对每个短语重复此处理,以将它们分成甚至个较小的语法组成部分。 我们可以将此名词短语分为确定词和名词:

图 9.14 –分解名词短语
再次,我们对动词短语执行此操作,将其分解为一个动词和另一个名词短语:

图 9.15 –分解动词短语
我们可以再次进行迭代,然后再次进行迭代,将句子的各个部分分成较小的和较小的块,直到剩下解析树为止。 这个解析的树传达了我们句子的整个句法结构。 我们可以在这里完整地看到示例的解析树:

图 9.16 –句子的分析树
这些解析树使我们能够看到句子的句法结构,但它们远非完美。 从这种结构中,我们可以清楚地看到,发生了两个带有动词的名词短语。 但是,从前面的结构中,尚不清楚实际发生了什么。 我们在两个对象之间有一个动作,但是仅凭语法尚不清楚发生了什么。 哪一方对谁采取行动? 我们将看到语义角色标签捕获了其中的一些歧义。
语义角色标签
语义角色标记是,将标记分配给句子中的单词或词组以指示其在句子中的语义角色的过程。 广义上讲,这涉及标识句子的谓词,并确定句子中的其他每个词如何与此谓词相关。 换句话说,对于给定的句子,语义角色标签确定“谁对谁做了什么,在哪里/什么时候?”
因此,对于给定的句子,我们通常可以将句子分解为各个组成部分,如下所示:

图 9.17 –将句子分为几个组成部分
句子的这些部分具有特定的语义角色。 任何给定句子的谓词表示该句子内发生的事件,而该句子的所有其他部分都与给定谓词相关。 在这句话中,我们可以将“谁”标记为谓词的智能体。 智能体是导致事件的原因。 我们还可以将Whom标记为谓词的主题。 主题是我们句子中受相关事件影响最大的元素:

图 9.18 –分解角色
从理论上讲,句子中的每个单词或短语都可以用其特定的语义成分标记。 几乎完整的表格如下:

通过执行语义角色标记,我们可以为句子的每个部分分配特定的角色。 这在 NLP 中非常有用,因为它允许模型更好地“理解”一个句子,因此而不是仅作为角色分类的句子,应理解为语义角色的组合,可以更好地传达,在句子所描述的事件中发生了什么。
当我们阅读句子The boy kicked the ball时,我们固有地知道有一个男孩,有一个球,并且那个男孩正在踢球。 但是,到目前为止,我们所研究的所有 NLP 模型都可以通过查看句子中的各个单词并为其创建一些表示来理解该句子。 到目前为止,我们所看到的系统不可能理解存在两个“事物”并且一个物体(男孩)对第二物体(球)执行某种动作(踢)的事实。 在模型中引入语义角色元素可以更好地帮助我们的系统通过定义句子的主题及其之间的相互作用来形成更现实的句子表示形式。
语义角色标签极大地帮助了我们一件事,即识别具有相同含义但在语法或语法上不同的句子; 例如以下内容:
The man bought the apple from the shop
The shop sold the man an apple
The apple was bought by the man from the shop
The apple was sold by the shop to the man
这些句子虽然没有明显地包含相同顺序的所有相同词,但它们实际上具有相同的含义。 通过对这些句子应用语义角色标签,我们可以确定谓词/智能体/主题都相同。
先前我们看到了选区分析/句法分析如何用于识别句子的句法结构。 在这里,我们可以看到如何将简单的句子“我买了一只猫”分解为它的组成部分-代词,动词,行列式和名词:

图 9.19 –选区解析
但是,这并不能使您对句子的每个部分所扮演的语义角色有任何了解。 是猫被我买了还是我被猫买了? 虽然句法作用对于理解句子的结构很有用,但它并没有对语义有太多了解。 一个有用的类比是图像字幕。 在训练过的图像标签模型中,我们希望获得一个描述图像内容的标题。 语义标记与此相反,在语义标记中我们采用一个句子,并尝试抽象出一个心理“形象”,以说明该句子中正在发生什么动作。
但是,语义角色标签在 NLP 中有用什么上下文? 简而言之,任何需要“理解”文本内容元素的 NLP 任务都可以通过添加角色来增强。 可以是文档摘要,问题解答或句子翻译之类的任何内容。 例如,使用语义角色标记来识别句子的谓词和相关的语义成分,我们可以训练一个模型来识别对句子贡献必要信息的成分,并丢弃不构成成分的成分。
因此,能够训练模型以执行准确且有效的语义角色标记将对 NLP 的其余部分有用。 最早的语义角色标记系统完全基于规则,由从语法派生的基本规则集组成。 在深度学习的最新发展之前,这些方法已经演变为包含统计建模方法,这意味着可以训练分类器来识别句子中的相关语义角色。
与任何分类任务一样,这是一个有监督的学习问题,需要完整标注的句子才能训练模型,该模型将识别以前未见过的句子的语义角色。 但是,这样带标注的句子的可用性非常稀缺。 我们在本章前面看到的巨大的语言模型(例如 BERT)是在原始句子上训练的,不需要带标签的示例。 但是,在语义角色标记的情况下,我们的模型需要使用正确标记的句子才能执行此任务。 尽管确实存在用于此目的的数据集,但它们的大小和通用性不足以训练完全综合,准确的模型,该模型在各种句子上均能很好地表现。
您可能会想到,解决语义角色标记任务的最新技术都是基于神经网络的。 初始模型使用 LSTM 和双向 LSTM 结合 GLoVe 嵌入,以便对句子进行分类。 这些模型还包含了卷积层,也表现出良好的表现。
但是,得知这些最新模型是基于 BERT 的也就不足为奇了。 使用 BERT 已在各种与 NLP 相关的任务中显示了出色的表现,并且语义角色标记也不例外。 集成了 BERT 的模型已经过全面训练,可以预测词性标签,执行语法分析并同时执行语义角色标记,并显示出良好的效果。
其他研究也表明,图卷积网络在语义标记方面是有效的。 图由节点和边构成,其中图内的节点代表语义成分,边代表父子部分之间的关系。
也可以使用许多用于语义角色标记的开源模型。 Google 的 SLING 解析器经过训练,可以执行数据的语义标注。 该模型使用双向 LSTM 对句子进行编码,并使用基于过渡的循环单元进行解码。 该模型仅将文本标记用作输入和输出角色,而无需任何进一步的符号表示:

Figure 9.20 – Bi-directional LSTM (SLING)
值得注意的是,SLING 仍在开发中。 当前,它还不够复杂,无法从任意文本中准确提取事实。 这表明在可以创建真实,准确的语义角色解析器之前,该领域需要完成许多工作。 完成此操作后,可以轻松地将语义角色解析器用作整体机器学习模型的一部分,以标记句子中的语义角色,然后将其用于更广泛的机器学习模型中,以增强模型对文本的“理解”。
文字蕴含
文本蕴涵是我们可以训练模型以试图更好地理解句子含义的另一种方法。 在文本方面,我们尝试确定两段文本之间的方向关系。 只要一条文本中的真相与另一条文本中的真相相同,就存在这种关系。 这意味着,对于给定的两个文本,如果第一文本中的信息可以使第二文本保持真实,则可以说这两个文本之间存在正向关系。
通常以以下方式设置此任务,我们的第一个文本标记为文本,第二个文本标记为假设:
文字:If you give money to charity, you will be happy
假设:Giving money to charity has good consequences
这是肯定文本蕴涵的示例。 如果假说来自文本,那么可以说这两个文本之间存在方向性关系。 设置带有文本/假设的示例非常重要,因为它定义了关系的方向。 大多数时候,这种关系是不对称的。 例如,在此示例中,句子 1 包含句子 2(我们可以根据句子 1 中的信息推断出句子 2 是正确的)。 但是,我们不能根据第二句中的信息推断出第一句是正确的。 尽管这两种说法都可能是正确的,但如果我们不能推断出两者之间存在方向关系,就无法从另一种推断出。
也存在否定文本蕴涵。 这是陈述矛盾的时候。 例如以下内容:
文字:If you give money to charity, you will be happy
假设:Giving money to charity has bad consequences
在此示例中,文本没有假设。 相反,本文与假设相矛盾。 最后,如果两个句子之间没有关系,也可以确定在两个句子之间没有文本蕴涵。 这意味着这两个陈述不一定是矛盾的,而是该案文并不包含以下假设:
文字:If you give money to charity, you will be happy
假设:Giving money to charity will make you relaxed
从 NLP 的角度来看,自然语言的歧义使其成为一项有趣的任务。 两个句子可以具有不同的句法结构,不同的语义结构,并且由完全不同的单词组成,但含义仍然非常相似。 同样,两个句子可以由相同的词和实体组成,但含义却大不相同。
在这里,使用模型来量化文本的含义特别有用。 文本蕴含也是一个独特的问题,因为两个句子可能没有完全相同的含义,但是仍然可以从另一个推断出一个句子。 这需要在大多数语言模型中不存在的语言推断元素。 通过将语言推断的元素纳入我们未来的模型中,我们可以更好地捕获文本的含义,并且能够确定两个文本是否包含相同的信息,而无论它们的表示形式是否相似。
幸运的是,创建简单的文本包含模型并不困难,并且已证明基于 LSTM 的模型是有效的。 可能证明有效的一种设置是连体 LSTM 网络的设置。
我们将模型设置为多类分类问题,其中两个文本可以是正数或负数,也可以是零数。 我们将两个文本输入到双输入模型中,从而获得两个文本的嵌入,然后将它们传递到双向 LSTM 层。 然后,将这两个输出以某种方式(使用一些张量操作)进行比较,然后再将它们输出给最终的 LSTM 层。 最后,我们使用 softmax 层对输出执行分类:

图 9.21 –连体 LSTM 网络
尽管这些模型远非完美,但它们代表了创建完全准确的文本包含模型的第一步,并为将集成到不断发展的语言模型中打开了可能性。
机器理解
到目前为止,在本书中,我们主要指的是 NLP,但是能够处理语言只是其中的一部分。 当您阅读一个句子时,我们不仅阅读,观察和处理单个单词,而且还对句子的实际含义建立了内在的理解。 NLP 的下一步无疑是能够训练不仅能理解句子而且还能形成对其中表达思想的理解的模型。 该字段的真正定义非常松散,但通常被称为机器理解或自然语言理解(NLU)。
在学校,我们从小就被教导阅读理解。 您可能很久以前学会了此技能,现在您已将其视为理所当然。 通常,您可能甚至没有意识到正在执行此操作; 实际上,您现在就在做! 阅读理解仅仅是阅读文本,理解文本并能够回答有关文本的问题的行为。 例如,看一下以下文本:
作为对水进行消毒的一种方法,将水加热到 100°C(212°F)的沸点是最古老,最有效的方法,因为它不会影响水的味道。 尽管其中存在污染物或颗粒,但它仍然有效,并且它是一步一步的过程,可以消除大多数引起肠道相关疾病的微生物。 在海平面和正常大气压下,水的沸点为 100°C(212°F)。
鉴于您了解此文本,您现在应该能够回答有关此文本的以下问题:
Q: What is the boiling point of water?
A: 100 °C (212 °F)
Q: Does boiling water affect its taste?
A: No
这种理解文本并回答有关问题的能力构成了我们机器理解任务的基础。 我们希望能够训练一种机器学习模型,该模型不仅可以形成对文本的理解,还可以用语法正确的自然语言回答有关该文本的问题。
这样做的好处很多,但是一个非常直观的用例是构建一个充当知识库的系统。 当前,搜索引擎的工作方式是我们运行搜索(在 Google 或类似的搜索引擎中),并且搜索引擎返回选择的文档。 但是,要查找特定信息,我们仍然必须从返回的文档中推断出正确的信息。 整个过程可能看起来像:

图 9.22 –查找信息的过程
在此示例中,为回答What is the boiling point of water,我们首先提出问题。 然后,我们在搜索引擎上搜索主题。 这可能是该问题的某种简化表示; 例如,boiling point of water。 然后,我们的搜索引擎将返回一些相关文档,最有可能是维基百科中关于水的条目,然后我们将不得不手动搜索并使用它来推断问题的答案。 尽管此方法有效,但机器理解模型将使此过程有所简化。
假设我们有一个完美的模型,能够完全理解并回答文本语料库上的问题。 我们可以在大量数据源(例如,互联网或维基百科的大型文本抓图)上训练该模型,并形成充当大型知识库的模型。 这样,我们便可以查询具有实际问题的知识库,并且答案将自动返回。 这消除了我们图的推理步骤,因为推理已由模型处理,因为该模型已经对主题有所了解:

图 9.23 –使用模型的新过程
在理想情况下,这就像键入搜索引擎What is the boiling point of water,并返回100 °C (212 °F)作为答案一样简单。
假设我们首先有此模型的简化版本。 假设我们已经知道该问题的答案已出现在文档中。因此,鉴于有关水的维基百科页面,我们能否训练一个模型来回答问题What is the boiling point of water。首先做一个简单的方法,而不是合并完整语言模型的元素,而是简单地返回包含我们问题答案的维基百科页面的段落。
我们可以训练以完成此任务的架构可能看起来像这样:

图 9.24 –模型的架构
我们的模型将要回答的问题和包含问题的文档作为输入。 然后将它们传递给嵌入层以形成每个张量的基于张量的表示,然后传递给编码层以形成进一步的缩减的向量表示。
现在,我们的问题和文档都表示为向量,我们的匹配层将尝试确定在文档向量中应寻找哪些位置以获得问题的答案。 这是通过一种注意力机制来完成的,在该机制中,我们的问题确定了我们应该查看文档向量的哪些部分才能回答问题。
最后,我们的融合层旨在捕获匹配层的长期依赖性,合并从匹配层收到的所有信息,并执行解码步骤以获得最终答案。 该层采用双向 RNN 的形式,它将我们的匹配层输出解码为最终预测。 我们在这里使用多类分类预测两个值 –起点和终点。 这代表了我们文档中包含初始问题答案的起点和终点。 如果我们的文档包含 100 个单词,而单词 40 和单词 50 之间的句子包含我们问题的答案,则我们的模型将理想地预测起点和终点的值分别为 40 和 50。 这些值然后可以很容易地用于从输入文档中返回相关段落。
虽然返回目标文档的相关区域是有用的训练模型,但它与真实的机器理解模型并不相同。 为此,我们必须合并较大语言模型的元素。
在任何机器理解任务中,实际上都有三个元素在起作用。 我们已经知道存在一个问题和答案,但是也有一个可以确定给定问题答案的相关上下文。 例如,我们可以问以下问题:
What day is it today?
答案可能会有所不同,具体取决于提出问题的环境。 例如,圣诞节的 3 月 6 日星期一,星期二,3 月 6 日。
我们还必须注意,问题和答案之间的关系是双向的。 如果提供了知识库,我们就可以给出给定问题的答案,但是也可以得出结论,我们可以给出给定问题的问题:

图 9.25 –问题与答案之间的关系
真正的机器理解程序可以执行问题生成(QG),以及问答(QA)。 对于,最明显的解决方案是训练两个单独的模型,每个模型一个,并比较其结果。 从理论上讲,我们的 QG 模型的输出应等于我们的 QA 模型的输入,因此通过比较两者,我们可以提供同时评估:

图 9.26 – QG 和 QA 模型之间的比较
但是,更全面的模型将能够同时执行这两项任务,从而从答案中生成问题并回答问题,就像人类能够做到的一样:

图 9.27 –对偶模型表示
实际上,近来在 NLU 中的进步意味着这种模型现已成为现实。 通过组合许多元素,我们能够创建一个神经网络结构,该结构能够执行对偶模型的功能,如先前的所示。 这就是双问答网络。 实际上,我们的模型包含到目前为止我们在本书中看到的神经网络的大多数组件,即,嵌入层,卷积层,编码器,解码器和注意层。 问答网络的完整架构类似于以下内容:

图 9.28 –问答网络的架构
我们可以在此处进行以下观察:
- 如前所述,模型的输入是问题,答案和上下文,也是向右移动的问题和答案。
- 我们的嵌入层跨字符和单词的 GLoVe 嵌入式向量进行卷积,以创建组合的表示形式。
- 我们的编码器由 LSTM 组成,需要引起注意。
- 我们的输出也是基于 RNN 的,并且一次将我们的输出解码一个单词,以产生最终的问题和答案。
尽管存在经过预训练的问答网络,但是您可以练习实现新获得的 PyTorch 技能,并尝试自己构建和训练这样的模型。
诸如此类的语言理解模型可能会成为 NLP 未来几年研究的主要重点之一,新论文的发布频率可能会越来越高。
总结
在本章中,我们首先研究了几种最新的 NLP 语言模型。 尤其是 BERT,似乎已被广泛接受为行业标准的最新语言模型,并且 BERT 及其变体已被企业在其自己的 NLP 应用中广泛使用。
接下来,我们研究了机器学习的几个重点领域。 即语义角色标签,选区解析,文本范围和机器理解。 这些领域可能会占 NLP 正在进行的当前研究的很大一部分。
既然您对 NLP 深度学习模型以及如何在 PyTorch 中实现它们具有全面的能力和了解,也许您会倾向于加入这项研究。 无论是在学术领域还是商业领域,您现在都希望足够了解,可以从头开始创建自己的深层 NLP 项目,并且可以使用 PyTorch 创建解决所需的任何 NLP 任务所需的模型。 通过不断提高自己的技能,并不断了解和掌握该领域的所有最新动态,您一定会成为一名成功的,行业领先的 NLP 数据科学家!
第一部分:用于 NLP 的 PyTorch 1.x 的要点
在本节中,您将在自然语言处理(NLP)的背景下了解 PyTorch 1.x 的基本概念。 您还将学习如何在计算机上安装 PyTorch 1.x,以及如何使用 CUDA 加快处理速度。
本节包含以下章节:
- “第 1 章”,“机器学习和深度学习基础知识”
- “第 2 章”,“NLP 的 PyTorch 1.x 入门”
第二部分:自然语言处理基础
在本节中,您将学习构建自然语言处理(NLP)应用的基础知识。 您还将在本节中学习如何在 PyTorch 中使用各种 NLP 技术,例如单词嵌入,CBOW 和分词。
本节包含以下章节:
- “第 3 章”,“NLP 和文本嵌入”
- “第 4 章”,“词干提取和词形还原”
第三部分:使用 PyTorch 1.x 的实际 NLP 应用
在本节中,我们将使用 PyTorch 中可用的各种自然语言处理(NLP)技术来构建各种实际 -使用 PyTorch 的世界应用。 情感分析,文本摘要,文本分类以及使用 PyTorch 构建聊天机器人应用是本节将介绍的一些任务。
本节包含以下章节:
- “第 5 章”,“循环神经网络和情感分析”
- “第 6 章”,“用于文本分类的卷积神经网络”
- “第 7 章”,“使用序列到序列神经网络的文本翻译”
- “第 8 章”,“使用基于注意力的神经网络构建聊天机器人”
- “第 9 章”,“未来之路”






























浙公网安备 33010602011771号