自然语言处理实战第二版(MEAP)(二)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第三章:用单词做数学运算(TF-IDF 向量)

本章包括

  • 计算单词、n-grams 和词频以分析含义

  • 使用Zipf 定律预测单词出现概率

  • 将自然语言文本表示为向量

  • 使用文档频率在文本集合中找到相关文档

  • 使用余弦相似度估计文档对的相似性

收集并计数了单词(标记),并将它们分桶为词干或词元后,就可以对它们做一些有趣的事情了。检测单词对于简单任务非常有用,比如获取关于单词使用情况的统计信息或进行关键词搜索。但是你想知道哪些单词对特定文档和整个语料库更重要。因此,你可以使用该“重要性”值来根据每个文档内关键字的重要性来查找语料库中的相关文档。这样一来,垃圾邮件检测器就不太可能因为单个脏话或少量带有些许垃圾邮件特征的词而被触发。你想要测量一条 Mastodon 消息有多积极和亲社会,尤其是当你有各种单词,这些单词具有不同程度的“积极性”分数或标签时。如果你对这些单词相对于其他文档出现的频率有一个想法,那么你可以用它来进一步确定文档的“积极性”。在本章中,你将了解有关单词及其在文档中使用的更为细致、不那么二元的度量方法。这种方法是商业搜索引擎和垃圾邮件过滤器从自然语言生成特征的主要方法已经有几十年了。

你冒险的下一步是将第二章的文字转化为连续数字,而不仅仅是表示单词计数或二进制“位向量”的整数。通过在连续空间中表示单词,你可以用更加有趣的数学来操作它们的表达。你的目标是找到单词的数值表示,以某种方式捕捉它们所代表的重要性或信息内容。你得等到第四章才能看到如何将这些信息内容转化为代表单词含义的数字。

在本章中,我们将研究三种越来越强大的方式来表示单词及其在文档中的重要性:

  • 词袋 — 单词计数或频率的向量

  • n-gram 包 — 单词对(bigrams)、三元组(trigrams)等的计数

  • TF-IDF 向量 — 更好地表示单词的重要性的单词分数

重要

TF-IDF 代表**词频乘以逆文档频率。词频是文档中每个单词的计数,这是你在前几章学到的。逆文档频率意味着你将每个单词的计数除以该单词出现的文档数。

这些技术可以分别应用,也可以作为 NLP 流水线的一部分应用。这些都是统计模型,因为它们基于频率。在本书的后面,您将看到各种方法,更深入地了解单词之间的关系、模式和非线性。

但是这些“浅层”自然语言处理(NLP)机器是强大且实用的,用于许多实际应用,如搜索、垃圾邮件过滤、情感分析,甚至聊天机器人。

3.1 词袋模型

在上一章中,您创建了文本的第一个向量空间模型。您对每个单词进行了独热编码,然后将所有这些向量与二进制 OR(或裁剪的 sum)结合起来,以创建文本的向量表示。这个二进制词袋向量在加载到诸如 Pandas DataFrame 等数据结构时,可以用作文档检索的优秀索引。

接着你看了一个更有用的向量表示,它计算了给定文本中每个单词出现的次数或频率。作为第一个近似,你假设一个单词出现的次数越多,它在文档中的贡献就越大。一个频繁提到“机翼”和“方向舵”的文档,可能与涉及喷气飞机或航空旅行的问题更相关,而不是频繁提到“猫”和“重力”的文档。或者,如果你已经将一些词分类为表达积极情感的词——像“好”、“最好”、“喜悦”和“棒极了”——那么包含这些词的文档越多,它就越有可能具有积极的“情感”。不过,你可以想象到一个依赖这些简单规则的算法可能会犯错或走上错误的道路。

让我们看一个统计单词出现次数有用的例子:

>>> import spacy
>>> # spacy.cli.download("en_core_web_sm") # #1
>>> nlp = spacy.load("en_core_web_sm")
>>> sentence = ('It has also arisen in criminal justice, healthcare, and '
...     'hiring, compounding existing racial, economic, and gender biases.')
>>> doc = nlp(sentence)
>>> tokens = [token.text for token in doc]
>>> tokens
['It', 'has', 'also', 'arisen', 'in', 'criminal', 'justice', ',',
'healthcare', ',', 'and', 'hiring', ',', 'compounding', 'existing',
'racial', ',', 'economic', ',', 'and', 'gender', 'biases', '.']

您只需要下载 SpaCy 语言模型一次,它可能会消耗大量的互联网带宽。所以只有在您的 Python 虚拟环境中首次运行此代码时才运行 cli.download()。SpaCy 语言模型将自然语言文本进行标记化,并返回一个包含输入文本中所有标记序列的文档对象(Doc 类)。它还会将文档分段,以便您在 .sents 属性中获得一个句子序列。借助 Python 的 set() 类型,您可以将这个标记序列转换为文本中所有唯一单词的集合。

文档或语料库中所有唯一单词的列表称为其词汇词典。创建您的词汇是您的 NLP 流水线中最重要的步骤。如果您不识别特定的标记并为其分配一个位置来存储它,您的流水线将完全忽略它。在大多数 NLP 流水线中,您将定义一个名为<OOV>(超出词汇)的单个标记,您将在其中存储有关您的流水线正在忽略的所有标记的信息,例如它们的出现次数。因此,您没有包含在词汇中的所有不寻常或虚构的“超级长的”单词将被合并到一个单一的通用标记中,而您的 NLP 流水线将无法计算其含义。

Python 的Counter类是一种高效计算任何东西(包括标记)在序列或数组中出现次数的方法。在第二章中,您了解到Counter是一种特殊类型的字典,其中键是数组中的所有唯一对象,而字典的值是每个对象的计数。

>>> from collections import Counter
>>> bag_of_words = Counter(tokens)
>>> bag_of_words
Counter({',': 5, 'and': 2, 'It': 1, 'has': 1, 'also': 1, 'arisen': 1, ...})

collections.Counter 对象实际上是一个 dict。这意味着键技术上存储在无序集合或set中,有时也称为“bag”。它可能看起来像这个字典已经保持了您的句子中单词的顺序,但这只是一种错觉。您有幸因为您的句子中没有包含许多重复的标记。而 Python 的最新版本(3.6 及以上)基于您在字典中插入新键的时间来维护键的顺序。但是您即将从这些标记和它们的计数的字典中创建向量。您需要向量来对一系列文档(在这种情况下是句子)进行线性代数和机器学习。您的词袋向量将使用一致的索引号来跟踪每个唯一标记在向量中的位置。这样,诸如“and”或逗号之类的标记的计数将在您的文档的所有向量中累加——维基百科文章标题为“算法偏见”的句子中。

重要提示

对于 NLP,字典中键的顺序并不重要,因为您将在向量中保持一致的排序,例如 Pandas Series。正如在第二章中一样,Counter 字典根据您处理语料库的每个文档的时间顺序对您的词汇(dict键)进行排序。有时您可能希望对您的词汇进行按字母顺序排列以便于分析。一旦您为计数向量的每个标记分配了一个维度,务必记录下该顺序以备将来使用,这样您就可以在不重新处理所有文档的情况下重新使用您的流水线而无需重新训练它。如果您试图复制他人的 NLP 流水线,您将想要重用其确切的词汇表(标记列表),并按照完全相同的顺序重复使用。否则,您将需要按照与他们相同的顺序,使用完全相同的软件来处理他们的训练数据集。

对于像这样的短文档,打乱的词袋仍然包含了关于句子原始意图的大量信息。词袋中的信息足以执行一些强大的任务,比如检测垃圾邮件、计算情感(积极性或其他情绪),甚至检测微妙的意图,比如讽刺。它可能是一个袋子,但它充满了意义和信息。为了更容易地思考这些词,并确保你的管道是一致的,你想要以某种一致的顺序对它们进行排序。要按计数对标记进行排名,Counter 对象有一个方便的方法,即most_common

>>> bag_of_words.most_common(3)  # #1
[(',', 5), ('and', 2), ('It', 1)]

这很方便!Counter.most_common方法将给出一个排名列表,其中包含你想要的任意数量的标记,并与其计数配对为 2 元组。但这还不是你想要的。你需要一个向量表示来轻松地对你的标记计数进行数学运算。

Pandas Series是一种高效的数据结构,用于存储标记计数,包括来自most_common方法的 2 元组。Pandas Series的好处是,每当你使用像加号(+)或(*)甚至.dot()这样的数学运算符时,它都表现得像一个向量(numpy 数组)。而且你仍然可以使用正常的方括号(['token'])语法访问与每个标记关联的每个命名(标记)维度。

提示

Python 表达式x[y]x.*getitem*(y)是相同的。方括号([])是字典、列表、Pandas Series 和许多其他容器对象上隐藏的.*getitem*()方法的语法糖(简写)。如果你想在自己的容器类上使用这个运算符,你只需要定义一个.*getitem*(index_value)方法,从你的容器中检索适当的元素即可。

你可以使用内置的dict类型构造函数将任意的 2 元组列表转换为字典。而且你可以使用Series构造函数将任意字典转换为 Pandas Series

>>> import pandas as pd
>>> most_common = dict(bag_of_words.most_common())  # #1
>>> counts = pd.Series(most_common)  # #2
>>> counts
,              5
and            2
It             1
has            1
also           1
...

当你将 Pandas Series 打印到屏幕上时,它会显示得很好,这在你试图理解一个标记计数向量包含的内容时可能会很方便。现在你已经创建了一个计数向量,你可以像对待任何其他 Pandas Series 一样对待它。

>>> len(counts)  # #1
18
>>> counts.sum()
23
>>> len(tokens)  # #2
23
>>> counts / counts.sum()  # #3
,              0.217391
and            0.086957
It             0.043478
has            0.043478
also           0.043478
...

你可以看到这个句子中有 23 个标记,但是你的词汇表中只有 18 个唯一的标记。因此,即使其他文档没有使用这些相同的 18 个词,你的每个文档向量也需要至少有 18 个值。这样可以让每个标记在你的计数向量中拥有自己的维度(槽)。每个标记在你的向量中被分配一个与其在词汇表中位置相对应的“槽”。向量中的某些标记计数将为零,这正是你想要的。

逗号(“,”)标记和单词"and"位于你的most_common词项列表的顶部是有道理的。逗号使用了五次,单词"and"使用了两次,而在这个句子中所有其他单词只使用了一次。在这个特定句子中,你的前两个词项或标记是",“和"and”。这是自然语言文本的一个相当普遍的问题——最常见的词往往是最无意义的。这些停用词并不能告诉你关于这个文档意义的很多,所以你可能会完全忽略它们。一个更好的方法是使用你的文档中词的统计数据来衡量你的词项计数,而不是别人从他们的文档中列出的停用词的任意列表。

一个词在给定文档中出现的次数被称为词频,通常缩写为"TF"。你可能想要做的第一件事情之一就是通过文档中的词项数进行归一化(除以)。这将为你提供文档中包含词项的相对频率(百分比或分数),而不考虑文档的长度。查看一下单词"justice"的相对频率,看看这种方法是否能恰当地体现这个词在这段文本中的重要性。

>>> counts['justice']
1
>>> counts['justice'] / counts.sum()
0.043...

在这篇维基百科文章开头句子中,单词"justice"的标准化词频约为 4%。而且你可能不会期望随着你处理文章中更多的句子,这个百分比会上升。如果句子和文章都在谈论"justice"大致相同的数量,那么这个标准化词频分数在整个文档中将保持大致不变。

根据这个词频,单词"justice"在这个句子中代表了约 4%的意义。考虑到这个单词对句子的意义有多重要,这并不多。所以你需要再做一步归一化,以使这个词相对于句子中的其他词得到提升。

要给单词"justice"一个重要性或重要性评分,你需要一些关于它的统计数据,不仅仅是这一个句子。你需要找出"justice"在其他地方的使用情况。幸运的是对于初学的 NLP 工程师来说,维基百科充满了许多语言的高质量准确的自然语言文本。你可以使用这些文本来"教"你的机器关于单词"justice"在许多文档中的重要性。为了展示这种方法的威力,你只需要从维基百科上的算法偏见文章中选取几段。这里有一些来自维基百科文章"Algorithmic Bias"的句子。

算法偏见描述了计算机系统中的系统性和可重复的错误,这些错误会导致不公平的结果,例如偏袒某个任意的用户群体而不是其他人。偏见可能由许多因素引起,包括但不限于算法的设计或数据编码、收集、选择或用于训练算法的方式的意外或未预料到的使用或决策。

…​

算法偏见已在各种情况下被引用,从选举结果到网络仇恨言论的传播。它还出现在刑事司法、医疗保健和招聘中,加剧了现有的种族、经济和性别偏见。

…​

由于算法的专有性质,即通常被视为商业机密,导致了对理解、研究和发现算法偏见的问题仍然存在。

— 维基百科

算法偏见 (en.wikipedia.org/wiki/Algorithmic_bias)

看看这些句子,看看是否能找到对您理解文本至关重要的关键字。您的算法需要确保包含这些词,并计算有关它们的统计数据。如果您尝试使用 Python 自动(程序化地)检测这些重要单词,您将如何计算重要性得分?看看您是否能想出如何使用 Counter 字典来帮助您的算法理解算法偏见的某些方面。

>>> sentence = "Algorithmic bias has been cited in cases ranging from " \
...     "election outcomes to the spread of online hate speech."
>>> tokens = [tok.text for tok in nlp(sentence)]
>>> counts = Counter(tokens)
>>> dict(counts)
{'Algorithmic': 1, 'bias': 1, 'has': 1, 'been': 1, 'cited': 1,
'in': 1, 'cases': 1, 'ranging': 1, 'from': 1, 'election': 1,
'outcomes': 1, 'to': 1, 'the': 1, 'spread': 1, 'of': 1,
'online': 1, 'hate': 1, 'speech': 1, '.': 1})

看起来这句话根本没有重复使用任何词。频率分析和词频向量的关键在于单词使用的统计数据相对于其他单词。因此,我们需要输入其他句子,并创建基于单词如何在其他地方使用的归一化的有用单词计数。要理解“算法偏见”,您可以花时间阅读并将维基百科文章的所有内容输入到 Python 字符串中。或者,您可以使用 nlpia2_wikipedia Python 包从维基百科下载文本,然后使用自然语言处理找到您需要复习的关键概念。要直接从维基百科检索最新的“算法偏见”文本,请使用 nlpia2.wikipedia 而不是官方(但已废弃)的维基百科包。

>>> from nlpia2 import wikipedia as wiki
>>> page = wiki.page('Algorithmic Bias')  # #1
>>> page.content[:70]
'Algorithmic bias describes systematic and repeatable errors in a compu'

您还可以从 GitLab 上的 nlpia2 包中下载包含维基百科文章的前 3 段的文本文件。如果您已经克隆了 nlpia2 包,您将在本地硬盘上看到 src/nlpia2/ch03/bias_intro.txt。如果您尚未从源代码安装 nlpia2,您可以使用此处的代码片段使用 requests 包检索文件。requests 包是一个方便的工具,用于从网络上抓取和下载自然语言文本。ChatGPT、Bard 和 YouChat 显得如此聪明的所有文本都是使用类似 requests 的工具从网页上抓取的。

>>> import requests
>>> url = ('https://gitlab.com/tangibleai/nlpia2/'
...        '-/raw/main/src/nlpia2/ch03/bias_intro.txt')
>>> response = requests.get(url)
>>> response
<Response [200]>

requests 包返回一个包含 HTTP 响应的对象,其中包含报头(在.headers中)和正文(.text)的内容。nlpia2 包数据中的 bias_intro.txt 文件是维基百科文章的前三段的 2023 快照。

>>> bias_intro_bytes = response.content  # #1
>>> bias_intro = response.text  # #2
>>> assert bias_intro_bytes.decode() == bias_intro    # #3
>>> bias_intro[:70]
'Algorithmic bias describes systematic and repeatable errors in a compu'

对于纯文本文档,您可以使用response.content属性,该属性包含原始 HTML 页面的bytes。如果要获取一个字符串,可以使用response.text属性将文本字节自动解码为 unicode str

来自collections模块的 Python 标准库中的Counter类非常适合高效计数任何对象的序列。这对于 NLP 来说非常完美,当您希望计算一组令牌中唯一单词和标点的出现次数时:

>>> tokens = [tok.text for tok in nlp(bias_intro)]
>>> counts = Counter(tokens)
>>> counts
Counter({'Algorithmic': 3, 'bias': 6, 'describes': 1, 'systematic': 2, ...
>>> counts.most_common(5)
[(',', 35), ('of', 16), ('.', 16), ('to', 15), ('and', 14)]

好吧,这些计数的统计意义更大一些。但是仍然有许多无意义的词汇和标点符号,它们的计数似乎很高。这篇维基百科文章可能并不真的是关于“of”、“to”、“commas”或“periods”等标记。也许关注最不常见的标记会比关注最常见的标记更有用。

>>> counts.most_common()[-4:]
('inputs', 1), ('between', 1), ('same', 1), ('service', 1)]

这个方法不那么成功。您可能希望找到类似“偏见”、“算法”和“数据”之类的术语。因此,您需要使用一个平衡计数的公式,以得到“刚刚好”的术语的“Goldilocks”得分。你可以这样做,通过得到另一个有用的计数——一个单词出现在多少篇文章中的计数,称为“文档频率”。这就是事情变得真正有趣的时候。

如果您有一个大型语料库,您可以基于一个令牌在所有文档中使用的频率来归一化(除以)文档中的计数。由于您刚开始使用令牌计数向量,最好将维基百科文章摘要拆分成更小的文档(句子或段落),以创建一些小文档。这样,您至少可以在一页上看到所有文档,并通过脑海中运行代码来确定所有计数的来源。在接下来的部分中,您将拆分“Algorithm Bias”文章文本为句子,并尝试不同的归一化和结构化计数字典的方法,以使它们在自然语言处理中更有用。

3.1.1 文本向量化

Counter 字典非常适合计数文本中的标记。但是向量才是真正需要的东西。原来字典可以通过在字典列表上调用 DataFrame 构造函数来强制转换为 DataFrameSeries。Pandas 将负责所有簿记工作,以便每个唯一的令牌或字典键都有自己的列。当文档的 Counter 字典缺少特定的键时,Pandas 会创建 NaN 值,因为该文档不包含该单词或符号。

一旦您将“算法偏差”文章拆分为行,您将看到向量表示的威力。您很快就会明白为什么 Pandas Series 与标准 Python dict.相比,对于处理标记计数来说是一个更有用的数据结构。

>>> docs = [nlp(s) for s in bias_intro.split('\n')
...         if s.strip()]  # #1
>>> counts = []
>>> for doc in docs:
...     counts.append(Counter([
...         t.text.lower() for t in doc]))  # #2
>>> df = pd.DataFrame(counts)
>>> df = df.fillna(0).astype(int)  # #3
>>> len(df)
16
>>> df.head()
  algorithmic bias describes  systematic  ... between  same service
0           1    1         1           1  ...       0     0       0
1           0    1         0           0  ...       0     0       0
2           1    1         0           0  ...       0     0       0
3           1    1         0           1  ...       0     0       0
4           0    1         0           0  ...       0     0       0

当您的向量维度用于保存标记或字符串的分数时,这就是您想要使用 Pandas DataFrameSeries来存储您的向量的时候。这样,您就可以看到每个维度的用途。检查一下我们在本章开头提到的那个句子。它碰巧是维基百科文章中的第 11 个句子。

>>> df.iloc[10]  # #1
algorithmic    0
bias           0
describes      0
systematic     0
and            2
...
Name: 10, Length: 246, dtype: int64

现在这个 Pandas Series是一个向量。这是您可以进行数学计算的东西。当您进行数学计算时,Pandas 将跟踪哪个单词在哪里,以便“偏见”和“正义”不会被意外加在一起。这个 DataFrame 中的行向量每个词汇中的一个“维度”。事实上,df.columns属性包含您的词汇表。

等等,标准英语词典中有超过 30,000 个单词。如果您开始处理大量维基百科文章而不仅仅是几句话,那么将有很多维度要处理。您可能习惯于 2D 和 3D 向量,因为它们易于可视化。但是 30,000 个维度的概念,例如距离和长度,甚至是否有效?事实证明它们确实有效,您将在本书后面学习如何改进这些高维向量。现在只需知道向量的每个元素用于表示您希望向量表示的文档中单词的计数,权重或重要性。

您将在每个文档中找到每个独特的单词,然后找到所有文档中的所有独特单词。在数学中,这就是每个文档中所有单词集合的并集。这些文档的主要单词集合称为您的管道的词汇。如果您决定跟踪有关每个单词的其他语言信息,例如拼写变体或词性,您可以称之为词典。您可能会发现使用术语语料库来描述一组文档的学者也可能会使用单词“词典”,只是因为它是比“词汇”更精确的技术术语。

因此,看一看这三段话的词汇或词典。首先,您将进行大小写转换(小写化),以忽略大写字母(例如专有名词)之间的差异,并将它们组合到一个词汇标记中。这将减少您管道后续阶段中词汇表中独特单词的数量,这可以使您更容易看到发生的事情。

>>> docs_tokens = []
>>> for doc in docs:
...     docs_tokens.append([
...         tok.text.lower() for tok in nlp(doc.text)])  # #1
>>> len(docs_tokens[0])
27

现在您已经将这 28 篇文档(句子)全部标记化了,您可以将所有这些标记列表连接在一起,以创建一个包含所有标记的大列表,包括重复。此标记列表与原始文档唯一的区别在于,它已经被分割成句子并标记化为单词。

>>> all_doc_tokens = []
>>> for tokens in docs_tokens:
...     all_doc_tokens.extend(tokens)
>>> len(all_doc_tokens)
482

从整个段落的标记序列中创建词汇表(词典)。你的词汇表是你语料库中所有唯一标记的列表。就像图书馆中的词典一样,词汇表不包含任何重复项。除了 dict 类型,你还知道哪些 Python 数据类型可以去除重复项?

>>> vocab = set(all_doc_tokens)  # #1
>>> vocab = sorted(vocab)  # #2
>>> len(vocab)
246
>>> len(all_doc_tokens) / len(vocab)  # #3
1.959...

使用 set 数据类型确保没有标记被计数两次。在转换所有标记为小写之后,你的短语料库中只有 248 个拼写独特的标记。这意味着,平均而言,每个标记几乎被使用了两次(498 / 248)。

>>> vocab  # #1
['"', "'s", ',', '-', '.', '2018', ';', 'a', 'ability',
 'accurately', 'across', 'addressed', 'advanced', 'algorithm',
 'algorithmic', 'algorithms', 'also', 'an', 'analysis',
 ...
 'within', 'world', 'wrongful']

通常最好在回到文档中计算标记并将它们放入词汇表的正确位置之前,遍历整个语料库以建立起你的词汇表。如果你这样做,可以按字母顺序排列你的词汇表,这样更容易跟踪每个标记计数应该在向量中的大致位置。你还可以过滤掉非常频繁或非常稀有的标记,这样你就可以忽略它们并保持维度较低。当你想要计算比 1-gram 更长的 n-grams 时,这一点尤为重要。

假设你想要统计这个全部小写的 1-gram 词汇中的所有 248 个标记的计数,你可以重新组装你的计数向量矩阵。

>>> count_vectors = []
>>> for tokens in docs_tokens:
...     count_vectors.append(Counter(tokens))
>>> tf = pd.DataFrame(count_vectors)  # #1
>>> tf = tf.T.sort_index().T
>>> tf = tf.fillna(0).astype(int)
>>> tf
    " 's , ... within world wrongful
0   0   0  1  ...       0      0         0
1   0   0  3  ...       0      0         0
2   0   0  5  ...       0      0         0
3   2   0  0  ...       0      0         0
4   0   1  1  ...       0      0         0
5   0   0  0  ...       0      0         0
6   0   0  4  ...       0      1         0
...
11  0   0  1  ...       0      0         1
12  0   0  3  ...       0      0         0
13  0   0  1  ...       0      0         0
14  0   0  2  ...       0      0         0
15  2   0  4  ...       1      0         0
16 rows × 246 columns

浏览几个这些计数向量,看看你能否在“算法偏见”维基百科文章中找到它们对应的句子。你能否通过仅查看向量来感受到每个句子在说什么?一个计数向量将文档的要点放入一个数值向量中。对于一个对单词含义一无所知的机器来说,将这些计数归一化为标记的总体频率是有帮助的。为此,你将使用 Scikit-Learn 包。

3.1.2 更快、更好、更容易的标记计数

现在你已经手动创建了你的计数向量,你可能想知道是否有人为所有这些标记计数和记账构建了一个库。你通常可以依靠 Scikit-Learn (sklearn) 包来满足你所有的自然语言处理需求。如果你已经安装了 nlpia2 包,你已经安装了 Scikit-Learn (sklearn)。如果你更愿意手动安装它,这是一种方式。

pip install scipy, scikit-learn

ipython 控制台或 jupyter notebook 中,你可以使用感叹号在行首运行 bash 命令。

!pip install scipy, scikit-learn

一旦你设置好了你的环境并安装了 Scikit-Learn,你就可以创建术语频率向量了。CountVectorizer 类似于你之前使用过的 Counter 类的列表。它是一个标准的转换器类,具有符合 sklearn API 的.fit().transform() 方法,适用于所有机器学习模型。

列出 3.1 使用 sklearn 计算单词计数向量
>>> from sklearn.feature_extraction.text import CountVectorizer
>>> corpus = [doc.text for doc in docs]
>>> vectorizer = CountVectorizer()
>>> count_vectors = vectorizer.fit_transform(corpus)  # #1
>>> print(count_vectors.toarray()) # #2
[[1 0 3 1 1 0 2 1 0 0 0 1 0 3 1 1]
 [1 0 1 0 0 1 1 0 1 1 0 0 1 0 0 0]
 [0 2 0 0 0 1 1 0 1 1 1 0 0 0 0 0]]

现在你有一个矩阵(在 Python 中实际上是一个列表的列表),代表了三个文档(矩阵的三行)和词汇表中每个词的计数组成了矩阵的列。这很快!只需一行代码vectorize.fit_transform(corpus),我们就达到了与你需要手动进行分词、创建词汇表和计数术语的几十行代码相同的结果。请注意,这些向量的长度为 16,而不是像你手动创建的向量一样的 18。这是因为 Scikit-Learn 对句子进行了稍微不同的分词(它只考虑两个或更多字母的单词作为标记)并且去掉了标点符号。

所以,你有三个向量,每个文档一个。现在呢?你能做什么?你的文档词数向量可以做任何向量可以做的很酷的事情,所以让我们先学习更多关于向量和向量空间的知识。

3.1.3 将你的代码向量化

如果你在网上读到关于“向量化代码”的内容,意味着与“向量化文本”完全不同。向量化文本是将文本转换为该文本的有意义的向量表示。向量化代码是通过利用强大的编译库(如numpy)加速代码,并尽可能少地使用 Python 进行数学运算。之所以称其为“向量化”,是因为你可以使用向量代数表示法来消除代码中的for循环,这是许多 NLP 管道中最慢的部分。而不是使用for循环遍历向量或矩阵中的所有元素进行数学运算,你只需使用 numpy 来在编译的 C 代码中为你执行for循环。Pandas 在其向量代数中使用了numpy,所以你可以混合和匹配 DataFrame 和 numpy 数组或 Python 浮点数,所有这些都将运行得非常快。

>>> v1 = np.array(list(range(5)))
>>> v2 = pd.Series(reversed(range(5)))
>>> slow_answer = sum([4.2 * (x1 * x2) for x1, x2 in zip(v1, v2)])
>>> slow_answer
42.0

>>> faster_answer = sum(4.2 * v1 * v2)  # #1
>>> faster_answer
42.0

>>> fastest_answer = 4.2 * v1.dot(v2)  # #2
>>> fastest_answer
42.0

Python 的动态类型设计使得所有这些魔法成为可能。当你将一个float乘以一个arrayDataFrame时,不会因为你在两种不同类型上进行数学运算而引发错误,解释器会弄清楚你想要做什么,就像苏鲁一样。“让它成为”并且它将以最快的方式计算你所寻找的东西,使用编译的 C 代码而不是 Python 的for循环。

提示

如果你在代码中使用向量化来消除一些for循环,你可以将你的 NLP 管道加速 100 倍甚至更多。这意味着你可以尝试 100 倍以上的模型。柏林社会科学中心(WZB)有一个关于向量化的很棒的教程。而且如果你在网站的其他地方搜索,你会发现这可能是唯一一个对 NLP 和 AI 对社会影响的统计数据和数据有信任的来源。

3.1.4 向量空间

向量是线性代数或向量代数的主要构建块。它们是向量空间中的一组有序数字或坐标。它们描述了该空间中的位置或位置。或者它们可以用来标识该空间中的特定方向和大小或距离。向量空间是该空间中可能出现的所有可能向量的集合。因此,具有两个值的向量将位于二维向量空间中,具有三个值的向量将位于三维向量空间中,依此类推。

一张纸上的一小块图,或者图像中的像素网格,都是不错的二维向量空间。你可以看到这些坐标的顺序很重要。如果你颠倒了图纸上位置的 x 和 y 坐标,而没有颠倒所有的向量计算,那么你所有的线性代数问题的答案都会被颠倒。图纸和图像是矩形的,或者欧几里得的空间的例子,因为 x 和 y 坐标是彼此垂直的。本章中讨论的向量都是矩形的,欧几里得的空间。

地图或地球上的经度和纬度呢?那地理坐标空间肯定是二维的,因为它是一组有序的两个数字:纬度和经度。但每个纬度-经度对描述的是一个近似球面的点——地球表面。纬度-经度向量空间不是直线的,并且欧几里得几何在其中不完全适用。这意味着在计算由一对二维地理坐标或任何非欧几里得空间中的点表示的距离或接近度时,你必须小心。想想如何计算波特兰和纽约的纬度和经度坐标之间的距离。^([6])

图 3.1 展示了一种可视化三个二维向量(5, 5)(3, 2)(-1, 1)的方法。向量的头部(由箭头尖端表示)用于标识向量空间中的位置。因此,该图中的向量头将位于这三个坐标对处。位置向量的尾部(由箭头的“后部”表示)始终位于原点,或(0, 0)

图 3. 1. 二维向量

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

三维向量空间呢?你生活的三维物理世界中的位置和速度可以用三维向量中的 x、y 和 z 坐标来表示。但你并不限于正常的三维空间。你可以有 5 个维度、10 个维度、5000 个维度,等等。线性代数都能得到相同的结果。随着维度的增加,你可能需要更多的计算能力。你会遇到一些“维度灾难”问题,但你可以等到第十章再处理它们。^([7])

对于自然语言文档向量空间,您的向量空间的维度是整个语料库中出现的不同单词数量的计数。对于 TF(和即将出现的 TF-IDF),我们将此维度称为大写字母“K”。这个不同单词的数量也是您语料库的词汇量大小,所以在学术论文中它通常被称为“|V|”。然后,您可以用一个 K 维向量描述这个 K 维向量空间中的每个文档。在关于哈利和吉尔的三个文档语料库中,K = 18(或者如果您的分词器去除了标点符号,则为 16)。因为人类不能轻易地可视化超过三维的空间,所以让我们暂时搁置大部分维度,看一看其中的两个,这样你就可以在这张平面上的页面上对这些向量进行可视化表示了。因此,在图 3.2 中,K 被缩减为两个,以便二维查看 18 维哈利和吉尔向量空间。

图 3. 2. 2D 项频率向量

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

K 维向量的工作方式相同,只是您不能轻易地可视化它们。现在您已经有了每个文档的表示,并且知道它们共享一个共同的空间,您可以比较它们了。您可以通过减去它们并计算它们之间的距离的长度来测量向量之间的欧几里德距离,这称为 2-范数距离。它是一只“乌鸦”飞行(直线)从一个向量的尖端(头部)到另一个向量的尖端的距离。查看线性代数附录 C,了解为什么这对于单词计数(项频率)向量是个糟糕的主意。

如果两个向量具有相似的方向,则它们是“相似的”。它们可能具有相似的大小(长度),这意味着单词计数(项频率)向量的长度大致相同。但是您是否在词汇量空间中对文档长度感兴趣?可能不。您希望您对文档相似性的估计发现相同单词的使用大致相同的次数和相似的比例。这样准确的估计会让您相信它们所代表的文档可能在讨论相似的内容。

图 3. 3. 2D 向量及其之间的角度

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

余弦相似度,是两个向量之间的夹角(θ)的余弦值。图 3.3 显示了如何使用方程 3.1 计算余弦相似度点积。余弦相似度在 NLP 工程师中很受欢迎,因为:

  • 即使对于高维向量也能快速计算

  • 对单个维度的变化敏感

  • 对高维向量表现良好

  • 其值介于 -1 和 1 之间

你可以使用余弦相似度而不拖慢你的 NLP 管道,因为你只需要计算点积。你可能会惊讶地发现,你不需要计算余弦函数就能得到余弦相似度。你可以使用线性代数点积,它不需要进行任何三角函数计算。这使得计算非常高效(快速)。余弦相似度独立地考虑每个维度及其对向量方向的影响,即使对于高维向量也是如此。TF-IDF 可能有数千甚至数百万个维度,因此你需要使用一个在维度数量增加时不会降低有用性的度量(称为维度灾难)。

余弦相似度的另一个重要优势是它输出一个介于 -1 和 +1 之间的值:

  • -1 表示向量指向完全相反的方向 - 这只会发生在具有负值的向量上(TF-IDF 向量除外)

  • 0 表示向量是垂直或正交的 - 这会在你的两个 TF-IDF 向量不共享任何相同单词(维度)时发生

  • +1 表示两个向量完全对齐 - 这会在你的两个文档使用相同单词且相对频率相同的情况下发生

这样更容易猜测在管道内的条件表达式中使用的好阈值。以下是在你的线性代数教科书中归一化点积的样子:

方程式 3.1

[\begin{equation} \boldsymbol{A} \cdot \boldsymbol{B} = |\boldsymbol{A}| |\boldsymbol{B}| * cos(\theta) \end{equation}]

在 Python 中,你可能会使用类似以下的代码来计算余弦相似度:

>>> A.dot(B) == (np.linalg.norm(A) * np.linalg.norm(B)) * \
...     np.cos(angle_between_A_and_B)

如果你解出这个方程得到 np.cos(angle_between_A_and_B)(称为“向量 A 和 B 之间的余弦相似度”),你可以导出计算余弦相似度的代码:

列表 3.2 Python 中的余弦相似度公式
>>> cos_similarity_between_A_and_B = np.cos(angle_between_A_and_B) \
...    = A.dot(B) / (np.linalg.norm(A) * np.linalg.norm(B))

用线性代数表示,这变成了方程式 3.2:

方程式 3.2 两个向量之间的余弦相似度

[\begin{equation} cos(\theta) = \frac{\boldsymbol{A} \cdot \boldsymbol{B}}{|\boldsymbol{A}||\boldsymbol{B}|} \end{equation}]

或者在纯 Python 中,不使用 numpy

列表 3.3 在 Python 中计算余弦相似度
>>> import math
>>> def cosine_sim(vec1, vec2):
...     vec1 = [val for val in vec1.values()]  # #1
...     vec2 = [val for val in vec2.values()]
...
...     dot_prod = 0
...     for i, v in enumerate(vec1):
...         dot_prod += v * vec2[i]
...
...     mag_1 = math.sqrt(sum([x**2 for x in vec1]))
...     mag_2 = math.sqrt(sum([x**2 for x in vec2]))
...
...     return dot_prod / (mag_1 * mag_2)

因此,你需要计算你感兴趣的两个向量的点积 - 将每个向量的元素成对相乘 - 然后将这些乘积相加。然后你除以每个向量的范数(大小或长度)。向量范数与其从头到尾的欧几里德距离相同 - 其元素平方和的平方根。这个归一化点积,就像余弦函数的输出一样,将是介于 -1 和 1 之间的值。它是这两个向量之间夹角的余弦。它给出了这两个向量指向相同方向的程度的值。[8]

1 的余弦相似度代表指向所有维度上完全相同方向的标准化向量。这些向量可能具有不同的长度或大小,但它们指向相同的方向。请记住,你将点积除以每个向量的范数。因此,余弦相似度值越接近 1,两个向量在角度上越接近。对于 NLP 文档向量,如果余弦相似度接近 1,你就知道这些文档使用相似的词汇以相似的比例。因此,文档向量彼此接近的文档很可能在谈论相同的事情。

0 的余弦相似度代表两个向量没有共享成分。它们在所有维度上都是正交的,即在所有维度上都是垂直的。对于 NLP 的 TF 向量来说,只有当两个文档没有共同的词时才会出现这种情况。这并不一定意味着它们具有不同的含义或主题,只是它们使用完全不同的词语。

-1 的余弦相似度代表两个完全相反的向量,完全相反。它们指向相反的方向。对于简单的词频(词项频率)向量甚至是标准化的 TF 向量(稍后我们会讨论),这种情况永远不会发生。单词的计数永远不会是负数。因此,词频(词项频率)向量始终位于向量空间的同一“象限”中。你的任何词频向量都不可能在向量空间的一个象限中悄悄溜走。你的任何词频向量都不可能有与另一个词频向量相反的分量(词频),因为词频就是不能是负数。

在本章节中,你不会看到任何自然语言文档向量对的负余弦相似度值。但在下一章中,我们将发展出一种概念,即相互“相反”的单词和主题。这将显示为余弦相似度小于零,甚至是 -1 的文档、单词和主题。

如果你想要计算常规 numpy 向量的余弦相似度,比如由 CountVectorizer 返回的向量,你可以使用 Scikit-Learn 内置的工具。这是如何计算我们在 3.4 中计算的词向量 1 和 2 之间的余弦相似度的方法:

第 3.4 节 余弦相似度
>>> from sklearn.metrics.pairwise import cosine_similarity
>>> vec1 = tf.values[:1,:]  # #1
>>> vec2 = tf.values[1:2,:]
>>> cosine_similarity(vec1, vec2)
array([[0.117...]])

对词频(tf)DataFrame 进行切片的操作可能看起来是检索向量的奇怪方式。这是因为 SciKit-Learn 用于计算余弦相似度的函数已经被优化为在大型向量数组(2-D 矩阵)上高效工作。这段代码将 DataFrame 的第一行和第二行切片为包含文本第一句中单词计数的 1xN 数组。这个第一句话来自于“算法偏见”文章的计数向量与该文章第二句话只有 11.7% 的相似度(余弦相似度为 0.117)。看起来第二句话与第一句话共享的单词非常少。

为了更深入地了解余弦距离,你可以检查代码 3.3,它会给你与sklearn余弦相似度函数在等效的 numpy 数组中给出的 Counter 字典相同的答案。当你尝试预测一个 NLP 算法的输出,然后根据实际情况进行修正时,它会提高你对 NLP 工作原理的直觉。

3.2 计数 n-grams

在上一章中你已经学到如何从语料库的标记中创建 n-gram。现在,是时候将它们用于创建更好的文档表示了。对你来说幸运的是,你可以使用你已经熟悉的相同工具,只需稍微调整参数即可。

首先,让我们在我们的语料库中添加另一句话,这将说明为什么 n-gram 向量有时比计数向量更有用。

>>> import copy
>>> question = "What is algorithmic bias?"
>>> ngram_docs = copy.copy(docs)
>>> ngram_docs.append(question)

如果你使用我们在 3.2 小节训练的相同的向量化器计算这个新句子(问题)的词频向量,你会发现它与第二个句子的表示完全相等:

>>> question_vec = vectorizer.transform([new_sentence])
>>> question_vec
<1x240 sparse matrix of type '<class 'numpy.int64'>'
    with 3 stored elements in Compressed Sparse Row format>

稀疏矩阵是存储标记计数的高效方法,但为了增强对正在发生的情况的直观理解,或者调试代码,你会希望将向量稠密化。你可以使用.toarray()方法将稀疏向量(稀疏矩阵的行)转换为 numpy 数组或 Pandas 系列。

>>> question_vec.to_array()
array([[0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, ... ]])

你可能猜到了问题中显示在计数向量的第 8 个位置(维度)上的单词是哪个。记住,这是由CountVectorizer计算的词汇表中的第 8 个词,并且在运行.fit()时它会按字典序对其词汇表进行排序。你可以将计数向量与 PandasSeries一起配对,以查看计数向量中的内容。

>>> vocab = list(zip(*sorted((i, tok) for tok, i in
...     vectorizer.vocabulary_.items())))[1]
>>> pd.Series(question_vec.to_array()[0], index=vocab).head(8)
2018           0
ability        0
accurately     0
across         0
addressed      0
advanced       0
algorithm      0
algorithmic    1

现在,计算问题向量与你的句子向量"知识库"中所有其他向量之间的余弦相似度。这就是搜索引擎或数据库全文搜索用来查找问题答案的方法。

>>> cosine_similarity(count_vectors, question_vector)
array([[0.23570226],
       [0.12451456],
       [0.24743583],
       [0.4330127 ],
       [0.12909944],
       ...

最相似的是语料库中的第四个句子。它与question_vector的余弦相似度为 0.433。检查一下你的句子知识库中的第四个句子,看看它是否能很好地匹配这个问题。

>>> docs[3]
The study of algorithmic bias is most concerned with algorithms
that reflect "systematic and unfair" discrimination.

不错!那个句子可能是一个不错的开头。然而,维基百科文章的第一句可能更适合这个问题的算法偏见的定义。想一想如何改进向量化流水线,使得你的搜索返回第一句而不是第四句。

要找出 2-grams 是否有帮助,请执行与几页前使用 CountVectorizer 进行的相同向量化过程,但是将 n-gram 超参数 设置为计算 2-grams 而不是单个令牌(1-grams)。超参数只是一个函数名称、参数值或任何你可能想要调整以改善 NLP 流水线的东西。找到最佳超参数称为超参数调整。因此开始调整 ngram_range 参数,看看是否有帮助。

>>> ngram_vectorizer = CountVectorizer(ngram_range=(1, 2))
>>> ngram_vectors = ngram_vectorizer.fit_transform(corpus)
>>> ngram_vectors
<16x616 sparse matrix of type '<class 'numpy.int64'>'
    with 772 stored elements in Compressed Sparse Row format>

查看新计数向量的维数,你可能注意到这些向量要长得多。唯一的 2-grams(单词对)总是比唯一的令牌多。检查一下对你的问题非常重要的“算法偏差”2-gram 的 ngram-计数。

>>> vocab = list(zip(*sorted((i, tok) for tok, i in
...     ngram_vectorizer.vocabulary_.items())))[1]
>>> pd.DataFrame(ngram_vectors.toarray(),
...     columns=vocab)['algorithmic bias']
0     1
1     0
2     1
3     1
4     0

现在,第一句话可能更符合你的查询。值得注意的是,词袋-n-gram 方法也有自己的挑战。在大型文本和语料库中,n-gram 的数量呈指数增长,导致了我们之前提到的“维度灾难”问题。然而,正如你在本节中看到的,可能会有一些情况,你会选择使用它而不是单个令牌计数。

3.2.1 分析这个

即使到目前为止我们只处理了词令牌的 n-grams,字符的 n-grams 也是有用的。例如,它们可以用于语言检测或作者归属(确定在分析的文档集中谁是作者)。让我们使用字符 n-grams 和你刚学会如何使用的 CountVectorizer 类来解决一个谜题。

我们将从导入一个名为 this 的小而有趣的 Python 包开始,并检查其中一些常量:

>>> from this import s
>>> print(s)
Gur Mra bs Clguba, ol Gvz Crgref
Ornhgvshy vf orggre guna htyl.
Rkcyvpvg vf orggre guna vzcyvpvg.
Fvzcyr vf orggre guna pbzcyrk.
...
Nygubhtu arire vf bsgra orggre guna *evtug* abj.
Vs gur vzcyrzragngvba vf uneq gb rkcynva, vg'f n onq vqrn.
Vs gur vzcyrzragngvba vf rnfl gb rkcynva, vg znl or n tbbq vqrn.
Anzrfcnprf ner bar ubaxvat terng vqrn -- yrg'f qb zber bs gubfr!

这些奇怪的词是什么?用什么语言写的?H.P. Lovecraft 的粉丝可能会想到用来召唤死神克苏鲁的古老语言。^([9]) 但即使对他们来说,这个消息也将是难以理解的。

为了弄清楚这段神秘文字的意思,你将使用你刚学到的方法 - 频率分析(计数令牌)。但这一次,一只小鸟告诉你,也许从字符令牌而不是单词令牌开始可能会更有价值!幸运的是,CountVectorizer 在这里也能为你提供帮助。你可以在图 3.4a 中看到列出的结果 3.5 。

列表 3.5 CountVectorizer 直方图
>>> char_vectorizer = CountVectorizer(
...     ngram_range=(1,1), analyzer='char')  # #1
>>> s_char_frequencies = char_vectorizer.fit_transform(s)
>>> generate_histogram(
...     s_char_frequencies, s_char_vectorizer)  # #2

嗯。 不太确定你可以用这些频率计数做什么。 但再说一遍,你甚至还没有看到其他文本的频率计数。 让我们选择一些大型文档 - 例如,机器学习的维基百科文章,^([10]) 并尝试进行相同的分析(查看图 3.4b 中的结果):

>>> DATA_DIR = ('https://gitlab.com/tangibleai/nlpia/'
...             '-/raw/master/src/nlpia/data')

>>> url = DATA_DIR + '/machine_learning_full_article.txt'
>>> ml_text = requests.get(url).content.decode()
>>> ml_char_frequencies = char_vectorizer.fit_transform(ml_text)
>>> generate_histogram(s_char_frequencies, s_char_vectorizer)

现在看起来很有趣!如果你仔细观察两个频率直方图,你可能会注意到一个模式。直方图的峰值和谷值似乎以相同的顺序排列。如果你之前曾经处理过频率谱,这可能会有意义。字符频率峰值和谷值的模式是相似的,但是偏移了。

要确定你的眼睛是否看到了一个真实的模式,你需要检查峰值和谷值的变化是否一致。这种信号处理方法被称为频谱分析。你可以通过将每个信号的最高点的位置相互减去来计算峰值的相对位置。

你可以使用几个内置的 Python 函数,ord()chr(),来在整数和字符之间进行转换。幸运的是,这些整数和字符的映射是按字母顺序排列的,“ABC…​”。

>>> peak_distance = ord('R') - ord('E')
>>> peak_distance
13
>>> chr(ord('v') - peak_distance)  # #1
'I'
>>> chr(ord('n') - peak_distance)  # #2
'A'

所以,如果你想解码这个秘密信息中的字母"R",你应该从它的ordinalord)值中减去 13,以得到字母"E"——英语中最常用的字母。同样,要解码字母"V",你可以将它替换为"I"——第二个最常用的字母。前三个最常用的字母已经被同样的peak_distance(13)移动,以创建编码消息。并且这个距离在最不常用的字母之间也被保持:

>>> chr(ord('W') - peak_distance)
'J'

到这个点为止,你可能已经通过 MetaGered(搜索网络)查找了有关这个谜题的信息。^([11])也许你发现了这个秘密信息很可能是使用 ROT13 密码(编码)进行编码的。^([12]) ROT13 算法将字符串中的每个字母向字母表的前面旋转 13 个位置。要解码一个据说是用 ROT13 编码的秘密信息,你只需要应用逆算法,将你的字母表向后旋转 13 个位置。你可能可以在一行代码中创建编码器和解码器函数。或者你可以使用 Python 的内置codecs包来揭示这一切是关于什么的:

>>> import codecs
>>> print(codecs.decode(s, 'rot-13'))
The Zen of Python, by Tim Peters

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!

现在你知道了 Python 的禅意!这些智慧之言是由 Python 族长之一的 Tim Peters 在 1999 年写的。由于这首诗已经放入了公共领域,被谱曲,([13])甚至被拙劣模仿。([14])Python 的禅意已经帮助本书作者编写了更干净、更易读和可重用的代码。由于基于字符的CountVectorizer,你能够解码这些智慧之言。

3.3 锡普夫定律

现在我们来到了我们的主题——社会学。好吧,不是,但你将会快速地进入人数和字词计数的世界,你将会学到一个看似普遍适用的规则来统计大多数事物。事实证明,在语言中,像大多数涉及到生物的事物一样,模式是丰富多彩的。

在 20 世纪初,法国速记员让-巴蒂斯特·埃斯图普(Jean-Baptiste Estoup)注意到他费力手动计算的许多文件中单词频率的模式(感谢计算机和Python)。20 世纪 30 年代,美国语言学家乔治·金斯利·齐普夫试图正式化埃斯图普的观察,并最终这种关系以齐普夫的名字命名。

Zipf 定律指出,在给定自然语言话语语料库的情况下,任何单词的频率与其在频率表中的排名成反比。

—— 维基百科

Zipf 定律 en.wikipedia.org/wiki/Zipfs_law

具体而言,反比例 指的是在排名列表中,项目将以与其在列表中的排名直接相关的频率出现。例如,排名列表中的第一项将出现两次,比第二项多三倍,依此类推。您可以对任何语料库或文档进行的一个快速处理是绘制单词使用频率相对于其(频率)排名的图表。如果在对数-对数图中看到不符合直线的任何异常值,可能值得调查。

作为 Zipf 定律延伸至词语以外领域的例子,图 3.6 描绘了美国城市人口与排名之间的关系。事实证明,Zipf 定律适用于许多事物的计数。自然界充满了经历指数增长和"网络效应"的系统,如人口动态、经济产出和资源分配^([15])。有趣的是,像 Zipf 定律这样简单的东西能够在广泛的自然和人造现象中成立。诺贝尔奖得主保罗·克鲁格曼在谈论经济模型和 Zipf 定律时,简洁地表达了这一点:

关于经济理论的常见抱怨是,我们的模型过于简化 — 它们提供了对复杂混乱现实过度整洁的观点。 [使用 Zipf 定律] 反之亦然:你有复杂混乱的模型,然而现实却惊人地整洁和简单。

这是克鲁格曼城市人口图的更新版本:^([16])

图 3.4 城市人口分布

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

与城市和社交网络一样,单词也是如此。让我们首先从 NLTK 下载布朗语料库。

布朗语料库是 1961 年在布朗大学创建的第一个百万字英文电子语料库。该语料库包含来自 500 个来源的文本,这些来源已按体裁分类,如新闻、社论等^([17])。

—— NLTK 文档

>>> nltk.download('brown')  # #1
>>> from nltk.corpus import brown
>>> brown.words()[:10]  # #2
['The',
 'Fulton',
 'County',
 'Grand',
 'Jury',
 'said',
 'Friday',
 'an',
 'investigation',
 'of']
>>> brown.tagged_words()[:5]  # #3
[('The', 'AT'),
 ('Fulton', 'NP-TL'),
 ('County', 'NN-TL'),
 ('Grand', 'JJ-TL'),
 ('Jury', 'NN-TL')]
>>> len(brown.words())
1161192

因此,拥有超过 100 万个标记,您有一些值得关注的东西。

>>> from collections import Counter
>>> puncs = set((',', '.', '--', '-', '!', '?',
...     ':', ';', '``', "''", '(', ')', '[', ']'))
>>> word_list = (x.lower() for x in brown.words() if x not in puncs)
>>> token_counts = Counter(word_list)
>>> token_counts.most_common(10)
[('the', 69971),
 ('of', 36412),
 ('and', 28853),
 ('to', 26158),
 ('a', 23195),
 ('in', 21337),
 ('that', 10594),
 ('is', 10109),
 ('was', 9815),
 ('he', 9548)]

快速浏览显示,Brown 语料库中的词频遵循了 Zipf 预测的对数关系。 “The”(在词频中排名第 1)出现的次数大约是 “of”(在词频中排名第 2)的两倍,大约是 “and”(在词频中排名第 3)的三倍。如果你不相信我们,可以使用示例代码(ch03.html)中的代码来亲自验证这一点。

简而言之,如果你按照语料库中单词的出现次数对它们进行排名,并按降序列出它们,你会发现,对于足够大的样本,排名列表中的第一个单词在语料库中出现的可能性是第二个单词的两倍。它在列表中出现的可能性是第四个单词的四倍。因此,给定一个大语料库,你可以使用这个分解来统计地说出一个给定单词在该语料库的任何给定文档中出现的可能性有多大。

3.4 逆文档频率(IDF)

现在回到你的文档向量。单词计数和n-gram 计数很有用,但纯单词计数,即使将其归一化为文档的长度,也不能告诉你有关该单词在该文档中相对于语料库中其他文档的重要性的多少。如果你能搞清楚这些信息,你就可以开始描述语料库中的文档了。假设你有一个关于人工智能(AI)的每本书的语料库。“Intelligence” 几乎肯定会在你计算的每一本书(文档)中出现多次,但这并没有提供任何新信息,它并不能帮助区分这些文档。而像 “neural network” 或 “conversational engine” 这样的东西可能在整个语料库中并不那么普遍,但对于频繁出现的文档,你会更多地了解它们的性质。为此,你需要另一种工具。

逆文档频率,或 IDF,是你通过 Zipf 进行主题分析的窗口。让我们拿你之前的词频计数器来扩展一下。你可以计数令牌并将它们分成两种方式:按文档和整个语料库。你将只按文档计数。

让我们返回维基百科中的算法偏见示例,并抓取另一个部分(涉及算法种族和民族歧视),假设它是你偏见语料库中的第二个文档。

算法被批评为是一种掩盖决策中种族偏见的方法。由于过去某些种族和民族群体的对待方式,数据往往会包含隐藏的偏见。例如,黑人可能会比犯同样罪行的白人接受更长的刑期。这可能意味着系统放大了数据中原有的偏见。

…​

2019 年 11 月,加州大学伯克利分校的研究人员进行的一项研究揭示,抵押贷款算法在对待拉丁裔和非洲裔美国人方面存在歧视,这种歧视是基于“信用价值”的,这是美国公平借贷法的根源,该法允许贷方使用身份识别措施来确定一个人是否值得获得贷款。这些特定的算法存在于金融科技公司中,并被证明对少数族裔进行了歧视。

— 维基百科

算法偏见:种族和族裔歧视 (en.wikipedia.org/wiki/Algorithmic_bias#Racial_and_ethnic_discrimination

首先,让我们得到语料库中每个文档的总词数:

>>> DATA_DIR = ('https://gitlab.com/tangibleai/nlpia/'
...             '-/raw/master/src/nlpia/data')
>>> url = DATA_DIR + '/bias_discrimination.txt'
>>> bias_discrimination = requests.get(url).content.decode()
>>> intro_tokens = [token.text for token in nlp(bias_intro.lower())]
>>> disc_tokens = [token.text for token in nlp(bias_discrimination.lower())]
>>> intro_total = len(intro_tokens)
>>> intro_total
479
>>> disc_total = len (disc_tokens)
>>> disc_total
451

现在,拿到几个关于偏见的 tokenized 文档,让我们看看每个文档中术语“偏见”的频率。您将把找到的 TF 存储在两个字典中,每个文档一个。

>>> intro_tf = {}
>>> disc_tf = {}
>>> intro_counts = Counter(intro_tokens)
>>> intro_tf['bias'] = intro_counts['bias'] / intro_total
>>> disc_counts = Counter(disc_tokens)
>>> disc_tf['bias'] = disc_counts['bias'] / disc_total
>>> 'Term Frequency of "bias" in intro is:{:.4f}'.format(intro_tf['bias'])
Term Frequency of "bias" in intro is:0.0167
>>> 'Term Frequency of "bias" in discrimination chapter is: {:.4f}'\
...     .format(disc_tf['bias'])
'Term Frequency of "bias" in discrimination chapter is: 0.0022'

好了,你得到了一个比另一个大八倍的数字。那么“介绍”部分关于偏见多八倍?实际上不是。所以你需要深入挖掘一下。首先,看看这些数字与其他一些词的得分比较,比如词"和"。

>>> intro_tf['and'] = intro_counts['and'] / intro_total
>>> disc_tf['and'] = disc_counts['and'] / disc_total
>>> print('Term Frequency of "and" in intro is: {:.4f}'\
...     .format(intro_tf['and']))
Term Frequency of "and" in intro is: 0.0292
>>> print('Term Frequency of "and" in discrimination chapter is: {:.4f}'\
...     .format(disc_tf['and']))
Term Frequency of "and" in discrimination chapter is: 0.0303

太棒了!你知道这两个文档关于“和”和“偏见”一样多——实际上,歧视章节更多地涉及“和”。哦,等等。

一个衡量术语的逆文档频率的好方法是:这个标记在这个文档中是多么令人惊讶?在统计学、物理学和信息论中,衡量标记的惊讶程度用来衡量其或信息内容。这正是你需要衡量特定词的重要性的方式。如果一个术语在一个文档中出现了很多次,但在整个语料库中很少出现,那么它是将该文档的含义与其他文档区分开的词。这

一个术语的 IDF 只是文档总数与术语出现的文档数之比。在当前示例中,对于“和”和“偏见”,答案是相同的:

2 total documents / 2 documents contain "and"  = 2/2 = 1
2 total documents / 2 documents contain "bias" = 2/2 = 1

不是很有趣。所以我们来看另一个单词“黑色”。

2 total documents / 1 document contains "black" = 2/1 = 2

好的,这是另一回事了。让我们使用这个“稀有性”度量来加权词频。

>>> num_docs_containing_and = 0
>>> for doc in [intro_tokens, disc_tokens]:
...     if 'and' in doc:
...         num_docs_containing_and += 1  # #1

然后让我们获取两个文档中“黑色”的词频:

>>> intro_tf['black'] = intro_counts['black'] / intro_total
>>> disc_tf['black'] = disc_counts['black'] / disc_total

最后,三者的 IDF。你将像之前的 TF 一样将 IDF 存储在每个文档的字典中:

>>> num_docs = 2
>>> intro_idf = {}
>>> disc_idf = {}
>>> intro_idf['and'] = num_docs / num_docs_containing_and
>>> disc_idf['and'] = num_docs / num_docs_containing_and
>>> intro_idf['bias'] = num_docs / num_docs_containing_bias
>>> disc_idf['bias'] = num_docs / num_docs_containing_bias
>>> intro_idf['black'] = num_docs / num_docs_containing_black
>>> disc_idf['black'] = num_docs / num_docs_containing_black

然后在引言文档中找到:

>>> intro_tfidf = {}
>>> intro_tfidf['and'] = intro_tf['and'] * intro_idf['and']
>>> intro_tfidf['bias'] = intro_tf['bias'] * intro_idf['bias']
>>> intro_tfidf['black'] = intro_tf['black'] * intro_idf['black']

然后看历史文件:

>>> disc_tfidf = {}
>>> disc_tfidf['and'] = disc_tf['and'] * disc_idf['and']
>>> disc_tfidf['bias'] = disc_tf['bias'] * disc_idf['bias']
>>> disc_tfidf['black'] = disc_tf['black'] * disc_idf['black']

3.4.1 兹普夫的回归

差一点了。假设你有一个包含 100 万个文件的语料库(也许你是 baby-Google),有人搜索词“猫”,而在你的 100 万个文件中只有 1 个包含词“猫”的文件。这个的原始 IDF 是:

1,000,000 / 1 = 1,000,000

让我们假设您有 10 个文档中都含有单词"狗"。您的"狗"的逆文档频率(idf)为:

1,000,000 / 10 = 100,000

这是一个很大的区别。您的朋友齐普夫可能会说这太大了,因为它可能经常发生。齐普夫定律表明,当您比较两个单词的频率时,例如"猫"和"狗",即使它们出现的次数相似,频率更高的单词的频率也将比频率较低的单词高得多。因此,齐普夫定律建议您使用log()函数的逆函数exp()来缩放所有单词频率(和文档频率)。这确保了具有相似计数的单词,例如"猫"和"狗",在频率上不会有很大差异。这种单词频率的分布将确保您的 TF-IDF 分数更加均匀分布。因此,您应该重新定义 IDF 为该单词在您的文档中出现的原始概率的对数。您还需要对术语频率取对数。

对数函数的底数并不重要,因为您只是想使频率分布均匀,而不是在特定数值范围内缩放它。如果使用底数为 10 的对数函数,您将获得:

搜索:猫

方程 3.3

[\begin{equation} \text{idf} = \log \left(\text{1,000,000} / 1 \right) = 6 \end{equation}]

搜索:狗

方程 3.4

[\begin{equation} \text{idf} = \log \left(\text{1,000,000} / 10 \right) = 5 \end{equation}]

所以现在您更适当地加权了每个 TF 的结果,以符合它们在语言中的出现次数。

然后,对于语料库D中给定文档d中的给定术语t,您得到:

方程 3.5

[\begin{equation} \text{tf}\left(t, d\right) = \frac{\text{count}(t)}{\text{count}(d)} \end{equation}]

方程 3.6

[\begin{equation} \text{idf}\left(t,D\right) = \log \left(\frac{\text{文档数量}}{\text{包含术语 t 的文档数量}}\right) \end{equation}]

方程 3.7

[\begin{equation} \text{tfidf}\left(t,d,D\right) = \text{tf}(t,d) \ast \text{idf}(t,D) \end{equation}]

单词在文档中出现的次数越多,TF(因此 TF-IDF)就会增加。同时,随着包含该单词的文档数的增加,该单词的 IDF(因此 TF-IDF)就会降低。所以现在,你有了一个数字。这是你的计算机可以处理的东西。但它到底是什么呢?它将特定单词或令牌与特定语料库中的特定文档相关联,然后将数值分配给该单词在给定文档中的重要性,考虑到其在整个语料库中的使用情况。

在某些课程中,所有的计算都将在对数空间中进行,以便乘法变为加法,除法变为减法:

>>> log_tf = log(term_occurences_in_doc) -\
...     log(num_terms_in_doc)  # #1
>>> log_log_idf = log(log(total_num_docs) -\
...     log(num_docs_containing_term))  # #2
>>> log_tf_idf = log_tf + log_log_idf  # #3

这个单一的数字,即 TF-IDF 分数,是所有搜索引擎的谦逊基础。现在你已经能够将单词和文档转换为数字和向量,是时候用一些 Python 来让所有这些数字发挥作用了。你可能永远不会需要从头实现 TF-IDF 公式,因为这些算法已经在许多软件库中为你实现了。你不需要成为线性代数的专家来理解自然语言处理,但如果你对生成像 TF-IDF 分数这样的数字的数学有一个心理模型,那肯定能提高你的信心。如果你理解了数学,你可以自信地为你的应用调整它,甚至可以帮助一个开源项目改进它的自然语言处理算法。

3.4.2 相关性排名

正如你之前看到的,你可以轻松比较两个向量并获得它们的相似性,但是你已经学到了,仅仅计算单词不如使用它们的 TF-IDF 值有效。因此,在每个文档向量中,你希望用单词的 TF-IDF 值(分数)替换每个单词的计数。现在你的向量将更全面地反映文档的意思或主题。

当你使用像 MetaGer.org、Duck.com 或 You.com 这样的搜索引擎时,10 多个搜索结果列表是从这些页面的 TF-IDF 向量中精心制作出来的。如果你想一想,一个算法能够给你几乎总是包含你正在寻找的重要信息的 10 个页面,这是相当了不起的。毕竟,搜索引擎可以从数十亿个网页中选择。这是怎么可能的?在幕后,所有搜索引擎都是通过计算查询的 TF-IDF 向量与其数据库中数十亿个网页的 TF-IDF 向量之间的相似度来开始的。这种与你的查询的相似度通常被称为相关性。以下是你如何通过相关性对任何文档进行排名。

>>> doc_tfidf_vectors = []
>>> for doc in docs:  # #1
...     vec = copy.copy(zero_vector)  # #2
...     tokens = [token.text for token in nlp(doc.lower())]
...     token_counts = Counter(tokens)
...
...     for token, count in token_counts.items():
...         docs_containing_key = 0
...         for d in docs:
...             if token in d:
...                 docs_containing_key += 1
...         tf = value / len(vocab)
...         if docs_containing_key:
...             idf = len(docs) / docs_containing_key
...         else:
...             idf = 0
...         vec[key] = tf * idf
...     doc_tfidf_vectors.append(vec)

有了这个设置,你在语料库中的每个文档都有一个 K 维向量表示。现在开始猎杀吧!或者说搜索,在你的情况下。从前一节中,你可能还记得我们如何定义向量之间的相似性。如果两个向量的余弦相似性高,则认为它们相似,因此如果它们最大化余弦相似性,你可以找到两个相似的向量靠近彼此。

现在你已经有了进行基于 TF-IDF 的基本搜索所需的一切。你可以将搜索查询本身视为一个文档,并因此获得其基于 TF-IDF 的向量表示。然后,最后一步是找到与查询具有最高余弦相似度的文档,并将它们作为搜索结果返回。

如果你拿出关于哈利的三个文档,并提出查询“到商店需要多长时间?”:

>>> query = "How long does it take to get to the store?"
>>> query_vec = copy.copy(zero_vector)  # #1

>>> tokens = [token.text for token in nlp(query.lower())]
>>> token_counts = Counter(tokens)

>>> for key, value in token_counts.items():
...     docs_containing_key = 0
...     for _doc in docs:
...       if key in _doc.lower():
...         docs_containing_key += 1
...     if docs_containing_key == 0:  # #1
...         continue
...     tf = value / len(tokens)
...     idf = len(docs) / docs_containing_key
...     query_vec[key] = tf * idf
>>> cosine_sim(query_vec, doc_tfidf_vectors[0])
0.5235048549676834
>>> cosine_sim(query_vec, doc_tfidf_vectors[1])
0.0
>>> cosine_sim(query_vec, doc_tfidf_vectors[2])
0.0

你可以放心地说文档 0 对你的查询最具相关性!有了这个,你可以在任何语料库中找到相关的文档,无论是维基百科的文章、古登堡计划的书籍,还是 ActivityPub(Mastodon)上的 toots。谷歌小心了!

实际上,谷歌的搜索引擎不会受到我们的竞争威胁。你必须对每个查询的 TF-IDF 向量进行“索引扫描”。这是一个 (O(N)) 算法。大多数搜索引擎可以在常数时间 ((O(1))) 内响应,因为它们使用了一个倒排索引。^([20]) 你不会在这里实现一个能够在常数时间内找到这些匹配项的索引,但如果你感兴趣,你可能会喜欢探索 Whoosh ^([21]) 包中的最先进的 Python 实现及其源代码。^([22])

提示

在前述代码中,你删除了在管道词汇表中找不到的键,以避免除零错误。但更好的方法是对每个 IDF 计算的分母加 1,以确保没有分母为零。实际上,这种方法非常常见,有一个名字叫做加法平滑或"Laplace 平滑"^([23]) — 通常会改善基于 TF-IDF 关键词搜索的搜索结果。

3.4.3 另一种向量化器

现在这是很多代码,但这些早已自动化。你在本章开头使用的 sklearn 包也有一个用于 TF-IDF 的工具。就像你之前看到的 CountVectorizer 一样,它进行标记化,省略标点,并一次性计算 tf-idf 分数。

下面是如何使用 sklearn 构建 TF-IDF 矩阵的方法。语法几乎与 CountVectorizer 完全相同。

列表 3.6 使用 Scikit-Learn 计算 TF-IDF 矩阵
>>> from sklearn.feature_extraction.text import TfidfVectorizer
>>> corpus = docs
>>> vectorizer = TfidfVectorizer(min_df=1) # #1
>>> vectorizer = vectorizer.fit(corpus)  # #2
>>> vectors = vectorizer.transform(corpus)  # #3
>>> print(vectors.todense().round(2))  # #4
[[0.16 0.   0.48 0.21 0.21 0.   0.25 0.21 ... 0.21 0.   0.64 0.21 0.21]
 [0.37 0.   0.37 0.   0.   0.37 0.29 0.   ... 0.   0.49 0.   0.   0.  ]
 [0.   0.75 0.   0.   0.   0.29 0.22 0.   ... 0.   0.   0.   0.   0.  ]]

使用 Scikit-Learn,只需四行代码,你就创建了一个矩阵,其中包含你的三个文档和词汇表中每个词的逆文档频率。它与之前从 CountVectorizer 得到的矩阵非常相似,只是这次它包含了词汇表中每个术语、标记或单词的 TF-IDF,构成了矩阵的列。在大型文本中,这种或其他一些预优化的 TF-IDF 模型将为你节省大量工作。

3.4.4 替代方案

几十年来,TF-IDF 矩阵(术语-文档矩阵)一直是信息检索(搜索)的主要方法。因此,研究人员和公司花费了大量时间来优化 IDF 部分,以尝试改善搜索结果的相关性。3.1 列出了一些你可以规范化和平滑化术语频率权重的方法。

表 3.1 替代 TF-IDF 规范化方法(Molino 2017)^([24])
方案定义
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
TD-IDF外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
TF-ICF外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
Okapi BM25外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
ATC外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
LTU外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
MI外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
PosMI外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
T-Test外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
卡方检验从分布到语义相似性 (www.era.lib.ed.ac.uk/bitstream/handle/1842/563/IP030023.pdf#subsection.4.3.5),作者詹姆斯·理查德·柯兰
Lin98a外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
Lin98b外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
Gref94外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

搜索引擎(信息检索系统)在语料库中匹配查询和文档之间的关键词(术语)。如果您正在构建一个搜索引擎,并希望提供可能与用户所寻找内容匹配的文档,您应该花一些时间研究皮耶罗·莫利诺在图 3.7 中描述的替代方案。

用于对查询结果进行排名的另一种替代方法是 Okapi BM25,或者其最新的变体 BM25F。

3.4.5 Okapi BM25

伦敦城市大学的聪明人提出了一种更好的方法来对搜索结果进行排名。他们不仅仅计算 TF-IDF 余弦相似度,还对相似度进行归一化和平滑处理。他们还忽略了查询文档中的重复术语,有效地将查询向量的术语频率剪切为 1。余弦相似度的点积不是由 TF-IDF 向量的规范化(文档和查询中的术语数)来规范化的,而是由文档长度本身的非线性函数来规范化的。

q_idf * dot(q_tf, d_tf[i]) * 1.5 / (dot(q_tf, d_tf[i]) + .25 + .75 * d_num_words[i] / d_num_words.mean()))

通过选择使用户获得最相关结果的加权方案,您可以优化您的管道。但是,如果您的语料库不太大,您可能会考虑进一步前进,以获得更有用和准确的单词和文档含义的表示。

3.5 使用 TF-IDF 为您的机器人

在本章中,您学习了如何使用 TF-IDF 来表示自然语言文档,并找到它们之间的相似性,并执行关键字搜索。但是,如果您想构建一个聊天机器人,您该如何利用这些功能来制作您的第一个智能助手?

实际上,许多聊天机器人严重依赖搜索引擎。一些聊天机器人使用他们的搜索引擎作为生成响应的唯一算法。您只需要额外采取一步,将您的简单搜索索引(TF-IDF)转换为聊天机器人即可。为了使本书尽可能实用,每一章都将向您展示如何使用您在该章中掌握的技能使您的机器人更智能。

在本章中,您将使您的聊天机器人回答数据科学问题。诀窍很简单:您将您的训练数据存储为问题和相应响应的对。然后,您可以使用 TF-IDF 搜索与用户输入文本最相似的问题。而不是返回数据库中最相似的语句,您返回与该语句相关联的响应。然后,您就可以聊天了!

让我们一步步来。首先,让我们加载我们的数据。你将使用 Hobson 的学生在过去几年中问他的数据科学问题的语料库。它们位于 qary 存储库中:

>>> DS_FAQ_URL = ('https://gitlab.com/tangibleai/qary/-/raw/main/'
...     'src/qary/data/faq/faq-python-data-science-cleaned.csv')
>>> qa_dataset = pd.read_csv(DS_FAQ_URL)

接下来,让我们为数据集中的问题创建 TF-IDF 向量。你将使用前一节中看到的 Scikit-Learn TfidfVectorizer 类。

>>> vectorizer = TfidfVectorizer()
>>> vectorizer.fit(df['question'])
>>> tfidfvectors_sparse = vectorizer.transform(df['question'])  # #1
>>> tfidfvectors = tfidfvectors_sparse.todense()  # #2

现在我们准备实现问答功能本身。你的机器人将使用你在数据集上训练的相同向量化器来回答用户的问题,并找到最相似的问题。

>>> def bot_reply(question):
...    question_vector = vectorizer.transform([question]).todense()
...    idx = question_vector.dot(tfidfvectors.T).argmax() # #1
...
...    print(
...        f"Your question:\n {question}\n\n"
...        f"Most similar FAQ question:\n {df['question'][idx]}\n\n"
...        f"Answer to that FAQ question:\n {df['answer'][idx]}\n\n"
...    )

你的第一个问答聊天机器人已经准备好了!让我们问它第一个问题:

>>> bot_reply("What's overfitting a model?")
Your question:
  What's overfitting a model?

Most similar FAQ question:
  What is overfitting?

Answer to that FAQ question:
  When your test set accuracy is significantly lower than your training set accuracy?

尝试与它玩耍,问它更多的问题,比如:- 什么是高斯分布?- 谁提出了感知器算法?

但你会很快意识到,你的聊天机器人经常失败 - 不仅仅是因为你训练它的数据集很小。

例如,让我们尝试以下问题:

>>> bot_reply('How do I decrease overfitting for Logistic Regression?')
Your question:
  How do I decrease overfitting for Logistic Regression?
Most similar FAQ question:
  How to decrease overfitting in boosting models?
Answer to that FAQ question:
  What are some techniques to reduce overfitting in general? Will they work with boosting models?

如果你仔细看了数据集,你可能会发现它实际上有一个关于减少提升模型过拟合的答案。然而,我们的向量化器只是有点太字面了 - 当它在错误的问题中看到“减少”一词时,这导致了对错误问题的点积更高。在下一章中,我们将看到如何通过查看含义而不是特定单词来克服这一挑战。

3.6 接下来要做什么

现在你可以将自然语言文本转换为数字了,你可以开始操作它们并计算它们。拿着牢牢的数字,在下一章中,你将对这些数字进行细化,试图代表自然语言文本的含义主题,而不仅仅是它的词语。在随后的章节中,我们将向你展示如何实现一个语义搜索引擎,该引擎找到与你查询中的单词“意思”相似的文档,而不仅仅是使用你查询中的这些确切单词的文档。语义搜索比 TF-IDF 加权和词干提取和词形还原能够实现的任何东西都要好得多。最先进的搜索引擎结合了 TF-IDF 向量和语义嵌入向量,以实现比传统搜索更高的准确性。

资金充裕的 OpenSearch 项目,一个 ElasticSearch 的分支,现在正在引领搜索创新之路。[25] ElasticSearch 在 2021 年开始封锁他们的技术花园。Google、Bing 和其他网络搜索引擎之所以不使用语义搜索方法,是因为它们的语料库太大了。语义词和主题向量无法扩展到数十亿个文档,但数百万个文档却没有问题。一些创业公司,比如 You.com,正在学习如何使用开源技术实现语义搜索和网络规模的对话式搜索(聊天)。

因此,你只需要最基本的 TF-IDF 向量来供给你的管道,以获得语义搜索、文档分类、对话系统以及我们在第一章提到的大多数其他应用的最先进性能。TF-IDF 只是你的管道中的第一阶段,是你从文本中提取的一组基本特征。在下一章中,你将从你的 TF-IDF 向量计算主题向量。主题向量甚至是比这些经过精心归一化和平滑处理的 TF-IDF 向量更好地表示文档的含义。当我们在第六章转向 Word2vec 单词向量和后续章节中的单词和文档含义的深度学习嵌入时,情况只会变得更好。

3.7 自测

  1. CountVectorizer.transform() 创建的计数向量和 Python collections.Counter 对象列表之间有什么区别?你能将它们转换成相同的 DataFrame 对象吗?

  2. 你能在一个大型语料库(超过 100 万个文档)和一个庞大的词汇表(超过 100 万个标记)上使用 TFIDFVectorizer 吗?你预计会遇到什么问题?

  3. 想象一个语料库或任务的例子,在这里术语频率(TF)会比 TF-IDF 表现更好。

  4. 我们提到过,字符 n-gram 的包可以用于语言识别任务。一个使用字符 n-gram 区分一种语言和另一种语言的算法会如何运作?

  5. 你在本章中看到的 TF-IDF 的限制或缺点是什么?你能想出未提及的其他缺点吗?

  6. 你会如何利用 TF-IDF 作为基础来改进今天大多数搜索引擎的工作方式?

3.8 总结

  • 任何具有毫秒响应时间的 Web 规模搜索引擎在引擎盖下都隐藏着 TF-IDF 术语文档矩阵的能力。

  • 希波夫定律可以帮助你预测各种事物的频率,包括单词、字符和人物。

  • 术语频率必须按其逆文档频率加权,以确保最重要、最有意义的词语得到应有的重视。

  • 词袋 / n-gram 词袋和 TF-IDF 是用实数向量表示自然语言文档的最基本算法。

  • 高维向量对之间的欧几里德距离和相似性并不能充分表示它们在大多数自然语言处理应用中的相似性。

  • 余弦距离,向量之间的“重叠”量,可以通过将归一化向量的元素相乘并将这些乘积相加来有效地计算。

  • 余弦距离是大多数自然语言向量表示的首选相似性评分。

[1] StackOverflow 讨论是否依赖于此功能(stackoverflow.com/questions/39980323/are-dictionaries-ordered-in-python-3-6/39980744#39980744

[2] Scikit-Learn 文档(scikit-learn.org/)。

[3] 如果你想要更多关于线性代数和向量的细节,请查看附录 C。

[4] “向量化和并行化” by WZB.eu (datascience.blog.wzb.eu/2018/02/02/vectorization-and-parallelization-in-python-with-numpy-and-pandas/)

[5] “动荡时代的知识与社会” (wzb.eu/en/node/60041)

[6] 你需要使用类似 GeoPy(geopy.readthedocs.io)的包来确保数学正确。

[7] 维度诅咒是,随着维度的增加,向量在欧几里得距离上会以指数方式远离彼此。许多简单操作在超过 10 或 20 个维度时变得不切实际,比如基于它们与“查询”或“参考”向量的距离对大量向量列表进行排序(近似最近邻搜索)。要深入了解,请查看维基百科的“维度诅咒”文章 (en.wikipedia.org/wiki/Curse_of_dimensionality)。

[8] 这些视频展示了如何使用 SpaCy 和 numpy 为单词创建向量,然后计算它们之间的余弦相似度 (www.dropbox.com/sh/3p2tt55pqsisy7l/AAB4vwH4hV3S9pUO0n4kTZfGa?dl=0)

[9] 如果这个参考对你来说陌生,请查看 H.P.洛夫克拉夫特的故事克苏鲁的呼唤www.hplovecraft.com/writings/texts/fiction/cc.aspx

[10] 于 2021 年 7 月 9 日从这里检索:en.wikipedia.org/wiki/Machine_learning

[11] 非营利性搜索引擎 MetaGer 严肃对待隐私、诚实和道德,不像你已经熟悉的顶级搜索引擎 (metager.org/)

[12] 维基百科 ROT13 文章 (en.wikipedia.org/wiki/ROT13)

[13] Zbwedicon 关于 Python 之禅的 YouTube 视频 (www.youtube.com/watch?v=i6G6dmVJy74)

[14] 你可以安装和导入 PyDanny 的 that 包,以便笑笑 Python 的反模式 (pypi.org/project/that)

[15] 查看标题为"Zipf 定律之外还有更多"的网页 (www.nature.com/articles/srep00812)

[16] 使用 Pandas 从维基百科下载的人口数据。查看 GitHub 上的 nlpia.book.examples 代码(gitlab.com/tangibleai/nlpia2/-/blob/main/src/nlpia2/ch03/ch03_zipf.py

[17] 完整列表请参见 icame.uib.no/brown/bcm-los.html

[18] Gerard Salton 和 Chris Buckley 首次在他们的论文《信息检索中的术语加权方法》中展示了对于信息检索的对数缩放的有用性(ecommons.cornell.edu/bitstream/handle/1813/6721/87-881.pdf)。

[19] 后面我们会向您展示如何在计算所有 TF-IDF 值后使用此对数缩放来归一化 TF-IDF 向量。

[20] 参见名为“倒排索引 - 维基百科”的网页(en.wikipedia.org/wiki/Inverted_index)。

[21] 参见名为“Whoosh : PyPI”的网页(pypi.python.org/pypi/Whoosh)。

[22] 参见名为“GitHub - Mplsbeb/whoosh: A fast pure-Python search engine”的网页(github.com/Mplsbeb/whoosh)。

[23] 参见名为“加法平滑 - 维基百科”的网页(en.wikipedia.org/wiki/Additive_smoothing)。

[24] Word Embeddings Past, Present and Future,Piero Molino,于 AI with the Best 2017

[25] “OpenSearch 中语义搜索的 ABC” ,Milind Shyani,(opensearch.org/blog/semantic-science-benchmarks/

第四章:在词频统计中找到含义(语义分析)

本章内容包括

  • 分析语义(含义)以创建主题向量

  • 使用主题向量之间的语义相似性进行语义搜索

  • 可伸缩的语义分析和大型语料库的语义搜索

  • 在你的 NLP 管道中使用语义组件(主题)作为特征

  • 导航高维向量空间

你已经学会了很多自然语言处理的技巧。但现在可能是你第一次能够做一点"魔术"。这是我们第一次讨论机器能够理解单词含义的时候。

第三章的 TF-IDF 向量(词频 - 逆文档频率向量)帮助你估计了文本块中单词的重要性。你使用 TF-IDF 向量和矩阵告诉你每个单词对文档集合中一小部分文本的整体含义的重要性。这些 TF-IDF"重要性"分数不仅适用于单词,还适用于短序列的单词,n-grams。如果你知道确切的单词或n-grams,它们对于搜索文本非常有效。但它们也有一定的局限性。通常,你需要一种不仅仅考虑单词计数,还考虑它们含义的表示。

研究人员发现了几种使用词语与其他词语的共现来表示词语含义的方法。在本章中,你将了解其中一些方法,比如潜在语义分析(LSA)和潜在狄利克雷分配。这些方法创建了用于表示词语和文档的语义主题向量。你将使用 TF-IDF 向量的加权频率分数,或者上一章学到的词袋(BOW)向量来创建它们。这些分数以及它们之间的相关性,将帮助你计算构成你的主题向量维度的主题"分数"。

主题向量将帮助你完成许多有趣的事情。它们使得根据其含义进行文档搜索成为可能 —— 语义搜索。大多数情况下,语义搜索返回的搜索结果要比关键词搜索好得多。有时,即使用户无法想到正确的查询词,语义搜索也会返回用户正要搜索的文档。

语义向量还可以用于识别最能代表语句、文档或语料库(文档集合)主题的单词和n-grams。有了这些单词及其相对重要性的向量,你可以为文档提供最有意义的单词 —— 一组总结其含义的关键词。

最后,你将能够比较任意两个语句或文档,并判断它们在含义上有多"接近"。

提示

“主题”、“语义”和“含义”这些术语在自然语言处理(NLP)中有着相似的意义,并且在讨论时通常可以互换使用。在本章中,您将学习如何构建一个 NLP 流水线,它可以自行找出这种同义词,甚至能够找出“搞明白”这个短语和“计算”这个词的含义相似之处。机器只能“计算”含义,而不能“搞明白”含义。

很快您将会发现,构成主题向量维度的单词的线性组合是相当强大的含义表示。

4.1 从单词计数到主题分数

您知道如何计算单词的频率,并在 TF-IDF 向量或矩阵中评分单词的重要性。但这还不够。让我们来看看这可能会产生哪些问题,以及如何处理文本的含义,而不仅仅是单个术语频率。

4.1.1 TF-IDF 向量和词形还原的局限性

TF-IDF 向量根据文档中单词的确切拼写进行计数。因此,如果文本以不同的方式表达相同的含义,它们的 TF-IDF 向量表示将完全不同,即使它们的拼写不同或使用不同的词汇。这会混淆搜索引擎和依赖于标记计数的文档相似性比较。

在第二章中,您对单词的词尾进行了归一化处理,以便将只在最后几个字符上有所不同的单词收集到一个单一的标记下。您使用了标准化方法,如词干提取和词形还原,来创建拼写相似、意思通常也相似的小型单词集合。您为这些单词的每一个小集合进行了标记,标记为它们的词元或词干,然后您处理了这些新标记,而不是原始单词。

这种词形还原方法将拼写相似的单词放在了一起进行分析,但并不一定是含义相似的单词。而且,它确实未能将大多数同义词配对起来。同义词通常在很多方面都不同,不仅仅是词形还原和词干提取处理的词尾。更糟糕的是,词形还原和词干提取有时会错误地将反义词(含义相反的单词)归类在一起。

结果是,两个讨论相同事物但使用不同词汇的文本片段在您的词形还原的 TF-IDF 向量空间模型中将不会“接近”彼此。有时,即使两个词形还原的 TF-IDF 向量彼此接近,它们的含义也完全不相似。即使是第三章中的最新的 TF-IDF 相似度评分,如 Okapi BM25 或余弦相似度,也无法连接这些同义词或将这些反义词分开。拼写不同的同义词产生的 TF-IDF 向量在向量空间中并不接近。

例如,在NLPIA这一章的 TF-IDF 向量,也就是你现在正在阅读的这一章,可能与关于潜在语义索引的大学教科书中的意思相去甚远。但这正是这一章所讨论的,只是我们在这一章中使用现代和口语化的术语。教授和研究人员在他们的教科书和讲座中使用更一致,更严格的语言。另外,教授们十年前使用的术语可能随着过去几年的快速进展而发生了变化。例如,像"潜在语义 索引"这样的术语比研究人员现在使用的"潜在语义分析"这个术语更受欢迎。^([3])

因此,具有相似含义的不同单词对 TF-IDF 造成问题。但是,看起来相似但含义完全不同的词也是如此。即使是由英语教授撰写的正式英语文本也无法避免大多数英语单词具有多重含义的事实,这对包括机器学习者在内的任何新学习者来说都是一个挑战。这种具有多重含义的单词的概念称为多义性

以下是一些多义词可能影响单词或语句语义的方式。

  • 同音异义词 — 拼写和发音相同,但含义不同的词(例如:乐队正在演奏老披头士的歌曲。她的发带非常漂亮。

  • 同形异义词 — 拼写相同但发音和含义不同的词。(例如:我反对这个决定。我不认识这个物体。

  • 双关语 — 在同一句子中同时使用一个词的两个含义(例如:皮克威克先生拿起了他的帽子和他的离开。

你可以看到所有这些现象会降低 TF-IDF 的性能,因为使具有相似但含义不同的单词的句子的 TF-IDF 向量更相似于彼此,而不应该是这样。为了解决这些挑战,我们需要更强大的工具。

话题向量

当你对 TF-IDF 向量进行数学运算,比如加法和减法时,这些和差只告诉你组合或差异化的向量所代表的文档中单词使用的频率。这种数学并不告诉你这些词背后的"含义"。你可以通过将 TF-IDF 矩阵乘以自身来计算单词与单词的 TF-IDF 向量(单词共现或相关向量)。但是用这些稀疏的,高维的向量进行"向量推理"并不奏效。当你将这些向量相加或相减时,它们并不能很好地代表一个现有的概念或单词或主题。

所以你需要一种方法来从单词统计中提取一些额外的信息和意义。你需要更好地估计文档中单词的"意义"。你需要知道在特定文档中那组词的含义是什么。你希望用一个类似于 TF-IDF 向量的向量来表示那个意义,只是更紧凑更有意义。

本质上,创建这些新向量时,您将定义一个新的空间。当您用 TF-IDF 或词袋向量表示单词和文档时,您正在一个由文档中出现的单词或术语定义的空间中操作。每个术语都有一个维度 - 这就是为什么您很容易达到数千个维度。每个术语与每个其他术语都是"正交"的 - 当您将表示一个单词的向量与表示另一个单词的向量相乘时,即使这些单词是同义词,您总是得到一个零。

主题建模的过程是找到一个维度较少的空间,使语义上相近的单词对齐到类似的维度。我们将这些维度称为主题,新空间中的向量称为主题向量。您可以拥有任意数量的主题。您的主题空间可以只有一个维度,也可以有数千个维度。

您可以像处理任何其他向量一样添加和减去您在本章中将计算的主题向量。只不过这一次,和差的含义比 TF-IDF 向量时更重要。主题向量之间的距离或相似度对于诸如查找与相似主题相关的文档或语义搜索等事情非常有用。

当您将您的向量转换到新空间时,您将为语料库中的每个文档有一个文档-主题向量。您的词汇表中每个单词都将有一个词-主题向量。因此,您只需将其所有词-主题向量相加,就可以计算任何新文档的主题向量。

创造出单词和句子语义(含义)的数值表示可能会有些棘手。这对于"模糊"语言如英语来说尤其如此,因为英语有多种方言,对相同单词有许多不同的解释。

考虑到这些挑战,您能想象如何将具有一百万维度(术语)的 TF-IDF 向量压缩为具有 10 或 100 维度(主题)的向量吗?这就像确定正确的基本颜色混合以尝试复制您公寓中的油漆颜色,以便您可以覆盖那些墙上的钉孔。

您需要找到那些在一个主题中“属于”一起的单词维度,并将它们的 TF-IDF 值相加,以创建一个新的数字来表示文档中该主题的数量。您甚至可以根据它们对主题的重要性对它们进行加权,以及您希望每个单词对"混合"的贡献有多少。您甚至可以为减少文本与该主题相关的可能性的单词添加负权重。

4.1.3 思想实验

让我们进行一个思想实验。假设您有某个特定文档的一些 TF-IDF 向量,并且您希望将其转换为一个主题向量。您可以考虑每个单词对您的主题的贡献。

假设你正在处理有关纽约市中央公园的宠物的一些句子(NYC)。让我们创建三个主题:一个关于宠物,一个关于动物,另一个关于城市。将这些主题称为“petness”、“animalness”和“cityness”。因此,关于宠物的“petness”主题将显著评分像“猫”和“狗”这样的词汇,但可能忽略像“NYC”和“苹果”这样的词汇。关于城市的“cityness”主题将忽略像“猫”和“狗”这样的词汇,但可能会对“苹果”稍微加权,仅仅因为与“大苹果”有关联。

如果你像这样“训练”你的主题模型,而不使用计算机,只使用你的常识,你可能会得出类似于清单 4.1 中的一些权重。

清单 4.1 你的主题的示例权重
>>> import numpy as np

>>> topic = {}
>>> tfidf = dict(list(zip('cat dog apple lion NYC love'.split(),
...     np.random.rand(6))))  # #1
>>> topic['petness'] = (.3 * tfidf['cat'] +\
...                     .3 * tfidf['dog'] +\
...                      0 * tfidf['apple'] +\
...                      0 * tfidf['lion'] -\
...                     .2 * tfidf['NYC'] +\
...                     .2 * tfidf['love'])  # #2
>>> topic['animalness']  = (.1 * tfidf['cat']  +\
...                         .1 * tfidf['dog'] -\
...                         .1 * tfidf['apple'] +\
...                         .5 * tfidf['lion'] +\
...                         .1 * tfidf['NYC'] -\
...                         .1 * tfidf['love'])
>>> topic['cityness']    = ( 0 * tfidf['cat']  -\
...                         .1 * tfidf['dog'] +\
...                         .2 * tfidf['apple'] -\
...                         .1 * tfidf['lion'] +\
...                         .5 * tfidf['NYC'] +\
...                         .1 * tfidf['love'])

在这个思想实验中,我们将可能是指示您的每个主题的单词频率相加起来。我们根据单词与主题相关的可能性加权单词频率(TF-IDF 值)。请注意,这些权重也可能是负值,因为某种意义上可能谈论与您的主题相反的内容的单词。

请注意,这不是一个真正的算法或示例实现,只是一个思想实验。你只是试图弄清楚如何教机器像你一样思考。你任意选择将你的单词和文档分解为只有三个主题(“petness”、“animalness”和“cityness”)。并且你的词汇是有限的,只有六个单词。

下一步是思考一个人可能如何在数学上决定哪些主题和单词是相关的,以及这些连接应该具有什么权重。一旦你决定了三个要建模的主题,你就必须确定为这些主题中的每个单词分配多少权重。你按比例混合单词以使你的主题“颜色混合”。主题建模转换(颜色混合配方)是一个 3 x 6 的比例(权重)矩阵,将三个主题与六个单词相连。你将该矩阵乘以一个想象中的 6 x 1 的 TF-IDF 向量,以获得该文档的 3 x 1 主题向量。

你做出了判断,认为术语“猫”和“狗”应该对“petness”主题具有类似的贡献(权重为 0.3)。因此,用于你的 TF-IDF 到主题转换的矩阵左上角的两个值都是 0.3。你能想象出可能使用软件“计算”这些比例的方法吗?记住,你有一堆计算机可以阅读,标记和计算标记的文档。你可以为尽可能多的文档制作 TF-IDF 向量。继续思考在阅读时如何使用这些计数来计算单词的主题权重。

你决定术语“NYC”在“petness”主题中应具有负权重。在某种意义上,城市名称,以及一般的专有名称,缩写和首字母缩写,与有关宠物的词汇几乎没有共同之处。思考一下单词“共同之处”在词汇中的含义。TF-IDF 矩阵中是否有表示单词共同含义的内容?

注意 “city” 这个主题向量中有少量的 “apple” 一词。这可能是因为你是手动进行的,而我们人类知道 “NYC” 和 “Big Apple” 经常是同义词。我们的语义分析算法有望能够根据 “apple” 和 “NYC” 在相同文档中出现的频率来计算出它们之间的同义关系。

在阅读清单 4.1 中的加权和之后,试着猜猜我们是如何得出这三个主题和六个单词的权重的。你脑海中可能有一个不同的"语料库",与我们在头脑中使用的不同。所以你可能对这些单词的"适当"权重有不同的看法。你会如何改变它们?你可以用什么客观的标准来衡量这些比例(权重)?我们将在下一节回答这个问题。

注意

我们选择了一种有符号的词权重来生成主题向量。这样可以使用负权重来表示与主题相反的词。因为你是手工进行的,我们选择使用易于计算的 L¹-norm (即向量维度的绝对值之和等于 1)来对你的主题向量进行归一化。不过,在本章稍后使用的真正的潜在语义分析(LSA)算法则通过更有用的 L²-norm 对主题向量进行归一化。我们将在本章后面介绍不同的范数和距离。

在阅读这些向量时,你可能已经意识到单词和主题之间的关系是可以"翻转"的。一个 3x6 的三个主题向量矩阵可以通过转置来产生你的词汇表中每个单词的主题权重。这些权重向量将成为你六个单词的词向量:

>>> word_vector = {}
>>> word_vector['cat']  =  .3*topic['petness'] +\
...                        .1*topic['animalness'] +\
...                         0*topic['cityness']
>>> word_vector['dog']  =  .3*topic['petness'] +\
...                        .1*topic['animalness'] -\
...                        .1*topic['cityness']
>>> word_vector['apple']=   0*topic['petness'] -\
...                        .1*topic['animalness'] +\
...                        .2*topic['cityness']
>>> word_vector['lion'] =   0*topic['petness'] +\
...                        .5*topic['animalness'] -\
...                        .1*topic['cityness']
>>> word_vector['NYC']  = -.2*topic['petness'] +\
...                        .1*topic['animalness'] +\
...                        .5*topic['cityness']
>>> word_vector['love'] =  .2*topic['petness'] -\
...                        .1*topic['animalness'] +\
...                        .1*topic['cityness']

这六个单词主题向量在图 4.1 中显示,每个单词对应一个向量,表示你的六个词的含义。

图 4.1. 关于宠物和纽约市的六个单词的思想实验的 3D 向量

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

之前,每个主题的向量都带有每个单词的权重,给出了表示三个主题中单词的线性组合的 6-D 向量。现在,你手工设计了一种通过主题来表示文档的方法。如果你只计算这些六个单词出现的次数,并将它们乘以相应的权重,就可以得到任何文档的 3D 主题向量。3D 向量非常有趣,因为人们可以很容易地进行可视化。你可以将它们绘制出来,并以图形形式分享关于你的语料库或特定文档的见解。

3D 向量(或任何低维向量空间)对于机器学习分类问题也非常有用。算法可以通过平面(或超平面)在向量空间中划分不同的类别。

你的语料库中的文档可能会使用更多的词,但是这个特定的主题向量模型只会受到这六个词的使用的影响。只要你的模型只需要根据三个不同的维度或主题来区分文档,你的词汇表可以按你的意愿不断增长。在这个思维实验中,你将六个维度(TF-IDF 规范化频率)压缩为三个维度(主题)。

这种主观的、劳动密集型的语义分析方法依赖于人类的直觉和常识来将文档分解成主题。人类的常识很难编码进算法中。^([4])显然,这种方法不能用于机器学习流程。而且在涉及更多主题和单词时,它的可扩展性也不强。

所以,让我们自动化这个手动的过程。让我们使用一种算法来为我们选择主题权重,而不依赖于常识。

如果你仔细思考一下,这些加权和实际上就是点积。而三个点积(加权和)就是矩阵乘法,或者内积。你需要用一个 TF-IDF 向量(文档中每个单词的值)乘以一个 3 x n 权重矩阵,其中 n 是词汇表中词项的数量,这样的乘法的输出就是该文档的新的 3 x 1 主题向量。你所做的就是将一个向量从一个向量空间(TF-IDFs)转换到另一个低维向量空间(主题向量)。你的算法应该创建一个 n x m 词项-主题矩阵,你可以用该矩阵乘以一个文档中的单词频率向量,以获得该文档的新的主题向量。

4.1.4 评分主题的算法

你仍然需要一种算法来确定这些主题向量,或者从你已经拥有的向量(如 TF-IDF 或词袋向量)中推导出它们。机器无法分辨哪些单词属于一起,或者它们代表着什么,不是吗?20 世纪的英国语言学家 J. R. Firth 研究了你可以估计一个词或词素代表的方式。在 1957 年,他给了你一个关于如何计算词的主题的线索。Firth 写道:

词的本性由它所携带的语境所决定。

— J. R. Firth

1957

那么如何确定一个词的 “公司” 呢?嗯,最直接的方法是在同一文档中统计共现次数。而你在第三章的 BOW 和 TF-IDF 向量中正好拥有所需的内容。这种 “计算共现次数” 的方法导致了开发出一些算法来创建向量来表示文档或句子中单词使用的统计信息。

在接下来的几节中,你将看到两种用于创建这些主题向量的算法。第一种,潜在语义分析(LSA),应用于你的 TF-IDF 矩阵以将单词聚合到主题中。它也适用于词袋向量,但 TF-IDF 向量的效果略好一些。LSA 优化这些主题以保持主题维度的多样性;当你使用这些新主题而不是原始单词时,仍然能捕捉到文档的大部分含义(语义)。你的模型所需的主题数量远远少于 TF-IDF 向量词汇表中的单词数量,因此 LSA 通常被称为一种维度缩减技术。LSA 减少了你需要捕捉文档含义的维度数量。^([6])

我们将要介绍的另一种算法被称为潜在狄利克雷分配,通常缩写为 LDA。因为在本书中我们使用 LDA 来表示潜在判别分析分类器,所以我们将潜在狄利克雷分配简称为 LDiA。

LDiA 将 LSA 的数学带入了不同的方向。它使用非线性统计算法将单词分组在一起。因此,通常比 LSA 之类的线性方法需要更长的训练时间。这使得 LDiA 在许多实际应用中不太实用,并且它很少是你尝试的第一种方法。尽管如此,它创建的主题的统计数据有时更接近人们对单词和主题的直觉。因此,LDiA 的主题通常更容易向你的老板解释。它还更适用于一些单文档问题,如文档摘要。

对于大多数分类或回归问题,通常最好使用 LSA。因此,我们首先解释 LSA 及其基础的 SVD 线性代数。

4.2 挑战:检测毒性

为了看到主题建模的威力,我们将尝试解决一个真实问题:识别维基百科评论中的有毒性。这是当前内容和社交媒体平台面临的常见自然语言处理任务。在本章中,我们将处理一个维基百科讨论评论的数据集,^([7])我们将希望将其分类为两个类别 - 有毒和无毒。首先,让我们加载数据集并查看一下:

第 4.2 节 有毒评论数据集
>>> import pandas as pd
>>> pd.options.display.width = 120  # #1
>>> DATA_DIR = ('https://gitlab.com/tangibleai/nlpia/-/raw/master/'
...             'src/nlpia/data')
>>> url= DATA_DIR + '/toxic_comment_small.csv'
>>>
>>> comments = pd.read_csv(url)
>>> index = ['comment{}{}'.format(i, '!'*j) for (i,j) in
...          zip(range(len(comments)), comments.toxic)
...         ]  # #2
>>> comments = pd.DataFrame(
...     comments.values, columns=comments.columns, index=index)
>>> mask = comments.toxic.astype(bool).values
>>> comments['toxic'] = comments.toxic.astype(int)
>>> len(comments)
5000
>>> comments.toxic.sum()
650
>>> comments.head(6)
                                                        text  toxic
comment0   you have yet to identify where my edits violat...      0
comment1   "\n as i have already said,wp:rfc or wp:ani. (...      0
comment2   your vote on wikiquote simple english when it ...      0
comment3   your stalking of my edits i've opened a thread...      0
comment4!  straight from the smear site itself. the perso...      1
comment5   no, i can't see it either - and i've gone back...      0

所以你有 5,000 条评论,其中 650 条被标记为二进制类别标签“有毒”。

在你深入了解所有复杂的降维技术之前,让我们尝试使用你已经熟悉的消息的向量表示来解决我们的分类问题 - TF-IDF。但是你会选择什么模型来对消息进行分类呢?为了决定,让我们首先看看 TF-IDF 向量。

第 4.3 节 为 SMS 数据集创建 TF-IDF 向量
>>> from sklearn.feature_extraction.text import TfidfVectorizer
>>> import spacy
>>> nlp = spacy.load("en_core_web_sm")
>>>
>>> def spacy_tokenize(sentence):
...    return [token.text for token in nlp(sentence.lower())]
>>>
>>> tfidf_model = TfidfVectorizer(tokenizer=spacy_tokenize)
>>> tfidf_docs = tfidf_model.fit_transform(\
...     raw_documents=comments.text).toarray()
>>> tfidf_docs.shape
(5000, 19169)

spaCy 分词器为您的词汇表提供了 19,169 个单词。您的词汇表中几乎有 4 倍于您的消息的单词数量。而且您的单词数量几乎是有毒评论的 30 倍。因此,您的模型将不会有太多关于表明评论是否有毒的单词的信息。

你在本书中已经至少遇到了一个分类器 - 第二章中的朴素贝叶斯。通常,当您的词汇量远大于数据集中标记示例的数量时,朴素贝叶斯分类器的效果不会很好。所以这次我们需要点不同的东西。

4.2.1 潜在判别分析分类器

在本章中,我们将介绍一种基于称为潜在判别分析(LDA)的算法的分类器。LDA 是您会找到的最简单和最快的分类模型之一,并且它需要的样本比较花哨的算法要少。

LDA 的输入将是一个带有标签的数据 - 因此我们不仅需要表示消息的向量,还需要它们的类别。在这种情况下,我们有两个类别 - 有毒评论和非有毒评论。LDA 算法使用了一些超出本书范围的数学知识,但在两个类别的情况下,其实现非常直观。

本质上,当面临两类问题时,这就是 LDA 算法的工作原理:

  1. 它找到一个线,或者说轴,在您的向量空间中,如果您将空间中的所有向量(数据点)投影到该轴上,两个类别将尽可能地分离。

  2. 它将所有向量投影到那条线上。

  3. 它预测每个向量属于两个类别之一的概率,根据两个类别之间的一个cutoff点。

令人惊讶的是,在大多数情况下,最大化类别分离的线非常接近连接代表每个类别的聚类的两个质心的线。

让我们手动执行这个 LDA 的近似,并看看它在我们的数据集上的表现如何。

>>> mask = comments.toxic.astype(bool).values  # #1
>>> toxic_centroid = tfidf_docs[mask].mean(axis=0)  # #2
>>> nontoxic_centroid = tfidf_docs[~mask].mean(axis=0)  # #3
>>> centroid_axis = toxic_centroid - nontoxic_centroid
>>> toxicity_score = tfidf_docs.dot(centroid_axis)  # #1
>>> toxicity_score.round(3)
array([-0.008, -0.022, -0.014, ..., -0.025, -0.001, -0.022])

特定评论的毒性评分是该评论向量在非有毒评论和非有毒评论之间的线上的投影的长度。您计算这些投影的方法与您对余弦距离所做的计算相同。它是评论向量与从非有毒评论指向有毒评论的向量之间的向量的归一化点积。通过将每个 TF-IDF 向量投影到该线上并使用点积来计算毒性分数。您使用dot()方法一次性进行了这 5000 个点积的“向量化”numpy 操作。与 Python 的for循环相比,这可以加速 100 倍。

在我们的分类中,你只剩下一步了。你需要将我们的分数转换为实际的类预测。理想情况下,你希望你的分数在 0 和 1 之间,就像概率一样。一旦你对分数进行了归一化,你就可以根据一个截止值推断出分类 - 在这里,我们选择了一个简单的 0.5。你可以使用 sklearnMinMaxScaler 来执行归一化:

>>> from sklearn.preprocessing import MinMaxScaler
>>> comments['manual_score'] = MinMaxScaler().fit_transform(\
...     toxicity_score.reshape(-1,1))
>>> comments['manual_predict'] = (comments.manual_score > .5).astype(int)
>>> comments['toxic manual_predict manual_score'.split()].round(2).head(6)
           toxic  manual_predict  manual_score
comment0       0               0          0.41
comment1       0               0          0.27
comment2       0               0          0.35
comment3       0               0          0.47
comment4!      1               0          0.48
comment5       0               0          0.31

看起来不错。前六条消息几乎全部被正确分类了。让我们看看它在其余的训练集上的表现如何。

>>> (1 - (comments.toxic - comments.manual_predict).abs().sum()
...     / len(comments))
0.895...

不错!这个简单的“近似”版本的 LDA 准确地分类了 89.5% 的消息。完整的 LDA 会表现如何?使用 SciKit Learn (sklearn) 来获得最先进的 LDA 实现。

>>> from sklearn import discriminant_analysis
>>> lda_tfidf = discriminant_analysis.LinearDiscriminantAnalysis
>>> lda_tfidf = lda_tfidf.fit(tfidf_docs, comments['toxic'])
>>> comments['tfidf_predict'] = lda_tfidf.predict(tfidf_docs)
>>> float(lda_tfidf.score(tfidf_docs, comments['toxic']))
0.999...

99.9%! 几乎完美的准确率。这意味着你不需要使用更复杂的主题建模算法,比如潜在狄利克雷分配或深度学习吗?这是一个陷阱问题。你可能已经发现了陷阱。这个完美的 99.9% 的结果之所以如此完美,是因为我们没有分离出一个测试集。这个 A+ 分数是在分类器已经“见过”的“问题”上获得的。这就像在学校考试时拿到了和前一天练习的完全相同的问题一样。所以这个模型在恶意评论和垃圾邮件的真实世界中可能表现不佳。

提示

注意你用来训练和进行预测的类方法。sklearn 中的每个模型都有相同的方法:fit()predict()。而且所有的分类器模型甚至都会有一个 predict_proba() 方法,用于给出所有类别的概率分数。这样,当你尝试找到解决机器学习问题的最佳模型算法时,更容易进行不同模型算法的替换。这样你就可以将你的脑力集中在 NLP 工程师的创造性工作上,调整你的模型超参数以在实际世界中发挥作用。

让我们看看我们的分类器在一个更加现实的情况下的表现。你将把你的评论数据集分成两部分 - 训练集和测试集。(你可以想象,在 sklearn 中有一个专门的函数用于此!)然后你将看到分类器在它没有被训练的消息上的表现。

列表 4.4 使用训练-测试拆分的 LDA 模型性能
>>> from sklearn.model_selection import train_test_split
>>> X_train, X_test, y_train, y_test = train_test_split(tfidf_docs,\
...     comments.toxic.values, test_size=0.5, random_state=271828)
>>> lda_tfidf = LDA(n_components=1)
>>> lda = lda_tfidf.fit(X_train, y_train)  # #1
>>> round(float(lda.score(X_train, y_train)), 3)
0.999
>>> round(float(lda.score(X_test, y_test)), 3)
0.554

基于 TF-IDF 的模型的训练集准确率几乎完美。但测试集准确率为 0.55 - 比抛硬币稍微好一点。而测试集准确率才是唯一重要的准确率。这正是主题建模将帮助你的地方。它将允许你从一个小训练集中推广你的模型,使其在使用不同词语组合(但是相似主题)的消息上仍然表现良好。

提示

注意 train_test_split 函数中的 random_state 参数。train_test_split() 函数是随机的。所以每次运行它都会得到不同的结果和不同的准确度值。如果你想要让你的流程可重复,可以查找这些模型和数据集拆分器的 seed 参数。你可以将种子设置为相同的值来获得可再现的结果。

让我们更深入地看一下我们的 LDA 模型的表现,使用一种称为 混淆矩阵 的工具。混淆矩阵将告诉你模型犯错的次数。有两种类型的错误,假阳性 错误和 假阴性 错误。在测试集中标记为有毒的示例上出现的错误称为“假阴性”,因为它们被错误地标记为负面(无毒)并且应该被标记为正面(有毒)。在测试集中标记为非有毒标签上的错误称为“假阳性”,因为它们应该被标记为负面(无毒),但被错误地标记为有毒。下面是使用 sklearn 函数 的方法:

>>> from sklearn.metrics import confusion_matrix
>>> confusion_matrix(y_test, lda.predict(X_test))
array([[1261,  913],
       [ 201,  125]], dtype=int64)

嗯。这里的情况不太清楚。幸运的是,sklearn 考虑到你可能需要一种更直观的方式来向人们展示你的混淆矩阵,并包含了一个专门的函数。让我们试试:

>>> import matplotlib.pyplot as plt
>>> from sklearn.metrics import plot_confusion_matrix
>>> plot_confusion_matrix(lda,X_test, y_test, cmap="Greys",
...                display_labels=['non-toxic', 'toxic'], colorbar=False)
>>> plt.show()

你可以在图 4.2 中看到生成的 matplotlib 图,显示了两个标签(有毒和非有毒)的每个标签的不正确和正确的预测数量。检查这个图表,看看你能否发现你的模型性能有什么问题。

图 4.2 基于 TF-IDF 的分类器的混淆矩阵

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

首先,在实际上是有毒的测试集中的 326 条评论中,模型只能正确识别出 125 条 - 这是 38.3%。这个指标(我们感兴趣的类别中模型能够识别出多少个实例),称为 召回率,或 敏感度。另一方面,模型标记为有毒的 1038 条评论中,只有 125 条是真正有毒的评论。所以“正面”标签在 12% 的情况下才是正确的。这个指标称为 精度。^([9])

你已经可以看到精度和召回率比模型准确度给我们更多的信息。例如,想象一下,如果你决定使用确定性规则而不是使用机器学习模型,并只将所有评论标记为非有毒。由于我们数据集中约有 13% 的评论实际上是有毒的,所以这个模型的准确度将达到 0.87 - 比你上次训练的 LDA 模型要好得多!但是,它的召回率将为 0 - 在我们的任务中完全没有帮助,即识别有毒消息。

您可能也意识到这两个指标之间存在一种权衡。如果您采用另一种确定性规则,并将所有评论标记为有毒呢?在这种情况下,您的召回率将是完美的,因为您将正确分类所有有毒评论。但是,精确度将会下降,因为大多数被标记为有毒的评论实际上是完全正常的。

根据您的用例,您可能会决定优先考虑另一方面的精确度或召回率。但在很多情况下,您希望它们两者都足够好。

在这种情况下,您可能会使用F[1]分数 - 精确度和召回率的调和平均值。较高的精确度和较高的召回率都会导致较高的 F[1]分数,使得只使用一个指标来评估您的模型更容易。

您可以在附录 D 中了解有关分析分类器性能的更多信息。暂时,在我们继续之前,我们将只记录此模型的 F[1]分数。

超越线性

LDA 在许多情况下都会为您服务。然而,当这些假设不被满足时,它仍然有一些假设将导致分类器性能不佳。例如,LDA 假定所有类别的特征协方差矩阵都相同。这是一个相当强的假设!因此,由此造成的结果是,LDA 只能在类别之间学习线性边界。

如果您需要放松这个假设,您可以使用称为二次判别分析或 QDA 的更一般情况的 LDA。QDA 允许不同类别的不同协方差矩阵,并分别估计每个协方差矩阵。这就是为什么它可以学习二次或曲线边界的原因。这使得它更加灵活,并在某些情况下有助于其表现更好。

减少维度

在我们深入了解 LSA 之前,让我们花点时间了解一下它对我们的数据做了什么概念上的事情。LSA 对主题建模的方法背后的想法是降维。顾名思义,降维是一个过程,在这个过程中,我们找到数据的一个低维表示,保留尽可能多的信息。

让我们审视这个定义并理解它的含义。为了让您有直观的理解,让我们暂时远离自然语言处理,并切换到更直观的例子。首先,什么是数据的低维表示?想象一下将一个三维物体(比如你的沙发)表示为二维空间。例如,如果您在黑暗的房间里用光照射在沙发后面,它在墙上的阴影就是它的二维表示。

我们为什么需要这样的表示?可能有很多原因。也许我们没有能力存储或传输完整的数据。或者我们想要可视化我们的数据以更好地理解它。当我们谈论 LDA 时,你已经看到了可视化数据点并将它们聚类的强大能力。但我们的大脑实际上不能处理超过 2 或 3 个维度 - 当我们处理现实世界的数据,特别是自然语言数据时,我们的数据集可能有数百甚至数千个维度。像 PCA 这样的降维工具在我们想要简化和可视化映射我们的数据时非常有用。

另一个重要原因是我们在第三章中简要提到的维度诅咒。稀疏、多维数据更难处理,而在其上训练的分类器更容易过拟合。数据科学家经常使用的一个经验法则是,每个维度至少应该有 5 条记录。我们已经看到,即使对于小型文本数据集,TF-IDF 矩阵也可能迅速扩展到 10 或 20 万个维度。这也适用于许多其他类型的数据。

从“沙发影子”示例中,你可以看到我们可以构建无限多个相同“原始”数据集的低维表示。但有些表示比其他表示更好。在这种情况下,“更好”是什么意思?当谈到视觉数据时,你可以直观地理解,一个可以让我们识别对象的表示比一个不能的表示更好。例如,让我们拿一个从真实对象的 3D 扫描中获取的点云,并将其投影到一个二维平面上。

您可以在图 4.3 中看到结果。你能猜到那个 3D 对象是什么吗?

图 4.3 从下面看实际对象的点云

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

继续我们的“影子”类比,想象一下正午的太阳照射在一群人的头顶上。每个人的影子都是一个圆形斑点。我们能用这些斑点来判断谁高谁矮,或者哪些人头发长吗?可能不行。

现在你明白了良好的降维与能够在新表示中区分不同对象和数据点有关。并不是你数据的所有特征或维度对这个区分过程同样重要。因此,可能有一些特征你可以轻松舍弃而不会丢失太多信息。但对于某些特征,丢失它们将严重影响你理解数据的能力。并且因为你在这里处理的是线性代数,你不仅可以选择留下或包括一个维度 - 你还可以将几个维度组合成一个更小的维度集,以更简洁的方式表示我们的数据。让我们看看我们是如何做到的。

4.3.1 进入主成分分析

你现在知道,为了在更少的维度中找到数据的表示,你需要找到一个维度的组合,能够保持你区分数据点的能力。这将使你能够,例如,将它们分成有意义的聚类。继续上面的阴影例子,一个好的“阴影表示”可以让你看到你的阴影的头在哪里,腿在哪里。它通过保持这些对象之间的高度差异来实现,而不是像“中午的太阳表示”那样“压扁”它们到一个点。另一方面,我们身体的“厚度”从顶部到底部大致是均匀的 - 所以当你看到我们的“扁平”阴影表示时,丢弃了那个维度,你不会像丢弃我们的高度那样丢失太多信息。

在数学中,这种差异被方差所代表。当你想一想的时候,更有方差的特征 - 与平均值的偏离更广泛和更频繁 - 对于你来区分数据点更有帮助是有意义的。

但你可以超越单独观察每个特征。更重要的是特征之间的关系如何。在这里,视觉类比可能开始让你失望,因为我们操作的三个维度彼此正交,因此完全不相关。但让我们回想一下你在上一部分看到的主题向量:“动物性”,“宠物性”,“都市性”。如果你检查这三元组中的每两个特征,就会显而易见地发现一些特征之间的联系更紧密。大多数具有“宠物性”质量的词,也具有一些“动物性”的质量。一对特征或者维度的这种性质被称为协方差。它与相关性密切相关,后者仅仅是将每个特征的协方差归一化为这两个特征的差异。特征之间的协方差越高,它们之间的联系就越紧密 - 因此,它们之间的冗余也更多,因为你可以从一个特征推断出另一个特征。这也意味着你可以找到一个单一的维度,能够保持这两个维度中包含的大部分方差。

总结一下,为了减少描述我们的数据的维数而不丢失信息,您需要找到一种表示,最大化其新轴上的方差,同时减少维度之间的依赖性,并消除具有高协方差的维度。 这正是主成分分析(PCA)所做的。 它找到一组最大化方差的维度。 这些维度是正交的(就像物理世界中的x,yz轴),称为主成分 - 因此得名该方法。 PCA 还允许您查看每个维度“负责”的方差有多少,以便您可以选择保留数据集“本质”的最佳主要成分数量。 然后,PCA 将您的数据投影到一组新坐标中。

在我们深入研究 PCA 如何做到这一点之前,让我们看看魔术是如何发挥作用的。 在下面的清单中,您将使用 Scikit-Learn 的 PCA 方法获取上一页上看到的相同的 3D 点云,并找到一组最大化此点云方差的两个维度。

清单 4.5 PCA 魔法
>>> import pandas as pd
>>> from sklearn.decomposition import PCA
>>> import seaborn
>>> from matplotlib import pyplot as plt

>>> DATA_DIR = ('https://gitlab.com/tangibleai/nlpia/'
...             '-/raw/master/src/nlpia/data')

>>> df = pd.read_csv(DATA_DIR + '/pointcloud.csv.gz', index_col=0)
>>> pca = PCA(n_components=2)  # #1
>>> df2d = pd.DataFrame(pca.fit_transform(df), columns=list('xy'))
>>> df2d.plot(kind='scatter', x='x', y='y')
>>> plt.show()

当你将 3D 点(向量)的维数减少到 2D 时,就像是拍摄了那个 3D 点云的照片。 结果可能看起来像图 4.4 的右边或左边的照片,但它永远不会倾斜或扭曲到新的角度。 x 轴(轴 0)将始终沿着点云点的最长轴对齐,在那里点的分布最广泛。 这是因为 PCA 始终找到将最大化方差的维度,并按照方差递减的顺序排列它们。 具有最高方差的方向将成为第一个轴(x)。 第二高方差的维度在 PCA 变换后成为第二维度(y 轴)。 但是这些轴的极性(符号)是任意的。 优化可以自由地围绕 x 轴或 y 轴镜像(翻转)向量(点),或两者兼而有之。

图 4.4 马头对马头的点云颠倒过来

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

现在我们已经看到了 PCA 是如何工作的^([12]),让我们看看它是如何找到那些允许我们在较少维度中处理数据而不丢失太多信息的主要成分的。

4.3.2 奇异值分解

PCA 的核心是一种称为奇异值分解(SVD)的数学过程^([13])。 SVD 是一种将任何矩阵分解为三个“因子”的算法,可以将这三个矩阵相乘以重新创建原始矩阵。 这类似于为大整数找到确切的三个整数因子。 但是您的因子不是标量整数,而是具有特殊属性的 2D 实数矩阵。

假设我们有一个数据集,由 m 个 n 维点组成,用矩阵 W 表示。在其完整版本中,这就是 W 的 SVD 在数学符号中的样子(假设 m>n):

W[m] [x] [n] = U[m] [x] [m] S[m] [x] [n] V[n] [x] [n]^T

矩阵 U、S 和 V 具有特殊的性质。U 和 V 矩阵是正交的,这意味着如果你将它们乘以它们的转置版本,你将得到一个单位矩阵。而 S 是对角的,意味着它只在对角线上有非零值。

注意这个公式中的等号。它意味着如果你乘以 U、S 和 V,你会得到 完全相同的 W,我们的原始数据集。但是你可以看到我们的矩阵的最小维度仍然是 n。我们不是想要减少维度吗?这就是为什么在这一章中,你将使用 SVD 的版本称为减少,或截断 SVD。这意味着你只需要找到你感兴趣的前 p 个维度。

在这一点上,你可能会说“等等,但我们不能做完整的 SVD 然后只取保留最大方差的维度吗?” 你完全正确,我们可以这样做!然而,使用截断 SVD 还有其他好处。特别是,有几种算法可以快速计算矩阵的截断 SVD 分解,特别是当矩阵是稀疏的时候。稀疏矩阵 是指在其大多数单元格中具有相同值(通常为零或 NaN)的矩阵。NLP 词袋和 TF-IDF 矩阵几乎总是稀疏的,因为大多数文档不包含词汇表中的许多单词。

这就是截断 SVD 的样子:

W[m] [x] [n] ~ U[m] [x] [p] S[p] [x] [p] V[p] [x] [n]^T 在这个公式中,mn 是原始矩阵中的行数和列数,而 p 是您想要保留的维数。例如,在马的例子中,如果我们想要在二维空间中显示马,则 p 将等于二。在下一章中,当您使用 SVD 进行 LSA 时,它将表示您在分析文档时想要使用的主题数。当然,p 需要小于 mn

注意这种情况下的“近似等于”符号 - 因为我们失去了维度,所以当我们乘以我们的因子时,不能期望得到完全相同的矩阵!总会有一些信息丢失。然而,我们所获得的是一种新的表示方式,用比原始表示更少的维度来表示我们的数据。通过我们的马点云,我们现在能够传达其“马”的本质,而无需打印庞大的 3D 图。当 PCA 在现实生活中使用时,它可以将百或千维数据简化为更容易分析、聚类和可视化的短向量。

那么,矩阵 U、S 和 V 有什么用呢?现在,我们将简单地介绍一下它们的作用。在下一章中,我们将深入探讨这些矩阵在 LSA 中的应用。

让我们从V^T开始 - 或者更确切地说,从它的转置版本V开始。V矩阵的列有时被称为主方向,有时被称为主成分。由于本章中使用的 Scikit-Learn 库采用了后一种惯例,我们也将坚持使用这种说法。

你可以将V看作是一个“转换器”工具,用于将你的数据从“旧”空间(在矩阵 W 的“世界”中的表示)映射到新的、低维度的空间。想象一下,我们在我们的 3D 马点云中添加了几个新点,现在想要了解这些新点在我们的 2D 表示中的位置,而不需要重新计算所有点的变换。要将每个新点q映射到其在 2D 图中的位置,你所需要做的就是将其乘以 V:

q̂ = q · V

那么*U · S*的含义是什么呢?通过一些代数技巧,你会发现它实际上是你的数据映射到新空间!基本上,它是你的数据点在新的、更低维度的表示中。

4.4 潜在语义分析

最后,我们可以停止“围绕”,回到主题建模!让我们看看当我们谈论如何在我们的文本数据中找到主题和概念时,你所学到的关于降维、PCA 和 SVD 的一切将开始变得有意义。

让我们从数据集本身开始。你将使用第 4.1 节中用于 LDA 分类器的相同评论语料库,并使用 TF-IDF 将其转换为矩阵。你可能还记得结果被称为术语 - 文档矩阵。这个名字很有用,因为它让你直观地理解了矩阵的行和列包含的内容:行是术语,即你的词汇词;列将是文档。

让我们重新运行列表 4.1 和 4.2 以再次得到我们的 TF-IDF 矩阵。在深入 LSA 之前,我们研究了矩阵的形状:

>>> tfidf_docs.shape
(5000, 19169)

那么这里有什么?一个 19,169 维的数据集,其“空间”由语料库词汇中的术语定义。在这个空间中使用单个向量表示评论是相当麻烦的,因为每个向量中有将近 20,000 个数字 - 比消息本身还要长!而且很难看出消息或其中的句子在概念上是否相似 - 例如,“离开这个页面”和“走开”这样的表达将具有非常低的相似度分数,尽管它们的含义非常接近。因此,在 TF-IDF 矩阵中表示文档的聚类和分类要困难得多。

你还需要注意,你的 5000 条消息中只有 650 条(13%)被标记为有毒。所以你的训练集是不平衡的,约有 8:1 的正常评论和有毒评论(人身攻击、淫秽语言、种族歧视等)。而且你的词汇量很大 - 你的词汇量标记(25172)比你要处理的 4837 条消息(样本)还要多。所以你的词汇表(或词汇)中有很多更多的唯一词,而你的评论数量要少得多,甚至在与有毒消息数量比较时更多。这是一种过拟合的情况。^([15]) 在你的大词汇表中,只有很少的唯一词会被标记为“有毒”词汇在你的数据集中。

过拟合意味着你的词汇表中只会“关键”几个词。所以你的毒性过滤器将依赖于那些毒性词在过滤出来的毒性消息中的位置。如果恶意用户只是使用那些毒性词的同义词,那么他们很容易绕过你的过滤器。如果你的词汇表不包括新的同义词,那么你的过滤器就会误将那些构造巧妙的评论分类为非毒性。

这种过拟合问题是自然语言处理中的固有问题。很难找到一个标记的自然语言数据集,其中包含所有人们可能表达的应该被标记的方式。我们找不到一个“理想”的评论集,其中包含人们说有毒和无毒话的所有不同方式。只有少数几家公司有能力创建这样的数据集。所以我们其他人都需要对过拟合采取“对策”。你必须使用算法,在只有少数几个示例的情况下就能“泛化”得很好。

对抗过拟合的主要措施是将这些数据映射到一个新的、低维空间中。定义这个新空间的是你的语料库以各种方式讨论的加权词汇组合,或者话题。用话题来表示你的消息,而不是具体的词频,会使你的自然语言处理管道更“通用”,并允许我们的垃圾邮件过滤器处理更广泛的消息。这正是 LSA 所做的 - 它找到新的“维度”话题,使方差最大化,使用我们在前一节中发现的 SVD 方法。

这些新话题不一定与我们人类认为的话题相关,比如“宠物”或“历史”。机器不“理解”单词组合的含义,只知道它们在一起。当它经常看到“狗”、“猫”和“爱”这样的词一起出现时,它会把它们放在一个话题中。它不知道这样的话题可能是关于“宠物”的。它可能会在同一个话题中包含很多像“驯养”的词和“野生”的词,它们是彼此的反义词。如果它们在同一份文件中经常出现在一起,LSA 将为它们同时获得高分数。我们人类要看一下哪些词在每个话题中具有较高的权重,并为它们取一个名字。

但是你不必给主题起名字来利用它们。正如你没有分析前几章中你的词袋向量或 TF-IDF 向量中的 1000 多个维度一样,你不必知道你所有主题的 “含义”。你仍然可以使用这些新主题向量进行向量数学运算,就像你使用 TF-IDF 向量一样。你可以将它们相加和相减,并根据它们的 “主题表示” 而不是 “词频表示” 估计文档之间的相似性。而且这些相似性估计将更准确,因为你的新表示实际上考虑了令牌的含义及其与其他令牌的共现。

4.4.1 深入语义分析

但是别光说 LSA 了 - 让我们来写些代码吧!这一次,我们将使用另一个名为TruncatedSVD的 Scikit-Learn 工具,执行 - 有多惊喜 - 我们在上一章中检查过的截断 SVD 方法。我们本可以使用你在上一节看到的PCA模型,但我们选择这种更直接的方法 - 这将使我们更好地理解发生了什么事情 “底层”。此外,TruncatedSVD旨在处理稀疏矩阵,因此它在大多数 TF-IDF 和 BOW 矩阵上表现更好。

我们将从 9232 减少维度到 16 - 后面我们会解释我们选择这个数字的原因。

列表 4.6 使用截断 SVD 进行 LSA
>>> from sklearn.decomposition import TruncatedSVD
>>>
>>> svd = TruncatedSVD(n_components=16, n_iter=100)  # #1
>>> columns = ['topic{}'.format(i) for i in range(svd.n_components)]
>>> svd_topic_vectors = svd.fit_transform(tfidf_docs)  # #2
>>> svd_topic_vectors = pd.DataFrame(svd_topic_vectors, columns=columns,\
...     index=index)
>>> svd_topic_vectors.round(3).head(6)
           topic0  topic1  topic2  ...  topic13  topic14  topic15
comment0    0.121  -0.055   0.036  ...   -0.038    0.089    0.011
comment1    0.215   0.141  -0.006  ...    0.079   -0.016   -0.070
comment2    0.342  -0.200   0.044  ...   -0.138    0.023    0.069
comment3    0.130  -0.074   0.034  ...   -0.060    0.014    0.073
comment4!   0.166  -0.081   0.040  ...   -0.008    0.063   -0.020
comment5    0.256  -0.122  -0.055  ...    0.093   -0.083   -0.074

使用fit-transform方法刚刚生成的是新表示中的文档向量。你不再用 19169 个频率计数来表示你的评论,而是用 16 个。这个矩阵也称为文档-主题矩阵。通过查看列,你可以看到每个主题在每个评论中 “表达” 多少。

我们使用的方法与我们描述的矩阵分解过程有什么关系?你可能已经意识到fit_transform方法返回的正是({U \cdot S}) - 你的 tf-idf 向量投影到新空间。而你的 V 矩阵保存在TruncatedSVD对象的components_变量中。

如果你想探索你的主题,你可以通过检查每个单词或单词组在每个主题中的权重来了解它们 “包含” 多少。

首先让我们为你的转换中的所有维度分配单词。你需要按正确的顺序获取它们,因为你的TFIDFVectorizer将词汇存储为一个字典,将每个术语映射到一个索引号(列号)。

>>> list(tfidf_model.vocabulary_.items())[:5]  # #1
[('you', 18890),
 ('have', 8093),
 ('yet', 18868),
 ('to', 17083),
 ('identify', 8721)]
>>> column_nums, terms = zip(*sorted(zip(tfidf.vocabulary_.values(),
...     tfidf.vocabulary_.keys())))  # #2
>>> terms[:5]
('\n', '\n ', '\n \n', '\n \n ', '\n  ')

现在你可以创建一个漂亮的 Pandas DataFrame,其中包含权重,每一列和每一行的标签都在正确的位置。但是看起来我们的前几个术语只是不同的换行符的组合 - 这并不是很有用!

谁给你提供数据集的人应该更加注意清理它们。让我们使用有用的 Pandas 方法DataFrame.sample()随机查看你的词汇中的一些术语

>>> topic_term_matrix = pd.DataFrame(
...     svd.components_, columns=terms,
...     index=['topic{}'.format(i) for i in range(16)])
>>> pd.options.display.max_columns = 8
>>> topic_term_matrix.sample(5, axis='columns',
...     random_state=271828).head(4)  # #1
...
        littered  unblock.(t•c  orchestra  flanking  civilised
topic0  0.000268      0.000143   0.000630  0.000061   0.000119
topic1  0.000297     -0.000211  -0.000830 -0.000088  -0.000168
topic2 -0.000367      0.000157  -0.001457 -0.000150  -0.000133
topic3  0.000147     -0.000458   0.000804  0.000127   0.000181

这些词都不像是“天生有毒”。让我们看一些我们直觉上认为会出现在“有毒”评论中的词,看看这些词在不同主题中的权重有多大。

>>> pd.options.display.max_columns = 8
>>> toxic_terms = topic_term_matrix[
...     'pathetic crazy stupid idiot lazy hate die kill'.split()
...     ].round(3) * 100  # #1
...
>>> toxic_terms
         pathetic  crazy  stupid  idiot  lazy  hate  die  kill
topic0        0.3    0.1     0.7    0.6   0.1   0.4  0.2   0.2
topic1       -0.2    0.0    -0.1   -0.3  -0.1  -0.4 -0.1   0.1
topic2        0.7    0.1     1.1    1.7  -0.0   0.9  0.6   0.8
topic3       -0.3   -0.0    -0.0    0.0   0.1  -0.0  0.0   0.2
topic4        0.7    0.2     1.2    1.4   0.3   1.7  0.6   0.0
topic5       -0.4   -0.1    -0.3   -1.3  -0.1   0.5 -0.2  -0.2
topic6        0.0    0.1     0.8    1.7  -0.1   0.2  0.8  -0.1
...
>>> toxic_terms.T.sum()
topic0     2.4
topic1    -1.2
topic2     5.0
topic3    -0.2
topic4     5.9
topic5    -1.8
topic6     3.4
topic7    -0.7
topic8     1.0
topic9    -0.1
topic10   -6.6
...

主题 2 和主题 4 似乎更可能包含有毒情绪。而主题 10 则似乎是一个“反有毒”的主题。因此,与毒性相关的词可能对某些主题产生积极影响,对其他主题产生负面影响。没有一个单一明显的有毒主题号。

transform 方法所做的就是将你传递给它的任何内容与 V 矩阵相乘,这个矩阵保存在 components_ 中。你可以查看 TruncatedSVD 的代码来亲眼看看! ^([16])屏幕左上角的链接。

4.4.2 截断 SVD 还是 PCA?

你现在可能会问自己 - 为什么我们在马的例子中使用了 Scikit-Learn 的 PCA 类,但对于评论数据集的主题分析却使用了 TruncatedSVD?难道我们不是说 PCA 基于 SVD 算法吗?

如果你看一下 sklearnPCATruncatedSVD 的实现,你会发现两者之间的大部分代码都是相似的。它们都使用相同的算法来对矩阵进行 SVD 分解。然而,有几个差异可能会使每个模型对某些用例更可取。

最大的区别在于 TruncatedSVD 在分解之前不会居中矩阵,而 PCA 会。这意味着如果你在执行 TruncatedSVD 之前通过减去矩阵的列平均值来居中你的数据,像这样:

>>> tfidf_docs = tfidf_docs - tfidf_docs.mean()

你会得到相同的结果。通过比较对中心化数据的 TruncatedSVD 和 PCA 的结果,自己试试看!

数据被居中是主成分分析(PCA)的某些属性的重要性,你可能还记得,PCA 在自然语言处理之外有很多应用。然而,对于大多数稀疏的 TF-IDF 矩阵来说,居中并不总是有意义的。在大多数情况下,居中会使得一个稀疏矩阵变得稠密,导致模型运行速度变慢,占用更多内存。PCA 经常用于处理稠密矩阵,并且可以计算小矩阵的精确全矩阵奇异值分解(SVD)。相比之下,TruncatedSVD 已经假定输入矩阵是稀疏的,并使用更快的近似随机方法。因此,它比 PCA 更有效地处理您的 TF-IDF 数据。

4.4.3 LSA 在毒性检测中表现如何?

你已经花了足够的时间研究这些主题了 - 现在让我们看看我们的模型如何处理评论的低维表示!你将使用与列表 4.3 中运行的相同代码,但会将其应用于新的 16 维向量。这次,分类将进行得快得多:

>>> X_train_16d, X_test_16d, y_train_16d, y_test_16d = train_test_split(
...     svd_topic_vectors, comments.toxic.values, test_size=0.5,
...     random_state=271828)
>>> lda_lsa = LinearDiscriminantAnalysis(n_components=1)
>>> lda_lsa = lda_lsa.fit(X_train_16d, y_train_16d)
>>> round(float(lda_lsa.score(X_train_16d, y_train_16d)), 3)
0.881
>>> round(float(lda_lsa.score(X_test_16d, y_test_16d)), 3)
0.88

哇,差异如此之大!分类器对 TF-IDF 向量的训练集准确率从 99.9%下降到了 88.1%,但测试集准确率却提高了 33%!这是相当大的进步。

让我们来看看 F1 分数:

>>> from sklearn.metrics import f1_score
>>> f1_score(y_test_16d, lda_lsa.predict(X_test_16d).round(3)
0.342

我们的 F1 分数几乎比 TF-IDF 向量分类时翻了一番!不错。

除非你有完美的记忆力,到现在你一定对滚动或翻页找到之前模型的性能感到很烦恼。当你进行现实的自然语言处理时,你可能会尝试比我们的玩具示例中更多的模型。这就是为什么数据科学家会在超参数表中记录他们的模型参数和性能。

让我们制作自己的超参数表。首先,回想一下在 TF-IDF 向量上运行 LDA 分类器时我们得到的分类性能,并将其保存到我们的表中。

>>> hparam_table = pd.DataFrame()
>>> tfidf_performance = {'classifier': 'LDA',
...                      'features': 'tf-idf (spacy tokenizer)',
...                      'train_accuracy': 0.99 ,
...                      'test_accuracy': 0.554,
...                      'test_precision': 0.383 ,
...                      'test_recall': 0.12,
...                      'test_f1': 0.183}
>>> hparam_table = hparam_table.append(
...     tfidf_performance, ignore_index=True)  # #1

实际上,因为你要提取几个模型的这些分数,所以创建一个执行这项任务的函数是有道理的:

列表 4.7 创建超参数表中记录的函数。
>>> def hparam_rec(model, X_train, y_train, X_test, y_test,
...                model_name, features):
...     return {
...         'classifier': model_name,
...         'features': features,
...         'train_accuracy': float(model.score(X_train, y_train)),
...         'test_accuracy': float(model.score(X_test, y_test)),
...         'test_precision':
...             precision_score(y_test, model.predict(X_test)),
...         'test_recall':
...             recall_score(y_test, model.predict(X_test)),
...         'test_f1': f1_score(y_test, model.predict(X_test))
...         }
>>> lsa_performance = hparam_rec(lda_lsa, X_train_16d, y_train_16d,
...        X_test_16d,y_test_16d, 'LDA', 'LSA (16 components)'))
>>> hparam_table = hparam_table.append(lsa_performance)
>>> hparam_table.T  # #1
                                       0          1
classifier                           LDA        LDA
features        tf-idf (spacy tokenizer)  LSA (16d)
train_accuracy                      0.99     0.8808
test_accuracy                      0.554       0.88
test_precision                     0.383        0.6
test_recall                         0.12   0.239264
test_f1                            0.183   0.342105

你甚至可以进一步将大部分分析包装在一个很好的函数中,这样你就不必再次复制粘贴:

>>> def evaluate_model(X,y, classifier, classifier_name, features):
...     X_train, X_test, y_train, y_test = train_test_split(
...         X, y, test_size=0.5, random_state=271828)
...     classifier = classifier.fit(X_train, y_train)
...     return hparam_rec(classifier, X_train, y_train, X_test,y_test,
...                       classifier_name, features)

4.4.4 降维的其他方法

SVD 是迄今为止最流行的降维数据集的方法,使 LSA 成为你在考虑主题建模时的首选。然而,还有几种其他降维技术可以用来达到相同的目标。并非所有技术都用于自然语言处理,但了解它们也是很好的。我们在这里提到了两种方法- 随机投影非负矩阵分解(NMF)。

随机投影是将高维数据投影到低维空间的方法,以便保留数据点之间的距离。其随机性使得能够在并行计算机上更容易运行。它还允许算法使用更少的内存,因为它不需要像 PCA 那样同时在内存中保存所有数据。并且由于它的计算复杂度较低,随机投影在处理维度非常高的数据集时可以偶尔使用,尤其是在分解速度成为重要因素时。

类似地,NMF 是另一种矩阵因式分解方法,类似于 SVD,但假设数据点和成分都是非负的。它在图像处理和计算机视觉中更常见,但在自然语言处理和主题建模中偶尔也很有用。

在大多数情况下,最好坚持使用 LSA,它在内部使用经过试验的 SVD 算法。

4.5 潜在狄利克雷分配(LDiA)

在本章的大部分时间里,你已经学习了关于潜在语义分析以及使用 Scikit-Learn 将单词和短语的潜在含义表示为向量的各种方法。LSA 应该是大多数主题建模、语义搜索或基于内容的推荐引擎的首选^([18])。它的数学是简单而高效的,并且它产生的线性转换可以应用到新的自然语言批次中,而无需训练,且准确度损失很小。在这里,你将了解一个更复杂的算法,潜在狄利克雷分配,或称为"LDiA"以区别于 LDA,即线性判别分析。在某些情况下,LDiA 将会给你略微更好的结果。

LDiA 做了很多与使用 LSA(和底层的 SVD)创建主题模型相似的事情,但与 LSA 不同的是,LDiA 假设单词频率呈狄利克雷分布。它在将单词分配给主题的统计学方面比 LSA 的线性数学更精确。

LDiA 创建了一个语义向量空间模型(类似于你的主题向量),使用的方法类似于本章早些时候的思维实验中你的大脑是如何工作的。在你的思维实验中,你根据它们在同一篇文档中出现的频率手动将单词分配给主题。然后,文档的主题混合可以通过每个主题中单词混合来确定,这些单词被分配到哪个主题。这使得 LDiA 主题模型更容易理解,因为分配给主题的单词和分配给文档的主题倾向于比 LSA 更有意义。

LDiA 假设每个文档都是一些任意数量的主题(线性组合)的混合,你在开始训练 LDiA 模型时选择这些主题。LDiA 还假设每个主题可以由单词(术语频率)的分布表示。每个文档中这些主题的概率或权重,以及单词被分配给主题的概率,都假设从一个狄利克雷概率分布开始(如果你记得你的统计学,这是先验)。这就是算法得到它名字的地方。

4.5.1 LDiA 的概念

LDiA 方法是在 2000 年由英国的遗传学家们开发的,以帮助他们从基因序列中“推断人群结构”^([19])。斯坦福大学的研究人员(包括安德鲁·吴)于 2003 年将该方法推广应用于 NLP^([20])。但不要被提出这种方法的大人物吓到。我们很快就会用几行 Python 解释它的要点。你只需要理解足够多,以便对它正在做的事情有所感觉(直觉),这样你就知道你可以在管道中使用它做什么。

Blei 和 Ng 通过颠覆您的思维实验的想法提出了这个想法。 他们想象一台机器,除了掷骰子(生成随机数)之外无能为力,可以写出您想要分析的语料库中的文档。 由于您只处理词袋,他们取消了将这些单词组合在一起以产生意义的部分,以编写真实文档的部分。 他们只是模拟了成为每个文档 BOW 一部分的单词混合的统计数据。

他们想象了一台机器,只需做出两个选择,就可以开始生成特定文档的单词混合。 他们想象,文档生成器随机选择这些单词,具有某种可能的选择概率分布,就像选择骰子的面数和要添加到一起以创建 D&D 角色表的骰子的组合一样。 您的文档“角色表”只需要两次掷骰子。 但是骰子很大,而且有几个,关于如何将它们组合在一起以产生您想要的不同值的所需概率的复杂规则。 您希望特定的概率分布适用于单词数量和主题数量,以便它与人类分析的真实文档中的这些值的分布相匹配。

两次掷骰子代表:

  1. 用于文档生成的单词数量(Poisson 分布)

  2. 用于文档混合的主题数量(Dirichlet 分布)

一旦它有了这两个数字,困难的部分就开始了,选择文档的单词。 想象的 BOW 生成机器在那些主题上迭代,并随机选择适合该主题的单词,直到它达到了在第 1 步中决定文档应该包含的单词数量。 决定这些单词对主题的概率-单词对每个主题的适当性-是困难的部分。 但是一旦确定了这一点,您的“机器人”只需从术语-主题概率矩阵中查找每个主题的单词的概率。 如果您忘记了该矩阵的外观,请回顾一下本章早些时候的简单示例。

因此,这台机器所需的一切就是用于 Poisson 分布(在第 1 步的骰子投掷中)的单个参数,该参数告诉它应该是什么“平均”文档长度,以及另外几个参数来定义设置主题数量的 Dirichlet 分布。 然后,您的文档生成算法需要一个术语-主题矩阵,其中包含其喜欢使用的所有单词和主题,即其词汇表。 它还需要一种它喜欢“谈论”的主题混合。

现在我们将文档生成(写作)问题反过来,回到你最初的问题,即从现有文档中估计主题和单词。您需要测量或计算前两步的有关单词和主题的参数。然后,您需要从一组文档中计算出术语-主题矩阵。这就是 LDiA 的作用。

Blei 和 Ng 意识到,可以通过分析语料库中文档的统计数据来确定步骤 1 和步骤 2 的参数。例如,针对步骤 1,他们可以计算他们语料库中所有文档的单词(或 n-grams)袋子中的平均数量,类似于这样:

>>> total_corpus_len = 0
>>> for document_text in comments.text:
...     total_corpus_len += len(spacy_tokenize(document_text))
>>> mean_document_len = total_corpus_len / len(sms)
>>> round(mean_document_len, 2)
21.35

或者,使用 sum 函数:

>>> sum([len(spacy_tokenize(t)) for t in comments.text]
...     ) * 1\. / len(comments.text)
21.35

请注意,您应直接从 BOW 中计算此统计数据。您需要确保计算您文档中的标记化和向量化单词。在计算您的唯一术语之前,请确保您已经应用了任何停用词过滤或其他标准化。这样,您的计数将包括您的 BOW 向量词汇表中的所有单词(您正在计算的全部 n-grams),但仅包括您的 BOWs 使用的单词(例如不包括停用词)。与将 TF-IDF 矩阵作为输入的 LSA 不同,此 LDiA 算法依赖于词袋向量空间模型。

对于 LDiA 模型的第二个需要指定的参数——主题数——有点棘手。在分配单词到这些主题之后,您才能直接测量特定文档集中的主题数。与 k-meansKNN 等其他聚类算法一样,您必须事先告诉它 k。您可以猜测主题数(类似于 k-means 中的 k,即“簇”的数量),然后检查它是否适用于文档集。告诉 LDiA 要查找多少个主题后,它将找到每个主题中要放入的单词组合,以优化其目标函数。

您可以通过调整“超参数”(主题数 k)来优化此参数,直到适合您的应用程序为止。如果您可以衡量 LDiA 语言模型在表示文档含义方面的质量的某些方面,则可以自动化此优化。您可以使用一些分类或回归问题(如情感分析、文档关键字标记或主题分析)中 LDiA 模型的执行情况作为此优化的 “成本函数”。您只需要一些标记过的文档来测试您的主题模型或分类器。

4.5.2 评论的 LDiA 主题模型

LDiA 产生的主题更易于人理解和“解释”。这是因为经常一起出现的单词被分配到相同的主题,而人们期望是这种情况。LSA 尝试保持原本分开的事物的分散程度,而 LDiA 则试图保持原本在一起的事物的接近程度。

这听起来可能是相同的事情,但它并不是。数学优化不同。你的优化器有不同的目标函数,因此它将达到不同的目标。为了保持高维向量在低维空间中靠得很近,LDiA 必须以非线性的方式扭曲和变形空间(和向量)。这是一个难以想象的事情,除非你在 3D 物体上做了这个操作,并在 2D 中取“投影”的结果向量。

让我们看看如何将一个标记为垃圾邮件的几千个评论的数据集应用于此。首先计算 TF-IDF 向量,然后为每个短信信息(文档)计算一些主题向量。与先前一样,我们假设使用仅 16 个主题(组件)来分类信息的垃圾邮件。保持主题数量(维度)较低可以有助于减少过拟合。^([23])

LDiA 使用的是原始 BOW 计数向量而不是归一化的 TF-IDF 向量。你已经在第三章中完成了这个过程:

>>> from sklearn.feature_extraction.text import CountVectorizer
>>>
>>> counter = CountVectorizer(tokenizer=spacy_tokenize)
>>> bow_docs = pd.DataFrame(counter.fit_transform(
raw_documents=comments.text)\
...     .toarray(), index=index)
>>> column_nums, terms = zip(*sorted(zip(counter.vocabulary_.values(),
...     counter.vocabulary_.keys())))
>>> bow_docs.columns = terms

让我们仔细检查一下第一个带有“comment0”标签的评论的计数是否正确:

>>> comments.loc['comment0'].text
'you have yet to identify where my edits violated policy.
 4 july 2005 02:58 (utc)'
>>> bow_docs.loc['comment0'][bow_docs.loc['comment0'] > 0].head()
         1
(        1
)        1
.        1
02:58    1
Name: comment0, dtype: int64

我们将在计数向量矩阵上应用潜在狄利克雷分配,方式与我们在 TF-IDF 矩阵上应用 LSA 相同:

>>> from sklearn.decomposition import LatentDirichletAllocation as LDiA

>>> ldia = LDiA(n_components=16, learning_method='batch')
>>> ldia = ldia.fit(bow_docs)  # #1
>>> ldia.components_.shape
(16, 19169)

因此,你的模型已经将 19,169 个单词(术语)分配到了 16 个主题(组件)。让我们看一下前几个单词及其分配情况。请记住,你的计数和主题与我们不同。LDiA 是一种依赖于随机数生成器进行某些关于将单词分配给主题的统计决策的随机算法。因此,每次运行sklearn.LatentDirichletAllocation(或任何 LDiA 算法)时,除非你将随机种子设置为固定值,否则你将得到不同的结果。

>>> pd.set_option('display.width', 75)
>>> term_topic_matrix = pd.DataFrame(ldia.components_, index=terms,\
...     columns=columns)  # #1
>>> term_topic_matrix.round(2).head(3)
                          topic0  topic1  ...  topic14  topic15
a                         21.853   0.063  ...    0.063  922.515
aaaaaaaaaahhhhhhhhhhhhhh   0.063   0.063  ...    0.063    0.063
aalst                      0.063   0.063  ...    0.063    0.063
aap                        0.063   0.063  ...    2.062    0.062

看起来 LDiA 主题向量中的值的分布比 LSA 主题向量中的值要高得多-有很多接近零的值,但也有一些非常大的值。让我们做与 LSA 进行主题建模时所做的相同技巧。我们可以查看典型的“有毒”词语,并查看它们在每个主题中的显著程度。

>>> toxic_terms= components.loc['pathetic crazy stupid lazy idiot hate die kill'.split()].round(2)
>>> toxic_terms
          topic0  topic1  topic2  ...  topic13  topic14  topic15
pathetic    1.06    0.06   32.35  ...     0.06     0.06     9.47
crazy       0.06    0.06    3.82  ...     1.17     0.06     0.06
stupid      0.98    0.06    4.58  ...     8.29     0.06    35.80
lazy        0.06    0.06    1.34  ...     0.06     0.06     3.97
idiot       0.06    0.06    6.31  ...     0.06     1.11     9.91
hate        0.06    0.06    0.06  ...     0.06   480.06     0.06
die         0.06    0.06   26.17  ...     0.06     0.06     0.06
kill        0.06    4.06    0.06  ...     0.06     0.06     0.06

这与我们有毒术语的 LSA 表示非常不同!似乎某些术语在某些主题中具有高的主题词权重,但在其他主题中没有。topic0topic1对有毒术语似乎非常“冷淡”,而topic2topic15至少对 4 或 5 个有毒术语具有很大的主题词权重。而topic14对词语hate的权重非常高!

让我们看看这个主题中的其他高分词。正如你之前看到的,因为我们没有对数据集进行任何预处理,很多术语并不是很有趣。让我们关注单词,而且长度大于 3 个字母的术语-这将消除很多停用词。

>>> non_trivial_terms = [term for term in components.index
                            if term.isalpha() and len(term)>3]
components.topic14.loc[non_trivial_terms].sort_values(ascending=False)[:10]
hate         480.062500
killed        14.032799
explosion      7.062500
witch          7.033359
june           6.676174
wicked         5.062500
dead           3.920518
years          3.596520
wake           3.062500
arrived        3.062500

看起来主题中的许多词之间有语义关系。像"killed"和"hate",或者"wicked"和"witch"这样的词,似乎属于"toxic"领域。您可以看到词语分配到主题的方式是可以理解或推理的,即使只是一个快速的看一眼。

在您拟合分类器之前,您需要计算所有文档(评论)的这些 LDiA 主题向量。让我们看看它们与相同文档的 LSA 产生的主题向量有什么不同。

>>> ldia16_topic_vectors = ldia.transform(bow_docs)
>>> ldia16_topic_vectors = pd.DataFrame(ldia16_topic_vectors,\
...     index=index, columns=columns)
>>> ldia16_topic_vectors.round(2).head()
           topic0  topic1  topic2  ...  topic13  topic14  topic15
comment0      0.0     0.0    0.00  ...     0.00      0.0      0.0
comment1      0.0     0.0    0.28  ...     0.00      0.0      0.0
comment2      0.0     0.0    0.00  ...     0.00      0.0      0.0
comment3      0.0     0.0    0.00  ...     0.95      0.0      0.0
comment4!     0.0     0.0    0.07  ...     0.00      0.0      0.0

您可以看到这些主题更清晰地分开了。在您将主题分配给消息时有很多零。这是 LDiA 主题更容易向同事解释的一点,这样他们就可以基于您的 NLP 管道结果做出商业决策。

那么 LDiA 主题对人类来说效果很好,但是对机器呢?你的 LDA 分类器在这些主题下会有怎样的表现?

4.5.3 使用 LDiA 检测毒性

让我们看看这些 LDiA 主题在预测一些有用的东西,比如评论毒性方面有多好。您将再次使用 LDiA 主题向量来训练一个 LDA 模型(就像您两次使用 TF-IDF 向量和 LSA 主题向量那样)。由于您在列表 4.5 中定义的方便函数,您只需要几行代码来评估您的模型:

>>> model_ldia16 = LinearDiscriminantAnalysis()
>>> ldia16_performance=evaluate_model(ldia16_topic_vectors,
       comments.toxic,model_ldia16, 'LDA', 'LDIA (16 components)')
>>> hparam_table = hparam_table.append(ldia16_performance,
...    ignore_index = True)
>>> hparam_table.T
                                       0          1          2
classifier                           LDA        LDA        LDA
features        tf-idf (spacy tokenizer)  LSA (16d) LDIA (16d)
train_accuracy                      0.99     0.8808     0.8688
test_accuracy                      0.554       0.88     0.8616
test_precision                     0.383        0.6   0.388889
test_recall                         0.12   0.239264   0.107362
test_f1                            0.183   0.342105   0.168269

看起来,在 16 个主题 LDIA 向量上的分类性能比没有主题建模的原始 TF-IDF 向量要差。这是否意味着在这种情况下 LDiA 是无用的?让我们不要太早放弃它,试着增加主题数量。

4.5.4 更公平的比较:32 个 LDiA 主题

让我们再试一次,用更多的维度,更多的主题。也许 LDiA 不像 LSA 那样有效,所以它需要更多的主题来分配词。让我们试试 32 个主题(组件)。

>>> ldia32 = LDiA(n_components=32, learning_method='batch')
>>> ldia32 = ldia32.fit(bow_docs)
>>> model_ldia32 = LinearDiscriminantAnalysis()
>>> ldia32_performance =evaluate_model(ldia32_topic_vectors,
...          comments.toxic, model_ldia32, 'LDA', 'LDIA (32d)')
>>> hparam_table = hparam_table.append(ldia32_performance,
...           ignore_index = True)
>>> hparam_table.T
                                       0          1          2           3
classifier                           LDA        LDA        LDA         LDA
features        tf-idf (spacy tokenizer)  LSA (16d) LDIA (16d)  LDIA (32d)
train_accuracy                      0.99     0.8808     0.8688      0.8776
test_accuracy                      0.554       0.88     0.8616      0.8796
test_precision                     0.383        0.6   0.388889    0.619048
test_recall                         0.12   0.239264   0.107362    0.199387
test_f1                            0.183   0.342105   0.168269    0.301624

很好!增加 LDIA 的维度几乎使模型的精确度和召回率翻了一番,我们的 F1 得分看起来好多了。更多的主题使 LDIA 在主题上更加精确,并且,至少对于这个数据集来说,产生了更好地线性分离主题的主题。但是这些向量表示的性能仍然不及 LSA。所以 LSA 让你的评论主题向量更有效地分散,允许在用超平面分隔类别时有更大的间隙。

随意探索 Scikit-Learn 和gensim中提供的狄利克雷分配模型的源代码。它们具有与 LSA(sklearn.TruncatedSVDgensim.LsiModel)类似的 API。我们将在后面的章节中讨论总结时向您展示一个示例应用程序。找到可解释的主题,比如用于总结的主题,是 LDiA 擅长的。而且它在创建用于线性分类的主题方面也不错。

提示

您之前看到了如何从文档页面浏览所有“sklearn”的源代码。但是,您甚至可以通过 Python 控制台更简单地执行此操作。您可以在任何 Python 模块上找到__file__属性中的源代码路径,例如sklearn.__file__。在ipythonjupyter console)中,您可以使用??查看任何函数、类或对象的源代码,例如LDA??

>>> import sklearn
>>> sklearn.__file__
'/Users/hobs/anaconda3/envs/conda_env_nlpia/lib/python3.6/site-packages/skl
earn/__init__.py'
>>> from sklearn.discriminant_analysis\
...     import LinearDiscriminantAnalysis as LDA
>>> LDA??
Init signature: LDA(solver='svd', shrinkage=None, priors=None, n_components
=None, store_covariance=False, tol=0.0001)
Source:
class LinearDiscriminantAnalysis(BaseEstimator, LinearClassifierMixin,
                                 TransformerMixin):
    """Linear Discriminant Analysis

    A classifier with a linear decision boundary, generated by fitting
    class conditional densities to the data and using Bayes' rule.

    The model fits a Gaussian density to each class, assuming that all
    classes share the same covariance matrix."""
...

这对于函数和类的扩展不起作用,其源代码隐藏在编译后的 C++ 模块中。

4.6 距离和相似度

我们需要重新审视第二章和第三章中谈到的那些相似性分数,以确保您的新主题向量空间与它们配合良好。请记住,您可以使用相似性分数(和距离)来根据您用于表示它们的向量的相似性(或距离)来判断两个文档的相似程度或相距多远。

您可以使用相似性分数(和距离)来查看您的 LSA 主题模型与第三章中更高维度的 TF-IDF 模型的一致性。您将看到您的模型在消除了许多包含在更高维度词袋中的信息后保留了多少距离。您可以检查主题向量之间的距离以及是否这是文档主题之间距离的良好表示。您希望检查意思相似的文档是否在您的新主题向量空间中彼此靠近。

LSA 保留了较大的距离,但并不总是保留较近的距离(您的文档之间关系的细微“结构”)。底层的 SVD 算法旨在最大化新主题向量空间中所有文档之间的方差。

特征向量(词向量、主题向量、文档上下文向量等)之间的距离驱动着 NLP 流水线或任何机器学习流水线的性能。那么,在高维空间中测量距离的选择有哪些呢?对于特定的 NLP 问题,应该选择哪些呢?其中一些常用的例子可能在几何课程或线性代数中很熟悉,但许多其他例子可能对您来说是新的:

  • 欧几里得距离或笛卡尔距离,或均方根误差(RMSE):2-范数或 L[2]

  • 平方欧几里得距离,平方和距离(SSD):L[2]²

  • 余弦距离或角距离或投影距离:归一化点积

  • 闵可夫斯基距离:p-范数或 L[p]

  • 分数距离,分数范数:p-范数或 L[p],其中 0 < p < 1

  • 城市街区距离,曼哈顿距离或出租车距离,绝对距离之和(SAD):1-范数或 L[1]

  • Jaccard 距离,逆集相似度

  • 马氏距离

  • Levenshtein 或编辑距离

计算距离的多种方法表明了它的重要性。除了 Scikit-Learn 中的成对距离实现之外,还有许多其他方法在数学专业中被使用,如拓扑学、统计学和工程学。^([24]) 供参考,以下是在sklearn.metrics模块中可以计算距离的所有方法:^([25])

列出 4.8 sklearn 中可用的成对距离
'cityblock', 'cosine', 'euclidean', 'l1', 'l2', 'manhattan', 'braycurtis',
'canberra', 'chebyshev', 'correlation', 'dice', 'hamming', 'jaccard',
'kulsinski', 'mahalanobis', 'matching', 'minkowski', 'rogerstanimoto',
'russellrao', 'seuclidean', 'sokalmichener', 'sokalsneath', 'sqeuclidean',
'yule'

距离度量通常是从相似度度量(分数)计算的,反之亦然,以便距离与相似度分数成反比。相似度分数的设计范围在 0 到 1 之间。典型的转换公式如下:

>>> similarity = 1\. / (1\. + distance)
>>> distance = (1\. / similarity) - 1.

但是对于范围在 0 到 1 之间的距离和相似度分数,例如概率,更常见的是使用以下公式:

>>> similarity = 1\. - distance
>>> distance = 1\. - similarity

余弦距离具有其自己的值范围约定。两个向量之间的角距离通常被计算为两个向量之间可能的最大角度分离的一部分,即pi弧度或 180 度。^([26]) 因此,余弦相似度和距离是彼此的倒数:

>>> import math
>>> angular_distance = math.acos(cosine_similarity) / math.pi
>>> distance = 1\. / similarity - 1.
>>> similarity = 1\. - distance

为什么我们要花这么多时间谈论距离呢?在本书的最后一节中,我们将讨论语义搜索。语义搜索的理念是找到与您的搜索查询具有最高语义相似性或最低语义距离的文档。在我们的语义搜索应用中,我们将使用余弦相似度 - 但正如您在最后两页中所看到的,有多种方法来衡量文档的相似程度。

4.7 带反馈的引导

所有先前的语义分析方法都未考虑文档之间相似性的信息。我们创建了一个适用于一般规则的最佳主题。我们对这些模型进行了无监督学习以提取特征(主题),没有关于主题向量应该彼此有多“接近”的数据。我们不允许任何关于主题向量最终位置或它们彼此之间的关系的“反馈”。

舵机或“学习距离度量”^([27])是降维和特征提取的最新进展。通过调整报告给聚类和嵌入算法的距离分数,您可以“引导”您的向量,使它们最小化某些成本函数。通过这种方式,您可以强制您的向量专注于您感兴趣的信息内容的某些方面。

在关于 LSA 的先前章节中,您忽略了关于您的文档的所有元信息。例如,对于您忽略了消息发送者的评论。这是主题相似性的一个很好的指标,可以用来通知您的主题向量转换(LSA)。

在 Talentpair,我们尝试使用每个文档的主题向量之间的余弦距离将简历与工作描述相匹配。这样做效果还不错。但我们很快发现,当我们开始根据候选人和负责帮助他们找工作的客户经理的反馈来“引导”我们的主题向量时,我们得到了更好的结果。与“好配对”的向量比其他配对的向量更加接近。

一种做法是计算你两个质心之间的平均差异(就像你为 LDA 做的那样),并将这种“偏差”的一部分添加到所有简历或工作描述向量中。这样做应该可以消除简历和工作描述之间的平均主题向量差异。工作描述中可能会出现的主题,如午餐时的生啤可能永远不会出现在简历中。类似地,一些简历中可能会出现奇特的爱好,如水下雕塑,但从不会出现在工作描述中。引导你的主题向量可以帮助你将它们聚焦在你感兴趣建模的主题上。

4.8 主题向量功效

借助主题向量,你可以比较单词、文档、语句和语料库的含义。你可以找到相似文档和语句的“聚类”。你不再只根据单词的使用情况来比较文档之间的距离。你不再局限于基于词语选择或词汇的关键字搜索和相关性排名。你现在可以找到与你的查询相关的文档,而不仅仅是与单词统计本身匹配的文档。

这被称为“语义搜索”,不要与“语义网”混淆。^([28]) 当强大的搜索引擎给你提供一些不包含查询中很多单词的文档时,这就是语义搜索,但这些文档正是你正在寻找的内容。这些先进的搜索引擎使用 LSA 主题向量来区分“The Cheese Shop”的Python包与佛罗里达宠物店水族馆中的一只蟒蛇,同时还能识别其与“Ruby gem”的相似性。^([29])

语义搜索为您提供了一种查找和生成有意义的文本的工具。但是我们的大脑不擅长处理高维对象、向量、超平面、超球面和超立方体。我们作为开发者和机器学习工程师的直觉在三个以上的维度上崩溃。

例如,在 Google 地图上进行 2D 向量查询,比如您的纬度/经度位置,您可以很快找到附近的所有咖啡店而无需进行太多的搜索。您可以使用肉眼或代码进行扫描,沿着搜索外螺旋向外扩展。或者,您可以使用代码创建越来越大的边界框,检查每个边界框上的经度和纬度是否在某个范围内,这仅用于比较操作,并应该找到附近的所有东西。

然而,用超平面和超立方体作为搜索的边界来分割高维向量空间(超空间)是不切实际的,在许多情况下是不可能的。

正如 Geoffry Hinton 所说:“在一个 14 维空间中处理超平面,将一个 3D 空间可视化,然后对自己说 14。”如果你年轻且容易受影响时读过 Abbott 1884 年的《Flatland》,你可能能比这种手势更好理解。“Flatland”中,你用了很多二维可视化来帮助你探索单词在超空间中在你的三维世界中留下的影子。如果你急于查看它们,请跳到显示单词向量的“散点矩阵”部分。你可能还想回顾一下上一章中的三维词袋向量,并尝试想象一下,如果你再增加一个词汇来创建一个四维的语义世界,那些点会是什么样子。

如果你在深思四维空间的事情,必须要记住,你试图理解的复杂性爆炸比从二维到三维的复杂性增长要大,而且是指数级别大于从数字的一维世界到三角形、正方形和圆形的二维世界的复杂性增长。

4.8.1 语义搜索

当你根据文档中包含的单词或部分单词搜索文档时,这被称为全文搜索。这就是搜索引擎的工作原理。它们将文档分成可以使用反向索引索引的块(通常是单词),就像你在教科书后面找到的索引一样。处理拼写错误和打字错误需要大量的簿记和猜测,但效果还不错。^([30])

语义搜索是全文搜索,它考虑了查询中的单词和被搜索的文档的含义。在本章中,你学会了两种方法——LSA 和 LDiA——来计算捕捉单词和文档语义(意义)的主题向量。潜在语义分析首先被称为潜在语义索引的原因之一是因为它承诺以数字值的索引(如 BOW 和 TF-IDF 表)来帮助语义搜索。语义搜索是信息检索领域的下一个重大突破。

但与 BOW 和 TF-IDF 表不同,语义向量表不能使用传统的倒排索引技术轻松离散化和索引。传统的索引方法适用于二进制单词出现向量、离散向量(BOW 向量)、稀疏连续向量(TF-IDF 向量)和低维连续向量(3D GIS 数据)。但高维连续向量,如 LSA 或 LDiA 的主题向量,是一个挑战。倒排索引适用于离散向量或二进制向量,例如二进制或整数词-文档向量表,因为索引只需要为每个非零离散维度维护一个条目。该维度的值在引用的向量或文档中存在或不存在。由于 TF-IDF 向量是稀疏的,大多数为零,您不需要为大多数文档的大多数维度在索引中添加条目。

LSA(和 LDiA)产生高维、连续且密集的主题向量(零很少)。语义分析算法不会产生可扩展搜索的高效索引。事实上,你在前一节谈到的维度诅咒使得精确索引成为不可能。潜在语义索引的“索引”部分是一种希望,而不是现实,因此 LSI 术语是一个误称。也许这就是为什么 LSA 已成为描述产生主题向量的语义分析算法的更流行方式。

解决高维向量挑战的一种方法是使用局部敏感哈希(LSH)对其进行索引。局部敏感哈希就像一个邮政编码,指定了一个超空间区域,以便稍后可以轻松找到。而且像常规哈希一样,它是离散的,仅取决于向量中的值。但即使如此,一旦超过约 12 个维度,这也不会完美地工作。在图 4.6 中,每行代表一个主题向量大小(维度),从 2 维开始,一直到 16 维,就像您之前用于短信垃圾邮件问题的向量一样。

图 4.5 语义搜索准确性在约 12-D 处下降

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

表格显示了如果您使用局部敏感哈希对大量语义向量进行索引,您的搜索结果将有多好。一旦您的向量超过 16 维,您将很难返回任何好的 2 个搜索结果。

那么,如何在 100 维向量上进行语义搜索而不使用索引呢?现在你知道如何使用 LSA 将查询字符串转换为主题向量。你也知道如何使用余弦相似度分数(标量乘积、内积或点积)来比较两个向量的相似性,以找到最接近的匹配项。要找到精确的语义匹配项,你需要找到与特定查询(搜索)主题向量最接近的所有文档主题向量(在专业术语中,它被称为穷举搜索)。但是如果你有n个文档,你必须对你的查询主题向量进行n次比较。这是很多点积。

你可以使用矩阵乘法在numpy中对操作进行向量化,但这并不会减少操作次数,只会使其快 100 倍。^([33]) 从根本上讲,精确的语义搜索仍然需要对每个查询进行O(N)次乘法和加法运算。因此,它的规模只会随着语料库的大小呈线性增长。这对于大型语料库,比如谷歌搜索或者维基百科语义搜索来说是行不通的。

关键是要接受“足够好”的结果,而不是为我们的高维向量追求完美的索引或 LSH 算法。现在有几种开源实现了一些高效准确的近似最近邻算法,它们使用 LSH 来有效地实现语义搜索。我们将在第十章中进一步讨论它们。

从技术上讲,这些索引或哈希解决方案不能保证您将为您的语义搜索查询找到所有最佳匹配项。但是,如果你愿意放弃一点精度,它们可以几乎与 TF-IDF 向量或词袋向量上的传统反向索引一样快地为你提供一个良好的近似匹配项列表。^([34])

4.9 为你的机器人配备语义搜索

让我们利用你在主题建模方面新获得的知识来改进你在上一章中开始构建的机器人。我们将专注于相同的任务 - 问答。

我们的代码实际上会与第三章中的你的代码非常相似。我们仍然会使用向量表示来找到数据集中最相似的问题。但是这次,我们的表示将更接近于表示这些问题的含义。

首先,让我们像上一章那样加载问题和答案数据

>>> REPO_URL = 'https://gitlab.com/tangibleai/qary/-/raw/master'
>>> FAQ_DIR = 'src/qary/data/faq'
>>> FAQ_FILENAME = 'short-faqs.csv'
>>> DS_FAQ_URL = '/'.join([REPO_URL, FAQ_DIR, FAQ_FILENAME])

>>> df = pd.read_csv(DS_FAQ_URL)

下一步是将问题和我们的查询都表示为向量。这就是我们需要添加一些内容来使我们的表示具有语义的地方。因为我们的问题数据集很小,所以我们不需要应用 LSH 或任何其他索引算法。

>>> vectorizer = TfidfVectorizer()
>>> vectorizer.fit(df['question'])
>>> tfidfvectors = vectorizer.transform(df['question'])
>>> svd = TruncatedSVD(n_components=16, n_iterations=100)
>>> tfidfvectors_16d = svd.fit_transform(tfidfvectors)
>>>
>>> def bot_reply(question):
...       question_tfidf = vectorizer.transform([question]).todense()
...       question_16d = svd.transform(question_tfidf)
...       idx = question_16d.dot(tfidfvectors_16d.T).argmax()
...       print(
...            f"Your question:\n  {question}\n\n"
...            f"Most similar FAQ question:\n  {df['question'][idx]}\n\n"
...            f"Answer to that FAQ question:\n  {df['answer'][idx]}\n\n"
...           )

让我们对我们的模型进行健全性检查,确保它仍然能够回答简单的问题:

>>> bot_reply("What's overfitting a model?")
Your question:
  What's overfitting a model?
Most similar FAQ question:
  What is overfitting?
Answer to that FAQ question:
  When your test set accuracy is significantly lower than your training
   set accuracy.

现在,让我们给我们的模型一个更难的问题 - 就像我们之前的模型处理不好的那个问题一样。它能做得更好吗?

>>> bot_reply("How do I decrease overfitting for Logistic Regression?")
Your question:
  How do I decrease overfitting for Logistic Regression?
Most similar FAQ question:
  How to reduce overfitting and improve test set accuracy for a
   LogisticRegression model?
Answer to that FAQ question:
  Decrease the C value, this increases the regularization strength.

哇!看起来我们的新版本机器人能够“意识到”'decrease’和’reduce’有相似的含义。不仅如此,它还能“理解”'Logistic Regression’和“LogisticRegression”非常接近 - 对于我们的 TF-IDF 模型来说,这样简单的步骤几乎是不可能的。

看起来我们正在接近建立一个真正健壮的问答系统。在下一章中,我们将看到如何做得比主题建模更好!

4.10 接下来是什么?

在接下来的章节中,您将学习如何微调主题向量的概念,以便与单词相关联的向量更加精确和有用。为此,我们首先开始学习神经网络。这将提高您的管道从短文本甚至孤立单词中提取含义的能力。

4.11 自我测试

  • 为了更高效地使用 LDiA 进行主题建模,您会使用哪些预处理技术?LSA 呢?

  • 您能想到一个数据集/问题,TF-IDF 表现比 LSA 更好吗?相反呢?

  • 我们提到过过滤停用词作为 LDiA 的预处理过程。在什么情况下,这种过滤会有益处?

  • 语义搜索的主要挑战是,密集的 LSA 主题向量无法逆向索引。你能解释为什么吗?

4.12 总结

  • 您可以通过分析数据集中术语的共现来推导您的单词和文档的含义。

  • SVD 可用于语义分析,将 TF-IDF 和 BOW 向量分解和转换为主题向量。

  • 超参数表可用于比较不同管道和模型的性能。

  • 当您需要进行可解释的主题分析时,请使用 LDiA。

  • 无论您如何创建主题向量,都可以利用语义搜索来查找基于其含义的文档。

在这一章关于主题分析中,我们使用术语“主题向量”,在第六章关于 Word2vec 中,我们使用术语“词向量”。像 Jurafsky 和 Martin 的《NLP 圣经》(web.stanford.edu/~jurafsky/slp3/ed3book.pdf#chapter.15:)这样的正式 NLP 文本使用“主题向量”。其他人,比如《语义向量编码和相似性搜索》的作者(arxiv.org/pdf/1706.00957.pdf:),则使用“语义向量”一词。

短语还是词元化都会去除或改变单词的词尾和前缀,即单词的最后几个字符。编辑距离计算更适合识别拼写相似(或拼写错误)的单词。

我喜欢用 Google Ngram Viewer 可视化趋势,比如这个:(mng.bz/ZoyA)。

斯坦福的 Doug Lenat 正在尝试将常识编码到算法中。请参阅《Wired Magazine》文章《Doug Lenat’s Artificial Intelligence Common Sense Engine》(www.wired.com/2016/03/doug-lenat-artificial-intelligence-common-sense-engine)。

[5] 语素 是一个单词的最小有意义的部分。参见维基百科上的“语素”文章(en.wikipedia.org/wiki/Morpheme)。

[6] 主题模型的维基百科页面有一个视频,展示了 LSA 背后的直觉。mng.bz/VRYW

[7] 这个数据集的较大版本是 2017 年 Kaggle 竞赛的基础(www.kaggle.com/c/jigsaw-toxic-comment-classification-challenge),由 Jigsaw 在 CC0 许可下发布。

[8] 簇的质心是一个点,其坐标是该簇中所有点的坐标的平均值。

[9] 要对精确率和召回率有更直观的了解,可以参考维基百科的文章(en.wikipedia.org/wiki/Precision_and_recall),其中有一些良好的可视化。

[10] 您可以阅读关于在某些情况下使用 F [1]分数以及替代指标的维基百科文章:en.wikipedia.org/wiki/F-score

[11] 您可以在 Scikit-Learn 文档中看到两个估算器的视觉示例:scikit-learn.org/dev/modules/lda_qda.html

[12] 要更深入地了解降维,可以查看 Hussein Abdullatif 的这篇四部曲文章系列:mng.bz/RlRv

[13] 实际上有两种主要的 PCA 执行方法;您可以查看 PCA 的维基百科文章(en.wikipedia.org/wiki/Principal_component_analysis#Singular_value_decomposition),了解另一种方法以及这两种方法基本上产生几乎相同的结果。

[14] 要了解Full SVD 及其其他应用,可以阅读维基百科上的文章:en.wikipedia.org/wiki/Singular_value_decomposition

[15] 查看名为“过拟合 - 维基百科”的网页(en.wikipedia.org/wiki/Overfitting)。

[16] 您可以点击查看任何 Scikit-Learn 函数的代码 [source

[17] 您可以深入研究 PCA 的数学原理:en.wikipedia.org/wiki/Principal_component_analysis

[18] Sonia Bergamaschi 和 Laura Po 在 2015 年对基于内容的电影推荐算法进行了比较,发现 LSA 的准确率大约是 LDiA 的两倍。详见 Sonia Bergamaschi 和 Laura Po 的论文“Comparing LDA and LSA Topic Models for Content-Based Movie Recommendation Systems”(www.dbgroup.unimo.it/~po/pubs/LNBI_2015.pdf)。

[19] “Jonathan K. Pritchard, Matthew Stephens, Peter Donnelly, 使用多位点基因型数据推断人口结构” www.genetics.org/content/155/2/945

[20] 参见标题为"Latent Dirichlet Allocation"的 PDF(David M. Blei、Andrew Y. Ng 和 Michael I. Jordan)( www.jmlr.org/papers/volume3/blei03a/blei03a.pdf)。

[21] 你可以在原论文《Online Learning for Latent Dirichlet Allocation》(Matthew D. Hoffman、David M. Blei 和 Francis Bach)中了解更多关于 LDiA 目标函数的详细信息。原论文链接在这里( www.di.ens.fr/%7Efbach/mdhnips2010.pdf)。

[22] Blei 和 Ng 使用的符号是theta,而不是k

[23] 如果你想了解过拟合为什么是一件坏事以及泛化是如何帮助的更多信息,请参见附录 D。

[24] 有关更多距离度量,请参见 Math.NET Numerics( numerics.mathdotnet.com/Distance.html)。

[25] 参见 sklearn.metrics 的文档( scikit-learn.org/stable/modules/generated/sklearn.metrics.DistanceMetric.html)。

[26] 参见标题为"Cosine similarity - Wikipedia"的网页( en.wikipedia.org/wiki/Cosine_similarity)。

[27] 参见标题为"eccv spgraph"的网页( users.cecs.anu.edu.au/~sgould/papers/eccv14-spgraph.pdf)。

[28] 语义网是在 HTML 文档中使用标签来结构化自然语言文本的实践,以便标签的层次结构和内容提供有关网页元素(文本、图片、视频)间关系(连接的网络)的信息。

[29] Ruby 是一种编程语言,其包被称为gems

[30] PostgreSQL 中的全文检索通常基于字符的trigrams,以处理拼写错误和无法解析为单词的文本。

[31] 对高维数据进行聚类等效于使用边界框离散化或索引化高维数据,这在维基百科文章"Clustering high dimensional data"中有描述( en.wikipedia.org/wiki/Clustering_high-dimensional_data)。

[32] 参见标题为"Inverted index - Wikipedia"的网页( en.wikipedia.org/wiki/Inverted_index)。

[33] 将你的 Python 代码向量化,特别是用于成对距离计算的双重嵌套for循环,可以将代码的速度加快近 100 倍。参见 Hacker Noon 文章"Vectorizing the Loops with Numpy"( hackernoon.com/speeding-up-your-code-2-vectorizing-the-loops-with-numpy-e380e939bed3)。

[34] 如果你想了解更快的找到高维向量最近邻居的方法,请查看附录 F,或者直接使用 Spotify 的annoy包来索引你的主题向量。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值