自然语言处理实战——思想的符号:自然语言单词

159 阅读59分钟

本章内容包括:

  • 将文本解析为单词和n-gram(标记)
  • 对标点符号、表情符号甚至中文字符进行标记化
  • 通过词干提取、词形还原和大小写折叠整合词汇
  • 构建自然语言文本的结构化数值表示
  • 对文本进行情感和亲社会意图评分
  • 使用字符频率分析优化标记词汇
  • 处理可变长度的单词和标记序列

你想用自然语言处理(NLP)的力量来拯救世界吗?无论你希望NLP管道执行什么任务,它都需要计算一些关于文本的内容。为此,你需要一种将文本表示为数值数据结构的方法。NLP管道中将文本分解为较小单元并用于数值表示的部分称为标记化器。标记化器将非结构化数据——自然语言文本——分解成信息块,这些块可以被计数为离散元素。文档中标记出现的计数可以直接用作表示该文档的向量。这立即将一个非结构化字符串(文本文档)转化为适合机器学习的数值数据结构。

标记化的最基本用途是,它可以用来计算文档的统计表示。关于标记的统计数据通常是进行关键词检测、全文搜索和信息检索所需的全部内容,我们将在第3章、第5章、第10章和第12章讨论这些内容。你甚至可以使用文本搜索构建客户支持聊天机器人,以从你的文档或常见问题(FAQ)列表中找到客户问题的答案。在聊天机器人能够回答你的问题之前,它需要知道去哪里查找答案。搜索是许多最先进应用的基础,例如会话式AI和开放领域问答。这种统计表示也可以用于分类和比较不同的文本片段,比如检测文本的情感(参见第3章和第4章)。

但这并不是你能用标记做的唯一事情。你还可以想出一种数值表示,以帮助反映标记的含义,例如第6章讨论的词向量。通过结合单个标记的含义,你可以创建一种文本表示,计算机知道如何使用它。

标记化器几乎是所有NLP管道的基础。即使是最大的语言模型(LLM),例如ChatGPT背后的模型,也会将文本分解为标记,并通过预测序列中的下一个标记来工作。由于将文本分解为标记是NLP管道的第一步,它可能对管道的其余部分产生重大影响。在本章中,你将学习可以用来将文本转换为标记序列的不同技术。

2.1 标记和标记化

在自然语言处理(NLP)中,标记化是一种特定的文档分割方式。分割将文本拆分成更小的块或段落。这些文本段落比整体包含的信息要少。文档可以被分割成段落,段落分割成句子,句子分割成短语,短语再分割成标记(通常是单词和标点符号)。在本章中,我们专注于使用标记化器将文本分割成标记。

标记可以是你希望作为思想和情感的载体处理的几乎任何文本块。某种语言的有效标记集合被称为该语言的词汇,或更正式地称为其词典。语言学和NLP研究者使用词典一词来指代一组自然语言标记。词汇这个词是指代一组自然语言单词或标记的更自然的方式,因此我们将在此使用词汇。

在本章的第一部分,你的标记将是单词、标点符号,甚至是象形符号和表意文字,如汉字、表情符号和颜文字。接下来,我们将讨论现代NLP中越来越常用的其他类型的标记化,特别是用于操作大语言模型(LLM)。在本书后续章节中,你将看到,你可以使用这些相同的技术来查找任何离散序列中的意义包。例如,你的标记可以是由一系列字节表示的ASCII字符,可能包含ASCII表情符号,或者它们可以是Unicode表情符号、数学符号、埃及象形文字,甚至是DNA或RNA序列中的单个氨基酸。自然语言的标记序列就在你周围……甚至在你体内。

2.1.1 你的标记化工具箱

现在你已经更好地理解了有哪些标记,我们可以开始真正进行标记化了。你可以选择几种标记化器实现:

  • Pythonstr.splitre.split
  • 自然语言工具包(NLTK)TreebankWordTokenizerTweetTokenizer
  • SpaCy — 快速且适用于生产的标记化
  • 斯坦福CoreNLP — 语言学上准确,需要Java解释器
  • Hugging FaceBertTokenizer,一种WordPiece标记化器

2.1.2 最简单的标记化器

标记化一个句子的最简单方法是使用字符串中的空白字符作为“分隔符”来分割单词。在Python中,这可以通过标准库方法 split 来实现。

假设你的NLP管道需要解析来自WikiQuote.org的引文,并且在解析《偷书贼》这本书时遇到了一些问题。

示例 2.1 来自《偷书贼》的引文分割成标记

>>> text = ("Trust me, though, the words were on their way, and when "
...         "they arrived, Liesel would hold them in her hands like "
...         "the clouds, and she would wring them out, like the rain.")
>>> tokens = text.split()           #1
>>> tokens[:8]
['Trust', 'me,', 'though,', 'the', 'words', 'were', 'on', 'their']

#1 str.split() 是你快速且简单的标记化工具。

如你所见,这个内置的Python方法在标记化这个句子时做得还可以。它唯一的“错误”是将逗号包含在标记中。这会妨碍你的关键词检测器检测到一些重要的标记:['me', 'though', 'way', 'arrived', 'clouds', 'out', 'rain']。单词“clouds”和“rain”对于这段文本的意义非常重要,因此你需要让你的标记化器做得更好,以确保你捕捉到所有重要的单词并“保存”它们(就像《偷书贼》中的Liesel一样)。

2.1.3 基于规则的标记化

实际上,解决将标点符号与单词分离的问题有一个简单的解决方法。你可以使用正则表达式标记化器来创建规则,处理常见的标点符号模式。以下是你可以用来处理标点符号“附加物”的一个正则表达式。我们同时会让这个正则表达式能够智能处理含有内部标点的单词,比如拥有撇号的所有格单词和缩写词。

你将使用正则表达式标记化一些来自彼得·沃茨《盲视》这本书的文本。这段文本描述了最适应的人的生存方式(以及外星入侵)。同样的道理也适用于你的标记化器。你希望找到一个能够充分解决你的问题的标记化器,而不是完美的标记化器。你可能甚至无法猜测哪个标记是正确的或最适合的。你需要一个准确度数字来评估你的NLP管道,这将告诉你哪个标记化器应当在选择过程中存活。以下示例将帮助你开始形成对正则表达式标记化器应用的直觉:

>>> import re
>>> pattern = r'\w+(?:'\w+)?|[^\w\s]'       #1
>>> texts = [text]
>>> texts.append("There's no such thing as survival of the fittest. "
...              "Survival of the most adequate, maybe.")
>>> tokens = list(re.findall(pattern, texts[-1]))
>>> tokens[:8]
["There's", 'no', 'such', 'thing', 'as', 'survival', 'of', 'the']
>>> tokens[8:16]
['fittest', '.', 'Survival', 'of', 'the', 'most', 'adequate', ',']
>>> tokens[16:]
['maybe', '.']

#1 (?:'\w+)? 的前瞻模式检测单词是否包含一个撇号,后面跟着一个或多个字母。

这好多了。现在,标记化器将标点符号从单词的末尾分开,但不会拆分包含内部标点的单词,如标记“There’s”中的撇号。所以,所有这些单词都按照你希望的方式进行了标记化:There’sfittestmaybe。这个正则表达式标记化器即使遇到多个撇号后跟字母的缩写词(如ya’llshe’llwhat’ve)也能很好地工作。即便遇到拼写错误(如can"tshe,llwhatve),它也能正常工作。但如果你的文本中包含了罕见的双重缩写(如couldn’t’veya’ll’lly’ain’t`),这种宽松的匹配内部标点可能不是你想要的。

提示  你可以使用正则表达式 r'\w+(?:'\w+){0,2}|[^\w\s]' 来适应双重缩写。

这是你需要记住的主要思想。无论你如何精心设计你的标记化器,它可能会破坏原始文本中的某些信息。在切割文本时,你只需要确保你留下的那些信息对你的管道的良好运行并不是必需的。此外,考虑你的下游NLP算法也很有帮助。稍后,你可能会配置一个大小写折叠、词干提取、词形还原、同义词替换或计数向量化算法。当你这么做时,你需要考虑你的标记化器在做什么,以确保整个管道能够协同工作,完成你想要的输出。

看看你为这段短文本生成的按字典顺序排序的词汇表中的前几个标记:

>>> import numpy as np
>>> vocab = sorted(set(tokens))     #1
>>> ' '.join(vocab[:12])          #2
", . Survival There's adequate as fittest maybe most no of such"
>>> num_tokens = len(tokens)
>>> num_tokens
18
>>> vocab_size = len(vocab)
>>> vocab_size
15

#1 将列表转换为集合,以确保词汇表中仅包含唯一的标记(没有重复项) #2 按字典顺序排序,因此标点符号排在字母之前,且大写字母排在小写字母之前

你可以看到,为什么你可能想将所有标记转换为小写,以便“Survival”被识别为与“survival”相同的词。出于类似的原因,你可能还想使用同义词替换算法将“There's”替换为“There is”。然而,这只有在你的标记化器将缩写词和所有格撇号与其父标记绑定时才有效。

提示  每当你的管道似乎无法很好地处理某个特定文本时,务必检查一下你的词汇表。你可能需要修改标记化器,确保它能够“看到”完成你的NLP任务所需的所有标记。

2.1.4 SpaCy

也许你不希望正则表达式标记化器将缩写词连接在一起。或许你希望将单词“isn't”识别为两个独立的单词:is 和 n’t。这样,你可以将同义词 n’t 和 not 合并为一个标记。这使得你的NLP管道能够理解,例如,“the ice cream isn’t bad”和“the ice cream is not bad”是相同的意思。对于一些应用,如全文搜索、意图识别和情感分析,你希望能够展开缩写词。通过分割缩写词,你可以使用同义词替换或缩写展开来提高搜索引擎的召回率和情感分析的准确性。

注意  我们将在本章稍后讨论大小写折叠、词干提取、词形还原和同义词替换。请小心在如作者归属、风格转移或文本指纹识别等应用中使用这些技术。你希望你的作者归属或风格转移管道能保持作者的写作风格和他们使用的单词的精确拼写。

SpaCy将标记化器直接集成到其最先进的自然语言理解(NLU)管道中,并在应用规则分割标记的同时为标记添加了几个附加标签。因此,spaCy通常是你需要使用的第一个也是最后一个标记化器。

让我们看看spaCy如何处理我们收集的深思者引文:

>>> import spacy                             #1
>>> spacy.cli.download('en_core_web_sm')    #2
>>> nlp = spacy.load('en_core_web_sm')    #3
>>> doc = nlp(texts[-1])
>>> type(doc)
spacy.tokens.doc.Doc

>>> tokens = [tok.text for tok in doc]
>>> tokens[:9]
['There', "'s", 'no', 'such', 'thing', 'as', 'survival', 'of', 'the']

>>> tokens[9:17]
['fittest', '.', 'Survival', 'of', 'the', 'most', 'adequate', ',']

#1 如果这是你第一次使用spaCy,你应该使用 spacy.cli.download('en_core_web_sm') 下载小型语言模型。 #2 为了避免不必要的重新下载语言模型,使用 from spacy_language_model import nlp。 #3 这里的“sm”代表“small”(17MB), “md”代表“medium”(45MB), “lg”代表“large”(780MB)。

这种标记化可能更有用,如果你在与学术论文或工作同事的结果进行比较时使用。SpaCy在幕后做了更多的工作。你下载的这个小型语言模型还使用一些句子边界检测规则来识别句子断点。语言模型是正则表达式和有限状态自动机(或规则)的集合,类似于你在英语课上学到的语法和拼写规则。它们被用于标记化算法中,用来标记单词并附上有用的信息,如它们的词性(POS)和它们在语法树中与其他单词之间的关系:

>>> from spacy import displacy
>>> sentence = list(doc.sents)[0]       #1
>>> svg = displacy.render(sentence, style="dep",
...     jupyter=False)                          #2
>>> open('sentence_diagram.svg', 'w').write(svg)       #3
>>> # displacy.serve(sentence, style="dep")     #4
>>> # !firefox 127.0.0.1:5000
>>> displacy.render(sentence, style="dep")    #5

#1 第一句以“There's no such thing … .”开头。 #2 设置 jupyter=False 返回可以保存到磁盘的SVG字符串。 #3 如果你使用的是IPython控制台,可以浏览硬盘上的SVG文件。 #4 启动Web服务器与HTML和SVG渲染的句子图进行交互。 #5 如果 jupyter=None,displaCy将在可能的情况下内联显示SVG。

有三种方法可以创建并查看来自displaCy的句子图:通过Web浏览器中的动态HTML/SVG文件,硬盘上的静态SVG文件,或者在Jupyter Notebook中的内联HTML对象。如果你浏览到本地硬盘上的 sentence_diagram.svg 文件,或访问localhost:5000服务器,你应该看到一个句子图,甚至可能比你在学校时能生成的更好。

图2.1显示了关于生存的句子中每个标记的词性和词形。displaCy图还包括一个依赖树,通过连接单词的弧线来表示,因此你可以看到句子中概念的逻辑嵌套和分支。你能找到依赖树的根节点吗——即那个不依赖其他单词来修改其含义的标记吗?寻找没有箭头指向它的单词,并且只有箭头的尾部从它的方向弯曲指向依赖的词。

image.png

你可以看到,spaCy不仅仅是将文本分割成标记。它还会识别句子边界,自动将文本分割成句子,并且为标记添加各种属性,比如它们的词性(POS),甚至它们在句子语法中的角色。你可以看到displaCy在每个标记的字面文本下方显示的词形。稍后在本章中,我们将解释词形还原、大小写折叠以及其他词汇压缩方法如何在一些应用中提供帮助。

因此,就准确性和一些“开箱即用”的功能(例如所有标记的词形和依赖标签)而言,spaCy似乎非常出色。那么,它的速度怎么样呢?

2.1.5 寻找最快的单词标记化器

SpaCy可以在大约5秒钟内解析本书某章节的AsciiDoc文本。首先,下载该章节的AsciiDoc文本文件:

>>> import requests
>>> text = requests.get('https://proai.org/nlpia2-ch2.adoc').text
>>> f'{round(len(text) / 10_000)}0k'   #1
'60k'

#1 除以10,000并四舍五入,以便在文本修订时doctests仍能通过。

AsciiDoc文件中大约有160,000个ASCII字符,其中我们写下了这一句。这意味着每秒多少个单词,是标记化器速度的标准基准呢?

>>> import spacy
>>> nlp = spacy.load('en_core_web_sm')
>>> %timeit nlp(text)                       #1
4.67 s ± 45.3 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
>>> f'{round(len(text) / 10_000)}0k'
'160k'
>>> doc = nlp(text)
>>> f'{round(len(list(doc)) / 10_000)}0k'
'30k'
>>> f'{round(len(doc) / 1_000 / 4.67)}kWPS'    #2
'7kWPS'

#1 %timeit 是Jupyter Notebook、Jupyter Console和IPython中的魔法函数。 #2 kWPS表示每秒千个单词(标记)。

大约5秒钟处理150,000个字符或34,000个英语和Python单词,约为每秒7,000个单词。

这对于你个人项目来说可能已经足够快了,但在医学记录总结项目中,我们需要处理数千个大型文档,其中的文本量与本书的总量相当。而医学记录总结管道中的延迟是项目的一个关键指标。所以,像SpaCy这样的全功能管道处理10,000本书(例如《NLPiA》或典型的10,000个患者的医学记录)至少需要五天。如果速度对你的应用来说不够快,你可以禁用SpaCy管道中你不需要的任何标记功能:

>>> nlp.pipe_names          #1
['tok2vec', 'tagger', 'parser', 'attribute_ruler', 'lemmatizer', 'ner']
>>> nlp = spacy.load('en_core_web_sm', disable=nlp.pipe_names)
>>> %timeit nlp(text)
199 ms ± 6.63 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

#1 pipe_names 列出了你当前启用的所有SpaCy NLP管道元素。

你可以禁用不需要的管道元素以加速标记化器:

  • tok2vec — 词向量
  • tagger — 词性标注(.pos 和 .pos_)
  • parser — 语法树角色
  • attribute_ruler — 细粒度的词性和其他标签
  • lemmatizer — 词形还原标注
  • ner — 命名实体识别标注

NLTK的 word_tokenize 方法常用作标记化器基准速度比较的标杆:

>>> import nltk
>>> nltk.download('punkt')
True
>>> from nltk.tokenize import word_tokenize
>>> %timeit word_tokenize(text)
156 ms ± 1.01 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
>>> tokens = word_tokenize(text)
>>> f'{round(len(tokens) / 10_000)}0k'
'10k'

你可能已经找到了标记化器竞赛的赢家?不过,别着急。你的正则表达式标记化器有一些相当简单的规则,所以它也应该运行得很快:

>>> pattern = r'\w+(?:'\w+)?|[^\w\s]'
>>> tokens = re.findall(pattern, text)    #1
>>> f'{round(len(tokens) / 10_000)}0k'
'20k'
>>> %timeit re.findall(pattern, text)
8.77 ms ± 29.8 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

#1 尝试通过 re.compile 预编译,看看Python核心开发人员有多聪明。

这并不令人惊讶。正则表达式可以在Python中的低级C例程中高效地编译和运行。

提示  当速度比准确性更重要时,使用正则表达式标记化器。如果你不需要SpaCy和其他管道提供的附加语言学标签,那么你的标记化器就不需要浪费时间去计算这些标签。每次你在 reregex 包中使用正则表达式时,它的已编译并优化的版本都会缓存在RAM中。因此,通常不需要预编译(使用 re.compile())你的正则表达式。

2.2 超越单词标记

将单词视为不可分割的原子思想和意义块似乎是自然而然的。然而,你确实会发现一些单词在空格或标点符号上没有明确分割。而且许多复合词或命名实体你希望保持在一起,但它们内部却有空格。

单词并不是我们可以用作标记的唯一意义单元。想一想,一个单词或标记对你来说代表什么。它代表一个单一的概念,还是一些模糊的概念云?你能总是清楚地辨认出一个单词的开始和结束吗?自然语言的单词像编程语言中的关键字那样有精确的拼写、定义和用法规则吗?你能编写出能可靠识别一个单词的软件吗?

你可能希望将单词分割成更小的、有意义的部分。词素,比如前缀“pre”、后缀“fix”和内部音节“la”,都具有意义。你可以利用这些词素将你对一个单词的理解转移到词汇中其他类似的单词上。你的自然语言理解(NLU)管道甚至可以使用这些词素来理解新单词,你的自然语言生成(NLG)管道也可以使用这些词素来创建能够简洁地捕捉思想或在集体意识中传播的表情的新词。

你的管道甚至可以将单词分割成更小的部分。字母、字符和字形也承载着情感和意义!我们尚未找到完美的思想封装编码方式,且机器的计算方式与大脑不同。我们人类用单词和术语(复合词)互相解释思想和语言,但机器往往能够发现我们错过的字符序列中的模式。而为了让机器能够将庞大的词汇装入有限的RAM,它们会使用更高效的自然语言编码方式。

对于高效计算,最优的标记与我们人类使用的思想封装(单词)不同。字节对编码(BPE)、词片编码(WPE)和句片编码(SPE)每种方式都能帮助机器更高效地使用自然语言,但对人类来说理解起来较为困难。如果你需要可解释的编码方式,使用前面章节中的单词标记化器。如果你想要更灵活且准确的预测和文本生成,BPE、WPE或SPE可能更适合你的应用。

那隐形或暗示的单词呢?你能想到由单一命令“Don’t!”暗示的其他单词吗?如果你强迫自己像机器一样思考,再切换回像人类思考,你可能会意识到在这个命令中有三个隐形的单词:“Don’t!”、“Don’t you do that!”和“You, do not do that!”这至少是三个隐藏的意义单元,总共有五个标记你希望机器能够识别。

但现在先不用担心隐形单词。对于本章,你所需要的仅仅是一个能够识别已拼写单词的标记化器。你将在第4章及以后讨论隐形单词、暗示含义甚至意义本身。

你的NLP管道可以从以下四个选项开始,作为你的标记:

  • 字符 — ASCII字符或多字节Unicode字符
  • 子词 — 音节和常见字符簇(即词片)
  • 单词 — 词典单词或它们的词根(即词干或词形)
  • 句片 — 短的、常见的单词和多词片段

随着你逐步深入这个列表,你的词汇量会增加,你的NLP管道将需要更多的数据来进行训练。基于字符的NLP管道通常用于翻译问题或需要从少量示例中进行泛化的NLG任务。基于字符的NLP管道通常只需要不到200个标记就能处理许多拉丁语系语言。这个小词汇量确保字节和字符基的NLP管道能够处理新的、未见过的测试样本,而不会产生太多无意义的词汇外(OOV)标记。

对于基于单词的NLP管道,你的管道将需要开始注意标记的使用频率,然后决定是否“计数”它们。但即使你确保你的管道关注经常出现的单词,最终你也可能得到一个像典型字典一样大的词汇——大约2万到5万个单词。

子词(词片)是大多数深度学习NLP管道的最优标记。子词标记化器被内置在许多最先进的变换器管道中。对于任何需要结果可解释和可说明的语言学项目或学术研究,单词是首选标记。

句片将子词算法推向了极致。句片标记化器允许你的算法将多个词片组合成一个单一的标记,这个标记有时甚至可以跨越多个单词。句片的唯一硬性限制是它们不能延伸到句子的结尾。这确保了标记的意义仅与单一的连贯思想相关,适用于单句和更长的文档。由于本书将大量讨论深度学习模型,让我们深入探讨子词或WordPiece标记化的机制。

2.2.1 WordPiece 标记化器

想一想我们如何通过相邻字符来构建单词,而不是仅仅通过空格和标点符号等分隔符来划分文本。你的标记化器可以寻找那些经常一起出现的字符组合,例如字母“i”出现在字母“e”之前。你可以将属于一起的字符和字符序列配对。10这些字符块可以成为你的标记。NLP管道只关注标记的统计数据,且希望这些统计数据能够与我们对单词的期望一致。

许多字符序列将是完整的单词,甚至是复合词,但许多将是单词的部分。事实上,所有子词标记化器都会在词汇中为每个单独的字符保留一个标记。这意味着,只要新的文本中不包含新的字符,它永远不需要使用OOV标记。子词标记化器试图将字符优化地组合起来以创建标记。通过使用字符n-gram计数的统计数据,这些算法可以识别子词,甚至是适合作为标记的句片。

通过组合字符来识别单词可能看起来有些奇怪。但对于机器而言,文本中意义单元之间唯一明显、一致的划分是字节或字符之间的边界。此外,字符一起使用的频率可以帮助机器识别与子词标记相关的含义,如单个音节或复合词的部分。

在英语中,甚至单个字母也有与之相关的微妙情感(情感)和意义(语义);然而,英语只有26个独特的字母。这没有为每个字母专门化处理某一主题或情感提供空间。然而,精明的营销人员知道,一些字母比其他字母“更酷”。品牌常常通过选择包含不常见字母的名字(如Q、Y和Z,想想Lyft或Cheez-Its)来试图塑造自己现代或技术先进的形象。这也有助于搜索引擎优化(SEO),因为较稀有的字母更容易在成千上万的公司和产品名称中被找到。你的NLP管道将捕捉到所有这些意义、内涵和意图的提示。你的标记计数器将为机器提供它需要的统计数据,以推断常用字符组合的含义。

使用子词标记化器的唯一缺点是,它们必须多次遍历你的文本语料库,才能收敛到一个最优的词汇和标记化器。子词标记化器必须像CountVectorizer一样进行训练或拟合到你的文本上。事实上,在下一节中,你将使用CountVectorizer来查看子词标记化器如何工作。

子词标记化有两种主要方法:BPE(字节对编码)和WordPiece标记化。

字节对编码(BPE)

在本书的前一版中,我们坚持认为单词是英语中你需要考虑的最小意义单元。随着变换器和其他使用BPE及类似技术的深度学习模型的兴起,我们改变了看法。11 基于字符的子词标记化器已被证明在大多数NLP问题中更具多功能性和鲁棒性。通过从Unicode多字节字符的构建块构建词汇,你可以构建一个可以处理你将来见到的所有可能的自然语言字符串的词汇——所有这些都可以使用最多50,000个标记的词汇。

你可能认为Unicode字符是自然语言文本中最小的意义单元。这对人类来说也许是对的,但对于机器来说,完全不是。正如BPE名称所示,字符不一定是你基础词汇中的最基本意义单元。你可以将字符拆分成8位字节。GPT-2使用字节级BPE标记化器,通过由字节组成的Unicode字符,天然地组合出你需要的所有Unicode字符。尽管需要一些特殊规则来处理字节级词汇中的Unicode标点符号,但不需要对基于字符的BPE算法做其他调整。字节级BPE标记化器使你能够使用256个标记的基础词汇表示所有可能的文本。GPT-2模型能够通过其默认的BPE词汇(仅包含50,000个多字节合并标记和256个单字节标记)实现最先进的性能。传闻GPT-4模型具有类似的词汇规模:大约50,000个标记。

你可以把BPE标记化算法看作是社交网络中朋友的“媒人”。BPE将常常出现在一起的字符配对,然后为这些字符组合创建一个新标记。BPE接着可以在你的文本中,每当这些标记配对常见时,将多字符标记配对在一起。它会不断地执行此操作,直到它的词汇大小限制允许的所有常用字符序列都被收集。

BPE正在改变我们对自然语言标记的思考方式。NLP工程师终于让数据来“发声”。在构建NLP管道时,统计思维比人类直觉更为有效。机器能够看到大多数人如何使用语言。而你只能熟悉当你使用特定的单词或音节时的意思。变换器模型现在在某些自然语言理解和生成任务中超越了人类读者和写作者,包括从子词标记中找到含义。

一个尚未处理的复杂问题是当你的管道首次遇到新单词时该如何处理。前面的例子中,我们只是在不断地将新单词添加到词汇中,但在实际应用中,你的管道将基于一个初始语料库进行训练,这个语料库可能并不包含它将来可能会遇到的所有标记。如果你的初始语料库缺少某些后来遇到的单词,那么你的词汇中就没有可以放置这些新单词计数的槽。因此,在训练初始管道时,你始终会为OOV标记保留一个槽(维度)。如果你的原始文档集没有包含,例如,名字“Aphra”,那么所有该名字的计数将被归类到OOV维度,就像Amandine和其他稀有单词的计数一样。

为了在向量空间中给“Aphra”提供相等的表示,你可以使用BPE。BPE将稀有单词分解成更小的部分,从而为你的语料库中的自然语言元素创建一个周期表。因为“aphr”是一个常见的英语前缀,所以你的BPE标记化器可能会为“Aphra”在词汇中提供两个槽:一个是“aphr”,另一个是“a”。你可能会发现这些词汇槽实际上是“ aphr”和“a ”,因为BPE像对待字母表中的任何其他字符一样,也跟踪空格。

BPE为你提供了多语言的灵活性,它使你的管道在处理常见拼写错误和打字错误时更加健壮,例如“aphradesiac”。每个单词,包括像“African American”这样的少数2-gram,都在BPE的投票系统中有表示。过去使用OOV标记来处理人类交流中的稀有特殊情况的日子已经一去不复返了。正因如此,最先进的深度学习NLP管道(如变换器)都使用类似BPE的WordPiece标记化。

BPE通过使用字符标记和WordPiece标记来保留新单词的一些含义,从而拼出任何未知的单词或单词的部分。例如,如果“syzygy”不在我们的词汇中,我们可以将它表示为六个标记:s、y、z、y、g 和 y。也许“smartz”可以表示为两个标记:“smart”和“z”。

这听起来很聪明。让我们看看它在我们的文本语料库中是如何工作的:

>>> import pandas as pd
>>> from sklearn.feature_extraction.text import CountVectorizer
>>> vectorizer = CountVectorizer(ngram_range=(1, 2), analyzer='char')
>>> vectorizer.fit(texts)
CountVectorizer(analyzer='char', ngram_range=(1, 2))

我们创建了一个 CountVectorizer 类,它将文本标记化为字符,而不是单词。它将计算标记对(字符2-gram),即BPE编码中的字节对,以及单字符标记。现在,我们可以检查我们的词汇表,看看它们长什么样:

>>> bpevocab_list = [...    sorted((i, s) for s, i in vectorizer.vocabulary_.items())]
>>> bpevocab_dict = dict(bpevocab_list[0])
>>> list(bpevocab_dict.values())[:7]
['  ', ' a', ' c', ' f', ' h', ' i', ' l']

我们配置了 CountVectorizer,使其将文本拆分为文本中所有可能的字符1-gram和2-gram。CountVectorizer 按词汇顺序组织词汇,因此以空格字符(' ')开头的n-gram会排在前面。一旦标记化器知道它需要计算的标记,它就可以将文本字符串转换为向量,其中每个维度对应我们字符n-gram词汇表中的一个标记:

>>> vectors = vectorizer.transform(texts)
>>> df = pd.DataFrame(
...     vectors.todense(),
...     columns=vectorizer.get_feature_names_out())
>>> df.index = [t[:8] + '...' for t in texts]
>>> df = df.T
>>> df['total'] = df.T.sum()
>>> df
    Trust me...  There's ...  total
 t           31      14          45
 r            3       2           5
 u            1       0           1
 s            0       1           1
              3       0           3
..           ...     ...        ...
at            1       0           1
ma            2       1           3
yb            1       0           1
be            1       0           1
e.            0       1           1
<BLANKLINE>
[148 rows x 3 columns]

这个 DataFrame 为每个句子包含了一列,为每个字符2-gram包含了一行。查看前四行,其中字节对(字符2-gram)"r "在这两个句子中出现了三到五次。因此,即使空格也算作字符,当你构建BPE标记化器时,空格也会被考虑在内。这是BPE的一个优势,因为它能够找出你的标记分隔符,这意味着即使在没有单词间空格的语言中,它也能正常工作。BPE还可以应用于替代密码文本,比如ROT13,这是一种将字母表向前旋转13个字符的玩具密码:

>>> df.sort_values('total').tail()
        Trust me...  There's ...  total
    en        10           3       13
    an        14           5       19
    uc        11           9       20
    e         18           8       26
    t         31          14       45

然后,BPE标记化器会找到最频繁的2-gram,并将它们添加到永久词汇中。随着时间的推移,它会删除不太频繁的字符对,因为它们不太可能在以后出现:

>>> df['n'] = [len(tok) for tok in vectorizer.vocabulary_]
>>> df[df['n'] > 1].sort_values('total').tail()
    Trust me...  There's ...      total  n
ur           8          4            12  2
en          10          3            13  2
an          14          5            19  2
uc          11          9            20  2
e           18          8            26  2

BPE标记化器的下一轮预处理将保留字符2-gram en、an,甚至是e。然后,BPE算法将再次通过这个较小的字符双字母词汇处理文本。它将查找这些字符双字母与彼此及单个字符的常见配对。这个过程会继续,直到达到最大标记数量,并且最长的字符序列被纳入词汇表中。

注意  你可能会看到提到WordPiece标记化器,如BERT及其衍生模型,这些模型在一些先进的语言模型中使用。15这个过程与BPE相同,但它实际上使用底层的语言模型来预测字符串中相邻字符。它从其词汇中剔除那些对语言模型精度影响最小的字符。数学上稍有不同,它生成了稍有不同的标记词汇,但你无需故意选择这个标记化器。使用它的模型将会在其管道中内置该标记化器。

BPE标记化器的一个主要挑战是它们必须在你的个别语料库上进行训练。因此,BPE标记化器通常只用于变换器和大语言模型(LLM),你将在第9章和第10章学习到这些。

BPE标记化器的另一个挑战是,你需要做大量的记账工作来追踪哪个训练好的标记化器与你的每个训练模型匹配。解决这个问题是Hugging Face的一大创新。他们的团队使得存储和共享所有预处理数据(例如标记化器词汇)与语言模型一同变得容易。这使得BPE标记化器更易于重用和共享。如果你想成为NLP专家,可能会想模仿Hugging Face在自己的NLP预处理管道中的做法。16

到目前为止,你已经学习了几种将文本分割成标记的方法。这些标记将帮助你创建词汇表——你文本中所有可能的标记集;然而,按原样包括这些标记可能不是最佳的方法。在下一节中,你将学习一些改进词汇表的技巧。

2.3 改进你的词汇表

到目前为止,你已经考虑了最基本的词汇构建形式:找到所有可能的标记,并将每个标记作为词汇表中的一个“术语”。这种方法可以帮助你将文本转换为向量,但由于多种原因,这些向量在分类或情感分析等任务中的表现可能并不好。首先,通过将每个标记单独处理并忽略标记序列的共现,你会丢失很多信息。同样,不将基本上是同一单词形式的标记连接起来,会降低你向量表示的性能。例如,如果将“swim”和“swimming”表示为两个不同的标记,那么就会更难找到所有关于游泳的文章。因此,让我们探讨一些可以帮助你使词汇表更高效的技术。

2.3.1 使用n-gram扩展词汇表

让我们回顾一下本章开始时的冰淇淋问题。记得我们讨论过如何将“ice”和“cream”放在一起:

I scream, you scream, we all scream for ice cream.

但我们并不认识很多为“cream”而尖叫的人。也没有人会为“ice”尖叫,除非他们即将滑倒。因此,你需要一种方法,使得你的单词向量能够将“ice”和“cream”放在一起。

我们都为n-gram尖叫

n-gram是一个包含最多n个元素的序列,这些元素通常从一系列元素(通常是字符串)中提取出来。一般来说,n-gram的元素可以是字符、音节、单词,甚至是符号(例如,A、D和G用于表示DNA或RNA序列中的化学氨基酸标记)。17

在本书中,我们只关心单词的n-gram,而不是字符的n-gram。18因此,当我们说2-gram时,我们指的是一对单词,例如“ice cream”。当我们说3-gram时,我们指的是一组三个单词,如“beyond the pale”,“Johann Sebastian Bach”或“riddle me this”。n-gram不一定要一起表示某种特殊的意义,如复合词。它们必须足够频繁地出现在一起,以引起你标记计数器的注意。

为什么要使用n-gram?正如你之前看到的,当一个标记序列被向量化为词袋(BOW)向量时,它会丢失很多单词顺序固有的意义。通过将标记的概念扩展到包括多单词标记,n-gram,你的NLP管道可以保留语句中单词顺序固有的大部分意义。例如,具有反转意义的单词“not”将保持与其邻近单词的关联,这正是它应该的位置。如果没有n-gram标记化,它将会是一个自由浮动的词,其意义会与整个句子或文档关联,而不是与其邻近的单词关联。2-gram“was not”保留了比单独的1-gram词汇在BOW向量中的“not”和“was”更多的含义。当你将一个单词与其邻居联系起来时,你会保留一些单词的上下文信息。

在下一章,我们将向你展示如何识别这些n-gram中哪些包含的信息相对于其他n-gram最为丰富,你可以使用这些信息来减少NLP管道需要跟踪的标记(n-gram)数量。否则,它就不得不存储并维护它遇到的每一个单词序列的列表。对n-gram的优先排序将帮助它识别“三体问题”和“ice cream”,而不特别关注“三体”或“ice shattered”。在第4章,我们将单词对,甚至更长的序列,与它们的实际含义关联,而不依赖于它们各自单词的含义。但现在,你需要你的标记化器生成这些序列,即这些n-gram。

停用词

停用词是任何语言中常见的词汇,这些词出现频率很高,但对于短语的意义并没有太多实质性的信息。以下是一些常见停用词的例子:

  • a, an
  • the, this
  • and, or
  • of, on

历史上,停用词通常被排除在NLP管道之外,以减少从文本中提取信息的计算工作量。尽管这些词本身携带的信息较少,但停用词作为n-gram的一部分,可以提供重要的关系信息。考虑以下两个例子:

  • Mark reported to the CEO
  • Suzanne reported as the CEO to the board

在你的NLP管道中,你可能会创建4-gram,例如“reported to the CEO”和“reported as the CEO”。如果你从4-gram中去除停用词,这两个例子都将被简化为“reported CEO”,这样你就丧失了关于职业层级的信息。在第一个例子中,Mark可能是CEO的助理,而在第二个例子中,Suzanne是CEO并向董事会汇报。不幸的是,在管道中保留停用词会带来另一个问题:它增加了生成这些由无意义的停用词形成的连接所需的n-gram长度。这个问题迫使我们至少保留4-gram,以避免“人力资源”例子中的歧义。

设计停用词过滤器取决于你的具体应用。词汇量将决定NLP管道中所有后续步骤的计算复杂度和内存需求,但停用词只占总词汇量的一小部分。典型的停用词列表大约只有100个常见且不重要的词。另一方面,为了跟踪在大量推文、博客文章和新闻文章中看到的95%的单词,你需要一个20,000个词的词汇表——这仅仅是为了1-gram或单词标记。20一个2-gram词汇表,旨在捕捉大型英语语料库中95%的2-gram,通常会包含超过一百万个独特的2-gram标记。

你可能担心词汇量会推动你必须获取的任何训练集的大小,以避免对特定单词或单词组合的过拟合。你知道训练集的大小决定了处理它所需的计算量。然而,从20,000个词汇中去掉100个停用词,并不会显著加快你的工作进程。对于2-gram词汇表来说,去除停用词所节省的计算量微乎其微。此外,对于2-gram来说,如果你不检查文本中使用这些停用词的2-gram频率而任意去除停用词,你会失去更多的信息。例如,你可能会错过把《闪灵》当作独特标题的提及,而将关于这部电影的文本与提到“Shining Light”或“shoe shining”的文档处理成一样的方式。

因此,如果你有足够的内存和处理带宽来运行管道中的所有NLP步骤,并且使用更大的词汇表,你可能不需要担心忽略那些不太重要的词。如果你担心用大词汇表对一个小训练集进行过拟合,选择词汇表或减少维度有比忽略停用词更好的方法。将停用词包含在词汇表中,可以让文档频率过滤器(在第3章中讨论)更准确地识别并忽略在特定领域中信息内容最少的词和n-gram。

spaCy和NLTK包包括适用于各种用例的多种预定义停用词集。21你可能不需要像列表2.2中那样的广泛停用词列表,但如果需要,你可以查看spaCy和NLTK的停用词列表。如果你需要更广泛的停用词集,你可以使用Searx22,23,它是SEO公司维护的多语言停用词列表。

如果你的NLP管道依赖于精细调整的停用词列表来实现高准确度,那么这可能会成为一个重大的维护难题。人类和机器(如搜索引擎)不断改变它们忽略的词汇。如果你能找到广告商使用的停用词列表,你可以利用这些列表来检测操控性的网页和SEO内容。如果网页或文章很少使用停用词,可能已经被“优化”以欺骗你。以下示例使用了从多个来源创建的详尽停用词列表。通过从示例文本中过滤掉这组广泛的词汇,你可以看到翻译过程中失去的含义。在大多数情况下,你会发现忽略停用词并不会提高你的NLP管道的准确性。

列表 2.2 一个广泛的停用词列表

>>> import requests
>>> url = ("https://gitlab.com/tangibleai/nlpia/-/raw/master/"
...        "src/nlpia/data/stopword_lists.json")
>>> response = requests.get(url)
>>> stopwords = response.json()['exhaustive']            #1
>>> tokens = 'the words were just as I remembered them'.split()   #2
>>> tokens_without_stopwords = [x for x in tokens if x not in stopwords]
>>> print(tokens_without_stopwords)
['I', 'remembered']
#1 这个详尽的停用词列表来自SEO列表、spaCy和NLTK。
#2 为了简化示例,句子中的标点符号和大写字母已被移除。

这是泰德·姜(Ted Chiang)的一篇短篇小说中的有意义句子,讲述机器帮助我们记住我们的话语,这样我们就不必依赖有缺陷的记忆。在这个短语中,你失去了三分之二的单词,只保留了句子的一些意义;然而,你可以看到,通过使用这个特别详尽的停用词集,重要的标记词“was”被丢弃了。你有时可以在没有冠词、介词甚至动词“to be”形式的情况下表达你的意思,但这会减少NLP管道的精度和准确性,至少会失去一些小的意义。

你可以看到,有些词比其他词承载更多的意义。想象一下某人做手语或某人急于写下给自己的一条便条。当他们急着写时,哪些词是他们会选择跳过的?这就是语言学家决定停用词列表的方式。但如果你很急,而你的NLP管道不像你一样匆忙,你可能不想浪费时间创建和维护停用词列表。

以下是另一个稍微不那么详尽的常见停用词列表。

列表 2.3 NLTK 停用词列表

>>> import nltk
>>> nltk.download('stopwords')
>>> stop_words = nltk.corpus.stopwords.words('english')
>>> len(stop_words)
179
>>> stop_words[:7]
['i', 'me', 'my', 'myself', 'we', 'our', 'ours']
>>> [sw for sw in stopwords if len(sw) == 1]
['i', 'a', 's', 't', 'd', 'm', 'o', 'y']

专注于第一人称的文档非常无聊,更重要的是,对你来说,它的信息内容很低。NLTK包将代词(不仅仅是第一人称代词)包括在其停用词列表中。这些单字母的停用词更为奇怪,但如果你经常使用NLTK的标记化器和Porter词干提取器,它们也有意义。当收缩词被拆分并使用NLTK的标记化器和词干提取器进行词干提取时,这些单字母标记会频繁出现。

警告

scikit-learn、spaCy、NLTK和SEO工具中的英语停用词集差异很大,并且这些停用词集在不断变化。截至写作时,scikit-learn有318个停用词,NLTK有179个停用词,spaCy有326个,而我们的“详尽”SEO列表包括667个停用词。这是考虑不过滤停用词的一个很好的理由。如果你选择过滤,其他人可能无法复制你的结果。

2.3.2 规范化你的词汇表

你已经看到,词汇表的大小对NLP管道的性能有多重要。另一个词汇“整理”技术是规范化你的词汇表,以便将表示相似意义的标记合并为一个单一的规范化形式。这样做可以减少你需要在词汇表中保留的标记数量,并且有助于在你的语料库中建立这些不同“拼写”形式的标记或n-gram之间的意义联系。正如我们之前提到的,减少词汇表的大小可以降低过拟合的可能性。

大小写折叠(Case Folding)

大小写折叠是指将单词的多个“拼写”形式合并为一个,通常这些拼写形式只在大小写上有所不同。为什么我们要使用大小写折叠呢?当单词因为在句子开头而被大写,或者为了强调而被写成全大写时,单词可能会变得“大小写不规范”。撤销这种不规范化称为大小写规范化,或者更常见地称为大小写折叠。规范化单词和字符的大写形式是减少词汇表大小并使NLP管道更具泛化能力的一种方式。它帮助你将那些本应具有相同意义(和拼写)的单词归并为一个标记。然而,大写字母通常传递某些信息——例如,"doctor" 和 "Doctor" 通常有不同的含义。大写常用于表示某个词是专有名词——如人的名字、地点或事物的名称。如果命名实体识别对你的管道很重要,你需要能够区分专有名词和其他单词。

然而,如果标记没有经过大小写规范化,你的词汇表大小可能会大约是原来的两倍,消耗更多的内存和处理时间,并增加你需要为机器学习管道提供标记的数据量,以便让它收敛到一个准确且通用的解决方案。就像任何其他机器学习管道一样,你用于训练的标记数据集必须是“代表性的”,即能够涵盖模型必须处理的所有特征向量空间,包括大小写的变化。对于100,000维的BOW(词袋)向量,你通常需要100,000个标记样本,有时甚至更多,才能训练一个监督式机器学习管道,而不至于过拟合。在某些情况下,减少一半的词汇表大小有时可以抵消失去的信息内容。

在Python中,你可以轻松地使用列表推导式来规范化标记的大小写:

>>> tokens = ['House', 'Visitor', 'Center']
>>> normalized_tokens = [x.lower() for x in tokens]
>>> print(normalized_tokens)
['house', 'visitor', 'center']

如果你确信要对整个文档进行大小写规范化,你可以在标记化之前使用 lower() 将整个文本字符串转为小写。但这会阻止一些高级标记化器,比如能够拆分驼峰式命名的标记化器(如WordPerfect、FedEx或stringVariableName)。也许你希望“WordPerfect”保持其独特的标记,或者你可能想怀念那个更完美的文字处理时代。你可以决定何时以及如何应用大小写折叠。

通过大小写规范化,你试图将这些标记恢复到它们的“正常”状态,之前的语法规则和它们在句子中的位置影响了它们的大小写。规范化文本字符串大小写的最简单且最常见的方法是使用像Python内置的 str.lower() 这样的函数将所有字符转换为小写。不幸的是,这种方法也会“规范化”掉很多有意义的大写字母,除了你打算规范化的句子首字母大写(即句子的第一个单词)。一种更好的大小写规范化方法是仅将句子的第一个单词转换为小写,并让其他单词保持原样。

对句子首个单词进行小写化可以保留句子中间的专有名词的意义,如“Joe”和“Smith”在“Joe Smith”中。它还可以正确地将那些只有在句首才大写的单词分组在一起,因为它们不是专有名词。这可以防止在标记化过程中将“Joe”与“coffee”(joe)混淆。此方法还能防止在句子“一个词匠喝了一杯joe”中,将“smith”的黑smith含义与“Smith”这一专有名词混淆。即使使用这种小心的大小写规范化方法,在句子开头的小写化时,你仍然需要为那些极少出现的专有名词引入大小写错误。“Joe Smith, the word smith, with a cup of joe” 会与 “Smith the word with a cup of joe, Joe Smith” 产生不同的标记集合。而你可能不希望那样。此外,对于没有大小写概念的语言(如阿拉伯语或印地语),大小写规范化是没有用的。

为了避免这种潜在的信息丢失,许多NLP管道根本不进行大小写规范化。对于许多应用程序,减少词汇表大小约一半所带来的效率提升(在存储和处理方面)被专有名词信息丧失所抵消。但是,即使不进行大小写规范化,仍然可能会丢失一些信息。没有识别出句首的“the”作为停用词,可能会对某些应用程序造成问题。非常复杂的管道会在选择性地规范化句子开头的单词大小写时,先检测到专有名词。如果你没有很多“Smith”或“word smith”出现在你的语料库中,也不在乎它们是否被分配到相同的标记上,你可以选择将所有文本小写化。最好的方法是尝试几种不同的方法,然后查看哪种方法能为你的NLP项目目标提供最佳性能。

通过将模型泛化为处理具有奇怪大小写的文本,大小写规范化可以减少机器学习管道的过拟合。大小写规范化对于搜索引擎特别有用。在搜索中,规范化会增加为特定查询找到的匹配项数量。这通常被称为搜索引擎(或任何其他分类模型)的召回性能指标。

没有规范化的搜索引擎,如果你搜索“Age”,你将得到与搜索“age”不同的文档集。Age更可能出现在诸如“New Age”或“Age of Reason”的短语中,而“age”则更可能出现在诸如“at the age of”之类的句子中,这些是关于托马斯·杰斐逊的。如果你在搜索索引(以及查询)中对词汇进行规范化,你可以确保无论查询中的大小写如何,关于“age”的两类文档都会被返回。

这种额外的召回精度是以牺牲精确度为代价的,返回了用户可能不感兴趣的许多文档。这导致现代搜索引擎允许用户关闭每次查询的大小写规范化,通常是通过引用他们希望仅返回完全匹配的单词。如果你正在构建这样的搜索引擎管道,为了适应两种类型的查询,你必须为你的文档构建两个索引:一个包含大小写规范化的n-gram,另一个包含原始大小写。

词干提取(STEMMING)

另一种常见的词汇规范化技术是消除单词的复数形式或所有格结尾,甚至是各种动词形式的细微意义差异。这种规范化技术,通过识别单词的共同词干,将不同形式的单词合并为一个规范化形式,称为词干提取。例如,单词“housing”和“houses”共享相同的词干:house。词干提取通过去除单词的后缀,试图将具有相似意义的单词合并在一起,形成它们共同的词干。词干不要求是正确拼写的单词,而只是一个标记或标签,表示一个单词的几种可能拼写形式。

人类可以轻松地看出“house”和“houses”是同一个名词的单数和复数形式;然而,你需要某种方法将这些信息提供给机器。词干提取的主要好处之一是压缩了你的软件或语言模型需要跟踪的单词数量。它减少了词汇表的大小,同时尽可能减少信息和意义的丧失。在机器学习中,这被称为维度减少。它帮助泛化你的语言模型,使模型在所有包含在同一词干索引位置的单词上表现相同。因此,只要你的应用不需要区分“house”和“houses”,这个词干提取就会将你的编程或数据集大小减少一半,甚至更多,具体取决于你选择的词干提取器的“激进程度”。

词干提取对于关键词搜索或信息检索非常重要。它允许你搜索波特兰的“developing houses”并获得同时使用“house”和“houses”甚至“housing”这几个单词的网页或文档,因为这些单词都被词干化(合并)为“hous”这个标记。同样,你可能会收到包含“developer”和“development”而不是“developing”的页面,因为所有这些单词通常都会归结为词干“develop”。如你所见,这是对搜索结果的扩展,确保你不太可能错过相关的文档或网页。这种扩展搜索结果的做法会大大提高你搜索引擎在返回所有相关文档时的召回率评分。

但词干提取可能会大大降低搜索引擎的精确度评分,因为它可能会返回许多不相关的文档,和相关文档一起。对于某些应用程序,这种假阳性率(返回的页面中你认为不有用的比例)可能是一个问题。因此,大多数搜索引擎允许你关闭词干提取,甚至关闭大小写规范化,只需在单词或短语周围加引号。加引号表示你只希望返回包含精确拼写的页面,例如“Portland Housing Development software”。这会返回与讨论“Portland software developer’s house”的文档不同的文档。有时你可能希望搜索“Dr. House’s calls”而不是“dr house call”,如果你对这个查询使用了词干提取器,后者可能会是有效的查询。

以下是一个纯Python实现的简单词干提取器,它可以处理以“s”结尾的情况:

>>> def stem(phrase):
...     return ' '.join([re.findall('^(.*ss|.*?)(s)?$',
...         word)[0][0].strip("'") for word in phrase.lower().split()])
>>> stem('houses')
'house'
>>> stem("Doctor House's calls")
'doctor house call'

上面的词干提取器函数遵循以下几个简单规则,在这个简短的正则表达式中:

  • 如果一个单词以多个“s”结尾,那么词干就是该单词,后缀为空字符串。
  • 如果一个单词以单个“s”结尾,那么词干就是去掉“s”后的单词,后缀为“s”。
  • 如果一个单词不是以“s”结尾,那么词干就是该单词,不返回后缀。

strip 方法确保一些所有格的单词可以与复数形式一起进行词干提取。

这个函数适用于常规情况,但它无法处理更复杂的情况。例如,这些规则会在处理“dishes”或“heroes”这样的单词时失败。对于像这些更复杂的情况,NLTK包提供了其他词干提取器。上述函数也无法处理你在波特兰住房搜索中的“housing”示例。

两个最流行的词干提取算法是Porter和Snowball词干提取器。Porter词干提取器以计算机科学家Martin Porter的名字命名,他花费了80年代和90年代的大部分时间,精心调整这个硬编码的算法。Porter还改进了Porter词干提取器,创造了Snowball词干提取器。Porter将自己大部分时间投入到文档化和改进词干提取器上,因为它们在信息检索(关键词搜索)中具有重要价值。这些词干提取器实现了比我们简单的正则表达式更复杂的规则,使得词干提取器能够处理英语拼写和单词结尾规则的复杂性:

>>> from nltk.stem.porter import PorterStemmer
>>> stemmer = PorterStemmer()
>>> ' '.join([stemmer.stem(w).strip("'") for w in
...   "dish washer's fairly washed dishes".split()])
'dish washer fairli wash dish'

请注意,Porter词干提取器和正则表达式词干提取器一样,保留了结尾的撇号(除非你显式地去掉它),这确保了所有格单词和非所有格单词的区分。所有格单词通常是专有名词,因此这个特性对于你希望将专有名词与其他名词区分开来的应用程序来说可能很重要。

更多关于Porter词干提取器的信息

Julia Menchavez 慷慨地分享了她将Porter的原始词干提取算法翻译成纯Python的代码(github.com/jedijulia/p…

Porter词干提取算法共有八个步骤:1a、1b、1c、2、3、4、5a 和 5b。步骤1a有点像你用来处理尾部有"s"的正则表达式:

def step1a(self, word):
    if word.endswith('sses'):
        word = self.replace(word, 'sses', 'ss')   #1
    elif word.endswith('ies'):
        word = self.replace(word, 'ies', 'i')
    elif word.endswith('ss'):
        word = self.replace(word, 'ss', 'ss')
    elif word.endswith('s'):
        word = self.replace(word, 's', '')
    return word

#1 这与str.replace()完全不同。Julia的 self.replace() 只修改单词的结尾部分。

其余的七个步骤要复杂得多,因为它们需要处理英语拼写规则的复杂性,具体包括:

  • 步骤1a——s和es结尾
  • 步骤1b——ed、ing和at结尾
  • 步骤1c——y结尾
  • 步骤2——“名词化”结尾,如ational、tional、ence 和 able
  • 步骤3——形容词结尾,如icate、ful和alize
  • 步骤4——形容词和名词结尾,如ive、ible、ent和ism
  • 步骤5a——剩余的顽固的e结尾
  • 步骤5b——尾部的双重辅音,其中词干将以单一的l结尾

Snowball词干提取器比Porter词干提取器更为激进。注意,它将“fairly”词干化为“fair”,比Porter词干提取器更为准确:

>>> from nltk.stem.snowball import SnowballStemmer
>>> stemmer = SnowballStemmer(language='english')
>>> ' '.join([stemmer.stem(w).strip("'") for w in
...   "dish washer's fairly washed dishes".split()])
'dish washer fair wash dish'

词形还原(LEMMATIZATION)

如果你能够获取到不同单词之间的语义关联信息,那么你可能可以将几个单词关联起来,即使它们的拼写差异很大。这种更广泛的规范化,直至单词的语义根——即词形——称为词形还原。

在第3章和第11章中,我们展示了如何使用词形还原来简化聊天机器人响应语句所需的逻辑复杂度。任何希望对多个不同拼写的同一基本词根单词作出相同反应的NLP管道,都能从词形还原中受益。它减少了你必须响应的单词数量——即语言模型的维度。词形还原可以使你的模型更加通用,但也可能使模型的精度降低,因为它会将所有拼写变体视为相同的词根。例如,chat、chatter、chatty、chatting,甚至可能包括chatbot,在进行词形还原的NLP管道中都会被视为相同的单词,尽管它们具有不同的含义。同样,bank、banked和banking在词干提取管道中也会被视为相同的标记,尽管它们分别表示河流、摩托车运动和金融的含义。

在你深入了解本节时,思考那些在词形还原中会极大改变单词意义的情况,甚至可能颠倒它的意义,并从你的管道中产生相反的响应。这种情况被称为欺骗——即通过巧妙地构造难度较大的输入,试图从机器学习管道中引出错误的响应。

有时,词形还原是规范化词汇表中单词的更好方式。你可能会发现,对于你的应用,词干提取和大小写折叠会创建不考虑单词意义的词干和标记。词形还原器使用单词同义词和单词结尾的知识库,确保只有具有相似含义的单词被合并为一个标记。

一些词形还原器会使用单词的词性标签(POS)来提高准确性。单词的词性标签指示它在短语或句子中的语法角色。例如,名词词性(POS)用于指代短语中的人、地方或事物;形容词词性用于描述或修饰名词;动词词性指代一个动作。要识别一个单词的词性,需要了解该单词的上下文;因此,一些高级的词形还原器无法仅对单词进行词性标记。

你能想到哪些方法可以使用词性来识别比词干提取更好的单词“根”吗?考虑单词“better”。词干提取器会去掉“better”中的“er”结尾,并返回词干“bett”或“bet”。然而,这样就将“better”与“betting”、“bets”和“Bet’s”这类单词混为一谈,而不是与“betterment”和“best”或甚至“good”和“goods”这样的更相似的单词混合。

因此,词形还原器在大多数应用中优于词干提取器。词干提取器通常只在大规模信息检索应用(如关键词搜索)中使用。如果你真的希望在信息检索管道中获得词干提取器的维度减少和召回提升,最好在词干提取器之前使用词形还原器。因为单词的词形是有效的英语单词,词干提取器可以很好地处理词形还原器的输出。这个技巧将减少你的维度,并比仅使用词干提取器更有效地提高信息检索的召回率。

如何在Python中识别单词词形?NLTK包提供了相关功能。请注意,要找到最准确的词形,你必须告诉WordNetLemmatizer你感兴趣的词性:

>>> nltk.download('wordnet')
True
>>> nltk.download('omw-1.4')
True
>>> from nltk.stem import WordNetLemmatizer
>>> lemmatizer = WordNetLemmatizer()
>>> lemmatizer.lemmatize("better")     #1
'better'
>>> lemmatizer.lemmatize("better", pos="a")    #2
'good'
>>> lemmatizer.lemmatize("good", pos="a")
'good'
>>> lemmatizer.lemmatize("goods", pos="a")
'goods'
>>> lemmatizer.lemmatize("goods", pos="n")
'good'
>>> lemmatizer.lemmatize("goodness", pos="n")
'goodness'
>>> lemmatizer.lemmatize("best", pos="a")
'best'
#1 默认词性为名词(n)。
#2 a表示形容词词性(POS)。

你可能会惊讶于第一次尝试对单词“better”进行词形还原时,它并没有发生变化。这是因为单词的词性可以对其意义产生重大影响。如果没有为单词指定词性,NLTK的词形还原器假定它是一个名词。一旦你为单词指定了正确的词性,在这个例子中是形容词(a),词形还原器就会返回正确的词形。不幸的是,NLTK的词形还原器仅限于普林斯顿WordNet词汇的意义连接,因此“best”并没有与“better”归一化到同一个词根。这个词汇图谱也缺少“goodness”和“good”之间的连接。另一方面,Porter词干提取器会通过盲目地去除所有单词的“ness”后缀来建立这一连接:

>>> stemmer.stem('goodness')
'good'

在spaCy中实现词形还原非常简单:

>>> import spacy
>>> nlp = spacy.load("en_core_web_sm")
>>> doc = nlp("better good goods goodness best")
>>> for token in doc:
>>>     print(token.text, token.lemma_)
better well
good good
goods good
goodness goodness
best good

与NLTK不同,spaCy将“better”还原为“well”,假定它是副词,并为“best”返回了正确的词形(good)。

同义词替换(SYNONYM SUBSTITUTION)

有五种类型的同义词替换,有时在创建一致的较小词汇表时很有帮助,这样可以帮助你的NLP管道更好地泛化:

  1. 错别字纠正
  2. 拼写纠正
  3. 同义词替换
  4. 缩写展开
  5. 表情符号展开

每种同义词替换算法的设计可以根据需求做得更为激进或保守,你需要考虑用户在你的领域中使用的语言。例如,在法律、技术或医学领域,通常不建议使用同义词替换。医生不希望聊天机器人告诉患者他们的“心碎了”,因为一些同义词替换可能把“心碎”误认为是心形表情符号(<3)。尽管如此,词形还原和词干提取的用例同样适用于同义词替换。

词形还原、词干提取和同义词替换的使用场景

在什么情况下你应该使用词形还原器、词干提取器或同义词替换?词干提取器通常计算速度更快,所需的代码和数据集也较简单,但词干提取器会犯更多错误,并且会对更多单词进行词干提取,从而比词形还原器减少更多的文本信息内容或意义。词干提取和词形还原都会减少词汇表的大小并增加文本的歧义性,但词形还原器能更好地保留文本中的信息内容,基于单词在文本中的使用方式和预期意义。因此,一些最先进的NLP包(如spaCy)不提供词干提取功能,而只提供词形还原方法。

如果你的应用涉及搜索,词干提取和词形还原可以通过将更多文档与相同的查询词关联,从而提高搜索的召回率。然而,词干提取、词形还原甚至大小写折叠通常会降低搜索结果的精度和准确性。这些词汇压缩方法可能导致信息检索系统(搜索引擎)返回与单词原始意义不相关的许多文档。这些被称为假阳性,即与搜索查询不匹配的错误结果。有时,假阳性比假阴性更不重要——假阴性是指搜索引擎根本没有列出你正在寻找的文档。

由于搜索结果可以根据相关性进行排序,搜索引擎和文档索引通常会在处理查询和索引文档时使用词形还原。这意味着搜索引擎会在标记化搜索文本时以及在索引其文档集合(如它爬取的网页)时使用词形还原。但搜索引擎会将未进行词干提取的单词版本的搜索结果合并,以排序向用户呈现的搜索结果。

对于基于搜索的聊天机器人,精确度通常比召回率更为重要。假阳性匹配可能导致聊天机器人说出不合适的话,而假阴性则会让聊天机器人谦逊地承认无法找到合适的回答。如果你的NLP管道首先使用未进行词干提取、未规范化的单词来搜索与用户问题的匹配,聊天机器人会显得更好。如果没有找到其他内容可以说,它可以回退到规范化标记匹配。你可以将这些回退匹配的结果排在未规范化匹配的后面。你甚至可以通过引入更低排名的回应并附上免责声明,比如“我之前没听过这样的句子,但使用我的词干提取器我找到了……”来为你的聊天机器人增加谦逊和透明度。在这个充斥着夸夸其谈的聊天机器人的现代世界中,你的谦逊型聊天机器人可以脱颖而出,获得成功!

在以下四种场景中,同义词替换可能有意义:

  1. 搜索引擎
  2. 数据增强
  3. 评估NLP的鲁棒性
  4. 对抗性NLP

搜索引擎可以通过使用同义词替换来提高稀有术语的召回率。当你拥有有限的标记数据时,仅通过同义词替换就可以将数据集扩展十倍。如果你想找到模型准确性的下限,可以在测试集中积极进行同义词替换,看看你的模型对这些变化的鲁棒性如何。如果你正在寻找对抗或逃避NLP算法检测的方法,同义词可以为你提供大量的探测文本。你可以想象,替换“cash”、“dollars”或“hryvnia”这些词可能有助于避开垃圾邮件检测器。

总结来说,除非你有有限量的文本,其中包含你感兴趣单词的使用和大小写形式,否则尽量避免使用词干提取、词形还原、大小写折叠或同义词替换。随着NLP数据集的爆炸性增长,对于英语文档来说,这种情况很少发生,除非你的文档使用大量的行话或来自某个非常小的科学、技术或文学子领域。尽管如此,对于非英语语言,你仍然可能发现词形还原的使用场景。斯坦福大学的信息检索课程完全摒弃了词干提取和词形还原,因为它们在提高召回率准确度方面的改善微乎其微,而在精度上的显著下降。

2.4 挑战性标记:处理表意文字语言

中文、日文及其他表意文字语言不像字母表语言那样仅使用少数几个字母来组成标记或单词。这些语言的字符看起来更像是图画,因此被称为“表意文字”。中文有成千上万个独特的字符,这些字符的使用方式类似于字母表语言中单词的使用方式。然而,每个中文字符通常并不是一个完整的单词;一个字符的意义依赖于其两边的字符,而且单词之间没有空格分隔。这使得将中文文本分割成单词或其他思维和意义的单元变得非常具有挑战性。

Jieba是一个Python包,可以用来将传统中文文本分割成单词。它支持三种分割模式:

  • 全模式——从句子中获取所有可能的单词
  • 精确模式——将句子切割成最精确的片段
  • 搜索引擎模式——将长单词切割成更短的单词(类似于你在英文中拆分复合词或找到词根)

在下面的例子中,中文句子“西安是一座举世闻名的文化古城”翻译为“Xi’an is a city famous worldwide for its ancient culture.” 或者更紧凑且直白的翻译可能是“Xi’an is a world-famous city for her ancient culture”。

从语法的角度来看,你可以将这个句子分解成以下部分:西安 (Xi'an)、是 (is)、一座 (a)、举世闻名 (world-famous)、的 (adjective suffix)、文化 (culture)、古城 (ancient city)。其中,字符“座”是量词,表示“古城”的“座”修饰语。在Jieba的精确模式下,它会将句子分割成这样,以便你能准确地提取文本的含义。

示例 2.4 Jieba的精确模式
>>> import jieba
>>> seg_list = jieba.cut("西安是一座举世闻名的文化古城")  #1
>>> list(seg_list)
['西安', '是', '一座', '举世闻名', '的', '文化', '古城']
#1 Jieba的默认模式是精确模式。

Jieba的精确模式将尽量保持尽可能多的字符在一起,从而最小化标记的总数。这将减少检测单词边界时的假阳性率或类型1错误。如以下示例所示,在全模式下,Jieba会尝试将文本拆分成更小的单词,从而增加它们的数量。

示例 2.5 Jieba的全模式
>>> import jieba
>>> seg_list = jieba.cut("西安是一座举世闻名的文化古城", cut_all=True)  #1
>>> list(seg_list)
['西安', '是', '一座', '举世', '举世闻名', '闻名', '的', '文化', '古城']
#1 cut_all=True指定全模式。

你可以在 Jieba GitHub页面 上找到更多信息。spaCy也包含了中文语言模型,能很好地分割和标记中文文本,例如 zh_core_web_sm。Jieba包也有词性标注功能,但现代版本的Python(3.5+)不支持Jieba的词性标注模型。然而,尽管Jieba在过去几年未得到维护,它仍然非常流行。

2.4.1 复杂的图像:中文的词形还原和词干提取

与英语不同,表意文字语言(如中文和日文(汉字))没有词干提取或词形还原的概念。然而,有一个相关的概念。中文字符的最基本构建块称为“偏旁部首”。为了更好地理解偏旁部首,你必须首先了解中文字符是如何构建的。中文字符有六类,但最重要的四类涵盖了大部分中文字符:

  • 象形字——由真实物体的图像构成的字符,例如“口”(mouth)和“门”(door)。
  • 形声字——由一个部首和一个单独的汉字构成,例如“妈”(mother)= “女”(female)+ “马”(horse)。
  • 会意字——由其他象形字组合而成,例如“旦”(dawn),上部(日)代表太阳,下部(一)象征地平线。
  • 指事字——不能容易用图像表示的字符,因此用一个抽象符号表示,例如“上”(up)和“下”(down)。

如你所见,像词干提取和词形还原这样的过程对于许多中文字符来说要么更加困难,要么是不可能的。分离字符的部分可能会彻底改变它的意义,并且没有规定的顺序或规则来将偏旁部首组合起来形成中文字符。

然而,有些词干提取在中文中比在英语中更为简单。例如,自动去除像“we”、“us”、“they”和“them”这类词的复数形式,在英语中很难做到,但在中文中却很简单。中文通过词尾的附加字符来构建复数形式,类似于在英语单词末尾加s。在中文中,复数后缀字符是“们”。例如,词语“朋友”(friend)变成“朋友们”(friends)。

即使是“we/us”、“they/them”和“y’all”这些字符,也使用相同的复数后缀:“我们”、“他们”、“你们”。在英语中,你可以通过去掉动词的ing或ed来获取根词;然而,在中文中,动词变位会在前面或后面添加额外的字符来表示时态。中文的动词变位没有固定规则。例如,看看“学”(learn)在“在学”(learning)和“学过”(learned)中的使用。在大多数情况下,你希望将整合的中文字符保留在一起,而不是将其拆解成组件。

事实上,这是所有语言的一个很好的经验法则:让数据说话。除非统计数据表明这样做会帮助你的NLP管道表现更好,否则不要进行词干提取或词形还原。难道在“smarter”和“smartest”还原为“smart”时,不会丢失少量意义吗?确保词干提取不会让你的NLP管道变得“愚蠢”。

让统计数据帮助你决定如何或者是否要分解任何特定的单词或n-gram。在下一章中,我们将向你展示一些工具,如scikit-learn的TfidfVectorizer,它能够处理所有这类繁琐的计算,帮助你正确地完成这项工作。

2.5 词汇标记的向量表示

现在,你已经将文本拆分成了有意义的标记,那么接下来应该如何处理它们呢?如何将它们转化为机器能够理解的数字呢?最简单、最基础的做法是检测一个你感兴趣的标记是否存在。你可以通过硬编码逻辑来检查是否包含重要的标记,这些标记通常被称为关键词。

这种方法可能适用于第1章中的问候语意图识别器,该识别器会在文本字符串的开头查找像“Hi”和“Hello”这样的词。通过新的标记化文本,你可以帮助检测“Hi”和“Hello”这类词是否存在,而不会被像“Hiking”和“Hell”这样的词所混淆。有了新的标记器,你的NLP管道就不会误将“Hiking”解读为问候语“Hi king”。

标记化可以帮助你减少在简单意图识别管道中出现的假阳性数量,这种管道查找问候语词汇的存在。这通常被称为“关键词检测”,因为你的词汇表被限制为一组你认为重要的词汇。然而,想要考虑所有可能出现在问候中的词汇,包括俚语、拼写错误和错别字,并为它们创建一个循环去迭代,这样做是非常繁琐且低效的。我们可以利用线性代数的数学和numpy的向量化操作来加速这一过程。因此,你需要学一些代数来解决一个问题:如何判断某个特定的标记或“意图”是否出现在你的文本中。你将首先学习一种最基本、直接、原始且无损的方式来表示单词:独热编码(One-hot Encoding)。

2.5.1 独热向量

现在,你已经成功地将文档拆分为你想要的单词,可以开始将它们转换为向量。数字向量是我们进行自然语言处理数学运算或处理所需要的:

>>> import pandas as pd
>>> onehot_vectors = np.zeros(
...     (len(tokens), vocab_size), int)    #1
>>> for i, tok in enumerate(tokens):
...     if tok not in vocab:
...         continue
...     onehot_vectors[i, vocab.index(tok)] = 1          #2
>>> df_onehot = pd.DataFrame(onehot_vectors, columns=vocab)
>>> df_onehot.shape
(18, 15)
>>> df_onehot.iloc[:,:8].replace(0, '')    #3
    ,  .  Survival  There's  adequate  as  fittest  maybe
0                       1
1
2
3
4                                   1
5
6
7
8                                       1
9      1
10              1
11
12
13
14                                            1
15  1
16                                                     1
17     1
#1 表格的宽度等于唯一词汇项的数量,高度等于文档的长度:18行和15列。
#2 对于句子中的每个标记,在对应的列上标记1。
#3 为了简洁起见,我们只显示了前八列,并将0替换为空字符串('')。

在这个表示两句话的例子中,每一行是文本中单个单词的向量表示。该表格有15列,因为这是你的词汇表中唯一单词的数量。表格有18行,每一行对应文档中的一个单词。列中的数字1表示该位置的词汇在文档中存在。

你可以“读取”一个独热编码(向量化)文本,从上到下。你可以看出,文本中的第一个单词是“There's”,因为第一行的1位于列标签“There's”下。接下来的三行(行索引1、2、3)为空白,因为我们为了适应页面显示而截断了表格。第五行,索引号为4的0偏移,显示文本中的第五个单词是“adequate”,因为该列有1。

独热向量是非常稀疏的,每行向量中只有一个非零值。为了显示方便,代码将0替换为空字符串(''),但它实际上没有更改你在NLP管道中处理的数据框(DataFrame)——它只是使其更易于阅读。

注意:不要在你打算在机器学习管道中使用的DataFrame中添加字符串。标记器和向量化器(如这种独热向量化器)的目的是创建一个机器学习管道可以进行数学运算的数字数组。你不能对字符串进行数学运算。

表格的每一行都是一个二进制行向量,你可以看到它也被称为独热向量:每行的所有位置(列)都是0或空白,只有一个位置或列是“热”的(1)。1表示开启,或热,0表示关闭,或缺失。

这种词汇表示和文档表格表示的一个好处是没有丢失任何信息。标记的精确顺序通过表格中独热向量的顺序编码。当你跟踪哪些词汇对应哪些列时,你可以从这个表格中完美地重建原始的标记序列。即使你的标记器在生成你认为有用的标记时只准确90%,这个重建过程仍然是100%准确的。因此,像这种独热词向量通常用于神经网络、序列到序列的语言模型和生成式语言模型。它们是任何需要保留原始文本中所有含义的模型或NLP管道的不错选择。

提示:独热编码器(向量化器)没有丢失文本中的任何信息,但我们的标记器丢失了。我们的正则表达式标记器丢弃了单词之间有时出现的空格字符(\s),因此你无法使用解标记器完美重建原始文本。然而,像spaCy这样的标记器会保留这些空格字符,实际上,它可以完美地解标记化一系列标记。spaCy正是因为这个高效且准确地处理空格字符的功能而得名。

这一系列独热向量就像是原始文本的数字录音。如果你仔细看,你可能会把这个由1和0组成的矩阵想象成播放器钢琴的滚轴,或者甚至是音乐盒金属鼓上的凸起。表格顶部的词汇键告诉机器在标记序列或钢琴音乐的每一行中播放哪个“音符”或单词,如图2.2所示。与播放器钢琴或音乐盒不同,你的机械词记录和播放器每次只能用一个“指头”,并且词汇间的间隔没有变化。

image.png

重点是,你已经将一段自然语言的句子转化为数字序列,或称向量。现在,你可以让计算机像处理其他向量或数字列表一样,读取并对这些向量进行数学运算。这使得你的向量可以输入到任何需要这种向量的NLP管道中。第5到第10章中的深度学习管道通常需要这种表示,因为它们可以被设计成从这些原始的文本表示中提取“意义特征”,而且深度学习管道还可以从这些数字表示中生成文本。因此,在后面的章节中,从你的NLG管道流出的词流通常会以独热编码向量流的形式表示,就像《西部世界》中播放器钢琴为一个较不人工化的观众演奏一首歌一样。

现在,你需要做的就是弄清楚如何构建一个能够理解并以新的方式组合这些词向量的“播放器钢琴”。最终,你希望你的聊天机器人或NLP管道能够播放一首歌或说出一些你以前没有听过的话。在第9和第10章中,你将学习到如何使用循环神经网络,它们对像这样的独热编码标记序列非常有效。

通过独热词向量表示的句子,保留了原句的所有细节、语法和顺序,你成功地将词转化为了计算机能够“理解”的数字。它们也是计算机非常喜欢的数字类型:二进制数字。但是,对于一段简短的句子来说,这是一个庞大的表格。如果你想一想,你会发现你已经扩大了存储文档所需的文件大小。对于长文档来说,这可能并不实用。

那么,这种无损的数字表示文档集合有多大呢?你的词汇表大小(即向量的长度)会变得非常庞大。英语语言至少包含20,000个常用词,如果包括名字和其他专有名词,则数量可能达到几百万个,而你的独热向量表示需要为每个要处理的文档创建一个新的表(矩阵)。这几乎就像是你文档的原始图像。如果你做过图像处理,你知道如果你想从数据中提取有用信息,就需要执行降维。

让我们通过数学来帮助你理解这些“钢琴卷轴”有多大且难以处理。在大多数情况下,你在NLP管道中使用的标记词汇表将远远超过10,000或20,000个标记——有时,它可能是几十万个甚至几百万个标记。假设你有一百万个标记在NLP管道词汇表中,并且假设你有3,000本书,每本书有3,500个句子,每个句子有15个单词(对于短书来说,这些是合理的平均值)。每本书都需要一个,这意味着你有大量的大型表格(矩阵)。

所有这些数据将占用157.5太字节的数据量,这是超过一百万兆字节;即使你非常高效,每个矩阵单元只使用一个字节,你仍然可能无法将其存储在硬盘上。每个单元格一个字节,你可能需要近20太字节的存储空间来处理这样的小书架。如果你以这种方式处理文档,幸运的是,你不会使用这种数据结构来存储文档。你仅在处理文档时,逐个词地在RAM中暂时使用它。

因此,存储所有这些零并记录文档中所有单词的顺序既不实际也不太有用。你的数据结构并没有从自然语言文本中抽象或概括出任何东西。像这样的NLP管道还没有进行任何实际的特征提取或降维操作来帮助你的机器学习在现实世界中运行得更好。

你真正想做的是压缩文档的意义到其精髓。你希望将文档压缩为一个单一的向量,而不是一个庞大的表格。你愿意放弃完美的“召回率”,只想捕捉到文档中的大部分意义(信息),而不是所有内容。

2.5.2 词袋模型向量

有没有办法将所有这些播放器钢琴的乐谱压缩成一个单一的向量呢?向量是表示任何对象的好方法。通过向量,我们可以通过检查它们之间的欧几里得距离来比较文档。向量使得我们可以在自然语言上应用所有线性代数工具,而这正是NLP的目标:对文本进行数学运算。

假设你可以忽略文本中单词的顺序。为了实现文本的向量表示的初步处理,你可以将所有单词混合到一起,形成一个“袋子”,每个句子或短文档对应一个袋子。事实上,仅仅知道一个文档中包含哪些单词,就可以为你的自然语言理解(NLU)管道提供大量关于文档内容的信息。实际上,这就是大多数互联网搜索引擎背后的表示方式。即使是几页长的文档,词袋模型(BOW)向量对于总结文档的核心内容也很有用。

让我们来看一下当我们把《偷书贼》中的文本单词混合并计数时会发生什么:

>>> bow = sorted(set(re.findall(pattern, text)))
>>> bow[:9]
[',', '.', 'Liesel', 'Trust', 'and', 'arrived', 'clouds', 'hands', 'her']
>>> bow[9:19]
['hold', 'in', 'like', 'me', 'on', 'out', 'rain', 'she', 'the', 'their']
>>> bow[19:27]
['them', 'they', 'though', 'way', 'were', 'when', 'words', 'would']

即使这个混乱的词袋中,你也可以大致了解这句话是关于“Trust”(信任)、“words”(单词)、“clouds”(云)、“rain”(雨)以及一个名为Liesel的人物。你可能注意到,Python的sorted()函数将标点符号放在字符前面,将大写字母放在小写字母前面。这是ASCII和Unicode字符集中的排序方式;然而,你的词汇表的顺序并不重要。只要在所有你以这种方式标记的文档中保持一致,机器学习管道在任何词汇顺序下都会同样有效。

你可以使用这种新的词袋向量方法将每个文档的信息内容压缩成一个更易于处理的数据结构。对于关键字搜索,你可以将播放器钢琴乐谱表示中的独热词向量用逻辑“或”运算组合成一个二进制的BOW向量。在播放器钢琴的比喻中,这就像是一次性弹奏多个旋律音符,形成一个“和弦”。而不是在你的NLU管道中“逐个重播”这些音符,你将为每个文档创建一个单一的BOW向量。

你可以用这个单一的向量来表示整个文档。由于向量需要具有相同的长度,你的BOW向量需要和你的词汇表大小一样长,也就是文档中唯一标记的单词数量。你还可以忽略许多不太有趣的单词,这些单词对于搜索术语或关键词并不重要。这就是为什么在进行BOW标记时,常常忽略停用词的原因。这对于搜索引擎索引或信息检索系统的第一个过滤器来说,是一种极其高效的表示方式。搜索索引只需要知道每个文档中每个单词的存在与否,帮助你稍后找到这些文档。

这种方法被证明对于帮助计算机“理解”一组单词作为一个单一的数学对象至关重要。如果你将标记限制为最重要的10,000个单词,你就可以将你的3,500句话的文档的数字表示压缩到10千字节,或者为你想象中的3,000本书的语料库压缩到大约30兆字节。对于如此适中大小的语料库,独热向量序列可能需要数百GB。

词袋表示文本的另一个优点是,它允许你在常数时间(O(1))内找到语料库中相似的文档。你无法比这更快了。BOW向量表示法使得网页规模的全文搜索索引如此迅速。在计算机科学和软件工程中,你总是寻找能够支持这种速度的数据结构。所有主要的全文搜索工具都使用BOW向量来快速找到你所寻找的内容。你可以在Elasticsearch、Solr、PostgreSQL甚至一些现代的网页搜索引擎中看到这种自然语言的数字表示,例如Qwant、Searx和Wolfram Alpha。

幸运的是,文档中的单词在给定文本中通常是稀疏使用的。对于大多数BOW应用,我们通常将文档保持短小,有时甚至只包含一个句子。因此,你的BOW向量更像是一个广泛而愉悦的钢琴和弦,而不是一次性按下所有钢琴键,它是多个“音符”(单词)的组合,能够一起发挥作用并包含意义。即使同一句话中的单词并非通常一起使用,它们的“不同调性”依然是机器学习管道能够利用的有用信息。

BOW向量是用来将文档的标记存储为单一的二进制向量,表示特定单词在特定句子中的存在或不存在。这种句子集合的向量表示可以“索引”,以指示哪些单词在文档中出现过。这个索引等同于你在许多教科书末尾找到的索引,不同之处在于,它不是记录单词出现在书的哪一页,而是记录它出现在句子(或相关的向量)中的位置。虽然教科书索引通常只包括与书籍主题相关的单词,但你将记录每个单词(至少现在是这样)。

2.5.3 为什么不是字符袋模型?

为什么我们使用词袋模型(BOW)而不是字符袋模型来表示自然语言文本?对于试图解密未知信息的密码学家来说,文本中字符的频率分析是一个不错的选择,但对于你母语中的自然语言文本,单词证明是一个更好的表示。原因在于,当你考虑我们使用这些BOW向量的目的时,答案变得清晰了。

如果你想想看,你有很多不同的方式来衡量事物的接近度。你大概能够很清楚地知道一个亲戚有多么亲近,或者你离某个咖啡馆的距离有多近,那里你可以与朋友见面。但是你知道如何衡量两段文本的相似度吗?在第4章,你将学习到编辑距离,它检查两个字符字符串的相似度,但这并不能真正捕捉你关心的本质。

你认为以下两句话在意义上有多接近?

  • I am now coming over to see you.
  • I am not coming over to see you.

你看出区别了吗?你更希望收到哪一封来自朋友的电子邮件?单词“now”和“not”有非常不同的意思,尽管它们在拼写上非常接近;这表明单个字符的不同可以改变整个句子的意思。

如果你只是计算不同的字符,你将得到一个距离值1,然后你可以通过最长句子的长度来确保你的距离值在0和1之间。你的字符差异或距离计算将是1除以32,结果是0.03125,大约是3%。然后,为了将距离转换为接近度,你只需要从1中减去它。你认为这两句话有0.96875,或者大约97%的相似度吗?它们的意义是相反的,所以我们需要一个比这更好的度量标准。

如果你比较的是单词而不是字符呢?在这种情况下,你会发现7个单词中有1个发生了变化,这比32个字符中有1个变化稍微好一点。现在,这两句话的接近度为6除以7,大约是85%。这稍微低一些,这正是我们想要的。对于自然语言,你不希望你的接近度或距离度量仅仅依赖于单个字符的差异计数。这就是为什么在处理自然语言文本时,你希望使用单词作为语义标记。

那这两句话呢?

  • She and I will come over to your place at 3:00.
  • At 3:00, she and I will stop by your apartment.

这两句话在意义上接近吗?它们的字符长度完全相同,而且使用了某些相同的单词,或者至少是同义词。但这些单词和字符并不是按相同的顺序排列的,所以我们需要确保我们的句子表示不会依赖于单词在句子中的精确位置。BOW向量通过为你在词汇表中看到的每个单词在向量中创建一个位置或槽位来解决这个问题。

稀疏表示

你可能会想,如果你处理一个庞大的语料库,你可能最终会在词汇表中得到成千上万甚至数百万个独特的标记。这就意味着,你需要在关于Liesel的20个标记的句子的向量表示中存储大量的零。使用字典会比使用向量节省更多内存;任何将单词映射到0/1值的映射都会比向量更高效,但你不能在字典上做数学运算。这就是为什么CountVectorizer使用稀疏的NumPy数组来保存单词频率向量中的单词计数。使用字典或稀疏数组作为向量,确保只有在字典中的某个词出现在特定文档中时,它才会存储1。

但如果你想查看单个向量以确保一切正常,pandas Series是最合适的选择。你将把它包装在一个pandas DataFrame中,这样你就可以将更多的句子添加到你的二进制向量语料库中。

这种将文档表示为二进制向量的表示方式非常强大。它曾是文档检索和搜索的主要方法。现代CPU都有硬件内存寻址指令,可以高效地对大量二进制向量进行哈希、索引和搜索。尽管这些指令是为其他目的(从RAM中检索数据的内存定位)而设计的,但它们同样能高效地执行文本的二进制向量操作。我们将在下一章继续探索BOW及其更强大的表亲TF-IDF。

2.6 情感分析

无论你在NLP管道中使用原始的单词标记、n-gram、词干还是词元,每个标记都包含一些信息。这些信息中的一个重要部分是单词的情感——单词所激发的整体情感或情绪。情感分析是NLP的一个常见应用,它衡量短语或文本块的情感内容;在许多公司中,这也是NLP工程师常常被要求做的事情。NLP可以自动化地去除繁琐的工作和花费,例如每天从客户发送到公司大量反馈信息的阅读任务。

公司希望了解用户对其产品的看法,因此他们通常会提供某种方式让用户提供反馈。像亚马逊或烂番茄上的星级评分是一种获取人们对购买产品的感受的定量数据的方法,但更直观的方法是使用自然语言评论。给用户一个空白的文本框,让他们填写有关产品的评论,可以生成更详细的反馈。

过去,你必须亲自阅读所有这些用户反馈。只有人类才能理解自然语言文本中的情感和情绪,对吧?然而,如果你需要阅读成千上万条评论,你就会意识到人类读者是多么容易疲惫且容易出错。人类在阅读反馈时特别差,尤其是在面对批评或负面反馈时,而客户通常也不擅长以能突破你自然的情感触发器和过滤器的方式传达反馈。

机器没有这些偏见和情感触发器,而人类并不是唯一能够处理自然语言文本并从中提取信息(甚至是意义)的方法。NLP管道可以快速且客观地处理大量的用户反馈,减少偏见的可能性,并输出一个与文本的正面、负面或其他情感质量相关的数值评分。

情感分析的另一个常见应用是在垃圾邮件和恶意信息过滤中。在这种情况下,你希望你的聊天机器人能测量它处理的消息的情感,以便做出适当的回应。更重要的是,你希望你的聊天机器人能测量它自己发出的语句的情感,帮助它遵循你母亲可能告诉你的永恒智慧:“如果你不能说些好话,就什么都不说。”因此,你需要让你的机器人测量你即将说的每句话的“友善度”,并利用这个来决定是否回应。

你会创建什么样的管道来衡量一段文本的情感并生成该情感的正面评分数字呢?假设你只想衡量文本的正面性或赞同度——衡量某人对他们写的产品或服务的喜爱程度。假设你的NLP管道和情感分析算法将输出一个介于–1和+1之间的单一浮动数值:+1表示情感非常正面,–1表示情感非常负面。你的NLP管道可以使用接近0的值,比如+0.1或–0.1,表示基本中立的陈述,比如“还好,有些好事也有些坏事。”

情感分析有两种方法:

  • 基于规则的算法(由人工设计)
  • 基于数据的机器学习模型

情感分析的第一种方法使用人工设计的规则,有时称为启发式方法,来衡量情感。情感分析的常见基于规则的方法是找到文本中的关键词,并将每个关键词映射到一个数值评分或权重,这个评分存储在字典或“映射”中(例如Python字典)。现在,你已经知道如何进行标记化,可以使用词干、词元或n-gram标记来替代单纯的单词。你的算法中的规则是对文本中每个在情感评分字典中找到的关键词进行加总。你当然需要在运行这个算法之前,手动编写包含关键词及其情感分数的字典。我们将在下一节中使用VADER算法(在scikit-learn中)演示如何实现这一点。

第二种方法是机器学习,它依赖于一组带有标签的陈述或文档,用于训练机器学习模型来创建这些规则。机器学习情感模型被训练来处理输入文本并输出你想衡量的情感数值,如正面性、“垃圾邮件性”或“恶意性”。对于机器学习方法,你需要大量带有正确情感分数的文本数据。Twitter数据流通常用于这种方法,因为像#awesome、#happy或#sarcasm这样的标签可以用来创建一个“自标记”数据集。假设你的公司有带有星级评分的产品评论,你可以将这些评分与评论内容关联。在这种情况下,你可以使用星级评分作为每个文本的正面性数值。我们将在完成VADER之后,向你展示如何处理这样一个数据集并训练一个基于标记的机器学习算法,称为朴素贝叶斯算法,用于衡量评论集中的情感正面性。

2.6.1 VADER:基于规则的情感分析器

乔治亚理工学院的Eric Gilbert和CJ Hutto研究员创建了第一个成功的基于规则的情感分析算法之一。他们将自己的算法命名为Valence Aware Dictionary and Sentiment Reasoner,简称VADER。许多NLP包实现了这种算法的一种形式;例如,NLTK包中就有VADER算法的实现,位于nltk.sentiment.vader模块。Hutto本人曾维护Python包vaderSentiment,即使在他于2020年停止维护后,该包仍然在一段时间内很受欢迎。你将直接使用vaderSentiment包。

要运行以下示例,你需要使用pip安装vaderSentiment包:

>>> from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
>>> sa = SentimentIntensityAnalyzer()
>>> sa.lexicon     #1
{ ...
':(': -1.9,    #2
':)': 2.0,
...
'pls': 0.3,     #3
'plz': 0.3,
...
'great': 3.1,
... }
>>> [(tok, score) for tok, score in sa.lexicon.items()
...   if " " in tok]    #4
[("( '}{' )", 1.6),
 ("can't stand", -2.0),
 ('fed up', -1.8),
 ('screwed up', -1.5)]
>>> sa.polarity_scores(text=\
...   "Python is very readable and it's great for NLP.")
{'compound': 0.6249, 'neg': 0.0, 'neu': 0.661,
'pos': 0.339}                                             #5
>>> sa.polarity_scores(text=\
...   "Python is not a bad choice for most applications.")
{'compound': 0.431, 'neg': 0.0, 'neu': 0.711,
'pos': 0.289}      #6

#1 SentimentIntensityAnalyzer.lexicon 包含了该字典的标记及其得分。 #2 对于VADER来说,标记化器必须能够很好地处理标点符号和表情符号(如emoji),因为这些表情符号设计用于传递大量情感。 #3 如果你在管道中使用了词干提取器(或词元化器),你也需要对VADER词典应用该词干提取器,将所有组合在一起的单词的得分合并为一个词干或词元的得分。 #4 在VADER定义的7,500个标记中,只有3个包含空格,其中只有2个是n-gram;另一个是“吻”的表情符号。 #5 VADER算法考虑了情感极性强度的三个独立得分(正面、负面和中性),然后将它们组合成一个复合的正面情感得分。 #6 请注意,VADER对否定处理得很好——例如,“great”比“not bad”具有稍微更积极的情感。VADER的内置标记化器忽略了任何不在其词典中的词,它也不考虑n-gram。

让我们看看这个基于规则的方法如何处理我们之前提到的示例陈述:

>>> corpus = ["Absolutely perfect! Love it! :-) :-) :-)",
...           "Horrible! Completely useless. :(",
...           "It was OK. Some good and some bad things."]
>>> for doc in corpus:
...     scores = sa.polarity_scores(doc)
...     print('{:+}: {}'.format(scores['compound'], doc))
+0.9428: Absolutely perfect! Love it! :-) :-) :-)
-0.8768: Horrible! Completely useless. :(
-0.1531: It was OK. Some good and some bad things.

这看起来非常符合我们的预期,唯一的缺点是VADER没有查看文档中的所有单词。VADER只“知道”它的算法中硬编码的7,500个词汇。如果你希望所有单词都能帮助增加情感得分呢?如果你不想为数千个单词自己编写词典或将一些自定义词汇添加到SentimentIntensityAnalyzer.lexicon字典中呢?基于规则的方法对于你不了解的语言来说可能是不可行的,因为你不知道该在词典中放什么得分!这就是机器学习情感分析器的作用所在。

2.6.2 朴素贝叶斯

朴素贝叶斯模型尝试在一组文档中找到预测目标(输出)变量的关键词。当你的目标变量是你试图预测的情感时,该模型将找到能够预测该情感的单词。朴素贝叶斯模型的优点在于,内部系数将像VADER一样将单词或标记映射到分数。只是这一次,你不必局限于个别人为这些分数设定的标准。机器将为任何问题找到“最佳”分数。

对于任何机器学习算法,你首先需要找到一个数据集。你需要一些带有正向情感内容标签(情感值)的文本文档。Hutto和他的合作者在构建VADER时为我们编制了四个不同的情感数据集。你将从nlpia2包中加载它们:

>>> movies = pd.read_csv('https://proai.org/movie-reviews.csv.gz',
...     index_col=0)
>>> movies.head().round(2)
    sentiment                                               text
id
1        2.27  The Rock is destined to be the 21st Century's ...
2        3.53  The gorgeously elaborate continuation of ''The...
3       -0.60                     Effective but too tepid biopic
4        1.47  If you sometimes like to go to the movies to h...
5        1.73  Emerges as something rare, an issue movie that...

>>> movies.describe().round(2)
       sentiment
count   10605.00
mean        0.00    #1
std         1.92
min        -3.88      #2
...
max         3.94    #3
#1 情感得分(电影评分)已经被归一化(平均值为零)。
#2 看起来评分范围从-4开始,表示最差的电影。
#3 看起来+4是最佳电影的最高评分。

看起来电影评论已经归一化——通过减去均值来规范化,使得新均值为零,这样就不会偏向一方。而且,电影评分的范围似乎是从-4到+4。

现在,你可以对所有这些电影评论文本进行标记化,为每一篇创建一个BOW(词袋)。如果将它们都放入一个pandas DataFrame中,它们会更容易操作:

>>> import pandas as pd
>>> pd.options.display.width = 75    #1
>>> from nltk.tokenize import casual_tokenize    #2
>>> bows = []
>>> from collections import Counter    #3
>>> for text in movies.text:
...     bows.append(Counter(casual_tokenize(text)))
>>> df_movies = pd.DataFrame.from_records(bows)    #4
>>> df_movies = df_movies.fillna(0).astype(int)    #5
>>> df_movies.shape    #6
(10605, 20756)

>>> df_movies.head()
   !  "  #  $  %  &  ' ...  zone  zoning  zzzzzzzzz  ½  élan  –  ’
0  0  0  0  0  0  0  4 ...     0       0          0  0     0  0  0
1  0  0  0  0  0  0  4 ...     0       0          0  0     0  0  0
2  0  0  0  0  0  0  0 ...     0       0          0  0     0  0  0
3  0  0  0  0  0  0  0 ...     0       0          0  0     0  0  0
4  0  0  0  0  0  0  0 ...     0       0          0  0     0  0  0

>>> df_movies.head()[list(bows[0].keys())]
   The  Rock  is  destined  to  be ...  Van  Damme  or  Steven  Segal  .
0    1     1   1         1   2   1 ...    1      1   1       1      1  1
1    2     0   1         0   0   0 ...    0      0   0       0      0  4
2    0     0   0         0   0   0 ...    0      0   0       0      0  0
3    0     0   1         0   4   0 ...    0      0   0       0      0  1
4    0     0   0         0   0   0 ...    0      0   0       0      0  1

#1 设置以便在控制台中显示较宽的DataFrame,使它们更美观。 #2 NLTK的casual_tokenize可以比TreebankWordTokenizer更好地处理表情符号、特殊标点和俚语。 #3 Counter接受一个对象列表(或可迭代对象),并统计它们的数量,返回一个字典,其中键是对象(标记),值是计数。 #4 from_records()方法将字典对象序列转换为DataFrame。字典的键变成列,缺失键的值被设置为NaN。 #5 NumPy和Pandas只能在浮动数据类型中表示NaN,因此需要用零填充NaN,然后转换为整数类型。 #6 如果不进行维度减少或特征选择,BOW表格可能非常大。

当你没有使用大小写规范化、停止词过滤、词干提取或词元化时,你的词汇量可能会非常庞大,因为你在跟踪每个单词的拼写或大小写的细微差异。尝试在你的管道中加入一些维度减少步骤,看看它们如何影响管道的准确性以及存储所有这些BOW所需的内存量。现在,我们有了所有数据来训练情感预测器并计算训练集上的损失:

>>> from sklearn.naive_bayes import MultinomialNB
>>> nb = MultinomialNB()
>>> nb = nb.fit(df_movies, movies.sentiment > 0)    #1
>>> movies['pred_senti'] = (
...   nb.predict_proba(df_movies))[:, 1] * 8 - 4      #2
>>> movies['error'] = movies.pred_senti - movies.sentiment
>>> mae = movies['error'].abs().mean().round(1)    #3
>>> mae
1.9

#1 朴素贝叶斯模型是分类器,因此你需要将输出变量(情感浮动值)转换为离散标签(整数、字符串或布尔值)。 #2 将离散的分类变量转换回-4到+4之间的实际值,这样可以与“真实情感”进行比较。 #3 平均绝对误差(MAE),即预测误差的平均绝对值。

要创建二元分类标签,你可以利用电影评分(情感标签)为正(大于零)表示评论的情感是积极的这一事实:

>>> movies['senti_ispos'] = (movies['sentiment'] > 0).astype(int)
>>> movies['pred_ispos'] = (movies['pred_senti'] > 0).astype(int)
>>> columns = [c for c in movies.columns if 'senti' in c or 'pred' in c]
>>> movies[columns].head(8)
    sentiment  pred_senti  senti_ispos  pred_ispos
id
1    2.266667    2.511515            1           1
2    3.533333    3.999904            1           1
3   -0.600000   -3.655976            0           0
4    1.466667    1.940954            1           1
5    1.733333    3.910373            1           1
6    2.533333    3.995188            1           1
7    2.466667    3.960466            1           1
8    1.266667   -1.918701            1           0
>>> (movies.pred_ispos ==
...   movies.senti_ispos).sum() / len(movies)
0.9344648750589345                               #1
#1 你正确预测“好评”的次数为93%。

这是构建情感分析器的一个很好的开始,只需要几行代码(和大量数据)。你不需要去猜测与7,500个单词列表相关的情感,并将它们硬编码到像VADER这样的算法中。相反,你告诉机器整个文本片段的情感评分,然后机器完成所有工作,找出与这些文本中每个单词相关的情感。这就是机器学习和NLP的强大之处!

你认为这个模型能否很好地推广到完全不同的文本示例集,比如产品评论?人们是否使用相同的词语来描述他们在电影和产品评论中喜欢的东西,例如电子产品和家用商品?可能不会——但通过在不同领域的具有挑战性的文本中测试你的语言模型,你可以获得更多示例和数据集的想法,用于你的训练集和测试集。

首先,你需要加载产品评论。查看加载的文件内容,确保你理解数据集的内容:

>>> products = pd.read_csv('https://proai.org/product-reviews.csv.gz')
>>> products.columns
Index(['id', 'sentiment', 'text'], dtype='object')
>>> products.head()
    id  sentiment                                               text
0  1_1      -0.90  troubleshooting ad-2500 and ad-2600 no picture...
1  1_2      -0.15  repost from january 13, 2004 with a better fit...
2  1_3      -0.20  does your apex dvd player only play dvd audio ...
3  1_4      -0.10  or does it play audio and video but scrolling ...
4  1_5      -0.50  before you try to return the player or waste h...

接下来,你需要加载产品评论:

>>> bows = []
>>> for text in products['text']:
...     bows.append(Counter(casual_tokenize(text)))
>>> df_products = pd.DataFrame.from_records(bows)
>>> df_products = df_products.fillna(0).astype(int)
>>> df_products.shape                          #1
#1 产品评论的BOW具有不同于电影评论的词汇。

当你将一个DataFrame的BOW向量与另一个合并时会发生什么?

>>> df_all_bows = pd.concat([df_movies, df_products])
>>> df_all_bows.columns                         #1
Index(['!', '"',
       ...
       'zoomed', 'zooming', 'zooms', 'zx', 'zzzzzzzzz', ...],
      dtype='object', length=23302)
#1 合并后的BOW DataFrame包含电影评论和产品评论的标记。

合并后的BOW DataFrame包含电影评论中没有的产品评论标记。现在,你的词汇中有23,302个独特标记,电影评论只有20,756个独特标记,因此你现在有2,546个新标记是关于产品的。

要使用朴素贝叶斯模型对产品评论进行预测,您需要确保新的产品BOW(词袋)具有与最初训练模型所用的电影评论完全相同的列(标记),并且顺序一致。毕竟,模型对这些新的标记没有经验,因此不知道哪些权重适合它们,您也不希望模型将权重混淆并应用到错误的标记上:

>>> vocab = list(df_movies.columns)     #1
>>> df_products = df_all_bows.iloc[len(movies):]    #2
>>> df_products = df_products[vocab]    #3
>>> df_products = df_products.fillna(0).astype(int)
>>> df_products.shape
(3546, 20756)
>>> df_movies.shape    #4
(10605, 20756)
#1 电影评论的词汇
#2 移除电影评论
#3 从词汇中移除所有新的产品评论标记
#4 电影BOW的词汇(列)与产品评论一致。

现在,您两个向量集(DataFrame)都具有20,756列或唯一的标记。接下来,您需要将产品评论的标签转换为与原始电影评论的二元分类标签相同,以便训练朴素贝叶斯模型:

>>> products['senti_ispos'] = (products['sentiment'] > 0).astype(int)
>>> products['pred_ispos'] = nb.predict(df_products).astype(int)
>>> correct = (products['pred_ispos']
...         == products['senti_ispos'])    #1
>>> correct.sum() / len(products)
0.557...
#1 正确的预测是当预测的情感与数据集中的标签相同。

因此,您的朴素贝叶斯模型在预测产品评论的情感(积极或消极)时表现不佳,准确度接近抛硬币的结果,略高于50%。这种表现不佳的原因之一是,从casual_tokenize产品文本中提取的词汇包含了2,546个不在电影评论中的标记。这约占您原始电影评论标记化的10%,这意味着所有这些词在您的朴素贝叶斯模型中将没有任何权重或分数。此外,朴素贝叶斯模型在处理否定时的效果不如VADER。您需要在标记化器中加入n-grams,以将否定词(如not或never)与它们可能用于修饰的积极词连接起来。

我们留给您继续改进此机器学习模型的任务。在每个步骤中,您可以与VADER进行比较,看看机器学习是否比为NLP硬编码算法的方式更有效。

自测题

  1. WordPiece 标记器(如 BPE)相比于词标记器有哪些优势?

    • WordPiece 标记器(如 BPE)能够通过对字符的组合来处理新的单词或未见过的单词,这使得它能够处理多种语言的变形形式和新词汇,而传统的词标记器通常依赖于预定义的词汇表,无法处理这些情况。
  2. 词形还原(Lemmatizer)和词干提取(Stemmer)有什么区别?在大多数情况下,哪一个更好?

    • 词形还原通过考虑单词的语法和上下文来将单词转换为其标准词形,而词干提取则是通过去掉单词的词缀来提取出“词干”,但没有考虑上下文。通常,词形还原比词干提取更好,因为它能保留更多的语义信息。
  3. 词形还原如何增加搜索引擎(如 You.com)返回与你所寻找的内容相关的搜索结果的可能性?

    • 词形还原能够将词汇的不同形式(如“running”和“ran”)归一化为相同的基本词形,从而使搜索引擎能够识别出相关内容,即使这些内容使用了不同的词形。
  4. 大小写折叠、词形还原或停用词移除会提高典型 NLP 流水线的准确性吗?对于像检测误导性新闻标题(点击诱饵)这样的任务呢?

    • 对于一般的 NLP 流水线,停用词移除和词形还原可能会提高搜索引擎的召回率,但在某些任务中,尤其是需要高精度的任务(如检测点击诱饵),可能需要保留更多的上下文信息,因此简单的词形还原或停用词移除可能不适用。
  5. 在你的标记计数中是否有统计数据,可以帮助你决定在你的 n-gram NLP 流水线中使用哪个 n 值?

    • 是的,n-gram 模型中的 n 值通常取决于语料库中的词频分布。例如,如果某些词组(如二元组或三元组)频繁出现,选择适当的 n 值可以提高模型的准确性。
  6. 是否有一个网站可以下载已发布的大多数单词和 n-grams 的标记频率?

    • 是的,一些网站如 Google Books Ngram Viewer 和其他公共语料库提供了各种语言的单词和 n-gram 的频率数据。

总结

  • 你已经实现了标记化并为你的应用配置了一个标记器。
  • n-gram 标记化有助于保留文档中的一些“词序”信息。
  • 规范化和词干提取将单词合并成有助于提高搜索引擎“召回率”的组,但可能会减少精度。
  • 词形还原和自定义标记器(如 casual_tokenize())可以提高精度并减少信息损失。
  • 停用词可能包含有用的信息,丢弃它们并不总是有益的。

4o mini