Python 文本分析蓝图(二)
原文:
zh.annas-archive.org/md5/c63f0fe6d74b904d41494495addce0ab译者:飞龙
第四章:为统计和机器学习准备文本数据
从技术上讲,任何文本文档都只是一系列字符。为了在内容上构建模型,我们需要将文本转换为一系列单词或更一般地说,被称为标记的有意义的字符序列。但仅仅这样是不够的。想象一下单词序列New York,它应该被视为一个单一的命名实体。正确地识别这样的单词序列作为复合结构需要复杂的语言处理。
数据准备或一般的数据预处理不仅涉及将数据转换为可以用于分析的形式,还涉及消除干扰噪声。什么是噪声,什么不是,这取决于您将要执行的分析。在处理文本时,噪声呈现不同的形式。原始数据可能包括应在大多数情况下移除的 HTML 标记或特殊字符。但是,频繁出现的具有很少含义的单词,所谓的停用词,会给机器学习和数据分析引入噪声,因为它们使得检测模式变得更加困难。
您将学到什么以及我们将要构建什么
在本章中,我们将为文本预处理流水线开发蓝图。该流水线将接受原始文本作为输入,对其进行清理、转换,并提取文本内容的基本特征。我们首先使用正则表达式进行数据清理和标记化,然后专注于使用spaCy进行语言处理。spaCy 是一个功能强大的自然语言处理库,具有现代 API 和最先进的模型。对于一些操作,我们将利用textacy,这是一个提供一些很好的附加功能,特别是用于数据预处理的库。我们还会在有需要时指向 NLTK 和其他库。
在学习本章之后,您将了解数据准备的必需和可选步骤。您将学会如何使用正则表达式进行数据清理,以及如何使用 spaCy 进行特征提取。通过提供的蓝图,您将能够快速为自己的项目建立数据准备流水线。
数据预处理流水线
数据预处理通常涉及一系列步骤。通常,这个序列被称为流水线,因为您将原始数据输入到流水线中,并从中获得转换和预处理后的数据。在第一章中,我们已经构建了一个简单的数据处理流水线,包括标记化和停用词去除。我们将在本章中使用术语流水线作为处理步骤序列的通用术语。图 4-1 概述了我们将在本章中为预处理流水线构建的蓝图。
图 4-1。文本数据预处理的典型预处理步骤流水线。
我们管道中的第一个主要操作块是数据清理。我们首先识别并删除文本中的噪声,如 HTML 标记和不可打印字符。在字符标准化过程中,特殊字符,如重音符号和连字符,被转换为标准表示。最后,如果 URL 或电子邮件地址与分析无关或存在隐私问题,则可以屏蔽或删除它们。现在文本已经清洁到足以开始语言处理了。
这里,分词 将文档分割成单独的标记列表,例如单词和标点符号。词性标注 是确定词的类别的过程,无论它是名词、动词、冠词等。词形还原 将屈折词映射到它们的不变词根,即词素(例如,“are” → “be”)。命名实体识别 的目标是在文本中识别对人物、组织、地点等的引用。
最后,我们希望创建一个包含准备好的用于分析和机器学习的数据的数据库。因此,所需的准备步骤因项目而异。您可以决定在问题特定管道中包含哪些以下蓝图。
介绍数据集:Reddit 自发帖子
处理文本数据在处理用户生成内容(UGC)时特别具有挑战性。与专业报告、新闻和博客中的精心编辑的文本相比,社交媒体中的用户贡献通常很短,并包含大量缩写、标签、表情符号和拼写错误。因此,我们将使用在 Kaggle 上托管的Reddit 自发帖子数据集。完整数据集包含大约 100 万条带有标题和内容的用户帖子,分为 1,013 个不同的子版块,每个子版块包含 1,000 条记录。我们将仅使用汽车类别中包含的 20,000 条帖子的子集。本章准备的数据集是第十章中单词嵌入分析的基础。
将数据加载到 Pandas
原始数据集由两个单独的 CSV 文件组成,一个包含帖子,另一个包含一些有关子版块的元数据,包括类别信息。这两个文件都通过 pd.read_csv() 加载到 Pandas 的 DataFrame 中,然后合并为一个单一的 DataFrame。
import pandas as pd
posts_file = "rspct.tsv.gz"
posts_df = pd.read_csv(posts_file, sep='\t')
subred_file = "subreddit_info.csv.gz"
subred_df = pd.read_csv(subred_file).set_index(['subreddit'])
df = posts_df.join(subred_df, on='subreddit')
蓝图:标准化属性名称
在我们开始处理数据之前,我们将将数据集特定的列名称更改为更通用的名称。我们建议始终将主要的 DataFrame 命名为 df,并将要分析的文本列命名为 text。对于常见变量和属性名称的此类命名约定,使得在不同项目中重用蓝图代码变得更容易。
让我们来看看该数据集的列列表:
print(df.columns)
输出:
Index(['id', 'subreddit', 'title', 'selftext', 'category_1', 'category_2',
'category_3', 'in_data', 'reason_for_exclusion'],
dtype='object')
对于列重命名和选择,我们定义一个名为 column_mapping 的字典,其中每个条目定义了当前列名到新名称的映射。映射到 None 的列和未提及的列将被丢弃。这种转换的字典非常适合文档化并易于重复使用。然后使用这个字典来选择和重命名我们想要保留的列。
column_mapping = {
'id': 'id',
'subreddit': 'subreddit',
'title': 'title',
'selftext': 'text',
'category_1': 'category',
'category_2': 'subcategory',
'category_3': None, # no data
'in_data': None, # not needed
'reason_for_exclusion': None # not needed
}
# define remaining columns
columns = [c for c in column_mapping.keys() if column_mapping[c] != None]
# select and rename those columns
df = df[columns].rename(columns=column_mapping)
如前所述,我们将数据限制在汽车类别中:
df = df[df['category'] == 'autos']
让我们简要看一下样本记录,以对数据有个初步印象:
df.sample(1).T
| 14356 | |
|---|---|
| id | 7jc2k4 |
| subreddit | volt |
| title | 2017 伏特行车记录仪 |
| text | Hello.我正在考虑购买一款行车记录仪。有人有推荐吗?我一般寻找一款可充电的,这样我就不必把电线引到点烟器里去了。除非有关于如何正确安装不露电线的说明。谢谢! |
| category | autos |
| subcategory | chevrolet |
保存和加载 DataFrame
在每个数据准备步骤之后,将相应的 DataFrame 写入磁盘作为检查点非常有帮助。Pandas 直接支持多种序列化选项。像 CSV 或 JSON 这样的文本格式可以轻松导入到大多数其他工具中。然而,数据类型信息丢失(CSV)或仅保存基本信息(JSON)。Python 的标准序列化格式 pickle 受到 Pandas 支持,因此是一个可行的选择。它速度快且保留所有信息,但只能由 Python 处理。“Pickling” 一个数据帧很简单;您只需指定文件名:
df.to_pickle("reddit_dataframe.pkl")
然而,我们更倾向于将数据帧存储在 SQL 数据库中,因为它们为您提供 SQL 的所有优势,包括过滤、连接和从许多工具轻松访问。但与 pickle 不同,只支持 SQL 数据类型。例如,包含对象或列表的列不能简单地以这种方式保存,需要手动序列化。
在我们的示例中,我们将使用 SQLite 来持久化数据帧。SQLite 与 Python 集成良好。此外,它只是一个库,不需要服务器,因此文件是自包含的,并且可以在不同团队成员之间轻松交换。为了更大的功能和安全性,我们建议使用基于服务器的 SQL 数据库。
我们使用 pd.to_sql() 将我们的 DataFrame 保存为 SQLite 数据库中的 posts 表。DataFrame 索引不会被保存,任何现有数据都会被覆盖:
import sqlite3
db_name = "reddit-selfposts.db"
con = sqlite3.connect(db_name)
df.to_sql("posts", con, index=False, if_exists="replace")
con.close()
可以使用 pd.read_sql() 轻松恢复 DataFrame:
con = sqlite3.connect(db_name)
df = pd.read_sql("select * from posts", con)
con.close()
清洁文本数据
当处理用户请求或评论时,相对于精心编辑的文章,通常需要处理一些质量问题:
特殊格式和程序代码
文本可能仍然包含特殊字符、HTML 实体、Markdown 标记等。这些残留物应提前清理,因为它们会复杂化标记化并引入噪音。
问候语、签名、地址等。
个人交流通常包含毫无意义的客套话和称呼姓名的问候语,这些通常对分析无关紧要。
回复
如果你的文本中包含重复问题文本的答案,你需要删除重复的问题。保留它们会扭曲任何模型和统计数据。
在本节中,我们将演示如何使用正则表达式识别和删除数据中的不需要的模式。查看以下侧边栏,获取有关 Python 中正则表达式的更多详细信息。
看看 Reddit 数据集中的以下文本示例:
text = """
After viewing the [PINKIEPOOL Trailer](https://www.youtu.be/watch?v=ieHRoHUg)
it got me thinking about the best match ups.
<lb>Here's my take:<lb><lb>[](/sp)[](/ppseesyou) Deadpool<lb>[](/sp)[](/ajsly)
Captain America<lb>"""
如果这段文本经过清理和润色,结果肯定会有所改善。有些标签只是网页抓取的产物,所以我们会将它们清除掉。因为我们对 URL 和其他链接不感兴趣,所以我们也会丢弃它们。
蓝图:使用正则表达式识别噪音
在大型数据集中识别质量问题可能会很棘手。当然,你可以并且应该查看一部分数据的样本。但很可能你不会发现所有的问题。更好的方法是定义粗略模式,指示可能存在的问题,并通过程序检查完整数据集。
下面的函数可以帮助您识别文本数据中的噪音。我们所说的噪音是指所有非纯文本的东西,可能会干扰进一步的分析。该函数使用正则表达式搜索一些可疑字符,并将它们在所有字符中的份额作为杂质的分数返回。非常短的文本(少于min_len个字符)将被忽略,因为在这里,单个特殊字符将导致显著的杂质并扭曲结果。
import re
RE_SUSPICIOUS = re.compile(r'[&#<>{}\[\]\\]')
def impurity(text, min_len=10):
"""returns the share of suspicious characters in a text"""
if text == None or len(text) < min_len:
return 0
else:
return len(RE_SUSPICIOUS.findall(text))/len(text)
print(impurity(text))
Out:
0.09009009009009009
在精心编辑的文本中,你几乎永远不会找到这些字符,因此通常情况下得分应该非常低。对于前面的文本示例,根据我们的定义,约 9%的字符是“可疑的”。当然,搜索模式可能需要适应包含特殊字符的语料库或类似标记的文本。然而,它不需要完全匹配;它只需要足够好以指示潜在的质量问题。
对于 Reddit 数据,我们可以用以下两个语句获取最“不纯净”的记录。请注意,我们使用 Pandas 的apply()而不是类似的map(),因为它允许我们向应用的函数传递额外的参数,如min_len。^(1)
# add new column to data frame
df['impurity'] = df['text'].apply(impurity, min_len=10)
# get the top 3 records
df[['text', 'impurity']].sort_values(by='impurity', ascending=False).head(3)
| 文本 | 杂质 | |
|---|---|---|
| 19682 | 我在考虑购买一辆 335i,行驶 39,000 英里,CPO 保修还剩 11 个月。我询问了交... | 0.21 |
| 12357 | 我打算租用带导航包的 a4 高级版自动挡。车辆价格:<ta... | 0.17 |
| 2730 | Breakdown below:Elantra GT2.0L 4 缸6 速手动变速器... | 0.14 |
显然,有许多像<lb>(换行符)和<tab>(制表符)这样的标签包含在内。让我们利用我们在第一章中的单词计数蓝图,结合简单的正则表达式分词器,来检查是否还有其他标签:
from blueprints.exploration import count_words
count_words(df, column='text', preprocess=lambda t: re.findall(r'<[\w/]*>', t))
| 频率 | 词元 |
|---|---|
| 100729 | |
| 642 |
现在我们知道,尽管这两个标签很常见,但它们是唯一的标签。
蓝图:使用正则表达式去除噪音
我们的数据清理方法包括定义一组正则表达式,并识别问题模式及相应的替换规则。^(2) 蓝图函数首先用它们的纯文本表示替换所有 HTML 转义符(例如&),然后用空格替换特定模式。最后,修剪空白序列:
import html
def clean(text):
# convert html escapes like & to characters.
text = html.unescape(text)
# tags like <tab>
text = re.sub(r'<[^<>]*>', ' ', text)
# markdown URLs like [Some text](https://....)
text = re.sub(r'\[([^\[\]]*)\]\([^\(\)]*\)', r'\1', text)
# text or code in brackets like [0]
text = re.sub(r'\[[^\[\]]*\]', ' ', text)
# standalone sequences of specials, matches &# but not #cool
text = re.sub(r'(?:^|\s)[&#<>{}\[\]+|\\:-]{1,}(?:\s|$)', ' ', text)
# standalone sequences of hyphens like --- or ==
text = re.sub(r'(?:^|\s)[\-=\+]{2,}(?:\s|$)', ' ', text)
# sequences of white spaces
text = re.sub(r'\s+', ' ', text)
return text.strip()
警告
要小心:如果您的正则表达式定义不够精确,您可能会在此过程中意外删除有价值的信息而未注意到!重复符号+和*尤其危险,因为它们匹配无界字符序列,可能会删除大部分文本。
让我们对前面的示例文本应用clean函数并检查结果:
clean_text = clean(text)
print(clean_text)
print("Impurity:", impurity(clean_text))
输出:
After viewing the PINKIEPOOL Trailer it got me thinking about the best
match ups. Here's my take: Deadpool Captain America
Impurity: 0.0
看起来不错。一旦处理了第一个模式,您应该再次检查清理后文本的杂质,并在必要时添加进一步的清理步骤:
df['clean_text'] = df['text'].map(clean)
df['impurity'] = df['clean_text'].apply(impurity, min_len=20)
df[['clean_text', 'impurity']].sort_values(by='impurity', ascending=False) \
.head(3)
| 清理文本 | 杂质 | |
|---|---|---|
| 14058 | Mustang 2018、2019 年还是 2020 年?必备条件!!1. 信用评分达到 780 分以上,以获得最低的利率!2. 加入信用社来为车辆融资!3. 或者找一个贷款人来为车辆融资... | 0.03 |
| 18934 | 在经销商那里,他们提供了一个车内照明的选项,但我在网上找不到任何相关信息。有人得到了吗?它看起来怎么样?有人有照片。不确定这是什么意思... | 0.03 |
| 16505 | 我正在看四辆凯曼,价格都差不多。主要区别在于里程、年限,还有一款不是 S 型。www.cargurus.com/Cars/invent…... | 0.02 |
即使根据我们的正则表达式,最肮脏的记录现在看起来也非常干净。但除了我们搜索的粗糙模式之外,还有更微妙的字符变体可能会引起问题。
蓝图:使用 textacy 进行字符归一化
注意以下句子,其中包含与字母变体和引号字符相关的典型问题:
text = "The café “Saint-Raphaël” is loca-\nted on Côte dʼAzur."
重音字符可能会带来问题,因为人们并不一致地使用它们。例如,tokens Saint-Raphaël 和 Saint-Raphael 将不会被识别为相同的。此外,文本经常包含由于自动换行而分隔的单词。像文本中使用的花哨 Unicode 连字符和撇号这样的字符对于标记化来说可能是个问题。针对所有这些问题,规范化文本并用 ASCII 等效物替换重音字符和花哨字符是有意义的。
我们将使用 textacy 来实现这一目的。textacy 是一个与 spaCy 配套使用的 NLP 库,它将语言学部分交给 spaCy,专注于预处理和后处理。因此,其预处理模块包括了一系列用于规范化字符以及处理常见模式(如 URLs、电子邮件地址、电话号码等)的函数,我们将在下面使用它们。表 4-1 展示了 textacy 预处理函数的一部分。所有这些函数都可以独立于 spaCy 在纯文本上工作。
表 4-1. textacy 预处理函数子集
| 功能 | 描述 |
|---|---|
normalize_hyphenated_words | 重新组合被连字符分隔的单词 |
normalize_quotation_marks | 用 ASCII 等效物替换所有类型的花哨引号 |
normalize_unicode | 统一 Unicode 中不同格式的重音字符 |
remove_accents | 尽可能用 ASCII 替换重音字符,否则删除它们 |
replace_urls | 类似于 URLs,比如 xyz.com |
replace_emails | 将电子邮件替换为 EMAIL |
replace_hashtags | 类似于标签 #sunshine |
replace_numbers | 类似于数字 1235 |
replace_phone_numbers | 类似于电话号码 +1 800 456-6553 |
replace_user_handles | 类似于用户句柄 @pete |
replace_emojis | 用 EMOJI 替换表情符号等 |
我们这里展示的蓝图函数,使用 textacy 标准化了花哨的连字符和引号,并通过它来去除重音:
import textacy.preprocessing as tprep
def normalize(text):
text = tprep.normalize_hyphenated_words(text)
text = tprep.normalize_quotation_marks(text)
text = tprep.normalize_unicode(text)
text = tprep.remove_accents(text)
return text
当这个应用到之前的例句时,我们得到以下结果:
print(normalize(text))
输出:
The cafe "Saint-Raphael" is located on Cote d'Azur.
注意
鉴于 Unicode 规范化有许多方面,您可以查看其他库。例如,unidecode 在这方面表现出色。
蓝图:基于模式的数据屏蔽与 textacy
文本,尤其是用户写的内容,经常包含不仅是普通单词,还有多种标识符,比如 URLs、电子邮件地址或电话号码等。有时我们特别对这些项感兴趣,例如分析提及最频繁的 URLs。然而,在许多情况下,出于隐私或无关紧要的原因,删除或屏蔽这些信息可能更为合适。
textacy 提供了一些方便的 replace 函数用于数据屏蔽(参见 表 4-1)。大部分函数基于正则表达式,可以通过 开源代码 轻松访问。因此,每当需要处理这些项中的任何一个时,textacy 都有一个相应的正则表达式可以直接使用或根据需要进行调整。让我们通过一个简单的调用来说明这一点,以找到语料库中最常用的 URL:
from textacy.preprocessing.resources import RE_URL
count_words(df, column='clean_text', preprocess=RE_URL.findall).head(3)
| token | freq |
|---|---|
| www.getlowered.com | 3 |
| www.ecolamautomotive.com/#!2/kv7fq | 2 |
| www.reddit.com/r/Jeep/comm… | 2 |
对于我们在本数据集中要执行的分析(见 第十章),我们对这些 URL 不感兴趣。它们更多地代表了一个干扰因素。因此,我们将用 replace_urls 替换文本中的所有 URL,实际上这只是调用了 RE_URL.sub。textacy 所有替换函数的默认替换是用下划线括起来的通用标记,如 _URL_。您可以通过指定 replace_with 参数选择自己的替换。通常情况下,不完全移除这些项是有意义的,因为这样可以保持句子的结构不变。下面的调用演示了这一功能:
from textacy.preprocessing.replace import replace_urls
text = "Check out https://spacy.io/usage/spacy-101"
# using default substitution _URL_
print(replace_urls(text))
Out:
Check out _URL_
为了最终完成数据清理,我们对数据应用标准化和数据屏蔽函数:
df['clean_text'] = df['clean_text'].map(replace_urls)
df['clean_text'] = df['clean_text'].map(normalize)
数据清理就像打扫你的房子一样。你总会发现一些脏角落,而且你永远也不可能把房子完全打扫干净。所以当它足够干净时你就停止清理了。这就是我们目前对数据的假设。在后续的过程中,如果分析结果受到剩余噪音的影响,我们可能需要重新进行数据清理。
最后,我们将文本列重命名为 clean_text 变成 text,删除杂质列,并将 DataFrame 的新版本存储在数据库中。
df.rename(columns={'text': 'raw_text', 'clean_text': 'text'}, inplace=True)
df.drop(columns=['impurity'], inplace=True)
con = sqlite3.connect(db_name)
df.to_sql("posts_cleaned", con, index=False, if_exists="replace")
con.close()
分词
我们已经在 第一章 中引入了一个正则表达式分词器,它使用了一个简单的规则。然而,在实践中,如果我们希望正确处理一切,分词可能会相当复杂。考虑下面的文本片段作为例子:
text = """
2019-08-10 23:32: @pete/@louis - I don't have a well-designed
solution for today's problem. The code of module AC68 should be -1.
Have to think a bit... #goodnight ;-) 😩😬"""
显然,定义词和句边界的规则并不是那么简单。那么什么是一个标记?不幸的是,没有明确的定义。我们可以说,一个标记是一个在语义分析中有用的语言单元。这个定义意味着分词在某种程度上依赖于应用程序。例如,在许多情况下,我们可以简单地丢弃标点符号,但如果我们想要保留表情符号像 :-) 用于情感分析,则不行。对于包含数字或标签的标记也是如此。尽管大多数分词器,包括 NLTK 和 spaCy 中使用的分词器,都基于正则表达式,但它们应用的规则相当复杂,有时是语言特定的。
我们将首先为基于分词的正则表达式开发我们自己的蓝图,然后简要介绍 NLTK 的分词器。spaCy 中的分词将在本章的下一节作为 spaCy 综合处理的一部分进行讨论。
蓝图:正则表达式分词
分词的有用函数包括re.split()和re.findall()。前者在匹配表达式时将字符串分割,而后者提取所有匹配特定模式的字符序列。例如,在第一章中,我们使用 POSIX 模式\[\w-]*\p{L}\[\w-]*和regex库来查找至少包含一个字母的字母数字字符序列。scikit-learn 的CountVectorizer默认使用模式\w\w+进行分词。它匹配所有由两个或更多字母数字字符组成的序列。应用于我们的示例句子时,它产生以下结果:^(3)
tokens = re.findall(r'\w\w+', text)
print(*tokens, sep='|')
输出:
2019|08|10|23|32|pete|louis|don|have|well|designed|solution|for|today
problem|The|code|of|module|AC68|should|be|Have|to|think|bit|goodnight
遗憾的是,所有特殊字符和表情符号都丢失了。为了改善结果,我们添加了一些表情符号的额外表达式,并创建了可重用的正则表达式RE_TOKEN。VERBOSE选项允许对复杂表达式进行可读格式化。以下是tokenize函数和示例说明其用法:
RE_TOKEN = re.compile(r"""
( [#]?[@\w'’\.\-\:]*\w # words, hashtags and email addresses
| [:;<]\-?[\)\(3] # coarse pattern for basic text emojis
| [\U0001F100-\U0001FFFF] # coarse code range for unicode emojis
)
""", re.VERBOSE)
def tokenize(text):
return RE_TOKEN.findall(text)
tokens = tokenize(text)
print(*tokens, sep='|')
输出:
2019-08-10|23:32|@pete|@louis|I|don't|have|a|well-designed|solution
for|today's|problem|The|code|of|module|AC68|should|be|-1|Have|to|think
a|bit|#goodnight|;-)|😩|😬
这个表达式应该能够在大多数用户生成内容上产生合理的结果。它可用于快速为数据探索分词,正如在第一章中所解释的那样。对于 scikit-learn 向量化器的默认分词,它也是一个很好的替代选择,该向量化器将在下一章中介绍。
使用 NLTK 进行分词
让我们简要了解一下 NLTK 的分词器,因为 NLTK 经常用于分词。标准的 NLTK 分词器可以通过快捷方式word_tokenize调用。它在我们的示例文本上产生以下结果:
import nltk
tokens = nltk.tokenize.word_tokenize(text)
print(*tokens, sep='|')
输出:
2019-08-10|23:32|:|@|pete/|@|louis|-|I|do|n't|have|a|well-designed
solution|for|today|'s|problem|.|The|code|of|module|AC68|should|be|-1|.
Have|to|think|a|bit|...|#|goodnight|;|-|)||😩😬
该函数在内部结合了TreebankWordTokenizer和PunktSentenceTokenizer。它适用于标准文本,但在处理标签或文本表情符号时存在缺陷。NLTK 还提供了RegexpTokenizer,它基本上是re.findall()的包装器,具有一些附加的便利功能。此外,NLTK 中还有其他基于正则表达式的分词器,如TweetTokenizer或多语言ToktokTokenizer,您可以在本章的GitHub笔记本中查看。
分词建议
如果您的目标是在特定领域的标记模式上达到高精度,您可能需要使用自定义正则表达式。幸运的是,您可以在开源库中找到许多常见模式的正则表达式,并根据自己的需求进行调整。^(4)
一般情况下,您应该注意应用程序中的以下问题案例,并定义如何处理它们:^(5)
-
包含句点的标记,比如
Dr.、Mrs.、U.、xyz.com -
连字符,比如
rule-based -
缩写词(连接词缩写),如
couldn't、we've或je t'aime -
数字表达式,如电话号码(
(123) 456-7890)或日期(2019 年 8 月 7 日) -
表情符号,主题标签,电子邮件地址或网址
常见库中的分词器特别在这些标记方面有所不同。
使用 spaCy 进行语言处理
spaCy 是一个强大的语言数据处理库。它提供了一个集成的处理组件流水线,默认包括分词器、词性标注器、依存解析器和命名实体识别器(详见 图 4-2)。分词基于复杂的语言相关规则和正则表达式,而所有后续步骤都使用预训练的神经模型。
图 4-2. spaCy 的自然语言处理流水线。
spaCy 的哲学是在整个处理过程中保留原始文本。而不是转换它,spaCy 添加了信息层。用于表示处理过的文本的主要对象是 Doc 对象,它本身包含了 Token 对象的列表。任何一组标记的选择都会创建一个 Span。每种这些对象类型都有逐步确定的属性。
在本节中,我们将解释如何使用 spaCy 处理文档,如何处理标记及其属性,如何使用词性标签以及如何提取命名实体。我们将在 第 12 章 深入探讨 spaCy 更高级的概念,其中我们编写自己的管道组件,创建自定义属性,并使用解析器生成的依存树进行知识提取。
警告
本书示例的开发使用的是 spaCy 版本 2.3.2. 如果您已经在使用仍在开发中的 spaCy 3.0,那么您的结果可能会略有不同。
实例化管道
让我们开始使用 spaCy。作为第一步,我们需要通过调用 spacy.load() 并指定要使用的模型文件来实例化 spaCy 的 Language 类的对象。^(6) 在本章中,我们将使用小型英语语言模型 en_core_web_sm。通常用于 Language 对象的变量是 nlp:
import spacy
nlp = spacy.load('en_core_web_sm')
现在这个 Language 对象包含了共享的词汇表、模型和处理管道。您可以通过该对象的属性检查管道组件:
nlp.pipeline
输出:
[('tagger', <spacy.pipeline.pipes.Tagger at 0x7fbd766f84c0>),
('parser', <spacy.pipeline.pipes.DependencyParser at 0x7fbd813184c0>),
('ner', <spacy.pipeline.pipes.EntityRecognizer at 0x7fbd81318400>)]
默认的处理流程包括标注器、解析器和命名实体识别器(ner),所有这些都是依赖于语言的。分词器没有明确列出,因为这一步骤总是必要的。
spaCy 的分词速度相当快,但所有其他步骤都基于神经模型,消耗大量时间。与其他库相比,spaCy 的模型速度是最快的。处理整个流程大约需要 10 到 20 倍于仅仅进行分词的时间,每个步骤所占的时间相似。例如,对 1,000 个文档进行分词如果需要一秒钟,标记、解析和命名实体识别可能会额外花费五秒钟。如果处理大型数据集,这可能成为问题。因此,最好关闭你不需要的部分。
通常你只需要分词器和词性标注器。在这种情况下,你应该像这样禁用解析器和命名实体识别:
nlp = spacy.load("en_core_web_sm", disable=["parser", "ner"])
如果你只需要分词器而不需要其他东西,你也可以简单地在文本上调用 nlp.make_doc。
文本处理
通过调用 nlp 对象执行流水线。调用返回一个 spacy.tokens.doc.Doc 类型的对象,一个访问 token、span(token 范围)及其语言标注的容器。
nlp = spacy.load("en_core_web_sm")
text = "My best friend Ryan Peters likes fancy adventure games."
doc = nlp(text)
spaCy 是面向对象的同时也是非破坏性的。原始文本始终保留。当你打印 doc 对象时,它使用 doc.text,这个属性包含原始文本。但 doc 也是一个 token 的容器,你可以像对待迭代器一样使用它们:
for token in doc:
print(token, end="|")
Out:
My|best|friend|Ryan|Peters|likes|fancy|adventure|games|.|
每个 token 实际上是 spaCy 类 Token 的对象。Token 和 doc 都有许多用于语言处理的有趣属性。Table 4-2 显示了每个流水线组件创建的这些属性。^(7)
表 4-2. spaCy 内置流水线创建的属性选择
| 组件 | 创建 |
|---|---|
| 分词器 | Token.is_punct, Token.is_alpha, Token.like_email, Token.like_url |
| 词性标注器 | Token.pos_ |
| 依赖解析器 | Token.dep_, Token.head, Doc.sents, Doc.noun_chunks |
| 命名实体识别器 | Doc.ents, Token.ent_iob_, Token.ent_type_ |
我们提供了一个小型实用函数 display_nlp,生成包含 token 及其属性的表格。在内部,我们为此创建了一个 DataFrame,并将文档中的 token 位置用作索引。默认情况下,此函数跳过标点符号。Table 4-3 显示了我们示例句子的输出:
def display_nlp(doc, include_punct=False):
"""Generate data frame for visualization of spaCy tokens."""
rows = []
for i, t in enumerate(doc):
if not t.is_punct or include_punct:
row = {'token': i, 'text': t.text, 'lemma_': t.lemma_,
'is_stop': t.is_stop, 'is_alpha': t.is_alpha,
'pos_': t.pos_, 'dep_': t.dep_,
'ent_type_': t.ent_type_, 'ent_iob_': t.ent_iob_}
rows.append(row)
df = pd.DataFrame(rows).set_index('token')
df.index.name = None
return df
表 4-3. spaCy 文档处理的结果,由 display_nlp 生成
| text | lemma_ | is_stop | is_alpha | pos_ | dep_ | ent_type_ | ent_iob_ | |
|---|---|---|---|---|---|---|---|---|
| 0 | My | -PRON- | True | True | DET | poss | O | |
| 1 | best | good | False | True | ADJ | amod | O | |
| 2 | friend | friend | False | True | NOUN | nsubj | O | |
| 3 | Ryan | Ryan | False | True | PROPN | compound | PERSON | B |
| 4 | Peters | Peters | False | True | PROPN | appos | PERSON | I |
| 5 | likes | like | False | True | VERB | ROOT | O | |
| 6 | fancy | fancy | False | True | ADJ | amod | O | |
| 7 | adventure | adventure | False | True | NOUN | compound | O | |
| 8 | games | game | False | True | NOUN | dobj | O |
对于每个标记,您可以找到词元、一些描述性标志、词性标签、依赖标签(这里未使用,但在第十二章中使用),以及可能有关实体类型的信息。is_<something> 标志是基于规则创建的,但所有词性、依赖和命名实体属性都基于神经网络模型。因此,这些信息总是存在一定的不确定性。用于训练的语料库包含新闻文章和在线文章的混合体。如果您的数据具有相似的语言特征,则模型的预测非常准确。但是,如果您的数据非常不同——例如,您正在处理 Twitter 数据或 IT 服务台工单——您应该意识到这些信息是不可靠的。
警告
spaCy 使用带有下划线的标记属性约定,例如 pos_ 返回可读的文本表示。不带下划线的 pos 返回 spaCy 的词性标签的数值标识符。^(8) 这些数值标识符可以作为常量导入,例如 spacy.symbols.VERB。请确保不要混淆它们!
蓝图:自定义标记化
标记化是管道中的第一步,一切都依赖于正确的标记。在大多数情况下,spaCy 的标记器表现良好,但有时会在井号、连字符和下划线上分割,这并不总是您想要的。因此,可能需要调整其行为。让我们以以下文本作为例子:
text = "@Pete: choose low-carb #food #eat-smart. _url_ ;-) 😋👍"
doc = nlp(text)
for token in doc:
print(token, end="|")
Out:
@Pete|:|choose|low|-|carb|#|food|#|eat|-|smart|.|_|url|_|;-)|😋|👍|
spaCy 的标记器完全基于规则。首先,它在空格字符上分割文本。然后,它使用由正则表达式定义的前缀、后缀和中缀分割规则来进一步分割剩余的标记。异常规则用于处理语言特定的异常情况,如 can’t,应该分割为 ca 和 n’t,词元为 can 和 not。^(9)
如您在示例中所见,spaCy 的英文标记器包含一个中缀规则,用于在连字符处拆分。此外,它还有一个前缀规则,用于拆分类似 # 或 _ 的字符。它对以 @ 开头的标记和表情符号也适用。
一种选项是在后处理步骤中使用 doc.retokenize 合并标记。然而,这并不能修复任何计算错误的词性标签和句法依赖,因为这些依赖于标记化。因此,更改标记化规则并在一开始创建正确的标记可能会更好。
对于这个问题,最好的方法是创建自己的分词器变体,具有单独的中缀、前缀和后缀分割规则。^(10) 下面的函数以“最小侵入”方式创建了一个具有单独规则的分词器对象:我们只是从 spaCy 的默认规则中删除了相应的模式,但保留了主要部分的逻辑:
from spacy.tokenizer import Tokenizer
from spacy.util import compile_prefix_regex, \
compile_infix_regex, compile_suffix_regex
def custom_tokenizer(nlp):
# use default patterns except the ones matched by re.search
prefixes = [pattern for pattern in nlp.Defaults.prefixes
if pattern not in ['-', '_', '#']]
suffixes = [pattern for pattern in nlp.Defaults.suffixes
if pattern not in ['_']]
infixes = [pattern for pattern in nlp.Defaults.infixes
if not re.search(pattern, 'xx-xx')]
return Tokenizer(vocab = nlp.vocab,
rules = nlp.Defaults.tokenizer_exceptions,
prefix_search = compile_prefix_regex(prefixes).search,
suffix_search = compile_suffix_regex(suffixes).search,
infix_finditer = compile_infix_regex(infixes).finditer,
token_match = nlp.Defaults.token_match)
nlp = spacy.load('en_core_web_sm')
nlp.tokenizer = custom_tokenizer(nlp)
doc = nlp(text)
for token in doc:
print(token, end="|")
Out:
@Pete|:|choose|low-carb|#food|#eat-smart|.|_url_|;-)|😋|👍|
警告
在修改分词的过程中要小心,因为它们的影响可能会很微妙,修复一组案例可能会破坏另一组案例。例如,通过我们的修改,像Chicago-based这样的标记将不再被分割。此外,如果 Unicode 字符的连字符和破折号没有被规范化,可能会出现问题。
蓝图:处理停用词
spaCy 使用语言特定的停用词列表直接在分词后为每个标记设置is_stop属性。因此,过滤停用词(以及类似的标点符号标记)非常容易:
text = "Dear Ryan, we need to sit down and talk. Regards, Pete"
doc = nlp(text)
non_stop = [t for t in doc if not t.is_stop and not t.is_punct]
print(non_stop)
Out:
[Dear, Ryan, need, sit, talk, Regards, Pete]
可以通过导入spacy.lang.en.STOP_WORDS来访问包含超过 300 个条目的英文停用词列表。当创建一个nlp对象时,该列表被加载并存储在nlp.Defaults.stop_words下。我们可以通过设置 spaCy 词汇表中相应单词的is_stop属性来修改 spaCy 的默认行为:^(11)
nlp = spacy.load('en_core_web_sm')
nlp.vocab['down'].is_stop = False
nlp.vocab['Dear'].is_stop = True
nlp.vocab['Regards'].is_stop = True
如果我们重新运行上一个示例,我们将得到以下结果:
[Ryan, need, sit, down, talk, Pete]
蓝图:基于词性提取词元
词形还原是将单词映射到其未屈折根的过程。像housing、housed和house这样的词被视为相同,对于统计、机器学习和信息检索具有许多优势。它不仅可以改善模型的质量,还可以减少训练时间和模型大小,因为词汇量如果只保留未屈折形式会更小。此外,将单词类型限制为特定类别,如名词、动词和形容词,通常也是有帮助的。这些词类型称为词性标签。
让我们首先深入了解词形还原。可以通过lemma_属性访问标记或跨度的词元,如下例所示:
text = "My best friend Ryan Peters likes fancy adventure games."
doc = nlp(text)
print(*[t.lemma_ for t in doc], sep='|')
Out:
-PRON-|good|friend|Ryan|Peters|like|fancy|adventure|game|.
正确地分配词元需要查找字典和对单词的词性的知识。例如,名词meeting的词元是meeting,而动词meet的词元是meet。在英语中,spaCy 能够做到这种区分。然而,在大多数其他语言中,词形还原纯粹基于字典,忽略了词性依赖。请注意,像I、me、you和her这样的人称代词在 spaCy 中总是得到词元-PRON-。
在这份蓝图中我们将使用的另一个标记属性是词性标记。表 4-3 显示 spaCy 文档中的每个标记都有两个词性属性:pos_ 和 tag_。 tag_ 是从用于训练模型的标记集中提取的标记。对于 spaCy 的英语模型,它们是在 OntoNotes 5 语料库上训练的,这是宾夕法尼亚树库标记集。对于德语模型,这将是斯图加特-图宾根标记集。 pos_ 属性包含通用词性标记集的简化标记。^(12) 我们建议使用此属性,因为其值将在不同模型之间保持稳定。表 4-4 显示了完整的标记集描述。
表 4-4. 通用词性标记
| Tag | 描述 | 例子 |
|---|---|---|
| ADJ | 形容词(描述名词) | 大的,绿色的,非洲的 |
| ADP | 介词(前置词和后置词) | 在,上 |
| ADV | 副词(修改动词或形容词) | 非常,确切地,总是 |
| AUX | 助动词(伴随动词) | 能(做),是(在做) |
| CCONJ | 连接连词 | 和,或,但是 |
| DET | 限定词(关于名词) | 这个,一个,所有(事物),你的(想法) |
| INTJ | 感叹词(独立词,感叹词,表达情感) | 嗨,是的 |
| NOUN | 名词(普通名词和专有名词) | 房子,电脑 |
| NUM | 基数 | 九,9,IX |
| PROPN | 专有名词,名字或名字的一部分 | 彼得,柏林 |
| PRON | 代词,代替名词 | 我,你,我自己,谁 |
| PART | 粒子(只有与其他单词一起才有意义) | |
| PUNCT | 标点符号字符 | ,。; |
| SCONJ | 从属连词 | 在…之前,因为,如果 |
| SYM | 符号(类似单词) | $,© |
| VERB | 动词(所有时态和方式) | 去,去过,思考 |
| X | 任何无法分配的东西 | grlmpf |
词性标记是作为单词过滤器的出色选择。在语言学中,代词、介词、连词和限定词被称为功能词,因为它们的主要功能是在句子内创建语法关系。名词、动词、形容词和副词是内容词,句子的意义主要取决于它们。
通常,我们只对内容词感兴趣。因此,我们可以使用词性标记来选择我们感兴趣的单词类型,并且丢弃其余部分。例如,可以生成一个仅包含文档中名词和专有名词的列表:
text = "My best friend Ryan Peters likes fancy adventure games."
doc = nlp(text)
nouns = [t for t in doc if t.pos_ in ['NOUN', 'PROPN']]
print(nouns)
输出:
[friend, Ryan, Peters, adventure, games]
我们可以很容易地为此目的定义一个更通用的过滤器函数,但是 textacy 的 extract.words 函数方便地提供了此功能。它还允许我们根据词性和其他标记属性(如 is_punct 或 is_stop)进行过滤。因此,过滤函数允许同时进行词性选择和停用词过滤。在内部,它的工作原理与我们之前展示的名词过滤器所示的方式相同。
以下示例展示了如何从样本句子中提取形容词和名词的标记:
import textacy
tokens = textacy.extract.words(doc,
filter_stops = True, # default True, no stopwords
filter_punct = True, # default True, no punctuation
filter_nums = True, # default False, no numbers
include_pos = ['ADJ', 'NOUN'], # default None = include all
exclude_pos = None, # default None = exclude none
min_freq = 1) # minimum frequency of words
print(*[t for t in tokens], sep='|')
Out:
best|friend|fancy|adventure|games
最终,我们提取过滤后的词元列表的蓝图函数只是这个函数的一个小包装。通过转发关键字参数(**kwargs),这个函数接受与 textacy 的extract.words相同的参数。
def extract_lemmas(doc, **kwargs):
return [t.lemma_ for t in textacy.extract.words(doc, **kwargs)]
lemmas = extract_lemmas(doc, include_pos=['ADJ', 'NOUN'])
print(*lemmas, sep='|')
Out:
good|friend|fancy|adventure|game
注意
使用词元而不是屈折词通常是个好主意,但并非总是如此。例如,在情感分析中,“好”和“最好”会产生不同的效果。
蓝图:提取名词短语
在第一章中,我们说明了如何使用 n-gram 进行分析。n-gram 是句子中n个连续词的简单枚举。例如,我们之前使用的句子包含以下二元组:
My_best|best_friend|friend_Ryan|Ryan_Peters|Peters_likes|likes_fancy
fancy_adventure|adventure_games
许多这些二元组对于分析并不十分有用,例如,likes_fancy或my_best。对于三元组而言情况可能会更糟。但是我们如何检测具有实际含义的词序列呢?一种方法是对词性标记应用模式匹配。spaCy 具有一个相当强大的基于规则的匹配器,而 textacy 则提供了一个便捷的基于模式的短语提取包装器。以下模式提取带有前置形容词的名词序列:
text = "My best friend Ryan Peters likes fancy adventure games."
doc = nlp(text)
patterns = ["POS:ADJ POS:NOUN:+"]
spans = textacy.extract.matches(doc, patterns=patterns)
print(*[s.lemma_ for s in spans], sep='|')
Out:
good friend|fancy adventure|fancy adventure game
或者,您可以使用 spaCy 的doc.noun_chunks进行名词短语提取。但是,由于返回的块还可能包括代词和限定词,因此此功能不太适合用于特征提取:
print(*doc.noun_chunks, sep='|')
Out:
My best friend|Ryan Peters|fancy adventure games
因此,我们根据词性模式定义了我们的名词短语提取蓝图。该函数接受一个doc,一组词性标记以及一个分隔字符,用于连接名词短语中的单词。构造的模式搜索由形容词或名词后跟名词序列组成的短语。返回的是词元。我们的例子提取所有由形容词或名词后跟名词序列组成的短语:
def extract_noun_phrases(doc, preceding_pos=['NOUN'], sep='_'):
patterns = []
for pos in preceding_pos:
patterns.append(f"POS:{pos} POS:NOUN:+")
spans = textacy.extract.matches(doc, patterns=patterns)
return [sep.join([t.lemma_ for t in s]) for s in spans]
print(*extract_noun_phrases(doc, ['ADJ', 'NOUN']), sep='|')
Out:
good_friend|fancy_adventure|fancy_adventure_game|adventure_game
蓝图:提取命名实体
命名实体识别指的是在文本中检测人物、地点或组织等实体的过程。每个实体可以由一个或多个标记组成,例如旧金山。因此,命名实体由Span对象表示。与名词短语类似,检索命名实体的列表以供进一步分析也很有帮助。
如果你再次查看表 4-3,你会看到用于命名实体识别的标记属性,ent_type_和ent_iob_。ent_iob_包含了一个标记是否开始一个实体(B)、是否在一个实体内部(I)或是否在外部(O)的信息。与遍历标记不同,我们还可以直接通过doc.ents访问命名实体。在这里,实体类型的属性被称为label_。让我们通过一个例子来说明这一点:
text = "James O'Neill, chairman of World Cargo Inc, lives in San Francisco."
doc = nlp(text)
for ent in doc.ents:
print(f"({ent.text}, {ent.label_})", end=" ")
Out:
(James O'Neill, PERSON) (World Cargo Inc, ORG) (San Francisco, GPE)
spaCy 的displacy模块还提供命名实体识别的可视化,这大大增强了结果的可读性,并在视觉上支持误分类实体的识别:
from spacy import displacy
displacy.render(doc, style='ent')
命名实体被正确识别为一个人物、一个组织和一个地缘政治实体(GPE)。但请注意,如果您的语料库缺乏明确的语法结构,则命名实体识别的准确性可能不会很高。详细讨论请参阅“命名实体识别”。
对于特定类型的命名实体提取,我们再次利用 textacy 的一个便利函数:
def extract_entities(doc, include_types=None, sep='_'):
ents = textacy.extract.entities(doc,
include_types=include_types,
exclude_types=None,
drop_determiners=True,
min_freq=1)
return [sep.join([t.lemma_ for t in e])+'/'+e.label_ for e in ents]
例如,使用此函数我们可以检索PERSON和GPE(地缘政治实体)类型的命名实体:
print(extract_entities(doc, ['PERSON', 'GPE']))
Out:
["James_O'Neill/PERSON", 'San_Francisco/GPE']
大型数据集上的特征提取
现在我们了解了 spaCy 提供的工具,我们最终可以构建我们的语言特征提取器了。图 4-3 说明了我们要做的事情。最终,我们希望创建一个可用作统计分析和各种机器学习算法输入的数据集。一旦提取完成,我们将在数据库中持久化预处理好的“即用”数据。
图 4-3. 使用 spaCy 从文本中提取特征。
蓝图:创建一个函数来获取所有内容
此蓝图函数将前面章节中的所有提取功能结合起来。它将我们想要提取的所有内容整齐地放在代码中的一个位置,这样如果您在此处添加或更改内容,后续步骤无需调整:
def extract_nlp(doc):
return {
'lemmas' : extract_lemmas(doc,
exclude_pos = ['PART', 'PUNCT',
'DET', 'PRON', 'SYM', 'SPACE'],
filter_stops = False),
'adjs_verbs' : extract_lemmas(doc, include_pos = ['ADJ', 'VERB']),
'nouns' : extract_lemmas(doc, include_pos = ['NOUN', 'PROPN']),
'noun_phrases' : extract_noun_phrases(doc, ['NOUN']),
'adj_noun_phrases': extract_noun_phrases(doc, ['ADJ']),
'entities' : extract_entities(doc, ['PERSON', 'ORG', 'GPE', 'LOC'])
}
该函数返回一个包含我们想要提取的所有内容的字典,如本例所示:
text = "My best friend Ryan Peters likes fancy adventure games."
doc = nlp(text)
for col, values in extract_nlp(doc).items():
print(f"{col}: {values}")
Out:
lemmas: ['good', 'friend', 'Ryan', 'Peters', 'like', 'fancy', 'adventure', \
'game']
adjs_verbs: ['good', 'like', 'fancy']
nouns: ['friend', 'Ryan', 'Peters', 'adventure', 'game']
noun_phrases: ['adventure_game']
adj_noun_phrases: ['good_friend', 'fancy_adventure', 'fancy_adventure_game']
entities: ['Ryan_Peters/PERSON']
返回的列名列表将在接下来的步骤中需要。我们不是硬编码它,而是简单地调用extract_nlp并传入一个空文档来检索列表:
nlp_columns = list(extract_nlp(nlp.make_doc('')).keys())
print(nlp_columns)
Out:
['lemmas', 'adjs_verbs', 'nouns', 'noun_phrases', 'adj_noun_phrases', 'entities']
蓝图:在大型数据集上使用 spaCy
现在我们可以使用此函数从数据集的所有记录中提取特征。我们获取并添加在本章开头创建和保存的清理文本及其标题:
db_name = "reddit-selfposts.db"
con = sqlite3.connect(db_name)
df = pd.read_sql("select * from posts_cleaned", con)
con.close()
df['text'] = df['title'] + ': ' + df['text']
在开始自然语言处理处理之前,我们初始化要填充值的新DataFrame列:
for col in nlp_columns:
df[col] = None
spaCy 的神经模型受益于在 GPU 上运行。因此,在开始之前,我们尝试在 GPU 上加载模型:
if spacy.prefer_gpu():
print("Working on GPU.")
else:
print("No GPU found, working on CPU.")
现在我们需要决定使用哪个模型和流水线组件。记住要禁用不必要的组件以提高运行时效率!我们坚持使用默认流水线的小型英语模型,并使用我们自定义的分词器,在连字符上进行分割:
nlp = spacy.load('en_core_web_sm', disable=[])
nlp.tokenizer = custom_tokenizer(nlp) # optional
在处理较大数据集时,建议使用 spaCy 的批处理来获得显著的性能提升(在我们的数据集上大约提升了 2 倍)。函数nlp.pipe接受一个文本的可迭代对象,在内部作为一个批次处理它们,并按照输入数据的顺序生成处理过的Doc对象列表。
要使用它,我们首先必须定义一个批处理大小。然后我们可以循环处理这些批次并调用nlp.pipe。
batch_size = 50
for i in range(0, len(df), batch_size):
docs = nlp.pipe(df['text'][i:i+batch_size])
for j, doc in enumerate(docs):
for col, values in extract_nlp(doc).items():
df[col].iloc[i+j] = values
在内部循环中,我们从处理过的doc中提取特征,并将这些值写回到DataFrame中。在没有使用 GPU 的数据集上,整个过程大约需要六到八分钟,在 Colab 上使用 GPU 时大约需要三到四分钟。
新创建的列非常适合使用来自第一章的函数进行频率分析。让我们来检查汽车类别中提到最频繁的名词短语:
count_words(df, 'noun_phrases').head(10).plot(kind='barh').invert_yaxis()
Out:
持久化结果
最后,我们将完整的DataFrame保存到 SQLite 中。为此,我们需要将提取的列表序列化为以空格分隔的字符串,因为大多数数据库不支持列表:
df[nlp_columns] = df[nlp_columns].applymap(lambda items: ' '.join(items))
con = sqlite3.connect(db_name)
df.to_sql("posts_nlp", con, index=False, if_exists="replace")
con.close()
结果表提供了一个坚实且可以直接使用的基础,用于进一步的分析。实际上,我们将在第十章再次使用这些数据来训练从提取的词形中得到的词向量。当然,预处理步骤取决于您要对数据执行什么操作。像我们的蓝图生成的单词集合这样的工作非常适合进行基于词袋向量化的任何类型的统计分析和机器学习。您将需要根据依赖于单词序列知识的算法来调整这些步骤。
关于执行时间的注意事项
完整的语言处理确实非常耗时。事实上,仅处理 20,000 个 Reddit 帖子就需要几分钟的时间。相比其他库,虽然 spaCy 处理速度非常快,但标记、解析和命名实体识别却是代价昂贵的。因此,如果您不需要命名实体,您应该明确地禁用解析器和命名实体识别,以节省超过 60%的运行时间。
使用nlp.pipe批处理数据并使用 GPU 是加快 spaCy 数据处理速度的一种方法。但是,一般来说,数据准备也是并行化的一个完美候选项。在 Python 中并行任务的一个选项是使用multiprocessing库。特别是对于数据框操作的并行化,还有一些可伸缩的替代方案值得一试,即Dask、Modin和Vaex。pandarallel是一个直接向 Pandas 添加并行应用运算符的库。
无论如何,观察进展并获取运行时估计都是有帮助的。正如在第一章中已经提到的那样,tqdm库是一个非常好的工具,因为它为迭代器和数据框操作提供了进度条。我们的 GitHub 笔记本在可能的情况下都使用了 tqdm。
还有更多
我们从数据清洗开始,经历了整个语言处理的流程。然而,还有一些方面我们没有详细涉及,但可能对您的项目有帮助,甚至是必要的。
语言检测
许多语料库包含不同语言的文本。每当您处理多语言语料库时,您必须选择其中一个选项:
-
如果其他语言仅代表可以忽略的少数群体,则将每个文本都视为语料库主要语言(例如英语)。
-
将所有文本翻译为主要语言,例如,通过使用谷歌翻译。
-
识别语言并在接下来的步骤中进行依赖语言的预处理。
有一些优秀的语言检测库。我们推荐使用 Facebook 的fastText 库。fastText 提供了一个预训练模型,可以快速准确地识别 176 种语言。我们在本章的GitHub 仓库中提供了一个使用 fastText 进行语言检测的额外蓝图。
textacy 的make_spacy_doc函数允许您在可用时自动加载相应的语言模型进行语言处理。默认情况下,它使用基于Google 的紧凑语言检测器 v3的语言检测模型,但您也可以接入任何语言检测功能(例如,fastText)。
拼写检查
用户生成的内容常常存在很多拼写错误。如果拼写检查器能自动纠正这些错误,那将会很棒。SymSpell是一个流行的拼写检查器,有一个Python 端口。然而,正如您从智能手机上了解到的那样,自动拼写纠正可能会引入有趣的错误。因此,您确实需要检查质量是否真正得到了提高。
令牌标准化
通常,相同术语存在不同拼写方式或要特别对待和统计相同的术语变体。在这种情况下,标准化这些术语并映射到共同标准是很有用的。以下是一些示例:
-
美国或 U.S. → 美国
-
点击泡沫 → 点 com 泡沫
-
慕尼黑 → 慕尼黑
您可以使用 spaCy 的短语匹配器将这种规范化作为后处理步骤集成到其管道中。如果您不使用 spaCy,则可以使用简单的 Python 字典将不同拼写映射到其规范化形式。
总结和建议
“垃圾进,垃圾出”是数据项目中经常提到的问题。这在文本数据中尤为突出,因为文本数据本身就存在噪音。因此,数据清洗是任何文本分析项目中最重要的任务之一。花足够的精力确保高质量的数据,并进行系统化的检查。在本节中,我们展示了许多解决质量问题的方案。
可靠分析和稳健模型的第二个前提是规范化。许多文本机器学习算法基于词袋模型,该模型根据单词频率生成文档间的相似度概念。一般来说,当进行文本分类、主题建模或基于 TF-IDF 进行聚类时,最好使用词形还原后的文本。在更复杂的机器学习任务(如文本摘要、机器翻译或问答系统)中,模型需要反映语言的多样性,因此应避免或仅在必要时使用这些规范化或停用词去除方法。
^(1) Pandas 操作map和apply已在《蓝图:构建简单文本预处理流水线》中详细解释。
^(2) 专门用于 HTML 数据清理的库,如 Beautiful Soup,已在第三章中介绍。
^(3) 星号操作符(*)将列表展开为print函数的单独参数。
^(4) 例如,查看NLTK 的推特分词器用于文本表情符号和 URL 的正则表达式,或查看 textacy 的编译正则表达式。
^(5) 有关概述,请参阅Craig Trim 的《Tokenization 艺术》。
^(6) 查看spaCy 的网站,了解可用模型的列表。
^(7) 查看spaCy 的 API获取完整列表。
^(8) 查看spaCy 的 API获取完整的属性列表。
^(9) 查看spaCy 的分词使用文档获取详细信息和说明性示例。
^(10) 查看spaCy 的分词器使用文档获取详细信息。
^(11) 通过这种方式修改停用词列表可能在 spaCy 3.0 中被弃用。相反,建议创建各语言类的修改子类。本章的GitHub 笔记本提供详细信息。
^(12) 有关更多信息,请参阅通用词性标签。
第五章:特征工程和句法相似性
正如我们在第一章中看到的那样,文本与结构化数据有着显著的不同。最引人注目的差异之一是,文本由单词表示,而结构化数据(大部分情况下)使用数字。从科学角度来看,数学研究的几个世纪已经对数字有了非常好的理解和复杂的方法。信息科学吸收了这些数学研究,并在此基础上发明了许多创造性的算法。机器学习的最新进展已经将许多以前非常特定的算法泛化,并使其适用于许多不同的用例。这些方法直接从数据中“学习”,并提供了一个无偏见的视角。
要使用这些工具,我们必须找到文本到数字的映射。考虑到文本的丰富性和复杂性,显然单一的数字无法代表文档的含义。需要更复杂的东西。在数学中,实数的自然扩展是一组实数,称为向量。几乎所有文本分析和机器学习中的文本表示都使用向量;详见第六章。
向量存在于向量空间中,大多数向量空间具有额外的属性,如范数和距离,这对我们是有帮助的,因为它们暗示了相似性的概念。正如我们将在后续章节中看到的,测量文档之间的相似性对于大多数文本分析应用至关重要,但它本身也很有趣。
您将学到什么,我们将构建什么
在本章中,我们将讨论文档的向量化。这意味着我们将把非结构化文本转换为包含数字的向量。
有很多方法可以用来向量化文档。由于文档向量化是所有机器学习任务的基础,我们将花一些时间来设计和实现我们自己的向量化器。如果您需要一个专门用于自己项目的向量化器,您可以将其用作蓝图。
随后,我们将关注两种已在 scikit-learn 中实现的流行模型:词袋模型和 TF-IDF 改进模型。我们将使用这些方法下载大量文档数据集并进行向量化。正如您将看到的那样,数据量和可扩展性可能会带来许多问题。
尽管向量化是更复杂的机器学习算法的基础技术,它也可以单独用于计算文档之间的相似性。我们将详细讨论其工作原理、优化方法以及如何实现可扩展性。对于更丰富的词表示,请参阅第十章,对于更上下文化的方法,请参阅第十一章。
在学习了本章后,您将了解如何使用单词或组合作为特征将文档转换为数字(向量)。^(1) 我们将尝试不同的文档向量化方法,您将能够确定适合您用例的正确方法。您将了解文档相似性为何重要以及计算它的标准方法。我们将通过一个包含许多文档的示例详细介绍如何有效地向量化它们并计算相似性。
第一部分通过实际构建一个简单的向量化器介绍了向量化器的概念。这可以作为您在项目中必须构建的更复杂的向量化器的蓝图。计算单词出现次数并将其用作向量称为词袋模型,并且已经创建了非常多功能的模型。
与数据集(拥有超过 1,000,000 个新闻标题)一起,我们在 TF-IDF 部分介绍了一个用例,并展示了可扩展的蓝图架构。我们将建立一个文档向量化的蓝图和文档的相似性搜索。更具挑战性的是,我们将尝试识别语料库中最相似(但非完全相同)的标题。
用于实验的玩具数据集
非常令人惊讶的是,许多实验证明,对于许多文本分析问题,只需知道单词是否出现在文档中就足够了。不必理解单词的含义或考虑单词顺序。由于底层映射特别简单且计算速度快,我们将从这些映射开始,并使用单词作为特征。
对于第一个蓝图,我们将集中在方法上,因此使用查尔斯·狄更斯的小说双城记中的几句话作为玩具数据集。我们将使用以下句子:
-
最好的时代已经来临。
-
最坏的时代已经过去。
-
智慧的时代已经来临。
-
愚蠢的时代已经过去。
蓝图:建立您自己的向量化器
由于向量化文档是本书后续章节的基础,我们深入探讨了向量化器的工作原理。通过实现我们自己的向量化器来最好地实现这一点。如果您需要在自己的项目中实现自定义向量化器或需要根据特定要求调整现有向量化器,可以使用本节中的方法。
为了尽可能简化,我们将实现所谓的单热向量化器。该向量化器通过记录单词是否出现在文档中来创建二进制向量,如果出现则为 1,否则为 0。
我们将开始创建一个词汇表并为单词分配编号,然后进行向量化,并在此二进制空间中分析相似性。
枚举词汇表
从单词作为特征开始,我们必须找到一种将单词转换为向量维度的方法。从文本中提取单词通过标记化完成,如第二章中解释的那样。^(2)
因为我们只关心一个单词是否出现在文档中,所以我们只需列举这些单词:
sentences = ["It was the best of times",
"it was the worst of times",
"it was the age of wisdom",
"it was the age of foolishness"]
tokenized_sentences = [[t for t in sentence.split()] for sentence in sentences]
vocabulary = set([w for s in tokenized_sentences for w in s])
import pandas as pd
pd.DataFrame([[w, i] for i,w in enumerate(vocabulary)])
输出:
| 它 | 0 |
|---|---|
| 年龄 | 1 |
| 最好 | 2 |
| 愚蠢 | 3 |
| 它 | 4 |
| 的 | 5 |
| 的 | 6 |
| 时代 | 7 |
| 是 | 8 |
| 智慧 | 9 |
| 最坏 | 10 |
正如你所看到的,单词根据它们第一次出现的顺序进行了编号。这就是我们所说的“字典”,包括单词(词汇表)及其相应的编号。现在,我们可以使用这些数字而不是单词来排列它们到以下向量中。
文档向量化
要比较向量、计算相似性等等,我们必须确保每个文档的向量具有相同的维度。为了实现这一点,我们对所有文档使用相同的词典。如果文档中不包含某个词,我们就在相应的位置放置一个 0;否则,我们将使用 1。按照惯例,行向量用于表示文档。向量的维度与词典的长度一样大。在我们的例子中,这不是问题,因为我们只有少数几个词。然而,在大型项目中,词汇表很容易超过 10 万个词。
让我们在实际使用库之前计算所有句子的一热编码:
def onehot_encode(tokenized_sentence):
return [1 if w in tokenized_sentence else 0 for w in vocabulary]
onehot = [onehot_encode(tokenized_sentence)
for tokenized_sentence in tokenized_sentences]
for (sentence, oh) in zip(sentences, onehot):
print("%s: %s" % (oh, sentence))
输出:
[0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1]: It was the best of times
[1, 1, 1, 0, 0, 0, 1, 1, 0, 1, 0]: it was the worst of times
[0, 1, 1, 0, 1, 0, 0, 1, 1, 1, 0]: it was the age of wisdom
[0, 1, 1, 1, 1, 0, 0, 1, 0, 1, 0]: it was the age of foolishness
对于每个句子,我们现在计算了一个向量表示。将文档转换为一热向量时,我们丢失了关于单词在文档中出现频率及顺序的信息。
超出词汇表的文档
如果我们尝试保持词汇表固定并添加新文档会发生什么?这取决于文档的单词是否已经包含在词典中。当然,可能所有单词都已知:
onehot_encode("the age of wisdom is the best of times".split())
输出:
[0, 1, 0, 0, 1, 0, 1, 0, 1, 1, 1]
然而,反之也完全可能。如果我们试图将只包含未知单词的句子向量化,我们会得到一个零向量:
onehot_encode("John likes to watch movies. Mary likes movies too.".split())
输出:
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
这个句子在语料库中与其他句子没有“交互”。从严格的角度来看,这个句子与语料库中的任何句子都不相似。对于单个句子来说,这没有问题;如果这种情况经常发生,需要调整词汇表或语料库。
文档-词项矩阵
将每个文档的行向量排列成一个矩阵,其中行枚举文档,我们得到了文档-词项矩阵。文档-词项矩阵是所有文档的向量表示,也是本书中几乎所有机器学习任务的最基本构建块。在本章中,我们将用它来计算文档相似性:
pd.DataFrame(onehot, columns=vocabulary)
输出:
注意:对于文档-词项矩阵,使用列表和数组在词汇量较小时效果最佳。对于大词汇量,我们将不得不找到更聪明的表示方式。Scikit-learn 负责此事,并使用所谓的稀疏向量和矩阵来自SciPy。
计算相似性
计算文档之间的相似性是通过计算对应位置的共同 1 的数量来进行的。在一热编码中,这是一种非常快速的操作,因为可以通过对向量进行AND运算并计算结果向量中的 1 的数量来计算。让我们计算前两个句子的相似性:
sim = [onehot[0][i] & onehot[1][i] for i in range(0, len(vocabulary))]
sum(sim)
Out:
4
我们经常会遇到的另一种计算相似性的可能方式是使用两个文档向量的标量积(通常称为点积)。标量积通过将两个向量的对应分量相乘并将这些乘积相加来计算。通过观察乘积只有在两个因子都为 1 时才为 1 的事实,我们有效地计算了向量中共同 1 的数量。让我们试一试:
np.dot(onehot[0], onehot[1])
Out:
4
相似性矩阵
如果我们有兴趣找出所有文档之间的相似性,有一个很棒的快捷方式可以只用一个命令计算所有的数值!从前一节的公式推广,我们得出文档 i 和文档 j 的相似性如下:
S ij = d i · d j
如果我们想要使用之前的文档-词项矩阵,我们可以将标量积写成一个和:
S ij = ∑ k D ik D jk = ∑ k D ik (D T ) kj = (D·D T ) ij
因此,这只是我们的文档-词项矩阵与其转置的矩阵乘积。在 Python 中,这现在很容易计算(输出中的句子已添加,以便更轻松地检查相似性):^(3)
np.dot(onehot, np.transpose(onehot))
Out:
array([[6, 4, 3, 3], # It was the best of times
[4, 6, 4, 4], # it was the worst of times
[3, 4, 6, 5], # it was the age of wisdom
[3, 4, 5, 6]]) # it was the age of foolishness
显然,最高的数值位于对角线上,因为每个文档最相似于它自己。矩阵必须是对称的,因为文档 A 与 B 的相似性与 B 与 A 的相似性相同。除此之外,我们可以看到第二个句子平均来说与所有其他句子最相似,而第三个和最后一个文档成对最相似(它们仅相差一个单词)。如果忽略大小写,第一个和第二个文档也是如此。
理解文档向量化器的工作原理对于实现自己的向量化器至关重要,但也有助于欣赏现有向量化器的所有功能和参数。这就是为什么我们实现了我们自己的向量化器。我们详细查看了向量化的不同阶段,从构建词汇表开始,然后将文档转换为二进制向量。
后来,我们分析了文档之间的相似性。事实证明,它们对应向量的点积是一个很好的度量。
独热向量在实践中也被广泛使用,例如在文档分类和聚类中。然而,scikit-learn 还提供了更复杂的向量化器,在接下来的几节中我们将使用它们。
词袋模型
独热编码已经为我们提供了文档的基本表示形式作为向量。然而,它没有处理文档中单词的出现次数。如果我们想计算每个文档中单词的频率,那么我们应该使用所谓的词袋表示法。
尽管有些简单,但这些模型被广泛使用。对于分类和情感检测等情况,它们表现合理。此外,还有像潜在狄利克雷分配(LDA)这样的主题建模方法,显式地需要词袋模型。^(4)
蓝图:使用 scikit-learn 的 CountVectorizer
不必自己实现词袋模型,我们使用 scikit-learn 提供的算法。
注意到相应的类被称为CountVectorizer,这是我们在 scikit-learn 中进行特征提取的第一次接触。我们将详细查看这些类的设计及其方法调用的顺序:
from sklearn.feature_extraction.text import CountVectorizer
cv = CountVectorizer()
我们来自独热编码的示例句子实际上非常简单,因为我们的数据集中没有句子包含多次单词。让我们再添加一些句子,并以此为基础使用 CountVectorizer。
more_sentences = sentences + \
["John likes to watch movies. Mary likes movies too.",
"Mary also likes to watch football games."]
CountVectorizer 分为两个明显的阶段:首先它必须学习词汇表;之后它可以将文档转换为向量。
拟合词汇表
首先,它需要学习词汇表。现在这更简单了,因为我们可以直接传递包含句子的数组:
cv.fit(more_sentences)
CountVectorizer(analyzer='word', binary=False, decode_error='strict',
dtype=<class 'numpy.int64'>, encoding='utf-8', input='content',
lowercase=True, max_df=1.0, max_features=None, min_df=1,
ngram_range=(1, 1), preprocessor=None, stop_words=None,
strip_accents=None, token_pattern='(?u)\\b\\w\\w+\\b',
tokenizer=None, vocabulary=None)
不要担心所有这些参数;我们稍后会讨论重要的参数。让我们首先看看 CountVectorizer 使用的词汇表,这里称为特征名称:
print(cv.get_feature_names())
Out:
['age', 'also', 'best', 'foolishness', 'football', 'games',
'it', 'john', 'likes', 'mary', 'movies', 'of', 'the', 'times',
'to', 'too', 'was', 'watch', 'wisdom', 'worst']
我们已经创建了一个词汇表和所谓的特征,使用 CountVectorizer。方便地,词汇表按字母顺序排序,这使我们更容易决定是否包含特定单词。
将文档转换为向量
在第二步中,我们将使用 CountVectorizer 将文档转换为向量表示:
dt = cv.transform(more_sentences)
结果是我们在上一节中已经遇到的文档-术语矩阵。然而,它是一个不同的对象,因为 CountVectorizer 创建了一个稀疏矩阵。让我们来检查一下:
dt
Out:
<6x20 sparse matrix of type '<class 'numpy.int64'>'
with 38 stored elements in Compressed Sparse Row format>
稀疏矩阵非常高效。它只需保存 38 个元素,而不是存储 6 × 20 = 120 个元素!稀疏矩阵通过跳过所有零元素来实现这一点。
让我们试着恢复我们先前的文档-术语矩阵。为此,我们必须将稀疏矩阵转换为(稠密的)数组。为了使其更易读,我们将其转换为 Pandas 的 DataFrame:
pd.DataFrame(dt.toarray(), columns=cv.get_feature_names())
Out:
文档-词项矩阵看起来与我们的单热向量化器非常相似。但请注意,列是按字母顺序排列的,并且注意第五行有几个 2。这源自文档"John likes to watch movies. Mary likes movies too.",其中有很多重复词语。
蓝图:计算相似性
现在在文档之间找到相似性更加困难,因为仅仅计算文档中共同出现的 1 不再足够。一般来说,每个词的出现次数可能更多,我们必须考虑这一点。不能使用点积来做这个,因为它也对向量的长度(文档中的词数)敏感。此外,欧氏距离在高维向量空间中并不是很有用。这就是为什么通常使用文档向量之间的角度作为相似性的度量。两个向量之间的夹角的余弦定义如下:
cos ( 𝐚 , 𝐛 ) = 𝐚·𝐛 ||𝐚||·||𝐛|| = ∑a i b i ∑a i a i ∑b i b i
Scikit-learn 通过提供cosine_similarity实用函数简化了这个计算。让我们来检查前两个句子的相似性:
from sklearn.metrics.pairwise import cosine_similarity
cosine_similarity(dt[0], dt[1])
Out:
array([[0.83333333]])
与早期章节中手工相似性比较起来,cosine_similarity提供了一些优势,因为它被适当地标准化,并且值只能在 0 到 1 之间。
计算所有文档的相似性当然也是可能的;scikit-learn 已经优化了cosine_similarity,因此可以直接传递矩阵:
pd.DataFrame(cosine_similarity(dt, dt)))
Out:
| 0 | 1 | 2 | 3 | 4 | 5 | |
|---|---|---|---|---|---|---|
| 0 | 1.000000 | 0.833333 | 0.666667 | 0.666667 | 0.000000 | 0.000000 |
| 1 | 0.833333 | 1.000000 | 0.666667 | 0.666667 | 0.000000 | 0.000000 |
| 2 | 0.666667 | 0.666667 | 1.000000 | 0.833333 | 0.000000 | 0.000000 |
| 3 | 0.666667 | 0.666667 | 0.833333 | 1.000000 | 0.000000 | 0.000000 |
| 4 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 1.000000 | 0.524142 |
| 5 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.524142 | 1.000000 |
同样,矩阵在对角线上具有最高值是对称的。很容易看出文档对 0/1 和 2/3 最相似。文档 4/5 与其他文档没有任何相似性,但它们彼此之间有些相似性。回顾这些句子,这正是人们所期望的。
词袋模型适用于各种用例。对于分类、情感检测和许多主题模型,它们会偏向于最频繁出现的词语,因为它们在文档-词项矩阵中的数值最高。通常这些词语并不带有太多意义,可以定义为停用词。
由于这些方法高度依赖领域特定,更通用的方法会“惩罚”那些在所有文档语料库中出现太频繁的词语。这被称为TF-IDF 模型,将在下一节讨论。
TF-IDF 模型
在我们之前的例子中,许多句子以“这是时候”开头。这在很大程度上增加了它们的相似性,但实际上,您通过这些词获得的实际信息很少。TF-IDF 通过计算总词出现次数来处理这一点。它会减少常见词的权重,同时增加不常见词的权重。除了信息理论的测量[⁵]之外,在阅读文档时,您还可以观察到:如果遇到不常见的词,作者很可能想要用它们传达重要信息。
使用 TfidfTransformer 优化文档向量
如我们在第二章中所见,与计数相比,更好的信息衡量方法是计算倒排文档频率,并对非常常见的单词使用惩罚。TF-IDF 权重可以从词袋模型计算出来。让我们再次尝试使用先前的模型,看看文档-术语矩阵的权重如何变化:
from sklearn.feature_extraction.text import TfidfTransformer
tfidf = TfidfTransformer()
tfidf_dt = tfidf.fit_transform(dt)
pd.DataFrame(tfidf_dt.toarray(), columns=cv.get_feature_names())
Out:
正如您所见,有些词已经被缩小了(例如“it”),而其他词则没有被缩小那么多(例如“wisdom”)。让我们看看对相似性矩阵的影响:
pd.DataFrame(cosine_similarity(tfidf_dt, tfidf_dt))
Out:
| 0 | 1 | 2 | 3 | 4 | 5 | |
|---|---|---|---|---|---|---|
| 0 | 1.000000 | 0.675351 | 0.457049 | 0.457049 | 0.00000 | 0.00000 |
| 1 | 0.675351 | 1.000000 | 0.457049 | 0.457049 | 0.00000 | 0.00000 |
| 2 | 0.457049 | 0.457049 | 1.000000 | 0.675351 | 0.00000 | 0.00000 |
| 3 | 0.457049 | 0.457049 | 0.675351 | 1.000000 | 0.00000 | 0.00000 |
| 4 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 1.000000 | 0.43076 |
| 5 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.43076 | 1.000000 |
我们确实达到了期望的效果!文档对 0/1 和 2/3 仍然非常相似,但数字也减少到一个更合理的水平,因为文档对在重要词语上有所不同。现在常见词的权重较低。
引入 ABC 数据集
作为实际的用例,我们将使用一份来自 Kaggle 的数据集,其中包含新闻标题。标题源自澳大利亚新闻源 ABC,时间跨度为 2003 至 2017 年。CSV 文件只包含时间戳和标题,没有标点符号,且全部小写。我们将 CSV 文件加载到 Pandas 的DataFrame中,并查看前几个文档:
headlines = pd.read_csv("abcnews-date-text.csv", parse_dates=["publish_date"])
print(len(headlines))
headlines.head()
Out:
1103663
| 发布日期 | 新闻标题 | |
|---|---|---|
| 0 | 2003-02-19 | ABA 决定不授予社区广播许可证... |
| 1 | 2003-02-19 | 澳大利亚 ACT 州的火灾目击者必须意识到诽谤问题 |
| 2 | 2003-02-19 | A G 呼吁举行基础设施保护峰会 |
| 3 | 2003-02-19 | 空中新西兰员工在澳大利亚罢工要求加薪 |
| 4 | 2003-02-19 | 空中新西兰罢工将影响澳大利亚旅客 |
此数据集中有 1,103,663 个标题。请注意,标题不包括标点符号,并且全部转换为小写。除了文本之外,数据集还包括每个标题的出版日期。
正如我们之前看到的,可以使用词袋模型(在 scikit-learn 术语中的计数向量)计算 TF-IDF 向量。由于使用 TF-IDF 文档向量非常常见,因此 scikit-learn 创建了一个“快捷方式”来跳过计数向量,直接计算 TF-IDF 向量。相应的类称为TfidfVectorizer,我们将在下面使用它。
在下面的内容中,我们还将fit和transform的调用组合成了fit_transform,这样做很方便:
from sklearn.feature_extraction.text import TfidfVectorizer
tfidf = TfidfVectorizer()
dt = tfidf.fit_transform(headlines["headline_text"])
这可能需要一段时间,因为需要分析和向量化许多文档。查看文档-术语矩阵的维度:
dt
输出:
<1103663x95878 sparse matrix of type '<class 'numpy.float64'>'
with 7001357 stored elements in Compressed Sparse Row format>
行数是预期的,但是列数(词汇表)非常大,几乎有 100,000 个单词。通过简单的计算可以得出,对数据进行天真的存储会导致 1,103,663 * 95,878 个元素,每个浮点数使用 8 字节,大约使用 788GB 的 RAM。这显示了稀疏矩阵的令人难以置信的有效性,因为实际使用的内存只有“仅”56,010,856 字节(大约 0.056GB;通过dt.data.nbytes找到)。这仍然很多,但是可以管理。
不过,计算两个向量之间的相似性又是另一回事了。Scikit-learn(以及其基础 SciPy)针对稀疏向量进行了高度优化,但是进行示例计算(前 10,000 个文档的相似性)仍然需要一些时间:
%%time
cosine_similarity(dt[0:10000], dt[0:10000])
输出:
CPU times: user 154 ms, sys: 261 ms, total: 415 ms
Wall time: 414 ms
array([[1. , 0. , 0. , ..., 0. , 0. , 0. ],
[0. , 1. , 0. , ..., 0. , 0. , 0. ],
[0. , 0. , 1. , ..., 0. , 0. , 0. ],
...,
[0. , 0. , 0. , ..., 1. , 0.16913596, 0.16792138],
[0. , 0. , 0. , ..., 0.16913596, 1. , 0.33258708],
[0. , 0. , 0. , ..., 0.16792138, 0.33258708, 1. ]])
在接下来的章节中进行机器学习时,许多这些线性代数计算是必要的,并且必须一遍又一遍地重复。通常操作随着特征数量呈二次方增长(O(N²))。优化矢量化,通过移除不必要的特征,不仅有助于计算相似性,而且对于可扩展的机器学习至关重要。
蓝图:降低特征维度
现在我们已经为我们的文档找到了特征,并用它们来计算文档向量。正如我们在示例中看到的,特征数量可能会非常大。许多机器学习算法需要大量计算,并且随着特征数量的增加而扩展,通常甚至是多项式的。因此,特征工程的一部分侧重于减少这些真正必要的特征。
在本节中,我们展示了如何实现这一点的蓝图,并衡量了它们对特征数量的影响。
移除停用词
首先,我们可以考虑删除具有最少含义的词语。尽管这取决于领域,但通常有一些最常见的英语单词列表,常识告诉我们通常可以忽略它们。这些词被称为停用词。常见的停用词包括冠词、助动词和代词。有关更详细的讨论,请参阅第四章。在删除停用词时要小心,因为它们可能包含在特殊文本中具有特定领域含义的某些词语!
由于几乎任何语言中都有几百个常见的停用词,因此这并没有极大地减少维度。然而,由于停用词非常常见,这应该显著减少存储元素的数量。这导致内存消耗更少,并且计算速度更快,因为需要相乘的数字更少。
让我们使用标准的 spaCy 停用词,并检查对文档-术语矩阵的影响。请注意,我们将停用词作为命名参数传递给 TfidfVectorizer:
from spacy.lang.en.stop_words import STOP_WORDS as stopwords
print(len(stopwords))
tfidf = TfidfVectorizer(stop_words=stopwords)
dt = tfidf.fit_transform(headlines["headline_text"])
dt
Out:
305
<1103663x95600 sparse matrix of type '<class 'numpy.float64'>'
with 5644186 stored elements in Compressed Sparse Row format>
仅使用 305 个停用词,我们成功将存储的元素数量减少了 20%。矩阵的维数几乎相同,但由于确实出现在标题中的 95,878 - 95,600 = 278 个停用词较少,列数更少。
最小频率
查看余弦相似度的定义,我们可以很容易地看到,只有当两个向量在相应索引处具有非零值时,它们的分量才会有贡献。这意味着我们可以忽略所有出现少于两次的词!TfidfVectorizer(以及 CountVectorizer)有一个称为 min_df 的参数。
tfidf = TfidfVectorizer(stop_words=stopwords, min_df=2)
dt = tfidf.fit_transform(headlines["headline_text"])
dt
Out:
<1103663x58527 sparse matrix of type '<class 'numpy.float64'>'
with 5607113 stored elements in Compressed Sparse Row format>
显然,有很多单词仅出现一次(95,600 - 58,527 = 37,073)。这些单词也应该只存储一次;通过存储元素数量的检查,我们应该得到相同的结果:5,644,186 - 5,607,113 = 37,073。在执行此类转换时,集成这些合理性检查总是很有用的。
丢失信息
注意:通过使用 min_df=2,我们在向量化此文档语料库的标题时没有丢失任何信息。如果我们计划以后用相同的词汇量向量化更多文档,我们可能会丢失信息,因为在原始文档中仅出现一次的单词,在新文档中再次出现时,将无法在词汇表中找到。
min_df 也可以采用浮点值。这意味着一个词必须在至少一部分文档中出现。通常情况下,即使对于较低的 min_df 数量,这也会显著减少词汇量:
tfidf = TfidfVectorizer(stop_words=stopwords, min_df=.0001)
dt = tfidf.fit_transform(headlines["headline_text"])
dt
Out:
<1103663x6772 sparse matrix of type '<class 'numpy.float64'>'
with 4816381 stored elements in Compressed Sparse Row format>
这种转换可能过于严格,导致词汇量过低。根据文档的数量,您应将 min_df 设置为一个较低的整数,并检查对词汇表的影响。
最大频率
有时文本语料库可能有一个特殊的词汇表,其中有很多重复出现的术语,这些术语太特定,不能包含在停用词列表中。对于这种情况,scikit-learn 提供了max_df参数,可以消除语料库中过于频繁出现的术语。让我们看看当我们消除所有至少在 10% 的标题中出现的词时,维度是如何减少的:
tfidf = TfidfVectorizer(stop_words=stopwords, max_df=0.1)
dt = tfidf.fit_transform(headlines["headline_text"])
dt
Out:
<1103663x95600 sparse matrix of type '<class 'numpy.float64'>'
with 5644186 stored elements in Compressed Sparse Row format>
将max_df设置为低至 10% 的值并没有消除任何一个词!^(6)我们的新闻标题非常多样化。根据您拥有的语料库类型,尝试使用max_df可能非常有用。无论如何,您都应该始终检查维度如何变化。
蓝图:通过使特征更具体来改进特征
到目前为止,我们只使用了标题的原始词,并通过停用词和频率计数减少了维度。我们还没有改变特征本身。通过语言分析,有更多的可能性。
进行语言分析
使用 spaCy,我们可以对所有标题进行词形还原,并只保留词形还原形式。这需要一些时间,但我们预计会找到一个更小的词汇表。首先,我们必须进行语言分析,这可能需要一些时间才能完成(参见第四章了解更多细节):
import spacy
nlp = spacy.load("en")
nouns_adjectives_verbs = ["NOUN", "PROPN", "ADJ", "ADV", "VERB"]
for i, row in headlines.iterrows():
doc = nlp(str(row["headline_text"]))
headlines.at[i, "lemmas"] = " ".join([token.lemma_ for token in doc])
headlines.at[i, "nav"] = " ".join([token.lemma_ for token in doc
if token.pos_ in nouns_adjectives_verbs])
蓝图:使用词形还原代替单词进行文档向量化
现在,我们可以使用词形还原对数据进行向量化,并查看词汇表的减少情况:
tfidf = TfidfVectorizer(stop_words=stopwords)
dt = tfidf.fit_transform(headlines["lemmas"].map(str))
dt
Out:
<1103663x71921 sparse matrix of type '<class 'numpy.float64'>'
with 5053610 stored elements in Compressed Sparse Row format>
节省近 25,000 个维度是很多的。在新闻标题中,对数据进行词形还原可能不会丢失任何信息。在其他用例中,比如第十一章,情况完全不同。
蓝图:限制词类
使用之前生成的数据,我们可以限制自己只考虑名词、形容词和动词进行向量化,因为介词、连词等被认为带有很少的意义。这会再次减少词汇量:
tfidf = TfidfVectorizer(stop_words=stopwords)
dt = tfidf.fit_transform(headlines["nav"].map(str))
dt
Out:
<1103663x68426 sparse matrix of type '<class 'numpy.float64'>'
with 4889344 stored elements in Compressed Sparse Row format>
在这里几乎没有什么可以获得的,这可能是因为标题主要包含名词、形容词和动词。但是在您自己的项目中,情况可能完全不同。根据您分析的文本类型,限制词类不仅会减少词汇量,还会减少噪音。建议先尝试对语料库的一小部分进行操作,以避免由于昂贵的语言分析而导致长时间等待。
蓝图:移除最常见的单词
根据我们的学习,去除频繁出现的词可以导致文档-词矩阵的条目大大减少。在进行无监督学习时尤其有用,因为通常不会对常见的、无足轻重的常用词感兴趣。
为了进一步减少噪音,我们现在尝试消除最常见的英文单词。请注意,通常还会涉及可能具有重要含义的单词。有各种各样的单词列表;它们可以很容易地在互联网上找到。来自 Google 的列表非常流行,并直接可在 GitHub 上获取。Pandas 可以直接读取该列表,只需告诉它是一个没有列标题的 CSV 文件。然后,我们将指示TfidfVectorizer使用该列表作为停用词:
top_10000 = pd.read_csv("https://raw.githubusercontent.com/first20hours/\
google-10000-english/master/google-10000-english.txt", header=None)
tfidf = TfidfVectorizer(stop_words=set(top_10000.iloc[:,0].values))
dt = tfidf.fit_transform(headlines["nav"].map(str))
dt
Out:
<1103663x61630 sparse matrix of type '<class 'numpy.float64'>'
with 1298200 stored elements in Compressed Sparse Row format>
正如您所见,矩阵现在减少了 350 万个存储的元素。词汇量减少了 68,426 - 61,630 = 6,796 个词,因此 ABC 标题中甚至有超过 3,000 个最常见的英文单词没有被使用。
删除频繁单词是从数据集中去除噪音并集中于不常见单词的优秀方法。但是,刚开始使用时应该小心,因为即使频繁单词也有意义,并且它们在您的文档语料库中可能也具有特殊含义。我们建议额外执行这样的分析,但不应仅限于此。
蓝图:通过 N-Grams 添加上下文
到目前为止,我们仅使用单词作为特征(我们文档向量的维度),作为我们向量化的基础。使用这种策略,我们失去了大量的上下文信息。使用单词作为特征不尊重单词出现上下文。在后续章节中,我们将学习如何通过像词嵌入这样的复杂模型克服这种限制。在我们当前的示例中,我们将使用一种更简单的方法,并利用单词组合,即所谓的n-grams。两个词的组合称为bigrams;三个词的组合称为trigrams。
幸运的是,CountVectorizer和TfidfVectorizer具有相应的选项。与前几节试图减少词汇量的做法相反,我们现在通过词组增强词汇量。有许多这样的组合;它们的数量(以及词汇量)几乎与n的指数级增长。^(7) 因此,我们要小心,并从 bigrams 开始:
tfidf = TfidfVectorizer(stop_words=stopwords, ngram_range=(1,2), min_df=2)
dt = tfidf.fit_transform(headlines["headline_text"])
print(dt.shape)
print(dt.data.nbytes)
tfidf = TfidfVectorizer(stop_words=stopwords, ngram_range=(1,3), min_df=2)
dt = tfidf.fit_transform(headlines["headline_text"])
print(dt.shape)
print(dt.data.nbytes)
Out:
(1103663, 559961)
67325400
(1103663, 747988)
72360104
尽管 RAM 大小并没有增加太多,但将特征维度从 95,600 增加到 2,335,132 甚至 5,339,558 是相当痛苦的。对于某些需要特定上下文信息的任务(如情感分析),n-grams 非常有用。但是,始终注意维度是非常有用的。
还可以将 n-grams 与语言特征和常见单词结合起来,大大减少词汇量:
tfidf = TfidfVectorizer(ngram_range=(1,2),
stop_words=set(top_10000.iloc[:,0].values))
dt = tfidf.fit_transform(headlines["nav"].map(str))
dt
Out:
<1103663x385857 sparse matrix of type '<class 'numpy.float64'>'
with 1753239 stored elements in Compressed Sparse Row format>
Compared to the original bigram vectorization with min_df=2 above,
there are just 82,370 dimensions left from 67,325,400
Scikit-learn 提供了许多不同的向量化器。通常,从TfidfVectorizer开始是个不错的主意,因为它是最多才多艺的之一。
TfidfVectorizer 的选项
TF-IDF 甚至可以关闭,因此可以无缝切换到CountVectorizer。由于参数众多,找到完美的选项可能需要一些时间。
找到正确的特征集通常是乏味的,并需要通过TfidfVectorizer的(许多)参数进行实验,如min_df、max_df或通过 NLP 简化文本。在我们的工作中,我们已经通过将min_df设置为5和max_df设置为0.7获得了良好的经验。最终,这些时间的投资是非常值得的,因为结果将严重依赖于正确的向量化。然而,并没有金弹,这种特征工程严重依赖于使用情况和向量计划使用。
TF-IDF 方法本身可以通过使用次正常术语频率或归一化所得到的向量来改进。后者对于快速计算相似性非常有用,我们将在本章后面演示其使用。前者主要适用于长文档,以避免重复单词获得过高的权重。
非常仔细地考虑特征维度
在我们以前的例子中,我们使用了单词和二元组作为特征。根据使用情况,这可能已经足够了。这对于像新闻这样有常见词汇的文本效果很好。但是,您经常会遇到特殊词汇的情况(例如科学出版物或写给保险公司的信函),这将需要更复杂的特征工程。
要牢记维度的数量。
正如我们所见,使用诸如ngram_range之类的参数可能会导致大的特征空间。除了 RAM 使用情况外,这也将成为许多机器学习算法的问题,因为会导致过拟合。因此,当更改参数或向量化方法时,始终考虑(增加)特征维度是一个好主意。
ABC 数据集中的语法相似性
相似性是机器学习和文本分析中最基本的概念之一。在这一部分中,我们将看一些在 ABC 数据集中找到相似文档的具有挑战性的问题。
在上一节中查看可能的向量化之后,我们现在将使用其中的一种方法来计算相似性。我们将提供一个蓝图,展示如何从 CPU 和 RAM 的角度高效执行这些计算。由于我们处理大量数据,因此必须广泛使用NumPy 库。
在第一步中,我们使用停用词和二元组对数据进行向量化:
# there are "test" headlines in the corpus
stopwords.add("test")
tfidf = TfidfVectorizer(stop_words=stopwords, ngram_range=(1,2), min_df=2, \
norm='l2')
dt = tfidf.fit_transform(headlines["headline_text"])
现在我们可以将这些向量用于我们的蓝图。
蓝图:查找最接近虚构标题的标题
假设我们想要在我们的数据中找到一个与我们记得的标题最接近的标题,但只是粗略地。这很容易解决,因为我们只需对我们的新文档进行向量化:
made_up = tfidf.transform(["australia and new zealand discuss optimal apple \
size"])
现在我们必须计算与语料库中每个标题的余弦相似度。我们可以通过循环来实现这一点,但使用 scikit-learn 的cosine_similarity函数会更容易:
sim = cosine_similarity(made_up, dt)
结果是一个“语料库中的头条数量” × 1 矩阵,其中每个数字表示与语料库中文档的相似性。使用np.argmax给出最相似文档的索引:
headlines.iloc[np.argmax(sim)]
Out:
publish_date 2011-08-17 00:00:00
headline_text new zealand apple imports
Name: 633392, dtype: object
最相似的头条中没有苹果的大小和澳大利亚,但它确实与我们虚构的头条有些相似。
蓝图:在大型语料库中找到两个最相似的文档(更加困难)
当处理多个文档的语料库时,您可能经常会被问到“是否有重复?”或“这之前提到过吗?” 这些问题都归结为在语料库中查找最相似(甚至可能是完全相同)的文档。我们将解释如何实现这一点,并再次以我们的示例数据集 ABC 来说明。头条的数量将被证明是一个挑战。
您可能会认为在语料库中找到最相似的文档就像计算所有文档之间的cosine_similarity一样简单。但是,这是不可能的,因为 1,103,663 × 1,103,663 = 1,218,072,017,569。即使是最先进的计算机的 RAM 也无法容纳超过一万亿个元素。完全可以执行所需的矩阵乘法,而无需等待太长时间。
显然,这个问题需要优化。由于文本分析经常需要处理许多文档,这是一个非常典型的挑战。通常,第一个优化步骤是深入研究所有需要的数字。我们可以轻松地观察到文档相似性关系是对称和标准化的。
换句话说,我们只需要计算相似矩阵的次对角线元素(图 5-1)
图 5-1. 需要在相似矩阵中计算的元素。只有对角线以下的元素需要计算,因为它们的数量与对角线上的元素镜像相同。对角线上的元素都是 1。
这将把元素数量减少到 1,103,663 × 1,103,662 / 2 = 609,035,456,953,可以在循环迭代中计算,并保留只有最相似的文档。然而,单独计算所有这些元素并不是一个好选择,因为必要的 Python 循环(每次迭代仅计算一个矩阵元素)会消耗大量 CPU 性能。
不是计算相似矩阵的各个元素,我们将问题分成不同的块,并一次计算 10,000 × 10,000 个 TF-IDF 向量的相似子矩阵^(8)。每个这样的矩阵包含 100,000,000 个相似度,仍然适合在 RAM 中。当然,这会导致计算太多元素,我们必须对 111 × 110 / 2 = 6,105 个子矩阵执行此操作(参见图 5-2)。
从前面的部分,我们知道迭代大约需要 500 毫秒来计算。这种方法的另一个优点是利用数据局部性,使我们更有可能在 CPU 缓存中拥有必要的矩阵元素。我们估计一切应该在大约 3,000 秒内完成,大约相当于一小时。
图 5-2. 将矩阵分成子矩阵,我们可以更轻松地计算;问题被分成块(这里是 4 × 4),块内的白色和对角线元素在计算时是冗余的。
我们能否改进这一点?是的,事实上,可以实现另一个 10 倍的加速。这通过使用 TfidfVectorizer 的相应选项来对 TF-IDF 向量进行归一化来实现。之后,可以使用 np.dot 计算相似性:^(9)
%%time
np.dot(dt[0:10000], np.transpose(dt[0:10000]))
输出:
CPU times: user 16.4 ms, sys: 0 ns, total: 16.4 ms
Wall time: 16 ms
<10000x10000 sparse matrix of type '<class 'numpy.float64'>'
with 1818931 stored elements in Compressed Sparse Row format>
每次迭代中,我们保存最相似的文档及其相似性,并在迭代过程中进行调整。为了跳过相同的文档(或更精确地说,具有相同文档向量的文档),我们只考虑相似性 < 0.9999。事实证明,在稀疏矩阵中使用 < 关系是极其低效的,因为所有不存在的元素都被假定为 0。因此,我们必须富有创造性地寻找另一种方法:
%%time
batch = 10000
max_sim = 0.0
max_a = None
max_b = None
for a in range(0, dt.shape[0], batch):
for b in range(0, a+batch, batch):
print(a, b)
r = np.dot(dt[a:a+batch], np.transpose(dt[b:b+batch]))
# eliminate identical vectors
# by setting their similarity to np.nan which gets sorted out
r[r > 0.9999] = np.nan
sim = r.max()
if sim > max_sim:
# argmax returns a single value which we have to
# map to the two dimensions
(max_a, max_b) = np.unravel_index(np.argmax(r), r.shape)
# adjust offsets in corpus (this is a submatrix)
max_a += a
max_b += b
max_sim = sim
输出:
CPU times: user 6min 12s, sys: 2.11 s, total: 6min 14s
Wall time: 6min 12s
幸运的是,这没有花太多时间!max_a 和 max_b 包含具有最大相似性的标题的索引(避免相同的标题)。让我们来看一下结果:
print(headlines.iloc[max_a])
print(headlines.iloc[max_b])
输出:
publish_date 2014-09-18 00:00:00
headline_text vline fails to meet punctuality targets report
Name: 904965, dtype: object
publish_date 2008-02-15 00:00:00
headline_text vline fails to meet punctuality targets
Name: 364042, dtype: object
使用块计算方法,我们在几分钟内计算了近万亿的相似性。由于我们找到了相似但不相同的文档,因此结果是可以解释的。不同的日期表明这些绝对也是不同的标题。
蓝图:查找相关词汇
到目前为止,我们已经分析了文档的相似性。但语料库在隐含中具有更多信息,具体而言是有关相关词汇的信息。在我们的意义上,如果单词在相同的文档中使用,则它们是相关的。如果这些单词经常一起出现在文档中,那么它们应该“更”相关。举个例子,考虑单词 zealand,它几乎总是与 new 一起出现;因此,这两个单词是相关的。
我们希望与文档-术语矩阵而非文档-术语矩阵一起工作,这只是其转置形式。我们不再取行向量,而是取列向量。但是,我们需要重新向量化数据。假设两个词很少使用,并且它们恰好同时出现在同一标题中。它们的向量将是相同的,但这不是我们要寻找的情况。例如,让我们考虑一个名为 扎福德·毕布罗克斯 的人,在两篇文章中提到了他。我们的算法将为这些词分配 100%的相关分数。尽管这是正确的,但不是非常显著。因此,我们只考虑出现至少 1000 次的单词,以获得良好的统计显著性:
tfidf_word = TfidfVectorizer(stop_words=stopwords, min_df=1000)
dt_word = tfidf_word.fit_transform(headlines["headline_text"])
词汇量非常小,我们可以直接计算余弦相似性。将行向量变为列向量,我们只需转置矩阵,使用 NumPy 的方便 .T 方法:
r = cosine_similarity(dt_word.T, dt_word.T)
np.fill_diagonal(r, 0)
如果要找到最大条目,最简单的方法是将其转换为一维数组,通过 np.argsort 获取排序元素的索引,并恢复用于词汇查找的原始索引:
voc = tfidf_word.get_feature_names()
size = r.shape[0] # quadratic
for index in np.argsort(r.flatten())[::-1][0:40]:
a = int(index/size)
b = index%size
if a > b: # avoid repetitions
print('"%s" related to "%s"' % (voc[a], voc[b]))
Out:
"sri" related to "lanka"
"hour" related to "country"
"seekers" related to "asylum"
"springs" related to "alice"
"pleads" related to "guilty"
"hill" related to "broken"
"trump" related to "donald"
"violence" related to "domestic"
"climate" related to "change"
"driving" related to "drink"
"care" related to "aged"
"gold" related to "coast"
"royal" related to "commission"
"mental" related to "health"
"wind" related to "farm"
"flu" related to "bird"
"murray" related to "darling"
"world" related to "cup"
"hour" related to "2014"
"north" related to "korea"
很容易解释这些结果。对于一些词组合(如 气候变化 ),我们已经恢复了频繁的二元组。另一方面,我们还可以看到在标题中并未相邻的相关词汇,如 饮酒 和 驾驶 。通过使用转置的文档-术语矩阵,我们进行了一种 共现分析 。
长时间运行程序的技巧,如句法相似性
以下是长时间运行程序的一些效率提示:
在等待过长之前进行基准测试
在对整个数据集进行计算之前,通常先运行单个计算并推断整个算法将运行多长时间以及需要多少内存是非常有用的。您应该努力理解随着复杂性增加运行时间和内存消耗的增长方式(线性、多项式、指数)。否则,您可能不得不等待很长时间,然后发现几个小时(甚至几天)后仅完成了 10%的进度而内存已经耗尽。
尝试将问题分解为较小的部分
将问题分解为较小的块可以极大地帮助。正如我们在新闻语料库中最相似文档中所见,这样的运行只需大约 20 分钟,并且没有使用大量内存。与朴素方法相比,我们将在长时间运行后发现内存不足。此外,通过将问题分解为部分,您可以利用多核架构甚至将问题分布在多台计算机上。
总结与结论
在本节中,我们已准备好向量化和句法相似性的蓝图。几乎所有文本相关的机器学习项目(如分类、主题建模和情感检测)都需要文档向量作为其基础。
结果表明,特征工程是实现这些复杂机器学习算法出色性能的最强大杠杆之一。因此,尝试不同的向量化器,调整它们的参数,并观察生成的特征空间是一个绝佳的主意。确实有很多可能性,而且有充分的理由:尽管优化这一步骤需要一些时间,但通常是非常值得的,因为分析管道后续步骤的结果将极大受益于此。
本章中使用的相似性度量仅作为文档相似性的示例。对于更复杂的需求,还有更复杂的相似性算法,你将在后续章节中了解到。
在信息检索中,寻找相似文档是一个众所周知的问题。还有更复杂的评分方法,如BM25。如果你需要一个可扩展的解决方案,非常流行的Apache Lucene库(它是像Apache Solr和Elasticsearch这样的搜索引擎的基础)利用这一点,在生产场景中用于非常大的文档集合。
在接下来的章节中,我们将经常重新讨论相似性。我们将探讨如何整合单词语义和文档语义,并利用预先训练过的大型文档语料库来实现最先进的性能,使用迁移学习。
^(1) 在后面的章节中,我们将探讨向量化单词(第十章)和文档(第十一章)的其他可能性。
^(2) 有更复杂的算法来确定词汇,比如SentencePiece和BPE,如果你想减少特征数,这些都值得一看。
^(3) 令人困惑的是,numpy.dot既用于点积(内积),也用于矩阵乘法。如果 Numpy 检测到两个行向量或列向量(即一维数组)具有相同的维度,它计算点积并生成一个标量。如果不是,而且传递的二维数组适合矩阵乘法,它执行这个操作并生成一个矩阵。所有其他情况都会产生错误。这很方便,但涉及到很多启发式方法。
^(4) 更多关于 LDA 的内容请参见第八章。
^(5) 例如,参见熵的定义,作为不确定性和信息的度量。基本上,这表明低概率值比更可能的值包含更多信息。
^(6) 当然,这与已使用的停用词列表有关。在新闻文章中,最常见的词通常是停用词。在特定领域的文本中,情况可能完全不同。使用停用词通常是更安全的选择,因为这些列表是经过精心策划的。
^(7) 如果所有词组合都可能且被使用,它将呈指数级增长。但由于这种情况不太可能发生,维度会以次指数级增长。
^(8) 我们选择了 10,000 个维度,因为生成的矩阵可以保持在内存中(即使在中等硬件上也应该可以使用大约 1 GB 的内存)。
^(9) 通过使用处理器特定的库,例如在 Anaconda 中订阅 Intel 频道,可以显著加快所有计算。这将使用 AVX2、AVX-512 等指令以及并行化。MKL 和 OpenBlas 是线性代数库的不错选择。