本章内容包括:
- 通过计数单词、n-gram 和词频来分析语义
- 使用 Zipf 法则预测单词出现的概率
- 将自然语言文本表示为向量
- 利用文档频率在文本集合中查找相关文档
- 通过余弦相似度估计文档对之间的相似度
在收集并计数单词(标记)并将其归类为词干或词形之后,接下来是做一些有趣的事情。检测单词对简单任务如关键词搜索很有用,但如果你想做更复杂的事情,比如文本分类或寻找文本主题,你需要知道哪些单词对于某个特定文档以及整个语料库来说是最重要的,并将这些重要性表示为一个值。然后,你可以利用这个“重要性”值,在语料库中根据每个文档内关键词的重要性来查找相关文档。这将使得垃圾邮件检测器更不容易被邮件中的一个脏话或几个略显“垃圾”的词所误导。如果你了解这些单词在文档中出现的频率,并与它们在其他文档中的出现频率进行对比,你可以利用这些信息进一步完善文档的“积极性”。在本章中,你将学习到一种更细致、非二元的单词使用度量方法,称为词频-逆文档频率(TF-IDF)。这种方法已经成为几十年来生成自然语言特征的主流技术,广泛应用于商业搜索引擎和垃圾邮件过滤器中。
接下来的任务是将第2章中的单词转化为连续数字,而不仅仅是代表单词计数的整数或检测特定单词存在与否的二进制“位向量”。通过将单词表示为连续空间中的向量,你可以用更复杂的数学对它们的表示进行操作。你的目标是找到单词的数值表示,这些表示能以某种方式捕捉到单词的重要性或信息内容。你需要等到第4章,才能看到如何将这些信息内容转化为代表单词意义的数字。
在本章中,我们将介绍三种逐步增强的表示单词及其在文档中重要性的方法:
- 词袋模型——单词计数或频率的向量
- n-gram 词袋模型——单词对(二元组)、三元组等的计数
- TF-IDF 向量——更好地表示单词重要性的词汇分数
重要提示:TF-IDF 代表词频-逆文档频率。词频是指文档中每个单词的出现次数,这在之前的章节中已经介绍过。逆文档频率意味着你会将每个单词的计数除以包含该单词的文档数量。
这些技术可以单独应用,也可以作为自然语言处理(NLP)管道的一部分。这些都是统计模型,因为它们基于频率。在本书后面,你将看到各种方法,可以更深入地探讨单词之间的关系、模式和非线性特征。但这些“浅层”NLP模型在许多实际应用中非常强大且有用,比如搜索、垃圾邮件过滤、情感分析甚至聊天机器人。
3.1 词袋模型向量
让我们更深入地探讨如何将一段文本表示为机器可以处理的数值向量。在上一章中,你创建了你的第一个文本向量空间模型。你对每个单词进行了独热编码,然后通过二进制“或”操作(或裁剪求和)将这些向量组合在一起,创建了一个文本的向量表示。这个二进制的词袋(BOW)向量在加载到像 pandas DataFrame 这样的数据结构中时,作为文档检索的索引非常有用。
接下来,你看到了一个更有用的向量表示方法,它统计了每个单词在给定文本中的出现次数或频率。作为初步的近似,你假设单词出现的次数越多,它对该文档的意义贡献也就越大。例如,一个频繁提到“机翼”和“舵”的文档,可能与涉及喷气飞机或航空旅行的问题更相关,而不是一个频繁提到“猫”和“重力”的文档。或者,如果你已经将一些词汇分类为表达积极情感的词——如“好”、“最好”、“喜悦”和“奇妙”——那么文档中这些词汇出现得越多,文档的情感就越有可能是积极的。然而,你可以想象,依赖这些简单规则的算法可能会出错或被误导。
让我们来看一个统计单词出现次数有用的例子。我们将从 Wikipedia 上关于算法偏见的文章中摘取一个句子:
>>> 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']
#1 如果你之前没有使用过 spaCy 小型语言模型,则需要运行这一行。
如果这是你第一次运行 en_core_web_sm 模型,你可能需要在运行上述脚本之前,在终端中执行 python -m spacy download en_core_web_sm。spaCy 语言模型将自然语言文本进行标记化,并返回一个包含输入文本中所有标记序列的文档对象(Doc 类)。它还会对文档进行分割,在 .sents 属性中提供一个句子序列。使用 Python 的 set() 类型,你可以将这个标记序列转换为一个包含文本中所有唯一单词的集合。
文档或语料库中所有唯一单词的列表称为它的词汇表或词典。创建词汇表是你的 NLP 管道中最重要的步骤之一。如果你没有识别特定的标记并为它分配一个存储位置,你的管道将完全忽略它。在大多数 NLP 管道中,你会定义一个名为 <OOV>(词汇外)的单一标记,用来存储所有管道忽略的标记的信息,例如它们的出现次数。所以,如果有一些不常见的或自造的“超长词”你不想包括在词汇表中,你可以将它们归为一个通用的标记,你的 NLP 管道就无法计算这些个别标记的含义了。
Python 的 Counter 类是一种高效的计数工具,可以用来计算序列或数组中任何元素的出现次数,包括标记。在第2章中,你学到了 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 对象底层是一个字典。这意味着,键实际上存储在一个无序集合或集合中,有时也叫做词袋。虽然看起来这个字典保留了句子中单词的顺序,但这只是一个假象。你很幸运,因为你的句子中没有包含很多重复的标记。Python 的最新版本(3.6及以上)会根据你向字典插入新键的顺序来维护键的顺序。1 但你现在将要从这些标记及其计数的字典中创建向量。你需要向量来对一组文档(在此例中是句子)进行线性代数和机器学习。你的词袋向量将跟踪每个唯一标记,并为其分配一个在向量中的位置索引。这样,像“and”或“,”这样的标记的计数将在你所有文档的向量中相加——这里的文档是“算法偏见” Wikipedia 文章中的句子。
提示:对于 NLP,字典中键的顺序并不重要,因为你会在向量中维护一致的顺序,例如 pandas 的 Series。就像在第2章一样,Counter 字典按照你处理语料库中每个文档的顺序来排序你的词汇表(字典的键)。有时,你可能希望将词汇表按字母顺序排序,以便更容易进行分析。一旦你将每个标记分配到向量中的某个维度,就要确保记录这个顺序,以便将来能够重用你的管道,而无需通过重新处理所有文档来重新训练它。如果你试图重现其他人的 NLP 管道,你需要精确地重用他们的词汇表(标记列表),并保持完全相同的顺序。否则,你将需要使用与他们相同的软件,按相同的顺序处理他们的训练数据集。
对于像 Wikipedia 文章中关于算法偏见的句子这样的短文档,混乱的词袋仍然包含了很多关于原始句子意图的信息。词袋中的信息足以做一些强大的事情,比如检测垃圾邮件、计算情感(积极性或其他情感),甚至检测细微的意图,如讽刺。它可能是一个词袋,但它充满了意义和信息。为了让这些单词更容易理解,并确保你的管道保持一致,你希望按某种一致的顺序对它们进行排序。要按计数对标记进行排名,Counter 对象有一个方便的方法:most_common:
>>> bag_of_words.most_common(3) #1
[(',', 5), ('and', 2), ('It', 1)]
#1 参数 3 意味着你将只列出排名前三的标记。
这很方便!Counter.most_common 方法会给你一个按照频率排序的标记列表,结果是一个包含标记及其计数的 2 元组列表。但这还不是你想要的。你需要一个向量表示,以便轻松地对标记计数进行数学运算。
pandas 的 Series 是一种高效的数据结构,用于存储标记计数,包括 most_common 方法返回的 2 元组。pandas Series 的优点是,当你使用数学运算符(如 +、*,甚至 .dot())时,它像一个向量(NumPy 数组)一样工作。而且你仍然可以使用普通的方括号(['token'])语法来访问与每个标记相关的命名(带标签的)维度。
你可以使用内置的 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
...
#1 most_common 返回的是一个 2 元组列表,所以将其转换为字典,以便更容易操作。
#2 将标记计数字典转换为 pandas Series。
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
...
#1 counts Series 的 len 给出句子中唯一标记的数量。
#2 counts 的和等于标记化句子的长度(总标记数,包括重复的)。
#3 默认情况下,most_common() 列出所有标记,从最频繁到最不频繁。
你可以看到,句子中有 23 个标记,但你的词汇表中只有 18 个唯一标记。因此,每个文档的向量将至少包含 18 个值,即使其他文档没有使用这 18 个相同的词。这允许每个标记在你的计数向量中有自己的维度(槽位)。每个标记在向量中被分配一个“槽位”,对应于它在词汇表中的位置。向量中一些标记的计数会是零,这正是你所希望的。
很明显,逗号(,)和单词“and”位于你 most_common 词汇列表的顶部。逗号出现了五次,“and”出现了两次,其他所有单词在这个句子中都只出现了一次。在这个句子中,你的前两个词是逗号和“and”。这是自然语言文本中一个非常常见的问题——最常见的单词往往是最不具意义的。这类停用词(如这些词)并不能告诉你关于文档的太多信息,因此你可能会倾向于完全忽略它们。一个更好的方法是使用文档中单词的统计数据来对你的标记计数进行缩放,而不是依赖别人文档中的任意停用词列表。
一个单词在给定文档中出现的次数被称为词频(TF)。你可能首先想做的事情之一是将标记计数进行归一化(除以文档中的词数)。这将给你一个相对频率(百分比或分数),表示文档中包含某个标记的比例,无论文档的长度如何。通过查看单词“justice”的相对频率,看看这种方法是否能合理反映这个词在这段文本中的重要性:
>>> counts['justice']
1
>>> counts['justice'] / counts.sum()
0.043...
“justice”在这个句子中的归一化词频大约是 4%,并且随着你处理更多来自该文章的句子,这个百分比不太可能上升。如果这个句子和整篇文章中提到“justice”的次数大致相同,那么这个归一化的 TF 分数将在整个文档中保持大致相同。
根据这个 TF,单词“justice”代表了句子大约 4% 的意义。考虑到这个词对句子意义的重要性,这个比例并不高。所以你需要做一步额外的归一化,以便让这个词在句子中的重要性相对于其他单词得到提升。
为了给单词“justice”一个重要性评分,你需要获取更多的统计数据,不仅仅来自这个句子;你需要找出“justice”在其他地方的使用频率。幸运的是,对于正在学习 NLP 的工程师来说,Wikipedia 充满了高质量、准确的自然语言文本,涵盖多种语言。你可以使用这些文本来“教”你的机器了解“justice”这个词在多个文档中的重要性。为了展示这种方法的强大功能,你只需要从 Wikipedia 上的“算法偏见”文章中获取几段文本:
算法偏见描述了计算机系统中的系统性和可重复的错误,这些错误会导致不公平的结果,例如,优待某一特定群体的用户。偏见的产生可以由于多种因素,包括但不限于算法设计、数据编码、收集、选择或用于训练算法的方式等方面的无意或意外使用。 ... 算法偏见已被引用于各种案件,从选举结果到在线仇恨言论的传播。它也在刑事司法、医疗保健和招聘等领域出现,进一步加剧了现有的种族、经济和性别偏见。 ... 由于算法的专有性,理解、研究和发现算法偏见的问题仍然存在,通常这些算法被当作商业机密。 — Wikipedia
看看这些句子,看看你是否能找到理解文本至关重要的关键词。你的算法需要确保包括这些词,并计算它们的统计数据。如果你尝试使用 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}
看起来这个句子并没有重复使用任何单词。频率分析和 TF 向量的关键是相对于其他单词确定单词的使用统计数据。所以我们需要输入其他句子,并创建基于其他地方单词使用情况的有用词汇计数。为了理解“算法偏见”,你可以花时间阅读并输入整个 Wikipedia 文章到 Python 字符串中。你也可以从 GitLab 上的 nlpia2 包中下载一个包含 Wikipedia 文章前三段的文本文件。如果你已经克隆了 nlpia2 包,你会在本地硬盘上看到 src/nlpia2/ch03/bias_intro.txt 文件。如果你没有从源代码安装 nlpia2,你可以使用以下代码段通过 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 文件是 Wikipedia 文章前三段的 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'
#1 requests.get 返回一个包含字节数据的对象(`content` 属性)。
#2 `.text` 属性包含 HTTP 响应体的 unicode 字符串。
#3 bytes.decode() 将字节数据转换为 unicode 字符串。
对于纯文本文件,你可以使用 response.content 属性,它包含原始 HTML 页面字节。如果你想获取字符串,可以使用 response.text 属性,它会自动解码字节文本并生成 unicode 字符串。
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)]
好了,这些计数看起来更具统计意义,但仍然有很多没有意义的单词和标点符号似乎有很高的计数。显然,这篇 Wikipedia 文章不太可能真的是关于“of”、“to”、“,” 或“.” 这些标记的。也许,关注最不常见的标记比关注最常见的标记更有用:
>>> counts.most_common()[-4:]
('inputs', 1), ('between', 1), ('same', 1), ('service', 1)]
嗯,这样不太成功。你可能希望找到像“bias”、“algorithmic”和“data”这样的术语。为了找到这些术语,你需要使用一个公式,平衡计数并为“刚刚好的”术语得出“Goldilocks”评分。你可以通过另一个有用的计数来做到这一点:单词在文档中出现的次数,或者文档频率。这时,事情才会变得真正有趣。
如果你有一个包含许多文档的大型语料库,你可以根据标记在所有文档中出现的频率来对文档内的计数进行归一化(除以)。由于你刚开始使用标记计数向量,最好还是先创建一些小文档,将 Wikipedia 文章的摘要拆分成更小的文档(句子或段落)。这样,你至少可以在一页上看到所有的文档,并通过在脑海中运行代码来确定所有计数的来源。在下一节中,这正是你将要做的:将“算法偏见”文章的文本拆分成句子,并尝试不同的归一化和构建计数字典的方式,以使它们对 NLP 更有用。
3.2 向量化文本数据框构造器
Counter 字典非常适合统计文本中的标记——但真正的重点在于向量。而且,事实证明,字典可以通过调用 DataFrame 构造器并传入字典列表,轻松地转换为 DataFrame 或 Series。Pandas 会处理所有的账目工作,使得每个唯一的标记或字典键都有自己的列。如果某个文档的 Counter 字典缺少某个特定的键(因为文档中没有该单词或符号),Pandas 会创建一个 NaN 值。
一旦你将“算法偏见”文章拆分成行,你将开始理解向量表示的强大之处。然后,你将明白为什么 Pandas 的 Series 在处理标记时,比标准的 Python 字典更有用。
列出 3.1 偏见的短文档
>>> 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
#1 使用 spaCy 标记化每一行,并跳过空行
#2 使用 spaCy 进行标记化,并将所有单词转为小写,以提高句子分割的准确性
#3 用零替换 NaN,并转换为整数,使结果更易读
由于它们让你看到每个维度的作用,将向量存储在 Pandas DataFrame 或 Series 类中,实际上在向量维度保存标记或字符串的分数时非常有用。看看我们在本章开头提到的那个句子,它恰好是 Wikipedia 文章中的第 11 个句子:
>>> docs[10]
It has also arisen in criminal justice, healthcare, and hiring,
compounding existing racial, economic, and gender biases.
>>> df.iloc[10] #1
algorithmic 0
bias 0
describes 0
systematic 0
and 2
...
Name: 10, Length: 246, dtype: int64
#1 索引 10 对应于一个零偏移的 DataFrame 的第 11 行——即 Wikipedia 文章中的第 11 个句子。
现在,这个 Pandas Series 就是一个向量——你可以对它进行数学运算。当你进行这些运算时,Pandas 会跟踪每个单词的位置,以确保像“bias”和“justice”这样的词不会被错误地相加。你在这个 DataFrame 中的行向量有一个“维度”来表示词汇表中的每个单词。实际上,df.columns 属性包含了你的词汇表。
不过等一下,标准英语词典中有超过 30,000 个单词。如果你开始处理很多 Wikipedia 文章,而不仅仅是几句话,那么你将会面临大量的维度。你可能习惯于使用二维和三维向量,因为它们容易可视化,但像距离和长度这样的概念是否能在 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
#1 使用 str.lower() 方法进行大小写折叠
现在你已经将所有这 28 个文档(句子)进行了标记化,你可以将所有这些标记列表连接起来,创建一个包含所有标记的大列表,包括重复的标记。这个标记列表和原始文档的唯一区别是它已经被分割成句子并标记化成单词:
>>> all_doc_tokens = []
>>> for tokens in docs_tokens:
... all_doc_tokens.extend(tokens)
>>> len(all_doc_tokens)
482
从整个段落的标记序列创建词汇表或词典。你的词汇表是语料库中所有唯一标记的列表,就像图书馆里的单词字典一样,词汇表不包含任何重复项。你知道哪些 Python 数据类型可以去除重复项(除了字典类型)吗?
>>> vocab = set(all_doc_tokens) #1
>>> vocab = sorted(vocab) #2
>>> len(vocab)
246
>>> len(all_doc_tokens) / len(vocab) #3
1.959...
#1 将标记列表强制转换为集合,确保每个唯一标记只有一个条目。
#2 除非你打算手动查看标记列表,否则无需对词汇表进行排序。
#3 通过将词汇表大小除以语料库大小(以标记为单位)来计算平均标记重复使用次数。
使用集合数据类型确保没有标记被重复计数。在对所有标记进行大小写折叠后,你的小语料库中只有 248 个唯一拼写的标记,包含 498 个单词。这意味着,平均来说,每个标记几乎正好被使用了两次(498 / 248):
>>> vocab #1
['"', "'s", ',', '-', '.', '2018', ';', 'a', 'ability', 'accurately', 'across', 'addressed', 'advanced', 'algorithm', 'algorithmic', 'algorithms', 'also', 'an', 'analysis', ... 'within', 'world', 'wrongful']
#1 你的词典存储在 vocab 变量中。
通常,最好先遍历整个语料库来构建词汇表,然后再回到文档中,统计标记并将它们放入正确的词汇槽位。如果你这样做,你可以按字母顺序排列你的词汇表,使得更容易跟踪每个标记计数应出现在向量中的位置。这种方法还允许你筛选出非常常见或罕见的标记,从而忽略它们并保持维度较低。假设你想跟踪这个全小写的词汇表中的所有 248 个标记,你可以重新组合你的计数向量矩阵。
列出 3.2 Python 内置 Counter 类
>>> 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
#1 tf 是“词频”(term frequency)的常见缩写。
浏览这些计数向量,看看你能否找到它们在“算法偏见” Wikipedia 文章中对应的句子。你是否注意到,仅通过查看向量,你就能大致了解每个句子的意思?
计数向量可以仅通过数字向量传达文档的“要义”。对于一个对单词含义一无所知的机器来说,通过标记在整个文档中出现的频率来归一化这些计数是很有帮助的。这时,scikit-learn 包就派上了用场。
3.2.1 更快速、更好、更简单的标记计数
现在你知道如何手动创建计数向量,你可能会想知道是否已经有现成的库来处理这些标记计数。幸运的是,答案是肯定的!你可以依赖 scikit-learn(sklearn)包来满足你在 NLP 中的所有机器学习需求。如果你已经安装了 nlpia2 包,那么你已经安装了 scikit-learn(sklearn)。如果你更愿意手动安装,可以使用 pip 或你喜欢的包管理器:
pip install scipy, scikit-learn
在 ipython 控制台或 Jupyter Notebook 中,你可以使用感叹号来执行 Bash 命令:
>>> !pip install scikit-learn #1
#1 行首的感叹号是用于执行 shell 命令的转义字符。
一旦你设置好了环境并安装了 scikit-learn,你可以随时使用它的 CountVectorizer 类来计数标记。CountVectorizer 是 scikit-learn 中的一个 Transformer 类,因此你需要按照顺序运行 .fit() 和 .transform() 方法。.fit() 方法会统计你整个语料库中的标记,并跟踪每个标记在每个文档中出现的次数。CountVectorizer 需要这些标记流行度的计数,以防你配置它使用 max_df(最大文档频率)或 min_df(最小文档频率)参数来过滤常见或稀有单词。你在列表 3.2 中没有考虑标记流行度,但当你处理更大的语料库时,你会很快意识到它的重要性。CountVectorizer.transform() 方法与列表 3.2 中你使用 Python Counter 类计数标记的方式类似。CountVectorizer 类基于 TransformerMixin,以确保它保持一致的 API,使你可以在整个 scikit-learn 包中依赖它。
使用 CountVectorizer 的步骤如下所示,概括起来就是:
- 配置一个空的
CountVectorizer实例。 - 将其拟合到你的语料库。
- 将你的语料库转换为计数向量。
列表 3.3 使用 sklearn 计算词频向量
>>> import numpy as np
>>> from sklearn.feature_extraction.text import CountVectorizer
>>> np.set_printoptions(edgeitems=8) #1
>>> corpus = [doc.text for doc in docs]
>>> vectorizer = CountVectorizer() #2
>>> vectorizer = vectorizer.fit(corpus) #3
>>> count_vectors = vectorizer.transform(corpus) #4
>>> count_vectors
<16x240 sparse matrix of type '<class 'numpy.int64'>'
with 376 stored elements in Compressed Sparse Row format>
#1 np.set_printoptions 函数告诉 numpy 如何美化大数组的显示(打印),也可以查看 pd.options.display。
#2 这是配置向量化器的地方,你可以配置它忽略某些标记或为你的语料库使用专门的标记化器。
#3 你可以使用 vectorizer.fit_transform(corpus) 将拟合和转换步骤合并。
#4 .transform() 方法返回一个行向量数组,每行对应你 16 个文档中的一个,每列对应向量化器词汇表中的每个单词。
哇,这真是太快了!但那稀疏矩阵到底是什么?稀疏矩阵是一种紧凑(且快速)存储大型二维数组的方式,比如这个计数向量数组。稀疏 numpy 矩阵通过跳过计数向量中的零计数来节省空间(内存)。CountVectorizer 类使用稀疏矩阵,因此它可以节省内存,以防你想用于大数据 NLP。稀疏矩阵是你如果想为所有 Wikipedia 文章创建计数向量时需要的,因为那时你将有数百万个文档和数百万个唯一标记。CountVectorizer 的稀疏矩阵可以处理这种数据,甚至更多。
列表 3.3 中的稀疏矩阵看起来具有正确数量的向量(行),对应于你转化为计数的 16 个文档(句子)。根据你对 Counter 字典列表的经验,你可能猜测这个稀疏矩阵中的 240 列是针对你词汇表中 240 个唯一标记的。然而,你可能会想知道如何检查这些隐藏在稀疏矩阵中的所有标记计数。你只需要将稀疏矩阵转换为密集矩阵或数组。在实际应用中,你只会想对数据的小片段进行这种转换;否则,你的计算机可能会崩溃。但对于这个包含 16 个句子的微小语料库,完全可以将整个矩阵转换回你熟悉的密集 NumPy 数组格式。
列表 3.4 使用 sklearn 计算词频向量
>>> count_vectors.toarray() #1
array([[0, 0, 0, 0, 0, 0, 0, 1, ..., 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 2, 0, ..., 0, 0, 0, 0, 0, 0, 0, 0],
...,
[0, 0, 0, 0, 0, 0, 0, 0, ..., 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 1, 0, ..., 1, 0, 0, 0, 0, 1, 0, 0]])
#1 .toarray() 方法将稀疏矩阵转换为常规的 numpy 数组,用零填充空隙。
现在,你可以理解为什么跳过所有这些零是有意义的。大多数文档只包含少数几个唯一的单词,因此你的计数向量总是充满零,作为所有未使用单词的占位符。你现在的向量数组,每一行对应 16 个文档中的一个,每一列对应 240 个标记中的一个。
那么你有了三个向量,每个文档一个。接下来呢?你可以用它们做什么?你的文档词频向量可以做任何向量能做的酷炫的事情,所以让我们首先多了解一些关于向量和向量空间的知识。
3.2.2 向量化你的代码
也许你在网上读到过关于代码向量化的内容;然而,这与文本向量化是完全不同的。文本向量化是指将文本转换为有意义的数值向量表示,而代码向量化(如列表 3.5 所示)是指通过利用强大的编译库(如 numpy),并避免在代码中使用 for 循环,来加速代码的执行。之所以叫做向量化,是因为你可以使用向量代数符号来消除代码中的 for 循环——这是许多 NLP 流水线中最慢的部分。与其使用 for 循环遍历向量或矩阵的所有元素来做数学运算,你可以直接使用 NumPy 来完成 for 循环的操作,而这些运算会在编译的 C 代码中完成。由于 pandas 在底层使用 numpy 进行所有的向量代数运算,你可以将 DataFrame 与 NumPy 数组或 Python 浮动点数混合使用,这样会非常快速。而作为额外的好处,你可以删除那些笨重、复杂的 for 循环。
列表 3.5 增加代码中向量化的数学计算
>>> v1 = np.arange(5) #1
>>> 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) #2
>>> faster_answer
42.0
>>> fastest_answer = 4.2 * v1.dot(v2) #3
>>> fastest_answer
42.0
#1 range() 函数也被认为是一个向量化函数,因为它可以用来消除一个 for 循环。
#2 向量化 for 循环——sum() 函数在 Python 中已经是向量化的。
#3 使用点积运算来乘法并求和数组。
Python 的动态类型设计使得这一切魔法成为可能。当你将浮动点数与数组或 DataFrame 相乘时,解释器不会因为你在两个不同类型上做数学运算而抛出错误。它会自动推断出你想要做的事情,并“实现它”,就像 Sulu 一样。它会以最快的方式进行计算,使用编译的 C 代码而不是 Python 的 for 循环。
提示:如果你使用向量化来消除代码中的一些 for 循环,你可以将 NLP 流水线的速度提升 100 倍甚至更多。这意味着你可以在相同的时间内尝试 100 倍的模型。柏林社会科学中心网站提供了一个关于向量化的很好的教程。如果你继续在网站上探索,你会找到可能是唯一可信的关于 NLP 和 AI 对社会影响的统计数据和资料来源。
如果你认为向量化只是一个“锦上添花”的操作,因为它不会改善你代码的算法复杂度(大 O 符号),那么请再考虑一下。虽然你算法的底层大 O 分析不会显示出改善,但你仍然可以实现极大的效率提升。向量化使得简单的向量搜索应用成为可能。向量搜索在现代 NLP 流水线中非常流行,驱动着从 ChatGPT 到你最喜欢的应用中的语义搜索引擎。列表 3.6 显示了 Knowt 项目中的一些代码,这段代码在一台普通的笔记本电脑上执行点积运算非常快速。你可以使用 NumPy 内存映射文件(numpy.memmap)快速完成向量化操作,且仅需有限的 RAM。不幸的是,TF-IDF 向量化器必须保存在 RAM 中,但列表 3.6 向你展示了向量化操作所能带来的性能提升。这对于构建需要在毫秒级别处理来自 Hacker Public Radio(HPR)超过 40,000 个句子的个人知识管理(PKM)系统至关重要。
列表 3.6 HPR 节目的向量化搜索
>>> !git clone git@gitlab.com/tangibleai/community/knowt
>>> !cd knowt
>>> mmvecs = np.memmap(
... '.knowt-data/hpr_vectors.memmap',
... shape=(41_531, 384), #1
... dtype=np.float32,
... mode='r')
>>> vecs = np.array(mmvecs.T.copy().tolist())
>>> variables = dict(vecs=vecs, v=v)
>>> dt_vectorized = timeit('v.dot(vecs)', globals=variables, number=20)
>>> dt_vectorized
0.106...
#1 内存映射文件要求你提前知道数组的形状。
执行 320 万次乘法和加法操作只用了 100 毫秒多一点。总共有 41,531 × 384(160 万)次乘法需要将 384D 向量的元素与 41,531 个句子向量的元素相乘。你几乎需要同样数量的加法(160 万),将每个 384 个乘积求和,得到每个 41,531 个向量的结果。假设一台笔记本电脑在 2.8 GHz × 4 核处理器上每秒可以执行 112 亿次浮点运算(FLOPS),所以如果考虑到执行 Python 代码时需要的数百个开销和账目操作,这个向量化操作每秒 3200 万次操作是合理的。
你认为使用 for 循环计算每个向量的点积会慢多少呢?以下列表循环遍历了 Hacker Public Radio 节目笔记的 40,000 个密集向量。
列表 3.7 循环搜索 HPR 节目
>>> def loops():
... answers = np.zeros(shape[0])
... for i, vec in enumerate(vecs):
... answers[i] = sum((x1 * x2 for (x1, x2) in zip(v[0], vec)))
... return answers
>>> variables = dict(np=np, loops=loops, vecs=vecs.T, v=v)
>>> dt_loop = timeit('loops()', globals=variables, number=20)
>>> dt_loop
0.671586558000854
>>> dt_loop / dt_vectorized
6.315181165358781
看起来传统的 for 循环会让线性代数操作(如点积)增加大约 6 倍的开销(减慢速度)。如果你花时间向量化你的代码,你可能节省了近一个数量级的计算时间,这样你就可以用这些时间做更多的训练、更大的数据集或更复杂的模型。在某些情况下,你可能不会节省太多计算时间,但它可能仍然会节省更多的开发时间。向量化代码使得你更容易、更快速地阅读和维护代码,即使它对 Python 解释器的执行速度没有提升。例如,在 Knowt 应用程序中,我们在内存映射文件(np.memmap)上运行了这些速度测试(列表 3.6 和列表 3.7),结果发现 for 循环反而更快。所以对于某些大数据 NLP 流水线,你可能需要自己做速度测试,看看哪种方法最适合你。
现在,你了解了如何高效地计算一些向量相似度(点积),接下来是建立一个心理模型,理解你可以用向量做什么。事实证明,自然语言文本的向量表示是你将在本书中学习的所有语言模型的关键。所以现在是时候讨论向量空间,以强化你对线性代数操作(如点积)的理解了。
3.2.3 向量空间 TF–IDF(词频-逆文档频率)
向量——有序的数字列表或坐标——是线性代数或向量代数的主要构建块。这些数字描述了空间中的位置或坐标。你还可以使用向量来表示方向和大小,或距离。向量空间是你可以存储任何你希望在算法和数学中使用的向量表示的空间。在数学和范畴理论中,它是所有可能的向量集合,这些向量可以通过该维度的向量来表示。例如,一个具有两个维度(坐标)的向量,将位于二维向量空间中。你朋友家中的纬度和经度坐标就是二维向量在二维向量空间中的例子。如果你曾经使用过地图、图纸或图像中的像素网格,那么你就接触过二维向量空间。
考虑一下,当你处理像 [纬度,经度] 这样的二维向量时,你必须始终保持一致,先写纬度再写经度,否则数学计算将无法进行。如果你不小心反转了 HTML 画布或网页上物体的 x 和 y 坐标位置,你会把这些物体移动到新的位置。如果你希望它们在新位置上仍能正确计算,可能还需要调整一些线性代数计算——因此,向量并不像普通的列表或数组那样,你绝不希望改变它们的顺序。如果你发现自己在对计数向量或 TF 向量进行排序,那么你可能做错了什么。
在本章(以及整本书)中,你将处理的所有向量和向量空间都是直线的(欧几里得的),这意味着每个维度都与其他维度正交(相互垂直)。因此,你可以使用毕达哥拉斯定理(平方和的平方根)来计算欧几里得距离。这些距离是连接两点的直线长度。
那么,地图或地球仪上的纬度和经度坐标呢?那种地理坐标空间显然是弯曲的,当你放大时,所有坐标似乎都在直角相交。但你不是一个平地球主义者(我们希望如此!),所以你知道用毕达哥拉斯公式来计算圣地亚哥到基辅的距离是不对的。你信任你的飞行员来计算穿越地球表面的弯曲线的长度,这是一种曲线(非直线)向量空间。每对纬度-经度坐标描述的是一个大致球形表面上的点——地球的表面。你甚至可以添加第三维,用于飞机在地面上的高度,或者它与地球中心的距离。
你不需要担心计算曲线向量空间中的距离,只要你计算的是你和附近某个纬度-经度位置之间的距离。对于 NLP,你几乎永远不需要考虑向量空间中的曲率。你可以在二维中可视化计数向量和 TF 向量,感受计数向量的坐标意味着什么,以及特定文档在你的向量空间中的位置,如下所示。Python 提供了你所需的所有工具来可视化二维和三维向量。
列表 3.8 2D 词频向量示例
>>> from matplotlib import pyplot as plt
>>> import seaborn as sns
>>> palette = sns.color_palette("muted") #1
>>> sns.set_theme() #2
>>> vecs = pd.DataFrame([[1, 0], [2, 1]], columns=['x', 'y'])
>>> vecs['color'] = palette[:2]
>>> vecs['label'] = [f'vec{i}' for i in range(1, len(vecs)+1)]
>>> fig, ax = plt.subplots()
>>> for i, row in vecs.iterrows():
... ax.quiver(0, 0, row['x'], row['y'], color=row['color'],
... angles='xy', scale_units='xy', scale=1)
... ax.annotate(row['label'], (row['x'], row['y']),
... color=row['color'], verticalalignment='top'
... )
>>> plt.xlim(-1, 3)
>>> plt.ylim(-1, 2)
>>> plt.xlabel('X (e.g. frequency of word "vector")')
>>> plt.ylabel('Y (e.g. frequency of word "space")')
>>> plt.show()
#1 其他调色板:pastel, bright, colorblind, deep 和 dark
#2 可选主题:notebook, whitegrid, darkgrid
图 3.1 展示了一种可视化二维向量(0, 1)和(2, 1)的方法。对于 TF 向量,每个向量从原点 (0, 0) 开始。向量的尾部是它开始的位置(原点),而头部(有时用尖三角形表示)则标识向量空间中的二维位置。因此,前两个句子的 TF 向量,如果你只计算“vector”和“space”这两个单词的频率,应该会生成 图 3.1 中的两个向量。以“向量的尾部…”开头的句子只使用了“vector”这个词一次,得到了坐标 (1, 0)。以“向量的头部…”开头的句子使用了“vector”两次和“space”一次,得到了坐标 (2, 1)。
那么,你在物理课上使用的 3D 向量空间(或者像 Minecraft 这样的 3D 视频游戏空间)呢?每当你想为词汇表中的第三个单词腾出空间时,你可以使用 3D 向量空间。你可以添加一个 z 轴——来自纸面上的第三维度,用于新单词的表示。但没有足够的字母为你的坐标轴命名并不会限制你的 TF(计数)向量的维度。对于 NLP,你可以使用 4 维、10 维、10,000 维,或任何你喜欢的维度。毕竟,你需要 246 个维度来跟踪短文“算法偏见”文本(列表 3.2 中的文本)中 246 个不同术语的计数。在现实中,你可能需要在 TF(单词计数)向量中处理数百万个维度。你无法在 2D 屏幕上可视化所有这些维度,但在第 4 章中,你将学习一些维度降维技巧和一些 3D 向量图,这些可以帮助你可视化高维向量的精髓(特征向量)。
无论你有多少维度,线性代数的运算方式是相同的。因此,你可以使用本章中的向量数学来衡量关于向量的有趣内容。最终你将遇到“维度诅咒”,但在第 4 章和第 10 章中,你将使用先进的算法来避免这个诅咒。维度过多之所以成为一种诅咒,是因为随着维度的增加,高维向量在欧几里得距离上会越来越远。而在所有这些空间中找到向量变得非常非常困难,除非一一查看所有向量。对于超过 10 或 20 个维度的情况,许多简单的操作变得不切实际。例如,基于与查询或参考向量的距离对大量向量进行排序变得不现实。搜索引擎必须使用近似最近邻(ANN)搜索算法,以合理的时间完成这个任务。幸运的是,本章中的高维向量不受维度诅咒的影响,因为它们是稀疏的(你 TF 矩阵中的大多数值将是零)。
对于自然语言文档的向量空间,向量空间的维度是整个语料库中出现的不同单词或标记的数量。这个不同单词的数量被称为语料库的词汇表大小。在学术论文中,你可能会看到词汇表大小用符号 |V| 表示。你也可以使用大写字母 K 作为变量名来表示词汇表单词的计数。如果你想单独跟踪每个单词,你将需要一个 K 维向量来存储每个单词在该向量中的计数。然后,你可以用一个 K 维向量来描述每个文档,如果你有多个这样的向量,你可能会称之为向量空间——你向量的所有可能值的空间。为了在向量空间中导航,你需要开发一种方法来衡量所有这些向量之间的距离。
3.3 向量距离和相似度
两个最常见的向量距离度量是欧几里得距离和余弦距离。对应的相似度度量是欧几里得相似度和余弦相似度。你可能对这些度量了解的比你意识到的要多。你可能已经知道如何使用毕达哥拉斯定理来计算图 3.1 中标记为 Vec1 的向量从尾部到头部的距离。这被称为该向量的长度或 L2 向量范数,其中 L 代表长度,2 是毕达哥拉斯定理中使用的指数。
向量的长度远没有它们之间的距离重要。为了计算它们之间的距离,你需要将一个向量减去另一个,并计算它们之间向量的长度。在图 3.2 中,有一个新的箭头,带有虚线轮廓,表示 Vec1 和 Vec2 之间的差异。
为了将 Vec1 从 Vec2 中减去,你可以使用 NumPy 表达式 v2 - v1。将 2D 向量值代入 v1 和 v2,得到 (2, 1) - (1, 0),结果是 (-1, -1),表示这两个向量之间的向量距离。该差异向量的长度就是两个向量 v1 和 v2 之间的距离,使用毕达哥拉斯公式计算得到长度(距离)值为 0.707。
当维度较少时,这种欧几里得距离计算方法效果很好,但如果你想象对一对百万维的向量做同样的计算,你会发现即使这些向量相对接近,这个数字也会变得巨大,因此对于高维向量,你可能更希望使用余弦距离而不是欧几里得距离。余弦距离的最大值为 1,所以无论向量有多少维度,距离值都不会大于 1。
余弦距离与两个向量之间的角度成正比——角度越大,余弦距离就越接近 1。两个向量相差 90 度时,它们的距离为 1。与其计算余弦距离,更容易且更有用的是计算余弦相似度。余弦相似度可以通过从 1 中减去余弦距离来得到,你可以将其可视化为一个向量与另一个向量重叠或“遮蔽”的比例。余弦相似度是两个向量之间的角度(θ)的余弦值。它是通过标准化的点积计算得到的,比欧几里得距离更容易计算。余弦相似度在 NLP 工程师中非常流行,因为它具有以下优点:
- 即使对于高维向量也能快速计算
- 对单一维度的变化非常敏感
- 对高维向量表现良好
- 值的范围在 -1 到 1 之间
你可以使用余弦相似度而不拖慢你的 NLP 流水线,因为你只需要计算点积,而你可能会惊讶地发现,你不需要计算余弦函数就能得到余弦相似度。相反,你可以使用线性代数中的点积运算,这不需要任何三角函数的计算,因此计算非常高效(快速)。而且,余弦相似度独立地考虑每个维度,它们对向量方向的影响会累加起来,即使是对于高维向量也是如此。TF–IDF 可能具有数千个甚至数百万个维度,因此你需要使用一种度量方式,它不会随着维度数量的增加而失去效果——这就是前面提到的维度诅咒。
3.3.1 点积
你将在 NLP 中经常使用点积,因此理解它非常重要。如果你已经能在脑海中计算点积,可以跳过这一节。
点积也叫内积,因为两个向量(每个向量中的元素数量)或矩阵(第一个矩阵的行和第二个矩阵的列)的“内”维度必须相同,因为计算结果将基于这个维度。它也可以称为标量积,因为它的输出是一个单一的标量值。这有助于将它与叉积区分开,后者的输出是一个向量。显然,这些名称反映了在正式数学符号中表示点积(⋅\cdot⋅)和叉积(×\times×)的符号形状。标量积输出的标量值可以通过将一个向量的所有元素与第二个向量的所有元素相乘,然后将这些乘积加起来来计算。
以下是一个 Python 代码片段,你可以在脑海中运行它,确保你理解点积是什么。
列表 3.9 点积计算示例
>>> v1 = np.array([1, 2, 3])
>>> v2 = np.array([2, 3, 4])
>>> v1.dot(v2)
20
>>> (v1 * v2).sum() #1
20
>>> sum([x1 * x2 for x1, x2 in zip(v1, v2)]) #2
20
#1 NumPy 数组的乘法是一个“向量化”操作,非常高效。
#2 你不应该以这种方式遍历向量,除非你想让你的流水线变慢。
提示:点积等同于矩阵乘积,这可以通过 NumPy 的 np.matmul() 函数或 @ 运算符来实现。由于所有向量都可以转换为 N×1N \times 1N×1 或 1×N1 \times N1×N 矩阵,你可以通过转置第一个向量,使它们的内维度对齐,然后在两个列向量(N×1N \times 1N×1)之间使用这个简写运算符,如下所示:v1.reshape(-1, 1).T @ v2.reshape(-1, 1)。这会在一个 1×11 \times 11×1 矩阵中输出你的标量积:array([[20]])。
这是在你的线性代数教材中标准化点积的样子:
在 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.10 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.11 在 Python 中计算余弦相似度
>>> import math
>>> def cosine_sim(vec1, vec2):
... dot_prod = 0
... for x1, x2 in zip(vec1, vec2):
... dot_prod += x1 * x2
...
... mag_1 = math.sqrt(sum([x1**2 for x1 in vec1]))
... mag_2 = math.sqrt(sum([x2**2 for x2 in vec2]))
...
... return dot_prod / (mag_1 * mag_2)
你需要计算两个向量的点积——逐个元素相乘,然后将这些乘积求和。然后,除以每个向量的范数(大小或长度)。向量范数等同于从向量的头部到尾部的欧几里得距离——即所有元素的平方和的平方根。这个标准化的点积,像余弦函数的输出一样,将是一个在 -1 到 1 之间的值——表示这两个向量之间的夹角的余弦值。这给你一个关于向量方向一致性的值。
余弦相似度为 1 表示两个完全相同的标准化向量,它们在所有维度上指向完全相同的方向,尽管它们的长度或大小可能不同。余弦相似度为 0 表示两个向量没有共享任何成分,并且在所有维度上是正交的。余弦相似度为 -1 表示两个相反的向量,它们指向完全相反的方向。对于 TF 向量,余弦相似度为 1 表示两个文档在相似的比例下共享相似的单词,0 表示它们没有共享任何单词,-1 是不可能的,因为词频不可能为负数。
在本章中,你不会看到自然语言文档对的负余弦相似度值;然而,在下一章中,我们将开发一些彼此相反的词汇和主题的概念。到时,文档、单词和主题的余弦相似度可能会小于零,甚至达到 -1。
如果你想为常规的 numpy 向量(如 CountVectorizer 返回的向量)计算余弦相似度,你可以使用 scikit-learn 的内置工具。以下列表展示了如何计算两个词向量 1 和 2 之间的余弦相似度。
列表 3.12 计算余弦相似度
>>> from sklearn.metrics.pairwise import cosine_similarity
>>> tf = tf.fillna(0) #1
>>> vec1 = tf.values[:1,:] #2
>>> vec2 = tf.values[1:2,:]
>>> cosine_similarity(vec1, vec2) #3
array([[0.11785113]])
#1 文档中未找到的单词会得到 NaN 计数,必须用 0 填充。
#2 对 DataFrame(2D 数组)进行切片以提取单行作为 1 × N 数组
#3 Scikit-learn 的相似度度量适用于一对 2D 数组;每个数组应为矩阵、列表的列表或数组的数组。
切片 tf DataFrame 可能看起来像是获取向量的一种奇怪方式。这是因为 scikit-learn 用于计算余弦相似度的函数已经针对大型向量数组(2D 矩阵)进行了优化。列表 3.12 中的代码将 DataFrame 的第一行和第二行切片成一个包含文本中第一句单词计数的 1 × N 数组。
为了检查你对为什么余弦相似度这么低的直觉,下面的列表展示了如何运行列表 3.11 中的函数。别忘了解引用 1 × N 矩阵,以创建与 cosine_sim 代码兼容的 N 长度数组。
列表 3.13 计算 vec1 和 vec2 之间的余弦相似度
>>> cosine_sim(vec1[0], vec2[0]) #1
0.11785113019775792
#1 通过 [0] 解引用,因为 vec1 和 vec2 是 2D 矩阵,`cosine_sim()` 期望 1D 数组。
太好了!这给出了完全相同的结果,精确到九位有效数字。即使你对这个计数向量对的低相似度得分有直觉,未来你可能需要调试不符合预期的流水线。为了进一步发展你对余弦相似度的直觉,你可以修改列表 3.11 打印出匹配的标记及其计数,并开发你自己的个人 NLP 调试工具,修改 cosine_sim 函数,使其能够与 pandas Series 或其他对象一起使用,帮助你跟踪哪些维度对应哪些标记。你可以使用这样的工具深入了解为什么你的团队的 NLP 流水线或你使用的搜索引擎给出了意外的结果。解释为什么 NLP 流水线和聊天机器人会做出它们的行为,是构建更智能、更具伦理的 AI 的重要工具。
来自“算法偏见”文章的第一句的计数向量与文章的第二句相似度仅为 11.7%(余弦相似度为 0.117)。似乎第二句与第一句共享的单词非常少。
为了深入理解余弦距离,你可以检查列表 3.11 中的代码,它将为 Counter 字典提供与 sklearn 余弦相似度函数相同的答案,只要它们是等效的 NumPy 数组。同时,在你查看函数输出之前,使用主动学习来猜测每对句子的余弦相似度。每次你尝试预测 NLP 算法的输出,并发现自己需要修正时,你对 NLP 工作原理的直觉就会得到提升。
3.4 计算 TF–IDF 频率
在第 2 章中,你学习了如何从语料库中的标记创建 n-grams。现在,是时候利用它们来创建更好的文档表示了。幸运的是,你可以使用你已经熟悉的相同工具,只需要稍微调整一些参数。
首先,让我们向语料库中添加一个新的句子,这将说明为什么 n-gram 向量有时比计数向量更有用:
>>> import copy
>>> question = "What is algorithmic bias?"
>>> ngram_docs = copy.copy(docs)
>>> ngram_docs.append(question)
如果你使用在列表 3.2 中训练的相同向量化器计算这个新句子(问题)的词频向量,你会看到它与第二个句子的表示完全相同:
>>> question_vec = vectorizer.transform([question])
>>> question_vec
<1x240 sparse matrix of type '<class 'numpy.int64'>'
with 3 stored elements in Compressed Sparse Row format>
稀疏矩阵是一种高效存储标记计数的方法,但为了帮助你理解发生了什么,或者调试你的代码,你可能希望将向量转换为密集格式。你可以使用 .toarray() 方法将稀疏向量(稀疏矩阵的一行)转换为 NumPy 数组或 pandas Series:
>>> question_vec.toarray()
array([[0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, ... ]])
你大概能猜出问题中第八个位置(维度)的单词是什么。记住,这是 CountVectorizer 计算的词汇表中的第八个单词,并且它会在运行 .fit() 时按字典顺序对词汇表进行排序。你可以将计数向量与词汇表配对,通过 pandas Series 查看发生了什么:
>>> vocab = list(zip(*sorted((i, tok) for tok, i in
... vectorizer.vocabulary_.items())))[1]
>>> pd.Series(question_vec.toarray()[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.
不错!这个句子是一个不错的开始。然而,Wikipedia 文章的第一句可能是这个问题的更好定义。想想如何改进向量化流水线,以便搜索返回第一句,而不是第四句。
为了找出 2-grams 是否有帮助,重复与列表 3.3 中相同的向量化过程,使用 CountVectorizer,但将 ngram_range 超参数设置为计算 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(单词对)总是比唯一的单词标记更多。查看对你的问题非常重要的 "algorithmic bias" 2-gram 的计数:
>>> 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.4.1 分析“this”
尽管到目前为止,我们只处理了词标记的 n-grams,但字符的 n-grams 也可以很有用。例如,它们可以用于语言检测或作者归属(确定在一组作者中谁写了正在分析的文档)。让我们使用字符 n-grams 和你在列表 3.3 中学习的 CountVectorizer 类来解决一个谜题。
Python 核心开发者们是一群富有创意的人,他们在一个名为 this 的包中藏了一个有趣的彩蛋(秘密信息)。
列表 3.14 Tim Peters 的秘密信息
>>> from this import s as secret
>>> print(secret)
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 的粉丝可能会想到用来召唤死神 Cthulhu 的古老语言。但即使是他们,这个消息也会让人无法理解。
为了确定这个神秘文本的含义,你将使用你刚学到的方法:频率分析(计算标记)。这次,一只小鸟(或 Mastodon)告诉你,开始时用字符标记而不是词标记可能会很有趣!幸运的是,CountVectorizer 可以帮助你。你可以配置它来计算字符甚至字符三元组,如果你想忽略标点符号,scikit-learn 的向量化器也很擅长这一点。你需要在不同的文本上运行这个计数向量化器,以揭示这个文本(列表 3.14)中的秘密模式。如果你在配置 scikit-learn 向量化器时做出明智的选择,并将这些选择显式地作为默认参数暴露在包装函数中,你的未来自己将会感谢你。这样,当你在想为什么你的流水线忽略了标点符号或其他下游函数需要的标记时,Python 的 traceback 会帮助你快速找到 bug。这些将帮助你确定在你的应用程序中可能使用的词汇表和向量表示。以下列表提供了一个可重用的函数,显式地暴露了向量化器的 vocabulary_ 和 stop_words 参数,供你的流水线使用。
列表 3.15 计算字符以揭示秘密信息
>>> from string import punctuation
>>> punc = list(punctuation) + list(' \n')
>>> def count_chars(text, tokenizer=list, token_pattern=None,
... stop_words=punc, **kwargs):
... lot = [text] if isinstance(text, str) else text #1
... vectorizer = CountVectorizer(
... token_pattern=token_pattern,
... stop_words=stop_words,
... tokenizer=tokenizer, #2
... **kwargs)
... counts = vectorizer.fit_transform(lot) #3
... counts = counts.toarray()[0] #4
... vocab = vectorizer.vocabulary_
... index = pd.Series(vocab).sort_values().index
... counts = pd.Series(counts, index=index)
... return counts.sort_values()
>>> secretcounts = count_chars(secret)
>>> secretcounts
m 1
x 2
...
g 79
r 92
#1 lot stands for list of texts because scikit-learn vectorizers expect an array of strings.
#2 Defaults to tokenize str into character-grams, using tokenizer=list
#3 Scikit-learn vectorizers return an array of count vectors, and you retrieve the first (and only) count vector here with [0].
#4 The .todense() method returns a matrix that is difficult to coerce into an array of vectors (2D array).
嗯,也许你不太确定该如何使用这些频率计数。但是,再想想,你还没有看到任何其他文本的频率计数呢。假设你在一个随机的英语文档上运行了这个。你能猜出哪些字母会出现在这个列表的顶部和底部吗?为了验证你的猜测,你可以下载任何随机的 Wikipedia 文章并统计其中的字符。下面是如何下载 Wikipedia “机器学习” 文章并查看它最常使用的字符:
>>> !pip install nlpia2_wikipedia #1
>>> import wikipedia as wiki
>>> page = wiki.page('machine learning') #2
>>> mlcounts = count_chars(page.content)
>>> mlcounts
’ 1
⇒ 1
...
a 4146
e 5440
#1 The nlpi2_wikipedia package fixes bugs in the original Wikipedia package on pypi.org.
#2 This text is also available in src/nlpia2/data/wikiml.txt on GitLab in the TangibleAI/nlpia2 project.
你可以看到,Wikipedia 文章中最常见的字母是 e,第二常见的是 a。你注意到英语文档中前两个字母(e 和 a)与秘密文档中前两个字母(r 和 g)之间有什么特别的区别吗?也许,使用可视化图会有帮助(图 3.3)。尝试将标准化的字符计数作为条形图,并将它们并排显示:
>>> plt.subplot(2,1,1)
>>> secretcounts /= secretcounts.sum() #1
>>> secretcounts.sort_index()['a':'z'].plot(kind='bar', grid='on')
>>> plt.title('Secret Message')
>>> plt.subplot(2,1,2)
>>> mlcounts /= mlcounts.sum() #2
>>> mlcounts.sort_index()['a':'z'].plot(kind='bar', grid='on')
>>> plt.title('ML Article')
>>> plt.show()
#1 Divides the count of the secret letters by the total count of letters in the message to normalize the counts as a percentage
#2 Normalizes the English (“Machine Learning” article) letter frequencies
现在,看起来很有趣!仔细看看图 3.3 中的两个频率直方图;你可能会注意到最流行字母附近的一个模式。这两个直方图中的波峰和波谷的头肩模式似乎是相同的,只是发生了位移。如果你之前处理过频谱数据,这可能会有意义。字符频率的波峰和波谷模式在两个图形之间是相位偏移的(左右偏移)。最常见的秘密字母 r,位于最常见的英语字母 e 的右侧 13 个字母处。另一个常见的秘密字母 n,位于英语字母 a 的对应波峰的右侧 13 个字母。因此,减去 13 个字母(按字母位置计)可能会将秘密信息字母转换为相应的明文英语字母。
为了确定你看到的是否是一个真实的模式,你需要检查波峰和波谷的偏移是否一致。这种信号处理方法称为谱分析。你可以通过将每个信号的最高点的位置相互减去,来计算波峰的相对位置。你可以使用 Python 中的两个内置函数 ord() 和 chr() 来在整数和字符之间相互转换:
>>> peak_distance = ord('R') - ord('E')
>>> peak_distance
13
>>> chr(ord('v') - peak_distance) #1
'I'
>>> chr(ord('n') - peak_distance) #2
'A'
#1 字母 'I' 在英语字母表中比字母 'V' 早 13 个位置。
#2 字母 'A' 在英语字母表中比字母 'N' 早 13 个位置。
幸运的是,这些整数和字符映射是按字母顺序排列的。所以,如果你想解码这个秘密消息中的字母 R,你应该将其字母顺序值(ord)减去 13,得到字母 E——英语中最常用的字母。同样,为了解码字母 V,你会将它替换为 I——英语中第二常用的字母。这个位移对于每个文档中最不常见的字母是否成立呢?
>>> chr(ord('W') - peak_distance)
'J'
到这一点,你可能已经在网上(MetaGered)查找了关于这个谜题的信息。你可能已经发现,这个秘密消息是用 ROT13 密码加密的。ROT13 算法将每个字母在字母表中向前旋转 13 个位置。为了解码一个用 ROT13 加密的所谓秘密消息,你只需要应用逆算法,将字母表向后旋转 13 个位置。你很可能能自己用一行代码创建加密和解密函数。或者,你可以使用 Python 内置的 codecs 包来揭示这一切的真相:
>>> import codecs
>>> print(codecs.decode(secret, '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 年写的。从那时起,这首诗已经进入了公共领域,被谱曲,甚至被模仿。《Python之禅》提醒本书的作者尽量编写更简洁、更具可读性和可重用性的代码。希望这些代码加深了你对如何通过统计符号的出现频率教机器解码人类语言的理解。
3.5 Zipf 法则
现在进入我们的主要话题——社会学。好吧,其实不是社会学,但你可能会喜欢这个快速的偏题,探讨通过计数人群和单词来揭示你日常生活中事物计数中的另一个秘密模式。事实证明,在自然语言中,就像自然界中的大多数事物一样,漂亮的模式无处不在。
20世纪初,法国速记员让-巴蒂斯特·埃斯图普(Jean-Baptiste Estoup)注意到词频中有一个显著的模式,这使他开始仔细地统计他能找到的所有文档(感谢计算机和 Python)。到了1930年代,美国语言学家乔治·金斯利·齐夫(George Kingsley Zipf)试图正式化埃斯图普的观察,这一关系最终被命名为 Zipf 定律。
根据维基百科的定义:
给定一些自然语言语料库,任何单词的频率与其在频率表中的排名成反比。
具体来说,反比关系指的是在排名列表中的一个项目,其计数(频率)与它在列表中的排名号相关联。显而易见,排名越高,计数越大,但令人惊讶的是,当在对数-线性图上绘制时,这种关系是线性的。一个这样的对数相关性的例子可能是,排名第一的项目将出现的次数是第二个项目的两倍,是第三个项目的三倍。你可以通过绘制任何语料库或文档中单词使用频率与排名(按频率排序)之间的关系图,来做一个有趣的实验。如果你看到任何在对数-对数图中没有落在一条直线上的异常值,可能值得进一步调查那个异常值。
作为 Zipf 法则超越词语世界的例子,图 3.4 绘制了美国城市人口与该人口排名之间的关系。事实证明,Zipf 法则适用于许多事物的计数。大自然中充满了经历指数增长和“网络效应”的系统,如人口动态、经济产出和资源分配等。令人感兴趣的是,像 Zipf 法则这样简单的规律,居然可以在广泛的自然现象和人造现象中成立。诺贝尔经济学奖得主保罗·克鲁格曼在谈到经济模型和 Zipf 法则时简洁地表达了这一点:
经济理论的常见抱怨是我们的模型过于简化——它们提供了对复杂、混乱现实的过度整洁的视角。[而 Zipf 法则的情况] 恰恰相反:你拥有复杂、混乱的模型,然而现实却惊人地整洁简单。
——保罗·克鲁格曼
图 3.4 显示了克鲁格曼的城市人口图的更新版本。
正如城市和社交网络一样,词汇也遵循同样的规律。首先,让我们从 NLTK 下载 Brown 语料库:
>>> import 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
#1 Brown 语料库大约为 3 MB。
#2 .words() 是 NLTK 语料库对象的内置方法,返回标记化的语料库,作为字符串序列。
#3 你将在第 7 章和第 11 章学习词性标注。
Brown 语料库是第一个百万词的英语电子语料库,创建于 1961 年,位于布朗大学。该语料库包含来自 500 个来源的文本,这些来源已按类别进行分类,如新闻、社论等。
—— NLTK 文档
因此,借助超过一百万个标记,你有足够的内容可以查看:
>>> 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"(TF 排名第 1)出现的频率大约是 "of"(TF 排名第 2)的两倍,且大约是 "and"(TF 排名第 3)的三倍。如果你不相信我们,可以使用示例代码(gitlab.com/tangibleai/…)在 nlpia2 包中查看。
简而言之,如果你按单词出现次数对语料库中的单词进行排名,并按降序列出它们,你会发现,对于一个足够大的样本,排名列表中的第一个单词出现的概率是第二个单词的两倍,且是列表中第四个单词的四倍。因此,给定一个大型语料库,你可以使用这种拆分来确定某个单词出现在该语料库任何给定文档中的统计可能性。
3.6 逆文档频率(IDF)
现在,我们回到文档向量。词频和 n-gram 频率很有用,但纯粹的词频,即使通过文档的长度进行标准化,也不能告诉你这个词在文档中的重要性,相对于语料库中其余文档的重要性。如果你能分析出这些信息,你就可以开始描述语料库中的文档。假设你有一个关于人工智能(AI)的所有书籍的语料库。单词 "intelligence" 几乎肯定会在每本书中出现多次(文档),但这并不会提供任何新信息——它不会帮助区分这些文档。相反,像 "neural network" 或 "conversational engine" 这样的词可能在整个语料库中并不那么普遍,但你肯定能更好地了解那些经常出现这些词的文档的性质。为此,你需要另一个工具。
逆文档频率(IDF)计算可能让你想起你用来创建 Brown 语料库词频 Zipf 图的计算。随着时间的推移,你会注意到最常见的词往往携带的意义最少。当你评估单词对你的 NLP 项目的影响时,你会注意到单词的重要性随着它们的频率呈指数增长,就像在 Zipf 分析中一样。语料库中第二重要的标记可能比最重要的标记常见得多。
以你之前的 TF 计数器为例,考虑一下你可以按以下方式计数标记:按单个文档或按整个语料库。在本节中,你只会按文档来计数。返回到 Wikipedia 中的算法偏见示例,并获取另一个处理算法种族歧视的部分;假设它是你的偏见语料库中的第二个文档。
Algorithms have been criticized as a method for obscuring racial prejudices in decision-making. Because of how certain races and ethnic groups were treated in the past, data can often contain hidden biases. For example, black people are likely to receive longer sentences than white people who committed the same crime. This could potentially mean that a system amplifies the original biases in the data.
…
A study conducted by researchers at UC Berkeley in November 2019 revealed that mortgage algorithms have been discriminatory towards Latino and African Americans which discriminated against minorities based on “creditworthiness,” which is rooted in the US fair-lending law which allows lenders to use measures of identification to determine if an individual is worthy of receiving loans. These particular algorithms were present in FinTech companies and were shown to discriminate against minorities.
首先,计算你的偏见语料库中两个文档的总词数:
>>> 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 = [t.text for t in nlp(bias_intro.lower())]
>>> disc_tokens = [t.text for t in nlp(bias_discrimination.lower())]
>>> intro_total = len(intro_tokens)
>>> intro_total
484
>>> disc_total = len(disc_tokens)
>>> disc_total
9550
现在,有了两个关于偏见的标记化文档,看看在每个文档中单词 "bias" 的 TF。你将把找到的 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'
好吧,你有一个比另一个大八倍的数值。那么,介绍部分的内容是否关于 "bias" 多了八倍?其实不完全是。因此,你需要深入一点。首先,查看这些数字与其他单词(例如单词 "and")的得分对比:
>>> 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
太好了!你知道这两个文档中 "and" 的频率与 "bias" 是一样的,实际上,歧视章节中 "and" 的频率比 "bias" 还要高!哦,等等。
思考一个术语的 IDF 时可以这样理解:这个标记出现在这个文档中的惊讶程度有多大?衡量标记的惊讶感可能听起来不像是一个非常数学化的想法。然而,在统计学、物理学和信息理论中,符号的惊讶感用于衡量它的熵或信息量。这正是你需要用来衡量某个词的重要性。如果一个术语在一个文档中出现很多次,但在语料库中的其他文档中很少出现,那么它就是一个能区分该文档含义的词。
一个术语的 IDF 就是总文档数与该术语出现在文档中的次数之比。对于 "and" 和 "bias" 来说,结果是相同的:
2 total documents / 2 documents contain "and" = 2/2 = 1
2 total documents / 2 documents contain "bias" = 2/2 = 1
这并不有趣,因此让我们来看另一个词——"black":
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
#1 This is similar for “bias,” “black,” and any other words you are interested in.
让我们获取 "black" 在两个文档中的 TF:
>>> 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.6.1 Zipf 的回归
你快完成了。不过,假设你有一个包含一百万个文档的语料库(也许你是小谷歌),然后有人搜索单词 "cat"。在这百万个文档中,假设你有一个文档包含了单词 "cat"。这个词的原始 IDF 为:
1,000,000 / 1 = 1,000,000
现在,假设你有 10 个文档包含了单词 "dog"。你对 "dog" 的 IDF 是:
1,000,000 / 10 = 100,000
这是一个巨大的差异。你的朋友 Zipf 会说,这个差异太大了,因为这种情况可能经常发生。Zipf 的法则表明,当你比较两个单词的频率时,比如 "cat" 和 "dog",即使它们出现的次数相似,频率较高的单词会比频率较低的单词指数级地更常见。因此,Zipf 的法则建议你用 log() 函数来缩放所有的词频(和文档频率),这是 exp() 的逆函数。这样可以确保像 "cat" 和 "dog" 这样的词频不会有很大的差异,并且这种词频分布会确保你的 TF–IDF 分数更加均匀分布。所以,你应该将 IDF 重新定义为该词在文档中出现的原始概率的对数。你也应该对 TF 取对数。
对数函数的基数并不重要,因为你只想使频率分布均匀,而不是在特定的数值范围内进行缩放。如果你使用基数为 10 的对数函数,那么在搜索 "cat" 时,你将得到如下公式:
如果你搜索 "dog",你应该得到公式 3.4:
现在,你已经更恰当地根据词语在语言中的出现频率对每个 TF 结果进行了加权。
最后,对于语料库 D 中给定文档 d 中的一个给定术语 t,你得到以下的术语频率表达式:
接下来,你需要计算逆文档频率(IDF),它告诉你某个标记的重要性,或者它在文档中出现时为文档添加了多少信息。估计标记信息值的最常见方法是取语料库中总文档数与包含该标记的文档数的比值的对数。在公式 3.6 中,添加了 1 以平滑处理零文档频率的情况,即当语料库中某些词汇中的标记在特定文档中未出现时。
然后,术语频率(TF)和逆文档频率(IDF)可以结合起来计算 TF-IDF 值,这是衡量一个词的含义在特定文档中占比的指标。
一个词在文档中出现的次数越多,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
#1 特定术语在特定文档中的对数概率
#2 特定术语至少在一个文档中出现的对数概率——第一个对数是为了线性化 IDF(补偿 Zipf 法则)。
#3 对数 TF–IDF 是 TF 和 IDF 的积的对数,或者是 TF 和 IDF 对数之和。
这个单一的数值——TF–IDF 分数,是所有搜索引擎的基础。现在,你已经能够将词汇和文档转化为数字和向量,是时候用 Python 让所有这些数字发挥作用了。你可能永远不需要从头实现 TF–IDF 公式,因为这些算法已经在许多软件库中为你实现了。你不需要成为线性代数的专家才能理解 NLP,但如果你能理解构成像 TF–IDF 分数这样的数字的数学,肯定会增强你的信心。如果你理解了这些数学原理,你可以自信地调整它以适应你的应用,甚至可能帮助开源项目改进它的 NLP 算法。
3.6.2 相关性排序
正如你之前所看到的,你可以轻松比较两个向量并获取它们的相似度,但你现在已经了解到,仅仅计算词频并不像使用它们的 TF–IDF 值那么有效。因此,在每个文档向量中,你需要将每个词的词频替换为其 TF–IDF 值(得分)。现在,你的向量将更全面地反映文档的意义或主题。
当你使用像 MetaGer.org、Duck.com 或 You.com 这样的搜索引擎时,搜索结果中的 10 个页面是从每个页面的 TF–IDF 向量中精心挑选出来的。如果你仔细想一想,算法能够给你提供 10 个页面,这些页面几乎总是包含你正在寻找的重要信息,确实很令人惊讶。毕竟,搜索引擎要从数十亿个网页中进行选择,这怎么可能呢?在幕后,所有的搜索引擎都是通过计算查询的 TF–IDF 向量与其数据库中数十亿网页的 TF–IDF 向量之间的相似度,通常称为相关性。下面的例子展示了你如何使用与大型搜索引擎相同的数学方法对任何文档语料库进行相关性排序。
假设你正在构建一个个人知识管理系统,并且想为你最喜欢的一些播客计算 TF–IDF 向量。以下代码从 GitLab 上的 Knowt 项目中下载了超过 4,000 集 Hacker Public Radio 节目的句子:
>>> from sklearn.feature_extraction.text import TfidfVectorizer
>>> url = 'https://gitlab.com/tangibleai/community/knowt/-/raw/main/'
>>> url += '.knowt-data/corpus_hpr/sentences.csv?inline=false'
>>> df = pd.read_csv(url)
>>> docs = df['sentence']
>>> vectorizer = TfidfVectorizer(min_df=1) #1
>>> vectorizer = vectorizer.fit(docs) #2
>>> vectors = vectorizer.transform(docs) #3
<41531x56404 sparse matrix of type '<class 'numpy.float64'>'
with 788383 stored elements in Compressed Sparse Row format>
#1 设置最小文档频率(min_df)为 1,以计算像 “Haycon” 这样的稀有词,只出现一次的词
#2 计算文本中词语的总出现次数,不包括出现频率高于 max_df 的词
#3 TfidfVectorizer 将文本转换为稀疏的 NumPy 矩阵,每一列代表文档中出现的每个唯一词语。
TfidfVectorizer 将你的 41,531 个文档转换为一个稀疏矩阵,具有 56,404 列,每列代表文档中找到的一个唯一标记。你可能会想将这个稀疏矩阵转换成更熟悉的 NumPy 数组或 pandas DataFrame,使用 .todense() 方法,但要小心。当你将稀疏矩阵转换为密集数组时,所有那些表示未使用词汇的空隙都会被填充为零。因此,对于一个 41,531 × 56,404 的 DataFrame 或数组,你需要在 RAM 中存储 41_531 * 56_404(大约 23 亿)个浮点值(在典型 PC 上约为 2.4 GB)。幸运的是,你可以使用稀疏矩阵完成所有必要的数学计算。
接下来,你需要查找丢失的 Haycon 音频:
>>> query_vec = vectorizer.transform(
... ['where is the lost audio']) #1
>>> query_vec #2
<1x56404 sparse matrix of type '<class 'numpy.float64'>'
with 5 stored elements in Compressed Sparse Row format>
>>> dotproducts = query_vec.dot(vectors.T) #3
>>> dotproducts.argmax()
>>> idx = dotproducts.argmax()
>>> idx
20068
>>> df.iloc[idx]
sentence ## hpr2407 :: The Lost Episode Part 2 A foll...
line_number 1
line_start 1
sent_start_char 0
len 74
num_tokens 21
>>> df.iloc[i]['sentence']
'The Lost Episode Part 2 A follow up to “The Lost Episode”.'
#1 向量化器和其他转换器在 scikit-learn 中期望的是文档对象的可迭代对象,而不是单个对象。
#2 五个查询词在 56,404 维的向量中产生了五个 TF–IDF 得分。
#3 点积与余弦距离不同,但它们是成比例的。
如果你正在寻找与 Haycon 相关的内容,那么你可能希望将这个词添加到你的查询中。它会有一个较大的 TF–IDF 值,因此你可能需要稍微调整你的查询。为了提高你的相关性得分,你可以通过除以 np.linalg.norm() 来对所有 TF–IDF 向量进行标准化。如果这样仍然不足以找到你想要的内容,还有一些技巧你可以从大型搜索引擎那里借用。
在示例 3.17 中,argmax() 函数需要对每个 TF–IDF 向量及其值进行顺序扫描(表扫描)。顺序扫描是数据库系统必须迭代表中所有项以找到查询匹配项的过程;这是一个 O(n) 操作。相比之下,大多数数据库索引方法,如 B 树和 R 树,提供 O(log(n)) 查询性能。有些数据库,如 PostgreSQL,提供专门的全文搜索索引,具有 O(1)(常数时间)的文本搜索性能。倒排索引数据结构使得这一切成为可能。
全文搜索倒排索引是一本书中所有重要单词的排序列表,附有页码,类似于教科书或百科全书的索引。在数据库中,倒排索引还会包含每个单词在每页上的位置。你可以想象,这样的索引让搜索引擎能够更轻松(且更快速)地找到数据库中所有文本字段内所有搜索词的确切位置。为了处理拼写变化和错别字,大多数数据库会实现带有字符三元组索引的全文搜索。如果你能想象一本教科书的索引,其中包含所有三字母的单词片段,那么你就能理解三元组索引是什么样的。
你不需要自己实现全文搜索的倒排索引。相反,你可以借助免费开源软件(FOSS)巨人的肩膀,通过探索 Whoosh 包中的最先进 Python 实现及其源代码来构建。你甚至可能想安装联邦网页搜索爬虫和索引器 Mwmbl——这是对大型搜索引擎的最新赛博朋克反叛。
3.6.3 数学的平滑处理
TF–IDF 矩阵(术语-文档矩阵)几十年来一直是信息检索(搜索)的基础。因此,研究人员和公司花费了大量时间尝试优化 IDF 部分,以提高搜索结果的相关性。使用 TF–IDF 向量的替代方法之一叫做 Okapi BM25,或者它的最新变体 BM25F。
伦敦城市大学的聪明人想出了一个更好的搜索结果排序方法。他们不仅直接计算 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.7 使用 TF–IDF 构建你的聊天机器人
在本章中,你学习了如何使用 TF–IDF 来表示自然语言文档的向量,找到它们之间的相似度,并进行关键词搜索。但是如果你想构建一个聊天机器人,如何利用这些能力来打造你的第一个虚拟助手呢?即使是最先进的聊天机器人,也严重依赖一个以 TF–IDF 倒排索引为核心的搜索引擎。一些商业聊天机器人将它们的搜索引擎作为生成响应的唯一算法。这种方法的优点在于它为你提供了一个可重复、可测试、可靠且可解释的 NLP 管道。一些行业,如医疗保健,将其视为任何计算机系统的要求——你可不希望你的护士机器人或放射学 AI 编造信息。你只需要做一步额外的工作,就能将你的简单搜索索引(TF–IDF)转化为聊天机器人。为了让这本书尽可能实用,每一章都会展示如何利用你在该章学到的技能,让你的聊天机器人更智能。
你将构建一个简单的聊天机器人,能够回答数据科学问题。方法很简单:你将存储你预期的所有问题,并将它们与相应的答案配对。然后,你可以使用 TF–IDF 来搜索与用户输入文本最相似的问题。你不再返回数据库中最相似的语句,而是返回与该语句或问题相关的回答。这样,你就将一个问答聊天机器人的问题,转变成了一个文本搜索问题。
让我们一步步来实现。首先,你需要加载一些常见问题(FAQ)数据。你将使用 Hobson 近几年被他的学员问到的数据科学问题语料库。在 nlpia2 包中,你可以找到一个基于本节代码的工作 FAQ 机器人,位于 ch03/faqbot.py。FAQ 机器人的数据文件可以直接从 nlpia2 仓库下载:
>>> DS_FAQ_URL = ('https://gitlab.com/tangibleai/nlpia2/-/raw/main/'
... 'src/nlpia2/data/faqbot.csv')
>>> df = pd.read_csv(DS_FAQ_URL, index_col=0)
接下来,你可以为这个数据集中的问题创建 TF–IDF 向量。你将使用之前在本节中看到的 scikit-learn 中的 TfidfVectorizer 类:
>>> vectorizer = TfidfVectorizer()
>>> vectorizer.fit(df['question'])
>>> tfidfvectors_sparse = vectorizer.transform(df['question']) #1
>>> tfidfvectors = tfidfvectors_sparse.todense() #2
#1 向量化我们数据集中的所有问题
#2 将向量转换为密集格式,以便更容易检查
现在我们准备实现问答功能了。你的机器人将通过使用你在数据集上训练的相同向量化器,找到最相似的问题来回答用户的问题:
>>> def ask(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"
... )
#1 查找每个问题与我们查询的余弦相似度,并识别最相似的问题
现在你的第一个问答聊天机器人准备好了!让我们来问它第一个问题:
>>> ask("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?
当然,这是一个与你的小型 FAQ 数据库中的问题非常相似的问题。你可以通过再问几个问题来与新的 FAQ 机器人互动,看看你是否能让它崩溃:
- What is a Gaussian distribution?
- Who came up with the perceptron algorithm?
你会很快发现,这个简单的聊天机器人经常会失败——不仅仅是因为你训练它的数据集很小。例如,试着使用一些数据集中不存在的单词,或者稍微不同的拼写:
>>> ask('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?
如果你仔细看一下数据集,你可能会找到一些关于减少过拟合的答案;但是,这个向量化器只是稍微有点太字面了。当它看到错误问题中的 "decrease" 一词时,这导致它计算出一个更大的点积,从而得到了比正确问题更高的相似度。为了更彻底地探索数据集,你可以使用 .argsort() 方法:
>>> question = 'LogisticRegression'
>>> question_vector = vectorizer.transform([question])
>>> dotproducts = question_vector.dot(tfidfvectors_sparse.T)
>>> dotproducts = dotproducts.toarray()[0] #1
>>> idx = dotproducts.argsort()[-3:] #2
>>> idx
array([18, 35, 71])
>>> dotproducts[idx]
array([0. , 0.2393827 , 0.34058149])
>>> df['answer'][idx]
18 You have a sample of measurements and you want...
35 A 'LogisticRegression' will be less likely to ...
71 Decrease the C value, this increases the regul...
#1 将稀疏矩阵结果强制转换为 1D 数组
#2 `.argsort` 方法按升序排序索引值,`[-3:]` 获取前三个匹配项
现在,你有机会应用你新学到的 NLP 技能了,以防你忘记它们。你可以标准化你的 TF–IDF 向量,以减少数据集中较长字符串主导搜索结果的倾向。如果不先标准化它们,较长的字符串会产生较大的 TF–IDF 点积。你还可以尝试字符三元组的 TF–IDF 向量化器,以帮助处理拼写错误和部分单词匹配;使用成熟的 TF–IDF 平滑公式,如 Okapi BM25;或者甚至为你的特定问题设计自定义的 TF–IDF 平滑公式。
为了与其他读者分享你的学习成果,你可以在 nlpia2 GitLab 项目中提交一个拉取请求,分享你关于问答聊天机器人的想法。如果你想创建更智能的问答(QA)聊天机器人,你将需要更多的数据。查看 GitHub 上的 Large-QA-Datasets 项目,或者浏览 Hugging Face 上的热门 QA 数据集。
在下一章中,你将看到另一种增强基于 TF–IDF 的搜索引擎和聊天机器人的方法。你将很快学习如何揭示自然语言单词的潜在含义,而不是仅仅依赖它们的字面拼写。
3.8 接下来做什么
现在你可以将自然语言文本转换为数字,开始对它们进行操作和计算。接下来,在下一章中,你将细化这些数字,尝试表示自然语言文本的意义或主题,而不仅仅是它的词汇。在后续章节中,我们将展示如何实现一个语义搜索引擎,它能够找到与查询中词语意义相似的文档,而不仅仅是使用与查询中词语完全相同的文档。语义搜索比任何 TF–IDF 加权、词干提取和词形还原方法都要强大。最先进的搜索引擎结合了 TF–IDF 向量和语义嵌入向量,以实现比传统搜索更高的准确度。
Google、Bing 和其他网络搜索引擎之所以没有使用语义搜索方法,仅仅是因为它们的语料库太大。语义词汇和主题向量无法扩展到数十亿的文档,但数百万文档没有问题。而一些小型初创公司,如 You.com,正在学习如何利用开源技术,在网络规模上实现语义搜索和对话式搜索(聊天)。
因此,你只需要最基本的 TF–IDF 向量,将其输入到你的管道中,就能为语义搜索、文档分类、对话系统以及我们在第一章中提到的大部分应用提供最先进的性能。TF–IDF 只是你管道中的第一阶段,是你从文本中提取的基本特征集。在下一章中,你将从 TF–IDF 向量中计算主题向量。主题向量比这些经过精心标准化和平滑处理的 TF–IDF 向量更能准确表示文档的意义。从这里开始,情况会越来越好,我们将在第六章和后续章节中转向文本的语义向量表示。即使在第十章,当你学习到目前为止发明的最大和最强大的语言模型时,你也会回到 TF–IDF 向量,用来增强语义搜索引擎的全文搜索功能,实现两者的完美结合。
3.9 自我测试
- CountVectorizer.transform() 创建的计数向量与 Python collections.Counter 对象的列表之间有哪些区别?你能将它们转换为相同的 DataFrame 对象吗?
- 你能在一个大型语料库(超过一百万个文档)和巨大的词汇表(超过一百万个词汇)上使用 TfidfVectorizer 吗?你预期会遇到什么问题?
- 想一个 TF 比 TF–IDF 更有效的语料库或任务的例子。
- 我们提到过字符 n-grams 的袋模型可以用于语言识别任务。使用字符 n-grams 来区分一种语言与另一种语言的算法是如何工作的?
- 你在本章中看到的 TF–IDF 的局限性或缺点是什么?你能想到一些没有提到的额外局限性吗?
- 你如何使用 TfidfVectorizer 实现一个能够处理拼写错误和错别字的全文搜索?提示:你可能需要设置 analyzer 和 ngram_range 参数,来索引字符三元组(trigrams)而不是词语的 1-gram。
总结
- 任何具有毫秒响应时间的网络规模搜索引擎,其背后都隐藏着 TF–IDF 矩阵的强大功能。
- Zipf 定律可以帮助你预测各种事物的频率,包括单词、字符和人。
- 术语频率必须通过其逆文档频率加权,以确保最重要和最有意义的词汇获得应有的权重。
- 袋模型(Bag-of-words)、袋模型 n-grams 和 术语频率-逆文档频率(TF–IDF)是表示自然语言文档的最基本算法,它们使用一个实数向量来表示文档。
- 高维向量之间的欧几里得距离和相似度对于大多数 NLP 应用来说不能充分代表它们的相似性。
- 余弦距离,即向量之间的重叠量,可以通过简单地将标准化向量的元素相乘并求和来高效计算。
- 余弦距离是大多数自然语言向量表示的首选相似度评分。