Python-自然语言处理快速启动指南-全-

Python 自然语言处理快速启动指南(全)

原文:zh.annas-archive.org/md5/568e58cc795df0aa9c5efb0bcd541fcc

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

自然语言处理(NLP)是使用机器来操作自然语言。本书通过代码和相关的案例研究,教你如何使用Python构建NLP应用程序。本书将介绍构建NLP应用程序的基本词汇和推荐的工作流程,帮助你开始进行诸如情感分析、实体识别、词性标注、词干提取和词嵌入等流行的NLP任务。

本书面向对象

本书面向希望构建能够解释语言的系统的程序员,并且对Python编程有所了解。熟悉NLP词汇和基础知识以及机器学习将有所帮助,但不是必需的。

本书涵盖内容

第一章开始文本分类之旅,向读者介绍了自然语言处理(NLP)以及一个良好的NLP工作流程是什么样的。你还将学习如何使用scikit-learn为机器学习准备文本。

第二章整理你的文本,讨论了一些最常见的文本预处理想法。你将了解spaCy,并学习如何使用它进行分词、句子提取和词形还原。

第三章利用语言学,探讨了一个简单的用例,并检查我们如何解决它。然后,我们再次执行这个任务,但是在一个略有不同的文本语料库上。

第四章文本表示 – 从单词到数字,向读者介绍了Gensim API。我们还将学习如何加载预训练的GloVe向量,并在任何机器学习模型中使用这些向量表示而不是TD-IDF。

第五章现代分类方法,探讨了关于机器学习的几个新想法。这里的目的是展示一些最常见的分类器。我们还将了解诸如情感分析、简单分类器以及如何针对你的数据集和集成方法进行优化的概念。

第六章深度学习在NLP中的应用,涵盖了深度学习是什么,它与我们所看到的不同之处,以及任何深度学习模型中的关键思想。我们还将探讨一些与PyTorch相关的话题,如何标记文本,以及什么是循环神经网络。

第七章构建自己的聊天机器人,解释了为什么应该构建聊天机器人,并确定正确的用户意图。我们还将详细了解意图响应模板实体

第八章Web部署,解释了如何训练模型并编写一些用于数据I/O的更简洁的实用工具。我们将构建一个预测函数,并通过Flask REST端点公开它。

为了充分利用本书

  • 你需要安装Python 3.6或更高版本的conda

  • 需要具备对 Python 编程语言的基本理解

  • NLP 或机器学习经验将有所帮助,但不是强制性的

下载示例代码文件

您可以从您的账户中下载本书的示例代码文件,网址为 www.packt.com。如果您在其他地方购买了本书,您可以访问 www.packt.com/support 并注册,以便将文件直接发送给您。

您可以通过以下步骤下载代码文件:

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

  2. 选择“支持”选项卡。

  3. 点击代码下载与勘误。

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

文件下载完成后,请确保使用最新版本的以下软件解压或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

书籍的代码包也托管在 GitHub 上,网址为 https://github.com/PacktPublishing/Natural-Language-Processing-with-Python-Quick-Start-Guide。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包可供选择,请访问https://github.com/PacktPublishing/。查看它们!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:http://www.packtpub.com/sites/default/files/downloads/9781789130386_ColorImages.pdf

使用的约定

本书使用了一些文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。以下是一个示例:“我使用了 sed 语法。”

代码块设置如下:

url = 'http://www.gutenberg.org/ebooks/1661.txt.utf-8'
file_name = 'sherlock.txt'

任何命令行输入或输出都如下所示:

import pandas as pd
import numpy as np

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的文字如下所示。以下是一个示例:“预测:pos 实际上是来自我之前上传到本页面的文件的输出。”

警告或重要注意事项看起来像这样。

小技巧和技巧看起来像这样。

联系我们

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

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并给我们发送电子邮件至 customercare@packtpub.com

勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将不胜感激,如果你能向我们报告这一点。请访问www.packt.com/submit-errata,选择你的书籍,点击勘误提交表单链接,并输入详细信息。

盗版: 如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并附上材料的链接。

如果你有兴趣成为作者: 如果你精通某个主题,并且你对撰写或为书籍做出贡献感兴趣,请访问authors.packtpub.com

评论

请留下评论。一旦你阅读并使用了这本书,为何不在你购买它的网站上留下评论呢?潜在读者可以查看并使用你的客观意见来做出购买决定,Packt公司可以了解你对我们的产品有何看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

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

第一章:文本分类入门

你可以通过几种方式学习新想法和技能。在美术课上,学生研究颜色,但直到大学才被允许实际绘画。听起来荒谬吗?

不幸的是,这就是大多数现代机器学习的教学方式。专家们正在做类似的事情。他们会告诉你需要了解线性代数、微积分和深度学习。在他们教你如何使用自然语言处理NLP)之前,这些都是必须知道的。

在这本书中,我希望我们通过教授整个游戏来学习。在每一节中,我们看到如何解决现实世界的问题,并在过程中学习工具。然后,我们将更深入地了解如何制作这些工具。这种学习和教学风格在很大程度上受到了fast.ai的Jeremy Howard的启发。

下一个重点是尽可能多地提供代码示例。这是为了确保学习一个主题背后有一个清晰和有动机的目的。这有助于我们用直觉理解,而不仅仅是代数符号的数学公式。

在这一章的开头,我们将专注于自然语言处理的介绍。然后,我们将通过代码示例跳入文本分类的例子。

这就是我们的旅程将大致看起来像什么:

  • 什么是自然语言处理(NLP)?

  • 一个好的自然语言处理工作流程是什么样的?这是为了提高你在任何NLP项目中的成功率。

  • 文本分类作为良好自然语言处理管道/工作流程的激励示例。

什么是自然语言处理(NLP)?

自然语言处理是使用机器操作自然语言。在这本书中,我们将专注于书面语言,或者用更简单的话说:文本。

实际上,这是一本关于英语文本处理的实践指南。

人类是唯一已知的开发书面语言的物种。然而,孩子们并不是自己学会阅读和写作的。这是为了强调文本处理和自然语言处理的复杂性。

自然语言处理的研究已经存在了50多年。著名的通用人工智能图灵测试就是使用这种语言。这个领域在语言学和计算技术方面都得到了发展。

在能够首先构建事物的精神下,我们将学习如何使用Python的scikit-learn和其他依赖项构建一个简单的文本分类系统。

我们还将讨论这本书是否适合你。

让我们开始吧!

为什么要学习自然语言处理(NLP)?

要最大限度地了解这本书,最好的方式是知道你希望自然语言处理为你做什么。

有许多原因可能吸引你学习自然语言处理(NLP)。可能是更高的收入潜力。也许你已经注意到并兴奋于NLP的潜力,例如,关于优步的客户服务机器人。是的,他们主要使用机器人来回答你的投诉,而不是人类。

了解您的动机并将其写下来是有用的。这有助于您选择让您兴奋的问题和项目。这也有助于您在阅读这本书时进行选择。这不是一本《NLP Made Easy》或类似的书籍。让我们坦诚:这是一个具有挑战性的主题。写下您的动机是一个有用的提醒。

作为一项法律声明,附带的代码具有宽松的MIT许可证。您可以在工作中使用它而无需法律麻烦。但话虽如此,每个依赖库都是第三方提供的,您应该绝对检查它们是否允许商业使用

我不期望您能够使用这里提到的所有工具和技术。挑选出有意义的部分。

您心中有一个问题

您已经有一个问题在心中,比如一个学术项目或工作中的问题。

您是否在寻找您可以使用来起步的最佳工具和技术?

首先,翻到书的索引,看看我是否在这里覆盖了您的问题。我已经在这里分享了某些最常见用例的端到端解决方案。如果没有分享,请不要担心——您仍然受到保护。许多任务的基本技术是通用的。我已经仔细选择了对更广泛受众有用的方法。

技术成就

学习对于您来说是一种成就的标志吗?

NLP以及更广泛的数据科学是流行的术语。您是那种想要跟上潮流的人。您是那种从学习新工具、技术和技术中获得乐趣的人。这是您的下一个重大挑战。这是您证明自我学习能力并达到精通的机会。

如果这听起来像您,您可能会对这个作为参考书感兴趣。我专门设置了部分,让我们为您提供足够的方法理解。我向您展示如何使用它,而无需深入研究最新的论文。这是学习更多知识的邀请,您不应该在这里停下来。亲自尝试这些代码示例!

做些新鲜事

您有一些领域专业知识。现在,您想在您的领域中做一些没有这些技能不可能做到的事情。确定新可能性的一种方法是将您的领域专业知识与在这里学到的知识结合起来。我在写这本书的时候看到了几个非常大的机会,包括以下内容:

  • 非英语语言如印地语、泰米尔语或泰卢固语的NLP。

  • 为您所在的领域提供专业的NLP,例如,金融和Bollywood在各自的方式中有不同的语言。您在Bollywood新闻上训练的模型并不期望在金融领域工作。

如果这听起来像您,您需要关注这本书中的文本预处理部分。这些部分将帮助您了解我们如何使文本准备好供机器消费。

这本书适合您吗?

这本书的编写是为了保持先前的用例和心态。这里选择的方法、技术、工具和技术是行业级稳定性与学术级结果质量的良好平衡。有几个工具,如parfit和Flashtext,以及像LIME这样的想法,在NLP的背景下从未被提及过。

最后,我理解深度学习方法的重要性和兴奋感,并为NLP方法专门编写了一章关于深度学习。

NLP工作流程模板

我们中的一些人非常喜欢从事自然语言处理,因为它纯粹是智力上的挑战——跨越研究和工程。为了衡量我们的进度,拥有一个带有粗略时间估计的工作流程是非常有价值的。在本节中,我们将简要概述一个典型的NLP或甚至大多数应用机器学习过程看起来像什么。

我从大多数人那里学到的喜欢使用一个(大致上)五步的过程:

  • 理解问题

  • 理解和准备数据

  • 快速胜利:概念验证

  • 迭代和改进结果

  • 评估和部署

这只是一个过程模板。它有很多空间来根据你公司的工程文化进行定制。这些步骤中的任何一个都可以进一步分解。例如,数据准备和理解可以进一步分为分析和清理。同样,概念验证步骤可能涉及多个实验,以及从这些实验中提交的最佳结果的演示或报告。

虽然这看起来是一个严格的线性过程,但实际上并非如此。通常情况下,你将希望回顾前面的步骤并更改参数或特定的数据转换,以查看对后续性能的影响。

为了做到这一点,在你的代码中考虑这一过程的循环性质是很重要的。编写具有良好设计的抽象代码,其中每个组件都是独立可重用的。

如果你感兴趣于如何编写更好的NLP代码,尤其是用于研究或实验,可以考虑查阅由AllenAI的Joel Grus提供的名为Writing Code for NLP Research的幻灯片。

让我们稍微深入到每个这些部分。

理解问题

我们将从理解来自实际商业视角的需求和约束开始。这通常回答以下问题:

  • 主要问题是什么?我们将尝试正式和非正式地理解我们项目中的假设和期望。

  • 我将如何解决这个问题?列出一些你可能之前或在这本书中看到过的想法。这是你将用来规划未来工作的清单。

理解和准备数据

文本和语言本质上是未结构化的。我们可能想要以某种方式对其进行清理,例如扩展缩写和首字母缩略词,删除标点符号等。我们还想选择一些样本,这些样本是我们可能在野外看到的数据的最佳代表。

另一种常见的做法是准备一个黄金数据集。黄金数据集是在合理条件下可用的最佳数据。这不是在理想条件下可用的最佳数据。创建黄金数据集通常涉及手动标记和清理过程。

接下来的几节将专注于NLP工作流程这一阶段的文本清理和文本表示。

快速胜利 – 概念验证

我们希望快速识别出对我们来说似乎有效的算法和数据集组合类型。然后我们可以专注于它们,并更深入地研究它们。

这里得到的结果将帮助你估计你面前的工作量。例如,如果你打算开发一个仅基于关键词的文档搜索系统,你的主要工作可能将是部署一个开源解决方案,如ElasticSearch。

假设你现在想要添加一个类似文档的功能。根据预期的结果质量,你可能需要研究诸如doc2vec和word2vec等技术,或者甚至使用Keras/Tensorflow或PyTorch的一些卷积神经网络解决方案。

这一步对于从你周围的人,如你的老板,那里获得更大的支持,投入更多精力和资源至关重要。在工程角色中,这个演示应该突出你的工作部分,这些部分是货架系统通常无法做到的。这些是你的独特优势。这些通常是其他系统无法提供的见解、定制和控制。

迭代和改进

在这一点上,我们已经选择了一系列算法、数据和方 法,它们对我们来说有令人鼓舞的结果。

算法

如果你的算法是机器学习或统计性质,你通常会剩下很多余量。

在早期阶段,对于一些参数,你可能只需要选择一个足够好的默认值。在这里,你可能想要加大力度,检查这些参数的最佳值。这个想法有时被称为参数搜索,或者按照机器学习的术语,称为超参数调整。

你可能想要以特定方式将一种技术的结果与其他技术结合。例如,某些统计方法可能非常适合在文本中找到名词短语并使用它们进行分类,而深度学习方法(我们可以称之为DL-LSTM)可能最适合整个文档的文本分类。在这种情况下,你可能希望将名词短语提取和DL-LSTM的额外信息传递给另一个模型。这将允许它利用两者的最佳之处。在机器学习的术语中,这个想法有时被称为堆叠。这在最近非常成功的机器学习竞赛平台Kaggle上非常成功。

预处理

在数据预处理或数据清洗阶段进行简单的更改,通常会带来显著更好的结果。例如,确保您的整个语料库都是小写字母,可以帮助您显著减少唯一单词的数量(您的词汇量)。

如果您的词语的数值表示受到词频的影响,有时通过归一化和/或缩放可能会有所帮助。最懒惰的技巧就是简单地除以频率。

评估和部署

评估和部署是使您的工作广泛可用的重要组件。您评估的质量决定了其他人信任您工作的程度。部署方式多种多样,但通常会被抽象为单个功能调用或 REST API 调用。

评估

假设您有一个模型在分类脑瘤时达到了 99% 的准确率。您能信任这个模型吗?不能。

如果您的模型说没有人有脑瘤,它仍然会有 99%+ 的准确率。为什么?

幸运的是,99% 或更多的民众没有脑瘤!

为了将我们的模型用于实际应用,我们需要超越准确率。我们需要了解模型在哪些方面做得对或错,以便改进它。花一分钟时间理解混淆矩阵将阻止我们继续使用这样的危险模型。

此外,我们还想了解模型在黑盒优化算法之下到底在做什么。t-SNE 等数据可视化技术可以帮助我们做到这一点。

对于持续运行的 NLP 应用程序,如邮件垃圾邮件分类器或聊天机器人,我们希望模型质量评估也能持续进行。这将帮助我们确保模型性能不会随着时间的推移而下降。

部署

这本书是以程序员优先的思维方式编写的。我们将学习如何将任何机器学习或 NLP 应用程序作为 REST API 部署,然后可以用于网页和移动设备。这种架构在行业中相当普遍。例如,我们知道亚马逊和领英等数据科学团队就是这样将他们的工作部署到网络上的。

示例 - 文本分类工作流程

前面的过程相当通用。对于最常见的一种自然语言应用——文本分类,它看起来会是什么样子?

下面的流程图是由微软 Azure 构建的,这里用它来解释他们的技术如何直接融入到我们的工作流程模板中。他们在特征工程中引入了一些新词,如 unigrams、TF-IDF、TF、n-grams 等:

流程图

他们的流程图中的主要步骤如下:

  1. 步骤 1: 数据准备

  2. 步骤 2: 文本预处理

  3. 步骤 3: 特征工程:

    • Unigram TF-IDF 提取

    • N-gram TF 提取

  4. 步骤 4: 训练和评估模型

  5. 步骤 5: 将训练好的模型作为网络服务部署

这意味着是时候停止谈论并开始编程了。让我们先快速设置环境,然后我们将用30行代码或更少的代码构建我们的第一个文本分类系统。

Launchpad – 编程环境设置

我们将使用fast.ai机器学习设置来完成这个练习。他们的设置环境非常适合个人实验和行业级概念验证项目。我已经在Linux和Windows上使用过fast.ai环境。我们将在这里使用Python 3.6,因为我们的代码在其他Python版本上无法运行。

在他们的论坛上快速搜索也会带你到如何在大多数云计算解决方案上设置相同内容的最新说明,包括AWS、Google Cloud Platform和Paperspace。

这个环境涵盖了我们将用于大多数主要任务的工具:文本处理(包括清理)、特征提取、机器学习与深度学习模型、模型评估和部署。

它包含spaCy。spaCy是一个开源工具,它被制作成一个行业级自然语言处理工具包。如果有人建议你使用NLTK来完成一项任务,请改用spaCy。接下来的演示将在他们的环境中直接运行。

我们还需要一些其他包来完成后续任务。我们将根据需要安装和设置它们。我们不希望因为不必要的包而膨胀你的安装,你可能甚至都不会使用这些包。

30行代码实现文本分类

让我们把分类问题划分为以下步骤:

  1. 获取数据

  2. 文本到数字

  3. 使用sklearn运行ML算法

获取数据

20个新闻组数据集在自然语言处理社区中是一个相当知名的数据集。它对于演示目的几乎是理想的。这个数据集在20个类别中具有几乎均匀的分布。这种均匀分布使得快速迭代分类和聚类技术变得容易。

我们将使用著名的20个新闻组数据集进行演示:

from sklearn.datasets import fetch_20newsgroups  # import packages which help us download dataset 
twenty_train = fetch_20newsgroups(subset='train', shuffle=True, download_if_missing=True)
twenty_test = fetch_20newsgroups(subset='test', shuffle=True, download_if_missing=True)

大多数现代自然语言处理方法都严重依赖于机器学习方法。这些方法需要将作为字符串文本编写的单词转换为数值表示。这种数值表示可以是简单地分配一个唯一的整数ID,也可以是稍微更全面的浮点值向量。在后一种情况下,这有时被称为向量化。

文本到数字

我们将在示例中使用著名的20个新闻组数据集。我们简单地转换每个文档中每个单词出现的次数。因此,每个文档都是一个“袋”,我们计算该袋中每个单词的频率。这也意味着我们失去了文本中存在的任何顺序信息。接下来,我们为每个唯一的单词分配一个整数ID。所有这些唯一的单词成为我们的词汇表。我们词汇表中的每个单词都被视为一个机器学习特征。让我们首先创建我们的词汇表。

Scikit-learn 有一个高级组件可以为我们创建特征向量。这被称为 CountVectorizer。我们建议您从 scikit-learn 文档中了解更多相关信息:

# Extracting features from text files
from sklearn.feature_extraction.text import CountVectorizer

count_vect = CountVectorizer()
X_train_counts = count_vect.fit_transform(twenty_train.data)

print(f'Shape of Term Frequency Matrix: {X_train_counts.shape}')

通过使用 count_vect.fit_transform(twenty_train.data),我们正在学习词汇字典,它返回一个形状为 ['n_samples', 'n_features'] 的文档-词矩阵。这意味着我们有 n_samples 个文档或袋,它们之间有 n_features 个独特的单词。

现在,我们将能够提取这些词与其所属标签或类别之间的有意义的关系。实现这一点的最简单方法之一是计算每个类别中一个词出现的次数。

我们对此有一个小问题——长文档往往会极大地影响结果。我们可以通过将词频除以该文档中的总词数来归一化这种影响。我们称这为词频,简称 TF。

theaof 这样的词在所有文档中都很常见,并不能真正帮助我们区分文档类别或将它们分开。我们想要强调的是较罕见的词,如 ManmohanModi,而不是常见词。实现这一目标的一种方法是通过逆文档频率,或称 IDF。逆文档频率是衡量一个词在所有文档中是常见还是罕见的度量。

我们将 TF 与 IDF 相乘以得到我们的 TF-IDF 指标,该指标总是大于零。TF-IDF 是针对三元组 term t、document d 和词汇字典 D 计算的。

我们可以直接使用以下代码行计算 TF-IDF:

from sklearn.feature_extraction.text import TfidfTransformer

tfidf_transformer = TfidfTransformer()
X_train_tfidf = tfidf_transformer.fit_transform(X_train_counts)

print(f'Shape of TFIDF Matrix: {X_train_tfidf.shape}')

最后一行将输出文档-词矩阵的维度,其值为 (11314, 130107)。

请注意,在先前的例子中,我们使用每个词作为特征,因此对每个词计算了 TF-IDF。当我们使用单个词作为特征时,我们称之为 unigram。如果我们使用两个连续的词作为特征,我们称之为 bigram。一般来说,对于 n 个词,我们称之为 n-gram。

机器学习

可以使用各种算法进行文本分类。您可以使用以下代码在 scikit 中构建分类器:

from sklearn.linear_model import LogisticRegression as LR
from sklearn.pipeline import Pipeline

让我们逐行分析先前的代码。

前两行是简单的导入。我们导入了一个相当知名的逻辑回归模型,并将其重命名为 LR。接下来是管道导入:

"依次应用一系列转换和一个最终估计器。管道的中间步骤必须是“转换”,也就是说,它们必须实现 fit 和 transform 方法。最终的估计器只需要实现 fit 方法。"

Scikit-learn 管道在逻辑上是一系列依次应用的运算。首先,我们应用了我们已经看到的两个操作:CountVectorizer()TfidfTransformer()。接着是 LR()。管道是通过 Pipeline(...) 创建的,但尚未执行。它只有在从 Pipeline 对象调用 fit() 函数时才会执行:

text_lr_clf = Pipeline([('vect', CountVectorizer()), ('tfidf', TfidfTransformer()), ('clf',LR())])
text_lr_clf = text_lr_clf.fit(twenty_train.data, twenty_train.target)

当调用此函数时,它会调用除了最后一个对象之外的所有对象的转换函数。对于最后一个对象——我们的逻辑回归分类器——它的 fit() 函数被调用。这些转换器和分类器也被称为估计器:

"在管道中的所有估计器(除了最后一个),都必须是转换器(也就是说,它们必须有一个转换方法)。最后一个估计器可以是任何类型(转换器、分类器等)。” - 来自 sklearn 管道文档

让我们计算这个模型在测试数据上的准确率。为了计算大量值上的平均值,我们将使用一个名为 numpy 的科学库:*

import numpy as np
lr_predicted = text_lr_clf.predict(twenty_test.data)
lr_clf_accuracy = np.mean(lr_predicted == twenty_test.target) * 100.

print(f'Test Accuracy is {lr_clf_accuracy}')

这会打印出以下输出:

Test Accuracy is 82.79341476367499

我们在这里使用了 LR 的默认参数。我们可以在以后使用 GridSearchRandomSearch 来优化这些参数,以进一步提高准确率。

如果你只想记住本节中的一件事,请记住尝试一个线性模型,如逻辑回归。它们通常对稀疏的高维数据(如文本、词袋或 TF-IDF)相当有效。

除了准确率之外,了解哪些文本类别被混淆为其他类别也是有用的。我们将称之为混淆矩阵。

以下代码使用了我们用来计算测试准确率的相同变量,以找出混淆矩阵:

from sklearn.metrics import confusion_matrix
cf = confusion_matrix(y_true=twenty_test.target, y_pred=lr_predicted)
print(cf)

这打印出一个巨大的数字列表,不太容易解释。让我们尝试使用 print-json 技巧来美化打印:

import json
print(json.dumps(cf.tolist(), indent=2))

这返回以下代码:

[
  [
    236,
    2,
    0,
    0,
    1,
    1,
    3,
    0,
    3,
    3,
    1,
    1,
    2,
    9,
    2,
    35,
    3,
    4,
    1,
    12
  ],
 ...
  [
    38,
    4,
    0,
    0,
    0,
    0,
    4,
    0,
    0,
    2,
    2,
    0,
    0,
    8,
    3,
    48,
    17,
    2,
    9,
    114
  ]
 ]

这稍微好一些。我们现在明白这是一个 20 × 20 的数字网格。然而,除非我们能引入一些可视化,否则解释这些数字是一项繁琐的任务。让我们接下来这么做:

# this line ensures that the plot is rendered inside the Jupyter we used for testing this code
%matplotlib inline 

import seaborn as sns
import matplotlib.pyplot as plt

plt.figure(figsize=(20,10))
ax = sns.heatmap(cf, annot=True, fmt="d",linewidths=.5, center = 90, vmax = 200)
# plt.show() # optional, un-comment if the plot does not show

这给了我们以下惊人的图:

这个图用不同的颜色方案突出了我们感兴趣的信息。例如,从左上角到右下角的光对角线显示了我们所做的一切正确的事情。如果我们混淆了那些,其他网格会更暗。例如,97 个样本被错误地标记为某一类,这通过第 18 行和第 16 列的深黑色迅速可见。

我们将在本书的稍后部分更详细地深入了解本节的两个部分——模型解释和数据可视化。

摘要

在本章中,你感受到了使项目工作所需的一些更广泛的事情。我们通过使用文本分类示例来查看这个过程所涉及的步骤。我们看到了如何使用 scikit-learn 准备文本进行机器学习。我们看到了机器学习中的逻辑回归。我们还看到了混淆矩阵,这是一个快速而强大的工具,可以理解所有机器学习的结果,而不仅仅是 NLP。

我们才刚刚开始。从现在开始,我们将深入探讨每一个步骤,并看看还有哪些其他方法存在。在下一章中,我们将探讨一些常见的文本清洗和提取方法。由于这是我们总共花费高达80%时间的地方,因此花时间和精力去学习它是值得的。

第二章:整理你的文本

数据清洗是自然语言处理(NLP)中最重要且耗时的工作之一:

“有这样一个笑话,80%的数据科学是数据清洗,20%是抱怨数据清洗。”

– Kaggle创始人兼首席执行官安东尼·戈德布卢姆在Verge访谈中提到

在本章中,我们将讨论一些最常见的文本预处理想法。这项任务是普遍的、繁琐的、不可避免的。大多数在数据科学或NLP领域工作的人都知道,这是一个被低估的价值增加。其中一些任务在单独使用时效果不佳,但在正确的组合和顺序中使用时具有强大的效果。由于该领域有两个世界的丰富历史,本章将介绍几个新词和工具。它从传统NLP和机器学习两个领域借鉴。我们将遇到spaCy,这是一个用于Python的自然语言处理的快速行业级工具包。我们将用它进行分词、句子提取和词形还原。

我们将学习使用正则表达式函数,这对于文本挖掘很有用。Python的正则表达式替换对于较大的数据集来说可能很慢。相反,我们将使用FlashText进行替换和扩展。

这是唯一一本涵盖FlashText的书籍。更广泛地说,我们将分享你如何开始思考操纵和清洗文本。这不是对任何一种技术的深入覆盖,而是为你提供一个起点,让你思考哪些可能对你有效。

常规任务 - 面包和黄油

有几个著名的文本清洗想法。它们都已经进入了今天最流行的工具中,如NLTK、Stanford CoreNLP和spaCy。我喜欢spaCy的两个主要原因:

  • 这是一个行业级NLP,与NLTK不同,NLTK主要是为了教学。

  • 它具有良好的速度与性能权衡。spaCy是用Cython编写的,这使得它具有类似C的性能,同时使用Python代码。

spaCy正在积极维护和开发,并集成了大多数挑战的最佳可用方法。

到本节结束时,你将能够做到以下几件事情:

  • 理解分词并使用spaCy手动进行

  • 理解为什么停用词去除和大小写标准化是有效的,以spaCy为例

  • 区分词干提取和词形还原,以spaCy词形还原为例

加载数据

我一直喜欢亚瑟·柯南·道尔的福尔摩斯探案集。让我们下载这本书并保存在本地:

url = 'http://www.gutenberg.org/ebooks/1661.txt.utf-8'
file_name = 'sherlock.txt'

让我们实际下载文件。你只需要做一次,但这个下载实用程序也可以在下载其他数据集时使用:

import urllib.request
# Download the file from `url` and save it locally under `file_name`:
with urllib.request.urlopen(url) as response:
    with open(file_name, 'wb') as out_file:
        data = response.read() # a `bytes` object
        out_file.write(data)

接下来,让我们检查在Jupyter笔记本内部是否正确放置了文件,使用shell语法。这种在Windows和Linux上运行基本shell命令的能力非常有用:

!ls *.txt

上述命令返回以下输出:

sherlock.txt

文件包含来自Project Gutenberg的标题和页脚信息。我们对此不感兴趣,并将丢弃版权和其他法律声明。这是我们想要做的:

  1. 打开文件。

  2. 删除标题和页脚信息。

  3. 将新文件保存为sherlock_clean.txt

我打开了文本文件,发现我需要删除前33行。让我们使用shell命令来完成这个任务——这些命令在Jupyter笔记本中的Windows上也同样适用。你现在还记得这个吗?继续前进:

!sed -i 1,33d sherlock.txt

我使用了sed语法。-i标志告诉你要进行必要的更改。1,33d指示你删除第1到33行。

让我们再次检查一下。我们期望这本书现在以标志性的书名/封面开始:

!head -5 sherlock.txt

这显示了这本书的前五行。它们正如我们所预期的那样:

THE ADVENTURES OF SHERLOCK HOLMES

   by

   SIR ARTHUR CONAN DOYLE

我看到了什么?

在我继续进行任何NLP任务前的文本清理之前,我想花几秒钟快速看一下数据本身。我在以下列表中记下了我注意到的一些事情。当然,更敏锐的眼睛能看到比我更多的东西:

  • 日期以混合格式书写:1888年3月20日;时间也是如此:三点钟

  • 文本在大约70列处换行,所以没有一行可以超过70个字符。

  • 有很多专有名词。这些包括像AtkinsonTrepoff这样的名字,以及像TrincomaleeBaker Street这样的地点。

  • 索引使用罗马数字,如IIV,而不是14

  • 有很多对话,如你有全权,周围没有叙述。这种叙事风格可以自由地从叙述切换到对话驱动。

  • 由于道尔写作的时代,语法和词汇稍微有些不寻常。

这些主观观察有助于理解文本的性质和边缘情况。让我们继续,并将这本书加载到Python中进行处理:

# let's get this data into Python

text = open(file_name, 'r', encoding='utf-8').read() # note that I add an encoding='utf-8' parameter to preserve information

print(text[:5])

这返回了前五个字符:

THE A

让我们快速验证一下我们是否已将数据加载到有用的数据类型中。

要检查我们的数据类型,请使用以下命令:

print(f'The file is loaded as datatype: {type(text)} and has {len(text)} characters in it')

前面的命令返回以下输出:

The file is loaded as datatype: <class 'str'> and has 581204 characters in it

在处理字符串方面,Py2.7和Py3.6之间有一个重大的改进。现在它们默认都是Unicode。

在Python 3中,str是Unicode字符串,这对于非英语文本的NLP来说更方便。

这里有一个小的相关示例,以突出两种方法之间的差异:

from collections import Counter
Counter('Möbelstück')

In Python 2: Counter({'\xc3': 2, 'b': 1, 'e': 1, 'c': 1, 'k': 1, 'M': 1, 'l': 1, 's': 1, 't': 1, '\xb6': 1, '\xbc': 1})
In Python 3: Counter({'M': 1, 'ö': 1, 'b': 1, 'e': 1, 'l': 1, 's': 1, 't': 1, 'ü': 1, 'c': 1, 'k': 1})

探索加载的数据

我们能看到多少个独特的字符?

作为参考,ASCII中有127个字符,所以我们预计这最多有127个字符:

unique_chars = list(set(text))
unique_chars.sort()
print(unique_chars)
print(f'There are {len(unique_chars)} unique characters, including both ASCII and Unicode character')

前面的代码返回以下输出:

   ['\n', ' ', '!', '"', '$', '%', '&', "'", '(', ')', '*', ',', '-', '.', '/', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', ';', '?', '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'à', 'â', 'è', 'é']
   There are 85 unique characters, including both ASCII and Unicode character

对于我们的机器学习模型,我们通常需要单词以单个标记或单个单词的形式出现。让我们在下一节中解释这意味着什么。

分词

给定一个字符序列和一个定义的文档单元,分词是将它切分成称为标记的片段的任务,也许同时丢弃某些字符,如标点符号。

这里是一个分词的示例:

Input: Friends, Romans, Countrymen, lend me your ears;
Output:       .

实际上,有时区分标记和单词是有用的。但在这里,为了便于理解,我们将它们互换使用。

我们将把原始文本转换为单词列表。这应该保留文本的原始顺序。

有几种方法可以做到这一点,所以让我们尝试其中几种。我们将从头开始编写两个方法来建立我们的直觉,然后检查spaCy如何处理分词。

直观 – 通过空格拆分

以下代码行简单地通过空格 ' ' 将整个文本主体进行分割或 拆分

words = text.split()
print(len(words))

   107431

让我们预览列表中的一个较大片段:

print(words[90:200])  #start with the first chapter, ignoring the index for now
   ['To', 'Sherlock', 'Holmes', 'she', 'is', 'always', 'THE', 'woman.', 'I', 'have', 'seldom', 'heard', 'him', 'mention', 'her', 'under', 'any', 'other', 'name.', 'In', 'his', 'eyes', 'she', 'eclipses', 'and', 'predominates', 'the', 'whole', 'of', 'her', 'sex.', 'It', 'was', 'not', 'that', 'he', 'felt', 'any', 'emotion', 'akin', 'to', 'love', 'for', 'Irene', 'Adler.', 'All', 'emotions,', 'and', 'that', 'one', 'particularly,', 'were', 'abhorrent', 'to', 'his', 'cold,', 'precise', 'but', 'admirably', 'balanced', 'mind.', 'He', 'was,', 'I', 'take', 'it,', 'the', 'most', 'perfect', 'reasoning', 'and', 'observing', 'machine', 'that', 'the', 'world', 'has', 'seen,', 'but', 'as', 'a', 'lover', 'he', 'would', 'have', 'placed', 'himself', 'in', 'a', 'false', 'position.', 'He', 'never', 'spoke', 'of', 'the', 'softer', 'passions,', 'save', 'with', 'a', 'gibe', 'and', 'a', 'sneer.', 'They', 'were', 'admirable', 'things', 'for']

这里标点符号的拆分方式并不理想。它经常与单词本身一起出现,例如 Adler**.** 结尾的全停点和 emotions**,** 中的逗号。很多时候,我们希望单词与标点符号分开,因为单词在大多数数据集中比标点符号传达的意义更多。

让我们看看一个更短的例子:

'red-headed woman on the street'.split()

以下是从前面代码中得到的输出:

['red-headed', 'woman', 'on', 'the', 'street']

注意到单词 red-headed 没有被拆分。这可能是我们想要保留的,也可能不是。我们将回过头来讨论这个问题,所以请记住这一点。

解决这个标点符号挑战的一种方法简单地提取单词并丢弃其他所有内容。这意味着我们将丢弃所有非ASCII字符和标点符号。

诡计 – 通过单词提取拆分

单词提取可以通过几种方式完成。反过来,我们可以使用单词提取将单词拆分成标记。我们将探讨正则表达式,或正则表达式进行单词提取。它是一种由用户定义的模式驱动的字符串搜索机制。

介绍正则表达式

正则表达式一开始可能有点挑战性,但它们非常强大。它们是通用的抽象,并且可以在Python以外的多种语言中使用:

import re
re.split('\W+', 'Words, words, words.')
> ['Words', 'words', 'words', '']

正则表达式 \W+ 表示 一个单词字符(A-Z等)重复一次或多次:

words_alphanumeric = re.split('\W+', text)
print(len(words_alphanumeric), len(words))

前面代码的输出是 (109111, 107431)

让我们预览一下我们提取的单词:

print(words_alphanumeric[90:200])

以下是从前面代码中得到的输出:

   ['BOHEMIA', 'I', 'To', 'Sherlock', 'Holmes', 'she', 'is', 'always', 'THE', 'woman', 'I', 'have', 'seldom', 'heard', 'him', 'mention', 'her', 'under', 'any', 'other', 'name', 'In', 'his', 'eyes', 'she', 'eclipses', 'and', 'predominates', 'the', 'whole', 'of', 'her', 'sex', 'It', 'was', 'not', 'that', 'he', 'felt', 'any', 'emotion', 'akin', 'to', 'love', 'for', 'Irene', 'Adler', 'All', 'emotions', 'and', 'that', 'one', 'particularly', 'were', 'abhorrent', 'to', 'his', 'cold', 'precise', 'but', 'admirably', 'balanced', 'mind', 'He', 'was', 'I', 'take', 'it', 'the', 'most', 'perfect', 'reasoning', 'and', 'observing', 'machine', 'that', 'the', 'world', 'has', 'seen', 'but', 'as', 'a', 'lover', 'he', 'would', 'have', 'placed', 'himself', 'in', 'a', 'false', 'position', 'He', 'never', 'spoke', 'of', 'the', 'softer', 'passions', 'save', 'with', 'a', 'gibe', 'and', 'a', 'sneer', 'They', 'were', 'admirable']

我们注意到 Adler 不再与标点符号一起出现。这正是我们想要的。任务完成了吗?

我们在这里做了什么权衡?为了理解这一点,让我们看看另一个例子:

words_break = re.split('\W+', "Isn't he coming home for dinner with the red-headed girl?")
print(words_break)

以下是从前面的代码中得到的输出:

 ['Isn', 't', 'he', 'coming', 'home', 'for', 'dinner', 'with', 'the', 'red', 'headed', 'girl', '']

我们将 Isn't 拆分为 Isnt。如果你处理的是电子邮件或推特数据,这就不太好了,因为你会有很多这样的缩写和缩写。作为一个小烦恼,我们在末尾有一个额外的空标记 ''。同样,因为我们忽略了标点符号,red-headed 被拆分为两个单词:redheaded。如果我们只有标记化版本,我们就没有简单的方法来恢复这种联系。

我们可以在我们的分词策略中编写自定义规则来覆盖这些边缘情况的大部分。或者,我们可以使用已经为我们编写好的东西。

spaCy 用于分词

spaCy 使用前面的 .load 语法加载英语 模型。这告诉 spaCy 使用哪些规则、逻辑、权重和其他信息:

 %%time
 import spacy
 # python -m spacy download en
 # uncomment above line to download the model
 nlp = spacy.load('en')

虽然我们在这本书中只使用 'en' 或英语示例,但 spaCy 支持更多语言的这些功能。我已使用他们的多语言分词器为印地语,并且对结果感到满意:

%%time 语法测量你在 Jupyter Notebook 运行时执行的单元格的 CPU 和 Wall 时间。

doc = nlp(text)

这创建了一个 spaCy 对象,doc。该对象存储了预先计算的语料库特征,包括标记。一些 NLP 库,尤其是在 Java 和 C 生态系统中,在调用特定功能时计算语料库特征,如标记、词元和词性。相反,spaCy 在将 text 传递给它时,在初始化时计算所有这些特征。

spaCy 预先计算了大多数语料库特征——你只需要从对象中检索它们。

我们可以通过调用对象迭代器来检索它们。在下面的代码中,我们调用迭代器并将其 列表化

print(list(doc)[150:200])

下面的输出是前面代码的结果:

[whole, of, her, sex, ., It, was, not, that, he, felt,
   , any, emotion, akin, to, love, for, Irene, Adler, ., All, emotions, ,, and, that,
   , one, particularly, ,, were, abhorrent, to, his, cold, ,, precise, but,
   , admirably, balanced, mind, ., He, was, ,, I, take, it, ,]

便利的是,spaCy 将所有标点符号和单词都分词。它们作为单独的标记返回。让我们尝试一下我们之前不喜欢的例子:

words = nlp("Isn't he coming home for dinner with the red-headed girl?")
print([token for token in words])
> [Is, n't, he, coming, home, for, dinner, with, the, red, -, headed, girl, ?]

这里是观察结果:

  • spaCy 正确地将 Isn't 分割为 Isn't

  • red-headed 被分割成三个标记:red-headed。由于标点信息没有丢失,如果我们想的话,可以恢复原始的 red-headed 标记。

spaCy 分词器是如何工作的?

最简单的解释来自 spaCy 文档(spacy-101)本身。

首先,原始文本在空白字符上分割,类似于 text.split (' ')。然后,分词器从左到右处理文本。在每个子字符串上,它执行两个检查:

  • 子字符串是否匹配分词器的异常规则? 例如,don't 不包含空格,但应该分割成两个标记,don't,而 U.K. 应始终作为一个标记。

  • 前缀、后缀或中缀可以被分割开吗? 例如,如逗号、句号、连字符或引号之类的标点符号:

图片

句子分词

我们也可以使用 spaCy 一次提取一句话,而不是一次提取一个单词:

sentences = list(doc.sents)
print(sentences[14:18])

下面的输出是前面代码的结果:

 [she is always THE woman., I have seldom heard
   him mention her under any other name., In his eyes she eclipse
   and predominates the whole of her sex., It was not that he felt
   any emotion akin to love for Irene Adler.]

停用词去除和大小写转换

这些简单的想法在许多任务中都很普遍,并且相当有效。它们特别有助于在处理文档时减少唯一标记的数量。

spaCy 已经将每个标记标记为停用词或非停用词,并将其存储在每个标记的 is_stop 属性中。这使得它在文本清理中非常方便。让我们快速看一下:

sentence_example = "the AI/AGI uprising cannot happen without the progress of NLP"
[(token, token.is_stop, token.is_punct) for token in nlp(sentence_example)]

   [(the, True, False),
    (AI, False, False),
    (/, False, True),
    (AGI, True, False),
    (uprising, False, False),
    (can, True, False),
    (not, True, False),
    (happen, False, False),
    (without, True, False),
    (the, True, False),
    (progress, False, False),
    (of, True, False),
    (NLP, True, False)]

回到我们的夏洛克示例,让我们看看前几行是否算作停用词:

for token in doc[:5]:
   print(token, token.is_stop, token.is_punct)   

Output:
   THE False False
   ADVENTURES False False
   OF False False
   SHERLOCK False False
   HOLMES False False

有趣的是,虽然theof被标记为停用词,但THEOF没有被标记。这不是一个错误,而是设计上的选择。spaCy不会自动移除因为大小写或标题化而不同的单词。

相反,我们可以在将原始文本传递给spaCy之前将其转换为小写,以强制这种行为:

text_lower = text.lower()  # native python function
doc_lower = nlp(text_lower)
for token in doc_lower[:5]:
   print(token, token.is_stop)

Output:
 the True
 adventures False
 of True
 sherlock False
 holmes False

让我们看看spaCy字典中存在哪些停用词,然后如何以编程方式扩展:

from spacy.lang.en.stop_words import STOP_WORDS
f'spaCy has a dictionary of {len(list(STOP_WORDS))} stop words'

   'spaCy has a dictionary of 305 stop words'

我们希望根据我们的领域和问题来扩展停用词字典。例如,如果你使用这段代码来处理NLP书籍的文本,我们可能希望将诸如NLPProcessingAGIData等词添加到停用词列表中。

spaCy有一个直观的.add() API来做这件事:

domain_stop_words = ["NLP", "Processing", "AGI"]
for word in domain_stop_words:
   STOP_WORDS.add(word)

让我们尝试运行之前相同的示例,并添加这些停用词:

[(token, token.is_stop, token.is_punct) for token in nlp(sentence_example)]

以下是从运行前面的代码中得到的输出:

    [(the, True, False),
    (AI, False, False),
    (/, False, True),
    (AGI, True, False),
    (uprising, False, False),
    (can, True, False),
    (not, True, False),
    (happen, False, False),
    (without, True, False),
    (the, True, False),
    (progress, False, False),
    (of, True, False),
    (NLP, True, False)]

正如预期的那样,NLPAGI现在也被标记为停用词。

让我们提取出不是停用词的字符串标记到一个Python列表或类似的数据结构中。

一些在文本预处理之后的NLP任务期望字符串标记而不是spaCy标记对象作为数据类型。为了演示,这里同时移除停用词和标点符号:

[str(token) for token in nlp(sentence_example) if not token.is_stop and not token.is_punct]
 ['AI', 'uprising', 'happen', 'progress']

或者只是移除停用词,同时保留标点符号:

[str(token) for token in nlp(sentence_example) if not token.is_stop]
['AI'], '/', 'uprising', 'happen', 'progress']

词干提取和词形还原

词干提取和词形还原是非常流行的两种方法,用于减少语料库的词汇量。

词干提取通常指的是一种粗略的启发式过程,它通过截断单词的末尾来希望大多数时候正确地实现这一目标,并且通常包括移除派生词缀。

词形还原通常指的是使用词汇和词形分析来正确地处理事物,通常旨在仅删除屈折词尾,并返回单词的基本或词典形式,这被称为词元。

如果遇到token saw,词干提取可能只返回s,而词形还原会尝试返回see或saw,这取决于token的使用是作为动词还是名词。

  • 克里斯托弗·曼宁博士等,2008年,[IR-Book]

(克里斯·曼宁是斯坦福大学计算机科学和语言学系的机器学习教授)

spaCy用于词形还原

spaCy仅支持词形还原。如spaCy创建者马特·霍尼巴尔在GitHub上的问题#327中讨论的那样,词干提取器很少是一个好主意。

我们希望将meet/NOUNmeeting/VERB区别对待。与旨在教授和介绍尽可能多的NLP思想的斯坦福NLTK不同,spaCy对词干提取持反对意见。

当你使用 nlp 对象处理文本时,spaCy 会默认为你进行词形还原。这些信息存储在每个标记的 lemma 属性中。spaCy 存储内部哈希或标识符,它存储在 token.lemma 中。这个数字哈希对我们没有意义。这种数字表示有助于 spaCy 比其其他 Python 组件更快地访问和操作信息。

属性末尾的下划线,例如 lemma_,告诉 spaCy 我们正在寻找的是可读性强的内容:

lemma_sentence_example = "Their Apples & Banana fruit salads are amazing. Would you like meeting me at the cafe?"
[(token, token.lemma_, token.lemma, token.pos_ ) for token in nlp(lemma_sentence_example)]

Printing this gives the following output: 

   [(Their, '-PRON-', 561228191312463089, 'ADJ'),
    (Apples, 'apples', 14374618037326464786, 'PROPN'),
    (&, '&', 15473034735919704609, 'CCONJ'),
    (Banana, 'banana', 2525716904149915114, 'PROPN'),
    (fruit, 'fruit', 17674554054627885835, 'NOUN'),
    (salads, 'salad', 16382906660984395826, 'NOUN'),
    (are, 'be', 10382539506755952630, 'VERB'),
    (amazing, 'amazing', 12968186374132960503, 'ADJ'),
    (., '.', 12646065887601541794, 'PUNCT'),
    (Would, 'would', 6992604926141104606, 'VERB'),
    (you, '-PRON-', 561228191312463089, 'PRON'),
    (like, 'like', 18194338103975822726, 'VERB'),
    (meeting, 'meet', 6880656908171229526, 'VERB'),
    (me, '-PRON-', 561228191312463089, 'PRON'),
    (at, 'at', 11667289587015813222, 'ADP'),
    (the, 'the', 7425985699627899538, 'DET'),
    (cafe, 'cafe', 10569699879655997926, 'NOUN'),
    (?, '?', 8205403955989537350, 'PUNCT')]

这里有很多事情在进行中。让我们来讨论它们。

-PRON-

spaCy 有一个稍微有些令人烦恼的词形还原(回想一下,词形还原是词形还原的输出): -PRON-。这被用作所有代词(如 TheiryoumeI)的词形。其他 NLP 工具将这些词形还原为 I 而不是占位符,如 -PRON-

不区分大小写

在检查停用词时,spaCy 并没有自动将我们的输入转换为小写。另一方面,词形还原为我们做了这件事。它将 "Apple" 转换为 "apple",将 "Banana" 转换为 "banana"。

这就是 spaCy 使我们的生活变得更轻松的一种方式,尽管有些不一致。当我们移除停用词时,我们希望保留 "THE ADVENTURES OF SHERLOCK HOLMES" 中的 THE,同时移除 "the street was black" 中的 the。在词形还原中,通常情况相反;我们更关心词语在上下文中的使用方式,并相应地使用正确的词形。

转换 - 会议到会议

词形还原(Lemmatization)意识到了词语在上下文中的语言角色。"Meeting" 转换为 "meet" 因为它是一个动词。spaCy 为我们提供了部分词性标注和其他语言特征供我们使用。我们很快就会学习如何查询这些信息。

spaCy 与 NLTK 和 CoreNLP 的比较

以下是对 NLTK 和 CoreNLP 的比较:

特性 Spacy NLTK CoreNLP
原生 Python 支持/API Y Y Y
多语言支持 Y Y Y
分词 Y Y Y
词性标注 Y Y Y
句子分割 Y Y Y
依存句法分析 Y N Y
实体识别 Y Y Y
集成词向量 Y N N
情感分析 Y Y Y
语义消歧 N N Y

修正拼写

最常见的文本挑战之一是纠正拼写错误。当数据由非专业用户输入时,这一点尤其正确,例如,运输地址或类似内容。

让我们来看一个例子。我们希望将 Gujrat、Gujart 和其他小错误拼写更正为 Gujarat。根据你的数据集和专业知识水平,有几种好的方法可以做到这一点。我们将讨论两种或三种流行的方法,并讨论它们的优缺点。

在我开始之前,我们需要向传奇人物 Peter Norvig 的拼写纠正 表示敬意。关于如何 思考 解决问题和 探索 实现方法,它仍然值得一读。即使他重构代码和编写函数的方式也是教育性的。

他的拼写纠正模块不是最简单或最好的方法。我推荐两个包:一个侧重于简单性,一个侧重于给您所有刀、铃和哨子来尝试:

  • FuzzyWuzzy 使用简单。它给出两个字符串之间简单的相似度评分,上限为 100。数字越高,表示单词越相似。

  • Jellyfish 支持六种编辑距离函数和四种音标编码选项,您可以根据您的用例使用它们。

FuzzyWuzzy

让我们看看我们如何使用 FuzzyWuzzy 来纠正我们的拼写错误。

使用以下代码在您的机器上安装 FuzzyWuzzy:

import sys

!{sys.executable} -m pip install fuzzywuzzy
# alternative for 4-10x faster computation: 

# !{sys.executable} -m pip install fuzzywuzzy[speedup]

FuzzyWuzzy 有两个主要的模块将非常有用:fuzz 和 process。让我们首先导入 fuzz:

from fuzzywuzzy import fuzz
# Trying the ratio and partial_ratio 
fuzz.ratio("Electronic City Phase One", "Electronic City Phase One, Bangalore")

fuzz.partial_ratio("Electronic City Phase One", "Electronic City Phase One, Bangalore")

我们可以看到,比率函数被前面地址中使用的尾随 Bangalore 搅乱了,但实际上这两个字符串指的是同一个地址/实体。这被 partial_ratio 捕获。

你看到没有,ratiopartial_ratio 都对单词的顺序很敏感?这对于比较遵循某种顺序的地址很有用。另一方面,如果我们想比较其他东西,例如人名,可能会得到反直觉的结果:

fuzz.ratio('Narendra Modi', 'Narendra D. Modi')

fuzz.partial_ratio('Narendra Modi', 'Narendra D. Modi')

如您所见,仅仅因为我们有一个额外的 D. 标记,我们的逻辑就不再适用了。我们想要的是对顺序不那么敏感的东西。FuzzyWuzzy 的作者已经为我们解决了这个问题。

FuzzyWuzzy 支持将我们的输入在空格上进行标记化,并删除标点符号、数字和非 ASCII 字符的功能。然后这被用来计算相似度。让我们试试这个:

fuzz.token_sort_ratio('Narendra Modi', 'Narendra D. Modi')

fuzz.token_set_ratio('Narendra Modi', 'Narendra D. Modi')

这对我们来说将完美无缺。如果我们有一个选项列表,并且我们想找到最接近的匹配项,我们可以使用 process 模块:

from fuzzywuzzy import process
query = 'Gujrat'

choices = ['Gujarat', 'Gujjar', 'Gujarat Govt.']

# Get a list of matches ordered by score, default limit to 5
print(process.extract(query, choices))
# [('Gujarat', 92), ('Gujarat Govt.', 75), ('Gujjar', 67)]

# If we want only the top one result to be # returned:
process.extractOne(query, choices)
# ('Gujarat', 92)

让我们看看另一个例子。在这里,我们将 Bangalore 拼写错误为 Banglore – 我们缺少一个 a

query = 'Banglore'
choices = ['Bangalore', 'Bengaluru']
print(process.extract(query, choices))
# [('Bangalore', 94), ('Bengaluru', 59)]
process.extractOne(query, choices)
# ('Bangalore', 94)

让我们以一个在线购物中常见的搜索拼写错误为例。用户将 chilli 拼写为 chili;注意缺少的 l

query = 'chili'
choices = ['chilli', 'chilled', 'chilling']
print(process.extract(query, choices))
# [('chilli', 91), ('chilling', 77), ('chilled', 67)]
process.extractOne(query, choices)
# ('chilli', 91)

Jellyfish

Jellyfish 支持几乎所有流行编辑距离函数的合理快速实现(回想一下编辑距离函数是如何告诉你两个序列/字符串相似度的)。虽然 FuzzyWuzzy 主要支持 Levenshtein 距离,但这个包支持一些更多的字符串比较工具:

  • Levenshtein 距离

  • Damerau-Levenshtein 距离

  • Jaro 距离

  • Jaro-Winkler 距离

  • 匹配评分方法比较

  • Hamming 距离

此外,它还支持英语的音标编码

使用以下代码在您的机器上安装 Jellyfish:

import sys
# !{sys.executable} -m pip install jellyfish

让我们尝试导入包并设置一些示例来尝试:

import jellyfish

correct_example = ('Narendra Modi', 'Narendra Modi')
damodardas_example = ('Narendra Modi', 'Narendra D. Modi')
modi_typo_example = ('Narendra Modi', 'Narendar Modi')
gujarat_typo_example = ('Gujarat', 'Gujrat')

examples = [correct_example, damodardas_example, modi_typo_example, gujarat_typo_example]

我们希望尝试所有示例中的多个距离函数。更聪明的方法是为此构建一个实用函数。我们现在就来做这件事:

def calculate_distance(function, examples=examples):
    for ele in examples:
        print(f'{ele}: {function(*ele)}')

注意calculate_distance函数接受距离函数作为输入。我们可以将examples保留为从我们在全局命名空间中之前声明的什么中隐式选择。

Levenshtein距离,这可能是最著名的字符串相似度函数,有时与编辑距离函数同义,但我们将这视为编辑距离函数家族的一个特定实现:

calculate_distance(jellyfish.levenshtein_distance) 
# ('Narendra Modi', 'Narendra Modi'): 0
# ('Narendra Modi', 'Narendra D. Modi'): 3
# ('Narendra Modi', 'Narendar Modi'): 2
# ('Gujarat', 'Gujrat'): 1

Damerau–Levenshtein距离在Levenshtein编辑操作(插入、删除和替换)中添加了交换。让我们尝试这个,看看是否对我们有什么影响:

calculate_distance(jellyfish.damerau_levenshtein_distance)
# ('Narendra Modi', 'Narendra Modi'): 0
# ('Narendra Modi', 'Narendra D. Modi'): 3
# ('Narendra Modi', 'Narendar Modi'): 1
# ('Gujarat', 'Gujrat'): 1

我们注意到NarendraNarendar的距离值从3变为2。这是因为我们现在至少将ar或反之进行交换。其他字符是替换,所以1+1=2。

我们接下来要尝试的距离函数是汉明距离。这计算了将一个字符串转换为另一个字符串所需的最小替换次数:

calculate_distance(jellyfish.hamming_distance)
# ('Narendra Modi', 'Narendra Modi'): 0
# ('Narendra Modi', 'Narendra D. Modi'): 7
# ('Narendra Modi', 'Narendar Modi'): 2
# ('Gujarat', 'Gujrat'): 4

Jaro和Jaro-Winkler返回相似度值——而不是不相似度。这意味着完美匹配返回1.0,而完全不相关的匹配往往会接近0:

calculate_distance(jellyfish.jaro_distance)
# ('Narendra Modi', 'Narendra Modi'): 1.0
# ('Narendra Modi', 'Narendra D. Modi'): 0.9375
# ('Narendra Modi', 'Narendar Modi'): 0.9743589743589745
# ('Gujarat', 'Gujrat'): 0.8968253968253969

尝试Jaro相似度的另一种变体,即Jaro-Winkler,我们得到以下结果:

calculate_distance(jellyfish.jaro_winkler)
# ('Narendra Modi', 'Narendra Modi'): 1.0
# ('Narendra Modi', 'Narendra D. Modi'): 0.9625
# ('Narendra Modi', 'Narendar Modi'): 0.9846153846153847
# ('Gujarat', 'Gujrat'): 0.9277777777777778

这些技术非常实用且多样化。然而,它们对书面文本的过度强调为英语创造了一个独特的问题。我们不会像说话那样写英语。这意味着我们没有捕捉到所有相似性的范围。为了解决这个挑战,这是在非母语英语使用者的聊天机器人中通常遇到的挑战,我们可以查看单词的音位相似性,这正是我们接下来要做的。

音位词相似度

我们说一个词的方式构成了它的音位。音位是语音信息。例如,在许多源自英国口音中,如印度口音中,soul和sole听起来相同。

很常见,单词可能因为打字员试图让它听起来正确而稍微拼错。在这种情况下,我们利用这种音位信息将这个错误拼写的单词映射回正确的拼写。

什么是音位编码?

我们可以将一个单词转换为其发音的表示。当然,这可能会因口音和转换技术而有所不同。

然而,随着时间的推移,已经出现了两种或三种流行的方法,我们可以这样做。每种方法都接受一个字符串并返回一个编码表示。我鼓励您在Google上搜索这些术语:

  • 美国Soundex(20世纪30年代):在流行的数据库软件中实现,如PostgreSQL、MySQL和SQLite

  • NYSIIS(纽约州身份和情报系统)(20世纪70年代

  • Metaphone(20世纪90年代)

  • 匹配评分码(20世纪初期

让我们快速预览一下同样的内容:

jellyfish.soundex('Jellyfish')
# 'J412'

对于NYSIIS,我们将使用以下方法:

jellyfish.nysiis('Jellyfish')
# 'JALYF'

使用稍微更新一点的 metaphone,我们得到以下输出:

jellyfish.metaphone('Jellyfish')
# 'JLFX'

匹配率编码器给出了以下输出:

jellyfish.match_rating_codex('Jellyfish')
# 'JLYFSH'

我们现在可以使用之前看到的字符串比较工具来比较两个字符串的音位。

Metaphone + Levenshtein

例如,writeright 应该具有零音位 Levenshtein 距离,因为它们的发音相同。让我们来试试看:

jellyfish.levenshtein_distance(jellyfish.metaphone('write'), jellyfish.metaphone('right'))# 

这正如预期的那样工作。让我们将一些例子添加到我们的旧例子列表中:

examples+= [('write', 'right'), ('Mangalore', 'Bangalore'), ('Delhi', 'Dilli')] # adding a few examples to show how cool this is

让我们将这个封装成一个实用函数,就像我们之前做的那样。现在我们将使用两个函数参数:phonetic_funcdistance_func

def calculate_phonetic_distance(phonetic_func, distance_func, examples=examples):
    print("Word\t\tSound\t\tWord\t\t\tSound\t\tPhonetic Distance")
    for ele in examples:
        correct, typo = ele[0], ele[1]
        phonetic_correct, phonetic_typo = phonetic_func(correct), phonetic_func(typo)
        phonetic_distance = distance_func(phonetic_correct, phonetic_typo)
        print(f'{correct:<10}\t{phonetic_correct:<10}\t{typo:<20}\t{phonetic_typo:<10}\t{phonetic_distance:<10}') 

calculate_phonetic_distance(phonetic_func=jellyfish.metaphone, distance_func=jellyfish.levenshtein_distance)        

这将返回以下表格:

Word               Sound           Word                    Sound           Phonetic Distance
Narendra Modi   NRNTR MT        Narendra Modi           NRNTR MT        0         
Narendra Modi   NRNTR MT        Narendra D. Modi        NRNTR T MT      2         
Narendra Modi   NRNTR MT        Narendar Modi           NRNTR MT        0         
Gujarat         KJRT            Gujrat                  KJRT            0         
write           RT              right                   RT              0         
Mangalore       MNKLR           Bangalore               BNKLR           1         
Delhi           TLH             Dilli                   TL              1         

注意德里(Delhi)和达利(Dilli)被分开,这并不好。另一方面,纳伦德拉(Narendra)和纳伦达尔(Narendar)被标记为零编辑距离相似,这相当酷。让我们尝试不同的技术,看看效果如何。

美国 Soundex

我们注意到 Soundex 能够识别常见的发音相似的单词,并为它们提供单独的音位编码。这使得我们可以区分 rightwrite

这只适用于美国/英语单词。例如,印度名字如 Narendra ModiNarendra D. Modi 现在被认为是相似的:

calculate_phonetic_distance(phonetic_func=jellyfish.soundex, distance_func=jellyfish.levenshtein_distance)        

注意以下表格中与之前代码的变化:

Word            Sound           Word                    Sound           Phonetic Distance
Narendra Modi   N653            Narendra Modi           N653            0         
Narendra Modi   N653            Narendra D. Modi        N653            0         
Narendra Modi   N653            Narendar Modi           N653            0         
Gujarat         G263            Gujrat                  G263            0         
write           W630            right                   R230            2         
Mangalore       M524            Bangalore               B524            1         
Delhi           D400            Dilli                   D400            0    

运行时间复杂度

我们现在有找到单词正确拼写或标记它们为相似的能力。在处理大型语料库时,我们可以提取所有唯一的单词,并将每个标记与每个其他标记进行比较。

这将需要 O(n²),其中 n 是语料库中唯一标记的数量。这可能会使大型语料库的处理过程变得太慢。

另一个选择是使用标准词典,并扩展你的语料库。如果词典有 m 个独特的单词,这个过程现在将是 O(m∗n)。假设 m << n,这将比之前的方法快得多。

使用 FlashText 清理语料库

但是,对于一个包含数百万份文档和数千个关键词的网页规模语料库呢?由于正则表达式的线性时间复杂度,它可能需要几天时间才能完成这样的精确搜索。我们如何改进这一点?

我们可以使用 FlashText 来处理这个非常具体的用例:

  • 几百万份文档和数千个关键词

  • 精确关键词匹配 - 要么替换,要么搜索这些关键词的存在

当然,这个问题有几种不同的可能解决方案。我推荐这个方案,因为它简单,专注于解决一个问题。它不需要我们学习新的语法或设置特定的工具,如 ElasticSearch。

以下表格展示了使用 Flashtext 与编译正则表达式进行搜索的比较:

图片

以下表格展示了使用 FlashText 与编译正则表达式进行替换的比较:

图片

我们注意到,虽然正则表达式的运行时间几乎呈线性增长,但Flashtext则相对平稳。现在,我们知道我们需要Flashtext来提高速度和扩展性。FlashText得到了社区的广泛喜爱。使用者包括NLProc – 来自国家卫生研究院的自然语言处理预处理工具包。

按照以下说明将FlashText安装到您的机器上。

首先,我们将在我们的conda环境中安装pip。我们将从我们的笔记本中这样做:

# import sys
# !{sys.executable} -m pip install flashtext

FlashText的源代码可在GitHub上找到(https://github.com/PacktPublishing/Natural-Language-Processing-with-Python-Quick-Start/tree/master/Chapter02),文档导航和使用都很简单。在这里,我们只考虑两个基本示例。让我们找出在语料库中查找关键词的语法:

from flashtext.keyword import KeywordProcessor
keyword_processor = KeywordProcessor()
keyword_processor.add_keyword('Delhi', 'NCR') # notice we are adding tuples here
keyword_processor.add_keyword('Bombay', 'Mumbai')
keywords_found = keyword_processor.extract_keywords('I love the food in Delhi and the people in Bombay')
keywords_found
# ['NCR', 'Mumbai']

现在我们就替换它们怎么样?

from flashtext.keyword import KeywordProcessor
keyword_processor = KeywordProcessor()
keyword_processor.add_keyword('Delhi', 'NCR')
keyword_processor.add_keyword('Bombay', 'Mumbai')
replaced_sentence = keyword_processor.replace_keywords('I love the food in Delhi and the people in Bombay')
replaced_sentence
# 'I love the food in NCR and the people in Mumbai'

不幸的是,目前FlashText只支持英语。正则表达式可以根据特殊字符如^,$,*,\d来搜索关键词,这些在FlashText中是不支持的。因此,为了匹配像word\dvec这样的部分单词,我们仍然需要使用正则表达式。然而,FlashText在提取完整的单词如word2vec方面仍然非常出色。

摘要

本章涵盖了大量的新内容。我们首先对文本进行了语言处理。我们遇到了spaCy,随着我们在本书中的深入,我们将继续深入研究。我们涵盖了以下语言学基础概念:分词(使用和未使用spaCy进行),去除停用词,大小写标准化,词形还原(我们跳过了词干提取) – 使用spaCy及其特性,如*-PRON-*

但我们除了进行文本清理之外,还能用spaCy做什么?我们能构建一些东西吗?当然可以!

我们不仅可以用spaCy的管道扩展我们的简单语言文本清理,还可以进行词性标注、命名实体识别和其他常见任务。我们将在下一章中探讨这一点。

我们探讨了拼写纠正或最接近的单词匹配问题。在这个背景下,我们讨论了FuzzyWuzzyJellyfish。为了确保我们可以扩展到超过几百个关键词,我们还研究了FlashText。我鼓励您深入研究这些优秀的库,以了解最佳软件工程实践。

在下一章中,我们将与其他语言工具结合,构建一个端到端的玩具程序。

第三章:利用语言学

在本章中,我们将选择一个简单的用例,看看我们如何解决它。然后,我们再次执行这个任务,但是在一个略微不同的文本语料库上。

这有助于我们了解在自然语言处理中使用语言学时的构建直觉。在这里,我将使用spaCy,但您可以使用NLTK或等效工具。它们的API和风格存在程序性差异,但基本主题保持不变。

在上一章中,我们第一次尝试处理自由文本。具体来说,我们学习了如何将文本标记为单词和句子,使用正则表达式进行模式匹配,以及进行快速替换。

通过做所有这些,我们以文本的字符串作为主要表示形式。在本章中,我们将使用语言语法作为主要表示形式。

在本章中,我们将学习以下主题:

  • spaCy,工业用自然语言库

  • 自然语言处理管道,以及一些英语语法

  • 关于我们可以用语言学做什么的现实生活示例

语言学与自然语言处理

This部分致力于向您介绍在语言学几十年的发展中一直存在的思想和工具。介绍这种思想的最传统方式是先提出一个想法,详细地讨论它,然后把这些都放在一起。

这里,我将反过来这样做。我们将解决两个问题,在这个过程中,我们将查看我们将使用的工具。而不是向您介绍一个8号扳手,我给您提供了一个汽车引擎和工具,我将在使用它们时介绍这些工具。

大多数自然语言处理任务都是在顺序管道中解决的,一个组件的结果会输入到下一个组件。

存储管道结果和中间步骤的数据结构种类繁多。在这里,为了简单起见,我将只使用spaCy中已有的以及原生的Python数据结构,如列表和字典。

在这里,我们将解决以下源于现实生活的挑战:

  • 从任何文档中删除姓名,例如,为了符合GDPR规定

  • 从任何文本中制作测验,例如,从维基百科文章中

开始使用

您可以通过condapip安装spaCy。由于我处于conda环境中,我将使用conda安装,如下所示:

# !conda install -y spacy 
# !pip install spacy

让我们下载spaCy提供的英语语言模型。我们将使用en_core_web_lg(末尾的lg代表大型)。这意味着这是spaCy为通用目的发布的最全面和性能最好的模型。

您只需做一次:

!python -m spacy download en_core_web_lg

如果您在下载时遇到任何错误,您可以使用较小的模型代替。

对于Windows Shell,您可以使用管理员权限执行python -m spacy download en。从Linux终端,您可以使用sudo python -m spacy download en

让我们先处理一下导入:

import spacy
from spacy import displacy # for visualization
nlp = spacy.load('en_core_web_lg')
spacy.__version__

我在这里使用的是来自conda的version 2.0.11版本,但您可以使用任何高于2.0.x的版本。

介绍textacy

Textacy是一套非常被低估的工具集,它围绕spaCy构建。其标语清楚地告诉你它做什么:NLP,spaCy前后。它实现了使用spaCy底层工具的工具,从用于生产的数据流实用程序到高级文本聚类功能。

你可以通过pipconda安装textacy。在conda中,它可在conda-forge频道而不是主conda频道中找到。我们通过添加-c标志和之后的频道名称来实现这一点:

# !conda install -c conda-forge textacy 
# !pip install textacy
import textacy

既然我们已经完成了设置并且安装问题已经解决,那么让我们为下一节中的挑战做好准备。

使用命名实体识别进行姓名红字

本节挑战在于将自由文本中所有的人名替换为[REDACTED]。

假设你是欧洲银行公司的初级工程师。为了准备通用数据保护条例GDPR),银行正在从所有旧记录和特殊内部通讯(如电子邮件和备忘录)中清除客户的姓名。他们要求你这么做。

你可以采取的第一种方法是查找客户的姓名,并将每个姓名与所有电子邮件进行匹配。这可能会非常慢且容易出错。例如,假设银行有一个名叫John D'Souza的客户——你可能会在电子邮件中简单地称他为DSouza,因此D'Souza的精确匹配永远不会从系统中清除。

在这里,我们将使用自动NLP技术来帮助我们。我们将使用spaCy解析所有电子邮件,并将所有人的姓名简单地替换为标记[REDACTED]。这将至少比匹配数百万个子字符串快5-10倍。

我们将使用《哈利·波特与密室》的一小段摘录作为例子,讨论流感:

text = "Madam Pomfrey, the nurse, was kept busy by a sudden spate of colds among the staff and students. Her Pepperup potion worked instantly, though it left the drinker smoking at the ears for several hours afterward. Ginny Weasley, who had been looking pale, was bullied into taking some by Percy."

让我们用spaCy解析文本。这运行了整个NLP管道:

doc = nlp(text)

doc现在包含文本的解析版本。我们可以用它来做任何我们想做的事情!例如,以下命令将打印出所有检测到的命名实体:

for entity in doc.ents:
    print(f"{entity.text} ({entity.label_})")
Pomfrey (PERSON)
Pepperup (ORG)
several hours (TIME)
Ginny Weasley (PERSON)
Percy (PERSON)

spaCy对象doc有一个名为ents的属性,用于存储所有检测到的实体。为了找到这个,spaCy为我们做了几件幕后工作,例如:

  • 句子分割,将长文本分割成更小的句子

  • 分词,将每个句子分割成单独的单词或标记

  • 移除停用词,移除像a, an, the,of这样的词

  • 命名实体识别,用于统计技术,以找出文本中的哪些实体,并用实体类型进行标记

让我们快速看一下doc对象:

doc.ents
> (Pomfrey, Pepperup, several hours, Ginny Weasley, Percy)

doc对象有一个名为ents的特定对象,简称实体。我们可以使用这些来查找文本中的所有实体。此外,每个实体都有一个标签:

在 spaCy 中,所有信息都是通过数字哈希存储的。因此,entity.label 将是一个数字条目,例如 378,而 entity.label_ 将是可读的,例如,PERSON

entity.label, entity.label_
> (378, 'PERSON')

在 spaCy 中,所有可读的标签也可以使用简单的 spacy.explain(label) 语法进行解释:

spacy.explain('GPE')
> 'Countries, cities, states'

使用 spaCy 的 NER,让我们编写一个简单的函数来将每个 PERSON 名称替换为 [已删除]:

def redact_names(text):
    doc = nlp(text)
    redacted_sentence = []
    for token in doc:
        if token.ent_type_ == "PERSON":
            redacted_sentence.append("[REDACTED]")
        else:
            redacted_sentence.append(token.string)
    return "".join(redacted_sentence)

函数接收一个字符串作为文本输入,并使用我们之前加载的 nlp 对象在 doc 对象中解析它。然后,它遍历文档中的每个标记(记得标记化吗?)。每个标记都被添加到一个列表中。如果标记具有人的实体类型,则将其替换为[已删除]。

最后,我们通过将这个列表转换回字符串来重建原始句子:

作为练习,尝试直接编辑原始字符串来完成这个挑战,而不是创建一个新的字符串。

redact_names(text)

> 'Madam [REDACTED], the nurse, was kept busy by a sudden spate of colds among the staff and students. Her Pepperup potion worked instantly, though it left the drinker smoking at the ears for several hours afterward. [REDACTED][REDACTED], who had been looking pale, was bullied into taking some by [REDACTED]

如果您试图进行符合 GDPR 的编辑,前面的输出仍然是一个漏洞百出的水龙头。通过使用两个 [已删除] 块而不是一个,我们正在披露姓名中的单词数量。如果我们将此用于其他上下文,例如删除地点或组织名称,这可能会非常有害。

让我们修复这个问题:

def redact_names(text):
    doc = nlp(text)
    redacted_sentence = []
    for ent in doc.ents:
        ent.merge()
    for token in doc:
        if token.ent_type_ == "PERSON":
            redacted_sentence.append("[REDACTED]")
        else:
            redacted_sentence.append(token.string)
    return "".join(redacted_sentence)

我们通过从管道中单独合并实体来实现这一点。注意,这里有两行额外的代码,它们在所有找到的实体上调用 ent.merge()ent.merge() 函数将每个 实体 中的所有标记合并为一个单独的标记。这就是为什么它需要在每个实体上调用:

redact_names(text)
> 'Madam [REDACTED], the nurse, was kept busy by a sudden spate of colds among the staff and students. Her Pepperup potion worked instantly, though it left the drinker smoking at the ears for several hours afterward. [REDACTED], who had been looking pale, was bullied into taking some by [REDACTED].

实际上,这个输出仍然可能是不完整的。您可能想在这里删除性别,例如,女士。由于我们已经在披露称号,即 护士,透露性别使得阅读此文档的人(甚至机器)更容易推断出来。

练习:删除对姓名的任何性别代词。

提示:查找共指消解以帮助您完成此操作。

实体类型

spaCy 支持我们在 nlp 对象中加载的大语言模型中的以下实体类型:

类型 描述
包括虚构人物
NORP 国籍或宗教或政治团体
FAC 建筑物、机场、高速公路、桥梁等
组织 公司、机构、机构等
GPE 国家、城市、州
地点 非GPE地点、山脉、水体
产品 物体、车辆、食物等(不包括服务)
事件 命名的飓风、战役、战争、体育赛事等
艺术作品 书籍、歌曲等的标题
法律 被制成法律的命名文件
语言 任何命名语言
日期 绝对或相对日期或时间段
时间 小于一天的时间单位
百分比 包括 %
货币 包括单位的货币值
数量 如重量或距离的度量
序数词 第一第二
基数 不属于其他类型的数词

让我们看看一些现实世界句子中先前实体类型的例子。我们还将使用spacy.explain()来解释所有实体,以快速建立对这些事物如何工作的心理模型。

由于我懒惰,我会写一个可以反复使用的函数,这样我就可以专注于学习,而不是为不同的例子调试代码:

def explain_text_entities(text):
    doc = nlp(text)
    for ent in doc.ents:
        print(f'{ent}, Label: {ent.label_}, {spacy.explain(ent.label_)}')

让我们先用几个简单的例子试一试:

explain_text_entities('Tesla has gained 20% market share in the months since')

Tesla, Label: ORG, Companies, agencies, institutions, etc.
20%, Label: PERCENT, Percentage, including "%"
the months, Label: DATE, Absolute or relative dates or periods

让我们看看一个稍微长一点的句子和东方例子:

explain_text_entities('Taj Mahal built by Mughal Emperor Shah Jahan stands tall on the banks of Yamuna in modern day Agra, India')

Taj Mahal, Label: PERSON, People, including fictional
Mughal, Label: NORP, Nationalities or religious or political groups
Shah Jahan, Label: PERSON, People, including fictional
Yamuna, Label: LOC, Non-GPE locations, mountain ranges, bodies of water
Agra, Label: GPE, Countries, cities, states
India, Label: GPE, Countries, cities, states

有趣的是,模型将泰姬陵弄错了。泰姬陵显然是一座世界著名的纪念碑。然而,模型犯了一个可信的错误,因为泰姬陵也是一位蓝调音乐家的艺名。

在大多数生产用例中,我们使用自己的注释来对内置的spaCy模型进行特定语言的微调。这将教会模型,对我们来说,泰姬陵几乎总是指一座纪念碑,而不是一位蓝调音乐家。

让我们看看模型在其他例子中是否会重复这些错误:

explain_text_entities('Ashoka was a great Indian king')
Ashoka, Label: PERSON, People, including fictional
Indian, Label: NORP, Nationalities or religious or political groups

让我们尝试一个不同含义的阿育王的不同句子:

explain_text_entities('The Ashoka University sponsors the Young India Fellowship')
Ashoka University, Label: ORG, Companies, agencies, institutions, etc.
the Young India Fellowship, Label: ORG, Companies, agencies, institutions, etc.

在这里,spaCy能够利用单词大学来推断Ashoka是一个组织的名字,而不是印度历史上的阿育王。

它还确定Young India Fellowship是一个逻辑实体,并且没有将India标记为地点。

看几个这样的例子有助于形成关于我们能做什么和不能做什么的心理模型。

自动问题生成

你能自动将一个句子转换成问题吗?

例如,马丁·路德·金是一位民权活动家和熟练的演说家,变为马丁·路德·金是谁?

注意,当我们把一个句子转换成问题,答案可能不再在原始句子中。对我来说,那个问题的答案可能不同,这没关系。我们在这里不是追求答案。

词性标注

有时,我们希望快速从大量文本中提取关键词或关键短语。这有助于我们在心理上描绘出文本的主题。这在分析文本,如长电子邮件或论文时尤其有用。

作为一种快速的方法,我们可以提取所有相关的名词。这是因为大多数关键词实际上是以某种形式存在的名词:

example_text = 'Bansoori is an Indian classical instrument. Tom plays Bansoori and Guitar.'
doc = nlp(example_text)

我们需要名词块。名词块是名词短语——不是单个单词,而是一个短语,用来描述名词。例如,蓝天世界最大的企业集团

要从文档中获取名词块,只需迭代doc.noun_chunks

for idx, sentence in enumerate(doc.sents):
    for noun in sentence.noun_chunks:
        print(f'sentence{idx+1}', noun)

sentence1 Bansoori
sentence1 an Indian classical instrument
sentence2 Tom
sentence2 Bansoori
sentence2 Guitar

我们的示例文本有两个句子,我们可以从每个句子中提取名词短语块。我们将提取名词短语而不是单个单词。这意味着我们能够提取一个印度古典乐器作为一个名词。这非常有用,我们将在稍后看到原因。

接下来,让我们快速浏览一下示例文本中所有的词性标签。我们将使用动词和形容词来编写一些简单的生成问题的逻辑:

for token in doc:
    print(token, token.pos_, token.tag_)

Bansoori PROPN NNP
is VERB VBZ
an DET DT
Indian ADJ JJ
classical ADJ JJ
instrument NOUN NN
. PUNCT .
Tom PROPN NNP
plays VERB VBZ
Bansoori PROPN NNP
and CCONJ CC
Guitar PROPN NNP
. PUNCT .

注意,在这里,工具被标记为名词,而印度人古典的被标记为形容词。这很有道理。此外,班苏里吉他被标记为专有名词,或称PROPN。

普通名词与专有名词的区别:名词用来命名人、地点和事物。普通名词用来命名一般项目,例如服务员、牛仔裤和国家。专有名词用来命名特定事物,例如罗杰、李维斯和印度。

创建一个规则集

在使用语言学时,你经常会编写自定义规则。这里有一个数据结构建议,可以帮助你存储这些规则:字典列表。每个字典可以包含从简单的字符串列表到字符串列表的各种元素。避免在字典内部嵌套字典列表:

ruleset = [
    {
        'id': 1, 
        'req_tags': ['NNP', 'VBZ', 'NN'],
    }, 
    {
        'id': 2, 
        'req_tags': ['NNP', 'VBZ'],
    }
    ]

在这里,我编写了两个规则。每个规则只是存储在req_tags键下的词性标签集合。每个规则由我将要在特定句子中查找的所有标签组成。

根据id,我将使用硬编码的问题模板来生成我的问题。在实践中,你可以也应该将问题模板移动到你的规则集中。

接下来,我需要一个函数来提取所有与特定标签匹配的标记。我们通过简单地遍历整个列表并匹配每个标记与目标标签来完成这项工作:

def get_pos_tag(doc, tag):
    return [tok for tok in doc if tok.tag_ == tag]

关于运行时复杂度:

这很慢,是O(n)。作为一个练习,你能想到一种方法将其减少到O(1)吗?

提示:你可以预先计算一些结果并将它们存储起来,但这会以更多的内存消耗为代价。

接下来,我将编写一个函数来使用前面的规则集,并使用问题模板。

这里是我将为每个句子遵循的广泛概述:

  • 对于每个规则ID,检查所有必需的标签(req_tags)是否满足条件

  • 找到第一个匹配的规则ID

  • 找到匹配所需词性标签的单词

  • 填写相应的问题模板并返回问题字符串

def sent_to_ques(sent:str)->str:
    """
    Return a question string corresponding to a sentence string using a set of pre-written rules
    """
    doc = nlp(sent)
    pos_tags = [token.tag_ for token in doc]
    for idx, rule in enumerate(ruleset):
        if rule['id'] == 1:
            if all(key in pos_tags for key in rule['req_tags']): 
                print(f"Rule id {rule['id']} matched for sentence: {sent}")
                NNP = get_pos_tag(doc, "NNP")
                NNP = str(NNP[0])
                VBZ = get_pos_tag(doc, "VBZ")
                VBZ = str(VBZ[0])
                ques = f'What {VBZ} {NNP}?'
                return(ques)
        if rule['id'] == 2:
            if all(key in pos_tags for key in rule['req_tags']): #'NNP', 'VBZ' in sentence.
                print(f"Rule id {rule['id']} matched for sentence: {sent}")
                NNP = get_pos_tag(doc, "NNP")
                NNP = str(NNP[0])
                VBZ = get_pos_tag(doc, "VBZ")
                VBZ = str(VBZ[0].lemma_)
                ques = f'What does {NNP} {VBZ}?'
                return(ques)

在每个规则ID匹配中,我会做更多的事情:我会丢弃除了第一个匹配之外的所有匹配项,对于我收到的每个词性标签。例如,当我查询NNP时,我稍后会选择带有NNP[0]的第一个元素,将其转换为字符串,并丢弃所有其他匹配项。

虽然这对于简单句子来说是一个非常好的方法,但当遇到条件语句或复杂推理时,这种方法就会失效。让我们运行前面函数中的每个句子,看看我们会得到什么问题:

for sent in doc.sents:
    print(f"The generated question is: {sent_to_ques(str(sent))}")

Rule id 1 matched for sentence: Bansoori is an Indian classical instrument.
The generated question is: What is Bansoori?
Rule id 2 matched for sentence: Tom plays Bansoori and Guitar.
The generated question is: What does Tom play?

这相当不错。在实践中,你可能需要一个更大的集合,可能需要10-15个规则集和相应的模板,才能对什么?问题有足够的覆盖。

可能还需要几个规则集来涵盖何时哪里类型的问题。例如,谁演奏班苏里?也是从前面代码中的第二个句子中得出的有效问题。

使用依存句法进行问答生成

这意味着词性标注和基于规则的引擎在处理这些问题时可以具有很大的覆盖率和合理的精确度,但维护、调试和泛化这个系统仍然会有些繁琐。

我们需要一个更好的工具集,它更少依赖于标记的状态,而更多地依赖于它们之间的关系。这将允许你改变关系来形成问题。这就是依存句法分析的作用所在。

什么是依存句法分析器?

"依存句法分析器分析句子的语法结构,建立“头部”词与修饰这些头部词的词之间的关系。"

依存句法分析器帮助我们理解句子各部分之间相互作用的多种方式。例如,名词是如何被形容词修饰的?

for token in doc:
    print(token, token.dep_)

Bansoori nsubj
is ROOT
an det
Indian amod
classical amod
instrument attr
. punct
Tom nsubj
plays ROOT
Bansoori dobj
and cc
Guitar conj
. punct

其中一些术语足够简单,可以猜测,例如,ROOT是依存树可能开始的地方,nsubj是名词或名词主语,而cc是连词。然而,这仍然是不完整的。幸运的是,spaCy包括了巧妙的explain()函数来帮助我们解释这些:

for token in doc:
    print(token, token.dep_, spacy.explain(token.dep_))

这给我们以下解释性文本:

Bansoori nsubj nominal subject
is ROOT None
an det determiner
Indian amod adjectival modifier
classical amod adjectival modifier
instrument attr attribute
. punct punctuation
Tom nsubj nominal subject
plays ROOT None
Bansoori dobj direct object
and cc coordinating conjunction
Guitar conj conjunct
. punct punctuation

这为我们提供了一个很好的起点,可以Google一些语言学特定的术语。例如,conjunct通常用于连接两个子句,而attribute只是强调名词主语属性的一种方式。

名词主语通常是名词或代词,它们反过来又是行为者(通过动词)或具有属性(通过属性)。

可视化关系

spaCy有一个内置工具称为displacy,用于显示简单但清晰且强大的可视化。它提供两种主要模式:命名实体识别和依存句法。在这里,我们将使用dep,或依存模式:

displacy.render(doc, style='dep', jupyter=True)

让我们快速研究第一句话:我们可以看到instrumentamod,即由Indian classical形容词修饰。我们之前已经把这个短语作为名词块提取出来:

这意味着当我们从这个句子中提取名词短语块时,spaCy已经在幕后完成了依存句法分析。

此外,注意箭头的方向,当NOUN(instrument)被ADJ(形容词)修饰时。它是ROOT动词(is)的attr属性。

我把第二句话的依存可视化留给你来完成:

这简单句子的逻辑树结构是我们将利用来简化问题生成的。为此,我们需要两个重要的信息

  • 主要动词,也称为ROOT

  • 这个ROOT动词所作用的主语

让我们编写一些函数来提取这些依存实体,以spaCy标记格式,而不将它们转换为字符串。

介绍textacy

或者,我们可以从textacy本身导入它们:

from textacy.spacier import utils as spacy_utils

在Jupyter Notebook中,你可以通过在Jupyter Notebook本身中使用??语法来看到文档字符串和函数实现:

??spacy_utils.get_main_verbs_of_sent

# Signature: spacy_utils.get_main_verbs_of_sent(sent)
# Source:   
# def get_main_verbs_of_sent(sent):
#     """Return the main (non-auxiliary) verbs in a sentence."""
#     return [tok for tok in sent
#             if tok.pos == VERB and tok.dep_ not in constants.AUX_DEPS]
# File:      d:\miniconda3\envs\nlp\lib\site-packages\textacy\spacier\utils.py
# Type:      function

通常,当你问某人一个问题,他们通常是在询问一些信息,例如,印度的首都是什么?有时,他们也可能是在询问某个特定的行为,例如,你周日做了什么?

回答“什么”意味着我们需要找出动词所作用的对象。这意味着我们需要找到动词的主语。让我们用一个更具体但简单的例子来探讨这个问题:

toy_sentence = 'Shivangi is an engineer'
doc = nlp(toy_sentence)

这句话中的实体是什么?

displacy.render(doc, style='ent', jupyter=True)

之前提到的例子可能会为较小的en模型返回ORG。这就是为什么使用en_core_web_lg很重要的原因。它提供了更好的性能。

让我们尝试柏林维基百科条目的前几行:

displacy.render(nlp("Berlin, German pronunciation: [bɛɐ̯ˈliːn]) is the capital and the largest city of Germany, as well as one of its 16 constituent states. With a steadily growing population of approximately 3.7 million, Berlin is the second most populous city proper in the European Union behind London and the seventh most populous urban area in the European Union"), style='ent', jupyter=True)

让我们找出这个句子中的主要动词:

verbs = spacy_utils.get_main_verbs_of_sent(doc)
print(verbs)
>> [is]

那么,这个动词的名词主语是什么?

for verb in verbs:
    print(verb, spacy_utils.get_subjects_of_verb(verb))
>> is [Shivangi]

你会注意到这合理地重叠了我们从词性标注中提取的名词短语。然而,也有一些是不同的:

print([(token, token.tag_) for token in doc])
>>[(Shivangi, 'NNP'), (is, 'VBZ'), (an, 'DT'), (engineer, 'NN')]

作为练习,将这种方法扩展到至少添加哪里何时问题,这是一个最佳实践。

提升水平——提问和回答

到目前为止,我们一直在尝试生成问题。但如果你试图为学生制作自动测验,你还需要挖掘正确的答案。

在这种情况下,答案将是动词的宾语。动词的宾语是什么?

在句子“Give the book to me”中,“book”是动词“give”的直接宾语,“me”是间接宾语。

– 来自剑桥英语词典

拉丁语中,宾语是动词作用的对象。这几乎总是我们“什么”问题的答案。让我们写一个问题来找到任何动词的宾语——或者,我们可以从textacy.spacier.utils.中提取它:

spacy_utils.get_objects_of_verb(verb)
>> [engineer]

让我们对所有的动词都这样做:

for verb in verbs:
    print(verb, spacy_utils.get_objects_of_verb(verb))
>> is [engineer]

让我们看看我们的函数从示例文本中的输出。首先是句子本身,然后是根动词,然后是那个动词的词形,接着是动词的主语,最后是动词的宾语:

doc = nlp(example_text)
for sentence in doc.sents:
    print(sentence, sentence.root, sentence.root.lemma_, spacy_utils.get_subjects_of_verb(sentence.root), spacy_utils.get_objects_of_verb(sentence.root))

>> Bansoori is an Indian classical instrument. is be [Bansoori] [instrument]
>> Tom plays Bansoori and Guitar. plays play [Tom] [Bansoori, Guitar]

让我们把前面的信息整理成一个整洁的函数,然后我们可以重用它:

def para_to_ques(eg_text):
    doc = nlp(eg_text)
    results = []
    for sentence in doc.sents:
        root = sentence.root
        ask_about = spacy_utils.get_subjects_of_verb(root)
        answers = spacy_utils.get_objects_of_verb(root)

        if len(ask_about) > 0 and len(answers) > 0:
            if root.lemma_ == "be":
                question = f'What {root} {ask_about[0]}?'
            else:
                question = f'What does {ask_about[0]} {root.lemma_}?'
            results.append({'question':question, 'answers':answers})
    return results

让我们在我们的示例文本上运行它,看看它会去哪里:

para_to_ques(example_text)
>> [{'question': 'What is Bansoori?', 'answers': [instrument]},
>> {'question': 'What does Tom play?', 'answers': [Bansoori, Guitar]}]

这在我看来似乎是正确的。让我们在更大的句子样本上运行这个。这个样本具有不同复杂程度和句子结构:

large_example_text = """
Puliyogare is a South Indian dish made of rice and tamarind. 
Priya writes poems. Shivangi bakes cakes. Sachin sings in the orchestra.

Osmosis is the movement of a solvent across a semipermeable membrane toward a higher concentration of solute. In biological systems, the solvent is typically water, but osmosis can occur in other liquids, supercritical liquids, and even gases.
When a cell is submerged in water, the water molecules pass through the cell membrane from an area of low solute concentration to high solute concentration. For example, if the cell is submerged in saltwater, water molecules move out of the cell. If a cell is submerged in freshwater, water molecules move into the cell.

Raja-Yoga is divided into eight steps. The first is Yama. Yama is nonviolence, truthfulness, continence, and non-receiving of any gifts.
After Yama, Raja-Yoga has Niyama. cleanliness, contentment, austerity, study, and self - surrender to God.
The steps are Yama and Niyama. 
"""

让我们在整个大型示例文本上运行它:

para_to_ques(large_example_text)

>> [{'question': 'What is Puliyogare?', 'answers': [dish]},
 {'question': 'What does Priya write?', 'answers': [poems]},
 {'question': 'What does Shivangi bake?', 'answers': [cakes]},
 {'question': 'What is Osmosis?', 'answers': [movement]},
 {'question': 'What is solvent?', 'answers': [water]},
 {'question': 'What is first?', 'answers': [Yama]},
 {'question': 'What is Yama?',
  'answers': [nonviolence, truthfulness, continence, of]},
 {'question': 'What does Yoga have?', 'answers': [Niyama]},
 {'question': 'What are steps?', 'answers': [Yama, Niyama]}]

整合并结束

语言学非常强大。我在这里只给你尝到了它巨大效用的一小部分。我们研究了两个激励用例和许多强大的想法。对于每个用例,我在这里列出了相关的想法:

    • 修改姓名:

      • 命名实体识别
    • 提问和回答生成:

      • 词性标注

      • 词形还原

      • 依存句法分析

摘要

我们现在有了一种生成问题和答案的方法。你打算问用户什么问题?你能将我们的答案与用户的答案进行匹配吗?

当然,精确匹配是完美的。但我们也应该寻找意义匹配,例如,蛋糕糕点,或者诚实真诚

我们可以使用同义词词典——但如何将其扩展到句子和文档中呢?

在下一章中,我们将使用文本表示来回答所有这些问题。

第四章:文本表示 - 将单词转换为数字

今天的计算机不能直接对单词或文本进行操作。它们需要通过有意义的数字序列来表示。这些长序列的十进制数字被称为向量,这一步骤通常被称为文本的向量化。

那么,这些词向量在哪里使用:

  • 在文本分类和摘要任务中

  • 在类似词的搜索中,例如同义词

  • 在机器翻译中(例如,将文本从英语翻译成德语)

  • 当理解类似文本时(例如,Facebook文章)

  • 在问答会话和一般任务中(例如,用于预约安排的聊天机器人)

非常频繁地,我们看到词向量在某种形式的分类任务中使用。例如,使用机器学习或深度学习模型进行情感分析,以下是一些文本向量化方法:

  • 在sklearn管道中使用逻辑回归的TF-IDF

  • 斯坦福大学的GLoVe,通过Gensim查找

  • Facebook的fastText使用预训练的向量

我们已经看到了TF-IDF的示例,并且在这本书的其余部分还将看到更多。本章将介绍其他可以将您的文本语料库或其部分向量的方法。

在本章中,我们将学习以下主题:

  • 如何向量化特定数据集

  • 如何制作文档嵌入

向量化特定数据集

本节几乎完全专注于词向量以及我们如何利用Gensim库来执行它们。

我们在本节中想要回答的一些问题包括这些:

  • 我们如何使用原始嵌入,如GloVe?

  • 我们如何处理词汇表外的单词?(提示:fastText)

  • 我们如何在我们自己的语料库上训练自己的word2vec向量?

  • 我们如何训练我们自己的word2vec向量?

  • 我们如何训练我们自己的fastText向量?

  • 我们如何使用相似词来比较上述两者?

首先,让我们从一些简单的导入开始,如下所示:

import gensim
print(f'gensim: {gensim.__version__}')
> gensim: 3.4.0

请确保您的Gensim版本至少为3.4.0。这是一个非常流行的包,主要由RaRe Technologies的文本处理专家维护和开发。他们在自己的企业B2B咨询工作中使用相同的库。Gensim的内部实现的大部分是用Cython编写的,以提高速度。它原生支持多进程。

这里需要注意的是,Gensim因其API的破坏性更改而闻名,因此在使用他们的文档或教程中的代码时,请考虑再次检查API。

如果你使用的是Windows机器,请注意以下类似的警告:

C:\Users\nirantk\Anaconda3\envs\fastai\lib\site-packages\Gensim\utils.py:1197: UserWarning: detected Windows; aliasing chunkize to chunkize_serial
 warnings.warn("detected Windows; aliasing chunkize to chunkize_serial")

现在,让我们开始下载预训练的GloVe嵌入。虽然我们可以手动完成这项工作,但在这里我们将使用以下Python代码来下载:

from tqdm import tqdm
class TqdmUpTo(tqdm):
    def update_to(self, b=1, bsize=1, tsize=None):
        if tsize is not None: self.total = tsize
        self.update(b * bsize - self.n)

def get_data(url, filename):
    """
    Download data if the filename does not exist already
    Uses Tqdm to show download progress
    """
    import os
    from urllib.request import urlretrieve

    if not os.path.exists(filename):

        dirname = os.path.dirname(filename)
        if not os.path.exists(dirname):
            os.makedirs(dirname)

        with TqdmUpTo(unit='B', unit_scale=True, miniters=1, desc=url.split('/')[-1]) as t:
            urlretrieve(url, filename, reporthook=t.update_to)

我们还将重用get_data API来下载我们想要在本节中使用的任何任意文件。我们还设置了tqdm(阿拉伯语意为进度),它通过将我们的urlretrieve可迭代对象包装在其中,为我们提供了一个进度条。

以下文本来自tqdm的README:

tqdm在Linux、Windows、Mac、FreeBSD、NetBSD、Solaris/SunOS等任何平台上工作,在任何控制台或GUI中,并且对IPython/Jupyter笔记本也很友好。

tqdm不需要任何依赖(甚至不是curses!),只需要Python和一个支持回车符\r和换行符\n控制字符的环境。

对了,我们终于可以下载嵌入文件了,不是吗?

embedding_url = 'http://nlp.stanford.edu/data/glove.6B.zip'
get_data(embedding_url, 'data/glove.6B.zip')

前面的代码片段将下载一个包含60亿个英语单词的GloVe词表示的大型文件。

让我们快速使用终端或Jupyter笔记本中的命令行语法解压文件。您也可以手动或通过编写代码来完成此操作,如下所示:

# # We need to run this only once, can unzip manually unzip to the data directory too
# !unzip data/glove.6B.zip 
# !mv glove.6B.300d.txt data/glove.6B.300d.txt 
# !mv glove.6B.200d.txt data/glove.6B.200d.txt 
# !mv glove.6B.100d.txt data/glove.6B.100d.txt 
# !mv glove.6B.50d.txt data/glove.6B.50d.txt

在这里,我们已经将所有的.txt文件移回到data目录。这里需要注意的是文件名,glove.6B.50d.txt

6B代表60亿个单词或标记。50d代表50个维度,这意味着每个单词都由一个由50个数字组成的序列表示,在这种情况下,那就是50个浮点数。

我们现在将稍微偏离一下,给您一些关于词表示的背景信息。

词表示

词嵌入中最受欢迎的名字是谷歌的word2vec(Mikolov)和斯坦福大学的GloVe(Pennington、Socher和Manning)。fastText似乎在多语言子词嵌入中相当受欢迎。

我们建议您不要使用word2vec或GloVe。相反,使用fastText向量,它们要好得多,并且来自同一作者。word2vec是由T. Mikolov等人(https://scholar.google.com/citations?user=oBu8kMMAAAAJ&hl=en)在谷歌工作时引入的,它在单词相似性和类比任务上表现良好。

GloVe是由斯坦福大学的Pennington、Socher和Manning在2014年引入的,作为一种词嵌入的统计近似。词向量是通过词-词共现矩阵的矩阵分解来创建的。

如果在两个恶之间选择较小的那个,我们推荐使用GloVe而不是word2vec。这是因为GloVe在大多数机器学习任务和学术界的NLP挑战中优于word2vec。

在这里跳过原始的word2vec,我们现在将探讨以下主题:

  • 我们如何使用GloVe中的原始嵌入?

  • 我们如何处理词汇表外的单词?(提示:fastText)

  • 我们如何在自己的语料库上训练自己的word2vec向量?

我们如何使用预训练的嵌入?

我们刚刚下载了这些。

word2vec和GloVe使用的文件格式略有不同。我们希望有一个一致的API来查找任何词嵌入,我们可以通过转换嵌入格式来实现这一点。请注意,在词嵌入的存储方式上存在一些细微的差异。

这种格式转换可以使用Gensim的API glove2word2vec来完成。我们将使用它将我们的GloVe嵌入信息转换为word2vec格式。

因此,让我们先处理导入,然后设置文件名,如下所示:

from gensim.scripts.glove2word2vec import glove2word2vec
glove_input_file = 'data/glove.6B.300d.txt'
word2vec_output_file = 'data/glove.6B.300d.word2vec.txt'

如果我们已经进行过一次转换,我们不想重复这一步骤。最简单的方法是查看word2vec_output_file是否已经存在。只有在文件不存在的情况下,我们才运行以下转换:

import os
if not os.path.exists(word2vec_output_file):
    glove2word2vec(glove_input_file, word2vec_output_file)

前面的片段将在一个与Gensim API堆栈兼容的标准中创建一个新文件。

KeyedVectors API

我们现在必须执行一个简单的任务,即从文件中加载向量。我们使用Gensim中的KeyedVectors API来完成这项工作。我们想要查找的单词是键,该单词的数值表示是相应的值。

让我们先导入API并设置目标文件名,如下所示:

from gensim.models import KeyedVectors
filename = word2vec_output_file

我们将把整个文本文件加载到我们的内存中,包括从磁盘读取的时间。在大多数运行过程中,这是一个一次性I/O步骤,并且不会为每次新的数据传递而重复。这将成为我们的Gensim模型,详细说明如下:

%%time
# load the Stanford GloVe model from file, this is Disk I/O and can be slow
pretrained_w2v_model = KeyedVectors.load_word2vec_format(word2vec_output_file, binary=False)
# binary=False format for human readable text (.txt) files, and binary=True for .bin files

更快的SSD应该可以显著提高速度,提高一个数量级。

我们可以进行一些单词向量算术运算来组合并展示这种表示不仅捕捉了语义意义。例如,让我们重复以下著名的单词向量示例:

(king - man) + woman = ?

现在我们对单词向量执行所提到的算术运算,如下所示:

# calculate: (king - man) + woman = ?
result = pretrained_w2v_model.wv.most_similar(positive=['woman', 'king'], negative=['man'], topn=1)

我们使用most_similar API完成了这项工作。在幕后,Gensim为我们做了以下操作:

  1. 查询了womankingman的向量

  2. 添加了kingwoman,并从man中减去向量以找到结果向量

  3. 在这个模型中的60亿个标记中,按距离对所有单词进行排序并找到了最接近的单词

  4. 找到了最接近的单词

我们还添加了topn=1来告诉API我们只对最接近的匹配感兴趣。现在的预期输出只是一个单词,'queen',如下面的片段所示:

print(result)
> [('queen', 0.6713277101516724)]

我们不仅得到了正确的单词,还得到了一个伴随的十进制数!我们现在暂时忽略这个数字,但请注意,这个数字代表了一个概念,即单词与API为我们计算的结果向量的接近程度或相似程度。

让我们再试几个例子,比如社交网络,如下面的片段所示:

result = pretrained_w2v_model.most_similar(positive=['quora', 'facebook'], negative=['linkedin'], topn=1)
print(result)

在这个例子中,我们正在寻找一个比LinkedIn更随意但比Facebook更专注于学习的社交网络,通过添加Quora来实现。正如您在以下输出中可以看到,Twitter似乎完美地符合这一要求:

[('twitter', 0.37966805696487427)]

我们同样可以期待Reddit也能符合这一要求。

那么,我们能否使用这种方法简单地探索更大语料库中的相似单词?看起来是这样的。现在让我们查找与india最相似的单词,如下面的片段所示。请注意,我们用小写写印度;这是因为模型中只包含小写单词:

pretrained_w2v_model.most_similar('india')

值得注意的是,这些结果可能有点偏颇,因为GloVe主要是基于一个名为Gigaword的大型新闻语料库进行训练的:

[('indian', 0.7355823516845703),
 ('pakistan', 0.7285579442977905),
 ('delhi', 0.6846907138824463),
 ('bangladesh', 0.6203191876411438),
 ('lanka', 0.609517514705658),
 ('sri', 0.6011613607406616),
 ('kashmir', 0.5746493935585022),
 ('nepal', 0.5421023368835449),
 ('pradesh', 0.5405811071395874),
 ('maharashtra', 0.518537700176239)]

考虑到在外国媒体中,印度经常因其与地理邻国(包括巴基斯坦和克什米尔)的紧张关系而被提及,先前的结果确实是有意义的。孟加拉国、尼泊尔和斯里兰卡是邻国,而马哈拉施特拉邦是印度商业之都孟买的所在地。

word2vec 和 GloVe 缺少了什么?

无论是 GloVe 还是 word2vec 都无法处理训练过程中没有见过的单词。这些单词在文献中被称为词汇表外(OOV)。

如果你尝试查找不常使用的名词,例如一个不常见的名字,就可以看到这种证据。如下面的代码片段所示,模型会抛出一个 not in vocabulary 错误:

try: 
  pretrained_w2v_model.wv.most_similar('nirant')
except Exception as e: 
  print(e)  

这导致了以下输出:

"word 'nirant' not in vocabulary"

这个结果还伴随着一个API警告,有时会声明API将在 gensim v4.0.0 中更改。

我们如何处理词汇表外的单词?

word2vec 的作者(Mikolov 等人)将其扩展到 Facebook 上的 fastText。它使用字符 n-gram 而不是整个单词。字符 n-gram 在具有特定形态学特性的语言中非常有效。

我们可以创建自己的 fastText 嵌入,它可以处理 OOV 标记。

获取数据集

首先,我们需要从公共数据集中下载几个 TED 演讲的字幕。我们将使用这些字幕以及 word2vec 嵌入进行对比训练,如下所示:

ted_dataset = "https://wit3.fbk.eu/get.php?path=XML_releases/xml/ted_en-20160408.zip&filename=ted_en-20160408.zip"
get_data(ted_dataset, "data/ted_en.zip")

Python 允许我们访问 .zip 文件内部的文件,使用 zipfile 包很容易做到这一点。注意,是 zipfile.zipFile 语法使得这一点成为可能。

我们还使用 lxml 包来解析 ZIP 文件内的 XML 文件。

在这里,我们手动打开文件以找到相关的 content 路径,并从中查找 text()。在这种情况下,我们只对字幕感兴趣,而不是任何伴随的元数据,如下所示:

import zipfile
import lxml.etree
with zipfile.ZipFile('data/ted_en.zip', 'r') as z:
    doc = lxml.etree.parse(z.open('ted_en-20160408.xml', 'r'))
input_text = '\n'.join(doc.xpath('//content/text()'))

现在我们预览一下以下 input_text 的前500个字符:

input_text[:500]
> "Here are two reasons companies fail: they only do more of the same, or they only do what's new.\nTo me the real, real solution to quality growth is figuring out the balance between two activities: exploration and exploitation. Both are necessary, but it can be too much of a good thing.\nConsider Facit. I'm actually old enough to remember them. Facit was a fantastic company. They were born deep in the Swedish forest, and they made the best mechanical calculators in the world. Everybody used them. A"

由于我们使用的是 TED 演讲中的字幕,有一些填充词是没有用的。这些通常是括号中描述声音的单词和演讲者的名字。

让我们使用一些正则表达式删除这些填充词,如下所示:

import re
# remove parenthesis 
input_text_noparens = re.sub(r'\([^)]*\)', '', input_text)

# store as list of sentences
sentences_strings_ted = []
for line in input_text_noparens.split('\n'):
    m = re.match(r'^(?:(?P<precolon>[^:]{,20}):)?(?P<postcolon>.*)$', line)
    sentences_strings_ted.extend(sent for sent in m.groupdict()['postcolon'].split('.') if sent)

# store as list of lists of words
sentences_ted = []
for sent_str in sentences_strings_ted:
    tokens = re.sub(r"[^a-z0-9]+", " ", sent_str.lower()).split()
    sentences_ted.append(tokens)

注意,我们使用 .split('\n') 语法在我们的整个语料库上创建了 sentence_strings_ted。作为读者练习,将其替换为更好的句子分词器,例如 spaCy 或 NLTK 中的分词器:

print(sentences_ted[:2])

注意,每个 sentences_ted 现在都是一个列表的列表。第一个列表的每个元素都是一个句子,每个句子是一个标记(单词)的列表。

这是使用 Gensim 训练文本嵌入的预期结构。我们将把以下代码写入磁盘,以便稍后检索:

import json
# with open('ted_clean_sentences.json', 'w') as fp:
#     json.dump(sentences_ted, fp)

with open('ted_clean_sentences.json', 'r') as fp:
    sentences_ted = json.load(fp)

我个人更喜欢 JSON 序列化而不是 Pickle,因为它稍微快一点,跨语言互操作性更强,最重要的是,它对人类来说是可读的。

现在让我们在这个小语料库上训练 fastText 和 word2vec 嵌入。虽然语料库很小,但我们使用的语料库代表了实践中通常看到的数据大小。在行业中,大型标注文本语料库极其罕见。

训练 fastText 嵌入

在新的 Gensim API 中设置导入实际上非常简单;只需使用以下代码:

from gensim.models.fasttext import FastText

下一步是输入文本并构建我们的文本嵌入模型,如下所示:

fasttext_ted_model = FastText(sentences_ted, size=100, window=5, min_count=5, workers=-1, sg=1)
 # sg = 1 denotes skipgram, else CBOW is used

您可能会注意到我们传递给构建模型的参数。以下列表解释了这些参数,正如 Gensim 文档中所述:

  • min_count (int, 可选): 模型忽略所有总频率低于此值的单词

  • size (int, 可选): 这表示单词向量的维度

  • window (int, 可选): 这表示句子中当前单词和预测单词之间的最大距离

  • workers (int, 可选): 使用这些工作线程来训练模型(这可以在多核机器上实现更快的训练;workers=-1 表示使用机器中每个可用的核心的一个工作线程)

  • sg ({1, 0}, 可选): 这是一个训练算法,当 sg=1 时为 skip-gram 或 CBOW

前面的参数实际上是更大列表的一部分,可以通过调整这些参数来改善文本嵌入的质量。我们鼓励您在探索 Gensim API 提供的其他参数的同时,尝试调整这些数字。

现在让我们快速查看这个语料库中按 fastText 嵌入相似度排名的与印度最相似的单词,如下所示:

fasttext_ted_model.wv.most_similar("india")

[('indians', 0.5911639928817749),
 ('indian', 0.5406097769737244),
 ('indiana', 0.4898717999458313),
 ('indicated', 0.4400438070297241),
 ('indicate', 0.4042605757713318),
 ('internal', 0.39166826009750366),
 ('interior', 0.3871103823184967),
 ('byproducts', 0.3752930164337158),
 ('princesses', 0.37265270948410034),
 ('indications', 0.369659960269928)]

在这里,我们注意到 fastText 利用子词结构,例如 indiandian 来对单词进行排序。我们在前 3 名中得到了 indiansindian,这相当不错。这是 fastText 有效的原因之一——即使是对于小型的训练文本任务。

现在让我们重复使用 word2vec 进行相同的过程,并查看那里与 india 最相似的单词。

训练 word2vec 嵌入

导入模型很简单,只需使用以下命令。到现在为止,您应该对 Gensim 模型 API 的结构有了直观的了解:

from gensim.models.word2vec import Word2Vec

在这里,我们使用与 fastText 相同的配置来构建 word2vec 模型。这有助于减少比较中的偏差。

鼓励您使用以下方法比较最佳 fastText 模型和最佳 word2vec 模型:

word2vec_ted_model = Word2Vec(sentences=sentences_ted, size=100, window=5, min_count=5, workers=-1, sg=1)

对了,现在让我们查看与 india 最相似的单词,如下所示:

word2vec_ted_model.wv.most_similar("india")

[('cent', 0.38214215636253357),
 ('dichotomy', 0.37258434295654297),
 ('executing', 0.3550642132759094),
 ('capabilities', 0.3549191951751709),
 ('enormity', 0.3421599268913269),
 ('abbott', 0.34020164608955383),
 ('resented', 0.33033430576324463),
 ('egypt', 0.32998529076576233),
 ('reagan', 0.32638251781463623),
 ('squeezing', 0.32618749141693115)]

india 最相似的单词与原始单词没有实质性的关系。对于这个特定的数据集和 word2vec 的训练配置,模型根本没有捕捉到任何语义或句法信息。这在 word2vec 旨在处理大型文本语料库的情况下并不罕见。

fastText 与 word2vec 的比较

根据以下 Gensim 的初步比较:

fastText在编码句法信息方面显著优于word2vec。这是预料之中的,因为大多数句法类比都是基于形态学的,而fastText的字符n-gram方法考虑了这种信息。原始的word2vec模型在语义任务上似乎表现更好,因为语义类比中的单词与它们的字符n-gram无关,而无关字符n-gram添加的信息反而恶化了嵌入。

此资料的来源是:word2vec fasttext比较笔记本 (https://github.com/RaRe-Technologies/gensim/blob/37e49971efa74310b300468a5b3cf531319c6536/docs/notebooks/Word2Vec_FastText_Comparison.ipynb)。

通常,我们更喜欢fastText,因为它天生具有处理训练中未见过的单词的能力。当处理小数据(如我们所展示的)时,它肯定优于word2vec,并且在大型数据集上至少与word2vec一样好。

fastText在处理充满拼写错误的文本时也非常有用。例如,它可以利用子词相似性在嵌入空间中将indianindain拉近。

在大多数下游任务中,如情感分析或文本分类,我们继续推荐GloVe优于word2vec。

以下是我们为文本嵌入应用推荐的经验法则:fastText > GloVe > word2vec。

文档嵌入

文档嵌入通常被认为是一种被低估的方法。文档嵌入的关键思想是将整个文档,例如专利或客户评论,压缩成一个单一的向量。这个向量随后可以用于许多下游任务。

实验结果表明,文档向量优于词袋模型以及其他文本表示技术。

其中最有用的下游任务之一是能够聚类文本。文本聚类有几种用途,从数据探索到在线分类管道中传入的文本。

尤其是我们对在小型数据集上使用doc2vec进行文档建模感兴趣。与捕捉在生成句子向量中的单词序列的序列模型(如RNN)不同,doc2vec句子向量与单词顺序无关。这种单词顺序无关性意味着我们可以快速处理大量示例,但这确实意味着捕捉到句子固有的意义较少。

本节大致基于Gensim存储库中的doc2Vec API教程。

让我们先通过以下代码处理掉导入:

from gensim.models.doc2vec import Doc2Vec, TaggedDocument
import gensim
from pprint import pprint
import multiprocessing

现在,让我们按照以下方式从我们之前使用的doc变量中提取谈话:

talks = doc.xpath('//content/text()')

要训练Doc2Vec模型,每个文本样本都需要一个标签或唯一标识符。为此,可以编写一个如下的小函数:

def read_corpus(talks, tokens_only=False):
    for i, line in enumerate(talks):
        if tokens_only:
            yield gensim.utils.simple_preprocess(line)
        else:
            # For training data, add tags
            yield gensim.models.doc2vec.TaggedDocument(gensim.utils.simple_preprocess(line), [i])

在前面的函数中发生了一些事情;具体如下:

  • 重载if条件:这个函数读取测试语料库并将tokens_only设置为True

  • 目标标签:这个函数分配一个任意的索引变量i作为目标标签。

  • **gensim.utils.simple_preprocess**:这个函数将文档转换为一系列小写标记,忽略过短或过长的标记,然后产生TaggedDocument实例。由于我们产生而不是返回,这个整个函数作为一个生成器在运行。

值得注意的是,这如何改变函数的行为。使用return时,当函数被调用时,它会返回一个特定的对象,例如TaggedDocument或如果没有指定返回,则返回None。另一方面,生成器函数只返回一个generator对象。

那么,你期望以下代码行返回什么?

read_corpus(talks)

如果你猜对了,你就会知道我们期望它返回一个generator对象,如下所示:

<generator object read_corpus at 0x0000024741DBA990>

前面的对象意味着我们可以按需逐个读取文本语料库的元素。如果训练语料库的大小超过你的内存大小,这特别有用。

理解Python迭代器和生成器的工作方式。它们使你的代码内存效率更高,更容易阅读。

在这个特定的例子中,我们有一个相当小的训练语料库作为示例,所以让我们将整个语料库作为一个TaggedDocument对象的列表读入工作内存,如下所示:

ted_talk_docs = list(read_corpus(talks))

list()语句遍历整个语料库,直到函数停止产生。我们的变量ted_talk_docs应该看起来像以下这样:

ted_talk_docs[0]

TaggedDocument(words=['here', 'are', 'two', 'reasons', 'companies', 'fail', ...., 'you', 'already', 'know', 'don', 'forget', 'the', 'beauty', 'is', 'in', 'the', 'balance', 'thank', 'you', 'applause'], tags=[0])

让我们快速看一下这台机器有多少个核心。我们将使用以下代码初始化doc2vec模型:

cores = multiprocessing.cpu_count()
print(cores)
8

现在我们去初始化我们的doc2vec模型,来自Gensim。

理解doc2vec API

model = Doc2Vec(dm=0, vector_size=100, negative=5, hs=0, min_count=2, iter=5, workers=cores)

让我们快速了解前面代码中使用的标志:

  • dm ({1,0}, optional):这定义了训练算法;如果dm=1,则使用分布式内存(PV-DM);否则,使用分布式词袋(PV-DBOW)。

  • size (int, optional):这是特征向量的维度

  • window (int, optional):这代表句子中当前词和预测词之间的最大距离

  • negative (int, optional):如果大于0,则使用负采样(负值的int指定应该抽取多少噪声词,这通常在5-20之间);如果设置为0,则不使用负采样。

  • hs ({1,0}, optional):如果设置为1,则用于模型训练的层次softmax;如果设置为0且负采样非零,则使用负采样。

  • iter (int, optional):这代表在语料库上的迭代次数(周期)

前面的列表直接来自Gensim文档。考虑到这一点,我们现在将解释这里引入的一些新术语,包括负采样和层次softmax。

负采样

负采样最初是一种为了加速训练而采用的技巧,现在已经成为一种被广泛接受的实践。这里的要点是,除了在可能正确的答案上训练你的模型之外,为什么不给它展示一些错误的例子?

特别是,使用负采样通过减少所需的模型更新次数来加速训练。我们不是为每个错误的单词更新模型,而是选择一个较小的数字,通常在5到25之间,并在它们上训练模型。因此,我们将从在大语料库上训练所需的几百万次更新减少到一个更小的数字。这是一个经典的软件编程技巧,在学术界也有效。

层次化软最大化

我们通常的softmax中的分母项是通过在大量单词上的求和操作来计算的。这种归一化操作在训练过程中的每次更新都是一个昂贵的操作。

相反,我们可以将其分解为一系列特定的计算,这样可以节省我们计算所有单词上的昂贵归一化的时间。这意味着对于每个单词,我们使用某种近似。

在实践中,这种近似已经非常有效,以至于一些系统在训练和推理时间都使用这种方法。对于训练来说,它可以提供高达50倍的速度(据NLP研究博主Sebastian Ruder所说)。在我的实验中,我看到了大约15-25倍的速度提升。

model.build_vocab(ted_talk_docs)

训练doc2vec模型的API略有不同。我们首先使用build_vocab API从一系列句子中构建词汇表,如前一个代码片段所示。我们还将我们的内存变量ted_talk_docs传递到这里,但也可以传递从read_corpora函数中获得的单次生成器流。

现在我们设置一些以下样本句子,以找出我们的模型是否学到了什么:

sentence_1 = 'Modern medicine has changed the way we think about healthcare, life spans and by extension career and marriage'

sentence_2 = 'Modern medicine is not just a boon to the rich, making the raw chemicals behind these is also pollutes the poorest neighborhoods'

sentence_3 = 'Modern medicine has changed the way we think about healthcare, and increased life spans, delaying weddings'

Gensim有一个有趣的API,允许我们使用我们刚刚用词汇表更新的模型在两个未见文档之间找到相似度值,如下所示:

model.docvecs.similarity_unseen_docs(model, sentence_1.split(), sentence_3.split())
> -0.18353473068679

model.docvecs.similarity_unseen_docs(model, sentence_1.split(), sentence_2.split())
> -0.08177642293252027

前面的输出并不完全合理,对吧?我们写的句子应该有一些合理的相似度,这绝对不是负面的。

哎呀!我们忘记在语料库上训练模型了。现在让我们用以下代码来做这件事,然后重复之前的比较,看看它们是如何变化的:

%time model.train(ted_talk_docs, total_examples=model.corpus_count, epochs=model.epochs)
Wall time: 6.61 s

在BLAS设置好的机器上,这一步应该不到几秒钟。

我们实际上可以根据以下模型提取任何特定句子的原始推理向量:

model.infer_vector(sentence_1.split())

array([-0.03805782,  0.09805363, -0.07234333,  0.31308332,  0.09668373,
       -0.01471598, -0.16677614, -0.08661497, -0.20852503, -0.14948   ,
       -0.20959479,  0.17605443,  0.15131783, -0.17354141, -0.20173495,
        0.11115499,  0.38531387, -0.39101505,  0.12799   ,  0.0808568 ,
        0.2573657 ,  0.06932276,  0.00427534, -0.26196653,  0.23503092,
        0.07589306, -0.01828301,  0.38289976, -0.04719075, -0.19283117,
        0.1305226 , -0.1426582 , -0.05023642, -0.11381021,  0.04444459,
       -0.04242943,  0.08780348,  0.02872207, -0.23920575,  0.00984556,
        0.0620702 , -0.07004016,  0.15629964,  0.0664391 ,  0.10215732,
        0.19148728, -0.02945088,  0.00786009, -0.05731675, -0.16740018,
       -0.1270729 ,  0.10185472,  0.16655563,  0.13184668,  0.18476236,
       -0.27073956, -0.04078012, -0.12580603,  0.02078131,  0.23821649,
        0.09743162, -0.1095973 , -0.22433399, -0.00453655,  0.29851952,
       -0.21170728,  0.1928157 , -0.06223159, -0.044757  ,  0.02430432,
        0.22560015, -0.06163954,  0.09602281,  0.09183675, -0.0035969 ,
        0.13212039,  0.03829316,  0.02570504, -0.10459486,  0.07317936,
        0.08702451, -0.11364868, -0.1518436 ,  0.04545208,  0.0309107 ,
       -0.02958601,  0.08201223,  0.26910907, -0.19102073,  0.00368607,
       -0.02754402,  0.3168101 , -0.00713515, -0.03267708, -0.03792975,
        0.06958092, -0.03290432,  0.03928463, -0.10203536,  0.01584929],
      dtype=float32)

在这里,infer_vector API期望一个标记列表作为输入。这应该解释了为什么我们也可以在这里使用read_corpora并设置tokens_only = True

既然我们的模型已经训练好了,让我们再次比较以下句子:

model.docvecs.similarity_unseen_docs(model, sentence_1.split(), sentence_3.split())
0.9010817740272721

model.docvecs.similarity_unseen_docs(model, sentence_1.split(), sentence_2.split())
0.7461058869759862

新的前置输出是有意义的。第一句和第三句确实比第一句和第二句更相似。在探索的精神下,现在让我们看看第二句和第三句的相似度,如下所示:

model.docvecs.similarity_unseen_docs(model, sentence_2.split(), sentence_3.split())
0.8189999598358203

啊,这样更好。我们的结果现在与我们的预期一致。相似度值大于第一句和第二句,但小于第一句和第三句,它们在意图上几乎相同。

作为一种轶事观察或启发式方法,真正相似的句子在相似度量表上的值大于0.8。

我们已经提到,文档或文本向量通常是一种探索数据语料库的好方法。接下来,我们将以非常浅显的方式探索我们的语料库,然后给你一些继续探索的想法。

数据探索和模型评估

评估任何向量化的简单技术是将训练语料库作为测试语料库。当然,我们预计我们的模型会对训练集过度拟合,但这没关系。

我们可以通过以下方式使用训练语料库作为测试语料库:

  • 为每份文档学习新的结果或推理向量

  • 将向量与所有示例进行比较

  • 根据相似度分数对文档、句子和段落向量进行排序

让我们用以下代码来做这件事:

ranks = []
for idx in range(len(ted_talk_docs)):
    inferred_vector = model.infer_vector(ted_talk_docs[idx].words)
    sims = model.docvecs.most_similar([inferred_vector], topn=len(model.docvecs))
    rank = [docid for docid, sim in sims].index(idx)
    ranks.append(rank)

我们现在已经弄清楚每个文档在排名中的位置。所以,如果最高排名是文档本身,那就足够了。正如我们所说,我们可能在训练语料库上略微过度拟合,但这仍然是一个很好的合理性测试。我们可以通过以下方式使用Counter进行频率计数:

import collections
collections.Counter(ranks) # Results vary due to random seeding + very small corpus
Counter({0: 2079, 1: 2, 4: 1, 5: 2, 2: 1})

Counter对象告诉我们有多少文档发现自己处于什么排名。所以,2079份文档发现自己排名第一(索引0),但有两份文档分别发现自己排名第二(索引1)和第六(索引5)。有一份文档排名第五(索引4)和第三(索引2)。总的来说,这是一个非常好的训练性能,因为2084份文档中有2079份将自己排名为第一。

这有助于我们理解向量确实以有意义的方式在文档中代表了信息。如果它们没有这样做,我们会看到更多的排名分散。

现在我们快速取一份文档,找到与之最相似、最不相似以及介于两者之间的文档。以下代码可以完成这个任务:

doc_slice = ' '.join(ted_talk_docs[idx].words)[:500]
print(f'Document ({idx}): «{doc_slice}»\n')
print(f'SIMILAR/DISSIMILAR DOCS PER MODEL {model}')
for label, index in [('MOST', 0), ('MEDIAN', len(sims)//2), ('LEAST', len(sims) - 1)]:
      doc_slice = ' '.join(ted_talk_docs[sims[index][0]].words)[:500]
      print(f'{label} {sims[index]}: «{doc_slice}»\n')

注意我们是如何选择预览整个文档的一部分以供探索的。你可以自由选择这样做,或者使用一个小型文本摘要工具来动态创建你的预览。

结果如下:

Document (2084): «if you re here today and very happy that you are you've all heard about how sustainable development will save us from ourselves however when we're not at ted we're often told that real sustainability policy agenda is just not feasible especially in large urban areas like new york city and that because most people with decision making powers in both the public and the private sector really don't feel as though they are in danger the reason why here today in part is because of dog an abandoned puppy»

SIMILAR/DISSIMILAR DOCS PER MODEL Doc2Vec(dbow,d100,n5,mc2,s0.001,t8)
 MOST (2084, 0.893369197845459): «if you are here today and very happy that you are you've all heard about how sustainable development will save us from ourselves however when we are not at ted we are often told that real sustainability policy agenda is just not feasible especially in large urban areas like new york city and that because most people with decision making powers in both the public and the private sector really don feel as though they re in danger the reason why here today in part is because of dog an abandoned puppy»

MEDIAN (1823, 0.42069244384765625): «so going to talk today about collecting stories in some unconventional ways this is picture of me from very awkward stage in my life you might enjoy the awkwardly tight cut off pajama bottoms with balloons anyway it was time when was mainly interested in collecting imaginary stories so this is picture of me holding one of the first watercolor paintings ever made and recently I've been much more interested in collecting stories from reality so real stories and specifically interested in collecting »

LEAST (270, 0.12334088981151581): «on june precisely at in balmy winter afternoon in so paulo brazil typical south american winter afternoon this kid this young man that you see celebrating here like he had scored goal juliano pinto years old accomplished magnificent deed despite being paralyzed and not having any sensation from mid chest to the tip of his toes as the result of car crash six years ago that killed his brother and produced complete spinal cord lesion that left juliano in wheelchair juliano rose to the occasion and»

摘要

这章不仅仅是Gensim API的介绍。我们现在知道如何加载预训练的GloVe向量,你可以在任何机器学习模型中使用这些向量表示,而不是TD-IDF。

我们探讨了为什么fastText向量在小型训练语料库上通常比word2vec向量更好,并了解到你可以将它们用于任何ML模型。

我们学习了如何构建doc2vec模型。现在,你可以将这种doc2vec方法扩展到构建sent2vec或paragraph2vec风格的模型。理想情况下,paragraph2vec将会改变,仅仅是因为每个文档将变成一个段落。

此外,我们现在知道如何在不使用标注测试语料库的情况下快速对doc2vec向量进行合理性检查。我们是通过检查排名分散度指标来做到这一点的。

第五章:分类现代方法

现在我们知道如何将文本字符串转换为捕获一些意义的数值向量。在本章中,我们将探讨如何使用这些向量与嵌入结合。嵌入是比词向量更常用的术语,也是数值表示。

在本章中,我们仍然遵循第一章节的总体框架,即文本→表示→模型→评估→部署。

我们将继续使用文本分类作为我们的示例任务。这主要是因为它是一个简单的演示任务,但我们也可以将本书中的几乎所有想法扩展到解决其他问题。然而,接下来的主要焦点是文本分类的机器学习。

总结来说,在本章中,我们将探讨以下主题:

  • 情感分析作为文本分类的一个特定类别和示例

  • 简单分类器和如何优化它们以适应你的数据集

  • 集成方法

文本机器学习

在社区中至少有10到20种广为人知的机器学习技术,从SVM到多种回归和梯度提升机。我们将从中选择一小部分进行尝试。

图片

来源:https://www.kaggle.com/surveys/2017.

上述图表显示了Kagglers使用最广泛的机器学习技术。

在处理20个新闻组数据集时,我们遇到了逻辑回归。我们将重新审视逻辑回归并介绍朴素贝叶斯支持向量机决策树随机森林XgBoostXgBoost是一种流行的算法,被多位Kaggle获奖者用于获得获奖结果。我们将使用Python中的scikit-learn和XGBoost包来查看之前的代码示例。

情感分析作为文本分类

分类器的一个流行用途是情感分析。这里的最终目标是确定文本文档的主观价值,这本质上是指文本文档的内容是积极的还是消极的。这对于快速了解你正在制作的影片或你想要阅读的书籍的语气特别有用。

简单分类器

让我们从简单地尝试几个机器学习分类器开始,如逻辑回归、朴素贝叶斯和决策树。然后我们将尝试随机森林和额外树分类器。对于所有这些实现,我们不会使用除scikit-learn以外的任何东西。

优化简单分类器

我们可以通过尝试几个略微不同的分类器版本来调整这些简单的分类器以改善它们的性能。为此,最常见的方法是尝试改变分类器的参数。

我们将学习如何使用GridSearchRandomizedSearch来自动化这个搜索过程以找到最佳分类器参数。

集成方法

拥有一系列不同的分类器意味着我们将使用一组模型。集成是一种非常流行且易于理解的机器学习技术,几乎是每个获胜的Kaggle竞赛的一部分。

尽管最初担心这个过程可能很慢,但一些在商业软件上工作的团队已经开始在生产软件中使用集成方法。这是因为它需要很少的开销,易于并行化,并且允许内置的单个模型回退。

我们将研究一些基于简单多数的简单集成技术,也称为投票集成,然后基于此构建。

总结来说,本节机器学习NLP涵盖了简单的分类器、参数优化和集成方法。

获取数据

我们将使用Python的标准内置工具urlretrieveurllib.request编程下载数据。以下是从互联网下载的部分:

from pathlib import Path
import pandas as pd
import gzip
from urllib.request import urlretrieve
from tqdm import tqdm
import os
import numpy as np

class TqdmUpTo(tqdm):
    def update_to(self, b=1, bsize=1, tsize=None):
        if tsize is not None: self.total = tsize
        self.update(b * bsize - self.n)

如果你使用的是fastAI环境,所有这些导入都有效。第二个块只是为我们设置Tqdm,以便可视化下载进度。现在让我们使用urlretrieve下载数据,如下所示:

def get_data(url, filename):
    """
    Download data if the filename does not exist already
    Uses Tqdm to show download progress
    """
    if not os.path.exists(filename):

        dirname = os.path.dirname(filename)
        if not os.path.exists(dirname):
            os.makedirs(dirname)

        with TqdmUpTo(unit='B', unit_scale=True, miniters=1, desc=url.split('/')[-1]) as t:
            urlretrieve(url, filename, reporthook=t.update_to)

让我们下载一些数据,如下所示:

data_url = 'http://files.fast.ai/data/aclImdb.tgz'
get_data(data_url, 'data/imdb.tgz')

现在我们提取前面的文件,看看目录包含什么:

data_path = Path(os.getcwd())/'data'/'imdb'/'aclImdb'
assert data_path.exists()
for pathroute in os.walk(data_path):
    next_path = pathroute[1]
    for stop in next_path:
        print(stop)

注意,我们更喜欢使用Path from pathlib而不是os.path功能。这使得它更加平台无关,也更加Pythonic。这个写得非常糟糕的实用工具告诉我们至少有两个文件夹:traintest。每个文件夹反过来又至少有三个文件夹,如下所示:

Test
 |- all
 |- neg
 |- pos

 Train
 |- all
 |- neg
 |- pos
 |- unsup

posneg文件夹包含评论,分别代表正面和负面。unsup文件夹代表无监督。这些文件夹对于构建语言模型很有用,特别是对于深度学习,但在这里我们不会使用。同样,all文件夹是多余的,因为这些评论在posneg文件夹中已经重复。

读取数据

让我们将以下数据读入一个带有适当标签的Pandas DataFrame中:

train_path = data_path/'train'
test_path = data_path/'test'

def read_data(dir_path):
    """read data into pandas dataframe"""

    def load_dir_reviews(reviews_path):
        files_list = list(reviews_path.iterdir())
        reviews = []
        for filename in files_list:
            f = open(filename, 'r', encoding='utf-8')
            reviews.append(f.read())
        return pd.DataFrame({'text':reviews})

    pos_path = dir_path/'pos'
    neg_path = dir_path/'neg'

    pos_reviews, neg_reviews = load_dir_reviews(pos_path), load_dir_reviews(neg_path)

    pos_reviews['label'] = 1
    neg_reviews['label'] = 0

    merged = pd.concat([pos_reviews, neg_reviews])
    merged.reset_index(inplace=True)

    return merged

此函数读取特定traintest分割的文件,包括正负样本,对于IMDb数据集。每个分割都是一个包含两列的DataFrametextlabellabel列给出了我们的目标值,或y,如下所示:

train = read_data(train_path)
test = read_data(test_path)

X_train, y_train = train['text'], train['label']
X_test, y_test = test['text'], test['label']

我们现在可以读取相应的DataFrame中的数据,然后将其拆分为以下四个变量:X_trainy_trainX_testy_test

简单分类器

为了尝试一些我们的分类器,让我们先导入基本库,如下所示。在这里,我们将根据需要导入其余的分类器。这种在稍后导入事物的能力对于确保我们不会将太多不必要的组件导入内存非常重要:

from sklearn.pipeline import Pipeline
from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer

由于本节仅用于说明目的,我们将使用最简单的特征提取步骤,如下所示:

  • 词袋模型

  • TF-IDF

我们鼓励您尝试使用更好的文本向量化(例如,使用直接GloVe或word2vec查找)的代码示例。

逻辑回归

现在我们简单地复制我们在第1章“开始文本分类”中做的简单逻辑回归,但是在我们的自定义数据集上,如下所示:

from sklearn.linear_model import LogisticRegression as LR
lr_clf = Pipeline([('vect', CountVectorizer()), ('tfidf', TfidfTransformer()), ('clf',LR())])

如您在前面代码片段中看到的,lr_clf成为我们的分类器管道。我们在介绍部分看到了管道。管道允许我们在一个单一的Python对象中排队多个操作。

我们能够调用fitpredictfit_transform等函数在我们的Pipeline对象上,因为管道会自动调用列表中最后一个组件的相应函数。

lr_clf.fit(X=X_train, y=y_train) # note that .fit function calls are inplace, and the Pipeline is not re-assigned

如前所述,我们正在调用管道上的predict函数。测试评论将经过与训练期间相同的预处理步骤,即CountVectorizer()TfidfTransformer(),如下面的代码片段所示:

lr_predicted = lr_clf.predict(X_test)

这个过程的简便性和简单性使得Pipeline成为软件级机器学习中最常用的抽象之一。然而,用户可能更喜欢独立执行每个步骤,或者在研究或实验用例中构建自己的管道等效物:

lr_acc = sum(lr_predicted == y_test)/len(lr_predicted)
lr_acc # 0.88316

我们如何找到我们的模型准确率?好吧,让我们快速看一下前面一行发生了什么。

假设我们的预测是[1, 1, 1],而真实值是[1, 0, 1]。等式将返回一个简单的布尔对象列表,例如[True, False, True]。当我们对Python中的布尔列表求和时,它返回True案例的数量,这给我们提供了模型正确预测的确切次数。

将此值除以所做预测的总数(或者,同样地,测试评论的数量)可以得到我们的准确率。

让我们将之前的两行逻辑写入一个简单、轻量级的函数来计算准确率,如下面的代码片段所示。这将防止我们重复逻辑:

def imdb_acc(pipeline_clf):
    predictions = pipeline_clf.predict(X_test)
    assert len(y_test) == len(predictions)
    return sum(predictions == y_test)/len(y_test), predictions

移除停用词

通过简单地传递一个标志到CountVectorizer步骤,我们可以移除最常见的停用词。我们将指定要移除的停用词所写的语言。在以下情况下,那是english

lr_clf = Pipeline([('vect', CountVectorizer(stop_words='english')), ('tfidf', TfidfTransformer()), ('clf',LR())])
lr_clf.fit(X=X_train, y=y_train)
lr_acc, lr_predictions = imdb_acc(lr_clf)
lr_acc # 0.879

如您所见,这并没有在提高我们的准确率方面起到很大作用。这表明分类器本身正在移除或忽略由停用词添加的噪声。

增加ngram范围

现在我们尝试通过包括二元组和三元组来改进分类器可用的信息,如下所示:

lr_clf = Pipeline([('vect', CountVectorizer(stop_words='english', ngram_range=(1,3))), ('tfidf', TfidfTransformer()), ('clf',LR())])
lr_clf.fit(X=X_train, y=y_train)
lr_acc, lr_predictions = imdb_acc(lr_clf)
lr_acc # 0.86596

多项式朴素贝叶斯

让我们将与我们的逻辑回归分类器相同的方式初始化分类器,如下所示:

from sklearn.naive_bayes import MultinomialNB as MNB
mnb_clf = Pipeline([('vect', CountVectorizer()), ('clf',MNB())])

之前的命令将测量以下方面的性能:

mnb_clf.fit(X=X_train, y=y_train)
mnb_acc, mnb_predictions = imdb_acc(mnb_clf)
mnb_acc # 0.81356

添加TF-IDF

现在,让我们尝试在单词袋(单语元)之后作为另一个步骤使用TF-IDF的先前模型,如下所示:

mnb_clf = Pipeline([('vect', CountVectorizer()), ('tfidf', TfidfTransformer()), ('clf',MNB())])
mnb_clf.fit(X=X_train, y=y_train)
mnb_acc, mnb_predictions = imdb_acc(mnb_clf)
mnb_acc # 0.82956

这比我们之前的价值要好,但让我们看看我们还能做些什么来进一步提高这个值。

移除停用词

现在我们再次通过将english传递给分词器来移除英语中的停用词,如下所示:

mnb_clf = Pipeline([('vect', CountVectorizer(stop_words='english')), ('tfidf', TfidfTransformer()), ('clf',MNB())])
mnb_clf.fit(X=X_train, y=y_train)
mnb_acc, mnb_predictions = imdb_acc(mnb_clf)
mnb_acc # 0.82992 

这有助于提高性能,但只是略微提高。我们可能不如简单地保留其他分类器中的停用词。

作为最后的手动实验,让我们尝试添加二元组和一元组,就像我们在逻辑回归中做的那样,如下所示:

mnb_clf = Pipeline([('vect', CountVectorizer(stop_words='english', ngram_range=(1,3))), ('tfidf', TfidfTransformer()), ('clf',MNB())])
mnb_clf.fit(X=X_train, y=y_train)
mnb_acc, mnb_predictions = imdb_acc(mnb_clf)
mnb_acc # 0.8572

这比之前的多项式朴素贝叶斯性能要好得多,但不如我们的逻辑回归分类器表现好,后者接近88%的准确率。

现在我们尝试一些针对贝叶斯分类器的特定方法。

将fit prior改为false

增加ngram_range对我们有所帮助,但将prioruniform改为拟合(通过将fit_prior改为False)并没有起到任何作用,如下所示:

mnb_clf = Pipeline([('vect', CountVectorizer(stop_words='english', ngram_range=(1,3))), ('tfidf', TfidfTransformer()), ('clf',MNB(fit_prior=False))])
mnb_clf.fit(X=X_train, y=y_train)
mnb_acc, mnb_predictions = imdb_acc(mnb_clf)
mnb_acc # 0.8572

我们已经考虑了所有可能提高我们性能的组合。请注意,这种方法很繁琐,而且容易出错,因为它过于依赖人类的直觉。

支持向量机

支持向量机SVM)继续保持着一种非常受欢迎的机器学习技术,它从工业界进入课堂,然后再回到工业界。除了多种形式的回归之外,SVM是构成数十亿美元在线广告定位产业支柱的技术之一。

在学术界,T Joachim的工作(https://www.cs.cornell.edu/people/tj/publications/joachims_98a.pdf)建议使用支持向量分类器进行文本分类。

基于这样的文献,很难估计它对我们是否同样有效,主要是因为数据集和预处理步骤的不同。尽管如此,我们还是试一试:

from sklearn.svm import SVC
svc_clf = Pipeline([('vect', CountVectorizer()), ('tfidf', TfidfTransformer()), ('clf',SVC())])
svc_clf.fit(X=X_train, y=y_train)
svc_acc, svc_predictions = imdb_acc(svc_clf)
print(svc_acc) # 0.6562

虽然 SVM最适合线性可分的数据(正如我们所见,我们的文本不是线性可分的),但为了完整性,仍然值得一试。

在上一个例子中,SVM表现不佳,并且与其他分类器相比,训练时间也非常长(约150倍)。我们将不再针对这个特定数据集查看SVM。

决策树

决策树是分类和回归的简单直观工具。当从视觉上看时,它们常常类似于决策流程图,因此得名决策树。我们将重用我们的管道,简单地使用DecisionTreeClassifier作为我们的主要分类技术,如下所示:

from sklearn.tree import DecisionTreeClassifier as DTC
dtc_clf = Pipeline([('vect', CountVectorizer()), ('tfidf', TfidfTransformer()), ('clf',DTC())])
dtc_clf.fit(X=X_train, y=y_train)
dtc_acc, dtc_predictions = imdb_acc(dtc_clf)
dtc_acc # 0.7028

随机森林分类器

现在我们尝试第一个集成分类器。随机森林分类器中的“森林”来源于这个分类器的每个实例都由多个决策树组成。随机森林中的“随机”来源于每个树从所有特征中随机选择有限数量的特征,如下面的代码所示:

from sklearn.ensemble import RandomForestClassifier as RFC
rfc_clf = Pipeline([('vect', CountVectorizer()), ('tfidf', TfidfTransformer()), ('clf',RFC())])
rfc_clf.fit(X=X_train, y=y_train)
rfc_acc, rfc_predictions = imdb_acc(rfc_clf)
rfc_acc # 0.7226

虽然在大多数机器学习任务中使用时被认为非常强大,但随机森林方法在我们的案例中表现不佳。这部分原因在于我们相当粗糙的特征提取。

决策树、随机森林(RFC)和额外的树分类器在文本等高维空间中表现不佳。

额外的树分类器

“额外的树”中的“额外”来源于其极端随机化的想法。虽然随机森林分类器中的树分割是有效确定性的,但在额外的树分类器中是随机的。这改变了高维数据(如我们这里的每个单词都是一个维度或分类器)的偏差-方差权衡。以下代码片段显示了分类器的实际应用:

from sklearn.ensemble import ExtraTreesClassifier as XTC
xtc_clf = Pipeline([('vect', CountVectorizer()), ('tfidf', TfidfTransformer()), ('clf',XTC())])
xtc_clf.fit(X=X_train, y=y_train)
xtc_acc, xtc_predictions = imdb_acc(xtc_clf)
xtc_acc # 0.75024

如您所见,这种变化在这里对我们有利,但这并不总是普遍适用的。结果会因数据集以及特征提取管道的不同而有所变化。

优化我们的分类器

让我们现在专注于我们表现最好的模型——逻辑回归,看看我们是否能将其性能提升一点。我们基于LR的模型的最佳性能是之前看到的0.88312的准确率。

我们在这里将短语“参数搜索”和“超参数搜索”互换使用。这样做是为了保持与深度学习词汇的一致性。

我们希望选择我们管道的最佳性能配置。每个配置可能在某些小方面有所不同,例如当我们移除停用词、二元词和三元词,或类似的过程时。这样的配置总数可能相当大,有时可能达到数千。除了手动选择一些组合进行尝试外,我们还可以尝试所有这些数千种组合并评估它们。

当然,这个过程对于我们这样的小规模实验来说将过于耗时。在大规模实验中,可能的空间可以达到数百万,需要几天的时间进行计算,这使得成本和时间变得难以承受。

我们建议阅读一篇关于超参数调整的博客(https://www.oreilly.com/ideas/evaluating-machine-learning-models/page/5/hyperparameter-tuning),以更详细地了解这里讨论的词汇和思想。

使用随机搜索进行参数调整

Bergstra 和 Bengio 在 2012 年提出了一种替代方法(http://www.jmlr.org/papers/volume13/bergstra12a/bergstra12a.pdf)。他们证明了在大超参数空间中进行随机搜索比手动方法更有效,正如我们在多项式朴素贝叶斯中做的那样,并且通常与GridSearch一样有效,甚至更有效。

我们在这里如何使用它?

在这里,我们将基于Bergstra 和 Bengio 等人的结果。我们将把我们的参数搜索分为以下两个步骤:

  1. 使用 RandomizedSearch,在有限的迭代次数中遍历广泛的参数组合空间

  2. 使用步骤 1 的结果,在略微狭窄的空间内运行 GridSearch

我们可以重复之前的步骤,直到我们不再看到结果中的改进,但在这里我们不会这么做。我们将把这个作为读者的练习。我们的示例在下面的片段中概述:

from sklearn.model_selection import RandomizedSearchCV
param_grid = dict(clf__C=[50, 75, 85, 100], 
                  vect__stop_words=['english', None],
                  vect__ngram_range = [(1, 1), (1, 3)],
                  vect__lowercase = [True, False],
                 )

如您所见,param_grid 变量定义了我们的搜索空间。在我们的管道中,我们为每个估计器分配名称,例如 vectclf 等。clf 双下划线(也称为 dunder)的约定表示这个 Cclf 对象的属性。同样,对于 vect,我们指定是否要移除停用词。例如,english 表示移除英语停用词,其中停用词列表是 scikit-learn 内部使用的。您也可以用 spaCy、NLTK 或更接近您任务的命令替换它。

random_search = RandomizedSearchCV(lr_clf, param_distributions=param_grid, n_iter=5, scoring='accuracy', n_jobs=-1, cv=3)
random_search.fit(X_train, y_train)
print(f'Calculated cross-validation accuracy: {random_search.best_score_}')

之前的代码给出了交叉验证准确率在 0.87 范围内。这可能会根据随机分割的方式而有所不同。

best_random_clf = random_search.best_estimator*_* best_random_clf.fit(X_train, y_train)
imdb_acc(best_random_clf) # 0.90096

如前述片段所示,通过简单地更改几个参数,分类器性能提高了超过 1%。这是一个惊人的进步!

让我们现在看看我们正在使用的参数。为了进行比较,您需要知道所有参数的默认值。或者,我们可以简单地查看我们编写的 param_grid 参数,并注意选定的参数值。对于不在网格中的所有内容,将选择默认值并保持不变,如下所示:

print(best_random_clf.steps)

[('vect', CountVectorizer(analyzer='word', binary=False, decode_error='strict',
          dtype=<class 'numpy.int64'>, encoding='utf-8', input='content',
          lowercase=True, max_df=1.0, max_features=None, min_df=1,
          ngram_range=(1, 3), preprocessor=None, stop_words=None,
          strip_accents=None, token_pattern='(?u)\\b\\w\\w+\\b',
          tokenizer=None, vocabulary=None)),
 ('tfidf',
  TfidfTransformer(norm='l2', smooth_idf=True, sublinear_tf=False, use_idf=True)),
 ('clf',
  LogisticRegression(C=75, class_weight=None, dual=False, fit_intercept=True,
            intercept_scaling=1, max_iter=100, multi_class='ovr', n_jobs=1,
            penalty='l2', random_state=None, solver='liblinear', tol=0.0001,
            verbose=0, warm_start=False))]

在这里,我们注意到最佳分类器中的这些事情:

  • clf 中选择的 C 值是 100

  • lowercase 被设置为 False

  • 移除停用词不是一个好主意

  • 添加二元词和三元词有帮助

前面的观察结果非常具体于这个数据集和分类器管道。然而,根据我的经验,这可以并且确实有很大的变化。

让我们也避免假设在运行迭代次数如此之少的 RandomizedSearch 时,我们总是能得到最佳值。在这种情况下,经验法则是至少运行 60 次迭代,并且也要使用一个更大的 param_grid

在这里,我们使用了 RandomizedSearch 来了解我们想要尝试的参数的广泛布局。我们将其中一些参数的最佳值添加到我们的管道中,并将继续对这些参数的值进行实验。

我们没有提到 C 参数代表什么或它如何影响分类器。在理解和执行手动参数搜索时,这绝对很重要。通过尝试不同的值来改变 C 可以简单地帮助。

网格搜索

我们现在将为我们选择的参数运行 GridSearch。在这里,我们选择在运行 GridSearch 时包括二元词和三元词,同时针对 LogisticRegressionC 参数进行搜索。

我们在这里的意图是尽可能自动化。我们不是在RandomizedSearch期间尝试改变C的值,而是在人类学习时间(几个小时)和计算时间(几分钟)之间进行权衡。这种思维方式为我们节省了时间和精力。

from sklearn.model_selection import GridSearchCV
param_grid = dict(clf__C=[85, 100, 125, 150])
grid_search = GridSearchCV(lr_clf, param_grid=param_grid, scoring='accuracy', n_jobs=-1, cv=3)
grid_search.fit(X_train, y_train)
grid_search.best_estimator_.steps

在前面的代码行中,我们已经使用新的、更简单的param_gridlr_clf上运行了分类器,这个param_grid只在LogisticRegressionC参数上工作。

让我们看看我们最佳估计器的步骤,特别是C的值,如下面的代码片段所示:

[('vect', CountVectorizer(analyzer='word', binary=False, decode_error='strict',
          dtype=<class 'numpy.int64'>, encoding='utf-8', input='content',
          lowercase=True, max_df=1.0, max_features=None, min_df=1,
          ngram_range=(1, 3), preprocessor=None, stop_words=None,
          strip_accents=None, token_pattern='(?u)\\b\\w\\w+\\b',
          tokenizer=None, vocabulary=None)),
 ('tfidf',
  TfidfTransformer(norm='l2', smooth_idf=True, sublinear_tf=False, use_idf=True)),
 ('clf',
  LogisticRegression(C=150, class_weight=None, dual=False, fit_intercept=True,
            intercept_scaling=1, max_iter=100, multi_class='ovr', n_jobs=1,
            penalty='l2', random_state=None, solver='liblinear', tol=0.0001,
            verbose=0, warm_start=False))]

让我们直接从我们的对象中获取结果性能。这些对象中每个都有一个名为best_score_的属性。该属性存储了我们选择的度量标准的最优值。在以下情况下,我们选择了准确率:

print(f'Calculated cross-validation accuracy: {grid_search.best_score_} while random_search was {random_search.best_score_}')

> Calculated cross-validation accuracy: 0.87684 while random_search was 0.87648

best_grid_clf = grid_search.best_estimator_
best_grid_clf.fit(X_train, y_train)

imdb_acc(best_grid_clf) 
> (0.90208, array([1, 1, 1, ..., 0, 0, 1], dtype=int64))

如您在前面的代码中看到的,这几乎是非优化模型的3%的性能提升,尽管我们尝试了很少的参数进行优化。

值得注意的是,我们可以并且必须重复这些步骤(RandomizedSearchGridSearch),以进一步提高模型的准确率。

集成模型

集成模型是提高各种机器学习任务模型性能的非常强大的技术。

在以下部分,我们引用了由MLWave撰写的Kaggle集成指南(https://mlwave.com/kaggle-ensembling-guide/)。

我们可以解释为什么集成可以帮助减少错误或提高精度,同时展示在我们选择的任务和数据集上流行的技术。虽然这些技术可能不会在我们的特定数据集上带来性能提升,但它们仍然是心理工具箱中一个强大的工具。

为了确保您理解这些技术,我们强烈建议您在几个数据集上尝试它们。

投票集成 - 简单多数(也称为硬投票)

也许是简单的多数投票是最简单的集成技术。这基于直觉,单个模型可能在某个特定预测上出错,但几个不同的模型不太可能犯相同的错误。

让我们看看一个例子。

真实值:11011001

数字1和0代表一个假想的二元分类器的TrueFalse预测。每个数字是对不同输入的单个真或假预测。

让我们假设在这个例子中有三个模型,只有一个错误;它们如下所示:

  • 模型A预测:10011001

  • 模型B预测:11011001

  • 模型C预测:11011001

多数投票给出了以下正确答案:

  • 多数投票:11011001

在模型数量为偶数的情况下,我们可以使用平局决定者。平局决定者可以是简单地随机选择一个结果,或者更复杂地选择更有信心的结果。

为了在我们的数据集上尝试这种方法,我们导入VotingClassifier从scikit-learn。VotingClassifier不使用预训练模型作为输入。它将对模型或分类器管道调用fit,然后使用所有模型的预测来做出最终预测。

为了反驳其他地方对集成方法的过度炒作,我们可以证明硬投票可能会损害你的准确率性能。如果有人声称集成总是有帮助的,你可以向他们展示以下示例以进行更有建设性的讨论:

from sklearn.ensemble import VotingClassifier
voting_clf = VotingClassifier(estimators=[('xtc', xtc_clf), ('rfc', rfc_clf)], voting='hard', n_jobs=-1)
voting_clf.fit(X_train, y_train)
hard_voting_acc, _ = imdb_acc(voting_clf)
hard_voting_acc # 0.71092

在前面的示例中,我们仅使用了两个分类器进行演示:Extra Trees 和 Random Forest。单独来看,这些分类器的性能上限大约为74%的准确率。

在这个特定的例子中,投票分类器的性能比单独使用任何一个都要差。

投票集成 – 软投票

软投票根据类别概率预测类别标签。为每个分类器计算每个类别的预测概率之和(这在多类别的情况下很重要)。然后,分配的类别是具有最大概率之和的类别或argmax(p_sum)

这对于一组校准良好的分类器是推荐的,如下所示:

校准良好的分类器是概率分类器,其predict_proba方法的输出可以直接解释为置信水平。

我们的代码流程与硬投票分类器相同,只是将参数voting传递为soft*,如下面的代码片段所示:

voting_clf = VotingClassifier(estimators=[('lr', lr_clf), ('mnb', mnb_clf)], voting='soft', n_jobs=-1)
voting_clf.fit(X_train, y_train)
soft_voting_acc, _ = imdb_acc(voting_clf)
soft_voting_acc # 0.88216

在这里,我们可以看到软投票为我们带来了1.62%的绝对准确率提升。

加权分类器

次级模型要推翻最佳(专家)模型,唯一的办法是它们必须集体且自信地同意一个替代方案。

为了避免这种情况,我们可以使用加权多数投票——但为什么要加权?

通常,我们希望在投票中给予更好的模型更多的权重。实现这一目标最简单但计算效率最低的方法是重复使用不同名称的分类器管道,如下所示:

weighted_voting_clf = VotingClassifier(estimators=[('lr', lr_clf), ('lr2', lr_clf),('rf', xtc_clf), ('mnb2', mnb_clf),('mnb', mnb_clf)], voting='soft', n_jobs=-1)
weighted_voting_clf.fit(X_train, y_train)

用硬投票而不是软投票重复实验。这将告诉你投票策略如何影响我们集成分类器的准确率,如下所示:


weighted_voting_acc, _ = imdb_acc(weighted_voting_clf)
weighted_voting_acc # 0.88092

在这里,我们可以看到加权投票为我们带来了1.50%的绝对准确率提升。

那么,到目前为止我们学到了什么?

  • 基于简单多数的投票分类器可能比单个模型表现更差

  • 软投票比硬投票更有效

  • 通过简单地重复分类器来权衡分类器可以帮助

到目前为止,我们似乎是在随机选择分类器。这并不理想,尤其是在我们为商业应用构建模型时,每0.001%的提升都很重要。

移除相关分类器

让我们通过三个简单的模型来观察这个方法在实际中的应用。正如你所见,真实值都是1:

1111111100 = 80% accuracy
 1111111100 = 80% accuracy
 1011111100 = 70% accuracy

这些模型在预测上高度相关。当我们进行多数投票时,我们并没有看到任何改进:

1111111100 = 80% accuracy

现在,让我们将这个结果与以下三个性能较低但高度不相关的模型进行比较:

1111111100 = 80% accuracy
 0111011101 = 70% accuracy
 1000101111 = 60% accuracy

当我们使用多数投票集成这些模型时,我们得到以下结果:

1111111101 = 90% accuracy

在这里,我们看到了比我们任何单个模型都要高的改进率。模型预测之间的低相关性可以导致更好的性能。在实践中,这很难做到正确,但仍然值得研究。

我们将以下部分留给你作为练习尝试。

作为一个小提示,你需要找到不同模型预测之间的相关性,并选择那些相互之间相关性较低(理想情况下小于0.5)且作为独立模型有足够好的性能的成对模型。


 np.corrcoef(mnb_predictions, lr_predictions)[0][1] # this is too high a correlation at 0.8442355164021454

corr_voting_clf = VotingClassifier(estimators=[('lr', lr_clf), ('mnb', mnb_clf)], voting='soft', n_jobs=-1)
corr_voting_clf.fit(X_train, y_train)
corr_acc, _ = imdb_acc(corr_voting_clf)
 print(corr_acc) # 0.88216 

那么,当我们使用来自同一方法的两分类器时,我们会得到什么结果呢?

np.corrcoef(dtc_predictions,xtc_predictions )[0][1] # this is looks like a low correlation # 0.3272698219282598

low_corr_voting_clf = VotingClassifier(estimators=[('dtc', dtc_clf), ('xtc', xtc_clf)], voting='soft', n_jobs=-1)
low_corr_voting_clf.fit(X_train, y_train)
low_corr_acc, _ = imdb_acc(low_corr_voting_clf)
 print(low_corr_acc) # 0.70564

正如你所见,前面的结果也不是很鼓舞人心,但请记住,这只是一个提示!我们鼓励你继续尝试这个任务,并使用更多的分类器,包括我们在这里没有讨论过的分类器。

摘要

在本章中,我们探讨了关于机器学习的几个新想法。这里的目的是展示一些最常见的分类器。我们通过一个主题思想来探讨如何使用它们:将文本转换为数值表示,然后将这个表示输入到分类器中。

本章只涵盖了可用可能性的一小部分。记住,你可以尝试从使用Tfidf进行更好的特征提取到使用GridSearchRandomizedSearch调整分类器,以及集成多个分类器。

本章主要关注特征提取和分类的深度学习之前的预方法。

注意,深度学习方法还允许我们使用一个模型,其中特征提取和分类都是从底层数据分布中学习的。虽然关于计算机视觉中的深度学习已经有很多文献,但我们只提供了自然语言处理中深度学习的一个简介。

第六章:深度学习在自然语言处理中的应用

在上一章中,我们使用经典的机器学习技术来构建我们的文本分类器。在本章中,我们将通过使用循环神经网络RNN)来替换这些技术。

特别是,我们将使用一个相对简单的双向LSTM模型。如果你对此不熟悉,请继续阅读——如果你已经熟悉,请随意跳过!

批变量数据集属性应指向torchtext.data.TabularData类型的trn变量。这是理解训练深度学习模型中数据流差异的有用检查点。

让我们先谈谈被过度炒作的术语,即深度学习中的“深度”和深度神经网络中的“神经”。在我们这样做之前,让我们花点时间解释为什么我使用PyTorch,并将其与Tensorflow和Keras等其他流行的深度学习框架进行比较。

为了演示目的,我将构建尽可能简单的架构。让我们假设大家对循环神经网络(RNN)有一般的了解,不再重复介绍。

在本章中,我们将回答以下问题:

  • 深度学习是什么?它与我们所看到的不同在哪里?

  • 任何深度学习模型中的关键思想是什么?

  • 为什么选择PyTorch?

  • 我们如何使用torchtext对文本进行标记化并设置数据加载器?

  • 什么是循环网络,我们如何使用它们进行文本分类?

深度学习是什么?

深度学习是机器学习的一个子集:一种从数据中学习的新方法,它强调学习越来越有意义的表示的连续层。但深度学习中的“深度”究竟是什么意思呢?

"深度学习中的‘深度’并不是指通过这种方法获得的任何更深层次的理解;相反,它代表的是这种连续层表示的想法。”

– Keras的主要开发者F. Chollet

模型的深度表明我们使用了多少层这样的表示。F Chollet建议将分层表示学习和层次表示学习作为更好的名称。另一个可能的名称是可微分编程。

“可微分编程”这个术语是由Yann LeCun提出的,源于我们的深度学习方法共有的不是更多的层——而是所有这些模型都通过某种形式微分计算来学习——通常是随机梯度下降。

现代机器学习方法的差异

我们所研究的现代机器学习方法主要在20世纪90年代成为主流。它们之间的联系在于它们都使用一层表示。例如,决策树只创建一组规则并应用它们。即使你添加集成方法,集成通常也很浅,只是直接结合几个机器学习模型。

这里是对这些差异的更好表述:

“现代深度学习通常涉及十几个甚至上百个连续的表示层——而且它们都是通过接触训练数据自动学习的。与此同时,其他机器学习的方法倾向于只学习数据的一层或两层表示;因此,它们有时被称为浅层学习。”

– F Chollet

让我们看看深度学习背后的关键术语,因为这样我们可能会遇到一些关键思想。

理解深度学习

以宽松的方式来说,机器学习是关于将输入(如图像或电影评论)映射到目标(如标签猫或正面)。模型通过查看(或从多个输入和目标对进行训练)来完成这项工作。

深度神经网络通过一系列简单数据转换(层)来实现从输入到目标的映射。这个序列的长度被称为网络的深度。从输入到目标的整个序列被称为学习数据的模型。这些数据转换是通过重复观察示例来学习的。让我们看看这种学习是如何发生的。

拼图碎片

我们正在研究一个特定的子类挑战,我们想要学习一个输入到目标的映射。这个子类通常被称为监督机器学习。这个词监督表示我们为每个输入都有一个目标。无监督机器学习包括尝试聚类文本等挑战,我们并没有目标。

要进行任何监督机器学习,我们需要以下条件:

  • 输入数据:从过去的股票表现到你的度假照片

  • 目标:期望输出的示例

  • 衡量算法是否做得好的方法:这是确定算法当前输出与其期望输出之间距离所必需的

上述组件对于任何监督方法都是通用的,无论是机器学习还是深度学习。特别是深度学习有其自己的一套令人困惑的因素:

  • 模型本身

  • 损失函数

  • 优化器

由于这些演员是新来的,让我们花一分钟时间了解他们做什么。

模型

每个模型由几个层组成。每个层是一个数据转换。这种转换通过一串数字来捕捉,称为层权重。但这并不是完全的真理,因为大多数层通常与一个数学运算相关联,例如卷积或仿射变换。一个更精确的观点是,层是通过其权重参数化的。因此,我们交替使用术语层参数层权重

所有层权重共同的状态构成了模型状态,即模型权重。一个模型可能有几千到几百万个参数。

让我们尝试理解在这个背景下模型学习的概念:学习意味着找到网络中所有层的权重值,以便网络能够正确地将示例输入映射到其相关目标。

注意,这个值集是针对一个地方的所有层。这个细微差别很重要,因为改变一个层的权重可能会改变整个模型的行为和预测。

损失函数

在设置机器学习任务时使用的组件之一是评估模型的表现。最简单的答案就是衡量模型的概念性准确度。虽然准确度有几个缺点:

  • 准确度是一个与验证数据相关联的代理指标,而不是训练数据。

  • 准确度衡量我们有多正确。在训练过程中,我们希望衡量我们的模型预测与目标有多远。

这些差异意味着我们需要一个不同的函数来满足我们之前的标准。在深度学习的背景下,这由损失函数来实现。这有时也被称为目标函数。

"损失函数将网络的预测和真实目标(你希望网络输出的内容)计算出一个距离分数,捕捉网络在这个特定例子上的表现如何。"

- 来自F Chollet的《Python深度学习》

这种距离测量被称为损失分数,或简单地称为损失。

优化器

这个损失值自动用作反馈信号来调整算法的工作方式。这个调整步骤就是我们所说的学习。

这种在模型权重上的自动调整是深度学习特有的。每次权重调整或更新都是朝着降低当前训练对(输入,目标)的损失的方向进行的。

这种调整是优化器的任务,它实现了所谓的反向传播算法:深度学习的核心算法。

优化器和损失函数是所有深度学习方法的共同点——即使我们没有输入/目标对。所有优化器都基于微分计算,如随机梯度下降SGD)、Adam等。因此,在我看来,可微分编程是深度学习的一个更精确的名称。

将所有这些放在一起——训练循环

我们现在有一个共享的词汇表。你对诸如层、模型权重、损失函数和优化器等术语有一个概念性的理解。但它们是如何协同工作的?我们如何对任意数据进行训练?我们可以训练它们,使它们能够识别猫的图片或亚马逊上的欺诈评论。

这里是训练循环内部发生步骤的大致轮廓:

  • 初始化:

    • 网络或模型权重被分配随机值,通常形式为(-1, 1)或(0, 1)。

    • 模型与目标相差甚远。这是因为它只是在执行一系列随机变换。

    • 损失值非常高。

  • 在网络处理每个示例时,都会发生以下情况:

    • 权重在正确的方向上略有调整

    • 损失得分降低

这就是训练循环,它会被重复多次。整个训练集的每一次遍历通常被称为一个epoch。适用于深度学习的每个训练集通常应该有数千个示例。模型有时会训练数千个epoch,或者也可以说是数百万次的迭代

在训练设置(模型、优化器、循环)中,前面的循环更新了最小化损失函数的权重值。一个训练好的网络是在整个训练和验证数据上具有可能最小损失得分的网络。

这是一个简单的机制,当经常重复时,就像魔法一样起作用。

Kaggle – 文本分类挑战

在这个特定的部分,我们将访问熟悉的文本分类任务,但使用不同的数据集。我们将尝试解决Jigsaw有毒评论分类挑战

获取数据

注意,您需要接受比赛的条款和条件以及数据使用条款才能获取此数据集。

对于直接下载,您可以从挑战网站上的数据标签获取训练和测试数据。

或者,您也可以使用官方的Kaggle API (github链接)通过终端或Python程序下载数据。

在直接下载和Kaggle API的情况下,您必须将训练数据分割成更小的训练和验证分割,以便在这个笔记本中使用。

您可以使用以下方法创建训练数据的训练和验证分割:

sklearn.model_selection.train_test_split 工具。或者,您也可以直接从本书的配套代码仓库中下载。

探索数据

如果您有任何缺失的包,您可以通过以下命令从笔记本本身安装它们:

# !conda install -y pandas
# !conda install -y numpy

让我们把导入的部分先放一放:

import pandas as pd
import numpy as np

然后,将训练文件读取到pandas DataFrame中:

train_df = pd.read_csv("data/train.csv")
train_df.head()

我们得到了以下输出:

id comment_text toxic severe_toxic obscene threat insult identity_hate
0 0000997932d777bf 解释\r\n为什么在我使用下进行的编辑... 0 0 0 0 0 0
1 000103f0d9cfb60f D'aww! 他匹配了这个背景颜色我正在用的... 0 0 0 0 0 0
2 000113f07ec002fd 嘿,伙计,我真的不是在尝试编辑战争。我... 0 0 0 0 0 0
3 0001b41b1c6bb37e \r\n更多\r\n 我无法提出任何真正的建议... 0 0 0 0 0 0
4 0001d958c54c6e35 先生,您是我的英雄。您还记得...吗? 0 0 0 0 0 0

让我们读取验证数据并预览一下:

val_df = pd.read_csv("data/valid.csv")
val_df.head()

我们得到了以下输出:

id comment_text toxic severe_toxic obscene threat insult identity_hate
0 000eefc67a2c930f Radial symmetry \r\n\r\n Several now extinct li... 0 0 0 0 0 0
1 000f35deef84dc4a There's no need to apologize. A Wikipedia arti... 0 0 0 0 0 0
2 000ffab30195c5e1 Yes, because the mother of the child in the ca... 0 0 0 0 0 0
3 0010307a3a50a353 \r\nOk. But it will take a bit of work but I ... 0 0 0 0 0 0
4 0010833a96e1f886 == A barnstar for you! ==\r\n\r\n The Real L... 0 0 0 0 0 0

多个目标数据集

这个数据集有趣的地方在于每个评论可以有多个标签。例如,一个评论可能是侮辱性和有害的,或者它可能是淫秽的,并包含identity_hate元素。

因此,我们在这里通过尝试一次预测多个标签(例如正面或负面)而不是一个标签来提升水平。对于每个标签,我们将预测一个介于0和1之间的值,以表示它属于该类别的可能性。

这不是一个贝叶斯意义上的概率值,但表示了相同的意图。

我建议尝试使用这个数据集之前看到的模型,并重新实现这段代码以适应我们最喜欢的IMDb数据集。

让我们用同样的想法预览一下测试数据集:

test_df = pd.read_csv("data/test.csv")
test_df.head()

我们得到了以下输出:

id comment_text
0 00001cee341fdb12 Yo bitch Ja Rule is more succesful then you'll...
1 0000247867823ef7 == From RfC == \r\n\r\n The title is fine as i...
2 00013b17ad220c46 \r\n\r\n == Sources == \r\n\r\n * Zawe Ashto...
3 00017563c3f7919a If you have a look back at the source, the in...
4 00017695ad8997eb I don't anonymously edit articles at all.

这个预览确认了我们面临的是一个文本挑战。这里的重点是文本的语义分类。测试数据集没有为目标列提供空标题或列,但我们可以从训练数据框中推断它们。

为什么选择PyTorch?

PyTorch是Facebook的一个深度学习框架,类似于Google的TensorFlow。

由于有Google的支持,数千美元被用于TensorFlow的市场营销、开发和文档。它几乎一年前就达到了稳定的1.0版本,而PyTorch最近才达到0.4.1。这意味着通常更容易找到TensorFlow的解决方案,你也可以从互联网上复制粘贴代码。

另一方面,PyTorch对程序员友好。它在语义上与NumPy和深度学习操作相似。这意味着我可以使用我已经熟悉的Python调试工具。

Pythonic:TensorFlow在某种程度上像C程序一样工作,因为代码都是在一次会话中编写的,编译后执行,从而完全破坏了其Python风格。这已经被TensorFlow的Eager Execution功能发布所解决,该功能很快将稳定到足以用于大多数原型设计工作。

训练循环可视化:直到不久前,TensorFlow有一个很好的可视化工具TensorBoard,用于理解训练和验证性能(以及其他特性),而PyTorch没有。现在,tensorboardX使得TensorBoard与PyTorch的使用变得简单。

简而言之,我推荐使用PyTorch,因为它更容易调试,更符合Python风格,并且对程序员更友好。

PyTorch和torchtext

你可以通过conda或pip在你的目标机器上安装Pytorch的最新版本(网站)。我在这台带有GPU的Windows笔记本电脑上运行此代码。

我使用conda install pytorch cuda92 -c pytorch安装了torch。

对于安装torchtext,我建议直接从他们的GitHub仓库使用pip进行安装,以获取最新的修复,而不是使用PyPi,因为PyPi更新并不频繁。在第一次运行此笔记本时取消注释该行:

# !pip install --upgrade git+https://github.com/pytorch/text

让我们设置torchtorch.nn(用于建模)和torchtext的导入:

import torch
import torch.nn as nn
import torch.nn.functional as F
import torchtext

如果你在一台带有GPU的机器上运行此代码,请将use_gpu标志设置为True;否则,设置为False

如果你设置use_gpu=True,我们将使用torch.cuda.is_available()实用程序检查GPU是否可供PyTorch使用:

use_gpu = True
if use_gpu:
    assert torch.cuda.is_available(), 'You either do not have a GPU or is not accessible to PyTorch'

让我们看看这台机器上有多少个GPU设备可供PyTorch使用:

torch.cuda.device_count()
> 1

使用torchtext的数据加载器

在大多数深度学习应用中,编写良好的数据加载器是最繁琐的部分。这一步骤通常结合了我们在前面看到的预处理、文本清理和向量化任务。

此外,它还将我们的静态数据对象包装成迭代器或生成器。这在处理比GPU内存大得多的数据大小时非常有帮助——这种情况相当常见。这是通过分割数据来实现的,这样你就可以制作出适合你GPU内存的批次样本。

批次大小通常是2的幂,例如32、64、512等等。这种约定存在是因为它有助于在指令集级别上进行向量操作。据我所知,使用不同于2的幂的批次大小并没有帮助或损害我的处理速度。

规范和风格

我们将要使用的代码、迭代器和包装器来自实用torchtext。这是一个由Keita Kurita创建的torchtext教程,他是torchtext的前五名贡献者之一。

命名规范和风格是受前面工作和基于PyTorch本身的深度学习框架fastai的启发。

让我们先设置所需的变量占位符:

from torchtext.data import Field

Field 类确定数据如何进行预处理并转换为数值格式。Field 类是 torchtext 的基本数据结构,值得深入研究。Field 类模拟常见的文本处理并将它们设置为数值化(或向量化):

LABEL = Field(sequential=False, use_vocab=False)

默认情况下,所有字段都接受单词字符串作为输入,然后字段在之后构建从单词到整数的映射。这个映射称为词汇表,实际上是标记的one-hot编码。

我们看到在我们的案例中,每个标签已经是一个标记为0或1的整数。因此,我们不会进行one-hot编码——我们将告诉 Field 类这已经是一组one-hot编码且非序列的,分别通过设置 use_vocab=Falsesequential=False

tokenize = lambda x: x.split()
TEXT = Field(sequential=True, tokenize=tokenize, lower=True)

这里发生了一些事情,所以让我们稍微展开一下:

  • lower=True:所有输入都被转换为小写。

  • sequential=True:如果为 False,则不应用分词。

  • tokenizer:我们定义了一个自定义的tokenize函数,它只是简单地根据空格分割字符串。你应该用spaCy分词器(设置 tokenize="spacy") 替换它,看看这会不会改变损失曲线或最终模型的表现。

了解字段

除了我们之前提到的关键字参数之外,Field 类还将允许用户指定特殊标记(unk_token 用于词汇表外的未知单词,pad_token 用于填充,eos_token 用于句子的结束,以及可选的 init_token 用于句子的开始)。

预处理和后处理参数接受它接收到的任何 torchtext.data.Pipeline。预处理在分词之后但在数值化之前应用。后处理在数值化之后但在将它们转换为Tensor之前应用。

Field 类的文档字符串写得相当好,所以如果你需要一些高级预处理,你应该查阅它们以获取更多信息:

from torchtext.data import TabularDataset

TabularDataset 是我们用来读取 .csv.tsv.json 文件的类。你可以在API中直接指定你正在读取的文件类型,即 .tsv.json,这既强大又方便。

初看之下,你可能觉得这个类有点放错位置,因为通用的文件I/O+处理器API应该直接在PyTorch中可用,而不是在专门用于文本处理的包中。让我们看看为什么它被放在那里。

TabularData 有一个有趣的 fields 输入参数。对于CSV数据格式,fields 是一个元组的列表。每个元组反过来是列名和我们要与之关联的 torchtext 变量。字段应该与CSV或TSV文件中的列顺序相同。

在这里,我们只定义了两个字段:TEXT和LABEL。因此,每一列都被标记为其中之一。如果我们想完全忽略某一列,我们可以简单地将其标记为None。这就是我们如何标记我们的列作为模型学习的输入(TEXT)和目标(LABEL)。

字段参数与TabularData的这种紧密耦合是为什么它是torchtext的一部分而不是PyTorch的原因:

tv_datafields = [("id", None), # we won't be needing the id, so we pass in None as the field
                 ("comment_text", TEXT), ("toxic", LABEL),
                 ("severe_toxic", LABEL), ("threat", LABEL),
                 ("obscene", LABEL), ("insult", LABEL),
                 ("identity_hate", LABEL)]

这定义了我们的输入列表。我在这里手动做了这件事,但您也可以通过代码读取train_df的列标题,并相应地分配TEXT或LABEL。

作为提醒,我们将不得不为我们的测试数据定义另一个字段列表,因为它有不同的标题。它没有LABEL字段。

TabularDataset支持两个API:splitsplits。我们将使用带有额外ssplits。splits API很简单:

  • path:这是文件名的前缀

  • trainvalidation:这是对应数据集的文件名

  • format:如前所述,可以是.csv.tsv.json;这里设置为.csv

  • skip_header:如果您的.csv文件中有列标题,就像我们的一样,则设置为True

  • fields:我们传递我们之前设置的字段列表:

trn, vld = TabularDataset.splits(
        path="data", # the root directory where the data lies
        train='train.csv', validation="valid.csv",
        format='csv',
        skip_header=True, # make sure to pass this to ensure header doesn't get proceesed as data!
        fields=tv_datafields)

现在我们也重复同样的步骤来处理测试数据。我们再次删除id列,并将comment_text设置为我们的标签:

tst_datafields = [("id", None), # we won't be needing the id, so we pass in None as the field
                 ("comment_text", TEXT)
                 ]

我们直接将整个相对文件路径传递到path中,而不是在这里使用pathtest变量的组合。我们在设置trnvld变量时使用了pathtrain的组合。

作为备注,这些文件名与Keita在torchtext教程中使用的一致:

tst = TabularDataset(
        path="data/test.csv", # the file path
        format='csv',
        skip_header=True, # if your csv header has a header, make sure to pass this to ensure it doesn't get proceesed as data!
        fields=tst_datafields)

探索数据集对象

让我们看看数据集对象,即trnvldtst

trn, vld, tst

> (<torchtext.data.dataset.TabularDataset at 0x1d6c86f1320>,
 <torchtext.data.dataset.TabularDataset at 0x1d6c86f1908>,
 <torchtext.data.dataset.TabularDataset at 0x1d6c86f16d8>)

它们都是同一类的对象。我们的数据集对象可以像正常列表一样索引和迭代,所以让我们看看第一个元素的样子:

trn[0], vld[0], tst[0]
> (<torchtext.data.example.Example at 0x1d6c86f1940>,
 <torchtext.data.example.Example at 0x1d6c86fed30>,
 <torchtext.data.example.Example at 0x1d6c86fecc0>)

我们所有的元素都是example.Example类的对象。每个示例将每一列存储为一个属性。但我们的文本和标签去哪里了?

trn[0].__dict__.keys()
> dict_keys(['comment_text', 'toxic', 'severe_toxic', 'threat', 'obscene', 'insult', 'identity_hate']

Example对象将单个数据点的属性捆绑在一起。我们的comment_textlabels现在都是这些示例对象组成的字典的一部分。我们通过在example.Example对象上调用__dict__.keys()找到了所有这些对象:

trn[0].__dict__['comment_text'][:5]
> ['explanation', 'why', 'the', 'edits', 'made']

文本已经被我们分词了,但还没有被向量化或数值化。我们将使用独热编码来处理训练语料库中存在的所有标记。这将把我们的单词转换成整数。

我们可以通过调用我们的TEXT字段的build_vocab属性来完成这个操作:

TEXT.build_vocab(trn)

这个语句处理整个训练数据——特别是comment_text字段。单词被注册到词汇表中。

为了处理词汇表,torchtext有自己的类。Vocab类也可以接受如max_sizemin_freq等选项,这些选项可以让我们知道词汇表中有多少单词,或者一个单词需要出现多少次才能被注册到词汇表中。

不在词汇表中的单词将被转换为 <unk>,表示 未知 的标记。过于罕见的单词也会被分配 <unk> 标记以简化处理。这可能会损害或帮助模型的性能,具体取决于我们失去了多少单词给 <unk> 标记:

TEXT.vocab
> <torchtext.vocab.Vocab at 0x1d6c65615c0>

TEXT 字段现在有一个词汇属性,它是 Vocab 类的特定实例。我们可以利用这一点来查找词汇对象的属性。例如,我们可以找到训练语料库中任何单词的频率。TEXT.vocab.freqs 对象实际上是 type collections.Counter 的对象:

type(TEXT.vocab.freqs)
> collections.Counter

这意味着它将支持所有功能,包括按频率排序单词的 most_common API,并为我们找到出现频率最高的前 k 个单词。让我们来看看它们:

TEXT.vocab.freqs.most_common(5)
> [('the', 78), ('to', 41), ('you', 33), ('of', 30), ('and', 26)]

Vocab 类在其 stoi 属性中持有从单词到 id 的映射,并在其 itos 属性中持有反向映射。让我们看看这些属性:

type(TEX

T.vocab.itos), type(TEXT.vocab.stoi), len(TEXT.vocab.itos), len(TEXT.vocab.stoi.keys())
> (list, collections.defaultdict, 784, 784)

itos,或整数到字符串映射,是一个单词列表。列表中每个单词的索引是其整数映射。例如,索引为 7 的单词将是 and,因为它的整数映射是 7。

stoi,或字符串到整数映射,是一个单词的字典。每个键是训练语料库中的一个单词,其值是一个整数。例如,“and”这个单词可能有一个整数映射,可以在该字典中以 O(1) 的时间复杂度查找。

注意,此约定自动处理由 Python 中的零索引引起的偏移量问题:

TEXT.vocab.stoi['and'], TEXT.vocab.itos[7]
> (7, 'and')

Iterators

torchtext 对 PyTorch 和 torchvision 中的 DataLoader 对象进行了重命名和扩展。本质上,它执行相同的三个任务:

  • 批量加载数据

  • 打乱数据

  • 使用 multiprocessing 工作者并行加载数据

这种批量加载数据使我们能够处理比 GPU RAM 大得多的数据集。Iterators 扩展并专门化 DataLoader 以用于 NLP/文本处理应用。

我们将在这里使用 Iterator 和它的表亲 BucketIterator

from torchtext.data import Iterator, BucketIterator

BucketIterator

BucketIterator 自动打乱并将输入序列桶化为相似长度的序列。

为了启用批量处理,我们需要具有相同长度的输入序列的批次。这是通过将较短的输入序列填充到批次中最长序列的长度来完成的。查看以下代码:

[ [3, 15, 2, 7], 
  [4, 1], 
  [5, 5, 6, 8, 1] ]

这需要填充以成为以下内容:

[ [3, 15, 2, 7, 0],
  [4, 1, 0, 0, 0],
  [5, 5, 6, 8, 1] ]

此外,当序列长度相似时,填充操作效率最高。BucketIterator 在幕后完成所有这些。这就是它成为文本处理中极其强大的抽象的原因。

我们希望桶排序基于 comment_text 字段的长度,因此我们将它作为关键字参数传递。

让我们继续初始化训练数据和验证数据的迭代器:

train_iter, val_iter = BucketIterator.splits(
        (trn, vld), # we pass in the datasets we want the iterator to draw data from
        batch_sizes=(32, 32),
        sort_key=lambda x: len(x.comment_text), # the BucketIterator needs to be told what function it should use to group the data.
        sort_within_batch=False,
        repeat=False # we pass repeat=False because we want to wrap this Iterator layer.
)

让我们快速看一下传递给此函数的参数:

  • batch_size: 我们在训练和验证中都使用较小的批处理大小 32。这是因为我在使用只有 3 GB 内存 GTX 1060。

  • sort_key: BucketIterator 被告知使用 comment_text 中的标记数量作为排序的键在任何示例中。

  • sort_within_batch: 当设置为 True 时,它根据 sort_key 以降序对每个小批处理中的数据进行排序。

  • repeat: 当设置为 True 时,它允许我们循环遍历并再次看到之前看到的样本。我们在这里将其设置为 False,因为我们正在使用我们将在一分钟内编写的抽象进行重复。

同时,让我们花一分钟时间探索我们刚刚创建的新变量:

train_iter

> <torchtext.data.iterator.BucketIterator at 0x1d6c8776518>

batch = next(train_iter.__iter__())
batch

> [torchtext.data.batch.Batch of size 25]
        [.comment_text]:[torch.LongTensor of size 494x25]
        [.toxic]:[torch.LongTensor of size 25]
        [.severe_toxic]:[torch.LongTensor of size 25]
        [.threat]:[torch.LongTensor of size 25]
        [.obscene]:[torch.LongTensor of size 25]
        [.insult]:[torch.LongTensor of size 25]
        [.identity_hate]:[torch.LongTensor of size 25]

现在,每个批处理都只有大小完全相同的 torch 张量(这里的尺寸是向量的向量的向量的向量的向量的向量的向量的长度)。这些张量还没有被移动到 GPU 上,但这没关系。

batch 实际上是已经熟悉的示例对象的包装器,它将所有与批处理相关的属性捆绑在一个变量字典中:

batch.__dict__.keys()
> dict_keys(['batch_size', 'dataset', 'fields', 'comment_text', 'toxic', 'severe_toxic', 'threat', 'obscene', 'insult', 'identity_hate'])

如果我们的先前的理解是正确的,并且我们知道 Python 的对象传递是如何工作的,那么批处理变量的数据集属性应该指向 torchtext.data.TabularData 类型的 trn 变量。让我们检查这一点:

batch.__dict__['dataset'], trn, batch.__dict__['dataset']==trn

哈哈!我们做对了。

对于测试迭代器,由于我们不需要洗牌,我们将使用普通的 torchtext Iterator

test_iter = Iterator(tst, batch_size=64, sort=False, sort_within_batch=False, repeat=False)

让我们看看这个迭代器:

next(test_iter.__iter__())
> [torchtext.data.batch.Batch of size 33]
  [.comment_text]:[torch.LongTensor of size 158x33]

这里 33 的序列长度与输入的 25 不同。这没关系。我们可以看到这现在也是一个 torch 张量。

接下来,让我们为批处理对象编写一个包装器。

BatchWrapper

在我们深入探讨 BatchWrapper 之前,让我告诉你批处理对象的问题所在。我们的批处理迭代器返回一个自定义数据类型,torchtext.data.Batch。这类似于多个 example.Example。它返回每个字段的批处理数据作为属性。这种自定义数据类型使得代码重用变得困难,因为每次列名更改时,我们需要修改代码。这也使得 torchtext 难以与其他库如 torchsample 和 fastai 一起使用。

那么,我们如何解决这个问题呢?

我们将批处理转换为形式为 (x, y) 的元组。x 是模型的输入,y 是目标 – 或者更传统地,x 是自变量,而 y 是因变量。一种思考方式是,模型将学习从 x 到 y 的函数映射。

BatchWrapper 帮助我们在不同数据集之间重用建模、训练和其他代码函数:

class BatchWrapper: 
  def __init__(self, dl, x_var, y_vars): 
      self.dl, self.x_var, self.y_vars = dl, x_var, y_vars # we pass in the list of attributes for x and y

  def __iter__(self): 
      for batch in self.dl: 
          x = getattr(batch, self.x_var) # we assume only one input in this wrapper 
          if self.y_vars is not None: 
                # we will concatenate y into a single tensor 
                y = torch.cat([getattr(batch, feat).unsqueeze(1) for feat in self.y_vars], dim=1).float()
                 else: y = torch.zeros((1)) if use_gpu: yield (x.cuda(), y.cuda()) else: yield (x, y) 

   def __len__(self): return len(self.dl)

BatchWrapper 类在初始化期间接受迭代器变量本身、变量 x 名称和变量 y 名称。它产生张量 x 和 y。x 和 y 的值通过 getattrself.dl 中的 batch 中查找。

如果 GPU 可用,这个类将使用 x.cuda()y.cuda() 将这些张量移动到 GPU 上,使其准备好被模型消费。

让我们快速使用这个新类包装我们的trainvaltest iter对象:

train_dl = BatchWrapper(train_iter, "comment_text", ["toxic", "severe_toxic", "obscene", "threat", "insult", "identity_hate"])

valid_dl = BatchWrapper(val_iter, "comment_text", ["toxic", "severe_toxic", "obscene", "threat", "insult", "identity_hate"])

test_dl = BatchWrapper(test_iter, "comment_text", None)

这返回了最简单的迭代器,准备好进行模型处理。请注意,在这种情况下,张量有一个设置为cuda:0的"device"属性。让我们预览一下:

next(train_dl.__iter__())

> (tensor([[ 453,   63,   15,  ...,  454,  660,  778],
         [ 523,    4,  601,  ...,   78,   11,  650],
         ...,
         [   1,    1,    1,  ...,    1,    1,    1],
         [   1,    1,    1,  ...,    1,    1,    1]], device='cuda:0'),
 tensor([[ 0.,  0.,  0.,  0.,  0.,  0.],
         [ 0.,  0.,  0.,  0.,  0.,  0.],
         ...,
         [ 0.,  0.,  0.,  0.,  0.,  0.],
         [ 0.,  0.,  0.,  0.,  0.,  0.]], device='cuda:0'))

训练文本分类器

我们现在准备好训练我们的文本分类器模型了。让我们从简单的事情开始:现在我们将这个模型视为一个黑盒。

模型架构的更好解释来自其他来源,包括斯坦福大学的CS224n等YouTube视频(http://web.stanford.edu/class/cs224n/)。我建议您探索并将其与您已有的知识相结合:

class SimpleLSTMBaseline(nn.Module):
    def __init__(self, hidden_dim, emb_dim=300,
                 spatial_dropout=0.05, recurrent_dropout=0.1, num_linear=2):
        super().__init__() # don't forget to call this!
        self.embedding = nn.Embedding(len(TEXT.vocab), emb_dim)
        self.encoder = nn.LSTM(emb_dim, hidden_dim, num_layers=num_linear, dropout=recurrent_dropout)
        self.linear_layers = []
        for _ in range(num_linear - 1):
            self.linear_layers.append(nn.Linear(hidden_dim, hidden_dim))
        self.linear_layers = nn.ModuleList(self.linear_layers)
        self.predictor = nn.Linear(hidden_dim, 6)

    def forward(self, seq):
        hdn, _ = self.encoder(self.embedding(seq))
        feature = hdn[-1, :, :]
        for layer in self.linear_layers:
            feature = layer(feature)
        preds = self.predictor(feature)
        return preds

所有PyTorch模型都继承自torch.nn.Module。它们都必须实现forward函数,该函数在模型做出预测时执行。相应的用于训练的backward函数是自动计算的。

初始化模型

任何Pytorch模型都是像Python对象一样实例化的。与TensorFlow不同,其中没有严格的会话对象概念,代码是在其中编译然后运行的。模型类就像我们之前写的那样。

前一个类的init函数接受一些参数:

  • hidden_dim:这些是隐藏层维度,即隐藏层的向量长度

  • emb_dim=300:这是一个嵌入维度,即LSTM的第一个输入的向量长度

  • num_linear=2:其他两个dropout参数:

    • spatial_dropout=0.05

    • recurrent_dropout=0.1

两个dropout参数都充当正则化器。它们有助于防止模型过拟合,即模型最终学习的是训练集中的样本,而不是可以用于做出预测的更通用的模式。

关于dropout之间的差异的一种思考方式是,其中一个作用于输入本身。另一个在反向传播或权重更新步骤中起作用,如前所述:

em_sz = 300
nh = 500
model = SimpleLSTMBaseline(nh, emb_dim=em_sz)
print(model)

SimpleLSTMBaseline(
  (embedding): Embedding(784, 300)
  (encoder): LSTM(300, 500, num_layers=2, dropout=0.1)
  (linear_layers): ModuleList(
    (0): Linear(in_features=500, out_features=500, bias=True)
  )
  (predictor): Linear(in_features=500, out_features=6, bias=True)
)

我们可以打印任何PyTorch模型来查看类的架构。它是从forward函数实现中计算出来的,这正是我们所期望的。这在调试模型时非常有用。

让我们编写一个小工具函数来计算任何PyTorch模型的大小。在这里,我们所说的“大小”是指可以在训练期间更新的模型参数数量,以学习输入到目标映射。

虽然这个函数是在Keras中实现的,但它足够简单,可以再次编写:

def model_size(model: torch.nn)->int:
    """
    Calculates the number of trainable parameters in any model

    Returns:
        params (int): the total count of all model weights
    """
    model_parameters = filter(lambda p: p.requires_grad, model.parameters())
#     model_parameters = model.parameters()
    params = sum([np.prod(p.size()) for p in model_parameters])
    return params

print(f'{model_size(model)/10**6} million parameters')
> 4.096706 million parameters

我们可以看到,即使是我们的简单基线模型也有超过400万个参数。相比之下,一个典型的决策树可能只有几百个决策分支,最多。

接下来,我们将使用熟悉的.cuda()语法将模型权重移动到GPU上:

if use_gpu:
    model = model.cuda()

再次将各个部分组合在一起

这些是我们查看的部分,让我们快速总结一下:

  • 损失函数:二元交叉熵与 Logit 损失。它作为预测值与真实值之间距离的质量指标。

    • 优化器:我们使用默认参数的 Adam 优化器,学习率设置为 1e-2 或 0.01:

这是我们如何在 PyTorch 中看到这两个组件的方式:

from torch import optim
opt = optim.Adam(model.parameters(), lr=1e-2)
loss_func = nn.BCEWithLogitsLoss().cuda()

我们在这里设置模型需要训练的周期数:

epochs = 3

这被设置为一个非常小的值,因为整个笔记本、模型和训练循环只是为了演示目的。

训练循环

训练循环在逻辑上分为两部分:model.train()model.eval()。注意以下代码的放置:

from tqdm import tqdm
for epoch in range(1, epochs + 1):
    running_loss = 0.0
    running_corrects = 0
    model.train() # turn on training mode
    for x, y in tqdm(train_dl): # thanks to our wrapper, we can intuitively iterate over our data!
        opt.zero_grad()
        preds = model(x)
        loss = loss_func(preds, y)
        loss.backward()
        opt.step()

        running_loss += loss.item() * x.size(0)

    epoch_loss = running_loss / len(trn)

    # calculate the validation loss for this epoch
    val_loss = 0.0
    model.eval() # turn on evaluation mode
    for x, y in valid_dl:
        preds = model(x)
        loss = loss_func(preds, y)
        val_loss += loss.item() * x.size(0)

    val_loss /= len(vld)
    print('Epoch: {}, Training Loss: {:.4f}, Validation Loss: {:.4f}'.format(epoch, epoch_loss, val_loss))

前半部分是实际的学习循环。这是循环内的步骤序列:

  1. 将优化器的梯度设置为零

  2. preds 中对这个训练批次进行模型预测

  3. 使用 loss_func 查找损失

  4. 使用 loss.backward() 更新模型权重

  5. 使用 opt.step() 更新优化器状态

整个反向传播的麻烦都在一行代码中处理:

loss.backward()

这种抽象级别暴露了模型的内部结构,而不必担心微分学的方面,这就是为什么像 PyTorch 这样的框架如此方便和有用。

第二个循环是评估循环。这是在数据的验证分割上运行的。我们将模型设置为 eval 模式,这会锁定模型权重。只要 model.eval() 没有被设置回 model.train(),权重就不会意外更新。

在这个第二个循环中,我们只做两件简单的事情:

  • 在验证分割上进行预测

  • 计算此分割的损失

在每个周期结束时,打印出所有验证批次的总损失,以及运行训练损失。

一个训练循环看起来可能如下所示:

100%|████████████████████████████████████████████████████████████████████████████████████| 1/1 [00:00<00:00, 2.34it/s]

Epoch: 1, Training Loss: 13.5037, Validation Loss: 4.6498
100%|████████████████████████████████████████████████████████████████████████████████████| 1/1 [00:00<00:00, 4.58it/s]

Epoch: 2, Training Loss: 7.8243, Validation Loss: 24.5401

100%|████████████████████████████████████████████████████████████████████████████████████| 1/1 [00:00<00:00, 3.35it/s]

Epoch: 3, Training Loss: 57.4577, Validation Loss: 4.0107

我们可以看到,训练循环以低验证损失和高训练损失结束。这可能表明模型或训练和验证数据分割存在问题。没有简单的方法来调试这个问题。

前进的好方法通常是训练模型几个更多个周期,直到观察到损失不再变化。

预测模式

让我们使用我们训练的模型在测试数据上进行一些预测:

test_preds = []
model.eval()
for x, y in tqdm(test_dl):
    preds = model(x)
    # if you're data is on the GPU, you need to move the data back to the cpu
    preds = preds.data.cpu().numpy()
    # the actual outputs of the model are logits, so we need to pass these values to the sigmoid function
    preds = 1 / (1 + np.exp(-preds))
    test_preds.append(preds)
test_preds = np.hstack(test_preds)

整个循环现在处于评估模式,我们使用它来锁定模型权重。或者,我们也可以将 model.train(False) 设置为同样。

我们迭代地从测试迭代器中取出批大小样本,进行预测,并将它们追加到一个列表中。最后,我们将它们堆叠起来。

将预测转换为 pandas DataFrame

这有助于我们将预测结果转换为更易理解的格式。让我们读取测试数据框,并将预测值插入到正确的列中:

test_df = pd.read_csv("data/test.csv")
for i, col in enumerate(["toxic", "severe_toxic", "obscene", "threat", "insult", "identity_hate"]):
    test_df[col] = test_preds[:, i]

现在,我们可以预览 DataFrame 的几行:

test_df.head(3)

我们得到以下输出:

id comment_text toxic severe_toxic obscene threat insult identity_hate
0 00001cee341fdb12 Yo bitch Ja Rule is more succesful then you'll... 0.629146 0.116721 0.438606 0.156848 0.139696 0.388736
1 0000247867823ef7 == From RfC == \r\n\r\n 标题是好的,正如我... 0.629146 0.116721 0.438606 0.156848 0.139696 0.388736
2 00013b17ad220c46 "\r\n\r\n == Sources == \r\n\r\n *. Zawe Ashto... 0.629146 0.116721 0.438606 0.156848 0.139696 0.388736

摘要

这是我们第一次接触深度学习在NLP中的应用。这是一个对torchtext的全面介绍,以及我们如何利用Pytorch来利用它。我们还对深度学习作为一个只有两到三个广泛组成部分的谜团有了非常广泛的了解:模型、优化器和损失函数。这无论你使用什么框架或数据集都是正确的。

为了保持简短,我们在模型架构解释上有所简化。我们将避免使用在其他部分未解释的概念。

当我们使用现代集成方法工作时,我们并不总是知道某个特定预测是如何被做出的。对我们来说,这是一个黑盒,就像所有深度学习模型的预测一样。

在下一章中,我们将探讨一些工具和技术,这些工具和技术将帮助我们窥视这些盒子——至少是更多一点。

第七章:构建自己的聊天机器人

聊天机器人,更确切地说是对话软件,对于许多企业来说都是惊人的工具。它们帮助企业在24/7不间断的服务中服务客户,无需增加工作量,保持一致的质量,并在机器人不足以应对时内置将任务转交给人类的选项。

它们是技术和人工智能结合以改善人力影响的绝佳例子。

它们从基于语音的解决方案,如Alexa,到基于文本的Intercom聊天框,再到Uber中的基于菜单的导航,种类繁多。

一个常见的误解是构建聊天机器人需要大量团队和大量的机器学习专业知识,尽管如果你试图构建像微软或Facebook(甚至Luis、Wit.ai等)这样的通用聊天机器人平台,这确实是正确的。

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

  • 为什么构建聊天机器人?

  • 确定正确的用户意图

  • 机器人响应

为什么以聊天机器人作为学习示例?

到目前为止,我们已经为每一个我们看到的NLP主题构建了一个应用程序:

  • 使用语法和词汇洞察进行文本清理

  • 语言学(和统计解析器),从文本中挖掘问题

  • 实体识别用于信息提取

  • 使用机器学习和深度学习进行监督文本分类

  • 使用基于文本的向量,如GloVe/word2vec进行文本相似度

我们现在将把它们组合成一个更复杂的设置,并从头开始编写我们自己的聊天机器人。但在你从头开始构建任何东西之前,你应该问自己为什么。

为什么构建聊天机器人?

相关的问题是为什么我们应该构建自己的聊天机器人?为什么我不能使用FB/MSFT/其他云服务?

可能,一个更好的问题是要问自己何时开始构建自己的东西?在做出这个决定时,以下是一些需要考虑的因素:

隐私和竞争:作为一个企业,与Facebook或Microsoft(甚至更小的公司)分享有关用户的信息是个好主意吗?

成本和限制:你那奇特的云服务限制了特定智能提供商做出的设计选择,这些选择类似于谷歌或Facebook。此外,你现在需要为每个HTTP调用付费,这比在本地运行代码要慢。

自由定制和扩展:你可以开发一个更适合你的解决方案!你不必解决世界饥饿——只需通过高质量的软件不断提供越来越多的商业价值。如果你在大公司工作,你更有理由投资于可扩展的软件。

快速代码意味着词向量和方法

为了简化起见,我们将假设我们的机器人不需要记住任何问题的上下文。因此,它看到输入,对其做出响应,然后完成。与之前的输入不建立任何链接。

让我们先简单地使用gensim加载词向量:

import numpy as np
import gensim
print(f"Gensim version: {gensim.__version__}")

from tqdm import tqdm
class TqdmUpTo(tqdm):
    def update_to(self, b=1, bsize=1, tsize=None):
        if tsize is not None: self.total = tsize
        self.update(b * bsize - self.n)

def get_data(url, filename):
    """
    Download data if the filename does not exist already
    Uses Tqdm to show download progress
    """
    import os
    from urllib.request import urlretrieve

    if not os.path.exists(filename):

        dirname = os.path.dirname(filename)
        if not os.path.exists(dirname):
            os.makedirs(dirname)

        with TqdmUpTo(unit='B', unit_scale=True, miniters=1, desc=url.split('/')[-1]) as t:
            urlretrieve(url, filename, reporthook=t.update_to)
    else:
        print("File already exists, please remove if you wish to download again")

embedding_url = 'http://nlp.stanford.edu/data/glove.6B.zip'
get_data(embedding_url, 'data/glove.6B.zip')

呼吸,这可能会根据你的下载速度而花费一分钟。一旦完成,让我们解压缩文件,将其放入数据目录,并将其转换为word2vec格式:

# !unzip data/glove.6B.zip 
# !mv -v glove.6B.300d.txt data/glove.6B.300d.txt 
# !mv -v glove.6B.200d.txt data/glove.6B.200d.txt 
# !mv -v glove.6B.100d.txt data/glove.6B.100d.txt 
# !mv -v glove.6B.50d.txt data/glove.6B.50d.txt 

from gensim.scripts.glove2word2vec import glove2word2vec
glove_input_file = 'data/glove.6B.300d.txt'
word2vec_output_file = 'data/glove.6B.300d.txt.word2vec'
import os
if not os.path.exists(word2vec_output_file):
    glove2word2vec(glove_input_file, word2vec_output_file)

到前一个代码块结束时,我们已经将来自官方斯坦福源的300维GloVe嵌入转换成了word2vec格式。

让我们将这个加载到我们的工作记忆中:

%%time
from gensim.models import KeyedVectors
filename = word2vec_output_file
embed = KeyedVectors.load_word2vec_format(word2vec_output_file, binary=False)

让我们快速检查我们是否可以通过检查任何单词的词嵌入来矢量化任何单词,例如,awesome

assert embed['awesome'] is not None

awesome,这行得通!

现在,让我们看看我们的第一个挑战。

确定正确的用户意图

这通常被称为意图分类问题。

作为玩具示例,我们将尝试构建一个DoorDash/Swiggy/Zomato等可能使用的订单机器人。

用例 - 食物订单机器人

考虑以下示例句子:我在Indiranagar找一个便宜的中国餐馆

我们想在句子中挑选出中国作为一个菜系类型。显然,我们可以采取简单的办法,比如精确子串匹配(搜索Chinese)或基于TF-IDF的匹配。

相反,我们将泛化模型以发现我们可能尚未识别但可以通过GloVe嵌入学习的菜系类型。

我们将尽可能简单:我们将提供一些示例菜系类型来告诉模型我们需要菜系,并寻找句子中最相似的单词。

我们将遍历句子中的单词,并挑选出与参考单词相似度高于某个阈值的单词。

词向量真的适用于这种情况吗?

cuisine_refs = ["mexican", "thai", "british", "american", "italian"]
sample_sentence = "I’m looking for a cheap Indian or Chinese place in Indiranagar"

为了简单起见,以下代码以for循环的形式编写,但可以矢量化以提高速度。

我们将遍历输入句子中的每个单词,并找到与已知菜系单词的相似度得分。

值越高,这个词就越有可能与我们的菜系参考或cuisine_refs相关:

tokens = sample_sentence.split()
tokens = [x.lower().strip() for x in tokens] 
threshold = 18.3
found = []
for term in tokens:
    if term in embed.vocab:
        scores = []
        for C in cuisine_refs:
            scores.append(np.dot(embed[C], embed[term].T))
            # hint replace above above np.dot with: 
            # scores.append(embed.cosine_similarities(<vector1>, <vector_all_others>))
        mean_score = np.mean(scores)
        print(f"{term}: {mean_score}")
        if mean_score > threshold:
            found.append(term)
print(found)

以下是对应的输出:

looking: 7.448504447937012
for: 10.627421379089355
a: 11.809560775756836
cheap: 7.09670877456665
indian: 18.64516258239746
or: 9.692893981933594
chinese: 19.09498405456543
place: 7.651237487792969
in: 10.085711479187012
['indian', 'chinese']

阈值是通过经验确定的。注意,我们能够推断出印度中国作为菜系,即使它们不是原始集合的一部分。

当然,精确匹配将会有更高的分数。

这是一个很好的例子,其中在通用菜系类型方面有更好的问题表述,这比基于字典的菜系类型更有帮助。这也证明了我们可以依赖基于词向量的方法。

我们能否将此扩展到用户意图分类?让我们尝试下一步。

分类用户意图

我们希望能够通过用户的意图将句子分类。意图是一种通用机制,它将多个个别示例组合成一个语义伞。例如,hihey早上好wassup!都是_greeting_意图的例子。

使用问候作为输入,后端逻辑可以确定如何响应用户。

我们有很多种方法可以将词向量组合起来表示一个句子,但同样,我们将采取最简单的方法:将它们相加。

这绝对不是一个理想的解决方案,但由于我们使用简单、无监督的方法,它在实践中是可行的:

def sum_vecs(embed,text):

    tokens = text.split(' ')
    vec = np.zeros(embed.vector_size)

    for idx, term in enumerate(tokens):
        if term in embed.vocab:
            vec = vec + embed[term]
    return vec

sentence_vector = sum_vecs(embed, sample_sentence)
print(sentence_vector.shape)
>> (300,)

让我们定义一个数据字典,为每个意图提供一些示例。

我们将使用由Alan在Rasa博客编写的数据字典来完成这项工作。

由于我们有更多的用户输入,这个字典可以被更新:

data={
  "greet": {
    "examples" : ["hello","hey you","howdy","hello","hi","hey there","hey ho", "ssup?"],
    "centroid" : None
  },
  "inform": {
    "examples" : [
        "i'd like something asian",
        "maybe korean",
        "what swedish options do i have",
        "what italian options do i have",
        "i want korean food",
        "i want vegetarian food",
        "i would like chinese food",
        "what japanese options do i have",
        "vietnamese please",
        "i want some chicken",
        "maybe thai",
        "i'd like something vegetarian",
        "show me British restaurants",
        "show me a cool malay spot",
        "where can I get some spicy food"
    ],
    "centroid" : None
  },
  "deny": {
    "examples" : [
      "no thanks"
      "any other places ?",
      "something else",
      "naah",
      "not that one",
      "i do not like that",
      "something else",
      "please nooo"
      "show other options?"
    ],
    "centroid" : None
  },
    "affirm":{
        "examples":[
            "yeah",
            "that works",
            "good, thanks",
            "this works",
            "sounds good",
            "thanks, this is perfect",
            "just what I wanted"
        ],
        "centroid": None
    }

}

我们的方法很简单:我们找到每个用户意图的重心。重心只是一个表示每个意图的中心点。然后,将传入的文本分配给最接近相应聚类的用户意图。

让我们写一个简单的函数来找到重心并更新字典:

def get_centroid(embed,examples):
     C = np.zeros((len(examples),embed.vector_size))
     for idx, text in enumerate(examples):
         C[idx,:] = sum_vecs(embed,text)

     centroid = np.mean(C,axis=0)
     assert centroid.shape[0] == embed.vector_size
     return centroid

让我们把这个重心加到数据字典里:

for label in data.keys():
    data[label]["centroid"] = get_centroid(embed,data[label]["examples"])

让我们现在写一个简单的函数来找到最近的用户意图聚类。我们将使用已经在np.linalg中实现的L2范数:

def get_intent(embed,data, text):
    intents = list(data.keys())
    vec = sum_vecs(embed,text)
    scores = np.array([ np.linalg.norm(vec-data[label]["centroid"]) for label in intents])
    return intents[np.argmin(scores)]

让我们在一些用户文本上运行这个,这些文本不在数据字典中:

for text in ["hey ","i am looking for chinese food","not for me", "ok, this is good"]:
    print(f"text : '{text}', predicted_label : '{get_intent(embed, data, text)}'")

相应的代码很好地推广了,并且令人信服地表明,这对于我们花了大约10-15分钟到达这个点来说已经足够好了:

text : 'hey ', predicted_label : 'greet'
text : 'i am looking for chinese food', predicted_label : 'inform'
text : 'not for me', predicted_label : 'deny'
text : 'ok, this is good', predicted_label : 'affirm'

机器人回应

我们现在知道了如何理解和分类用户意图。我们现在需要简单地用一些相应的回应来响应每个用户意图。让我们把这些模板机器人回应放在一个地方:

templates = {
        "utter_greet": ["hey there!", "Hey! How you doin'? "],
        "utter_options": ["ok, let me check some more"],
        "utter_goodbye": ["Great, I'll go now. Bye bye", "bye bye", "Goodbye!"],
        "utter_default": ["Sorry, I didn't quite follow"],
        "utter_confirm": ["Got it", "Gotcha", "Your order is confirmed now"]
    }

Response映射存储在单独的实体中很有帮助。这意味着你可以从你的意图理解模块中生成回应,然后将它们粘合在一起:

response_map = {
    "greet": "utter_greet",
    "affirm": "utter_goodbye",
    "deny": "utter_options",
    "inform": "utter_confirm",
    "default": "utter_default",
}

如果我们再深入思考一下,就没有必要让回应映射仅仅依赖于被分类的意图。你可以将这个回应映射转换成一个单独的函数,该函数使用相关上下文生成映射,然后选择一个机器人模板。

但在这里,为了简单起见,让我们保持字典/JSON风格的格式。

让我们写一个简单的get_bot_response函数,它接受回应映射、模板和意图作为输入,并返回实际的机器人回应:

import random
def get_bot_response(bot_response_map, bot_templates, intent):
    if intent not in list(response_map):
        intent = "default"
    select_template = bot_response_map[intent]
    templates = bot_templates[select_template]
    return random.choice(templates)

让我们快速尝试一句话:

user_intent = get_intent(embed, data, "i want indian food")
get_bot_response(response_map, templates, user_intent)

代码目前没有语法错误。这似乎可以进行更多的性能测试。但在那之前,我们如何使它更好?

更好的回应个性化

你会注意到,该函数会随机选择一个模板来响应任何特定的机器人意图。虽然这里是为了简单起见,但在实践中,你可以训练一个机器学习模型来选择一个针对用户的个性化回应。

一个简单的个性化调整是适应用户的说话/打字风格。例如,一个用户可能会用正式的方式,你好,今天过得怎么样?,而另一个用户可能会用更非正式的方式,Y**o

因此,Hello会得到Goodbye!的回应,而Yo!在同一对话中可能会得到Bye bye甚至TTYL

目前,让我们检查一下我们已经看到的句子的机器人回应:

for text in ["hey","i am looking for italian food","not for me", "ok, this is good"]:
    user_intent = get_intent(embed, data, text)
    bot_reply = get_bot_response(response_map, templates, user_intent)
    print(f"text : '{text}', intent: {user_intent}, bot: {bot_reply}")

由于随机性,回应可能会有所不同;这里是一个例子:

text : 'hey', intent: greet, bot: Hey! How you doin'? 
text : 'i am looking for italian food', intent: inform, bot: Gotcha
text : 'not for me', intent: deny, bot: ok, let me check some more
text : 'ok, this is good', intent: affirm, bot: Goodbye!

摘要

在本章关于聊天机器人的内容中,我们学习了意图,通常指的是用户输入,响应,通过机器人进行,模板,定义了机器人响应的性质,以及实体,例如在我们的例子中是菜系类型。

此外,为了理解用户意图——甚至找到实体——我们使用了无监督方法,也就是说,这次我们没有训练示例。在实践中,大多数商业系统使用混合系统,结合了监督和无监督系统。

你应该从这里带走的一点是,我们不需要大量的训练数据来制作特定用例的第一个可用的聊天机器人版本。

第八章:网络部署

到目前为止,我们一直专注于让某件事第一次工作,然后进行增量更新。这些更新几乎总是针对更好的技术和更好的可用性。但是,我们如何向用户展示它们呢?一种方法是通过REST端点。

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

  • 训练模型,并为数据I/O编写一些更简洁的实用工具

  • 建立预测函数,与训练分离

  • 使用Flask REST端点公开我们所涵盖的内容

网络部署

这是黑客马拉松版本,更有经验的工程师会注意到我们为了节省开发者时间而忽略了大量最佳实践。为了自我辩护,我确实添加了相当实用的日志记录功能。

我们将从我们之前讨论使用机器学习方法进行文本分类的地方开始。我们留下了一些未解决的问题:

  • 模型持久化:我如何将模型、数据和代码写入磁盘?

  • 模型加载和预测:我如何从磁盘加载模型数据和代码?

  • Flask用于REST端点:我如何通过网络公开加载的模型?

如果你从这个章节中学到了什么,应该是前面的三个问题。如果你对如何解决这三个问题有一个清晰和完整的概念,那么你的战斗就赢了。

我们将使用scikit-learn模型以及我们熟悉的基于TF-IDF的管道进行这个演示。

模型持久化

第一个挑战是将模型数据和代码写入磁盘。让我们先从训练管道开始。

让我们先把导入的部分处理掉:

import gzip
import logging
import os
from pathlib import Path
from urllib.request import urlretrieve
import numpy as np
import pandas as pd
from sklearn.externals import joblib
from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer
from sklearn.linear_model import LogisticRegression as LR
from sklearn.pipeline import Pipeline
from tqdm import tqdm

让我们编写一些从文本文件中读取数据以及如果不存在则下载它们的实用工具:

让我们先为我们的使用设置一个下载进度条。我们将通过在tqdm包上构建一个小抽象来实现这一点:

class TqdmUpTo(tqdm):
    def update_to(self, b=1, bsize=1, tsize=None):
        if tsize is not None:
        self.total = tsize
        self.update(b * bsize - self.n)

让我们使用前面的tqdm进度信息来定义一个下载实用工具:

def get_data(url, filename):
    """
    Download data if the filename does not exist already
    Uses Tqdm to show download progress
    """
    if not os.path.exists(filename):
        dirname = os.path.dirname(filename)
        if not os.path.exists(dirname):
            os.makedirs(dirname)
        with TqdmUpTo(unit="B", unit_scale=True, miniters=1, desc=url.split("/")[-1]) as t:
            urlretrieve(url, filename, reporthook=t.update_to)

注意到这个实用工具使用os而不是pathlib,这在文本的其他部分是首选的。这既是为了多样性,也是因为os在Python 2中同样有效,而pathlib最好与Python 3.4或更高版本一起使用。作为提醒,这本书假设你正在使用Python 3.6代码。

现在我们已经有一个get_data实用工具,让我们编写一个read_data实用工具,它针对我们的特定数据集进行了定制:

def read_data(dir_path):
    """read data into pandas dataframe"""
    def load_dir_reviews(reviews_path):
         files_list = list(reviews_path.iterdir())
         reviews = []
         for filename in files_list:
         f = open(filename, "r", encoding="utf-8")
         reviews.append(f.read())
         return pd.DataFrame({"text": reviews})
    pos_path = dir_path / "pos"
    neg_path = dir_path / "neg"
    pos_reviews, neg_reviews = load_dir_reviews(pos_path), load_dir_reviews(neg_path)
    pos_reviews["label"] = 1
    neg_reviews["label"] = 0
    merged = pd.concat([pos_reviews, neg_reviews])
    df = merged.sample(frac=1.0) # shuffle the rows
    df.reset_index(inplace=True) # don't carry index from previous
    df.drop(columns=["index"], inplace=True) # drop the column 'index'
    return df

pandas DataFrames使我们的代码更容易阅读、管理和调试。此外,这个函数实际上使用Python嵌套函数来提高代码重用性。注意,对于正面和负面评论,我们都使用同一个内部函数来为我们执行I/O。

现在让我们导入这些实用工具:

from utils import get_data, read_data

我已经从Python 3的logging模块定义了一个日志记录器,包括文件处理程序和控制台处理程序。由于这是一个众所周知且已确立的最佳实践,我在这里将跳过它,直接使用日志记录器:

data_path = Path(os.getcwd()) / "data" / "aclImdb"
logger.info(data_path)

data_path 变量现在包含了从 aclImdb 提取的文件夹和文件。请注意,这个提取不是通过代码完成的,而是由用户在代码外部完成的。

这是因为从 *.tar.gz*.tgz 的提取是依赖于操作系统的。现在你应该注意到的另一件事是我们已经从带有穿插打印语句和预览的笔记本转向了本节中的 Python 脚本。

我们必须下载压缩文件——它略大于 110 MB——如果目标位置不存在:

if not data_path.exists():
    data_url = "http://files.fast.ai/data/aclImdb.tgz"
    get_data(data_url, "data/imdb.tgz")

在尝试读取之前,先离线提取文件:

train_path = data_path / "train"
# load data file as dict object
train = read_data(train_path)

train 变量现在是一个包含两列的 DataFrame:原始 文本标签。标签是 posneg,分别代表正面或负面。标签表示评论的整体情感。我们将这些分开成两个变量:X_trainy_train


# extract the images (X) and labels (y) from the dict
X_train, y_train = train["text"], train["label"]

接下来,让我们定义我们想要执行的操作 Pipeline。使用 TF-IDF 表示的逻辑回归模型是训练模型最简单、最快的方式,并且性能相当不错。我们在这里将使用它,但你实际上可以(并且通常应该)用你在测试数据上性能最好的任何东西来替换它:

lr_clf = Pipeline(
 [("vect", CountVectorizer()), ("tfidf", TfidfTransformer()), ("clf", LR())]
)
lr_clf.fit(X=X_train, y=y_train)

一旦我们调用 .fit 函数,我们就已经训练了我们的文本分类管道。

熟悉 Python 的人可能会记得 pickle 或 cPickle。Pickle 是一个 Python 原生的工具,用于将对象和其他 Python 数据结构以二进制形式保存到磁盘,以便稍后重用。joblib 是 pickle 的改进!

joblib 是一个改进,因为它还缓存了 带有数据的代码,这对于我们的用例来说非常棒。我们不必担心在 Web API 层定义管道。它不再与我们的特定模型绑定,这意味着我们可以通过简单地更改底层的 joblib.dump 文件来持续发布更好的版本。

作为对经典 Python pickle 的致敬,我们将给这个缓存的代码和 model.pkl 数据文件赋予 .pkl 扩展名:

# save model
joblib.dump(lr_clf, "model.pkl")

就这样!我们现在已经将我们的代码和数据逻辑写入了一个单一的二进制文件。

我们实际上会如何使用它呢?让我们看看下一步。

模型加载和预测

下一个挑战实际上是加载我们的 pickle 文件中的模型并使用它进行预测。

让我们先从从磁盘加载模型开始:

from sklearn.externals import joblib
model = joblib.load("model.pkl")

model 变量现在应该暴露出原始 lr_clf 对象所拥有的所有函数。在所有这些方法中,我们感兴趣的是 predict 函数。

但在我们使用它之前,让我们从磁盘加载一些文件来进行预测:

# loading one example negative review
with open(r".\\data\\aclImdb\\train\neg\\1_1.txt", "r") as infile:
    test_neg_contents = infile.read()

# loading one example positive review
with open(r".\\data\\aclImdb\\train\pos\\0_9.txt", "r") as infile:
    test_pos_contents = infile.read()

我们现在可以将这些变量作为一个列表传递给 predict 方法:

predictions = model.predict([test_neg_contents, test_pos_contents])

此时 predictions 变量包含什么内容?

它是一个列表吗?是一个 numpy 数组?或者只是一个整数?

你可以通过以下代码进行检查:

print(predictions)
> [0 1]

for p in predictions:
    print("pos" if p else "neg")

> neg
> pos

如我们所见,预测结果是一个整数列表,与我们读取训练文件中的y_train变量方式相同。让我们继续将在这里学到的知识应用到网络界面和REST端点中。

Flask用于Web部署

让我们先处理导入:

import logging
import flask
import os
import numpy as np
from flask import Flask, jsonify, render_template, request
from sklearn.externals import joblib

我假设作为程序员,你可以在本书之外掌握Flask基础知识。即便如此,为了完整性,我添加了与我们相关的主要思想:

  • 主要的Web应用程序定义在Flask模块中,该模块是从Flask导入的。

  • jsonify将任何JSON友好的字典转换为可以返回给用户的JSON。

  • render_template是我们向用户公开HTML页面和Web界面的方式。

让我们先声明我们的应用程序:

app = Flask(__name__)

接下来,我们将使用route函数装饰我们的Python函数,并将它们公开为REST端点。让我们先公开一个始终开启的简单状态端点,并在服务运行时返回200。

@app.route("/status", methods=["GET"])
def get_status():
    return jsonify({"version": "0.0.1", "status": True})

methods变量通常是一个包含值GETPOST或两者的字符串列表。GET用于不需要用户信息的HTTP(S) GET调用,除了GET调用中已经包含的信息。HTTP POST调用从客户端(如浏览器)向服务器提供额外数据。

这可以通过在浏览器中点击/status端点来访问。

尝试一下。

哎呀!我们忘记先运行应用程序本身了。

让我们继续以调试模式运行应用程序。调试模式允许我们添加和编辑代码,并在每次保存时自动加载代码:

if __name__ == "__main__":
    # load ml model from disk
    model = joblib.load("model.pkl")
    # start api
    app.run(host="0.0.0.0", port=8000, debug=True)

注意,我们像之前一样从joblib加载了model变量。这段代码位于api.py文件的末尾。这非常草率,没有并发支持,也没有与nginx集成,但所有这些都适合这个演示。

现在我们从浏览器中点击localhost:8000/status端点会发生什么?

我们得到了状态码200,数据字段包含我们的JSON版本和状态信息。太好了。

让我们继续添加我们的/predict端点。以下是此函数将执行的步骤概述:

  1. 它将检查这确实是一个POST方法。如果是,它将从flask.request.files中的*file*键提取文件信息。

  2. 然后,它将此文件写入磁盘并再次读取,然后将字符串文本作为列表的单个元素传递给model.predict

  3. 最后,它将在可选删除写入磁盘的文件后,将结果返回到Web界面中的HTML:

@app.route("/predict", methods=["POST"])
def make_prediction():
    if request.method == "POST":
        # get uploaded file if it exists
        logger.debug(request.files)
        f = request.files["file"]
        f.save(f.filename) # save file to disk
        logger.info(f"{f.filename} saved to disk")
        # read file from disk
        with open(f.filename, "r") as infile:
            text_content = infile.read()
            logger.info(f"Text Content from file read")
        prediction = model.predict([text_content])
        logger.info(f"prediction: {prediction}")
        prediction = "pos" if prediction[0] == 1 else "neg"
        os.remove(f.filename)
    return flask.render_template("index.html", label=prediction)

显然,如果我们只是稍后删除它,将文件写入磁盘的步骤是多余的。在实践中,我保留文件在磁盘上,因为这有助于调试,在某些情况下,也有助于理解用户在实际使用中如何使用API。

在前面的代码片段中,你可能已经注意到我们返回了一个带有 label 值的 index.html 文件。这个标签是作为 Jinja2 模板的一部分设置的。变量在 index.html 本身中使用,并在渲染页面时更新其值。

这是我们将要使用的 index.html

<html>
<head>
<title>Text Classification model as a Flask API</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>

<body>
<h1>Movie Sentiment Analysis</h1>
<form action="/predict" method="post" enctype="multipart/form-data">
 <input type="file" name="file" value="Upload">
 <input type="submit" value="Predict"> 
 <p>Prediction: {% if label %} {{ label }} {% endif %}</p>
</form>
</body>
</html>

这就是 HTML 的样子:

预测:pos 实际上是来自我之前上传到这个页面的文件的结果。这由实际 HTML 中的 {%%} 语法标记:

Prediction: {% if label %} {{ label }} {% endif %}

因此,我们在基于 Flask 的网络部署部分看到了一些内容:

  • 你如何在 Flask 网络服务器上接收上传的文件?

  • 你如何使用网络界面上传文件?

  • 另外,作为额外奖励:用于显示返回答案的 Jinja 模板

值得注意的是,我们可以通过分离返回值使这个功能更加通用。这将用于人类用户,我们返回 HTML,以及用于机器用户,我们返回 JSON。我将这个函数重构作为你的练习。

显然,我们也可以用 Django 或任何其他网络框架来做这件事。我选择 Flask 的唯一原因是为了演示目的,并且它非常轻量级,没有关注模型-视图-控制器分离。

摘要

本章的关键要点应该是任何机器学习模型都可以像其他任何代码片段一样部署。唯一的区别是我们必须留出空间以便能够从磁盘再次加载模型。为此,首先,我们需要训练一个模型,并将模型代码和权重使用 joblib 写入磁盘。然后,我们需要构建一个预测函数,这个函数与训练是分开的。最后,我们通过使用 Flask 和 Jinja2 HTML 模板来展示我们所做的工作。

posted @ 2025-09-22 13:21  绝不原创的飞龙  阅读(40)  评论(0)    收藏  举报