Python-文本分析蓝图-四-

178 阅读1小时+

Python 文本分析蓝图(四)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第九章:文本摘要

互联网上有大量关于每个主题的信息。Google 搜索返回数百万条搜索结果,其中包含文本、图像、视频等内容。即使我们只考虑文本内容,也不可能全部阅读。文本摘要方法能够将文本信息压缩成几行或一个段落的简短摘要,并使大多数用户能够理解。文本摘要的应用不仅限于互联网,还包括类似法律助理案例摘要、书籍梗概等领域。

您将学到什么以及我们将要构建的内容

在本章中,我们将从文本摘要的介绍开始,并概述所使用的方法。我们将分析不同类型的文本数据及其特定特征,这些特征对于确定摘要方法的选择非常有用。我们将提供适用于不同用例的蓝图,并分析它们的性能。在本章末尾,您将对不同的文本摘要方法有很好的理解,并能够为任何应用选择合适的方法。

文本摘要

很可能在生活中的某个时刻,您有意或无意地进行了摘要任务。例如,告诉朋友您昨晚观看的电影,或尝试向家人解释您的工作。我们都喜欢向世界其他地方提供我们经历的简要总结,以分享我们的感受并激励他人。文本摘要被定义为在保留有用信息的同时生成更简洁的长文本摘要的方法,而不会失去整体背景。这是一种我们非常熟悉的方法:在阅读课程教材、讲义笔记甚至本书时,许多学生会尝试突出重要的句子或做简短的笔记来捕捉重要概念。自动文本摘要方法允许我们使用计算机来完成这项任务。

摘要方法可以大致分为抽取生成方法。在抽取式摘要中,会从给定的文本中识别重要短语或句子,并将它们组合成整个文本的摘要。这些方法通过正确分配权重来识别文本的重要部分,删除可能传达冗余信息的句子,对文本的不同部分进行排名,并将最重要的部分组合成摘要。这些方法选择原始文本的一部分作为摘要,因此每个句子在语法上都是准确的,但可能不会形成连贯的段落。

另一方面,抽象总结方法尝试像人类一样转述并生成摘要。这通常涉及使用能够生成提供文本语法正确摘要的短语和句子的深度神经网络。然而,训练深度神经网络的过程需要大量的训练数据,并且涉及 NLP 的多个子领域,如自然语言生成、语义分割等。

抽象总结方法是一个活跃研究领域,有几种方法致力于改进现有技术。Hugging Face 的Transformers提供了一个使用预训练模型执行总结任务的实现。我们将在第十一章详细探讨预训练模型和 Transformers 库的概念。在许多用例中,萃取式总结更受青睐,因为这些方法实现简单且运行速度快。在本章中,我们将专注于使用萃取式总结的蓝图。

假设您在一家法律公司工作,希望查看历史案例以帮助准备当前案例。由于案件程序和判决非常长,他们希望生成摘要,并仅在相关时查看整个案例。这样的摘要帮助他们快速查看多个案例,并有效分配时间。我们可以将此视为应用于长篇文本的文本总结示例。另一个用例可能是媒体公司每天早晨向订阅者发送新闻简报,重点突出前一天的重要事件。客户不喜欢长邮件,因此创建每篇文章的简短摘要对保持他们的参与至关重要。在这种用例中,您需要总结较短的文本。在处理这些项目时,也许您需要在使用 Slack 或 Microsoft Teams 等聊天沟通工具的团队中工作。有共享的聊天组(或频道),所有团队成员可以彼此交流。如果您在会议中离开几个小时,聊天信息可能会迅速积累,导致大量未读消息和讨论。作为用户,浏览 100 多条未读消息很困难,并且无法确定是否错过了重要内容。在这种情况下,通过自动化机器人总结这些错过的讨论可能会有所帮助。

在每个用例中,我们看到不同类型的文本需要总结。让我们简要再次呈现它们:

  • 结构化撰写的长篇文本,包含段落并分布在多页之间。例如案件程序、研究论文、教科书等。

  • 短文本,如新闻文章和博客,其中可能包含图像、数据和其他图形元素。

  • 多个短文本片段采用对话形式,可以包含表情符号等特殊字符,结构不是很严谨。例如 Twitter 的线索、在线讨论论坛和群组消息应用程序。

这些类型的文本数据每种呈现信息方式不同,因此用于一个类型的摘要方法可能不适用于另一种。在我们的蓝图中,我们提出适用于这些文本类型的方法,并提供指导以确定适当的方法。

抽取方法

所有抽取方法都遵循这三个基本步骤:

  1. 创建文本的中间表示。

  2. 基于选择的表示对句子/短语进行评分。

  3. 对句子进行排名和选择,以创建文本摘要。

虽然大多数蓝图会按照这些步骤进行,但它们用来创建中间表示或分数的具体方法会有所不同。

数据预处理

在继续实际蓝图之前,我们将重复使用第三章中的蓝图来读取我们想要总结的给定 URL。在这份蓝图中,我们将专注于使用文本生成摘要,但您可以研究第三章以获取从 URL 提取数据的详细概述。为了简洁起见,文章的输出已经缩短;要查看整篇文章,您可以访问以下 URL:

import reprlib
r = reprlib.Repr()
r.maxstring = 800

url1 = "https://www.reuters.com/article/us-qualcomm-m-a-broadcom-5g/\
 what-is-5g-and-who-are-the-major-players-idUSKCN1GR1IN"
article_name1 = download_article(url1)
article1 = parse_article(article_name1)
print ('Article Published on', r.repr(article1['time']))
print (r.repr(article1['text']))

输出:

Article Published on '2018-03-15T11:36:28+0000'
'LONDON/SAN FRANCISCO (Reuters) - U.S. President Donald Trump has blocked
microchip maker Broadcom Ltd’s (AVGO.O) $117 billion takeover of rival Qualcomm
(QCOM.O) amid concerns that it would give China the upper hand in the next
generation of mobile communications, or 5G. A 5G sign is seen at the Mobile
World Congress in Barcelona, Spain February 28, 2018\. REUTERS/Yves HermanBelow
are some facts... 4G wireless and looks set to top the list of patent holders
heading into the 5G cycle. Huawei, Nokia, Ericsson and others are also vying to
amass 5G patents, which has helped spur complex cross-licensing agreements like
the deal struck late last year Nokia and Huawei around handsets. Editing by Kim
Miyoung in Singapore and Jason Neely in LondonOur Standards:The Thomson Reuters
Trust Principles.'

注意

我们使用reprlib包,该包允许我们自定义打印语句的输出。在这种情况下,打印完整文章的内容是没有意义的。我们限制输出的大小为 800 个字符,reprlib包重新格式化输出,显示文章开头和结尾的选定序列词语。

蓝图:使用主题表示进行文本摘要

让我们首先尝试自己总结一下例子 Reuters 文章。阅读完之后,我们可以提供以下手动生成的摘要:

5G 是下一代无线技术,将依赖更密集的小天线阵列,提供比当前 4G 网络快 50 到 100 倍的数据速度。这些新网络预计不仅将数据传输速度提高到手机和电脑,还将扩展到汽车、货物、农作物设备等各种传感器。高通是今天智能手机通信芯片市场的主导者,人们担心新加坡的博通公司收购高通可能会导致高通削减研发支出或将公司战略重要部分出售给其他买家,包括在中国的买家。这可能会削弱高通,在 5G 竞赛中促进中国超越美国的风险。

作为人类,我们理解文章传达的内容,然后生成我们理解的摘要。然而,算法没有这种理解,因此必须依赖于识别重要主题来确定是否应将句子包括在摘要中。在示例文章中,主题可能是技术、电信和 5G 等广泛主题,但对于算法来说,这只是一组重要单词的集合。我们的第一种方法试图区分重要和不那么重要的单词,从而使我们能够将包含重要单词的句子排名较高。

使用 TF-IDF 值识别重要词语

最简单的方法是基于句子中单词的 TF-IDF 值的总和来识别重要句子。详细解释 TF-IDF 在第五章中提供,但对于这个蓝图,我们应用 TF-IDF 向量化,然后将值聚合到句子级别。我们可以为每个句子生成一个分数,作为该句子中每个单词的 TF-IDF 值的总和。这意味着得分高的句子包含的重要单词比文章中的其他句子多:

from sklearn.feature_extraction.text import TfidfVectorizer
from nltk import tokenize

sentences = tokenize.sent_tokenize(article1['text'])
tfidfVectorizer = TfidfVectorizer()
words_tfidf = tfidfVectorizer.fit_transform(sentences)

在这种情况下,文章中大约有 20 句话,我们选择创建一个只有原始文章大小的 10%的简要总结(大约两到三句话)。我们对每个句子的 TF-IDF 值进行求和,并使用np.argsort对它们进行排序。这种方法按升序对每个句子的索引进行排序,我们使用[::-1]来逆转返回的索引。为了确保与文章中呈现的思路相同,我们按照它们出现的顺序打印所选的摘要句子。我们可以看到我们生成的摘要结果,如下所示:

# Parameter to specify number of summary sentences required
num_summary_sentence = 3

# Sort the sentences in descending order by the sum of TF-IDF values
sent_sum = words_tfidf.sum(axis=1)
important_sent = np.argsort(sent_sum, axis=0)[::-1]

# Print three most important sentences in the order they appear in the article
for i in range(0, len(sentences)):
    if i in important_sent[:num_summary_sentence]:
        print (sentences[i])

输出:

LONDON/SAN FRANCISCO (Reuters) - U.S. President Donald Trump has blocked
microchip maker Broadcom Ltd’s (AVGO.O) $117 billion takeover of rival Qualcomm
(QCOM.O) amid concerns that it would give China the upper hand in the next
generation of mobile communications, or 5G.
5G networks, now in the final testing stage, will rely on denser arrays of
small antennas and the cloud to offer data speeds up to 50 or 100 times faster
than current 4G networks and serve as critical infrastructure for a range of
industries.
The concern is that a takeover by Singapore-based Broadcom could see the firm
cut research and development spending by Qualcomm or hive off strategically
important parts of the company to other buyers, including in China, U.S.
officials and analysts have said.

在这种方法中,我们使用 TF-IDF 值创建文本的中间表示,根据这些值对句子进行评分,并选择三个得分最高的句子。使用这种方法选择的句子与我们之前写的手动摘要一致,并捕捉了文章涵盖的主要要点。一些细微差别,比如 Qualcomm 在行业中的重要性和 5G 技术的具体应用,被忽略了。但这种方法作为快速识别重要句子并自动生成新闻文章摘要的良好蓝图。我们将这个蓝图封装成一个名为tfidf_summary的函数,该函数在附带的笔记本中定义并在本章后面再次使用。

LSA 算法

在基于抽取的摘要方法中,使用的一种现代方法是潜在语义分析(LSA)。LSA 是一种通用方法,用于主题建模、文档相似性和其他任务。LSA 假设意思相近的词会出现在同一篇文档中。在 LSA 算法中,我们首先将整篇文章表示为一个句子-词矩阵。文档-词矩阵的概念已在第八章中介绍过,我们可以将该概念调整为适合句子-词矩阵。每行代表一个句子,每列代表一个词。该矩阵中每个单元格的值是词频通常按 TF-IDF 权重进行缩放。该方法的目标是通过创建句子-词矩阵的修改表示来将所有单词减少到几个主题中。为了创建修改后的表示,我们应用非负矩阵分解的方法,将该矩阵表示为具有较少行/列的两个新分解矩阵的乘积。您可以参考第八章更详细地了解这一方法。在矩阵分解步骤之后,我们可以通过选择前 N 个重要主题生成摘要,然后选择每个主题中最重要的句子来形成我们的摘要。

我们不再从头开始应用 LSA,而是利用sumy包,可以使用命令**pip install sumy**进行安装。该库提供了同一库内的多种摘要方法。此库使用一个集成的停用词列表,并结合来自 NLTK 的分词器和词干处理功能,但可以进行配置。此外,它还能够从纯文本、HTML 和文件中读取输入。这使我们能够快速测试不同的摘要方法,并更改默认配置以适应特定的使用案例。目前,我们将使用默认选项,包括识别前三个句子:

from sumy.parsers.plaintext import PlaintextParser
from sumy.nlp.tokenizers import Tokenizer
from sumy.nlp.stemmers import Stemmer
from sumy.utils import get_stop_words

from sumy.summarizers.lsa import LsaSummarizer

LANGUAGE = "english"
stemmer = Stemmer(LANGUAGE)

parser = PlaintextParser.from_string(article1['text'], Tokenizer(LANGUAGE))
summarizer = LsaSummarizer(stemmer)
summarizer.stop_words = get_stop_words(LANGUAGE)

for sentence in summarizer(parser.document, num_summary_sentence):
    print (str(sentence))

输出:

LONDON/SAN FRANCISCO (Reuters) - U.S. President Donald Trump has blocked
microchip maker Broadcom Ltd’s (AVGO.O) $117 billion takeover of rival Qualcomm
(QCOM.O) amid concerns that it would give China the upper hand in the next
generation of mobile communications, or 5G.
Moving to new networks promises to enable new mobile services and even whole
new business models, but could pose challenges for countries and industries
unprepared to invest in the transition.
The concern is that a takeover by Singapore-based Broadcom could see the firm
cut research and development spending by Qualcomm or hive off strategically
important parts of the company to other buyers, including in China, U.S.
officials and analysts have said.

通过分析结果,我们看到 TF-IDF 的结果仅有一句与 LSA 的结果有所不同,即第 2 句。虽然 LSA 方法选择突出显示一个关于挑战主题的句子,但 TF-IDF 方法选择了一个更多关于 5G 信息的句子。在这种情况下,两种方法生成的摘要并没有非常不同,但让我们分析一下这种方法在更长文章上的工作效果。

我们将这个蓝图封装成一个函数lsa_summary,该函数在附带的笔记本中定义,并可重复使用:

r.maxstring = 800
url2 = "https://www.reuters.com/article/us-usa-economy-watchlist-graphic/\
 predicting-the-next-u-s-recession-idUSKCN1V31JE"
article_name2 = download_article(url2)
article2 = parse_article(article_name2)
print ('Article Published', r.repr(article1['time']))
print (r.repr(article2['text']))

输出:

Article Published '2018-03-15T11:36:28+0000'
'NEW YORK A protracted trade war between China and the United States, the
world’s largest economies, and a deteriorating global growth outlook has left
investors apprehensive about the end to the longest expansion in American
history. FILE PHOTO: Ships and shipping containers are pictured at the port of
Long Beach in Long Beach, California, U.S., January 30, 2019\.   REUTERS/Mike
BlakeThe recent ...hton wrote in the June Cass Freight Index report.  12.
MISERY INDEX The so-called Misery Index adds together the unemployment rate and
the inflation rate. It typically rises during recessions and sometimes prior to
downturns. It has slipped lower in 2019 and does not look very miserable.
Reporting by Saqib Iqbal Ahmed; Editing by Chizu NomiyamaOur Standards:The
Thomson Reuters Trust Principles.'

然后:

summary_sentence = tfidf_summary(article2['text'], num_summary_sentence)
for sentence in summary_sentence:
    print (sentence)

输出:

REUTERS/Mike BlakeThe recent rise in U.S.-China trade war tensions has brought
forward the next U.S. recession, according to a majority of economists polled
by Reuters who now expect the Federal Reserve to cut rates again in September
and once more next year.
On Tuesday, U.S. stocks jumped sharply higher and safe-havens like the Japanese
yen and Gold retreated after the U.S. Trade Representative said additional
tariffs on some Chinese goods, including cell phones and laptops, will be
delayed to Dec. 15.
ISM said its index of national factory activity slipped to 51.2 last month, the
lowest reading since August 2016, as U.S. manufacturing activity slowed to a
near three-year low in July and hiring at factories shifted into lower gear,
suggesting a further loss of momentum in economic growth early in the third
quarter.

最后:

summary_sentence = lsa_summary(article2['text'], num_summary_sentence)
for sentence in summary_sentence:
    print (sentence)

输出:

NEW YORK A protracted trade war between China and the United States, the
world’s largest economies, and a deteriorating global growth outlook has left
investors apprehensive about the end to the longest expansion in American
history.
REUTERS/Mike BlakeThe recent rise in U.S.-China trade war tensions has brought
forward the next U.S. recession, according to a majority of economists polled
by Reuters who now expect the Federal Reserve to cut rates again in September
and once more next year.
Trade tensions have pulled corporate confidence and global growth to multi-year
lows and U.S. President Donald Trump’s announcement of more tariffs have raised
downside risks significantly, Morgan Stanley analysts said in a recent note.

在这里,选择的摘要句子的差异变得更加明显。贸易战紧张局势的主要话题被两种方法捕捉到,但 LSA 摘要器还突出了投资者的担忧和企业信心等重要话题。虽然 TF-IDF 试图在其选择的句子中表达相同的观点,但它没有选择正确的句子,因此未能传达这一观点。还有其他基于主题的摘要方法,但我们选择突出 LSA 作为一个简单且广泛使用的方法。

注意

有趣的是,sumy库还提供了自动文本摘要的一个最古老的方法(LuhnSummarizer)的实现,该方法由Hans Peter Luhn 于 1958 年创造。这种方法也是基于通过识别重要词汇的计数和设置阈值来表示主题。您可以将其用作文本摘要实验的基准方法,并比较其他方法提供的改进。

蓝图:使用指示器表示对文本进行摘要

指示器表示方法旨在通过使用句子的特征及其与文档中其他句子的关系来创建句子的中间表示,而不仅仅是使用句子中的单词。TextRank是指示器方法中最流行的例子之一。TextRank 受 PageRank 启发,是一种“基于图的排名算法,最初由 Google 用于排名搜索结果。根据 TextRank 论文的作者,基于图的算法依赖于网页结构的集体知识,而不是单个网页内容的分析”,这导致了改进的性能。在我们的背景下应用,我们将依赖句子的特征和它们之间的链接,而不是依赖每个句子所包含的主题。

首先我们将尝试理解 PageRank 算法的工作原理,然后将方法应用于文本摘要问题。让我们考虑一个网页列表(A、B、C、D、E 和 F)及其彼此之间的链接。在图 9-1 中,页面 A 包含指向页面 D 的链接。页面 B 包含指向 A 和 D 的链接,依此类推。我们还可以用一个矩阵表示,行表示每个页面,列表示来自其他页面的入链。图中显示的矩阵表示我们的图,行表示每个节点,列表示来自其他节点的入链,单元格的值表示它们之间边的权重。我们从一个简单的表示开始(1 表示有入链,0 表示没有)。然后我们可以通过将每个网页的出链总数进行除法来归一化这些值。例如,页面 C 有两个出链(到页面 E 和 F),因此每个出链的值为 0.5。

图 9-1. 网页链接和相应的 PageRank 矩阵。

对于给定页面的 PageRank 是所有具有链接的其他页面的 PageRank 的加权和。这也意味着计算 PageRank 是一个迭代函数,我们必须从每个页面的一些假设的 PageRank 初始值开始。如果我们假设所有初始值为 1,并按照图 9-2 所示的方式进行矩阵乘法,我们可以在一次迭代后得到每个页面的 PageRank(不考虑此示例的阻尼因子)。

Brin 和 Page 的研究论文表明,重复进行多次迭代计算后,数值稳定,因此我们得到每个页面的 PageRank 或重要性。TextRank 通过将文本中的每个句子视为一个页面和因此图中的一个节点来调整先前的方法。节点之间边的权重由句子之间的相似性决定,TextRank 的作者建议通过计算共享词汇标记的数量(归一化为两个句子的大小)来实现简单的方法。还有其他相似度度量,如余弦距离和最长公共子串也可以使用。

图 9-2. PageRank 算法的一次迭代应用。

由于 sumy 包还提供了 TextRank 实现,我们将使用它为我们之前看到的关于美国经济衰退的文章生成总结的句子:

from sumy.summarizers.text_rank import TextRankSummarizer

parser = PlaintextParser.from_string(article2['text'], Tokenizer(LANGUAGE))
summarizer = TextRankSummarizer(stemmer)
summarizer.stop_words = get_stop_words(LANGUAGE)

for sentence in summarizer(parser.document, num_summary_sentence):
    print (str(sentence))

REUTERS/Mike BlakeThe recent rise in U.S.-China trade war tensions has brought
forward the next U.S. recession, according to a majority of economists polled
by Reuters who now expect the Federal Reserve to cut rates again in September
and once more next year.
As recession signals go, this so-called inversion in the yield curve has a
solid track record as a predictor of recessions.
Markets turned down before the 2001 recession and tumbled at the start of the
2008 recession.

当总结句之一保持不变时,这种方法选择返回其他两个可能与本文主要结论相关联的句子。虽然这些句子本身可能并不重要,但使用基于图的方法选择了支持文章主题的高度关联句子。我们将这个蓝图封装成一个函数textrank_summary,允许我们进行重复使用。

我们还想看看这种方法在我们之前查看过的关于 5G 技术的较短文章上的运作:

parser = PlaintextParser.from_string(article1['text'], Tokenizer(LANGUAGE))
summarizer = TextRankSummarizer(stemmer)
summarizer.stop_words = get_stop_words(LANGUAGE)

for sentence in summarizer(parser.document, num_summary_sentence):
    print (str(sentence))

Out:

Acquiring Qualcomm would represent the jewel in the crown of Broadcom’s
portfolio of communications chips, which supply wi-fi, power management, video
and other features in smartphones alongside Qualcomm’s core baseband chips -
radio modems that wirelessly connect phones to networks.
Qualcomm (QCOM.O) is the dominant player in smartphone communications chips,
making half of all core baseband radio chips in smartphones.
Slideshow (2 Images)The standards are set by a global body to ensure all phones
work across different mobile networks, and whoever’s essential patents end up
making it into the standard stands to reap huge royalty licensing revenue
streams.

我们看到,结果捕捉到了高通收购的中心思想,但没有提及 LSA 方法选择的 5G 技术。TextRank 通常在长文本内容的情况下表现更好,因为它能够使用图链接识别最重要的句子。在较短的文本内容中,图不是很大,因此网络智慧发挥的作用较小。让我们使用来自维基百科的更长内容的例子来进一步说明这一点。我们将重复使用来自第二章的蓝图,下载维基百科文章的文本内容。在这种情况下,我们选择描述历史事件或事件系列的文章:蒙古入侵欧洲。由于这是更长的文本,我们选择总结大约 10 句话,以提供更好的总结:

p_wiki = wiki_wiki.page('Mongol_invasion_of_Europe')
print (r.repr(p_wiki.text))

Out:

'The Mongol invasion of Europe in the 13th century occurred from the 1220s into
the 1240s. In Eastern Europe, the Mongols destroyed Volga Bulgaria, Cumania,
Alania, and the Kievan Rus\' federation. In Central Europe, the Mongol armies
launched a tw...tnotes\nReferences\nSverdrup, Carl (2010). "Numbers in Mongol
Warfare". Journal of Medieval Military History. Boydell Press. 8: 109–17 [p.
115]. ISBN 978-1-84383-596-7.\n\nFurther reading\nExternal links\nThe Islamic
World to 1600: The Golden Horde'

然后:

r.maxstring = 200

num_summary_sentence = 10

summary_sentence = textrank_summary(p_wiki.text, num_summary_sentence)

for sentence in summary_sentence:
    print (sentence)

我们将结果展示为原始维基百科页面中的突出显示句子(Figure 9-3),以展示使用 TextRank 算法通过从文章的每个部分中选择最重要的句子,几乎准确地对文章进行了总结。我们可以比较这与 LSA 方法的工作,但我们将这留给读者使用先前的蓝图作为练习。根据我们的经验,当我们想要总结大量的文本内容时,例如科学研究论文、作品集以及世界领导人的演讲或多个网页时,我们会选择像 TextRank 这样基于图的方法。

Figure 9-3. 维基百科页面,突出显示了选定的摘要句子。

衡量文本摘要方法的性能

到目前为止,我们在蓝图中已经看到了许多方法来生成某段文本的摘要。每个摘要在细微之处都有所不同,我们必须依靠我们的主观评估。在选择最适合特定使用案例的方法方面,这无疑是一个挑战。在本节中,我们将介绍常用的准确度度量标准,并展示它们如何被用来经验性地选择最佳的摘要方法。

我们必须理解,要自动评估某段给定文本的摘要,必须有一个可以进行比较的参考摘要。通常,这是由人类编写的摘要,称为黄金标准。每个自动生成的摘要都可以与黄金标准进行比较,以获得准确度的度量。这也为我们提供了比较多种方法并选择最佳方法的机会。然而,我们经常会遇到一个问题,即并非每个使用案例都有人类生成的摘要存在。在这种情况下,我们可以选择一个代理度量来视为黄金标准。在新闻文章的案例中,一个例子就是标题。虽然它是由人类编写的,但作为一个代理度量它并不准确,因为它可能非常简短,更像是一个引导性陈述来吸引用户。虽然这可能不会给我们带来最佳结果,但比较不同摘要方法的性能仍然是有用的。

用于 Gisting 评估的召回导向的 Understudy(ROUGE)是最常用的测量摘要准确性的方法之一。有几种类型的 ROUGE 度量标准,但基本思想很简单。它通过比较自动生成的摘要与黄金标准之间的共享术语数量来得出准确度的度量。ROUGE-N 是一种度量标准,用于衡量常见的 n-gram(ROUGE-1 比较单个词,ROUGE-2 比较二元组,依此类推)。

原始的 ROUGE 论文 比较了在自动生成的摘要中出现的单词中有多少也出现在金标准中。这就是我们在 第六章 中介绍的 召回率。因此,如果金标准中大多数单词也出现在生成的摘要中,我们将获得高分。然而,单靠这一指标并不能讲述整个故事。考虑到我们生成了一个冗长但包含金标准中大多数单词的摘要。这个摘要将获得高分,但它不是一个好的摘要,因为它没有提供简洁的表示。这就是为什么 ROUGE 测量已经扩展到将共享单词的数量与生成的摘要中的总单词数进行比较。这表明了精度:生成摘要中实际有用的单词数。我们可以结合这些措施生成 F 分数。

让我们看一个我们生成摘要的 ROUGE 示例。由于我们没有金标准的人工生成摘要,我们使用文章标题作为金标准的代理。虽然这样计算独立简单,但我们利用名为 rouge_scorer 的 Python 包来使我们的生活更轻松。这个包实现了我们后来将使用的所有 ROUGE 测量,并且可以通过执行命令 **pip install rouge_scorer** 进行安装。我们利用一个打印实用函数 print_rouge_score 来展示得分的简洁视图:

num_summary_sentence = 3
gold_standard = article2['headline']
summary = ""

summary = ''.join(textrank_summary(article2['text'], num_summary_sentence))
scorer = rouge_scorer.RougeScorer(['rouge1'], use_stemmer=True)
scores = scorer.score(gold_standard, summary)
print_rouge_score(scores)

输出:

rouge1 Precision: 0.06 Recall: 0.83 fmeasure: 0.11

先前的结果显示,TextRank 生成的摘要具有高召回率但低精度。这是我们金标准是一个极短标题的结果,本身并不是最佳选择,但在这里用于说明。我们度量标准的最重要用途是与另一种总结方法进行比较,在这种情况下,让我们与 LSA 生成的摘要进行比较:

summary = ''.join(lsa_summary(article2['text'], num_summary_sentence))
scores = scorer.score(gold_standard, summary)
print_rouge_score(scores)

输出:

rouge1 Precision: 0.04 Recall: 0.83 fmeasure: 0.08

上述结果表明,在这种情况下,TextRank 是优越的方法,因为它具有更高的精度,而两种方法的召回率相同。我们可以轻松地扩展 ROUGE-1 到 ROUGE-2,这将比较两个词(二元组)的公共序列的数量。另一个重要的指标是 ROUGE-L,它通过识别参考摘要与生成摘要之间的最长公共子序列来衡量。句子的子序列是一个新句子,可以从原始句子中删除一些单词而不改变剩余单词的相对顺序。这个指标的优势在于它不专注于精确的序列匹配,而是反映句子级词序的顺序匹配。让我们分析维基百科页面的 ROUGE-2 和 ROUGE-L 指标。再次强调,我们没有一个金标准,因此我们将使用简介段落作为我们金标准的代理:

num_summary_sentence = 10
gold_standard = p_wiki.summary

summary = ''.join(textrank_summary(p_wiki.text, num_summary_sentence))

scorer = rouge_scorer.RougeScorer(['rouge2','rougeL'], use_stemmer=True)
scores = scorer.score(gold_standard, summary)
print_rouge_score(scores)

输出:

rouge2 Precision: 0.18 Recall: 0.46 fmeasure: 0.26
rougeL Precision: 0.16 Recall: 0.40 fmeasure: 0.23

然后:

summary = ''.join(lsa_summary(p_wiki.text, num_summary_sentence))

scorer = rouge_scorer.RougeScorer(['rouge2','rougeL'], use_stemmer=True)
scores = scorer.score(gold_standard, summary)
print_rouge_score(scores)

输出:

rouge2 Precision: 0.04 Recall: 0.08 fmeasure: 0.05
rougeL Precision: 0.12 Recall: 0.25 fmeasure: 0.16

根据结果,我们看到 TextRank 比 LSA 更准确。我们可以使用与前面展示的相同方法来查看哪种方法对较短的维基百科条目效果最好,这将留给读者作为练习。当应用到您的用例时,重要的是选择正确的摘要进行比较。例如,在处理新闻文章时,您可以查找文章内包含的摘要部分,而不是使用标题,或者为少数文章生成自己的摘要。这样可以在不同方法之间进行公平比较。

蓝图:使用机器学习进行文本总结

许多人可能参与了关于旅行规划、编程等主题的在线讨论论坛。在这些平台上,用户以线程的形式进行交流。任何人都可以开始一个线程,其他成员则在该线程上提供他们的回应。线程可能会变得很长,关键信息可能会丢失。在这个蓝图中,我们将使用从研究论文中提取的数据,^(2) 这些数据包含了一个线程中所有帖子的文本以及该线程的摘要,如图 9-4 所示。

在这个蓝图中,我们将使用机器学习来帮助我们自动识别整个线程中最重要的帖子,这些帖子准确地总结了整个线程。我们首先使用注释者的摘要为我们的数据集创建目标标签。然后生成能够确定特定帖子是否应该出现在摘要中的特征,并最终训练一个模型并评估其准确性。手头的任务类似于文本分类,但是在帖子级别上执行。

虽然论坛线程用于说明这个蓝图,但它也可以轻松地用于其他用例。例如,考虑CNN 和每日邮报新闻摘要任务DUC,或SUMMAC数据集。在这些数据集中,你会找到每篇文章的文本和突出显示的摘要句子。这些与本蓝图中呈现的每个线程的文本和摘要类似。

图片

图 9-4。一个线程中的帖子及其来自旅行论坛的对应摘要。

第 1 步:创建目标标签

第一步是加载数据集,了解其结构,并使用提供的摘要创建目标标签。我们已经执行了初始的数据准备步骤,创建了一个格式良好的DataFrame,如下所示。请参阅书籍的 GitHub 仓库中的Data_Preparation笔记本,详细了解这些步骤:

import pandas as pd
import numpy as np

df = pd.read_csv('travel_threads.csv', sep='|', dtype={'ThreadID': 'object'})
df[df['ThreadID']=='60763_5_3122150'].head(1).T

 170
日期2009 年 9 月 29 日,1:41
文件名thread41_system20
线程 ID60763_5_3122150
标题需要预订哪些景点?
帖子编号1
textHi I am coming to NY in Oct! So excited" Have wanted to visit for years. We are planning on doing all the usual stuff so wont list it all but wondered which attractions should be pre booked and which can you just turn up at> I am plannin on booking ESB but what else? thanks x
用户 IDmusicqueenLon...
summaryA woman was planning to travel NYC in October and needed some suggestions about attractions in the NYC. She was planning on booking ESB.Someone suggested that the TOTR was much better compared to ESB. The other suggestion was to prebook the show to avoid wasting time in line.Someone also suggested her New York Party Shuttle tours.

这个数据集中的每一行都指的是主题中的一个帖子。每个主题由一个唯一的 ThreadID 标识,DataFrame 中可能有多行具有相同的 ThreadIDTitle 列指的是用户开始主题时使用的名称。每个帖子的内容都在 text 列中,还包括其他细节,比如创建帖子的用户的姓名(userID)、帖子创建时间(Date)以及在主题中的位置(postNum)。对于这个数据集,每个主题都提供了人工生成的摘要,位于 summary 列中。

我们将重用第四章中的正则表达式清理和 spaCy 流水线蓝图,以删除帖子中的特殊格式、URL 和其他标点符号。我们还将生成文本的词形还原表示,用于预测。你可以在本章的附带笔记本中找到函数定义。由于我们正在使用 spaCy 的词形还原功能,执行可能需要几分钟才能完成:

# Applying regex based cleaning function
df['text'] = df['text'].apply(regex_clean)
# Extracting lemmas using spacy pipeline
df['lemmas'] = df['text'].apply(clean)

我们数据集中的每个观测都包含一个帖子,该帖子是主题的一部分。如果我们在这个层面应用训练-测试分割,那么可能会导致两个属于同一主题的帖子分别进入训练集和测试集,这将导致训练不准确。因此,我们使用 GroupShuffleSplit 将所有帖子分组到它们各自的主题中,然后随机选择 80% 的主题来创建训练数据集,其余的主题组成测试数据集。这个函数确保属于同一主题的帖子属于同一数据集。GroupShuffleSplit 函数实际上并不分割数据,而是提供了一组索引,这些索引标识了由 train_splittest_split 确定的数据的分割。我们使用这些索引来创建这两个数据集:

from sklearn.model_selection import GroupShuffleSplit

gss = GroupShuffleSplit(n_splits=1, test_size=0.2)
train_split, test_split = next(gss.split(df, groups=df['ThreadID']))

train_df = df.iloc[train_split]
test_df = df.iloc[test_split]

print ('Number of threads for Training ', train_df['ThreadID'].nunique())
print ('Number of threads for Testing ', test_df['ThreadID'].nunique())

输出:

Number of threads for Training  559
Number of threads for Testing  140

我们的下一步是确定每篇文章的目标标签。目标标签定义了是否应将特定文章包含在摘要中。我们通过将每篇文章与注释员摘要进行比较,并选择最相似的文章来确定这一点。有几种度量标准可用于确定两个句子的相似性,但在我们的用例中,我们处理短文本,因此选择了Jaro-Winkler 距离。我们使用textdistance包,该包还提供其他距离度量的实现。您可以使用命令**pip install textdistance**轻松安装它。您还可以轻松修改蓝图,并根据您的用例选择度量标准。

在接下来的步骤中,我们根据所选择的度量标准确定相似性并对主题中的所有帖子进行排序。然后,我们创建名为summaryPost的目标标签,其中包含一个 True 或 False 值,指示此帖子是否属于摘要。这是基于帖子的排名和压缩因子。我们选择了 30%的压缩因子,这意味着我们选择按相似性排序的所有帖子中的前 30%来包含在摘要中:

import textdistance

compression_factor = 0.3

train_df['similarity'] = train_df.apply(
    lambda x: textdistance.jaro_winkler(x.text, x.summary), axis=1)
train_df["rank"] = train_df.groupby("ThreadID")["similarity"].rank(
    "max", ascending=False)

topN = lambda x: x <= np.ceil(compression_factor * x.max())
train_df['summaryPost'] = train_df.groupby('ThreadID')['rank'].apply(topN)

train_df[['text','summaryPost']][train_df['ThreadID']=='60763_5_3122150'].head(3)

输出:

 textsummaryPost
170嗨,我十月份要去纽约!好兴奋!多年来一直想去参观。我们计划做所有传统的事情,所以不会列出所有的事情,但想知道哪些景点应该提前预订,哪些可以直接到场?我打算预订帝国大厦,还有什么?谢谢 xTrue
171如果我是你,我不会去帝国大厦,TOPR 要好得多。你还有哪些景点考虑?False
172自由女神像,如果您计划去雕像本身或埃利斯岛(而不是乘船经过):www.statuecruises.com/ 另外,我们更喜欢提前预订演出和戏剧,而不是尝试购买当天票,因为这样可以避免排队浪费时间。如果这听起来对您有吸引力,请看看 www.broadwaybox.com/True

正如您在前面的结果中看到的,对于给定的主题,第一和第三篇文章被标记为summaryPost,但第二篇文章不重要,不会被包含在摘要中。由于我们定义了目标标签的方式,很少情况下可能会将非常短的帖子包含在摘要中。当一个短帖子包含与主题标题相同的词时,可能会发生这种情况。这对摘要没有用,我们通过将所有包含 20 个词或更少的帖子设置为不包含在摘要中来进行修正:

train_df.loc[train_df['text'].str.len() <= 20, 'summaryPost'] = False

步骤 2:添加帮助模型预测的特征

由于我们在这个蓝图中处理的是论坛主题,我们可以生成一些额外的特征来帮助我们的模型进行预测。主题的标题简洁地传达了主题,并且在识别应该在摘要中实际选择的帖子时可能会有所帮助。我们不能直接将标题包含为一个特征,因为对于主题中的每个帖子来说它都是相同的,但是我们可以计算帖子与标题之间的相似度作为其中一个特征:

train_df['titleSimilarity'] = train_df.apply(
    lambda x: textdistance.jaro_winkler(x.text, x.Title), axis=1)

另一个有用的特征可能是帖子的长度。短帖子可能是在询问澄清问题,不会捕捉到主题的最有用的知识。长帖子可能表明正在分享大量有用信息。帖子在主题中的位置也可能是一个有用的指标,用于确定是否应该将其包含在摘要中。这可能会根据论坛主题的组织方式而有所不同。在旅行论坛的情况下,帖子是按时间顺序排序的,帖子的发生是通过列postNum给出的,我们可以直接将其用作一个特征:

# Adding post length as a feature
train_df['textLength'] = train_df['text'].str.len()

作为最后一步,让我们使用TfidfVectorizer创建我们之前提取的词元的向量化表示。然后,我们创建一个新的DataFrametrain_df_tf,其中包含向量化的词元和我们之前创建的附加特征:

feature_cols = ['titleSimilarity','textLength','postNum']

train_df['combined'] = [
    ' '.join(map(str, l)) for l in train_df['lemmas'] if l is not '']
tfidf = TfidfVectorizer(min_df=10, ngram_range=(1, 2), stop_words="english")
tfidf_result = tfidf.fit_transform(train_df['combined']).toarray()

tfidf_df = pd.DataFrame(tfidf_result, columns=tfidf.get_feature_names())
tfidf_df.columns = ["word_" + str(x) for x in tfidf_df.columns]
tfidf_df.index = train_df.index
train_df_tf = pd.concat([train_df[feature_cols], tfidf_df], axis=1)

添加特征的这一步骤可以根据使用情况进行扩展或定制。例如,如果我们想要总结更长的文本,那么一个句子所属的段落将是重要的。通常,每个段落或部分都试图捕捉一个思想,并且在该水平上使用的句子相似性度量将是相关的。如果我们试图生成科学论文的摘要,那么引用次数和用于这些引用的句子已被证明是有用的。我们还必须在测试数据集上重复相同的特征工程步骤,我们在附带的笔记本中展示了这一点,但在这里排除了。

步骤 3:构建机器学习模型

现在我们已经生成了特征,我们将重用第六章中的文本分类蓝图,但是使用RandomForestClassifier模型代替 SVM 模型。在构建用于摘要的机器学习模型时,我们可能有除了向量化的文本表示之外的其他特征。特别是在存在数字和分类特征的组合的情况下,基于树的分类器可能会表现得更好:

from sklearn.ensemble import RandomForestClassifier

model1 = RandomForestClassifier()
model1.fit(train_df_tf, train_df['summaryPost'])

输出:

RandomForestClassifier(bootstrap=True, ccp_alpha=0.0, class_weight=None,
                       criterion='gini', max_depth=None, max_features='auto',
                       max_leaf_nodes=None, max_samples=None,
                       min_impurity_decrease=0.0, min_impurity_split=None,
                       min_samples_leaf=1, min_samples_split=2,
                       min_weight_fraction_leaf=0.0, n_estimators=100,
                       n_jobs=None, oob_score=False, random_state=20, verbose=0,
                       warm_start=False)

让我们在测试主题上应用这个模型,并预测摘要帖子。为了确定准确性,我们连接所有识别的摘要帖子,并通过与注释摘要进行比较生成 ROUGE-1 分数:

# Function to calculate rouge_score for each thread
def calculate_rouge_score(x, column_name):
    # Get the original summary - only first value since they are repeated
    ref_summary = x['summary'].values[0]

    # Join all posts that have been predicted as summary
    predicted_summary = ''.join(x['text'][x[column_name]])

    # Return the rouge score for each ThreadID
    scorer = rouge_scorer.RougeScorer(['rouge1'], use_stemmer=True)
    scores = scorer.score(ref_summary, predicted_summary)
    return scores['rouge1'].fmeasure

test_df['predictedSummaryPost'] = model1.predict(test_df_tf)
print('Mean ROUGE-1 Score for test threads',
      test_df.groupby('ThreadID')[['summary','text','predictedSummaryPost']] \
      .apply(calculate_rouge_score, column_name='predictedSummaryPost').mean())

输出:

Mean ROUGE-1 Score for test threads 0.3439714323225145

我们看到,测试集中所有主题的平均 ROUGE-1 分数为 0.34,与其他公共摘要任务上的抽取式摘要分数相当。您还会注意到排行榜上使用预训练模型如 BERT 改善了分数,我们在第十一章中详细探讨了这一技术。

random.seed(2)
random.sample(test_df['ThreadID'].unique().tolist(), 1)

Out:

['60974_588_2180141']

让我们也来看看由这个模型生成的一个摘要结果,以了解它可能有多有用:

example_df = test_df[test_df['ThreadID'] == '60974_588_2180141']
print('Total number of posts', example_df['postNum'].max())
print('Number of summary posts',
      example_df[example_df['predictedSummaryPost']].count().values[0])
print('Title: ', example_df['Title'].values[0])
example_df[['postNum', 'text']][example_df['predictedSummaryPost']]

Out:

Total number of posts 9
Number of summary posts 2
Title:  What's fun for kids?

 postNumtext
5514看来你真的很幸运,因为有很多事情可以做,包括艾尔姆伍德艺术节(www.elmwoodartfest.org),为年轻人准备的特别活动,表演(包括我最喜欢的本地歌手之一尼基·希克斯的表演),以及各种美食。艾尔姆伍德大道是该地区最丰富多彩且充满活力的社区之一,非常适合步行。布法罗爱尔兰文化节也将在汉堡的周末举行,正好在展览会场地:www.buf...
5525根据您的时间安排,快速到尼亚加拉大瀑布旅行是一个很好的选择。从汉堡开车 45 分钟,非常值得投资时间。否则,您可以去安哥拉的一些海滩享受时光。如果女孩们喜欢购物,您可以去加勒利亚购物中心,这是一个非常大的商场。如果您喜欢一个更有特色的下午,可以在艾尔布赖特诺艺术画廊午餐,漫步艾尔姆伍德大道,然后逛逛一些时尚店铺,这将是一个很酷的下午。达里恩湖主题公园距离...

在前面的例子中,原始主题包括九个帖子,其中两个被选出来总结主题,如前所示。阅读总结帖子显示,主题是关于年轻人的活动,已经有一些具体建议,比如艾尔姆伍德大道,达里恩湖主题公园等。想象一下,在浏览论坛搜索结果时,鼠标悬停时提供这些信息。这为用户提供了足够准确的摘要,以决定是否有趣并单击获取更多详细信息或继续查看其他搜索结果。您还可以轻松地将此蓝图与其他数据集重新使用,如开头所述,并自定义距离函数,引入附加功能,然后训练模型。

结尾语

在本章中,我们介绍了文本摘要的概念,并提供了可用于为不同用例生成摘要的蓝图。如果您希望从诸如网页、博客和新闻文章等短文本生成摘要,则基于 LSA 摘要器的主题表示的第一个蓝图将是一个不错的选择。如果您处理的文本更大,例如演讲稿、书籍章节或科学文章,则使用 TextRank 的蓝图将是一个更好的选择。这些蓝图作为您迈向自动文本摘要的第一步非常棒,因为它们简单又快速。然而,使用机器学习的第三个蓝图为您的特定用例提供了更定制的解决方案。只要您拥有必要的标注数据,就可以通过添加特征和优化机器学习模型来定制此方法以提高性能。例如,您的公司或产品可能有多个管理用户数据、条款和条件以及其他流程的政策文件,您希望为新用户或员工总结这些文件的重要内容。您可以从第三个蓝图开始,并通过添加特征(例如从句数量、使用块字母、是否存在粗体或下划线文本等)来定制第二步,以帮助模型总结政策文件中的重要要点。

进一步阅读

^(1) 您可以在GitHub上找到有关该软件包的更多信息,包括我们在设计此蓝图时使用的使用指南。

^(2) Sansiri Tarnpradab 等人提出了一种通过层次注意力网络实现在线论坛讨论摘要的方法。https://arxiv.org/abs/1805.10390。也可以查看数据集(.zip

第十章:利用词嵌入探索语义关系

相似性的概念对所有机器学习任务都是基础性的。在第五章中,我们解释了如何基于词袋模型计算文本相似性。给定两个文档的 TF-IDF 向量,它们的余弦相似度可以轻松计算,我们可以使用这些信息来搜索、聚类或分类相似的文档。

然而,在词袋模型中,相似性的概念完全基于两个文档中共同单词的数量。如果文档没有共享任何标记,文档向量的点积以及因此的余弦相似度将为零。考虑以下关于一部新电影的两条社交平台评论:

“多么美妙的电影。”

“这部电影很棒。”

显然,尽管使用完全不同的词语,这些评论具有类似的含义。在本章中,我们将介绍词嵌入作为捕捉单词语义并用于探索语义相似性的一种手段。

您将学到什么以及我们将构建什么

对于我们的用例,我们假设我们是市场研究人员,希望使用关于汽车的文本来更好地理解汽车市场中的一些关系。具体而言,我们想探索汽车品牌和型号之间的相似性。例如,品牌 A 的哪些型号与品牌 B 的特定型号最相似?

我们的语料库包括 Reddit 自我帖子数据集汽车类别中的 20 个子社区,这在第四章中已经使用过。每个子社区都包含关于 Mercedes、Toyota、Ford 和 Harley-Davidson 等品牌的汽车和摩托车的 1,000 条帖子。由于这些帖子是用户编写的问题、答案和评论,我们实际上可以了解到这些用户隐含地认为什么是相似的。

我们将再次使用Gensim 库,这在第八章中已经介绍过。它提供了一个良好的 API 来训练不同类型的嵌入,并使用这些模型进行语义推理。

学习本章后,您将能够使用词嵌入进行语义分析。您将知道如何使用预训练的嵌入,如何训练自己的嵌入,如何比较不同的模型以及如何可视化它们。您可以在我们的GitHub 代码库中找到本章的源代码以及部分图像。

语义嵌入的理由

在前几章中,我们使用 TF-IDF 向量化我们的模型。这种方法易于计算,但也有一些严重的缺点:

  • 文档向量具有由词汇量大小定义的非常高的维度。因此,向量非常稀疏;即大多数条目为零。

  • 它在短文本(如 Twitter 消息、服务评论和类似内容)中表现不佳,因为短文本中共同词的概率较低。

  • 高级应用例如情感分析、问答或机器翻译需要准确捕捉单词的实际含义以正确工作。

尽管词袋模型在分类或主题建模等任务中表现出色,但仅当文本足够长且有足够的训练数据时。请记住,词袋模型中的相似性仅基于显著共同单词的存在。

嵌入则是一个密集的数值向量表示对象,捕捉某种语义相似性。当我们在文本分析的背景下讨论嵌入时,我们必须区分单词嵌入和文档嵌入。单词嵌入是单个单词的向量表示,而文档嵌入是代表文档的向量。在这一章节中,我们将重点关注单词的密集向量表示。

单词嵌入

嵌入算法的目标可以定义如下:给定一个维度d,找到单词的向量表示,使得具有相似含义的单词具有相似的向量。维度d是任何单词嵌入算法的超参数。通常设置在 50 到 300 之间。

维度本身没有预定义或人类可理解的含义。相反,模型从文本中学习单词之间的潜在关系。图 10-1(左)展示了这一概念。我们对每个单词有五维向量。这些维度中的每一个代表单词之间某种关系,使得在这一维度上相似的单词具有类似的值。所示的维度名称是对这些值的可能解释。

图 10-1. 密集向量表示语义相似性的标注(左)可用于回答类比问题(右)。我们对向量维度命名为“Royalty”等来展示可能的解释。^(1)

训练的基本思想是在相似上下文中出现的单词具有相似的含义。这被称为分布假设。例如,以下描述tesgüino的句子:^(2)

  • 桌子上有一瓶 ___。

  • 每个人都喜欢 ___。

  • 开车前不要 ___。

  • 我们用玉米制造 ___。

即使不了解tesgüino这个词,通过分析典型语境,你也能对其含义有相当好的理解。你还可以识别语义上相似的单词,因为你知道它是一种酒精饮料。

使用单词嵌入进行类比推理

真正令人惊讶的是,用这种方法构建的词向量使我们能够通过向量代数检测类似于“queen is to king like woman is to man”的类比(见图 10-1 右侧)。设 v ( w ) 为单词 w 的词嵌入。那么这个类比可以用数学方式表达如下:

v ( q u e e n ) - v ( k i n g ) ≈ v ( w o m a n ) - v ( m a n )

如果这个近似等式成立,我们可以将这个类比重述为一个问题:像“king”对应于“man”,“woman”对应于什么?或者数学上表示为:^(3)

v ( w o m a n ) + v ( k i n g ) - v ( m a n ) ≈ ?

这种方式允许一种模糊推理来回答类似于这样的类比问题:“巴黎是法国的首都,那么德国的首都是什么?”或者在市场研究场景中,正如我们将要探索的那样:“考虑到 F-150 是福特的皮卡,那么丰田的类似车型是什么?”

嵌入类型

已经开发了几种算法来训练词嵌入。Gensim 允许您训练 Word2Vec 和 FastText 词嵌入。GloVe 词嵌入可以用于相似性查询,但不能与 Gensim 一起训练。我们介绍了这些算法的基本思想,并简要解释了更先进但也更复杂的上下文嵌入方法。您将在本章末找到原始论文的参考文献和进一步的解释。

Word2Vec

尽管之前已经有过词嵌入的方法,但谷歌的 Tomáš Mikolov(Mikolov 等人,2013 年)的工作标志着一个里程碑,因为它在类比任务上显著优于以前的方法,特别是刚刚解释的那些任务。Word2Vec 有两个变体,即连续词袋模型(CBOW)和跳字模型(见图 10-2)。

图 10-2. 连续词袋模型(左)与跳字模型(右)。

这两种算法都在文本上使用一个滑动窗口,由目标词 w t 和上下文窗口大小 c 定义。在这个例子中,c = 2 ,即训练样本由五个词组成 w t-2 , ⋯ , w t+2 。其中一种训练样本以粗体显示:... is trying things to see ...。在 CBOW 架构(左侧),模型被训练来预测从上下文词到目标词。这里,一个训练样本由上下文词的独热编码向量的总和或平均值以及目标词作为标签。相比之下,skip-gram 模型(右侧)被训练来预测给定目标词的上下文词。在这种情况下,每个目标词为每个上下文词生成一个单独的训练样本;没有向量平均。因此,skip-gram 训练速度较慢(对于大窗口大小来说要慢得多!),但通常能够更好地处理不常见的词语。

这两种嵌入算法都使用了一个简单的单层神经网络和一些技巧来进行快速和可扩展的训练。学习到的嵌入实际上是由隐藏层的权重矩阵定义的。因此,如果你想学习 100 维的向量表示,隐藏层必须由 100 个神经元组成。输入和输出的词语都由独热向量表示。嵌入的维度和上下文窗口的大小 c 都是所有这里介绍的嵌入方法中的超参数。我们将在本章后面探讨它们对嵌入的影响。

GloVe

全局向量(GloVe)方法,由斯坦福自然语言处理组在 2014 年开发,使用全局共现矩阵来计算词向量,而不是一个预测任务(Pennington 等,2014 年)。一个大小为 V 的词汇的共现矩阵具有维度 V × V 。矩阵中的每个单元 ( i , j ) 包含基于固定上下文窗口大小的词 w i 和 w j 的共现次数。这些嵌入是通过类似于主题建模或降维技术中使用的矩阵分解技术来推导的。

这个模型被称为全局,因为共现矩阵捕获全局语料库统计,与只使用局部上下文窗口进行预测任务的 Word2Vec 形成对比。 GloVe 通常不比 Word2Vec 表现更好,但根据训练数据和任务的不同,它产生类似的好结果(参见 Levy 等人,2014 年,进行讨论)。

FastText

我们介绍的第三种模型再次由一支由Tomáš Mikolov领导的团队在 Facebook 开发(Joulin 等人,2017 年)。 主要动机是处理词汇外的词汇。 无论是 Word2Vec 还是 GloVe,都仅为训练语料库中包含的词汇生成词嵌入。 相比之下,FastText利用字符 n-gram 的子词信息来推导向量表示。 例如,fasttext的字符三元组是fasaststtttetexext。 使用的 n-gram 长度(最小和最大)是模型的超参数。

任何单词向量都是从其字符 n-grams 的嵌入构建的。 并且即使是模型以前未见过的单词,大多数字符 n-gram 也有嵌入。 例如,fasttext的向量将类似于fasttext,因为它们有共同的 n-grams。 因此,FastText 非常擅长为通常是词汇外的拼写错误的单词找到嵌入。

深度上下文化的嵌入

单词的语义含义往往取决于其上下文。 想想“我是对的”和“请右转”中right一词的不同含义^(4)。 所有这三种模型(Word2Vec,GloVe 和 FastText)每个单词仅有一个向量表示;它们无法区分依赖上下文的语义。

类似来自语言模型的嵌入(ELMo)的上下文化嵌入考虑上下文,即前后的单词(Peters 等人,2018 年)。 没有为每个单词存储一个可以简单查找的单词向量。 相反,ELMo 通过多层双向长短期记忆神经网络(LSTM)传递整个句子,并从内部层的权重组合每个单词的向量。 最近的模型如 BERT 及其后继模型通过使用注意力变换器而不是双向 LSTM 改进了这种方法。 所有这些模型的主要优点是迁移学习:能够使用预训练的语言模型并针对特定的下游任务(如分类或问题回答)进行微调。 我们将在第十一章中更详细地介绍这个概念。

蓝图:在预训练模型上使用相似性查询

所有这些理论之后,让我们开始一些实践。在我们的第一个例子中,我们使用预训练的嵌入。这些具有优势,即其他人已经在大型语料库(如维基百科或新闻文章)上花费了训练工作。在我们的蓝图中,我们将检查可用的模型,加载其中一个,并对单词向量进行推理。

加载预训练模型

几个模型可以公开下载。^(5) 我们稍后会描述如何加载自定义模型,但在这里,我们将使用 Gensim 的方便下载 API。

根据默认设置,Gensim 将模型存储在 ~/gensim-data 下。如果您想将其更改为自定义路径,可以在导入下载器 API 之前设置环境变量 GENSIM_DATA_DIR。我们将所有模型存储在本地目录 models 中:

import os
os.environ['GENSIM_DATA_DIR'] = './models'

现在让我们看看可用的模型。以下行将由 api.info()['models'] 返回的字典转换为 DataFrame,以获得格式良好的列表,并显示总共 13 个条目中的前五个:

import gensim.downloader as api

info_df = pd.DataFrame.from_dict(api.info()['models'], orient='index')
info_df[['file_size', 'base_dataset', 'parameters']].head(5)

file_sizebase_datasetparameters
fasttext-wiki-news-subwords-3001005007116Wikipedia 2017, UMBC webbase corpus and statmt.org news dataset (16B tokens){'dimension’: 300}
conceptnet-numberbatch-17-06-3001225497562ConceptNet, word2vec, GloVe, and OpenSubtitles 2016{'dimension’: 300}
word2vec-ruscorpora-300208427381Russian National Corpus (about 250M words){'dimension’: 300, ‘window_size’: 10}
word2vec-google-news-3001743563840Google News (about 100 billion words){'dimension’: 300}
glove-wiki-gigaword-5069182535Wikipedia 2014 + Gigaword 5(6B tokens, uncased){'dimension’: 50}

我们将使用 glove-wiki-gigaword-50 模型。这个具有 50 维单词向量的模型体积较小,但对我们的目的来说完全足够。它在大约 60 亿个小写标记上进行了训练。api.load 如果需要会下载模型,然后将其加载到内存中:

model = api.load("glove-wiki-gigaword-50")

我们下载的文件实际上并不包含完整的 GloVe 模型,而只包含纯粹的词向量。由于未包含模型的内部状态,这种简化模型无法进一步训练。

相似性查询

给定一个模型,可以通过属性 model.wv['king'] 或甚至更简单地通过快捷方式 model['king'] 访问单词 king 的向量。让我们看看 kingqueen 的 50 维向量的前 10 个分量。

v_king = model['king']
v_queen = model['queen']

print("Vector size:", model.vector_size)
print("v_king  =", v_king[:10])
print("v_queen =", v_queen[:10])
print("similarity:", model.similarity('king', 'queen'))

输出:

Vector size: 50
v_king  = [ 0.5   0.69 -0.6  -0.02  0.6  -0.13 -0.09  0.47 -0.62 -0.31]
v_queen = [ 0.38  1.82 -1.26 -0.1   0.36  0.6  -0.18  0.84 -0.06 -0.76]
similarity: 0.7839043

顾名思义,在许多维度上的值是相似的,导致高达 0.78 的高相似性分数。因此,queenking 相当相似,但它是最相似的词吗?好的,让我们通过调用相应的函数来检查与 king 最相似的三个词:

model.most_similar('king', topn=3)

输出:

[('prince', 0.824), ('queen', 0.784), ('ii', 0.775)]

实际上,男性的princequeen更相似,但queen在列表中排名第二,其后是罗马数字 II,因为许多国王被称为“第二”。

单词向量的相似性分数通常通过余弦相似度计算,这在第五章中介绍过。Gensim 提供了几种变体的相似性函数。例如,cosine_similarities方法计算单词向量与其他单词向量数组之间的相似度。让我们比较king与更多单词:

v_lion = model['lion']
v_nano = model['nanotechnology']

model.cosine_similarities(v_king, [v_queen, v_lion, v_nano])

Out:

array([ 0.784,  0.478, -0.255], dtype=float32)

基于模型的训练数据(维基百科和 Gigaword),模型假设单词kingqueen相似,与lion略有相似,但与nanotechnology完全不相似。需要注意的是,与非负 TF-IDF 向量不同,单词嵌入在某些维度上也可能是负的。因此,相似度值范围从+ 1到- 1不等。

先前使用的most_similar()函数还允许两个参数,positivenegative,每个参数都是向量列表。如果p o s i t i v e = [ p o s 1 , ⋯ , p o s n ]和n e g a t i v e = [ n e g 1 , ⋯ , n e g m ],那么此函数将找到与∑ i=1 n p o s i - ∑ j=1 m n e g j最相似的单词向量。

因此,我们可以用 Gensim 来制定关于皇室的类比查询:

model.most_similar(positive=['woman', 'king'], negative=['man'], topn=3)

Out:

[('queen', 0.852), ('throne', 0.766), ('prince', 0.759)]

以及关于德国首都的问题:

model.most_similar(positive=['paris', 'germany'], negative=['france'], topn=3)

Out:

[('berlin', 0.920), ('frankfurt', 0.820), ('vienna', 0.818)]

我们也可以省略负面列表,以找到与francecapital之和最接近的单词:

model.most_similar(positive=['france', 'capital'], topn=1)

Out:

[('paris', 0.784)]

实际上,它就是paris!这真是令人惊叹,显示了词向量的巨大威力。然而,正如在机器学习中一样,模型并不完美。它们只能学习到数据中存在的内容。因此,并非所有相似性查询都会产生如此惊人的结果,下面的例子就说明了这一点:

model.most_similar(positive=['greece', 'capital'], topn=3)

Out:

[('central', 0.797), ('western', 0.757), ('region', 0.750)]

显然,模型没有足够的训练数据来推导雅典和希腊之间的关系。

注意

Gensim 还提供了余弦相似度的一种变体,most_similar_cosmul。这对于类比查询比前面显示的方法更有效,因为它平滑了一个大相似性项主导方程的效果(Levy 等,2015)。然而,对于前面的例子,返回的单词将是相同的,但相似性分数将更高。

如果您使用来自维基百科和新闻文章的编辑文本来训练嵌入,您的模型将能够很好地捕捉到类似首都-国家的事实关系。但是,对于市场研究问题,比较不同品牌产品的情况呢?通常这些信息在维基百科上找不到,而是在最新的社交平台上,人们在讨论产品。如果您在社交平台上使用用户评论来训练嵌入,您的模型将学习到来自用户讨论的词语关联。这样,它就成为了人们对关系的认知表示,独立于其是否客观真实。这是一个有趣的副作用,您应该意识到。通常,您希望捕捉到这种特定应用的偏见,这也是我们接下来要做的事情。但是请注意,每个训练语料库都包含一定的偏见,这可能还会导致一些不希望的副作用(参见“男人对计算机程序员如同女人对家庭主妇”)。

训练和评估自己嵌入的蓝图

在本节中,我们将在 Reddit Selfposts 数据集中的 2 万个关于汽车的用户帖子上训练和评估特定领域的嵌入。在开始训练之前,我们必须考虑数据准备的选项,因为这总是对模型在特定任务中的实用性产生重要影响的因素。

数据准备

Gensim 要求输入训练的令牌序列。除了分词之外,还有一些其他方面需要考虑数据准备。根据分布假设,经常一起出现或在相似上下文中的单词将获得相似的向量。因此,我们应确保确实识别了这些共现关系。如果像我们这里的示例一样训练句子不多,您应在预处理中包括这些步骤:

  1. 清理文本,去除不需要的标记(符号、标签等)。

  2. 将所有单词转换为小写。

  3. 使用引理。

所有这些都使得词汇量保持较小,训练时间较短。当然,如果根据这些规则修剪我们的训练数据,屈折形式和大写字词将会是词汇外的情况。对于我们想要进行的名词语义推理来说,这不是问题,但如果我们想要分析例如情感,这可能会成为问题。此外,您应考虑以下标记类别:

停用词

停用词可以提供有关非停用词语境的宝贵信息。因此,我们更倾向于保留停用词。

数字

根据应用程序的不同,数字可能是有价值的,也可能只是噪音。在我们的例子中,我们正在查看汽车数据,并且肯定希望保留像328这样的标记,因为它是宝马车型的名称。如果数字携带相关信息,则应保留这些数字。

另一个问题是我们是否应该按句子拆分,还是仅保留帖子的原样。考虑虚构帖子“I like the BMW 328. But the Mercedes C300 is also great.”这两个句子在我们的相似性任务中应该被视为两个不同的帖子吗?可能不应该。因此,我们将所有用户帖子中的所有词形的列表视为一个单独的“句子”用于训练。

我们已经为第四章中的 2 万条 Reddit 汽车帖子准备了词形。因此,在这里我们可以跳过数据准备的这一部分,直接将词形加载到 Pandas 的DataFrame中:

db_name = "reddit-selfposts.db"
con = sqlite3.connect(db_name)
df = pd.read_sql("select subreddit, lemmas, text from posts_nlp", con)
con.close()

df['lemmas'] = df['lemmas'].str.lower().str.split() # lower case tokens
sents = df['lemmas'] # our training "sentences"

短语

特别是在英语中,如果一个词是复合短语的一部分,那么该词的含义可能会发生变化。例如,timing beltseat beltrust belt。所有这些复合词虽然都可以在我们的语料库中找到,但它们的含义各不相同。因此,将这些复合词视为单个标记可能更为合适。

我们可以使用任何算法来检测这些短语,例如 spaCy 检测名词块(见“使用 spaCy 进行语言处理”)。还有许多统计算法可用于识别这样的搭配,如异常频繁的 n-gram。原始的 Word2Vec 论文(Mikolov 等人,2013)使用了一种简单但有效的基于点间互信息(PMI)的算法,基本上衡量了两个词出现之间的统计依赖性。

对于我们现在正在训练的模型,我们使用了一个高级版本,称为归一化点间互信息(NPMI),因为它能提供更稳健的结果。鉴于其值范围有限,从- 1到+ 1,它也更容易调整。我们在初始运行中将 NPMI 阈值设定为一个相当低的值,即 0.3. 我们选择使用连字符作为短语中单词的分隔符。这将生成类似harley-davidson的复合标记,无论如何这些标记都会在文本中找到。如果使用默认的下划线分隔符,则会产生不同的标记:

from gensim.models.phrases import Phrases, npmi_scorer

phrases = Phrases(sents, min_count=10, threshold=0.3,
                  delimiter=b'-', scoring=npmi_scorer)

通过这种短语模型,我们可以识别一些有趣的复合词:

sent = "I had to replace the timing belt in my mercedes c300".split()
phrased = phrases[sent]
print('|'.join(phrased))

Out:

I|had|to|replace|the|timing-belt|in|my|mercedes-c300

timing-belt很好,但我们不希望为品牌和型号名称的组合构建复合词,比如奔驰 c300。因此,我们将分析短语模型,找到一个合适的阈值。显然,选择的值太低了。以下代码导出我们语料库中找到的所有短语及其分数,并将结果转换为DataFrame以便轻松检查:

phrase_df = pd.DataFrame(phrases.export_phrases(sents),
                         columns =['phrase', 'score'])
phrase_df = phrase_df[['phrase', 'score']].drop_duplicates() \
            .sort_values(by='score', ascending=False).reset_index(drop=True)
phrase_df['phrase'] = phrase_df['phrase'].map(lambda p: p.decode('utf-8'))

现在我们可以检查哪个阈值适合奔驰

phrase_df[phrase_df['phrase'].str.contains('mercedes')]

短语分数
83奔驰0.80
1417奔驰 c3000.47

如我们所见,阈值应该大于 0.5 且小于 0.8。通过检查宝马福特哈雷戴维森等几个其他品牌,我们确定 0.7 是一个很好的阈值,可以识别复合供应商名称,但保持品牌和型号分开。实际上,即使是 0.7 这样严格的阈值,短语模型仍然保留了许多相关的词组,例如street glide(哈雷戴维森)、land cruiser(丰田)、forester xt(斯巴鲁)、water pumpspark plugtiming belt

我们重建了我们的短语分析器,并在我们的DataFrame中为复合词创建了一个新列,该列包含单词标记:

phrases = Phrases(sents, min_count=10, threshold=0.7,
                  delimiter=b'-', scoring=npmi_scorer)

df['phrased_lemmas'] = df['lemmas'].map(lambda s: phrases[s])
sents = df['phrased_lemmas']

我们数据准备步骤的结果是由词形和短语组成的句子。现在,我们将训练不同的嵌入模型,并检查我们能从中获得哪些见解。

蓝图:使用 Gensim 训练模型

使用 Gensim 可以方便地训练 Word2Vec 和 FastText 嵌入。以下调用Word2Vec在语料库上训练了 100 维的 Word2Vec 嵌入,窗口大小为 2,即目标词的±2 个上下文词。为了说明,还传递了一些其他相关超参数。我们使用 skip-gram 算法,并在四个线程中训练网络五次迭代:

from gensim.models import Word2Vec

model = Word2Vec(sents,       # tokenized input sentences
                 size=100,    # size of word vectors (default 100)
                 window=2,    # context window size (default 5)
                 sg=1,        # use skip-gram (default 0 = CBOW)
                 negative=5,  # number of negative samples (default 5)
                 min_count=5, # ignore infrequent words (default 5)
                 workers=4,   # number of threads (default 3)
                 iter=5)      # number of epochs (default 5)

在 i7 笔记本电脑上,处理 2 万个句子大约需要 30 秒,速度相当快。增加样本数和迭代次数,以及更长的向量和更大的上下文窗口,会增加训练时间。例如,在这种设置下训练 30 大小的 100 维向量,跳跃图算法大约需要 5 分钟。相比之下,CBOW 的训练时间与上下文窗口的大小无关。

以下调用将完整模型保存到磁盘。完整模型意味着包括所有内部状态的完整神经网络。这样,模型可以再次加载并进一步训练:

model.save('./models/autos_w2v_100_2_full.bin')

算法的选择以及这些超参数对生成的模型影响很大。因此,我们提供了一个训练和检查不同模型的蓝图。参数网格定义了将为 Word2Vec 或 FastText 训练哪些算法变体(CBOW 或 skip-gram)和窗口大小。我们也可以在这里变化向量大小,但这个参数的影响不是很大。根据我们的经验,在较小的语料库中,50 或 100 维的向量效果很好。因此,我们在实验中将向量大小固定为 100:

from gensim.models import Word2Vec, FastText

model_path = './models'
model_prefix = 'autos'

param_grid = {'w2v': {'variant': ['cbow', 'sg'], 'window': [2, 5, 30]},
              'ft': {'variant': ['sg'], 'window': [5]}}
size = 100

for algo, params in param_grid.items():
    for variant in params['variant']:
        sg = 1 if variant == 'sg' else 0
        for window in params['window']:
            if algo == 'w2v':
                model = Word2Vec(sents, size=size, window=window, sg=sg)
            else:
                model = FastText(sents, size=size, window=window, sg=sg)

            file_name = f"{model_path}/{model_prefix}_{algo}_{variant}_{window}"
            model.wv.save_word2vec_format(file_name + '.bin', binary=True)

由于我们只想分析语料库内的相似性,我们不保存完整的模型,而是仅保存纯单词向量。这些由KeyedVectors类表示,并且可以通过模型属性model.wv访问。这样生成的文件更小,并且完全足够我们的目的。

警告

要注意信息丢失!当您重新加载仅由单词向量组成的模型时,它们无法进一步训练。此外,FastText 模型失去了为超出词汇表单词推导嵌入的能力。

蓝图:评估不同的模型

实际上,对于特定领域任务和语料库,算法化地确定最佳超参数是相当困难的。因此,检查模型的表现并手动验证它们如何执行以识别一些已知的关系并非坏主意。

仅包含单词向量的保存文件很小(每个约 5 MB),因此我们可以将许多文件加载到内存中并运行一些比较。我们使用五个模型的子集来说明我们的发现。这些模型存储在一个由模型名称索引的字典中。您可以添加任何您想比较的模型,甚至是早期预训练的 GloVe 模型:

from gensim.models import KeyedVectors

names = ['autos_w2v_cbow_2', 'autos_w2v_sg_2',
         'autos_w2v_sg_5', 'autos_w2v_sg_30', 'autos_ft_sg_5']
models = {}

for name in names:
    file_name = f"{model_path}/{name}.bin"
    models[name] = KeyedVectors.load_word2vec_format(file_name, binary=True)

我们提供了一个小的蓝图函数用于比较。它接受一个模型列表和一个单词,并生成一个DataFrame,其中包含根据每个模型最相似的单词:

def compare_models(models, **kwargs):

    df = pd.DataFrame()
    for name, model in models:
        df[name] = [f"{word} {score:.3f}"
                    for word, score in model.most_similar(**kwargs)]
    df.index = df.index + 1 # let row index start at 1
    return df

现在让我们看看参数对我们计算的模型有什么影响。因为我们要分析汽车市场,我们查看与宝马最相似的单词:

compare_models([(n, models[n]) for n in names], positive='bmw', topn=10)

autos_w2v_cbow_2autos_w2v_sg_2autos_w2v_sg_5autos_w2v_sg_30autos_ft_sg_5
1梅赛德斯 0.873奔驰 0.772奔驰 0.808xdrive 0.803宝马 0.819
2莱克萨斯 0.851奔驰 0.710335i 0.740328i 0.797bmwfs 0.789
3大众 0.807保时捷 0.705328i 0.736f10 0.762m135i 0.774
4奔驰 0.806莱克萨斯 0.704奔驰 0.723335i 0.760335i 0.773
5沃尔沃 0.792奔驰 0.695x-drive 0.708535i 0.755梅赛德斯-奔驰 0.765
6哈雷 0.783梅赛德斯 0.693135i 0.703宝马 0.745奔驰 0.760
7保时捷 0.781奔驰-奔驰 0.680梅赛德斯 0.690x-drive 0.74035i 0.747
8斯巴鲁 0.777奥迪 0.675e92 0.6855 系列 0.736奔驰 0.747
9MB 0.769335i 0.670奔驰-奔驰 0.680550i 0.728135i 0.746
10大众 0.768135i 0.662奔驰 0.679435i 0.726435i 0.744

有趣的是,窗口大小为 2 的第一批模型主要生成其他汽车品牌,而窗口大小为 30 的模型基本上生成了不同 BMW 型号的列表。事实上,较短的窗口强调范式关系,即可以在句子中互换的词语。在我们的案例中,这将是品牌,因为我们正在寻找类似BMW的词语。较大的窗口捕获更多的语法关系,其中词语之间的相似性在于它们经常在相同的上下文中出现。窗口大小为 5,即默认值,产生了两者的混合。对于我们的数据,CBOW 模型最好地表示了范式关系,而语法关系则需要较大的窗口大小,因此更适合由 skip-gram 模型捕获。FastText 模型的输出显示了其性质,即拼写相似的词语得到相似的分数。

寻找相似概念

窗口大小为 2 的 CBOW 向量在范式关系上非常精确。从一些已知术语开始,我们可以使用这样的模型来识别领域的核心术语和概念。表 10-1 展示了在模型autos_w2v_cbow_2上进行一些相似性查询的输出。列concept是我们添加的,以突出我们预期的输出词语类型。

表 10-1. 使用 CBOW 模型和窗口大小为 2 查找选定词语的最相似邻居

WordConceptMost Similar
toyota汽车品牌ford mercedes nissan certify dodge mb bmw lexus chevy honda
camry汽车型号corolla f150 f-150 c63 is300 ranger 335i 535i 328i rx
spark-plug汽车部件water-pump gasket thermostat timing-belt tensioner throttle-body serpentine-belt radiator intake-manifold fluid
washington地点oregon southwest ga ottawa san_diego valley portland mall chamber county

当然,答案并不总是符合我们的期望;它们只是类似的词语。例如,Toyota 的列表中不仅包含汽车品牌,还包括多种型号。然而,在实际项目中,业务部门的领域专家可以轻松识别错误的术语,仍然找到有趣的新联想。但是,在以这种方式处理词嵌入时,手动筛选绝对是必要的。

我们自己模型上的类比推理

现在让我们看看我们的不同模型如何能够检测类似的概念。我们想要知道 Toyota 是否有一款与 Ford 的 F-150 皮卡相媲美的产品。因此,我们的问题是:“Toyota”对应于“Ford”的“F-150”的什么?我们使用之前的函数compare_models并对结果进行转置,以比较不同模型的wv.most_similar()结果:

compare_models([(n, models[n]) for n in names],
               positive=['f150', 'toyota'], negative=['ford'], topn=5).T

Out:

12345
autos_w2v_cbow_2f-150 0.850328i 0.824s80 0.82093 0.8194matic 0.817
autos_w2v_sg_2f-150 0.744f-250 0.727dodge-ram 0.716tacoma 0.713ranger 0.708
autos_w2v_sg_5tacoma 0.724tundra 0.707f-150 0.664highlander 0.6444wd 0.631
autos_w2v_sg_304runner 0.742tacoma 0.7394runners 0.7074wd 0.678tacomas 0.658
autos_ft_sg_5toyotas 0.777toyo 0.762tacoma 0.748tacomas 0.745f150s 0.744

实际上,Toyota Tacoma 直接与 F-150 以及 Toyota Tundra 竞争。考虑到这一点,窗口大小为 5 的跳字模型给出了最佳结果。[⁶]实际上,如果你用gmc替换toyota,你会得到sierra,如果你要chevy,你会得到silverado作为这个模型最相似的车型。所有这些都是竞争激烈的全尺寸皮卡。对于其他品牌和车型,这也效果很好,但当然最适合那些在 Reddit 论坛中广泛讨论的模型。

可视化嵌入的蓝图

如果我们像本章一样基于词嵌入探索我们的语料库,我们对实际相似度分数不感兴趣,因为整个概念本质上是模糊的。我们想要理解的是基于接近性和相似性概念的语义关系。因此,视觉表现对于探索词嵌入及其关系非常有帮助。在本节中,我们将首先使用不同的降维技术来可视化嵌入。之后,我们将展示如何通过视觉探索给定关键词的语义邻域。正如我们将看到的那样,这种数据探索可以揭示领域特定术语之间非常有趣的关系。

应用降维蓝图

高维向量可以通过将数据投影到二维或三维来进行可视化。如果投影效果良好,可以直观地检测到相关术语的聚类,并更深入地理解语料库中的语义概念。我们将寻找相关词汇的聚类,并使用窗口大小为 30 的模型探索某些关键词的语义邻域,这有利于同位语关系。因此,我们期望看到一个“BMW”词汇组,包含 BMW 相关术语,一个“Toyota”词汇组,包含 Toyota 相关术语,等等。

在机器学习领域,降维也有许多用例。一些学习算法对高维且常稀疏的数据存在问题。诸如 PCA、t-SNE 或 UMAP(见“降维技术”)之类的降维技术试图通过投影来保留或甚至突出数据分布的重要方面。其一般思想是以一种方式投影数据,使得在高维空间中彼此接近的对象在投影中也接近,而远离的对象仍然保持距离。在我们的示例中,我们将使用 UMAP 算法,因为它为可视化提供了最佳结果。但是由于 umap 库实现了 scikit-learn 的估算器接口,你可以轻松地用 scikit-learn 的 PCATSNE 类替换 UMAP 缩减器。

下面的代码块包含了使用 UMAP 将嵌入投影到二维空间的基本操作,如 图 10-3 所示。在选择嵌入模型和要绘制的词(在本例中我们采用整个词汇表)之后,我们使用目标维数 n_components=2 实例化 UMAP 降维器。我们像往常一样使用余弦而不是标准的欧氏距离度量。然后通过调用 reducer.fit_transform(wv) 将嵌入投影到 2D。

from umap import UMAP

model = models['autos_w2v_sg_30']
words = model.vocab
wv = [model[word] for word in words]

reducer = UMAP(n_components=2, metric='cosine', n_neighbors = 15, min_dist=0.1)
reduced_wv = reducer.fit_transform(wv)

图 10-3. 我们模型的所有词嵌入的二维 UMAP 投影。突出显示了一些词及其最相似的邻居,以解释此散点图中的一些聚类。

我们在这里使用 Plotly Express 进行可视化,而不是 Matplotlib,因为它有两个很好的特性。首先,它生成交互式图。当你用鼠标悬停在一个点上时,相应的词将被显示出来。此外,你可以放大和缩小并选择区域。Plotly Express 的第二个很好的特性是它的简单性。你只需要准备一个带有坐标和要显示的元数据的 DataFrame。然后你只需实例化图表,本例中为散点图 (px.scatter):

import plotly.express as px

plot_df = pd.DataFrame.from_records(reduced_wv, columns=['x', 'y'])
plot_df['word'] = words
params = {'hover_data': {c: False for c in plot_df.columns},
          'hover_name': 'word'}

fig = px.scatter(plot_df, x="x", y="y", opacity=0.3, size_max=3, **params)
fig.show()

你可以在我们的 GitHub 仓库 中的 embeddings 包中找到一个更通用的蓝图函数 plot_embeddings。它允许你选择降维算法,并突出显示低维投影中的选定搜索词及其最相似的邻居。对于 图 10-3 中的绘图,我们事先手动检查了一些聚类,然后明确命名了一些典型的搜索词来着色聚类。^(7) 在交互视图中,你可以在悬停在点上时看到这些词。

下面是生成此图的代码:

from blueprints.embeddings import plot_embeddings

search = ['ford', 'lexus', 'vw', 'hyundai',
          'goodyear', 'spark-plug', 'florida', 'navigation']

plot_embeddings(model, search, topn=50, show_all=True, labels=False,
                algo='umap', n_neighbors=15, min_dist=0.1)

对于数据探索,仅可视化搜索词集合及其最相似的邻居可能更有趣。图 10-4 展示了以下几行代码生成的示例。展示的是搜索词及其前 10 个最相似的邻居:

search = ['ford', 'bmw', 'toyota', 'tesla', 'audi', 'mercedes', 'hyundai']

plot_embeddings(model, search, topn=10, show_all=False, labels=True,
    algo='umap', n_neighbors=15, min_dist=10, spread=25)

图 10-4. 选定关键词及其最相似邻居的二维 UMAP 投影。

图 10-5 显示了相同的关键词,但具有更多相似邻居的三维绘图。Plotly 允许您旋转和缩放点云,这样可以轻松调查感兴趣的区域。以下是生成该图的调用:

plot_embeddings(model, search, topn=30, n_dims=3,
    algo='umap', n_neighbors=15, min_dist=.1, spread=40)

要可视化如 tacoma is to toyota like f150 is to ford 的类比,应使用线性 PCA 转换。UMAP 和 t-SNE 都以非线性方式扭曲原始空间。因此,投影空间中的差异向量方向可能与原始方向毫无关联。即使 PCA 也因剪切而扭曲,但效果不及 UMAP 或 t-SNE 明显。

图 10-5. 选定关键词及其最相似邻居的三维 UMAP 投影。

蓝图:使用 TensorFlow Embedding Projector

一个很好的替代自实现可视化函数的选择是 TensorFlow Embedding Projector。它还支持 PCA、t-SNE 和 UMAP,并为数据过滤和突出显示提供了一些便利选项。您甚至无需安装 TensorFlow 就可以使用它,因为有一个在线版本可用。一些数据集已加载为演示。

要显示我们自己的单词嵌入与 TensorFlow Embedding Projector,我们需要创建两个以制表符分隔值的文件:一个包含单词向量的文件和一个可选的包含嵌入元数据的文件,在我们的情况下,它们只是单词。这可以通过几行代码实现:

import csv

name = 'autos_w2v_sg_30'
model = models[name]

with open(f'{model_path}/{name}_words.tsv', 'w', encoding='utf-8') as tsvfile:
    tsvfile.write('\n'.join(model.vocab))

with open(f'{model_path}/{name}_vecs.tsv', 'w', encoding='utf-8') as tsvfile:
    writer = csv.writer(tsvfile, delimiter='\t',
                        dialect=csv.unix_dialect, quoting=csv.QUOTE_MINIMAL)
    for w in model.vocab:
        _ = writer.writerow(model[w].tolist())

现在我们可以将我们的嵌入加载到投影仪中,并浏览 3D 可视化效果。要检测聚类,应使用 UMAP 或 t-SNE。图 10-6 显示了我们嵌入的 UMAP 投影的截图。在投影仪中,您可以单击任何数据点或搜索单词,并突出显示其前 100 个邻居。我们选择 harley 作为起点来探索与哈雷 - 戴维森相关的术语。正如您所见,这种可视化在探索领域重要术语及其语义关系时非常有帮助。

图 10-6. 使用 TensorFlow Embedding Projector 可视化嵌入。

蓝图:构建相似性树

这些词及其相似关系可以被解释为网络图,如下所示:词表示图的节点,当两个节点“非常”相似时,就创建一条边。此标准可以是节点位于它们的前 n 个最相似邻居之间,或者是相似度分数的阈值。然而,一个词附近的大多数词不仅与该词相似,而且彼此也相似。因此,即使对于少量词的子集,完整的网络图也会有太多的边,以至于无法理解的可视化。因此,我们从略微不同的角度出发,创建这个网络的子图,即相似性树。图 10-7 展示了这样一个根词 noise 的相似性树。

图 10-7. noise 最相似的单词的相似性树。

我们提供两个蓝图函数来创建这样的可视化效果。第一个函数 sim_tree 从根词开始生成相似性树。第二个函数 plot_tree 创建绘图。我们在两个函数中都使用 Python 的图形库 networkx

让我们首先看一下 sim_tree。从根词开始,我们寻找前 n 个最相似的邻居。它们被添加到图中,并且相应地创建边。然后,我们对每个新发现的邻居及其邻居执行相同的操作,依此类推,直到达到与根节点的最大距离。在内部,我们使用队列 (collections.deque) 实现广度优先搜索。边的权重由相似度确定,稍后用于设置线宽:

import networkx as nx
from collections import deque

def sim_tree(model, word, top_n, max_dist):

    graph = nx.Graph()
    graph.add_node(word, dist=0)

    to_visit = deque([word])
    while len(to_visit) > 0:
        source = to_visit.popleft() # visit next node
        dist = graph.nodes[source]['dist']+1

        if dist <= max_dist: # discover new nodes
            for target, sim in model.most_similar(source, topn=top_n):
                if target not in graph:
                    to_visit.append(target)
                    graph.add_node(target, dist=dist)
                    graph.add_edge(source, target, sim=sim, dist=dist)
    return graph

函数 plot_tree 只需几个调用来创建布局并绘制节点和边,并对其进行一些样式设置。我们使用 Graphviz 的 twopi 布局来创建节点的雪花状位置。为简化起见,这里略去了一些细节,但你可以在 GitHub 上找到完整代码

from networkx.drawing.nx_pydot import graphviz_layout

def plot_tree(graph, node_size=1000, font_size=12):

    pos = graphviz_layout(graph, prog='twopi', root=list(graph.nodes)[0])

    colors = [graph.nodes[n]['dist'] for n in graph] # colorize by distance
    nx.draw_networkx_nodes(graph, pos, node_size=node_size, node_color=colors,
                           cmap='Set1', alpha=0.4)
    nx.draw_networkx_labels(graph, pos, font_size=font_size)

    for (n1, n2, sim) in graph.edges(data='sim'):
         nx.draw_networkx_edges(graph, pos, [(n1, n2)], width=sim, alpha=0.2)

    plt.show()

图 10-7 使用这些函数和参数生成。

model = models['autos_w2v_sg_2']
graph = sim_tree(model, 'noise', top_n=10, max_dist=3)
plot_tree(graph, node_size=500, font_size=8)

它展示了与 noise 最相似的单词及其与 noise 的最相似单词,直到设想的距离为 3。可视化表明,我们创建了一种分类法,但实际上并非如此。我们只选择在我们的图中包含可能的边的子集,以突出“父”词与其最相似的“子”词之间的关系。这种方法忽略了兄弟之间或祖父辈之间可能的边。然而,视觉呈现有助于探索围绕根词的特定应用领域的词汇。然而,Gensim 还实现了用于学习单词之间分层关系的 Poincaré embeddings

本图使用了窗口大小为 2 的模型,突显了不同种类和同义词的噪声。如果我们选择较大的窗口大小,我们将得到与根词相关的更多概念。图 10-8 是使用以下参数创建的:

model = models['autos_w2v_sg_30']
graph = sim_tree(model, 'spark-plug', top_n=8, max_dist=2)
plot_tree(graph, node_size=500, font_size=8)

图 10-8. 与火花塞最相似的单词的相似性树。

在这里,我们选择了 spark-plug 作为根词,并选择了窗口大小为 30 的模型。生成的图表很好地概述了与 spark-plugs 相关的领域特定术语。例如,p0302 等代码是不同汽缸中点火故障的标准化 OBD2 故障代码。

当然,这些图表也揭示了我们数据准备中的一些弱点。我们看到 spark-plugsparkplugsparkplugs 四个节点,它们都代表着相同的概念。如果我们希望为所有这些形式的写法创建单一的嵌入向量,就必须将它们合并成一个标记。

结语

探索特定关键术语在领域特定模型中相似邻居可以是一种有价值的技术,以发现领域特定语料库中单词之间的潜在语义关系。尽管单词相似性的整体概念本质上是模糊的,但我们通过仅在约 20,000 用户关于汽车的帖子上训练一个简单的神经网络,产生了非常有趣和可解释的结果。

与大多数机器学习任务一样,结果的质量受到数据准备的强烈影响。根据您要完成的任务,您应该有意识地决定对原始文本应用哪种规范化和修剪。在许多情况下,使用词形和小写字母单词能产生良好的相似性推理嵌入。短语检测可能有助于改进结果,还可以识别应用领域中可能重要的复合术语。

我们使用了 Gensim 来训练、存储和分析我们的嵌入向量。Gensim 非常流行,但您可能也想检查可能更快的替代方案,比如 (Py)Magnitude 或者 finalfusion。当然,您也可以使用 TensorFlow 和 PyTorch 来训练不同类型的嵌入向量。

今天,语义嵌入对所有复杂的机器学习任务至关重要。然而,对于诸如情感分析或释义检测等任务,您不需要单词的嵌入,而是需要句子或完整文档的嵌入。已经发表了许多不同的方法来创建文档嵌入(Wolf, 2018; Palachy, 2019)。一个常见的方法是计算句子中单词向量的平均值。一些 spaCy 模型在其词汇表中包含了单词向量,并且可以基于平均单词向量计算文档相似性。然而,对于单个句子或非常短的文档,仅平均单词向量的方法效果还不错。此外,整个方法受到袋装词袋思想的限制,其中不考虑单词顺序。

当前最先进的模型利用了语义嵌入的能力以及词序。在下一章节中,我们将使用这样的模型进行情感分类。

进一步阅读

^(1) 受到 Adrian Colyer 的“词向量的惊人力量”博文的启发。

^(2) 这个经常被引用的例子最初来自语言学家尤金·尼达,于 1975 年提出。

^(3) Jay Alammar 的博文“图解 Word2Vec”生动地解释了这个方程。

^(4) 拥有相同发音但不同意义的单词被称为同音异义词。如果它们拼写相同,则被称为同形异义词

^(5) 例如,来自RaRe Technologies3Top

^(6) 如果你自己运行这段代码,由于随机初始化的原因,结果可能会与书中打印的略有不同。

^(7) 你可以在电子版和GitHub上找到彩色的图表。

第十一章:在文本数据上执行情感分析

在我们在现实世界中的每一次互动中,我们的大脑在潜意识中不仅通过所说的话来注册反馈,还使用面部表情、身体语言和其他物理线索。然而,随着越来越多的沟通变成数字化形式,它越来越多地出现在文本形式中,我们无法评估物理线索。因此,通过他们写的文本理解一个人的情绪或感受是非常重要的,以便形成对他们信息完整理解。

例如,现在很多客户支持都通过软件服务系统或者自动聊天机器人来自动化。因此,了解客户感受的唯一方式就是通过理解他们回复中的情感。因此,如果我们处理一个特别愤怒的客户,就非常重要要在回复时特别小心,以免进一步激怒他们。同样,如果我们想要了解客户对特定产品或品牌的看法,我们可以分析他们在社交媒体渠道上关于该品牌的帖子、评论或者评价的情感,并理解他们对品牌的感受。

从文本中理解情感是具有挑战性的,因为有几个方面需要推断,这些方面并不直接明显。一个简单的例子是来自亚马逊购买的笔记本电脑的以下客户评价:

这台笔记本电脑存在严重问题。它的速度完全符合规格,非常慢!启动时间更长。

如果一个人类读它,他们可以察觉到关于笔记本电脑速度的讽刺表达,以及它启动时间长的事实,这导致我们得出结论这是一个负面评价。然而,如果我们只分析文本,很明显速度完全符合规格。启动时间较长的事实也可能被认为是一件好事,除非我们知道这是需要小的参数。情感分析的任务也特定于所使用的文本数据类型。例如,报纸文章以结构化方式编写,而推文和其他社交媒体文本则遵循松散结构,并且存在俚语和不正确的标点符号。因此,并不存在一种可以适用于所有情景的蓝图。相反,我们将提供一套可以用来进行成功情感分析的蓝图。

您将学到什么,我们将构建什么

在本章中,我们将探讨多种技术,用于从文本数据片段中估计情感。我们将从简单的基于规则的技术开始,并逐步深入到更复杂的方法,最终使用来自 Google 的 BERT 等最新语言模型。通过这些技术的介绍,我们的目的是提升对客户情感的理解,并为您提供一套可以应用于各种用例的蓝图。例如,结合第二章中的 Twitter API 蓝图(见 ch02.xhtml#ch-api),您可以确定公众对某一特定人物或政治问题的情感。您还可以在组织内使用这些蓝图来分析客户投诉或支持电子邮件中的情感,从而了解客户的满意度。

情感分析

大量信息以文本形式提供,根据通信的上下文,可以将信息分类为客观文本和主观文本。客观文本包含简单的事实陈述,如我们在教科书或维基百科文章中找到的内容。这类文本通常只呈现事实,不表达观点或情感。另一方面,主观文本传达了某人的反应,或包含了情感、情绪或感觉的信息。这在社交媒体渠道如推特中或顾客在产品评论中典型地表现出来。我们进行情感分析研究,以了解通过文本表达的个体心态状态。因此,情感分析最适用于包含此类信息的主观文本,而不是客观文本。在开始分析之前,我们必须确保拥有捕捉我们寻找的情感信息的正确类型数据集。

一段文本的情感可以在短语、句子或文档级别确定。例如,如果我们以客户写给公司的电子邮件为例,将会有几段,每段中包含多个句子。可以为每个句子和每个段落计算情感。虽然第 1 段可能是积极的,但第 3 和第 4 段可能是消极的。因此,如果我们想要确定该客户表达的整体情感,我们需要确定将每段的情感聚合到文档级别的最佳方法。在我们提供的蓝图中,我们在句子级别计算情感。

进行情感分析的技术可以分解为简单的基于规则的技术和监督式机器学习方法。基于规则的技术更容易应用,因为它们不需要标注的训练数据。监督学习方法提供更好的结果,但包括标记数据的额外努力。我们将在我们的用例中展示,可能有简单的方法来绕过这个要求。在本章中,我们将提供以下一套蓝图:

  • 使用基于词典的方法进行情感分析。

  • 通过从文本数据构建附加特征并应用监督式机器学习算法进行情感分析。

  • 使用转移学习技术和预训练语言模型如 BERT 进行情感分析。

介绍亚马逊客户评论数据集。

假设您是一家领先消费电子公司市场部门的分析师,并希望了解您的智能手机产品与竞争对手的比较情况。您可以轻松比较技术规格,但更有趣的是了解产品的消费者感知。您可以通过分析顾客在亚马逊产品评论中表达的情感来确定这一点。利用蓝图并对每个品牌的每条评论的情感进行汇总,您将能够确定顾客如何看待每个品牌。同样,如果您的公司计划通过在相邻类别引入产品来扩展业务,该怎么办?您可以分析一个段落中所有产品的顾客评论,例如媒体平板电脑、智能手表或行动摄像机,并根据汇总的情感确定一个顾客满意度较低的段落,因此您的产品具有更高的潜在成功机会。

对于我们的蓝图,我们将使用一个包含亚马逊不同产品的客户评论的数据集,涵盖多个产品类别。这个亚马逊客户评论数据集已经由斯坦福大学的研究人员抓取和编译好了。^(1) 最新版本 包括了 1996 年至 2018 年间从亚马逊网站抓取的产品评论,涵盖了多个类别。它包括产品评论、产品评级以及其他信息,如有用的投票和产品元数据。对于我们的蓝图,我们将专注于产品评论,并仅使用那些只有一句话的评论。这是为了保持蓝图的简单性,并且去掉聚合步骤。一个包含多个句子的评论可能包含积极和消极的情感。因此,如果我们标记一个评论中所有句子具有相同的情感,那将是不正确的。我们只使用部分类别的数据,以便其可以适应内存并减少处理时间。这个数据集已经准备好了,但你可以参考存储库中的 Data_Preparation 笔记本了解步骤并可能扩展它。蓝图适用于任何类型的数据集,因此如果你可以访问强大的硬件或云基础设施,那么你可以选择更多的类别。

现在让我们看一下数据集:

df = pd.read_json('reviews.json', lines=True)
df.sample(5)

Out:

 overallverifiedreviewerIDasintextsummary
1638075FalseA2A8GHFXUG1B28B0045Z4JAI不错的无咖啡因... 对于一种无咖啡因咖啡来说味道不错 :)好!
1956405TrueA1VU337W6PKAR3B00K0TIC56对于我的小温室来说,我无法找到比这个系统更好的选择,设置容易,喷嘴也表现非常好。对于我的小温室来说,我无法找到比这个系统更好的选择。
1678204TrueA1Z5TT1BBSDLRMB0012ORBT6品质不错的产品,价格合理,省去了一趟商店的旅程。四星评价
1042681FalseA4PRXX2G8900XB005SPI45U我喜欢生的薯片的理念 - 可以和我自制的莎莎酱和鳄梨酱一起吃 - 但这些味道真是太恶心了。没有更好的选择,但味道仍然很差。
519611TrueAYETYLNYDIS2SB00D1HLUP8仿制品来自中国,一分钱一分货。绝对不是原装产品

查看数据集摘要,我们可以看到它包含以下列:

Overall

这是评论者对产品的最终评级。从 1(最低)到 5(最高)。

Verified

这表明产品购买是否经过了亚马逊的验证。

ReviewerID

这是亚马逊为每个评论者分配的唯一标识符。

ASIN

这是亚马逊用来识别产品的唯一产品代码。

文本

用户提供的评论中的实际文本。

Summary

这是用户提供的评论的标题或摘要。

text 包含客户评价的主要内容,表达了用户的观点。尽管其他信息也有用,但我们将专注于在蓝图中使用此列。

蓝图:使用基于词典的方法执行情感分析

作为分析师在亚马逊客户评价数据上工作,可能遇到的第一个挑战是缺少目标标签。我们无法自动知道特定评价是积极还是消极的。文本是因为产品完美运作而表达快乐,还是因为产品在第一次使用时损坏而表达愤怒?直到我们实际阅读评价,我们都无法确定这一点。这是具有挑战性的,因为我们将不得不阅读接近 30 万条评价,并手动为每一条评价分配目标情感。我们通过使用基于词典的方法来解决这个问题。

什么是词典?词典 就像一个包含一系列词汇并使用专家知识编制的字典。词典的关键区别因素在于它包含特定知识并且是为特定目的而收集的。我们将使用包含常用词汇和捕捉与之关联情感的情感词典。一个简单的例子是词汇 happy,情感得分为 1,另一个例子是词汇 frustrated,其得分为-1。有几种标准化的词典可供使用,流行的包括 AFINN 词典、SentiWordNet、Bing Liu 的词典以及 VADER 词典等。它们在词汇量和表达方式上各不相同。例如,AFINN 词典 是一个包含 3,300 个词汇的单一词典,每个词汇都分配了从-3 到+3 的有符号情感分数。负/正表示极性,大小表示强度。另一方面,如果我们看 Bing Liu 词典,它以两个列表的形式存在:一个为积极词汇,另一个为消极词汇,总共有 6,800 个词汇。大多数情感词典适用于英语,但也有适用于德语^(2)及其他 81 种语言的词典,这是由该研究论文生成的^(3)。

句子或短语的情感是通过首先从选择的 lexicon 中识别每个单词的情感分数,然后将它们相加以得出整体情感来确定的。通过使用这种技术,我们避免了手动查看每个评论并分配情感标签的需要。相反,我们依赖于 lexicon,它为每个单词提供专家情感分数。对于我们的第一个蓝图,我们将使用必应刘 lexicon,但您可以自由地扩展蓝图以使用其他 lexicon。 lexicon 通常包含单词的多个变体并排除停用词,因此在这种方法中标准的预处理步骤并不重要。只有 lexicon 中存在的单词才会真正得分。这也导致了这种方法的一个缺点,我们将在蓝图的末尾讨论它。

必应刘 lexicon

必应刘 lexicon 已经编制,将单词分成表达积极意见和表达消极意见的两类。这个 lexicon 还包含拼写错误的单词,更适合用于从在线讨论论坛、社交媒体和其他类似来源提取的文本,并因此应该在亚马逊客户评论数据上产生更好的结果。

必应刘 lexicon 可从作者的网站作为zip 文件获得,其中包含一组积极和消极的单词。它也作为 NLTK 库中的语料库提供,我们可以在下载后使用。一旦我们提取了 lexicon,我们将创建一个可以保存 lexicon 单词及其相应情感分数的字典。我们的下一步是为数据集中的每个评论生成评分。我们首先将文本内容转换为小写;然后使用 NLTK 包中的 word_tokenize 函数,将句子分割成单词,并检查这个单词是否属于我们的 lexicon,如果是,我们将单词的相应情感分数添加到评论的总情感分数中。作为最后一步,我们基于句子中的单词数量对这个分数进行归一化。这个功能被封装在函数 bing_liu_score 中,并应用于数据集中的每个评论:

from nltk.corpus import opinion_lexicon
from nltk.tokenize import word_tokenize
nltk.download('opinion_lexicon')

print('Total number of words in opinion lexicon', len(opinion_lexicon.words()))
print('Examples of positive words in opinion lexicon',
      opinion_lexicon.positive()[:5])
print('Examples of negative words in opinion lexicon',
      opinion_lexicon.negative()[:5])

Out:

Total number of words in opinion lexicon 6789
Examples of positive words in opinion lexicon ['a+', 'abound', 'abounds',
'abundance', 'abundant']
Examples of negative words in opinion lexicon ['2-faced', '2-faces',
'abnormal', 'abolish', 'abominable']

然后:

# Let's create a dictionary which we can use for scoring our review text
df.rename(columns={"reviewText": "text"}, inplace=True)
pos_score = 1
neg_score = -1
word_dict = {}

# Adding the positive words to the dictionary
for word in opinion_lexicon.positive():
        word_dict[word] = pos_score

# Adding the negative words to the dictionary
for word in opinion_lexicon.negative():
        word_dict[word] = neg_score

def bing_liu_score(text):
    sentiment_score = 0
    bag_of_words = word_tokenize(text.lower())
    for word in bag_of_words:
        if word in word_dict:
            sentiment_score += word_dict[word]
    return sentiment_score / len(bag_of_words)

df['Bing_Liu_Score'] = df['text'].apply(bing_liu_score)
df[['asin','text','Bing_Liu_Score']].sample(2)

Out:

 asintextBing_Liu_Score
188097B00099QWOU一如预期0.00
184654B000RW1XO8按设计工作...0.25

现在我们已经计算出情感分数,我们想要检查计算出的分数是否符合基于客户提供的评分的预期。我们可以比较具有不同评分的评论的情感分数,而不是对每个评论都进行检查。我们预期,一个五星评价的评论的情感分数会高于一个一星评价的评论。在下一步中,我们将为每个类型的星级评分缩放每个评论的分数在 1 到-1 之间,并计算所有评论的平均情感分数:

df['Bing_Liu_Score'] = preprocessing.scale(df['Bing_Liu_Score'])
df.groupby('overall').agg({'Bing_Liu_Score':'mean'})

Out:

overallBing_Liu_Score
1-0.587061
2-0.426529
40.344645
50.529065

前述蓝图使我们能够使用任何类型的情感词汇表快速确定情感分数,并且还可以作为比较其他复杂技术的基准,这应该能提高情感预测的准确性。

基于词汇表的方法的缺点

尽管基于词汇表的方法很简单,但我们观察到它有一些明显的缺点:

  • 首先,我们受限于词汇表的大小;如果一个词不在所选的词汇表中,那么我们无法在确定该评论的情感分数时使用这些信息。在理想情况下,我们希望使用一个涵盖语言中所有单词的词汇表,但这是不可行的。

  • 其次,我们假设所选的词汇表是一个金标准,并信任作者提供的情感分数/极性。这是一个问题,因为特定的词汇表可能不适合特定的用例。在前面的例子中,Bing Liu 词汇表是相关的,因为它捕捉到了在线语言的使用,并在其词汇表中包含了常见的拼写错误和俚语。但如果我们正在处理推文数据集,那么 VADER 词汇表将更适合,因为它支持流行缩写(例如,LOL)和表情符号。

  • 最后,词汇表的最大缺点之一是它忽略了否定词。由于词汇表只匹配单词而不是短语,这将导致包含“not bad”的句子获得负分,而实际上它更中性。

要改进我们的情感检测,我们必须探索使用监督式机器学习方法。

监督学习方法

使用监督学习方法是有益的,因为它允许我们对数据中的模式进行建模,并创建一个接近现实的预测函数。它还为我们提供了选择不同技术并确定提供最大准确性的技术的灵活性。有关监督式机器学习的更详细概述,请参阅第六章。

要使用这种方法,我们需要标记数据,这可能不容易得到。通常,需要两个或更多的人类注释者查看每个评论,并确定情感。如果注释者意见不一致,那么可能需要第三个注释者来打破僵局。通常会有五个注释者,其中三个人对意见达成一致以确认标签。这可能会很乏味和昂贵,但在处理实际业务问题时是首选的方法。

然而,在许多情况下,我们可以在不经过昂贵的标注过程的情况下测试监督学习方法。一个更简单的选择是检查数据中可能帮助我们自动注释的任何代理指标。让我们在亚马逊评论的案例中说明这一点。如果有人给了一个五星级的产品评分,那么我们可以假设他们喜欢他们使用的产品,并且这应该在他们的评论中反映出来。同样,如果有人为一个产品提供了一星评级,那么他们对此不满意,并且可能有一些负面的话要说。因此,我们可以将产品评分作为衡量特定评论是积极还是消极的代理措施。评级越高,特定评论就越积极。

准备数据以进行监督学习方法

因此,在将我们的数据集转换为监督学习问题的第一步中,我们将使用评级自动注释我们的评论。我们选择将所有评级为 4 和 5 的评论标注为积极,并根据之前提供的推理将评级为 1 和 2 的评论标注为消极。在数据准备过程中,我们还过滤掉了评级为 3 的评论,以提供积极和消极评论之间更清晰的分离。这一步骤可以根据您的用例进行定制。

df = pd.read_json('reviews.json', lines=True)

# Assigning a new [1,0] target class label based on the product rating
df['sentiment'] = 0
df.loc[df['overall'] > 3, 'sentiment'] = 1
df.loc[df['overall'] < 3, 'sentiment'] = 0

# Removing unnecessary columns to keep a simple DataFrame
df.drop(columns=[
    'reviewTime', 'unixReviewTime', 'overall', 'reviewerID', 'summary'],
        inplace=True)
df.sample(3)

Out:

 verifiedasintextsentiment
176400TrueB000C5BN72everything was as listed and is in use all appear to be in good working order1
65073TrueB00PK03IVIthis is not the product i received.0
254348TrueB004AIKVPCJust like the dealership part.1

正如您从呈现的评论选择中可以看出,我们创建了一个名为sentiment的新列,其中包含根据用户提供的评分值为 1 或 0 的值。现在我们可以将其视为一个监督学习问题,我们将使用text中的内容来预测情感:积极(1)或消极(0)。

蓝图:文本数据向量化和应用监督学习算法

在这个蓝图中,我们将通过首先清洗文本数据,然后进行向量化,最后应用支持向量机模型来构建一个监督学习的机器学习算法。

步骤 1:数据准备

为了预处理数据,我们将应用来自第四章的正则表达式蓝图,以删除任何特殊字符、HTML 标签和 URL:

df['text_orig'] = df['text'].copy()
df['text'] = df['text'].apply(clean)

然后,我们将应用来自同一章节的数据准备蓝图,该蓝图使用了 spaCy 流水线。这确保文本被标准化为小写形式,不包括数字和标点,并且格式化为后续步骤可以使用的格式。请注意,执行此步骤可能需要几分钟的时间。在某些情况下,可能在清理步骤中删除了评论中的所有标记,这种情况下不再有必要包括这样的评论:

df["text"] = df["text"].apply(clean_text)

# Remove observations that are empty after the cleaning step
df = df[df['text'].str.len() != 0]

步骤 2:训练-测试分割

我们将数据分割,使得接下来的向量化步骤仅使用训练数据集。我们按照 80-20 的比例划分数据,并通过指定目标变量情感为stratify参数来确认正负类在两个划分中显示出类似的分布:

from sklearn.model_selection import train_test_split
X_train, X_test, Y_train, Y_test = train_test_split(df['text'],
                                                    df['sentiment'],
                                                    test_size=0.2,
                                                    random_state=42,
                                                    stratify=df['sentiment'])

print ('Size of Training Data ', X_train.shape[0])
print ('Size of Test Data ', X_test.shape[0])

print ('Distribution of classes in Training Data :')
print ('Positive Sentiment ', str(sum(Y_train == 1)/ len(Y_train) * 100.0))
print ('Negative Sentiment ', str(sum(Y_train == 0)/ len(Y_train) * 100.0))

print ('Distribution of classes in Testing Data :')
print ('Positive Sentiment ', str(sum(Y_test == 1)/ len(Y_test) * 100.0))
print ('Negative Sentiment ', str(sum(Y_test == 0)/ len(Y_test) * 100.0))

Out:

Size of Training Data  234108
Size of Test Data  58527
Distribution of classes in Training Data :
Positive Sentiment  50.90770071932612
Negative Sentiment  49.09229928067388
Distribution of classes in Testing Data :
Positive Sentiment  50.9081278726058
Negative Sentiment  49.09187212739419

步骤 3:文本向量化

下一步是将清理后的文本转换为可用特征的步骤。机器学习模型无法理解文本数据,只能处理数值数据。我们重新使用了 TF-IDF 向量化的蓝图来创建向量化表示。我们选择了min_df参数为 10,并且不包括二元组。此外,我们在前一步已经移除了停用词,因此在向量化过程中无需再处理此问题。我们将使用相同的向量化器来转换测试集,该测试集将在评估过程中使用:

from sklearn.feature_extraction.text import TfidfVectorizer

tfidf = TfidfVectorizer(min_df = 10, ngram_range=(1,1))
X_train_tf = tfidf.fit_transform(X_train)
X_test_tf = tfidf.transform(X_test)

步骤 4:训练机器学习模型

如第六章所述,当处理文本数据时,支持向量机是首选的机器学习算法。SVM 在处理具有大量数值特征的数据集时表现良好,特别是我们使用的 LinearSVC 模块非常快速。我们还可以选择基于树的方法,如随机森林或 XGBoost,但根据我们的经验,准确性相当,并且由于训练时间快,可以更快地进行实验:

from sklearn.svm import LinearSVC

model1 = LinearSVC(random_state=42, tol=1e-5)
model1.fit(X_train_tf, Y_train)

Out:

LinearSVC(C=1.0, class_weight=None, dual=True, fit_intercept=True,
          intercept_scaling=1, loss='squared_hinge', max_iter=1000,
          multi_class='ovr', penalty='l2', random_state=42, tol=1e-05,
          verbose=0)

然后:

from sklearn.metrics import accuracy_score
from sklearn.metrics import roc_auc_score

Y_pred = model1.predict(X_test_tf)
print ('Accuracy Score - ', accuracy_score(Y_test, Y_pred))
print ('ROC-AUC Score - ', roc_auc_score(Y_test, Y_pred))

Out:

Accuracy Score -  0.8658396979172006
ROC-AUC Score -  0.8660667427476778

正如我们所看到的,该模型的准确率约为 86%。让我们来看一些模型的预测结果和评论文本,以对模型进行一次审查:

sample_reviews = df.sample(5)
sample_reviews_tf = tfidf.transform(sample_reviews['text'])
sentiment_predictions = model1.predict(sample_reviews_tf)
sentiment_predictions = pd.DataFrame(data = sentiment_predictions,
                                     index=sample_reviews.index,
                                     columns=['sentiment_prediction'])
sample_reviews = pd.concat([sample_reviews, sentiment_predictions], axis=1)
print ('Some sample reviews with their sentiment - ')
sample_reviews[['text_orig','sentiment_prediction']]

Out:

Some sample reviews with their sentiment -

 text_origsentiment_prediction
29500这是一个不错的夜灯,但显然用途不多!1
98387太小了,不知道该怎么做或如何使用它们0
113648没有使房间“足够蓝” - 无条件退回0
281527卓越1
233713与 OEM 相匹配,看起来不错1

我们可以看到,该模型能够合理地预测评论。例如,用户在评论 98387 中认为产品太小不好用,被标记为负面。再看评论 233713,用户表示产品穿着合适且外观不错,被标记为正面。该模型与使用 Bing Liu 词汇表的基准模型相比如何?

def baseline_scorer(text):
    score = bing_liu_score(text)
    if score > 0:
        return 1
    else:
        return 0

Y_pred_baseline = X_test.apply(baseline_scorer)
acc_score = accuracy_score(Y_pred_baseline, Y_test)
print (acc_score)

输出:

0.7521998393903668

它确实提升了 75%的基准模型准确率,虽然准确率还可以进一步提高,但这是一个能够快速产生结果的简单蓝图。例如,如果你想要了解客户对你的品牌与竞争对手的感知,那么使用这个蓝图并聚合每个品牌的情感将会给你一个公平的理解。或者,假设你想要创建一个帮助人们决定是否观看电影的应用程序。使用这个蓝图分析从 Twitter 或 YouTube 评论中收集的数据,你可以确定人们的情感倾向,然后提供建议。在下一个蓝图中,我们将描述一种更复杂的技术,可以用来提高准确性。

使用深度学习的预训练语言模型

语言在几个世纪以来不断演变,并且仍在不断变化中。虽然有语法规则和形成句子的指导方针,但这些规则通常不严格遵循,且严重依赖于上下文。一个人在发推文时选择的词语与写电子邮件表达相同思想时选择的词语会有很大不同。而且在许多语言(包括英语)中,例外情况实在太多!因此,计算机程序要理解基于文本的交流是很困难的。通过使算法深入理解语言,使用语言模型可以克服这一难题。

语言模型是自然语言的数学表示,允许我们理解句子的结构和其中的词语。有几种类型的语言模型,但在本蓝图中我们将专注于预训练语言模型的使用。这些语言模型的最重要特征是它们利用深度神经网络架构,并在大型数据语料库上进行训练。语言模型的使用极大地提高了自然语言处理任务的性能,如语言翻译、自动拼写校正和文本摘要。

深度学习和迁移学习

深度学习通常用来描述一组利用人工神经网络(ANNs)的机器学习方法。人工神经网络受人类大脑启发,试图模仿生物系统中神经元之间的连接和信息处理活动。简单来说,它试图使用一个由多层节点组成的互连网络来建模函数,网络边的权重通过数据学习。有关更详细的解释,请参考Hands-On Machine Learning(O’Reilly,2019)的第 II 部分,作者是 Aurélien Géron。

转移学习是深度学习中的一项技术,允许我们通过将模型转移到特定用例来受益于预训练的广泛可用语言模型。它使我们能够利用在一个任务中获得的知识和信息,并将其应用到另一个问题上。作为人类,我们擅长这样做。例如,我们最初学习弹吉他,但随后可以相对容易地应用这些知识来更快地学会大提琴或竖琴(比完全初学者快)。当相同的概念应用于机器学习算法时,就被称为转移学习

这个想法首次在计算机视觉行业中流行起来,一个大规模的图像识别挑战促使几个研究小组竞相建立复杂的深度神经网络,网络层数深达数层,以降低挑战中的错误。其他研究人员发现,这些复杂模型不仅对该挑战有效,还可以通过微小调整适用于其他图像识别任务。这些大型模型已经学习了关于图像的基本特征(如边缘、形状等),可以在不需要从头开始训练的情况下,针对特定应用进行微调。在过去两年中,同样的技术已成功应用于文本分析。首先,在大型文本语料库(通常来自公开可用的数据源,如维基百科)上训练一个深度神经网络。所选择的模型架构是 LSTM 或 Transformer 的变体。^(4) 在训练这些模型时,会在句子中去掉一个词(掩码),预测任务是确定给定句子中所有其他词的情况下的掩码词。回到我们的人类类比,也许有更多的 YouTube 视频教你如何弹吉他而不是竖琴或大提琴。因此,首先学习弹吉他将是有益的,因为有大量的资源可用,然后将这些知识应用到不同的任务,如学习竖琴或大提琴。

大型模型训练时间长,耗时较多。幸运的是,许多研究团队已经公开了这些预训练模型,包括来自 fastai 的ULMFiT,来自 Google 的BERT,来自 OpenAI 的GPT-2,以及来自 Microsoft 的Turing。图 11-1 展示了应用迁移学习的最后一步,即保持预训练模型的初始层不变,重新训练模型的最终层以更好地适应手头的任务。通过这种方式,我们可以将预训练模型应用于文本分类和情感分析等特定任务。

图 11-1. 迁移学习。网络中较早层的参数通过对大语料进行训练而学习,而最终层的参数则被解冻,并允许在特定数据集上进行微调训练。

对于我们的蓝图,我们将使用 Google 发布的预训练模型 BERT。BERT 是双向编码器表示转换的缩写。它使用 Transformers 架构,并使用大量文本数据训练模型。在本蓝图中使用的模型(bert-base-uncased)是在结合了英文维基百科和 Books 语料库的基础上,使用掩蔽语言模型(MLM)进行训练的。BERT 模型的其他版本可以基于不同语料库进行训练。例如,有一个 BERT 模型是在德语维基百科文章上训练的。掩蔽语言模型随机掩盖输入中的一些标记(单词),其目标是仅基于上下文(周围单词)预测掩蔽词的原始词汇 ID。由于是双向的,模型从两个方向查看每个句子,能够更好地理解上下文。此外,BERT 还使用子词作为标记,这在识别单词含义时提供了更精细的控制。另一个优点是 BERT 生成上下文感知的嵌入。例如,在一个句子中使用单词cell时,根据周围单词,它可以具有生物参考或实际上指的是监狱单元的含义。要更详细地了解 BERT 的工作原理,请参阅“进一步阅读”。

蓝图:使用迁移学习技术和预训练语言模型

这份蓝图将向您展示如何利用预训练语言模型进行情感分类。考虑这样一个使用案例,您希望根据表达的情感采取行动。例如,如果一个客户特别不满意,您希望将他们转接到最优秀的客户服务代表那里。能够准确检测情感非常重要,否则您可能会失去他们。或者,假设您是一个依赖公共网站如Yelp上的评价和评级的小企业。为了提高评分,您希望通过向不满意的客户提供优惠券或特别服务来跟进。准确性对于定位正确的客户非常重要。在这些使用案例中,我们可能没有大量数据来训练模型,但高准确度是至关重要的。我们知道情感受到词语使用上下文的影响,而使用预训练语言模型可以改善我们的情感预测。这使我们能够超越我们拥有的有限数据集,融入来自一般使用的知识。

在我们的蓝图中,我们将使用 Transformers 库,因为它具有易于使用的功能和对多个预训练模型的广泛支持。"选择 Transformers 库"提供了关于这个主题的更多详细信息。Transformers 库不断更新,多位研究人员在其中贡献。

第一步:加载模型和标记化

使用 Transformers 库的第一步是导入所选模型所需的三个类。这包括config类,用于存储重要的模型参数;tokenizer,用于标记化和准备文本进行模型训练;以及model类,定义模型架构和权重。这些类特定于模型架构,如果我们想要使用不同的架构,那么需要导入相应的类。我们从预训练模型中实例化这些类,并选择最小的 BERT 模型,bert-base-uncased,它有 12 层深,并包含 1.1 亿个参数!

使用 Transformers 库的优势在于,它已经为许多模型架构提供了多个预训练模型,您可以在这里查看。当我们从预训练模型实例化一个模型类时,模型架构和权重将从由 Hugging Face 托管的 AWS S3 存储桶中下载。这可能会花费一些时间,但在您的机器上缓存后,就不需要再次下载。请注意,由于我们使用预训练模型来预测情感(积极与消极),我们指定finetuning_task='binary'。在运行此蓝图之前,我们在附带的笔记本中提供了额外的安装 Python 包的说明。

from transformers import BertConfig, BertTokenizer, BertForSequenceClassification

config = BertConfig.from_pretrained('bert-base-uncased',finetuning_task='binary')
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertForSequenceClassification.from_pretrained('bert-base-uncased')

我们必须将输入文本数据转换为模型架构所需的标准格式。我们定义一个简单的get_tokens方法,将我们评论的原始文本转换为数值。预训练模型将每个观察作为固定长度序列接受。因此,如果一个观察比最大序列长度短,则用空(零)标记进行填充,如果它更长,则进行截断。每个模型架构都有一个它支持的最大序列长度。分词器类提供了一个分词函数,它将句子分割成标记,填充句子以创建固定长度序列,并最终表示为可在模型训练期间使用的数值。此函数还添加了注意力掩码,以区分那些包含实际单词的位置和包含填充字符的位置。以下是这个过程如何工作的示例:

def get_tokens(text, tokenizer, max_seq_length, add_special_tokens=True):
  input_ids = tokenizer.encode(text,
                               add_special_tokens=add_special_tokens,
                               max_length=max_seq_length,
                               pad_to_max_length=True)
  attention_mask = [int(id > 0) for id in input_ids]
  assert len(input_ids) == max_seq_length
  assert len(attention_mask) == max_seq_length
  return (input_ids, attention_mask)

text = "Here is the sentence I want embeddings for."
input_ids, attention_mask = get_tokens(text,
                                       tokenizer,
                                       max_seq_length=30,
                                       add_special_tokens = True)
input_tokens = tokenizer.convert_ids_to_tokens(input_ids)
print (text)
print (input_tokens)
print (input_ids)
print (attention_mask)

Out:

Here is the sentence I want embeddings for.
['[CLS]', 'here', 'is', 'the', 'sentence', 'i', 'want', 'em', '##bed',
'##ding', '##s', 'for', '.', '[SEP]', '[PAD]', '[PAD]', '[PAD]', '[PAD]',
'[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]', '[PAD]',
'[PAD]', '[PAD]', '[PAD]', '[PAD]']
[101, 2182, 2003, 1996, 6251, 1045, 2215, 7861, 8270, 4667, 2015, 2005, 1012,
102, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0]

我们观察到的第一个标记是[CLS]标记,它代表分类,这是 BERT 模型的预训练任务之一。此标记用于标识句子的开始,并在模型内存储整个句子的聚合表示。我们还在句子末尾看到了[SEP]标记,它代表分隔符。当 BERT 用于非分类任务(如语言翻译)时,每个观察将包括一对文本(例如,英文文本和法文文本),而[SEP]标记用于将第一个文本与第二个文本分隔开。然而,由于我们正在构建一个分类模型,分隔符标记后面跟随着[PAD]标记。我们指定了序列长度为 30,由于我们的测试观察并不那么长,在末尾添加了多个填充标记。另一个有趣的观察是,像embedding这样的词不是一个标记,而实际上被分割成em##bed##ding##s##用于识别子词标记,这是 BERT 模型的一个特殊特性。这使得模型能够更好地区分词根、前缀和后缀,并尝试推断它以前可能没有见过的单词的含义。

一个重要的注意点是,由于深度学习模型使用基于上下文的方法,建议使用原始形式的文本而不进行任何预处理,这样允许分词器从其词汇表中生成所有可能的标记。因此,我们必须再次使用原始的text_orig列而不是清理过的text列来分割数据。然后,让我们将相同的函数应用于我们的训练和测试数据,这次使用max_seq_length为 50:

X_train, X_test, Y_train, Y_test = train_test_split(df['text_orig'],
                                                    df['sentiment'],
                                                    test_size=0.2,
                                                    random_state=42,
                                                    stratify=df['sentiment'])
X_train_tokens = X_train.apply(get_tokens, args=(tokenizer, 50))
X_test_tokens = X_test.apply(get_tokens, args=(tokenizer, 50))

深度学习模型使用像TensorFlowPyTorch这样的框架在 GPU 上进行训练。张量是这些框架用来表示和处理数据的基本数据结构,可以在 N 维中存储数据。用象征性的方式来可视化张量,我们可以将其类比为棋盘。假设我们用 0 标记未占用的位置,用 1 标记白子占用的位置,用 2 标记黑子占用的位置。我们得到一个 8×8 矩阵,表示特定时间点上棋盘的状态。如果我们现在想要跟踪并存储多个动作,我们将得到多个 8×8 矩阵,这些可以存储在我们所谓的tensor中。张量是数据的 n 维表示,包含一组坐标空间函数的分量数组。跟踪历史棋局动作的张量将是一个 3 阶张量,而初始的 8×8 矩阵也可以被认为是张量,但是是一个 2 阶张量。

这只是一个简单的解释,但是为了更深入地理解,我们建议阅读 Joseph C. Kolecki 的“An Introduction to Tensors for Students of Physics and Engineering”。在我们的案例中,我们创建了三个张量,包含标记(包含大小为 50 的多个数组的张量)、输入掩码(包含大小为 50 的数组的张量)和目标标签(包含大小为 1 的标量的张量):

import torch
from torch.utils.data import TensorDataset

input_ids_train = torch.tensor(
    [features[0] for features in X_train_tokens.values], dtype=torch.long)
input_mask_train = torch.tensor(
    [features[1] for features in X_train_tokens.values], dtype=torch.long)
label_ids_train = torch.tensor(Y_train.values, dtype=torch.long)

print (input_ids_train.shape)
print (input_mask_train.shape)
print (label_ids_train.shape)

Out:

torch.Size([234104, 50])
torch.Size([234104, 50])
torch.Size([234104])

我们可以窥探一下这个张量中的内容,并看到它包含了句子中每个标记对应的 BERT 词汇映射。数字 101 表示开始,102 表示结束评论句子。我们将这些张量组合成一个 TensorDataset,这是模型训练期间用来加载所有观察结果的基本数据结构。

input_ids_train[1]

Out:

tensor([ 101, 2009, 2134, 1005, 1056, 2147, 6314, 2055, 2009, 1037, 5808, 1997,
        2026, 2769,  102,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,    0,
           0,    0])

然后:

train_dataset = TensorDataset(input_ids_train,input_mask_train,label_ids_train)

步骤 2:模型训练

现在我们已经预处理和标记化了数据,我们准备训练模型。由于深度学习模型的大内存使用和计算需求,我们采用了与前一蓝图中使用的 SVM 模型不同的方法。所有训练观测数据被分成批次(由train_batch_size定义),并从所有观测数据中随机采样(使用RandomSampler),然后通过模型的各层向前传递。当模型通过所有批次看到了所有训练观测数据时,就说它已经训练了一个 epoch。因此,一个 epoch 是通过训练数据中的所有观测值的一次传递。batch_size的组合和 epoch 数确定了模型训练的时间长度。选择较大的batch_size减少了 epoch 中的前向传递次数,但可能会导致更高的内存消耗。选择更多的 epochs 给模型更多时间来学习参数的正确值,但也会导致更长的训练时间。对于这个蓝图,我们定义了batch_size为 64,num_train_epochs为 2:

from torch.utils.data import DataLoader, RandomSampler

train_batch_size = 64
num_train_epochs = 2

train_sampler = RandomSampler(train_dataset)
train_dataloader = DataLoader(train_dataset,
                              sampler=train_sampler,
                              batch_size=train_batch_size)
t_total = len(train_dataloader) // num_train_epochs

print ("Num examples = ", len(train_dataset))
print ("Num Epochs = ", num_train_epochs)
print ("Total train batch size  = ", train_batch_size)
print ("Total optimization steps = ", t_total)

输出:

Num examples =  234104
Num Epochs =  2
Total train batch size  =  64
Total optimization steps =  1829

当一个批次中的所有观测数据通过模型的各层向前传递后,反向传播算法将以反向方向应用。这种技术允许我们自动计算神经网络中每个参数的梯度,从而为我们提供了一种调整参数以减少误差的方法。这类似于随机梯度下降的工作原理,但我们不打算详细解释。《动手学习机器学习》(O’Reilly,2019)第四章提供了一个很好的介绍和数学解释。需要注意的关键点是,在训练深度学习算法时,影响反向传播的参数(如学习率和优化器的选择)决定了模型学习参数并达到更高准确度的速度。然而,并没有科学上的原因说明某种方法或值更好,但许多研究者^(5)试图确定最佳选择。根据 BERT 论文中的参数和 Transformers 库中的推荐,我们为蓝图做出了明智的选择,如下所示:

from transformers import AdamW, get_linear_schedule_with_warmup

learning_rate = 1e-4
adam_epsilon = 1e-8
warmup_steps = 0

optimizer = AdamW(model.parameters(), lr=learning_rate, eps=adam_epsilon)
scheduler = get_linear_schedule_with_warmup(optimizer,
                                            num_warmup_steps=warmup_steps,
                                            num_training_steps=t_total)

在设置训练循环之前,我们检查是否有可用的 GPU(见“在 Google Colab 免费使用 GPU”)。如果有,模型和输入数据将被传输到 GPU,然后我们通过模型运行输入来设置前向传递以产生输出。由于我们已经指定了标签,我们已经知道与实际情况的偏差(损失),并且我们使用反向传播来调整参数以计算梯度。优化器和调度器步骤用于确定参数调整的量。请注意特殊条件,即将梯度剪裁到最大值,以防止梯度爆炸问题的出现。

现在我们将所有这些步骤包装在嵌套的for循环中——一个用于每个时期,另一个用于每个时期中的每个批次——并使用之前介绍的 TQDM 库来跟踪训练进度,同时打印损失值:

from tqdm import trange, notebook

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
train_iterator = trange(num_train_epochs, desc="Epoch")

# Put model in 'train' mode
model.train()

for epoch in train_iterator:
    epoch_iterator = notebook.tqdm(train_dataloader, desc="Iteration")
    for step, batch in enumerate(epoch_iterator):

        # Reset all gradients at start of every iteration
        model.zero_grad()

        # Put the model and the input observations to GPU
        model.to(device)
        batch = tuple(t.to(device) for t in batch)

        # Identify the inputs to the model
        inputs = {'input_ids':      batch[0],
                  'attention_mask': batch[1],
                  'labels':         batch[2]}

        # Forward Pass through the model. Input -> Model -> Output
        outputs = model(**inputs)

        # Determine the deviation (loss)
        loss = outputs[0]
        print("\r%f" % loss, end='')

        # Back-propogate the loss (automatically calculates gradients)
        loss.backward()

        # Prevent exploding gradients by limiting gradients to 1.0
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)

        # Update the parameters and learning rate
        optimizer.step()
        scheduler.step()

到目前为止,我们已经对下载的 BERT 模型进行了参数微调,以适应对亚马逊客户评论的情感分析。如果模型正确学习参数值,您应该观察到损失值在多次迭代中减少。在训练步骤结束时,我们可以将模型和分词器保存到选择的输出文件夹中:

model.save_pretrained('outputs')

第三步:模型评估

在测试数据上评估我们的模型类似于训练步骤,只有细微差别。首先,我们必须评估整个测试数据集,因此不需要进行随机抽样;相反,我们使用SequentialSampler类加载观测值。然而,我们仍然受限于一次加载的观测数目,因此必须使用test_batch_size来确定这一点。其次,我们不需要进行反向传播或调整参数,只执行前向传播。模型为我们提供包含损失值和输出概率值的输出张量。我们使用np.argmax函数确定具有最大概率的输出标签,并通过与实际标签比较来计算准确率:

import numpy as np
from torch.utils.data import SequentialSampler

test_batch_size = 64
test_sampler = SequentialSampler(test_dataset)
test_dataloader = DataLoader(test_dataset,
                             sampler=test_sampler,
                             batch_size=test_batch_size)

# Load the pretrained model that was saved earlier
# model = model.from_pretrained('/outputs')

# Initialize the prediction and actual labels
preds = None
out_label_ids = None

# Put model in "eval" mode
model.eval()

for batch in notebook.tqdm(test_dataloader, desc="Evaluating"):

    # Put the model and the input observations to GPU
    model.to(device)
    batch = tuple(t.to(device) for t in batch)

    # Do not track any gradients since in 'eval' mode
    with torch.no_grad():
        inputs = {'input_ids':      batch[0],
                  'attention_mask': batch[1],
                  'labels':         batch[2]}

        # Forward pass through the model
        outputs = model(**inputs)

        # We get loss since we provided the labels
        tmp_eval_loss, logits = outputs[:2]

        # There maybe more than one batch of items in the test dataset
        if preds is None:
            preds = logits.detach().cpu().numpy()
            out_label_ids = inputs['labels'].detach().cpu().numpy()
        else:
            preds = np.append(preds, logits.detach().cpu().numpy(), axis=0)
            out_label_ids = np.append(out_label_ids,
                                      inputs['labels'].detach().cpu().numpy(),
                                      axis=0)

# Get final loss, predictions and accuracy
preds = np.argmax(preds, axis=1)
acc_score = accuracy_score(preds, out_label_ids)
print ('Accuracy Score on Test data ', acc_score)

输出:

Accuracy Score on Test data  0.9535086370393152

我们的测试数据结果显示模型准确率提高到 95%,比我们先前基于 TF-IDF 和 SVM 的基线提高了 10 个百分点。这些都是使用最先进的语言模型的好处,这很可能是 BERT 在大型语料库上训练的结果。评论内容相当简短,早期的模型只有这些数据来学习关系。另一方面,BERT 是上下文感知的,并且可以将其对评论中单词的先前信息传递出来。通过微调learning_rate等超参数或增加训练轮次,可以提高准确性。由于预训练语言模型的参数数量远远超过我们用于微调的观测数目,因此在此过程中必须小心避免过拟合!

使用保存的模型

如果您单独运行评估,则可以直接加载微调的模型,而无需再次进行训练。请注意,这与最初用于从 transformers 加载预训练模型的相同函数相同,但这次我们使用的是我们自己训练的微调模型。

如您所见,使用预训练语言模型可以提高模型的准确性,但也涉及许多额外步骤,并可能会带来成本,如使用 GPU(在 CPU 上训练一个有用的模型可能需要 50 到 100 倍的时间)。预训练模型非常庞大且不够内存高效。在生产中使用这些模型通常更加复杂,因为加载数百万参数到内存中需要时间,并且它们在实时场景中的推理时间较长。一些像DistilBERTALBERT这样的预训练模型已经专门开发,以在准确性和模型简单性之间取得更有利的权衡。您可以通过重复使用蓝图并更改适当的模型类来轻松尝试此功能,以选择 Transformers 库中提供的distil-bert-uncasedalbert-base-v1模型,以检查准确性。

总结语

在本章中,我们介绍了几种可用于情感分析的蓝图。它们从简单的基于词汇的方法到复杂的最新语言模型。如果您的用例是对特定主题使用 Twitter 数据进行一次性分析以确定情感,则第一个蓝图最合适。如果您希望根据客户评论中表达的情感创建产品/品牌排名或根据情感对客户投诉进行路由,则监督式机器学习方法(如第二和第三个蓝图中描述的方法)更加合适。如果准确性最重要,则使用预训练语言模型可以获得最佳结果,但这也是一种更复杂且昂贵的技术。每个蓝图都适合特定的用例,关键是确定哪种方法适合您的需求。总体而言,您必须找到一种适合您用例的方法,建议始终从简单开始,然后增加复杂性以获得更好的结果。

进一步阅读

^(1) J. McAuley 和 J. Leskovec。“隐藏因素和隐藏主题:理解评论文本中的评分维度。” RecSys,2013. https://snap.stanford.edu/data/web-Amazon.html.

^(2) “德国情感分析兴趣小组,德语多领域情感词典”,https://oreil.ly/WpMhF.

^(3) Yanqing Chen 和 Steven Skiena。为所有主要语言构建情感词典。词典可在Kaggle上获取。

^(4) Ashish Vaswani 等人。“关注就是一切:Attention Is All You Need。” 2017. https://arxiv.org/abs/1706.03762.

^(5) Robin M. Schmidt, Frank Schneider 和 Phillipp Hennig。“穿越拥挤山谷:深度学习优化器基准测试。” 2020. https://arxiv.org/pdf/2007.01547.pdf.