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

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

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

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

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

适合读者

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

这本书涵盖了什么内容

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

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

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

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

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

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

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

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

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

要充分利用本书

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

下载示例代码文件

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

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

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

  2. 选择支持选项卡。

  3. 点击代码下载

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

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

  • 适用于 Windows 的 WinRAR/7-Zip

  • 适用于 Mac 的 Zipeg/iZip/UnRarX

  • 适用于 Linux 的 7-Zip/PeaZip

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

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

下载彩色图像

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

使用的约定

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

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

代码块如下所示:

import torch

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

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

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

$ mkdir flaskAPI
$ cd flaskAPI

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

提示或重要注意事项

显示如此。

联系我们

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

customercare@packtpub.com

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

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

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

评价

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

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

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

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

本节包括以下章节:

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

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

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

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

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

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

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

  • 机器学习概述

  • 神经网络简介

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

机器学习概述

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

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

监督学习

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 1.2 – 房屋数据的输出

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 1.3 – 预测房屋价格

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

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

F (X) = y

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

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

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这里,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传x轴截距,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传是直线的斜率。

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 1.4 – 模型训练

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

我喜欢这部电影 -> 正面

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

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

无监督学习

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 1.5 – 聚类算法的输出

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

模型如何学习?

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

梯度下降

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 1.6 – 梯度下降

我们首先用一个随机值初始化外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。为了达到使损失最小化的点,我们需要从损失函数的下坡处向中间移动。为了做到这一点,我们首先需要知道向哪个方向移动。在我们的初始点,我们使用基本的微积分来计算初始斜坡的梯度:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在我们的前述示例中,初始点处的梯度是正的。这告诉我们我们的外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传的值大于最优值,所以我们更新我们的外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传的值,使其低于先前的值。我们逐步迭代这个过程,直到外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传越来越接近使均方误差最小化的值的点。这发生在梯度等于零的点。

过拟合和欠拟合

考虑以下情况,基本线性模型在我们的数据上拟合得很差。我们可以看到我们的模型,由方程外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传表示,似乎不是一个很好的预测器:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

当我们的模型由于特征不足、数据不足或模型规格不足而无法很好地拟合数据时,我们称之为欠拟合。我们注意到数据的梯度逐渐增加,并怀疑如果使用多项式,例如外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,模型可能会更好地拟合;我们稍后将看到,由于神经网络的复杂结构,欠拟合很少成为问题:

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 1.8 – 过拟合的样本输出

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

训练与测试

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

评估模型

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

神经网络

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

神经网络的结构

当我们在学习 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的最优值时,这实际上等同于一个单层神经网络

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 1.10 – 单层神经网络

在这里,我们将每个特征外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传作为输入,这里用节点表示。我们希望学习参数外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,这在图中表示为连接。我们最终的所有外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传之间的乘积的总和给出了我们的最终预测y

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 1.11 – 全连接网络

每个输入节点都连接到另一层中的每个节点。这被称为全连接层。全连接层的输出然后乘以它自己的额外权重,以预测y。因此,我们的预测不再只是外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传的函数,而是包括每个参数的多个学习权重。特征外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传不再仅仅受到外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传的影响。现在,它还受到外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传的影响。

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

激活函数

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 1.13 – ReLU 输出的表示

ReLU 是一个非常简单的非线性函数,在外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传时返回y = 0,在X > 0时返回y = X。在我们的模型中引入这些激活函数后,我们的最终学习函数变得非线性,这意味着我们可以比仅使用传统回归和特征工程组合创建更多的模型。

神经网络如何学习?

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这里,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 是网络中每个给定节点的输出。因此,总结一下,在神经网络上执行梯度下降时我们采取的四个主要步骤如下:

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

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

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

  4. 直到收敛为止。

神经网络中的过拟合

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 1.14 – 测试和训练轮次

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

机器学习的自然语言处理

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

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

词袋模型

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

猫坐在垫子上

狗坐在猫上

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

![图 1.15 – 单词计数表

[img/B12365_01_15.jpg)

图 1.15 – 单词计数表

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

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

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

然后,这种数值表示可以用作机器学习模型的输入特征向量,其中特征向量是 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

顺序表示法

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

![图 1.16 – 独热编码向量

[img/B12365_01_16.jpg)

图 1.16 – 独热编码向量

总结

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

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

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

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

  • 安装 PyTorch

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

  • PyTorch 的自然语言处理功能

技术要求

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

安装和使用 PyTorch 1.x

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

pip install torch torchvision

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

conda install torch torchvision -c pytorch

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

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

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

    这将导致以下输出:

    ![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=https%3A%2F%2Fgitee.com%2FOpenDocCN%2Ffreelearn-dl-pt2-zh%2Fraw%2Fmaster%2Fdocs%2Fhsn-nlp-pt1x%2Fimg%2FB12365_02_1.png&pos_id=img-eVdsVN3r-1721788260612)

    图 2.1 – 张量输出

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

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

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

    这将导致以下输出:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    图 2.2 – 张量乘法输出

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

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

    这会产生以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

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

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

    cuda = torch.device('cuda') 
    

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

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

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

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

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

    x*y
    

    这将导致以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

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

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

  • 模型计算的方式

  • 计算图编译的方式

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

  • 语法上的差异

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 2.10 – PyTorch 中的模型构建

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

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

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

加载数据

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

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

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

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

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

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

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

构建分类器

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

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

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

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 2.12 – 我们的神经网络

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

实施dropout

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

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

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

定义前向传播

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

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

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

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

设置模型参数

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

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

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

Loss(y) = -log(y)

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

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

训练我们的网络

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

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

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

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

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

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

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 2.14 – 训练周期

进行预测

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

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 之间的数字)有一个预测值。具有最高预测值的那个是我们模型选择作为预测的那个。在这种情况下,它是向量的第 10 个单元,对应于数字 9。请注意,由于我们之前使用了对数 softmax,我们的预测是对数而不是原始概率。要将其转换回概率,我们可以简单地使用 x 进行转换。

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

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

这导致以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 2.16 – 预测表格

注意,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 的自然语言处理

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

分类器的设置

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

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

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

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

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

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

    这将导致以下输出:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    图 2.18 – 设置分类器

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

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

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

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

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

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

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

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

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

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

训练分类器

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

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

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

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

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

这导致了以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 2.19 – 训练损失

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

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

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

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

    这导致了以下输出:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    图 2.20 – 预测输出

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

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

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

    这导致了以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

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

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

这导致了以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 2.22 – 最终输出

总结

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

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

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

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

本节包括以下章节:

  • 第三章, NLP 和文本嵌入

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

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

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

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

本章将涵盖以下主题:

  • 词嵌入

  • 探索 CBOW

  • 探索 n-gram

  • 分词

  • 词性标注和分块

  • TF-IDF

技术要求

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

NLP 中的嵌入

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 3.1 – 单热编码向量

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 3.2 – n=3 的向量

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

GLoVe

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

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

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

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

    glove['python']
    

    这导致以下输出:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    图 3.3 – 向量输出

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

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

    这导致以下输出:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

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

    这导致以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

嵌入操作

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

Queen-Woman+Man

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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:这个模型试图在文档中预测目标词,给定周围的单词。

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

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

PyTorch 是一个深度学习框架

假设我们想预测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 模型,从而演示如何学习我们的嵌入向量:

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

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

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

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

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

    这导致以下输出:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    图 3.10 – 编码数据

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

    embedding_length = 20
    

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

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

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

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

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

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

    这导致以下输出:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    图 3.11 – 张量值

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

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

    这导致以下输出:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    图 3.12 – 训练我们的网络

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

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

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

    这导致以下输出:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    图 3.13 – 预测值

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

    print(model.get_word_emdedding('leap'))
    

    这导致以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

探索 n-gram

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

猫坐在狗上

狗坐在猫上

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 3.15 – 二元组的表格表示

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

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

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

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

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

n-gram 语言建模

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

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

My name is my name

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 3.16 – 一元表达的表现

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

Name is Name my my

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

My favourite language is ___

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 3.17 – 概率的表格表示

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

标记化

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

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

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

    这导致以下输出:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    图 3.18 – 分割句子

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

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

    这导致以下输出:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    图 3.19 – 移除标点符号

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

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

    这导致以下输出:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

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

    这导致以下输出:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

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

    这导致以下输出:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    图 3.22 – 显示停用词

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

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

    这导致以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 3.23 – 移除停用词

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

词性标注和块句法分析

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

大狗正在床上睡觉

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

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

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 3.24 – 词性

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

标记

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

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 的解释

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

他喝水

我会给我们买一些饮料

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

切块

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

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

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

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

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

这导致以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 3.27 – 树表示

TF-IDF

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

这是一只小长颈鹿

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 3.28 – 词嵌入

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

计算 TF-IDF

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

实现 TF-IDF

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

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

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

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

    这导致以下输出:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    图 3.29 – TF-IDF 分数

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

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

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

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

    这导致以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

计算 TF-IDF 加权嵌入

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

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

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

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

    这导致以下输出:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    图 3.31 – 平均嵌入

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

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

    这导致以下输出:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    图 3.32 – TF-IDF 嵌入

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

    cosine_similarity(mean_embedding, tfidf_weighted_embedding)
    

    这导致以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

摘要

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

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

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

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

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

他跳跃了

他正在跳跃

他跳跃

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

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

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

  • 文本预处理

  • 词干化

  • 词形归并

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

技术要求

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

文本预处理

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

移除 HTML

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

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

这将返回以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.1 – 删除 HTML

前面的截图显示已成功删除了 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(鸟)。

删除标点符号

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

熊猫吃饭开枪和离开

熊猫吃饭,开枪和离开

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

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

这返回以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

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

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 和 one 不被视为不同实体。我们可以使用以下方法在 Python 中实现这一点:

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

这返回以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.5 – 用文本替换数字

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

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

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(添加 “s” 后缀以使其现在时)

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

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

To be -> Is(现在时)

To be -> Was(过去时)

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

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

Cat -> Cats(复数)

Cat -> Cat’s(所有格)

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

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

“他的猫的毛色不同” -> “他 猫 毛色 不同”

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

词干提取

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

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

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

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

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

    这导致以下输出:

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    图 4.7 – 返回单词的词干

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

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

这将返回以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.9 – Porter Stemmer 算法的规则

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

词形还原

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

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

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

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

这导致以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4.10 – 词形还原输出

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

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

这导致以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

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

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

这导致以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

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

这导致以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

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

这导致以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

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

这导致以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

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 - 最终词形还原函数的输出

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

使用词干化和词形还原

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

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

单词的词形还有提取

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

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

概述

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

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

  • 8
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值