Python-NLTK-2-0-文本处理秘籍-全-

Python NLTK 2.0 文本处理秘籍(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

自然语言处理(NLP)无处不在——在搜索引擎、拼写检查器、移动电话、计算机游戏,甚至是在您的洗衣机中。Python 的自然语言工具包(NLTK)套件迅速成为自然语言处理中最有效的工具之一。您希望采用不亚于最佳技术的自然语言处理技术——这本书就是您的答案。

Python Text Processing with NLTK 2.0 Cookbook 是您的实用指南,它将逐步引导您了解所有自然语言处理技术。它将揭示使用 NLTK 全面套件进行文本分析和文本挖掘的高级功能。

本书省略了序言部分,让您直接通过实践动手的方式深入探索文本处理科学。

从学习文本分词开始。了解 WordNet 的概述及其使用方法。学习词干提取和词形还原的基本和高级功能。发现用更简单、更常见的(即:更常搜索的)变体替换单词的各种方法。创建您自己的语料库,并学习为存储在 MongoDB 中的数据创建自定义语料库读取器。使用和操作词性标注器。转换和归一化解析的短语,以产生不改变其含义的规范形式。深入研究特征提取和文本分类。学习如何轻松处理大量数据,而不会损失效率或速度。

本书将教会您所有这些以及更多,以动手实践、边做边学的方式。通过这本实用的伴侣书籍,让自己成为 NLTK 在自然语言处理(NLP)方面的专家。

本书涵盖内容

第一章,文本分词和 WordNet 基础知识,涵盖了文本分词的基本知识和如何使用 WordNet。

第二章,替换和纠正单词,讨论了各种单词替换和纠正技术。这些配方涵盖了语言压缩、拼写纠正和文本归一化的范围。

第三章,创建自定义语料库,涵盖了如何使用语料库读取器创建自定义语料库。同时,它还解释了如何使用 NLTK 附带的存在语料库数据。

第四章,词性标注,解释了将句子(以单词列表的形式)转换为元组列表的过程。它还解释了标注器,这些标注器是可以训练的。

第五章,提取短语,解释了从词性标注句子中提取短语的流程。它使用宾州树库(Penn Treebank)作为基本训练和测试短语提取的语料库,并使用 CoNLL 2000 语料库,因为它具有更简单、更灵活的格式,支持多种短语类型。

第六章, 转换块和树,展示了如何在块和树上执行各种转换。这些菜谱中详细说明的函数修改数据,而不是从数据中学习。

第七章, 文本分类,描述了一种对文档或文本片段进行分类的方法,通过检查文本中的单词使用情况,分类器决定应该将其分配给哪个类别标签。

第八章, 分布式处理和大型数据集处理,讨论了如何使用 execnet 在 NLTK 中进行并行和分布式处理。它还解释了如何使用 Redis 数据结构服务器/数据库来存储频率分布。

第九章, 解析特定数据,涵盖了解析特定类型的数据,主要关注日期、时间和 HTML。

附录, Penn Treebank 词性标注,列出了在 NLTK 附带treebank语料库中出现的所有词性标注。

你需要这本书什么

在本书的过程中,你需要以下软件工具来尝试各种代码示例:

  • NLTK

  • MongoDB

  • PyMongo

  • Redis

  • redis-py

  • execnet

  • Enchant

  • PyEnchant

  • PyYAML

  • dateutil

  • chardet

  • BeautifulSoup

  • lxml

  • SimpleParse

  • mxBase

  • lockfile

这本书的适用对象

这本书是为想要快速掌握使用 NLTK 进行自然语言处理的 Python 程序员而写的。需要熟悉基本的文本处理概念。NLTK 经验丰富的程序员会发现它很有用。语言学专业的学生会发现它非常有价值。

习惯用法

在这本书中,你会找到许多不同风格的文本,以区分不同类型的信息。以下是一些这些风格的示例,以及它们含义的解释。

文本中的代码词如下所示:"现在我们想要将para分割成句子。首先我们需要导入句子分词函数,然后我们可以用段落作为参数调用它。"

一段代码如下设置:

 >>> para = "Hello World. It's good to see you. Thanks for buying this book."
 >>> from nltk.tokenize import sent_tokenize
 >>> sent_tokenize(para)

新术语重要词汇以粗体显示。

注意

警告或重要注意事项以这种方式出现在框中。

提示

小贴士和技巧看起来像这样。

读者反馈

我们始终欢迎读者的反馈。让我们知道你对这本书的看法——你喜欢什么或可能不喜欢什么。读者反馈对我们开发你真正能从中获得最大价值的标题非常重要。

要向我们发送一般反馈,只需发送一封电子邮件到<feedback@packtpub.com>,并在邮件主题中提及书名。

如果您需要一本书并且希望我们出版,请通过 www.packtpub.com 上的 建议书名 表格或发送电子邮件至 <suggest@packtpub.com> 给我们留言。

如果您在某个领域有专业知识,并且对撰写或为书籍做出贡献感兴趣,请参阅我们在 www.packtpub.com/authors 上的作者指南。

客户支持

现在您已经是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

注意

下载本书的示例代码

您可以从您在 www.PacktPub.com 的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.PacktPub.com/support 并注册,以便将文件直接通过电子邮件发送给您。

勘误

尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。这样做可以避免其他读者感到沮丧,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/support,选择您的书籍,点击 勘误提交 表格链接,并输入您的勘误详情来报告。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分下的现有勘误列表中。任何现有勘误都可以通过从 www.packtpub.com/support 选择您的标题来查看。

盗版

在互联网上,版权材料的盗版是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过 <copyright@packtpub.com> 与我们联系,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面提供的帮助。

问题

如果你在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。

第一章. 分词文本和 WordNet 基础知识

本章我们将介绍:

  • 将文本分词成句子

  • 将句子分词成单词

  • 使用正则表达式进行句子分词

  • 在分词后的句子中过滤停用词

  • 在 WordNet 中查找一个单词的 synset

  • 在 WordNet 中查找词元和同义词

  • 计算 WordNet synset 相似度

  • 发现词组

简介

NLTK自然语言工具包,是一个用于自然语言处理和文本分析的综合性 Python 库。最初是为教学设计的,由于其有用性和广泛的应用范围,它已被工业界用于研究和开发。

本章将介绍分词文本和使用 WordNet 的基础知识。分词是将一段文本分解成许多片段的方法,是后续章节中食谱的必要第一步。

WordNet是为自然语言处理系统程序化访问而设计的词典。NLTK 包括一个 WordNet 语料库读取器,我们将使用它来访问和探索 WordNet。我们将在后续章节中再次使用 WordNet,因此首先熟悉其基础知识很重要。

将文本分词成句子

分词是将字符串分割成一系列片段或标记的过程。我们将从将一个段落分割成句子列表开始。

准备工作

NLTK 的安装说明可在www.nltk.org/download找到,截至本文写作的最新版本是 2.0b9。NLTK 需要 Python 2.4 或更高版本,但不兼容 Python 3.0推荐的 Python 版本是 2.6

一旦安装了 NLTK,你还需要按照www.nltk.org/data上的说明安装数据。我们建议安装所有内容,因为我们将在后续章节中使用多个语料库和 pickle 对象。数据安装在数据目录中,在 Mac 和 Linux/Unix 系统中通常是/usr/share/nltk_data,在 Windows 系统中是C:\nltk_data。请确保tokenizers/punkt.zip在数据目录中,并且已经解压,以便在tokenizers/punkt/english.pickle中有一个文件。

最后,为了运行代码示例,你需要启动一个 Python 控制台。有关如何操作的说明可在www.nltk.org/getting-started找到。对于 Mac 和 Linux/Unix 用户,你可以打开一个终端并输入python

如何实现...

一旦安装了 NLTK 并且你有一个 Python 控制台正在运行,我们可以从创建一段文本开始:

>>> para = "Hello World. It's good to see you. Thanks for buying this book."

现在我们想将para分割成句子。首先我们需要导入句子分词函数,然后我们可以用段落作为参数调用它。

>>> from nltk.tokenize import sent_tokenize
>>> sent_tokenize(para)
['Hello World.', "It's good to see you.", 'Thanks for buying this book.']

因此,我们现在有一系列句子可以用于进一步处理。

工作原理...

sent_tokenize 使用来自 nltk.tokenize.punkt 模块的 PunktSentenceTokenizer 实例。这个实例已经在许多欧洲语言上进行了训练,并且效果良好。因此,它知道哪些标点符号和字符标志着句子的结束和新一行的开始。

还有更多...

sent_tokenize() 中使用的实例实际上是从 pickle 文件中按需加载的。所以如果你要分词大量句子,一次性加载 PunktSentenceTokenizer 并调用其 tokenize() 方法会更有效率。

>>> import nltk.data
>>> tokenizer = nltk.data.load('tokenizers/punkt/english.pickle')
>>> tokenizer.tokenize(para)
['Hello World.', "It's good to see you.", 'Thanks for buying this book.']

其他语言

如果你想要对非英语语言的句子进行分词,你可以加载 tokenizers/punkt 中的其他 pickle 文件,并像使用英语句子分词器一样使用它。以下是一个西班牙语的例子:

>>> spanish_tokenizer = nltk.data.load('tokenizers/punkt/spanish.pickle')
>>> spanish_tokenizer.tokenize('Hola amigo. Estoy bien.')

参见

在下一个菜谱中,我们将学习如何将句子分割成单个单词。之后,我们将介绍如何使用正则表达式进行文本分词。

将句子分词成单词

在这个菜谱中,我们将一个句子分割成单个单词。从字符串中创建单词列表的简单任务是所有文本处理的基本部分。

如何做到...

基本单词分词非常简单:使用 word_tokenize() 函数:

>>> from nltk.tokenize import word_tokenize
>>> word_tokenize('Hello World.')
['Hello', 'World', '.']

它是如何工作的...

word_tokenize() 是一个包装函数,它在一个 TreebankWordTokenizer 实例上调用 tokenize()。它等同于以下代码:

>>> from nltk.tokenize import TreebankWordTokenizer
>>> tokenizer = TreebankWordTokenizer()
>>> tokenizer.tokenize('Hello World.')
['Hello', 'World', '.']

它通过使用空格和标点符号来分隔单词。正如你所见,它不会丢弃标点符号,这让你可以决定如何处理它们。

还有更多...

忽略显然命名的 WhitespaceTokenizerSpaceTokenizer,还有两个其他值得关注的单词分词器:PunktWordTokenizerWordPunctTokenizer。它们与 TreebankWordTokenizer 的不同之处在于它们处理标点和缩写的方式,但它们都继承自 TokenizerI。继承关系如下:

还有更多...

缩写

TreebankWordTokenizer 使用在宾州树库语料库中找到的约定,我们将在第四章(ch04.html)词性标注和第五章(ch05.html)提取词组中用于训练。这些约定之一是分隔缩写。例如:

>>> word_tokenize("can't")
['ca', "n't"]

如果你觉得这个约定不可接受,那么请继续阅读以了解替代方案,并查看下一个菜谱,了解如何使用正则表达式进行分词。

PunktWordTokenizer

另一个可选的单词分词器是 PunktWordTokenizer。它会在标点符号处分割,但会将标点符号与单词一起保留,而不是创建单独的标记。

>>> from nltk.tokenize import PunktWordTokenizer
>>> tokenizer = PunktWordTokenizer()
>>> tokenizer.tokenize("Can't is a contraction.")
['Can', "'t", 'is', 'a', 'contraction.']

WordPunctTokenizer

另一个可选的单词分词器是 WordPunctTokenizer。它将所有标点符号分割成单独的标记。

>>> from nltk.tokenize import WordPunctTokenizer
>>> tokenizer = WordPunctTokenizer()
>>> tokenizer.tokenize("Can't is a contraction.")
['Can', "'", 't', 'is', 'a', 'contraction', '.']

参见

为了更好地控制单词分词,你可能需要阅读下一个菜谱,了解如何使用正则表达式和 RegexpTokenizer 进行分词。

使用正则表达式进行句子分词

如果你想要完全控制如何标记化文本,可以使用正则表达式。由于正则表达式可能会很快变得复杂,我们只建议在之前的配方中提到的单词标记化器不可接受时使用它们。

准备工作

首先,你需要决定你想要如何标记化一段文本,因为这将决定你如何构建你的正则表达式。选择包括:

  • 匹配标记

  • 匹配分隔符,或间隔

我们将从第一个示例开始,匹配字母数字标记和单引号,这样我们就不需要分割缩略语。

如何操作...

我们将创建一个RegexpTokenizer的实例,给它一个用于匹配标记的正则表达式字符串。

>>> from nltk.tokenize import RegexpTokenizer
>>> tokenizer = RegexpTokenizer("[\w']+")
>>> tokenizer.tokenize("Can't is a contraction.")
["Can't", 'is', 'a', 'contraction']

如果你不想实例化类,你也可以使用一个简单的辅助函数。

>>> from nltk.tokenize import regexp_tokenize
>>> regexp_tokenize("Can't is a contraction.", "[\w']+")
["Can't", 'is', 'a', 'contraction']

现在我们终于有一种可以处理缩略语作为整个单词的方法,而不是将它们分割成标记。

工作原理...

RegexpTokenizer通过编译你的模式,然后在你的文本上调用re.findall()来工作。你可以使用re模块自己完成所有这些操作,但RegexpTokenizer实现了TokenizerI接口,就像之前配方中的所有单词标记化器一样。这意味着它可以被 NLTK 包的其他部分使用,例如语料库读取器,我们将在第三章创建自定义语料库中详细讨论。许多语料库读取器需要一个方法来标记化他们正在读取的文本,并且可以接受可选的关键字参数来指定一个TokenizerI子类的实例。这样,如果你觉得默认标记化器不合适,你可以提供自己的标记化器实例。

更多...

RegexpTokenizer也可以通过匹配间隔来工作,而不是匹配标记。它不会使用re.findall(),而是使用re.split()。这就是nltk.tokenize中的BlanklineTokenizer是如何实现的。

简单空白标记化器

这里是一个使用RegexpTokenizer在空白处进行标记化的简单示例:

>>> tokenizer = RegexpTokenizer('\s+', gaps=True)
>>> tokenizer.tokenize("Can't is a contraction.")
 ["Can't", 'is', 'a', 'contraction.']

注意,标点符号仍然保留在标记中。

参见

对于更简单的单词标记化,请参阅之前的配方。

在标记化句子中过滤停用词

停用词是通常不贡献于句子意义的常见单词,至少对于信息检索和自然语言处理的目的来说是这样。大多数搜索引擎都会从搜索查询和文档中过滤掉停用词,以节省索引空间。

准备工作

NLTK 附带了一个包含许多语言单词列表的停用词语料库。请确保解压缩数据文件,以便 NLTK 可以在nltk_data/corpora/stopwords/中找到这些单词列表。

如何操作...

我们将创建一个包含所有英语停用词的集合,然后使用它来过滤句子中的停用词。

>>> from nltk.corpus import stopwords
>>> english_stops = set(stopwords.words('english'))
>>> words = ["Can't", 'is', 'a', 'contraction']
>>> [word for word in words if word not in english_stops]
["Can't", 'contraction']

工作原理...

停用词语料库是 nltk.corpus.reader.WordListCorpusReader 的一个实例。因此,它有一个 words() 方法,可以接受单个参数作为文件 ID,在这种情况下是 'english',指的是包含英语停用词列表的文件。您也可以不带参数调用 stopwords.words(),以获取所有可用的语言的停用词列表。

还有更多...

您可以使用 stopwords.words('english') 或通过检查 nltk_data/corpora/stopwords/english 中的单词列表文件来查看所有英语停用词。还有许多其他语言的停用词列表。您可以使用 fileids() 方法查看完整的语言列表:

>>> stopwords.fileids()
['danish', 'dutch', 'english', 'finnish', 'french', 'german', 'hungarian', 'italian', 'norwegian', 'portuguese', 'russian', 'spanish', 'swedish', 'turkish']

这些 fileids 中的任何一个都可以用作 words() 方法的参数,以获取该语言的停用词列表。

参见

如果您想创建自己的停用词语料库,请参阅 第三章 中的 Creating a word list corpus 菜谱,Creating Custom Corpora,了解如何使用 WordListCorpusReader。我们还将在此章后面的 Discovering word collocations 菜谱中使用停用词。

在 WordNet 中查找单词的同义词集

WordNet 是英语的词汇数据库。换句话说,它是一个专门为自然语言处理设计的字典。

NLTK 提供了一个简单的接口来查找 WordNet 中的单词。您得到的是 synset 实例的列表,这些实例是表达相同概念的同类词的分组。许多单词只有一个同义词集,但有些有几个。我们现在将探索一个同义词集,在下一道菜谱中,我们将更详细地查看几个同义词集。

准备工作

确保您已将 wordnet 语料库解压缩到 nltk_data/corpora/wordnet。这将允许 WordNetCorpusReader 访问它。

如何操作...

现在我们将查找 cookbooksynset,并探索同义词集的一些属性和方法。

>>> from nltk.corpus import wordnet
>>> syn = wordnet.synsets('cookbook')[0]
>>> syn.name
'cookbook.n.01'
>>> syn.definition
'a book of recipes and cooking directions'

它是如何工作的...

您可以使用 wordnet.synsets(word) 在 WordNet 中查找任何单词,以获取同义词集列表。如果找不到单词,列表可能为空。列表也可能包含很多元素,因为一些单词可能有多种可能的含义,因此有多个同义词集。

还有更多...

列表中的每个同义词集都有一些属性,您可以使用这些属性来了解更多关于它的信息。name 属性将为您提供同义词集的唯一名称,您可以使用它直接获取同义词集。

>>> wordnet.synset('cookbook.n.01')
Synset('cookbook.n.01')

definition 属性应该是自解释的。一些同义词集(synsets)也具有 examples 属性,其中包含使用该词的短语列表。

>>> wordnet.synsets('cooking')[0].examples
['cooking can be a great art', 'people are needed who have experience in cookery', 'he left the preparation of meals to his wife']

上位词

同义词集以某种继承树的形式组织。更抽象的术语称为 上位词,更具体的术语称为 下位词。这棵树可以追溯到根上位词。

上义词提供了一种根据词之间的相似性对词进行分类和分组的方法。上义词树中两个词之间的距离计算的相似度菜谱详细说明了用于计算相似度的函数。

>>> syn.hypernyms()
[Synset('reference_book.n.01')]
>>> syn.hypernyms()[0].hyponyms()
[Synset('encyclopedia.n.01'), Synset('directory.n.01'), Synset('source_book.n.01'), Synset('handbook.n.01'), Synset('instruction_book.n.01'), Synset('cookbook.n.01'), Synset('annual.n.02'), Synset('atlas.n.02'), Synset('wordbook.n.01')]
>>> syn.root_hypernyms()
[Synset('entity.n.01')]

如您所见,参考书食谱上义词,但食谱只是参考书众多下义词中的一个。所有这些类型的书籍都有相同的根上义词,实体,这是英语中最抽象的术语之一。您可以使用hypernym_paths()方法从实体追踪到食谱的整个路径。

>>> syn.hypernym_paths()
[[Synset('entity.n.01'), Synset('physical_entity.n.01'), Synset('object.n.01'), Synset('whole.n.02'), Synset('artifact.n.01'), Synset('creation.n.02'), Synset('product.n.02'), Synset('work.n.02'), Synset('publication.n.01'), Synset('book.n.01'), Synset('reference_book.n.01'), Synset('cookbook.n.01')]]

此方法返回一个列表的列表,其中每个列表从根上义词开始,以原始sense结束。大多数情况下,您只会得到一个嵌套的sense列表。

词性(POS)

您还可以查找简化的词性标签。

>>> syn.pos
'n'

WordNet 中有四种常见的词性。

词性 标签
名词 n
形容词 a
副词 r
动词 v

这些 POS 标签可用于查找一个词的特定sense。例如,单词great可以用作名词或形容词。在 WordNet 中,great有一个名词sense和六个形容词sense

>>> len(wordnet.synsets('great'))
7
>>> len(wordnet.synsets('great', pos='n'))
1
>>> len(wordnet.synsets('great', pos='a'))
6

这些 POS 标签将在第四章的使用 WordNet 进行词性标注菜谱中更多地进行参考。

参见

在接下来的两个菜谱中,我们将探讨词元和如何计算sense相似度。在第二章中,替换和纠正单词,我们将使用 WordNet 进行词元化、同义词替换,然后探讨反义词的使用。

在 WordNet 中查找词元和同义词

在上一个菜谱的基础上,我们还可以在 WordNet 中查找词元,以找到一个词的同义词。在语言学中,词元是一个词的规范形式或形态形式。

如何做...

在以下代码块中,我们将通过使用lemmas属性找到cookbook sense的两个词元:

>>> from nltk.corpus import wordnet
>>> syn = wordnet.synsets('cookbook')[0]
>>> lemmas = syn.lemmas
>>> len(lemmas)
2
>>> lemmas[0].name
'cookbook'
>>> lemmas[1].name
'cookery_book'
>>> lemmas[0].synset == lemmas[1].synset
True

它是如何工作的...

如您所见,cookery_bookcookbook是同一sense中的两个不同的词元。事实上,一个词元只能属于一个sense。这样,一个sense代表了一组具有相同意义的词元,而一个词元代表了一个独特的单词形式。

还有更多...

由于一个sense中的词元都具有相同的意义,因此它们可以被视为同义词。所以如果您想获取一个sense的所有同义词,您可以这样做:

>>> [lemma.name for lemma in syn.lemmas]
['cookbook', 'cookery_book']

所有可能的同义词

如前所述,许多词有多个sense,因为这个词可以根据上下文有不同的含义。但假设您不关心上下文,只想为一个词找到所有可能的同义词。

>>> synonyms = []
>>> for syn in wordnet.synsets('book'):
...     for lemma in syn.lemmas:
...         synonyms.append(lemma.name)
>>> len(synonyms)
38

如您所见,似乎有 38 个可能的同义词用于单词book。但实际上,有些是动词形式,许多只是book的不同用法。相反,如果我们取同义词集,那么独特的单词就少多了。

>>> len(set(synonyms))
25

反义词

一些词元也有 反义词。例如,单词 good 有 27 个 synset,其中 5 个有带反义词的 lemmas

>>> gn2 = wordnet.synset('good.n.02')
>>> gn2.definition
'moral excellence or admirableness'
>>> evil = gn2.lemmas[0].antonyms()[0]
>>> evil.name
'evil'
>>> evil.synset.definition
'the quality of being morally wrong in principle or practice'
>>> ga1 = wordnet.synset('good.a.01')
>>> ga1.definition
'having desirable or positive qualities especially those suitable for a thing specified'
>>> bad = ga1.lemmas[0].antonyms()[0]
>>> bad.name
'bad'
>>> bad.synset.definition
'having undesirable or negative qualities'

antonyms() 方法返回一个 lemmas 列表。在这里的第一个例子中,我们看到 good 作为名词的第二个 synset 被定义为 道德上的优点,其第一个反义词是 evil,定义为 道德上的错误。在第二个例子中,当 good 被用作形容词来描述积极的品质时,第一个反义词是 bad,它描述的是消极的品质。

参见

在下一个菜谱中,我们将学习如何计算 synset 相似度。然后在 第二章,替换和修正单词 中,我们将重新审视词元化、同义词替换和反义词替换。

计算 WordNet synset 相似度

Synsets 是按 hypernym 树组织起来的。这棵树可以用来推理它包含的 synset 之间的相似度。两个 synset 越接近树中的位置,它们就越相似。

如何做...

如果你查看 reference book(它是 cookbook 的超类)的所有下位词,你会看到其中之一是 instruction_book。这些看起来直观上与 cookbook 非常相似,所以让我们看看 WordNet 相似度对此有什么看法。

>>> from nltk.corpus import wordnet
>>> cb = wordnet.synset('cookbook.n.01')
>>> ib = wordnet.synset('instruction_book.n.01')
>>> cb.wup_similarity(ib)
0.91666666666666663

因此,它们的相似度超过 91%!

它是如何工作的...

wup_similarity 是指 Wu-Palmer Similarity,这是一种基于词义相似性和在超类树中相对位置进行评分的方法。用于计算相似度的核心指标之一是两个 synset 及其共同超类之间的最短路径距离。

>>> ref = cb.hypernyms()[0]
>>> cb.shortest_path_distance(ref)
1
>>> ib.shortest_path_distance(ref)
1
>>> cb.shortest_path_distance(ib)
2

因此,cookbookinstruction book 必定非常相似,因为它们只相差一步就能到达同一个超类 reference book,因此彼此之间只相差两步。

还有更多...

让我们看看两个不同的词,看看我们会得到什么样的分数。我们将比较 dogcookbook,这两个词看起来非常不同。

>>> dog = wordnet.synsets('dog')[0]
>>> dog.wup_similarity(cb)
0.38095238095238093

哇,dogcookbook 看起来有 38% 的相似度!这是因为它们在树的上层共享共同的超类。

>>> dog.common_hypernyms(cb)
[Synset('object.n.01'), Synset('whole.n.02'), Synset('physical_entity.n.01'), Synset('entity.n.01')]

比较动词

之前的比较都是名词之间的,但同样的方法也可以用于动词。

>>> cook = wordnet.synset('cook.v.01')
>>> bake = wordnet.synset('bake.v.02')
>>> cook.wup_similarity(bake)
0.75

之前的 synset 明显是特意挑选出来进行演示的,原因是动词的超类树有更多的广度而深度较少。虽然大多数名词可以追溯到 object,从而提供相似性的基础,但许多动词没有共享的共同超类,这使得 WordNet 无法计算相似度。例如,如果你在这里使用 bake.v.01synset,而不是 bake.v.02,返回值将是 None。这是因为这两个 synset 的根超类不同,没有重叠的路径。因此,你也不能计算不同词性的单词之间的相似度。

路径和 LCH 相似度

另外两种相似度比较是路径相似度和Leacock Chodorow (LCH)相似度。

>>> cb.path_similarity(ib)
0.33333333333333331
>>> cb.path_similarity(dog)
0.071428571428571425
>>> cb.lch_similarity(ib)
2.5389738710582761
>>> cb.lch_similarity(dog)
0.99852883011112725

如你所见,这些评分方法的数值范围差异很大,这就是为什么我们更喜欢wup_similarity()方法。

参考内容

在本章前面讨论的在 WordNet 中查找单词的 synsets的食谱中,有更多关于上位词和上位词树的信息。

发现单词搭配

搭配词是指两个或更多经常一起出现的单词,例如“United States”。当然,还有许多其他单词可以跟在“United”后面,例如“United Kingdom”,“United Airlines”等等。与自然语言处理的许多方面一样,上下文非常重要,对于搭配词来说,上下文就是一切!

在搭配词的情况下,上下文将是一个单词列表形式的文档。在这个单词列表中寻找搭配词意味着我们将找到在整个文本中频繁出现的常见短语。为了好玩,我们将从《蒙提·派森与圣杯》的剧本开始。

准备工作

《蒙提·派森与圣杯》的剧本可以在webtext语料库中找到,所以请确保它在nltk_data/corpora/webtext/中已解压。

如何做到这一点...

我们将创建一个包含文本中所有小写单词的列表,然后生成一个BigramCollocationFinder,我们可以使用它来查找双词组合,即单词对。这些双词组合是通过在nltk.metrics包中找到的关联测量函数找到的。

>>> from nltk.corpus import webtext
>>> from nltk.collocations import BigramCollocationFinder
>>> from nltk.metrics import BigramAssocMeasures
>>> words = [w.lower() for w in webtext.words('grail.txt')]
>>> bcf = BigramCollocationFinder.from_words(words)
>>> bcf.nbest(BigramAssocMeasures.likelihood_ratio, 4)
[("'", 's'), ('arthur', ':'), ('#', '1'), ("'", 't')]

嗯,这并不很有用!让我们通过添加一个单词过滤器来去除标点符号和停用词来稍微改进一下。

>>> from nltk.corpus import stopwords
>>> stopset = set(stopwords.words('english'))
>>> filter_stops = lambda w: len(w) < 3 or w in stopset
>>> bcf.apply_word_filter(filter_stops)
>>> bcf.nbest(BigramAssocMeasures.likelihood_ratio, 4)
[('black', 'knight'), ('clop', 'clop'), ('head', 'knight'), ('mumble', 'mumble')]

更好——我们可以清楚地看到《蒙提·派森与圣杯》中最常见的四个双词组合。如果你想要看到超过四个,只需将数字增加到你想要的任何值,搭配词查找器将尽力而为。

它是如何工作的...

BigramCollocationFinder构建了两个频率分布:一个用于每个单词,另一个用于双词组合。频率分布,在 NLTK 中称为FreqDist,基本上是一个增强的字典,其中键是正在计数的项,值是计数。任何应用到的过滤函数都会通过消除任何未通过过滤器的单词来减少这两个FreqDist的大小。通过使用过滤函数消除所有一或两个字符的单词以及所有英语停用词,我们可以得到一个更干净的结果。过滤后,搭配词查找器就准备好接受一个通用的评分函数来查找搭配词。额外的评分函数将在本章后面的评分函数部分进行讨论。

还有更多...

除了BigramCollocationFinder,还有TrigramCollocationFinder,用于查找三元组而不是成对的三元组。这次,我们将寻找澳大利亚单身广告中的三元组

>>> from nltk.collocations import TrigramCollocationFinder
>>> from nltk.metrics import TrigramAssocMeasures
>>> words = [w.lower() for w in webtext.words('singles.txt')]
>>> tcf = TrigramCollocationFinder.from_words(words)
>>> tcf.apply_word_filter(filter_stops)
>>> tcf.apply_freq_filter(3)
>>> tcf.nbest(TrigramAssocMeasures.likelihood_ratio, 4)
[('long', 'term', 'relationship')]

现在,我们不知道人们是否在寻找长期关系,但显然这是一个重要的话题。除了停用词过滤器外,我们还应用了一个频率过滤器,该过滤器移除了出现次数少于三次的所有三元组。这就是为什么当我们要求四个结果时只返回一个结果的原因——因为只有一个结果出现了两次以上。

评分函数

除了 likelihood_ratio() 之外,还有许多其他的评分函数可用。但除了 raw_freq() 之外,你可能需要一点统计学背景才能理解它们是如何工作的。请参考 nltk.metrics 包中 NgramAssocMeasures 的 NLTK API 文档,以查看所有可能的评分函数。

评分 n-gram

除了 nbest() 方法之外,还有两种从搭配查找器中获取 n-gram(描述 bigramtrigram 的通用术语)的方法。

  1. above_score(score_fn, min_score) 可以用来获取所有评分至少为 min_score 的 n-gram。你选择的 min_score 将在很大程度上取决于你使用的 score_fn

  2. score_ngrams(score_fn) 将返回一个包含 (ngram, score) 元组的列表。这可以用来告知你之前步骤中 min_score 的选择。

参见

nltk.metrics 模块将在 第七章 文本分类 中再次使用。

第二章:替换和纠正单词

在本章中,我们将介绍以下内容:

  • 词干提取

  • 使用 WordNet 词元化单词

  • 使用 Babelfish 翻译文本

  • 替换匹配正则表达式的单词

  • 移除重复字符

  • 使用 Enchant 进行拼写纠正

  • 替换同义词

  • 将否定词替换为反义词

简介

在本章中,我们将介绍各种单词替换和纠正技术。这些配方涵盖了语言压缩、拼写纠正和文本归一化的范围。所有这些方法在文本搜索索引、文档分类和文本分析之前的预处理中都非常有用。

词干提取

词干提取是从单词中移除词缀的技术,最终得到词干。例如,“cooking”的词干是“cook”,一个好的词干提取算法知道“ing”后缀可以被移除。词干提取最常由搜索引擎用于索引单词。搜索引擎可以存储单词的所有形式,而不是存储词干,这可以大大减少索引的大小,同时提高检索的准确性。

最常见的词干提取算法之一是 Martin Porter 的Porter 词干提取算法。它旨在移除和替换英语单词的已知后缀,NLTK 中的使用将在下一节中介绍。

注意

结果词干不总是有效的单词。例如,“cookery”的词干是“cookeri”。这是一个特性,而不是错误。

如何做到...

NLTK 包含 Porter 词干提取算法的实现,使用起来非常简单。只需实例化PorterStemmer类,并使用要提取词干的单词调用stem()方法。

>>> from nltk.stem import PorterStemmer
>>> stemmer = PorterStemmer()
>>> stemmer.stem('cooking')
'cook'
>>> stemmer.stem('cookery')
'cookeri'

它是如何工作的...

PorterStemmer知道许多常见的单词形式和后缀,并使用这些知识通过一系列步骤将输入单词转换为最终的词干。结果词干通常是更短的单词,或者至少是单词的常见形式,具有相同的词根意义。

还有更多...

除了 Porter 词干提取算法之外,还有其他词干提取算法,例如在兰开斯特大学开发的Lancaster 词干提取算法。NLTK 将其作为LancasterStemmer类包含在内。在撰写本文时,没有确凿的研究表明一个算法优于另一个算法。然而,Porter 词干提取通常是默认选择。

接下来的所有词干提取器都继承自StemmerI接口,该接口定义了stem()方法。以下是一个继承图,展示了这一点:

还有更多...

LancasterStemmer

LancasterStemmer的功能与PorterStemmer类似,但可以产生略微不同的结果。它被认为是比P orterStemmer更激进。

>>> from nltk.stem import LancasterStemmer
>>> stemmer = LancasterStemmer()
>>> stemmer.stem('cooking')
'cook'
>>> stemmer.stem('cookery')
'cookery'

RegexpStemmer

您还可以使用RegexpStemmer构建自己的词干提取器。它接受单个正则表达式(可以是编译后的或字符串形式),并将移除任何匹配的前缀或后缀。

>>> from nltk.stem import RegexpStemmer
>>> stemmer = RegexpStemmer('ing')
>>> stemmer.stem('cooking')
'cook'
>>> stemmer.stem('cookery')
'cookery'
>>> stemmer.stem('ingleside')
'leside'

RegexpStemmer 应仅用于 PorterStemmerLancasterStemmer 无法覆盖的非常特定的情况。

SnowballStemmer

NLTK 2.0b9 新增了 SnowballStemmer,它支持 13 种非英语语言。要使用它,你需要创建一个以你使用的语言命名的实例,然后调用 stem() 方法。以下是所有支持的语言列表,以及使用西班牙语 SnowballStemmer 的示例:

>>> from nltk.stem import SnowballStemmer
>>> SnowballStemmer.languages
('danish', 'dutch', 'finnish', 'french', 'german', 'hungarian', 'italian', 'norwegian', 'portuguese', 'romanian', 'russian', 'spanish', 'swedish')
>>> spanish_stemmer = SnowballStemmer('spanish')
>>> spanish_stemmer.stem('hola')
u'hol'

参见

在下一个菜谱中,我们将介绍词元化,它与词干提取非常相似,但有一些细微的差别。

使用 WordNet 词元化单词

词元化 与词干提取非常相似,但更类似于同义词替换。一个 词元 是一个词根,与根 词干 相反。所以与词干提取不同,你总是留下一个有效的单词,它意味着相同的事情。但最终你得到的单词可能完全不同。一些例子将解释词元化...

准备工作

确保你已经解压缩了 wordnet 语料库在 nltk_data/corpora/wordnet 中。这将允许 WordNetLemmatizer 访问 WordNet。你还应该对 第一章 中 在 WordNet 中查找单词的词义集 菜单中涵盖的词性标签有所了解,文本分词和 WordNet 基础

如何做...

我们将使用 WordNetLemmatizer 来查找词元:

>>> from nltk.stem import WordNetLemmatizer
>>> lemmatizer = WordNetLemmatizer()
>>> lemmatizer.lemmatize('cooking')
'cooking'
>>> lemmatizer.lemmatize('cooking', pos='v')
'cook'
>>> lemmatizer.lemmatize('cookbooks')
'cookbook'

它是如何工作的...

WordNetLemmatizer 是围绕 WordNet 语料库的一个薄包装,并使用 W ordNetCorpusReadermorphy() 函数来查找词元。如果没有找到词元,则将单词按原样返回。与词干提取不同,了解单词的词性很重要。如前所述,“cooking”没有词元,除非你指定词性(pos)是动词。这是因为默认的词性是名词,而“cooking”不是名词,因此找不到词元。“Cookbooks”,另一方面,是名词,其词元是单数形式,“cookbook”。

更多...

下面是一个示例,说明了词干提取和词元化之间的一大主要区别:

>>> from nltk.stem import PorterStemmer
>>> stemmer = PorterStemmer()
>>> stemmer.stem('believes')
'believ'
>>> lemmatizer.lemmatize('believes')
'belief'

PorterStemmer 不同,WordNetLemmatizer 会找到一个有效的词根。词干提取器只关注单词的形式,而词元化器关注单词的意义。通过返回一个词元,你将始终得到一个有效的单词。

将词干提取与词元化结合

词干提取和词元化可以结合起来压缩单词,比单独的任何过程都能压缩更多。这些情况相对较少,但它们确实存在:

>>> stemmer.stem('buses')
'buse'
>>> lemmatizer.lemmatize('buses')
'bus'
>>> stemmer.stem('bus')
'bu'

在这个例子中,词干提取节省了一个字符,词元化节省了两个字符,而词干提取词元总共节省了五个字符中的三个字符。这几乎是 60% 的压缩率!在成千上万的单词中,这种程度的单词压缩虽然不太可能总是产生如此高的收益,但仍然可以产生巨大的差异。

参见

在前面的食谱中,我们介绍了词干提取的基础知识,并在第一章的在 WordNet 中查找单词的 synsets在 WordNet 中查找词元和同义词食谱中介绍了 WordNet。展望未来,我们将在第四章的使用 WordNet 进行词性标注食谱中介绍。

使用 Babelfish 翻译文本

Babelfish是 Yahoo 提供的一个在线语言翻译 API。使用它,你可以将源语言的文本翻译成目标语言。NLTK 提供了一个简单的接口来使用它。

准备工作

首先确保你已经连接到互联网。babelfish.translate()函数需要访问 Yahoo 的在线 API 才能工作。

如何操作...

要翻译你的文本,你首先需要知道两件事:

  1. 你的文本或源语言的语言。

  2. 你想要翻译到的语言或目标语言。

语言检测不在这个食谱的范围内,所以我们将假设你已经知道源语言和目标语言。

>>> from nltk.misc import babelfish
>>> babelfish.translate('cookbook', 'english', 'spanish')
'libro de cocina'
>>> babelfish.translate('libro de cocina', 'spanish', 'english')
'kitchen book'
>>> babelfish.translate('cookbook', 'english', 'german')
'Kochbuch'
>>> babelfish.translate('kochbuch', 'german', 'english')
'cook book'

注意

你不能使用相同的语言来翻译源语言和目标语言。尝试这样做将会引发一个BabelfishChangedError错误。

它是如何工作的...

translate()函数是一个小的函数,它向babelfish.yahoo.com/translate_txt发送urllib请求,然后搜索响应以找到翻译后的文本。

注意

如果由于某种原因 Yahoo 改变了他们的 HTML 响应,以至于translate()无法识别翻译后的文本,将会引发一个BabelfishChangedError错误。这种情况不太可能发生,但如果真的发生了,你可能需要升级到 NLTK 的新版本,或者报告这个错误。

还有更多...

还有一个有趣的功能叫做babelize(),它可以在源语言和目标语言之间来回翻译,直到没有更多变化。

>>> for text in babelfish.babelize('cookbook', 'english', 'spanish'):
...  print text
cookbook
libro de cocina
kitchen book
libro de la cocina
book of the kitchen

可用语言

你可以通过检查available_languages属性来查看所有可用的翻译语言。

>>> babelfish.available_languages
['Portuguese', 'Chinese', 'German', 'Japanese', 'French', 'Spanish', 'Russian', 'Greek', 'English', 'Korean', 'Italian']

这些语言的低档版本可以用作翻译的源语言或目标语言。

替换匹配正则表达式的单词

现在我们将进入替换单词的过程。其中词干提取和词形还原是一种语言压缩,而单词替换可以被视为错误纠正,或文本规范化

对于这个食谱,我们将根据正则表达式替换单词,重点是扩展缩写词。记得我们在第一章中分词单词时,文本分词和 WordNet 基础知识,很明显大多数分词器在处理缩写词时都有困难吗?这个食谱旨在通过将缩写词替换为其扩展形式来解决这个问题,例如将"can't"替换为"cannot",或将"would've"替换为"would have"。

准备工作

理解这个食谱的工作原理需要具备正则表达式和re模块的基本知识。关键要知道的是匹配模式re.subn()函数。

如何做...

首先,我们需要定义一系列替换模式。这将是一个元组对的列表,其中第一个元素是要匹配的模式,第二个元素是替换内容。

接下来,我们将创建一个RegexpReplacer类,该类将编译模式,并提供一个replace()方法来替换所有找到的模式。

以下代码可以在replacers.py模块中找到,并打算导入,而不是在控制台中输入:

import re

replacement_patterns = [
  (r'won\'t', 'will not'),
  (r'can\'t', 'cannot'),
  (r'i\'m', 'i am'),
  (r'ain\'t', 'is not'),
  (r'(\w+)\'ll', '\g<1> will'),
  (r'(\w+)n\'t', '\g<1> not'),
  (r'(\w+)\'ve', '\g<1> have'),
  (r'(\w+)\'s', '\g<1> is'),
  (r'(\w+)\'re', '\g<1> are'),
  (r'(\w+)\'d', '\g<1> would')

]
class RegexpReplacer(object):
  def __init__(self, patterns=replacement_patterns):
    self.patterns = [(re.compile(regex), repl) for (regex, repl) in patterns]

  def replace(self, text):
    s = text
    for (pattern, repl) in self.patterns:
      (s, count) = re.subn(pattern, repl, s)
    return s

它是如何工作的...

下面是一个简单的使用示例:

>>> from replacers import RegexpReplacer
>>> replacer = RegexpReplacer()
>>> replacer.replace("can't is a contraction")
'cannot is a contraction'
>>> replacer.replace("I should've done that thing I didn't do")
'I should have done that thing I did not do'

RegexpReplacer.replace()通过将每个替换模式的所有实例替换为其相应的替换模式来工作。在replacement_patterns中,我们定义了如(r'(\w+)\'ve', '\g<1> have')这样的元组。第一个元素匹配一组 ASCII 字符后跟've'。通过在've'之前将字符分组放在括号中,我们找到了一个匹配组,并可以使用\g<1>引用在替换模式中使用。因此,我们保留've'之前的所有内容,然后将've'替换为单词have。这就是“should've”可以变成“should have”的方式。

更多内容...

这种替换技术可以与任何类型的正则表达式一起工作,而不仅仅是缩写。因此,你可以将任何“&”的出现替换为“and”,或者通过将其替换为空字符串来消除所有“-”的出现。RegexpReplacer可以接受任何用于任何目的的替换模式列表。

分词前的替换

让我们尝试在分词之前使用RegexpReplacer作为初步步骤:

>>> from nltk.tokenize import word_tokenize
>>> from replacers import RegexpReplacer
>>> replacer = RegexpReplacer()
>>> word_tokenize("can't is a contraction")
['ca', "n't", 'is', 'a', 'contraction']
>>> word_tokenize(replacer.replace("can't is a contraction"))
['can', 'not', 'is', 'a', 'contraction']

更好!通过首先消除缩写,分词器将产生更干净的结果。在处理文本之前进行清理是自然语言处理中的常见模式。

参见

关于分词的更多信息,请参阅第一章的前三个食谱,“文本分词和 WordNet 基础知识”。有关更多替换技术,请继续阅读本章的其余部分。

移除重复字符

在日常语言中,人们往往并不严格遵守语法。他们会写出像“我 looooooove 它”这样的句子来强调“爱”这个词。但除非有人告诉它们,“looooooove”是“love”的变体,否则计算机并不知道。这个方法提供了一种去除那些令人烦恼的重复字符的方法,以便最终得到一个“正确”的英语单词。

准备工作

正如上一个食谱中一样,我们将使用re模块,特别是回溯引用。回溯引用是在正则表达式中引用之前匹配的组的一种方式。这将使我们能够匹配和删除重复字符。

如何做...

我们将创建一个类,其形式与之前菜谱中的 RegexpReplacer 相同。它将有一个 replace() 方法,该方法接受一个单词并返回该单词的正确版本,移除了可疑的重复字符。以下代码可以在 replacers.py 中找到,并打算导入:

import re

class RepeatReplacer(object):
  def __init__(self):
    self.repeat_regexp = re.compile(r'(\w*)(\w)\2(\w*)')
    self.repl = r'\1\2\3'

  def replace(self, word):
    repl_word = self.repeat_regexp.sub(self.repl, word)
    if repl_word != word:
      return self.replace(repl_word)

    else:
      return repl_word

现在是一些示例用法:

>>> from replacers import RepeatReplacer
>>> replacer = RepeatReplacer()
>>> replacer.replace('looooove')
'love'
>>> replacer.replace('oooooh')
'oh'
>>> replacer.replace('goose')
'gose'

它是如何工作的...

RepeatReplacer 首先编译一个用于匹配的正则表达式,并定义一个带有回溯引用的替换字符串。repeat_regexp 匹配三个组:

  1. 零个或多个起始字符 (\w*)

  2. 一个字符 (\w),后跟该字符的另一个实例 \2

  3. 零个或多个结尾字符 (\w*)

然后,替换字符串 用于保留所有匹配的组,同时丢弃对第二个组的引用。因此,单词 "looooove" 被分割成 (l)(o)o(ooove),然后重新组合为 "loooove",丢弃第二个 "o"。这会一直持续到只剩下一个 "o",此时 repeat_regexp 不再匹配字符串,不再移除更多字符。

还有更多...

在前面的示例中,您可以看到 RepeatReplacer 稍微有点贪婪,最终将 "goose" 改成了 "gose"。为了纠正这个问题,我们可以在 replace() 函数中增加 WordNet 查找。如果 WordNet 识别该单词,那么我们可以停止替换字符。以下是 WordNet 增强版本:

import re
from nltk.corpus import wordnet

class RepeatReplacer(object):
  def __init__(self):
    self.repeat_regexp = re.compile(r'(\w*)(\w)\2(\w*)')
    self.repl = r'\1\2\3'

  def replace(self, word):
    if wordnet.synsets(word):
      return word
    repl_word = self.repeat_regexp.sub(self.repl, word)

    if repl_word != word:
      return self.replace(repl_word)
    else:
      return repl_word

现在,"goose" 将在 WordNet 中找到,不会进行字符替换。而 "oooooh" 将变成 "ooh" 而不是 "oh",因为 "ooh" 实际上是一个单词,在 WordNet 中定义为表示钦佩或愉悦的表达。

参考信息

读取下一菜谱了解如何纠正拼写错误。有关 WordNet 的更多信息,请参阅第一章 Tokenizing Text and WordNet Basics 中的 WordNet 菜谱。我们还将在本章后面使用 WordNet 进行反义词替换。

使用 Enchant 进行拼写纠正

替换重复字符实际上是拼写纠正的一种极端形式。在这个菜谱中,我们将处理不那么极端的情况,即使用 Enchant(一个拼写纠正 API)纠正轻微的拼写错误。

准备工作

您需要安装 Enchant 以及为其使用的词典。Enchant 是 "Abiword" 开源文字处理器的分支,更多信息可以在 www.abisource.com/projects/enchant/ 找到。

对于词典,aspell 是一个优秀的开源拼写检查器和词典,可以在 aspell.net/ 找到。

最后,您还需要 pyenchant 库,可以在 www.rfk.id.au/software/pyenchant/ 找到。您应该能够使用随 python-setuptools 一起提供的 easy_install 命令安装它,例如在 Linux 或 Unix 上执行 sudo easy_install pyenchant

如何操作...

我们将在 replacers.py 中创建一个新的类 SpellingReplacer,这次 replace() 方法将检查 Enchant 以查看单词是否有效。如果不是,我们将查找建议的替代方案,并使用 nltk.metrics.edit_distance() 返回最佳匹配:

import enchant
from nltk.metrics import edit_distance

class SpellingReplacer(object):
  def __init__(self, dict_name='en', max_dist=2):
    self.spell_dict = enchant.Dict(dict_name)
    self.max_dist = 2

  def replace(self, word):
    if self.spell_dict.check(word):
      return word
    suggestions = self.spell_dict.suggest(word)

    if suggestions and edit_distance(word, suggestions[0]) <= self.max_dist:
      return suggestions[0]
    else:
      return word

之前提到的类可以用来如下修正英语拼写:

>>> from replacers import SpellingReplacer
>>> replacer = SpellingReplacer()
>>> replacer.replace('cookbok')
'cookbook'

它是如何工作的...

SpellingReplacer 首先创建了一个对 enchant 字典的引用。然后,在 replace() 方法中,它首先检查给定的 word 是否存在于字典中。如果存在,则不需要拼写修正,并返回该单词。但如果单词未找到,它会查找一个建议列表,并返回第一个建议,只要其编辑距离小于或等于 max_dist编辑距离是将给定单词转换为建议单词所需的字符更改数。max_dist 作为对 Enchant suggest() 函数的约束,以确保不会返回不太可能的替换词。以下是一个显示 "languege"("language" 的拼写错误)的所有建议的示例:

>>> import enchant
>>> d = enchant.Dict('en')
>>> d.suggest('languege')
['language', 'languisher', 'languish', 'languor', 'languid']

除了正确的建议 "language" 之外,所有其他单词的编辑距离都为三个或更大。

还有更多...

您可以使用除 'en' 之外的语言字典,例如 'en_GB',假设字典已经安装。要检查哪些其他语言可用,请使用 enchant.list_languages()

>>> enchant.list_languages()
['en_AU', 'en_GB', 'en_US', 'en_ZA', 'en_CA', 'en']

注意

如果您尝试使用不存在的字典,您将得到 enchant.DictNotFoundError。您可以使用 enchant.dict_exists() 首先检查字典是否存在,如果存在,它将返回 True,否则返回 False

en_GB 字典

总是要确保使用正确的字典来对您正在进行的拼写修正的语言进行操作。'en_US' 可能会给出与 'en_GB' 不同的结果,例如对于单词 "theater"。 "Theater" 是美式英语的拼写,而英式英语的拼写是 "Theatre":

>>> import enchant
>>> dUS = enchant.Dict('en_US')
>>> dUS.check('theater')
True
>>> dGB = enchant.Dict('en_GB')
>>> dGB.check('theater')
False
>>> from replacers import SpellingReplacer
>>> us_replacer = SpellingReplacer('en_US')
>>> us_replacer.replace('theater')
'theater'
>>> gb_replacer = SpellingReplacer('en_GB')
>>> gb_replacer.replace('theater')
'theatre'

个人单词列表

Enchant 还支持个人单词列表。这些可以与现有字典结合使用,允许您通过自己的单词来扩展字典。所以假设您有一个名为 mywords.txt 的文件,其中有一行是 nltk。您然后可以创建一个包含您的个人单词列表的扩展字典,如下所示:

>>> d = enchant.Dict('en_US')
>>> d.check('nltk')
False
>>> d = enchant.DictWithPWL('en_US', 'mywords.txt')
>>> d.check('nltk')
True

要使用 SpellingReplacer 的扩展字典,我们可以在 replacers.py 中创建一个子类,它接受现有的拼写字典。

class CustomSpellingReplacer(SpellingReplacer):
  def __init__(self, spell_dict, max_dist=2):
    self.spell_dict = spell_dict
    self.max_dist = max_dist

这个 CustomSpellingReplacer 不会替换您放入 mywords.txt 中的任何单词。

>>> from replacers import CustomSpellingReplacer
>>> d = enchant.DictWithPWL('en_US', 'mywords.txt')
>>> replacer = CustomSpellingReplacer(d)
>>> replacer.replace('nltk')
'nltk'

相关内容

之前的配方涉及通过替换重复字符的极端形式的拼写修正。您还可以通过简单的单词替换来进行拼写修正,如下一配方中讨论的那样。

替换同义词

通过用常用同义词替换单词来减少文本的词汇量通常很有用。通过在不失去意义的情况下压缩词汇量,您可以在 频率分析文本索引 等情况下节省内存。词汇量减少还可以增加重要搭配的出现频率,这在 第一章 的 发现单词搭配 食谱中已有介绍,文本分词和 WordNet 基础

准备工作

您需要定义一个单词与其同义词的映射。这是一个简单的 受控词汇表。我们将首先将同义词硬编码为 Python 字典,然后探讨存储同义词映射的其他选项。

如何做...

我们首先在 replacers.py 中创建一个 WordReplacer 类,它接受一个单词替换映射:

class WordReplacer(object):
  def __init__(self, word_map):
    self.word_map = word_map
  def replace(self, word):
    return self.word_map.get(word, word)

然后,我们可以演示其用于简单单词替换的使用方法:

>>> from replacers import wordReplacer
>>> replacer = WordReplacer({'bday': 'birthday'})
>>> replacer.replace('bday')
'birthday'
>>> replacer.replace('happy')
'happy'

它是如何工作的...

WordReplacer 类简单地封装了一个 Python 字典。replace() 方法在其 word_map 中查找给定的单词,如果存在替换同义词,则返回该同义词。否则,返回给定的单词。

如果您只使用 word_map 字典,您就不需要 WordReplacer 类,可以直接调用 word_map.get()。但是 WordReplacer 可以作为从各种文件格式构建 word_map 的其他类的基类。继续阅读以获取更多信息。

还有更多...

将同义词硬编码为 Python 字典不是一个好的长期解决方案。两种更好的替代方案是将同义词存储在 CSV 文件或 YAML 文件中。选择对维护同义词词汇表的人来说最简单的格式。以下部分概述的两个类都从 WordReplacer 继承了 replace() 方法。

CSV 同义词替换

CsvWordReplacer 类在 replacers.py 中扩展了 WordReplacer,以便从 CSV 文件中构建 word_map

import csv

class CsvWordReplacer(WordReplacer):
  def __init__(self, fname):
    word_map = {}
    for line in csv.reader(open(fname)):
      word, syn = line
      word_map[word] = syn
    super(CsvWordReplacer, self).__init__(word_map)

您的 CSV 文件应该有两列,其中第一列是单词,第二列是要替换的单词的同义词。如果此文件名为 synonyms.csv 且第一行是 bdaybirthday,则可以这样做:

>>> from replacers import CsvWordReplacer
>>> replacer = CsvWordReplacer('synonyms.csv')
>>> replacer.replace('bday')
'birthday'
>>> replacer.replace('happy')
'happy'

YAML 同义词替换

如果您已安装 PyYAML,您可以在 replacers.py 中创建一个 YamlWordReplacer。PyYAML 的下载和安装说明位于 pyyaml.org/wiki/PyYAML

import yaml

class YamlWordReplacer(WordReplacer):
  def __init__(self, fname):
    word_map = yaml.load(open(fname))
    super(YamlWordReplacer, self).__init__(word_map)

您的 YAML 文件应该是一个简单的 "单词:同义词" 映射,例如 bday: birthday。请注意,YAML 语法非常特别,冒号后面的空格是必需的。如果文件名为 synonyms.yaml,则可以这样做:

>>> from replacers import YamlWordReplacer
>>> replacer = YamlWordReplacer('synonyms.yaml')
>>> replacer.replace('bday')
'birthday'
>>> replacer.replace('happy')
'happy'

参见

您可以使用 WordReplacer 来进行任何类型的单词替换,甚至是对更复杂的单词进行拼写纠正,这些单词无法自动纠正,就像我们在前面的食谱中所做的那样。在下一个食谱中,我们将介绍反义词替换。

用反义词替换否定词

同义词替换的相反是 反义词替换反义词是一个词的相反含义。这次,我们不再创建自定义的词映射,而是可以使用 WordNet 用明确的反义词替换词。有关反义词查找的更多详细信息,请参阅 第一章 中的 在 Wordnet 中查找词元和同义词 配方。

如何做到这一点...

假设你有一个句子,例如 "let's not uglify our code"。使用反义词替换,你可以将 "not uglify" 替换为 "beautify",从而得到句子 "let's beautify our code"。为此,我们需要在 replacers.py 中创建一个 AntonymReplacer,如下所示:

from nltk.corpus import wordnet
class AntonymReplacer(object):
  def replace(self, word, pos=None):
    antonyms = set()
    for syn in wordnet.synsets(word, pos=pos):
      for lemma in syn.lemmas:
        for antonym in lemma.antonyms():
          antonyms.add(antonym.name)
    if len(antonyms) == 1:
      return antonyms.pop()
    else:
      return None

  def replace_negations(self, sent):
    i, l = 0, len(sent)
    words = []
    while i < l:
      word = sent[i]
      if word == 'not' and i+1 < l:
        ant = self.replace(sent[i+1])
        if ant:
          words.append(ant)
          i += 2
          continue
      words.append(word)
      i += 1
    return words

现在我们可以将原始句子分词为 ["let's", 'not', 'uglify', 'our', 'code'],并将其传递给 replace_negations() 函数。以下是一些示例:

>>> from replacers import AntonymReplacer
>>> replacer = AntonymReplacer()
>>> replacer.replace('good')
>>> replacer.replace('uglify')
'beautify'
>>> sent = ["let's", 'not', 'uglify', 'our', 'code']
>>> replacer.replace_negations(sent)
["let's", 'beautify', 'our', 'code']

它是如何工作的...

AntonymReplacer 有两种方法:replace()replace_negations()replace() 方法接受一个单个的 word 和一个可选的词性标签,然后查找 WordNet 中该词的 synsets。遍历所有 synsets 以及每个 synset 的每个词元,它创建一个包含所有找到的反义词的 set。如果只找到一个反义词,那么它是一个 明确的替换。如果找到多个反义词(这种情况相当常见),那么我们无法确定哪个反义词是正确的。在存在多个反义词(或没有反义词)的情况下,replace() 返回 None,因为它无法做出决定。

replace_negations() 中,我们遍历一个分词句子以查找单词 "not"。如果找到 "not",则尝试使用 replace() 查找下一个词的反义词。如果我们找到一个反义词,则将其追加到 words 列表中,替换掉 "not" 和原始词。所有其他词都按原样追加,结果是一个分词句子,其中明确的否定被其反义词替换。

还有更多...

由于在 WordNet 中明确的反义词并不常见,您可能需要创建一个与同义词相同的自定义反义词映射。这个 AntonymWordReplacer 可以通过从 WordReplacerAntonymReplacer 继承来构建:

class AntonymWordReplacer(WordReplacer, AntonymReplacer):
  pass

继承顺序非常重要,因为我们希望 WordReplacer 的初始化和 replace() 函数与 AntonymReplacerreplace_negations() 函数结合。结果是这样一个替换器,它可以执行以下操作:

>>> from replacers import AntonymWordReplacer
>>> replacer = AntonymWordReplacer({'evil': 'good'})
>>> replacer.replace_negations(['good', 'is', 'not', 'evil'])
['good', 'is', 'good']

当然,如果您想从文件中加载反义词词映射,您也可以从 CsvWordReplacerYamlWordReplacer 继承而不是 WordReplacer

参见

之前的配方从同义词替换的角度介绍了 WordReplacer。在 第一章 中,文本分词和 WordNet 基础 详细介绍了 Wordnet 的使用,包括 在 Wordnet 中查找词的 synsets在 Wordnet 中查找词元和同义词 配方。

第三章:创建自定义语料库

在本章中,我们将介绍:

  • 设置自定义语料库

  • 创建单词列表语料库

  • 创建词性标注的单词语料库

  • 创建分块短语语料库

  • 创建分类文本语料库

  • 创建分类分块语料库读取器

  • 懒加载语料库

  • 创建自定义语料库视图

  • 创建基于 MongoDB 的语料库读取器

  • 使用文件锁定进行语料库编辑

简介

在本章中,我们将介绍如何使用语料库读取器创建自定义语料库。同时,你将学习如何使用 NLTK 附带的存在语料库数据。这些信息对于后续章节至关重要,届时我们需要将语料库作为训练数据来访问。我们还将介绍创建自定义语料库读取器,这可以在你的语料库不是 NLTK 已识别的文件格式时使用,或者如果你的语料库根本不在文件中,而是位于数据库(如 MongoDB)中。

设置自定义语料库

语料库是一组文本文档的集合,corpora是语料库的复数形式。因此,自定义语料库实际上只是目录中的一些文本文件,通常与许多其他文本文件目录并列。

准备工作

你应该已经按照www.nltk.org/data上的说明安装了 NLTK 数据包。我们将假设数据安装在了 Windows 上的C:\nltk_data,Linux、Unix 或 Mac OS X 上的/usr/share/nltk_data

如何操作...

NLTK 定义了一个数据目录列表,或路径,在nltk.data.path中。我们的自定义语料库必须位于这些路径之一,以便 NLTK 可以找到它。为了避免与官方数据包冲突,我们将在主目录中创建一个自定义的nltk_data目录。以下是一些 Python 代码,用于创建此目录并验证它是否在由nltk.data.path指定的已知路径列表中:

>>> import os, os.path
>>> path = os.path.expanduser('~/nltk_data')
>>> if not os.path.exists(path):
...    os.mkdir(path)
>>> os.path.exists(path)
True
>>> import nltk.data
>>> path in nltk.data.path
True

如果最后一行path in nltk.data.pathTrue,那么你现在应该在主目录中有一个nltk_data目录。在 Windows 上,路径应该是%UserProfile%\nltk_data,在 Unix、Linux 或 Mac OS X 上,路径应该是~/nltk_data。为了简化,我将把这个目录称为~/nltk_data

注意

如果最后一行没有返回True,请尝试在你的主目录中手动创建nltk_data目录,然后验证绝对路径是否在nltk.data.path中。在继续之前,确保此目录存在并且位于nltk.data.path中是至关重要的。一旦你有了nltk_data目录,惯例是语料库位于一个corpora子目录中。在nltk_data目录中创建此corpora目录,以便路径为~/nltk_data/corpora。最后,我们将在corpora中创建一个子目录来存放我们的自定义语料库。让我们称它为cookbook,完整的路径为~/nltk_data/corpora/cookbook

现在我们可以创建一个简单的 单词列表 文件并确保它被加载。在 第二章,替换和纠正单词使用 Enchant 进行拼写纠正 菜谱中,我们创建了一个名为 mywords.txt 的单词列表文件。将此文件放入 ~/nltk_data/corpora/cookbook/。现在我们可以使用 nltk.data.load() 来加载该文件。

>>> import nltk.data
>>> nltk.data.load('corpora/cookbook/mywords.txt', format='raw')
'nltk\n'

注意

我们需要指定 format='raw',因为 nltk.data.load() 不知道如何解释 .txt 文件。正如我们将看到的,它确实知道如何解释许多其他文件格式。

它是如何工作的...

nltk.data.load() 函数识别多种格式,例如 'raw''pickle''yaml'。如果没有指定格式,它将尝试根据文件的扩展名猜测格式。在前一个例子中,我们有一个 .txt 文件,这不是一个已识别的扩展名,因此我们必须指定 'raw' 格式。但如果我们使用以 .yaml 结尾的文件,则不需要指定格式。

传递给 nltk.data.load() 的文件名可以是 绝对 路径或 相对 路径。相对路径必须是 nltk.data.path 中指定的路径之一。文件是通过 nltk.data.find(path) 找到的,它搜索所有已知路径与相对路径的组合。绝对路径不需要搜索,可以直接使用。

更多内容...

对于大多数语料库访问,实际上你不需要使用 nltk.data.load,因为这将由以下菜谱中介绍的 CorpusReader 类处理。但了解这个函数对于加载 .pickle 文件和 .yaml 文件是很有帮助的,同时它也引入了将所有数据文件放入 NLTK 已知路径的概念。

加载 YAML 文件

如果你将 第二章,替换和纠正单词替换同义词 菜谱中的 synonyms.yaml 文件放入 ~/nltk_data/corpora/cookbook(在 mywords.txt 旁边),你可以使用 nltk.data.load() 来加载它,无需指定格式。

>>> import nltk.data
>>> nltk.data.load('corpora/cookbook/synonyms.yaml')
{'bday': 'birthday'}

这假设 PyYAML 已经安装。如果没有,你可以在pyyaml.org/wiki/PyYAML找到下载和安装说明。

相关内容

在接下来的菜谱中,我们将介绍各种语料库读取器,然后在 Lazy corpus loading 菜谱中,我们将使用 LazyCorpusLoader,它期望语料库数据位于 nltk.data.path 指定路径之一的 corpora 子目录中。

创建单词列表语料库

WordListCorpusReader 是最简单的 CorpusReader 类之一。它提供对包含单词列表的文件的访问,每行一个单词。实际上,当我们在 第一章,文本分词和 WordNet 基础在分词句子中过滤停用词发现词搭配 菜谱中使用 stopwords 语料库时,你已经使用过它了。

准备工作

我们需要首先创建一个单词列表文件。这可以是一个单列 CSV 文件,或者只是一个每行一个单词的普通文本文件。让我们创建一个名为wordlist的文件,如下所示:

nltk
corpus
corpora
wordnet

如何操作...

现在我们可以实例化一个WordListCorpusReader,它将从我们的文件中生成单词列表。它需要两个参数:包含文件的目录路径和文件名列表。如果你在包含文件的同一目录中打开 Python 控制台,那么'.'可以用作目录路径。否则,你必须使用一个目录路径,例如:'nltk_data/corpora/cookbook'

>>> from nltk.corpus.reader import WordListCorpusReader
>>> reader = WordListCorpusReader('.', ['wordlist'])
>>> reader.words()
['nltk', 'corpus', 'corpora', 'wordnet']
>>> reader.fileids()
['wordlist']

工作原理...

WordListCorpusReader类继承自CorpusReader,这是所有语料库读取器的公共基类。CorpusReader负责确定要读取哪些文件,而WordListCorpus读取文件并将每一行分词以生成单词列表。下面是一个继承关系图:

工作原理...

当你调用words()函数时,它会使用nltk.tokenize.line_tokenize()对原始文件数据进行分词,你可以通过raw()函数访问这些数据。

>>> reader.raw()
'nltk\ncorpus\ncorpora\nwordnet\n'
>>> from nltk.tokenize import line_tokenize
>>> line_tokenize(reader.raw())
['nltk', 'corpus', 'corpora', 'wordnet']

还有更多...

stopwords语料库是多文件WordListCorpusReader的一个很好的例子。在第一章第一章.文本分词和 WordNet 基础知识中,在分词句子中过滤停用词的配方中,我们看到了它为每种语言有一个单词列表文件,并且你可以通过调用stopwords.words(fileid)来访问该语言的单词。如果你想创建自己的多文件单词列表语料库,这是一个很好的例子。

名字语料库

NLTK 附带的其他单词列表语料库是names语料库。它包含两个文件:female.txtmale.txt,每个文件都包含按性别组织的几千个常见名字列表。

>>> from nltk.corpus import names
>>> names.fileids()
['female.txt', 'male.txt']
>>> len(names.words('female.txt'))
5001
>>> len(names.words('male.txt'))
2943

英语单词

NLTK 还附带了一个大量的英语单词列表。有一个包含 850 个基本单词的文件,还有一个包含超过 20 万个已知英语单词的列表。

>>> from nltk.corpus import words
>>> words.fileids()
['en', 'en-basic']
>>> len(words.words('en-basic'))
850
>>> len(words.words('en'))
234936

参考信息

在第一章第一章.文本分词和 WordNet 基础知识中,在分词句子中过滤停用词的配方中,对使用stopwords语料库有更多细节。在接下来的配方中,我们将介绍更高级的语料库文件格式和语料库读取器类。

创建词性标注的语料库

词性标注是识别单词的词性标签的过程。大多数情况下,一个标记器必须首先在一个训练语料库上训练。如何训练和使用标记器将在第四章第四章.词性标注中详细说明,但首先我们必须知道如何创建和使用词性标注单词的训练语料库。

准备工作

标注语料库的最简单格式是“word/tag”的形式。以下是从brown语料库中摘录的内容:

The/at-tl expense/nn and/cc time/nn involved/vbn are/ber astronomical/jj ./.

每个单词都有一个表示其词性的标记。例如,nn 指名词,而以 vb 开头的标记是动词。

如何做到这一点...

如果你将前面的摘录放入一个名为 brown.pos 的文件中,然后你可以创建一个 TaggedCorpusReader 并执行以下操作:

>>> from nltk.corpus.reader import TaggedCorpusReader
>>> reader = TaggedCorpusReader('.', r'.*\.pos')
>>> reader.words()
['The', 'expense', 'and', 'time', 'involved', 'are', ...]
>>> reader.tagged_words()
[('The', 'AT-TL'), ('expense', 'NN'), ('and', 'CC'), …]
>>> reader.sents()
[['The', 'expense', 'and', 'time', 'involved', 'are', 'astronomical', '.']]
>>> reader.tagged_sents()
[[('The', 'AT-TL'), ('expense', 'NN'), ('and', 'CC'), ('time', 'NN'), ('involved', 'VBN'), ('are', 'BER'), ('astronomical', 'JJ'), ('.', '.')]]
>>> reader.paras()
[[['The', 'expense', 'and', 'time', 'involved', 'are', 'astronomical', '.']]]
>>> reader.tagged_paras()
[[[('The', 'AT-TL'), ('expense', 'NN'), ('and', 'CC'), ('time', 'NN'), ('involved', 'VBN'), ('are', 'BER'), ('astronomical', 'JJ'), ('.', '.')]]]

它是如何工作的...

这次,我们不是明确命名文件,而是使用正则表达式 r'.*\.pos' 来匹配所有以 .pos 结尾的文件。我们本来可以像对 WordListCorpusReader 做的那样,将 ['brown.pos'] 作为第二个参数传递,但这样你可以看到如何在语料库中包含多个文件而不需要明确命名每个文件。

TaggedCorpusReader 提供了多种从语料库中提取文本的方法。首先,你可以获取所有单词的列表,或者标记化标记的列表。一个标记化标记就是一个 (word, tag) 的元组。接下来,你可以获取每个句子的列表,以及每个标记化句子的列表,其中句子本身是一个单词或标记化标记的列表。最后,你可以获取段落列表,其中每个段落是一个句子列表,每个句子是一个单词或标记化标记的列表。以下是一个列出所有主要方法的继承图:

它是如何工作的...

还有更多...

之前图中展示的函数都依赖于 tokenizers 来分割文本。TaggedCorpusReader 尝试使用良好的默认值,但你可以在初始化时传递自己的标记化器来自定义它们。

自定义词标记器

默认单词标记器是 nltk.tokenize.WhitespaceTokenizer 的一个实例。如果你想使用不同的标记器,你可以将其作为 word_tokenizer 传递。

>>> from nltk.tokenize import SpaceTokenizer
>>> reader = TaggedCorpusReader('.', r'.*\.pos', word_tokenizer=SpaceTokenizer())
>>> reader.words()
['The', 'expense', 'and', 'time', 'involved', 'are', ...]

自定义句子标记器

默认句子标记器是 nltk.tokenize.RegexpTokenize 的一个实例,使用 '\n' 来识别间隔。它假设每个句子都单独在一行上,并且单个句子没有换行符。要自定义这一点,你可以传递自己的标记器作为 sent_tokenizer

>>> from nltk.tokenize import LineTokenizer
>>> reader = TaggedCorpusReader('.', r'.*\.pos', sent_tokenizer=LineTokenizer())
>>> reader.sents()
[['The', 'expense', 'and', 'time', 'involved', 'are', 'astronomical', '.']]

自定义段落块读取器

假设段落是通过空白行分隔的。这是通过默认的 para_block_reader 实现的,即 nltk.corpus.reader.util.read_blankline_blocknltk.corpus.reader.util 中有其他许多块读取函数,其目的是从 stream 中读取文本块。它们的用法将在后面的食谱中更详细地介绍,即 创建自定义语料库视图,我们将创建一个自定义语料库读取器。

自定义标记分隔符

如果你不想使用 '/' 作为单词/标记分隔符,你可以传递一个替代字符串给 TaggedCorpusReadersep 参数。默认是 sep='/',但如果你想用 '|' 分隔单词和标记,例如 'word|tag',那么你应该传递 sep='|'

使用标记映射函数简化标记

如果你想要以某种方式转换词性标签,你可以在初始化时传入一个 tag_mapping_function,然后使用 simplify_tags=True 调用一个 tagged_* 函数。以下是一个将每个标签转换为小写的示例:

>>> reader = TaggedCorpusReader('.', r'.*\.pos', tag_mapping_function=lambda t: t.lower())
>>> reader.tagged_words(simplify_tags=True)
[('The', 'at-tl'), ('expense', 'nn'), ('and', 'cc'), …]

不带 simplify_tags=True 调用 tagged_words() 将产生与未传入 tag_mapping_function 相同的结果。

nltk.tag.simplify 中定义了多个标签简化函数。这些函数可以用于减少不同词性标签的数量。

>>> from nltk.tag import simplify
>>> reader = TaggedCorpusReader('.', r'.*\.pos', tag_mapping_function=simplify.simplify_brown_tag)
>>> reader.tagged_words(simplify_tags=True)
[('The', 'DET'), ('expense', 'N'), ('and', 'CNJ'), ...]
>>> reader = TaggedCorpusReader('.', r'.*\.pos', tag_mapping_function=simplify.simplify_tag)
>>> reader.tagged_words(simplify_tags=True)
[('The', 'A'), ('expense', 'N'), ('and', 'C'), ...]

相关内容

第四章,词性标注 将更详细地介绍词性标注和标注。有关分词器的更多信息,请参阅 第一章 的前三个食谱,文本分词和 WordNet 基础知识

在下一个食谱中,我们将创建一个 语块短语 语料库,其中每个短语也被标注了词性。

创建语块短语语料库

语块 是句子中的一个短短语。如果你还记得小学时的句子图,它们是句子中短语的树形表示。这正是语块:句子树中的子树,它们将在 第五章,提取语块 中更详细地介绍。以下是一个包含三个名词短语(NP)语块作为子树的示例句子树形图。

创建语块短语语料库

本食谱将介绍如何创建包含语块的句子语料库。

准备工作

这里是标注过的 treebank 语料库的摘录。它有词性标注,就像之前的食谱一样,但它还有方括号来表示语块。这与之前的树形图中的句子相同,但以文本形式呈现:

[Earlier/JJR staff-reduction/NN moves/NNS] have/VBP trimmed/VBN about/IN [300/CD jobs/NNS] ,/, [the/DT spokesman/NN] said/VBD ./.

在这种格式中,每个语块都是一个 名词短语。不在括号内的单词是句子树的一部分,但不属于任何名词短语子树。

如何做...

将此摘录放入一个名为 treebank.chunk 的文件中,然后执行以下操作:

>>> from nltk.corpus.reader import ChunkedCorpusReader
>>> reader = ChunkedCorpusReader('.', r'.*\.chunk')
>>> reader.chunked_words()
[Tree('NP', [('Earlier', 'JJR'), ('staff-reduction', 'NN'), ('moves', 'NNS')]), ('have', 'VBP'), ...]
>>> reader.chunked_sents()
[Tree('S', [Tree('NP', [('Earlier', 'JJR'), ('staff-reduction', 'NN'), ('moves', 'NNS')]), ('have', 'VBP'), ('trimmed', 'VBN'), ('about', 'IN'), Tree('NP', [('300', 'CD'), ('jobs', 'NNS')]), (',', ','), Tree('NP', [('the', 'DT'), ('spokesman', 'NN')]), ('said', 'VBD'), ('.', '.')])]
>>> reader.chunked_paras()
[[Tree('S', [Tree('NP', [('Earlier', 'JJR'), ('staff-reduction', 'NN'), ('moves', 'NNS')]), ('have', 'VBP'), ('trimmed', 'VBN'), ('about', 'IN'), Tree('NP', [('300', 'CD'), ('jobs', 'NNS')]), (',', ','), Tree('NP', [('the', 'DT'), ('spokesman', 'NN')]), ('said', 'VBD'), ('.', '.')])]]

ChunkedCorpusReader 提供了与 TaggedCorpusReader 相同的方法来获取标注的标记,同时提供了三个新方法来获取语块。每个语块都表示为 nltk.tree.Tree 的一个实例。句子级树形看起来像 Tree('S', [...]),而名词短语树形看起来像 Tree('NP', [...])。在 chunked_sents() 中,你得到一个句子树的列表,其中每个名词短语作为句子的子树。在 chunked_words() 中,你得到一个名词短语树的列表,以及不在语块中的单词的标注标记。以下是一个列出主要方法的继承图:

如何做...

注意

你可以通过调用 draw() 方法来绘制一个 Tree。使用前面定义的语料库读取器,你可以执行 reader.chunked_sents()[0].draw() 来获取与该食谱开头所示相同的句子树形图。

工作原理...

ChunkedCorpusReader 与上一道菜谱中的 TaggedCorpusReader 类似。它具有相同的默认 sent_tokenizerpara_block_reader,但使用 str2chunktree() 函数代替 word_tokenizer。默认为 nltk.chunk.util.tagstr2tree(),它将包含括号内短语的句子字符串解析为句子树,每个短语作为一个名词短语子树。单词通过空格分隔,默认的单词/标签分隔符是 '/'。如果您想自定义短语解析,则可以为 str2chunktree() 传递自己的函数。

更多内容...

表示短语的另一种格式称为 IOB 标签。IOB 标签与词性标签类似,但提供了一种表示短语内部、外部和开始的方法。它们还有允许表示多种不同的短语类型(而不仅仅是名词短语)的优点。以下是 conll2000 语料库的一个摘录。每个单词都在单独的一行上,后面跟着一个词性标签和一个 IOB 标签。

Mr. NNP B-NP
Meador NNP I-NP
had VBD B-VP
been VBN I-VP
executive JJ B-NP
vice NN I-NP
president NN I-NP
of IN B-PP
Balcor NNP B-NP
. . O

B-NP 表示名词短语的开始,而 I-NP 表示该词位于当前名词短语内部。B-VPI-VP 表示动词短语的开始和内部。O 表示句子的结束。

要使用 IOB 格式读取语料库,您必须使用 ConllChunkCorpusReader。每个句子由一个空行分隔,但段落之间没有分隔。这意味着 para_* 方法不可用。如果您将之前的 IOB 示例文本放入名为 conll.iob 的文件中,您可以使用我们即将看到的代码创建并使用 ConllChunkCorpusReaderConllChunkCorpusReader 的第三个参数应该是一个元组或列表,指定文件中的短语类型,在这种情况下是 ('NP', 'VP', 'PP')

>>> from nltk.corpus.reader import ConllChunkCorpusReader
>>> conllreader = ConllChunkCorpusReader('.', r'.*\.iob', ('NP', 'VP', 'PP'))
>>> conllreader.chunked_words()
[Tree('NP', [('Mr.', 'NNP'), ('Meador', 'NNP')]), Tree('VP', [('had', 'VBD'), ('been', 'VBN')]), ...]
>>> conllreader.chunked_sents()
[Tree('S', [Tree('NP', [('Mr.', 'NNP'), ('Meador', 'NNP')]), Tree('VP', [('had', 'VBD'), ('been', 'VBN')]), Tree('NP', [('executive', 'JJ'), ('vice', 'NN'), ('president', 'NN')]), Tree('PP', [('of', 'IN')]), Tree('NP', [('Balcor', 'NNP')]), ('.', '.')])]
>>> conllreader.iob_words()
[('Mr.', 'NNP', 'B-NP'), ('Meador', 'NNP', 'I-NP'), ...]
>>> conllreader.iob_sents()
[[('Mr.', 'NNP', 'B-NP'), ('Meador', 'NNP', 'I-NP'), ('had', 'VBD', 'B-VP'), ('been', 'VBN', 'I-VP'), ('executive', 'JJ', 'B-NP'), ('vice', 'NN', 'I-NP'), ('president', 'NN', 'I-NP'), ('of', 'IN', 'B-PP'), ('Balcor', 'NNP', 'B-NP'), ('.', '.', 'O')]]

之前的代码还展示了 iob_words()iob_sents() 方法,它们返回包含三个元组的列表 (word, pos, iob)ConllChunkCorpusReader 的继承图如下,其中大多数方法由其超类 ConllCorpusReader 实现:

更多内容...

树的叶子

当涉及到短语树时,树的叶子是标记过的标记。因此,如果您想获取树中所有标记过的标记的列表,请调用 leaves() 方法。

>>> reader.chunked_words()[0].leaves()
[('Earlier', 'JJR'), ('staff-reduction', 'NN'), ('moves', 'NNS')]
>>> reader.chunked_sents()[0].leaves()
[('Earlier', 'JJR'), ('staff-reduction', 'NN'), ('moves', 'NNS'), ('have', 'VBP'), ('trimmed', 'VBN'), ('about', 'IN'), ('300', 'CD'), ('jobs', 'NNS'), (',', ','), ('the', 'DT'), ('spokesman', 'NN'), ('said', 'VBD'), ('.', '.')]
>>> reader.chunked_paras()[0][0].leaves()
[('Earlier', 'JJR'), ('staff-reduction', 'NN'), ('moves', 'NNS'), ('have', 'VBP'), ('trimmed', 'VBN'), ('about', 'IN'), ('300', 'CD'), ('jobs', 'NNS'), (',', ','), ('the', 'DT'), ('spokesman', 'NN'), ('said', 'VBD'), ('.', '.')]

树库短语语料库

nltk.corpus.treebank_chunk 语料库使用 ChunkedCorpusReader 来提供华尔街日报标题的词性标注单词和名词短语短语。NLTK 包含了宾夕法尼亚树库项目的 5% 样本。您可以在 www.cis.upenn.edu/~treebank/home.html 获取更多信息。

CoNLL2000 语料库

CoNLL代表计算自然语言学习会议。对于 2000 年的会议,一个共享任务被承担,基于《华尔街日报》语料库生成一个基于块的语料库。除了名词短语(NP)之外,它还包含动词短语(VP)和介词短语(PP)。这个块语料库作为nltk.corpus.conll2000提供,它是ConllChunkCorpusReader的一个实例。你可以了解更多信息在这里

相关内容

第五章,提取块将详细介绍块提取。也可以查看之前的配方,了解从语料库读取器获取标记化标记的详细信息。

创建分类文本语料库

如果你有一个大量的文本语料库,你可能想要将其分类到不同的部分。例如,布朗语料库就有许多不同的类别。

>
>> from nltk.corpus import brown
>>> brown.categories()
['adventure', 'belles_lettres', 'editorial', 'fiction', 'government', 'hobbies', 'humor', 'learned', 'lore', 'mystery', 'news', 'religion', 'reviews', 'romance', 'science_fiction']

在这个配方中,我们将学习如何创建自己的分类文本语料库。

准备工作

将语料库分类的最简单方法是每个类别一个文件。以下是movie_reviews语料库的两个摘录:

movie_pos.txt

the thin red line is flawed but it provokes .

movie_neg.txt

a big-budget and glossy production can not make up for a lack of spontaneity that permeates their tv show .

使用这两个文件,我们将有两个类别:posneg

如何实现...

我们将使用继承自PlaintextCorpusReaderCategorizedCorpusReaderCategorizedPlaintextCorpusReader。这两个超类需要三个参数:根目录、fileids和类别指定。

>>> from nltk.corpus.reader import CategorizedPlaintextCorpusReader
>>> reader = CategorizedPlaintextCorpusReader('.', r'movie_.*\.txt', cat_pattern=r'movie_(\w+)\.txt')
>>> reader.categories()
['neg', 'pos']
>>> reader.fileids(categories=['neg'])
['movie_neg.txt']
>>> reader.fileids(categories=['pos'])
['movie_pos.txt']

它是如何工作的...

CategorizedPlaintextCorpusReader的前两个参数是根目录和fileids,它们被传递给PlaintextCorpusReader以读取文件。cat_pattern关键字参数是从fileids中提取类别名称的正则表达式。在我们的例子中,类别是fileidmovie_之后和.txt之前的部分。类别必须被分组括号包围

cat_pattern被传递给CategorizedCorpusReader,它覆盖了常见的语料库读取器函数,如fileids()words()sents()paras(),以接受一个categories关键字参数。这样,你可以通过调用reader.sents(categories=['pos'])来获取所有pos句子。CategorizedCorpusReader还提供了一个categories()函数,它返回语料库中所有已知类别的列表。

CategorizedPlaintextCorpusReader是使用多继承将多个超类的方法结合起来的一个例子,如下面的图所示:

如何工作...

更多内容...

除了cat_pattern,你也可以传递一个cat_map,它是一个将fileid映射到类别标签列表的字典。

>>> reader = CategorizedPlaintextCorpusReader('.', r'movie_.*\.txt', cat_map={'movie_pos.txt': ['pos'], 'movie_neg.txt': ['neg']})
>>> reader.categories()
['neg', 'pos']

类别文件

指定类别的第三种方式是使用 cat_file 关键字参数来指定一个包含 fileid 到类别映射的文件名。例如,brown 语料库有一个名为 cats.txt 的文件,看起来像这样:

ca44 news
cb01 editorial

reuters 语料库有多个类别的文件,其 cats.txt 看起来像这样:

test/14840 rubber coffee lumber palm-oil veg-oil
test/14841 wheat grain

分类标记语料库读取器

brown 语料库读取器实际上是一个 CategorizedTaggedCorpusReader 的实例,它继承自 CategorizedCorpusReaderTaggedCorpusReader。就像在 CategorizedPlaintextCorpusReader 中一样,它覆盖了 TaggedCorpusReader 的所有方法,以允许一个 categories 参数,因此你可以调用 brown.tagged_sents(categories=['news']) 来获取 news 类别中的所有标记句子。你可以像使用 CategorizedPlaintextCorpusReader 一样使用 CategorizedTaggedCorpusReader 来处理你自己的分类和标记文本语料库。

分类语料库

movie_reviews 语料库读取器是 CategorizedPlaintextCorpusReader 的一个实例,同样 reuters 语料库读取器也是如此。但是,movie_reviews 语料库只有两个类别(negpos),而 reuters 有 90 个类别。这些语料库通常用于训练和评估分类器,这将在 第七章 文本分类 中介绍。

参见

在下一个配方中,我们将创建一个 CategorizedCorpusReaderChunkedCorpusReader 的子类,用于读取分类块语料库。也请参阅 第七章 文本分类,其中我们使用分类文本进行分类。

创建分类块语料库读取器

NLTK 提供了 CategorizedPlaintextCorpusReaderCategorizedTaggedCorpusReader,但没有为块语料库提供分类语料库读取器。因此,在这个配方中,我们将创建一个。

准备工作

参考前面的配方,创建块短语语料库,以了解 ChunkedCorpusReader 的解释,以及前面的配方,以了解 CategorizedPlaintextCorpusReaderCategorizedTaggedCorpusReader 的详细信息,这两个类都继承自 CategorizedCorpusReader

如何做...

我们将创建一个名为 CategorizedChunkedCorpusReader 的类,它继承自 CategorizedCorpusReaderChunkedCorpusReader。它主要基于 CategorizedTaggedCorpusReader,并提供了三个额外的获取分类块的方法。以下代码位于 catchunked.py 中:

from nltk.corpus.reader import CategorizedCorpusReader, ChunkedCorpusReader

class CategorizedChunkedCorpusReader(CategorizedCorpusReader, ChunkedCorpusReader):
  def __init__(self, *args, **kwargs):
    CategorizedCorpusReader.__init__(self, kwargs)
    ChunkedCorpusReader.__init__(self, *args, **kwargs)

  def _resolve(self, fileids, categories):
    if fileids is not None and categories is not None:
      raise ValueError('Specify fileids or categories, not both')
    if categories is not None:
      return self.fileids(categories)
    else:
      return fileids

所有以下方法都调用 ChunkedCorpusReader 中的相应函数,并使用 _resolve() 返回的值。我们将从纯文本方法开始。

  def raw(self, fileids=None, categories=None):
    return ChunkedCorpusReader.raw(self, self._resolve(fileids, categories))
  def words(self, fileids=None, categories=None):
    return ChunkedCorpusReader.words(self, self._resolve(fileids, categories))

  def sents(self, fileids=None, categories=None):
    return ChunkedCorpusReader.sents(self, self._resolve(fileids, categories))

  def paras(self, fileids=None, categories=None):
    return ChunkedCorpusReader.paras(self, self._resolve(fileids, categories))

接下来是标记文本方法。

  def tagged_words(self, fileids=None, categories=None, simplify_tags=False):
    return ChunkedCorpusReader.tagged_words(
      self, self._resolve(fileids, categories), simplify_tags)

  def tagged_sents(self, fileids=None, categories=None, simplify_tags=False):
    return ChunkedCorpusReader.tagged_sents(
      self, self._resolve(fileids, categories), simplify_tags)

  def tagged_paras(self, fileids=None, categories=None, simplify_tags=False):
    return ChunkedCorpusReader.tagged_paras(
      self, self._resolve(fileids, categories), simplify_tags)

最后,是块方法,这是我们真正追求的。

  def chunked_words(self, fileids=None, categories=None):
    return ChunkedCorpusReader.chunked_words(
      self, self._resolve(fileids, categories))

  def chunked_sents(self, fileids=None, categories=None):
    return ChunkedCorpusReader.chunked_sents(
      self, self._resolve(fileids, categories))

  def chunked_paras(self, fileids=None, categories=None):
    return ChunkedCorpusReader.chunked_paras(
      self, self._resolve(fileids, categories))

所有这些方法共同构成了一个完整的 CategorizedChunkedCorpusReader

它是如何工作的...

CategorizedChunkedCorpusReader覆盖了所有ChunkedCorpusReader方法,以接受一个categories参数来定位fileids。这些fileids通过内部_resolve()函数找到。这个_resolve()函数利用CategorizedCorpusReader.fileids()返回给定categories列表的fileids。如果没有提供categories,则_resolve()只返回给定的fileids,这可能是None,在这种情况下,将读取所有文件。CategorizedCorpusReaderChunkedCorpusReader的初始化使得这一切成为可能。如果你查看CategorizedTaggedCorpusReader的代码,你会看到它与它非常相似。继承图如下:

如何工作...

下面是使用treebank语料库的一些示例代码。我们只是将fileids制作成类别,但重点是你可以使用相同的技巧来创建自己的分类分块语料库。

>>> import nltk.data
>>> from catchunked import CategorizedChunkedCorpusReader
>>> path = nltk.data.find('corpora/treebank/tagged')
>>> reader = CategorizedChunkedCorpusReader(path, r'wsj_.*\.pos', cat_pattern=r'wsj_(.*)\.pos')
>>> len(reader.categories()) == len(reader.fileids())
True
>>> len(reader.chunked_sents(categories=['0001']))
16

我们使用nltk.data.find()在数据目录中搜索以获取FileSystemPathPointertreebank语料库。所有以wsj_开头,后跟数字,并以.pos结尾的treebank标记文件。前面的代码将文件编号转换为类别。

更多...

创建分块短语语料库配方中所述,有一个使用 IOB 标签的块语料库的替代格式和读取器。为了有一个分类的 IOB 块语料库,我们必须创建一个新的语料库读取器。

分类 Conll 语料库分块读取器

这是一个名为CategorizedConllChunkCorpusReaderCategorizedCorpusReaderConllChunkReader的子类。它覆盖了所有接受fileids参数的ConllCorpusReader方法,因此这些方法也可以接受categories参数。ConllChunkReader只是ConllCorpusReader的一个小子类,用于处理初始化;大部分工作都在ConllCorpusReader中完成。此代码也可在catchunked.py中找到。

from nltk.corpus.reader import CategorizedCorpusReader, ConllCorpusReader, ConllChunkCorpusReader

class CategorizedConllChunkCorpusReader(CategorizedCorpusReader, ConllChunkCorpusReader):
  def __init__(self, *args, **kwargs):
    CategorizedCorpusReader.__init__(self, kwargs)
    ConllChunkCorpusReader.__init__(self, *args, **kwargs)

  def _resolve(self, fileids, categories):
    if fileids is not None and categories is not None:
      raise ValueError('Specify fileids or categories, not both')
    if categories is not None:
      return self.fileids(categories)
    else:
      return fileids

所有以下方法都调用ConllCorpusReader的相应方法,并使用从_resolve()返回的值。我们将从纯文本方法开始。

  def raw(self, fileids=None, categories=None):
    return ConllCorpusReader.raw(self, self._resolve(fileids, categories))

  def words(self, fileids=None, categories=None):
    return ConllCorpusReader.words(self, self._resolve(fileids, categories))

  def sents(self, fileids=None, categories=None):
    return ConllCorpusReader.sents(self, self._resolve(fileids, categories))

ConllCorpusReader不识别段落,因此没有*_paras()方法。接下来是标记和分块的方法。

  def tagged_words(self, fileids=None, categories=None):
    return ConllCorpusReader.tagged_words(self, self._resolve(fileids, categories))

  def tagged_sents(self, fileids=None, categories=None):
    return ConllCorpusReader.tagged_sents(self, self._resolve(fileids, categories))

  def chunked_words(self, fileids=None, categories=None, chunk_types=None):
    return ConllCorpusReader.chunked_words(
      self, self._resolve(fileids, categories), chunk_types)

  def chunked_sents(self, fileids=None, categories=None, chunk_types=None):
    return ConllCorpusReader.chunked_sents(
      self, self._resolve(fileids, categories), chunk_types)

为了完整性,我们必须覆盖ConllCorpusReader的以下方法:

  def parsed_sents(self, fileids=None, categories=None, pos_in_tree=None):
    return ConllCorpusReader.parsed_sents(
      self, self._resolve(fileids, categories), pos_in_tree)

  def srl_spans(self, fileids=None, categories=None):
    return ConllCorpusReader.srl_spans(self, self._resolve(fileids, categories))

  def srl_instances(self, fileids=None, categories=None, pos_in_tree=None, flatten=True):
    return ConllCorpusReader.srl_instances(
      self, self._resolve(fileids, categories), pos_in_tree, flatten)

  def iob_words(self, fileids=None, categories=None):
    return ConllCorpusReader.iob_words(self, self._resolve(fileids, categories))

  def iob_sents(self, fileids=None, categories=None):
    return ConllCorpusReader.iob_sents(self, self._resolve(fileids, categories))

该类的继承图如下:

分类 Conll 分块语料库读取器

下面是使用conll2000语料库的一些示例代码。与treebank一样,我们使用fileids作为类别。ConllChunkCorpusReader需要一个第三个参数来指定chunk_types。这些chunk_types用于解析 IOB 标签。正如你在创建分块短语语料库配方中学到的,conll2000语料库识别三种分块类型:

  • NP表示名词短语

  • VP表示动词短语

  • PP表示介词短语

>>> import nltk.data
>>> from catchunked import CategorizedConllChunkCorpusReader
>>> path = nltk.data.find('corpora/conll2000')
>>> reader = CategorizedConllChunkCorpusReader(path, r'.*\.txt', ('NP','VP','PP'), cat_pattern=r'(.*)\.txt')
>>> reader.categories()
['test', 'train']
>>> reader.fileids()
['test.txt', 'train.txt']
>>> len(reader.chunked_sents(categories=['test']))
2012

参见

在本章的 创建分块短语语料库 食谱中,我们介绍了 ChunkedCorpusReaderConllChunkCorpusReader。在前一个食谱中,我们介绍了 CategorizedPlaintextCorpusReaderCategorizedTaggedCorpusReader,它们与 CategorizedChunkedCorpusReaderCategorizedConllChunkReader 使用的相同超类 CategorizedCorpusReader 具有相同的超类。

懒惰语料库加载

由于文件数量、文件大小和多种初始化任务,加载语料库读取器可能是一个昂贵的操作。虽然你通常会想在公共模块中指定语料库读取器,但你并不总是需要立即访问它。为了在定义语料库读取器时加快模块导入时间,NLTK 提供了一个 LazyCorpusLoader 类,它可以在你需要时立即将自身转换为实际的语料库读取器。这样,你可以在公共模块中定义语料库读取器,而不会减慢模块加载速度。

如何操作...

LazyCorpusLoader 需要两个参数:语料库的 name 和语料库读取器类,以及初始化语料库读取器类所需的任何其他参数。

name 参数指定语料库的根目录名称,它必须位于 nltk.data.path 中某个路径的 corpora 子目录内。有关 nltk.data.path 的更多详细信息,请参阅本章的第一个食谱,设置自定义语料库

例如,如果你在你的本地 nltk_data 目录中有一个名为 cookbook 的自定义语料库,它的路径将是 ~/nltk_data/corpora/cookbook。然后,你会将 'cookbook' 传递给 LazyCorpusLoader 作为 nameLazyCorpusLoader 将在 ~/nltk_data/corpora 中查找名为 'cookbook' 的目录。

LazyCorpusLoader 的第二个参数是 reader_cls,它应该是 CorpusReader 的子类的名称,例如 WordListCorpusReader。你还需要传递 reader_cls 初始化所需的任何其他参数。以下将演示如何使用我们之前在 创建单词列表语料库 食谱中创建的相同 wordlist 文件,这将展示 LazyCorpusLoader 的第三个参数,即将在初始化时传递给 WordListCorpusReader 的文件名和 fileids 列表。

>>> from nltk.corpus.util import LazyCorpusLoader
>>> from nltk.corpus.reader import WordListCorpusReader
>>> reader = LazyCorpusLoader('cookbook', WordListCorpusReader, ['wordlist'])
>>> isinstance(reader, LazyCorpusLoader)
True
>>> reader.fileids()
['wordlist']
>>> isinstance(reader, LazyCorpusLoader)
False
>>> isinstance(reader, WordListCorpusReader)
True

它是如何工作的...

LazyCorpusLoader 存储了所有给出的参数,但在你尝试访问属性或方法之前,它不会做任何事情。这样初始化非常快,消除了立即加载语料库读取器的开销。一旦你访问了属性或方法,它就会执行以下操作:

  1. 调用 nltk.data.find('corpora/%s' % name) 来查找语料库数据根目录。

  2. 使用根目录和任何其他参数实例化语料库读取器类。

  3. 将自身转换为语料库读取类。

因此,在之前的示例代码中,在我们调用 reader.fileids() 之前,readerLazyCorpusLoader 的一个实例,但在调用之后,readerWordListCorpusReader 的一个实例。

还有更多...

NLTK 中包含的所有语料库和定义在nltk.corpus中的语料库最初都是LazyCorpusLoader的一个实例。以下是从nltk.corpus定义treebank语料库的代码。

treebank = LazyCorpusLoader(

    'treebank/combined', BracketParseCorpusReader, r'wsj_.*\.mrg',

    tag_mapping_function=simplify_wsj_tag)

treebank_chunk = LazyCorpusLoader(

    'treebank/tagged', ChunkedCorpusReader, r'wsj_.*\.pos',

    sent_tokenizer=RegexpTokenizer(r'(?<=/\.)\s*(?![^\[]*\])', gaps=True),

    para_block_reader=tagged_treebank_para_block_reader)

treebank_raw = LazyCorpusLoader(

    'treebank/raw', PlaintextCorpusReader, r'wsj_.*')

如您所见,可以通过LazyCorpusLoader通过reader_cls传递任意数量的附加参数。

创建自定义语料库视图

语料库视图是一个围绕语料库文件的类包装器,它按需读取标记块。其目的是在不一次性读取整个文件的情况下(因为语料库文件通常相当大)提供对文件的“视图”。如果 NLTK 中包含的语料库读取器已经满足您的所有需求,那么您不需要了解任何关于语料库视图的知识。但是,如果您有一个需要特殊处理的自定义文件格式,这个菜谱将向您展示如何创建和使用自定义语料库视图。主要的语料库视图类是StreamBackedCorpusView,它将单个文件作为打开,并维护它已读取的块的内缓存。

使用块读取器函数读取标记块。一个可以是任何文本片段,例如一个段落或一行,而标记是块的一部分,例如单个单词。在创建词性标注词语料库菜谱中,我们讨论了TaggedCorpusReader的默认para_block_reader函数,该函数从文件中读取行,直到找到空白行,然后返回这些行作为单个段落标记。实际的块读取器函数是:nltk.corpus.reader.util.read_blankline_blockTaggedCorpusReader在需要从文件中读取块时将此块读取器函数传递给TaggedCorpusViewTaggedCorpusViewStreamBackedCorpusView的一个子类,知道如何将“word/tag”段落分割成(word, tag)元组。

如何做到这一点...

我们将从需要被语料库读取器忽略的标题的纯文本文件开始。让我们创建一个名为heading_text.txt的文件,其外观如下:

A simple heading

Here is the actual text for the corpus.

Paragraphs are split by blanklines.

This is the 3rd paragraph.

通常我们会使用PlaintextCorpusReader,但默认情况下,它将A simple heading视为第一段。为了忽略这个标题,我们需要对PlaintextCorpusReader进行子类化,这样我们就可以用我们自己的StreamBackedCorpusView子类覆盖其CorpusView类变量。这段代码位于corpus.py中。

from nltk.corpus.reader import PlaintextCorpusReader
from nltk.corpus.reader.util import StreamBackedCorpusView

class IgnoreHeadingCorpusView(StreamBackedCorpusView):
  def __init__(self, *args, **kwargs):
    StreamBackedCorpusView.__init__(self, *args, **kwargs)
    # open self._stream
    self._open()
    # skip the heading block
    self.read_block(self._stream)
    # reset the start position to the current position in the stream
    self._filepos = [self._stream.tell()]

class IgnoreHeadingCorpusReader(PlaintextCorpusReader):
  CorpusView = IgnoreHeadingCorpusView

为了证明这按预期工作,以下代码显示了默认的PlaintextCorpusReader找到了四个段落,而我们的IgnoreHeadingCorpusReader只有三个段落。

>>> from nltk.corpus.reader import PlaintextCorpusReader
>>> plain = PlaintextCorpusReader('.', ['heading_text.txt'])
>>> len(plain.paras())
4
>>> from corpus import IgnoreHeadingCorpusReader
>>> reader = IgnoreHeadingCorpusReader('.', ['heading_text.txt'])
>>> len(reader.paras())
3

它是如何工作的...

PlaintextCorpusReader设计上有一个可以被子类覆盖的CorpusView类变量。所以我们就是这样做的,并使我们的IgnoreHeadingCorpusView成为CorpusView

注意

大多数语料库读取器没有CorpusView类变量,因为它们需要非常特定的语料库视图。

IgnoreHeadingCorpusViewStreamBackedCorpusView的一个子类,在初始化时执行以下操作:

  1. 使用self._open()打开文件。此函数由StreamBackedCorpusView定义,并将内部实例变量self._stream设置为打开的文件。

  2. 使用read_blankline_block()读取一个块,它将读取标题作为段落,并将流的文件位置向前移动到下一个块。

  3. 将文件起始位置重置为self._stream的当前位置。self._filepos是文件中每个块的内部索引。

下面是一个说明类之间关系的图示:

如何工作...

还有更多...

语料库视图可以变得更加复杂和花哨,但其核心概念是相同的:从stream中读取以返回一个标记列表。nltk.corpus.reader.util中提供了一些块读取器,但您始终可以创建自己的。如果您确实想定义自己的块读取器函数,那么您有两种实现方式:

  1. 将其定义为单独的函数,并将其作为block_reader传递给StreamBackedCorpusView。如果您的块读取器相对简单、可重用且不需要任何外部变量或配置,这是一个不错的选择。

  2. 继承StreamBackedCorpusView类并重写read_block()方法。这是因为块读取非常专业化,需要额外的函数和配置,通常在初始化语料库视图时由语料库读取器提供。

块读取器函数

下面是对nltk.corpus.reader.util中包含的大多数块读取器的一个概述。除非另有说明,否则每个块读取器函数只接受一个参数:要从中读取的stream

  • read_whitespace_block()将从流中读取 20 行,通过空白字符将每行分割成标记。

  • read_wordpunct_block()从流中读取 20 行,使用nltk.tokenize.wordpunct_tokenize()分割每行。

  • read_line_block()从流中读取 20 行,并将它们作为列表返回,每行作为一个标记。

  • read_blankline_block()将从流中读取行,直到找到空白行。然后,它将返回所有找到的行合并成一个字符串的单个标记。

  • read_regexp_block()需要两个额外的参数,这些参数必须是可以通过re.match()传递的正则表达式:start_reend_restart_re匹配块的起始行,而end_re匹配块的结束行。end_re默认为None,在这种情况下,块将在找到新的start_re匹配时结束。返回值是所有行合并成一个字符串的单个标记。

Pickle 语料库视图

如果你想要一个包含序列化对象的语料库,你可以使用PickleCorpusView,它是位于nltk.corpus.reader.util中的StreamBackedCorpusView的子类。一个文件由序列化对象的块组成,可以使用PickleCorpusView.write()类方法创建,该方法接受一个对象序列和一个输出文件,然后使用pickle.dump()将每个对象序列化并写入文件。它覆盖了read_block()方法,使用pickle.load()从流中返回一个未序列化对象的列表。

连接语料库视图

nltk.corpus.reader.util中还可以找到ConcatenatedCorpusView。如果你有多个文件,希望语料库读取器将其视为单个文件,这个类很有用。ConcatenatedCorpusView是通过提供一个corpus_views列表来创建的,然后像单个视图一样迭代这些视图。

参见

块读取器的概念在本章的创建词性标注词语料库食谱中引入。

创建 MongoDB 支持的语料库读取器

到目前为止,我们处理的所有语料库读取器都是基于文件的。这在一定程度上是由于CorpusReader基类的设计,以及大多数语料库数据将存储在文本文件中的假设。但有时你会有大量存储在数据库中的数据,你希望像访问和使用文本文件语料库一样访问和使用这些数据。在本食谱中,我们将介绍你有存储在 MongoDB 中的文档,并且希望使用每个文档的特定字段作为你的文本块的情况。

准备工作

MongoDB 是一个面向文档的数据库,它已成为 MySQL 等关系数据库的流行替代品。MongoDB 的安装和设置超出了本书的范围,但你可以在www.mongodb.org/display/DOCS/Quickstart找到说明。

你还需要安装 PyMongo,它是 MongoDB 的 Python 驱动程序。你可以通过easy_installpip来完成此操作,方法是执行sudo easy_install pymongosudo pip install pymongo

如何做到...部分中的代码假设你的数据库位于localhost端口27017,这是 MongoDB 的默认配置,并且你将使用名为corpus的集合,该集合包含具有text字段的文档。这些参数的解释可以在 PyMongo 文档中找到,网址为api.mongodb.org/python/

如何做到...

由于CorpusReader类假设你有一个基于文件的语料库,所以我们不能直接继承它。相反,我们将模拟StreamBackedCorpusViewPlaintextCorpusReaderStreamBackedCorpusViewnltk.util.AbstractLazySequence的子类,因此我们将继承AbstractLazySequence来创建一个 MongoDB 视图,然后创建一个新的类,该类将使用视图提供类似于PlaintextCorpusReader的功能。此代码位于mongoreader.py中。

import pymongo
from nltk.data import LazyLoader
from nltk.tokenize import TreebankWordTokenizer
from nltk.util import AbstractLazySequence, LazyMap, LazyConcatenation

class MongoDBLazySequence(AbstractLazySequence):
  def __init__(self, host='localhost', port=27017, db='test', collection='corpus', field='text'):
    self.conn = pymongo.Connection(host, port)
    self.collection = self.conn[db][collection]
    self.field = field

  def __len__(self):
    return self.collection.count()

  def iterate_from(self, start):
    f = lambda d: d.get(self.field, '')
    return iter(LazyMap(f, self.collection.find(fields=[self.field], skip=start)))

class MongoDBCorpusReader(object):
  def __init__(self, word_tokenizer=TreebankWordTokenizer(),
         sent_tokenizer=LazyLoader('tokenizers/punkt/english.pickle'),
         **kwargs):
    self._seq = MongoDBLazySequence(**kwargs)
    self._word_tokenize = word_tokenizer.tokenize
    self._sent_tokenize = sent_tokenizer.tokenize

  def text(self):
    return self._seq

  def words(self):
    return LazyConcatenation(LazyMap(self._word_tokenize, self.text()))

  def sents(self):
    return LazyConcatenation(LazyMap(self._sent_tokenize, self.text()))

它是如何工作的...

AbstractLazySequence是一个抽象类,它提供了只读的按需迭代。子类必须实现__len__()iterate_from(start)方法,同时它提供了列表和迭代器模拟的其余方法。通过创建MongoDBLazySequence子类作为我们的视图,我们可以在按需迭代 MongoDB 集合中的文档,而不需要将所有文档都保存在内存中。LazyMap是 Python 内置的map()函数的懒加载版本,并在iterate_from()中使用,以将文档转换为感兴趣的特定字段。它也是一个AbstractLazySequence的子类。

MongoDBCorpusReader为迭代创建了一个MongoDBLazySequence的内部实例,然后定义了单词和句子分词方法。text()方法简单地返回MongoDBLazySequence的实例,从而得到每个文本字段的懒加载列表。words()方法使用LazyMapLazyConcatenation返回所有单词的懒加载列表,而sents()方法对句子执行相同的操作。sent_tokenizer通过LazyLoader按需加载,LazyLoadernltk.data.load()的包装器,类似于LazyCorpusLoaderLazyConcatentation也是一个AbstractLazySequence的子类,它从给定的列表列表(每个列表也可以是懒加载的)中生成一个扁平列表。在我们的情况下,我们通过连接LazyMap的结果来确保我们不返回嵌套列表。

还有更多...

所有参数都是可配置的。例如,如果您有一个名为db的数据库,其collection名为comments,文档中有一个名为comment的字段,您可以创建一个MongoDBCorpusReader如下:

>>> reader = MongoDBCorpusReader(db='website', collection='comments', field='comment')

您也可以为word_tokenizersent_tokenizer传递自定义实例,只要这些对象通过提供tokenize(text)方法实现了nltk.tokenize.TokenizerI接口。

参考以下内容

在之前的菜谱中已经介绍了语料库视图,而在第一章中介绍了分词,文本分词和 WordNet 基础知识

使用文件锁定进行语料库编辑

语料库读取器和视图都是只读的,但有时您可能想要添加或编辑语料库文件。然而,当其他进程正在使用它时,例如通过语料库读取器,修改语料库文件可能会导致危险的不确定行为。这时文件锁定就派上用场了。

准备工作

您必须使用sudo easy_install lockfilesudo pip install lockfile命令安装lockfile库。这个库提供跨平台的文件锁定功能,因此可以在 Windows、Unix/Linux、Mac OS X 等操作系统上工作。您可以在http://packages.python.org/lockfile/ g/lockfile/找到关于lockfile的详细文档。

为了使以下代码能够正常工作,您还必须安装 Python 2.6。2.4 版本及更早的版本不支持with关键字。

如何操作...

这里有两个文件编辑功能:append_line()remove_line()。这两个函数在更新文件之前都会尝试获取一个 独占锁。独占锁意味着这些函数将等待直到没有其他进程正在读取或写入文件。一旦获取了锁,任何尝试访问文件的进程都必须等待直到锁被释放。这样,修改文件将是安全的,并且不会在其他进程中引起任何未定义的行为。这些函数可以在 corpus.py 中找到。

import lockfile, tempfile, shutil

def append_line(fname, line):
  with lockfile.FileLock(fname):
    fp = open(fname, 'a+')
    fp.write(line)
    fp.write('\n')
    fp.close()

def remove_line(fname, line):

  with lockfile.FileLock(fname):
    tmp = tempfile.TemporaryFile()
    fp = open(fname, 'r+')
    # write all lines from orig file, except if matches given line
    for l in fp:
      if l.strip() != line:
        tmp.write(l)

    # reset file pointers so entire files are copied
    fp.seek(0)
    tmp.seek(0)
    # copy tmp into fp, then truncate to remove trailing line(s)
    shutil.copyfileobj(tmp, fp)
    fp.truncate()
    fp.close()
    tmp.close()

当您使用 with lockfile.FileLock(fname) 时,锁的获取和释放是透明发生的。

注意

除了使用 with lockfile.FileLock(fname),您还可以通过调用 lock = lockfile.FileLock(fname) 来获取锁,然后调用 lock.acquire() 来获取锁,以及调用 lock.release() 来释放锁。这种替代用法与 Python 2.4 兼容。

它是如何工作的...

您可以使用以下功能:

>>> from corpus import append_line, remove_line
>>> append_line('test.txt', 'foo')
>>> remove_line('test.txt', 'foo')

append_line() 中,获取锁后,文件以 追加模式 打开,文本与换行符一起写入,然后关闭文件,释放锁。

小贴士

lockfile 获取的锁只能保护文件免受其他也使用 lockfile 的进程的影响。换句话说,仅仅因为您的 Python 进程使用 lockfile 有锁,并不意味着非 Python 进程不能修改文件。因此,最好只使用 lockfile 与那些不会被任何非 Python 进程编辑的文件,或者不使用 lockfile 的 Python 进程。

remove_line() 函数稍微复杂一些。因为我们是在删除一行而不是文件的具体部分,所以我们需要遍历文件以找到要删除的行的每个实例。在将更改写回文件时,最简单的方法是使用 TemporaryFile 来保存更改,然后使用 shutil.copyfileobj() 将该文件复制回原始文件。

这些函数最适合用于单词列表语料库,或者某些其他具有可能唯一行的语料库类型,这些语料库可能由多人同时编辑,例如通过网页界面。使用这些函数与更面向文档的语料库,如 browntreebankconll2000,可能是一个糟糕的主意。

第四章 词性分词

在本章中,我们将介绍:

  • 默认分词

  • 训练单词词性分词器

  • 将分词器与回退分词结合

  • 训练和组合 Ngram 分词器

  • 创建可能的单词标记模型

  • 使用正则表达式进行分词

  • 词缀分词

  • 训练 Brill 分词器

  • 训练 TnT 分词器

  • 使用 WordNet 进行分词

  • 分词专有名词

  • 基于分类器的分词

简介

词性分词 是将句子(以单词列表的形式)转换为元组列表的过程,其中每个元组的形式为 (word, tag)标记 是词性标记,表示单词是名词、形容词、动词等。

我们将要介绍的多数分词器都是可训练的。它们使用标记过的句子列表作为训练数据,例如从 TaggedCorpusReadertagged_sents() 函数中获取的数据(参见第三章 创建自定义语料库 中的 创建词性标记的词语语料库 菜单,更多详情请参阅 创建自定义语料库)。有了这些训练句子,分词器会生成一个内部模型,告诉它们如何标记一个单词。其他分词器使用外部数据源或匹配单词模式来为单词选择一个标记。

NLTK 中的所有分词器都在 nltk.tag 包中,并继承自 TaggerI 基类。TaggerI 要求所有子类实现一个 tag() 方法,该方法接收一个单词列表作为输入,并返回一个标记过的单词列表作为输出。TaggerI 还提供了一个 evaluate() 方法来评估分词器的准确性(在 默认分词 菜单的末尾介绍)。许多分词器也可以组合成一个回退链,这样如果某个分词器无法标记一个单词,则使用下一个分词器,依此类推。

词性分词是 短语提取 之前的一个必要步骤,短语提取 在第五章 提取短语 中介绍。没有词性标记,短语提取器无法知道如何从句子中提取短语。但是有了词性标记,你可以告诉短语提取器如何根据标记模式来识别短语。

默认分词

默认分词为词性分词提供了一个基线。它简单地将相同的词性标记分配给每个标记。我们使用 DefaultTagger 来完成这个操作。

准备工作

我们将使用 treebank 语料库来完成本章的大部分内容,因为它是一个常见的标准,加载和测试都很快速。但我们所做的一切都应该同样适用于 brownconll2000 和任何其他词性标记语料库。

如何操作...

DefaultTagger 接收一个单一参数——你想要应用的标记。我们将给它 'NN',这是单数名词的标记。

>>> from nltk.tag import DefaultTagger
>>> tagger = DefaultTagger('NN')
>>> tagger.tag(['Hello', 'World'])
[('Hello', 'NN'), ('World', 'NN')]

每个标签器都有一个tag()方法,它接受一个标记列表,其中每个标记是一个单词。这个标记列表通常是由单词分词器(见第一章,Tokenizing Text and WordNet Basics 中有关分词的更多信息)生成的单词列表。正如你所看到的,tag()返回一个标记标记列表,其中标记标记是一个(word, tag)元组。

如何工作...

DefaultTaggerSequentialBackoffTagger的子类。SequentialBackoffTagger的每个子类都必须实现choose_tag()方法,该方法接受三个参数:

  1. tokens的列表。

  2. 当前标记的index,我们想要选择其标签。

  3. history,它是一个先前标签的列表。

SequentialBackoffTagger实现了tag()方法,该方法对标记列表中的每个索引调用子类的choose_tag(),同时累积先前标记的标记历史。这就是SequentialBackoffTagger中“Sequential”的原因。我们将在Combining taggers with backoff tagging配方中介绍名称中的Backoff部分。以下是一个显示继承树的图表:

如何工作...

DefaultTaggerchoose_tag()方法非常简单——它返回我们在初始化时给出的标签。它不关心当前标记或历史记录。

更多内容...

你可以为DefaultTagger分配很多不同的标签。你可以在www.ling.upenn.edu/courses/Fall_2003/ling001/penn_treebank_pos.html找到treebank语料库中所有可能标签的完整列表。这些标签也在附录 Penn Treebank Part-of-Speech Tags 中进行了说明。

评估准确性

要了解标签器的准确性,你可以使用evaluate()方法,该方法接受一个标记标记列表作为黄金标准来评估标签器。使用我们之前创建的默认标签器,我们可以将其与treebank语料库的标记句子子集进行比较。

>>> from nltk.corpus import treebank
>>> test_sents = treebank.tagged_sents()[3000:]
>>> tagger.evaluate(test_sents)
0.14331966328512843

因此,只需为每个标签选择'NN',我们就可以在treebank语料库的四分之一上实现 14%的准确性测试。我们将在未来的配方中重复使用这些相同的test_sents来评估更多的标签器。

批量标签化句子

TaggerI还实现了一个batch_tag()方法,可以用来对一系列句子进行标签化,而不是单个句子。以下是对两个简单句子进行标签化的示例:

>>> tagger.batch_tag([['Hello', 'world', '.'], ['How', 'are', 'you', '?']])
[[('Hello', 'NN'), ('world', 'NN'), ('.', 'NN')], [('How', 'NN'), ('are', 'NN'), ('you', 'NN'), ('?', 'NN')]]

结果是两个已标记句子的列表,当然,每个标签都是NN,因为我们正在使用DefaultTagger。如果你有很多句子需要一次性进行标签化,batch_tag()方法非常有用。

解除已标记句子的标签

可以使用nltk.tag.untag()来解除已标记句子的标签。调用此函数并传入一个已标记句子将返回一个不带标签的单词列表。

>>> from nltk.tag import untag
>>> untag([('Hello', 'NN'), ('World', 'NN')])
['Hello', 'World']

参考信息

关于分词的更多信息,请参阅第一章,分词和 WordNet 基础知识。要了解更多关于标记句子的信息,请参阅第三章中的创建自定义语料库配方。要查看在树库语料库中找到的所有词性标记的完整列表,请参阅附录,Penn Treebank 词性标记

训练单语词性标记器

单语通常指单个标记。因此,单语标记器仅使用单个单词作为其上下文来确定词性标记。

UnigramTagger继承自NgramTagger,而NgramTaggerContextTagger的子类,ContextTagger继承自SequentialBackoffTagger。换句话说,UnigramTagger是一个基于上下文的标记器,其上下文是一个单词,或单语。

如何操作...

UnigramTagger可以通过在初始化时提供标记句子列表来训练。

>>> from nltk.tag import UnigramTagger
>>> from nltk.corpus import treebank
>>> train_sents = treebank.tagged_sents()[:3000]
>>> tagger = UnigramTagger(train_sents)
>>> treebank.sents()[0]
['Pierre', 'Vinken', ',', '61', 'years', 'old', ',', 'will', 'join', 'the', 'board', 'as', 'a', 'nonexecutive', 'director', 'Nov.', '29', '.']
>>> tagger.tag(treebank.sents()[0])
[('Pierre', 'NNP'), ('Vinken', 'NNP'), (',', ','), ('61', 'CD'), ('years', 'NNS'), ('old', 'JJ'), (',', ','), ('will', 'MD'), ('join', 'VB'), ('the', 'DT'), ('board', 'NN'), ('as', 'IN'), ('a', 'DT'), ('nonexecutive', 'JJ'), ('director', 'NN'), ('Nov.', 'NNP'), ('29', 'CD'), ('.', '.')]

我们使用treebank语料库的前 3,000 个标记句子作为训练集来初始化UnigramTagger。然后我们将第一句话视为单词列表,并可以看到它是如何通过tag()函数转换为标记标记列表的。

工作原理...

UnigramTagger从标记句子列表中构建一个上下文模型。因为UnigramTagger继承自ContextTagger,所以它必须实现一个context()方法,该方法接受与choose_tag()相同的三个参数。在这种情况下,context()的结果是单词标记。上下文标记用于创建模型,并在模型创建后查找最佳标记。以下是一个继承图,显示了从SequentialBackoffTagger开始的每个类:

工作原理...

让我们看看UnigramTagger在测试句子上的准确率如何(参见前面的配方了解test_sents是如何创建的)。

>>> tagger.evaluate(test_sents)
0.85763004532700193

对于仅使用单个单词查找来确定词性标记的标记器来说,其准确率高达 86%。从现在开始,所有准确率的提升都将非常小。

更多内容...

模型构建实际上是在ContextTagger中实现的。给定标记句子列表,它计算每个上下文中标记出现的频率。上下文中频率最高的标记存储在模型中。

覆盖上下文模型

所有继承自ContextTagger的标记器都可以使用预构建的模型,而不是自己训练。这个模型只是一个将上下文键映射到标记的 Python dict。上下文键将取决于ContextTagger子类从其context()方法返回的内容。对于UnigramTagger,上下文键是单个单词。但对于其他NgramTagger子类,上下文键将是元组。

这里有一个例子,我们向UnigramTagger传递一个非常简单的模型,而不是训练集:

>>> tagger = UnigramTagger(model={'Pierre': 'NN'})
>>> tagger.tag(treebank.sents()[0])
[('Pierre', 'NN'), ('Vinken', None), (',', None), ('61', None), ('years', None), ('old', None), (',', None), ('will', None), ('join', None), ('the', None), ('board', None), ('as', None), ('a', None), ('nonexecutive', None),('director', None), ('Nov.', None), ('29', None), ('.', None)]

由于模型只包含上下文键'Pierre',因此只有第一个单词得到了标签。由于上下文词不在模型中,其他每个单词的标签都是None。所以除非你确切知道你在做什么,否则让标记器自己训练模型,而不是传递你自己的模型。

将自定义模型传递给UnigramTagger的一个好例子是当你有一个单词和标签的字典,并且你知道每个单词都应该始终映射到其标签。然后,你可以将这个UnigramTagger作为你的第一个回退标记器(在下一个菜谱中介绍),以查找无歧义单词的标签。

最小频率截止值

ContextTagger使用出现频率来决定给定上下文中最可能的标签。默认情况下,即使上下文词和标签只出现一次,它也会这样做。如果你想设置一个最小频率阈值,那么你可以向UnigramTagger传递一个cutoff值。

>>> tagger = UnigramTagger(train_sents, cutoff=3)
>>> tagger.evaluate(test_sents)
0.775350744657889

在这种情况下,使用cutoff=3降低了准确率,但有时设置截止值可能是个好主意。

参见

在下一个菜谱中,我们将介绍回退标记以结合标记器。在创建可能的单词标签模型菜谱中,我们将学习如何统计地确定非常常见单词的标签。

结合标记器与回退标记

回退标记SequentialBackoffTagger的核心功能之一。它允许你将标记器链接在一起,以便如果一个标记器不知道如何标记一个单词,它可以将其传递给下一个回退标记器。如果那个标记器也不能这样做,它可以将其传递给下一个回退标记器,依此类推,直到没有回退标记器可以检查。

如何做到这一点...

SequentialBackoffTagger的每个子类都可以接受一个backoff关键字参数,其值是另一个SequentialBackoffTagger实例。因此,我们将使用默认标记菜谱中的DefaultTagger作为训练单语词性标记器菜谱中的UnigramTaggerbackoff。请参阅这两个菜谱以了解train_sentstest_sents的详细信息。

>>> tagger1 = DefaultTagger('NN')
>>> tagger2 = UnigramTagger(train_sents, backoff=tagger1)
>>> tagger2.evaluate(test_sents)
0.87459529462551266

UnigramTagger无法对单词进行标记时,我们使用默认标签NN,从而将准确率提高了近 2%!

它是如何工作的...

SequentialBackoffTagger初始化时,它会创建一个包含自身作为第一个元素的内部回退标记器列表。如果提供了一个backoff标记器,那么回退标记器的内部标记器列表将被附加。以下是一些代码示例来说明这一点:

>>> tagger1._taggers == [tagger1]
True
>>> tagger2._taggers == [tagger2, tagger1]
True

_taggersSequentialBackoffTagger在调用tag()方法时使用的内部回退标记器列表。它遍历其标记器列表,对每个标记器调用choose_tag()。一旦找到标记,它就停止并返回该标记。这意味着如果主要标记器可以标记单词,那么返回的将是该标记。但如果返回None,则尝试下一个标记器,依此类推,直到找到标记,或者返回None。当然,如果您的最终回退标记器是DefaultTagger,则永远不会返回None

还有更多...

虽然 NLTK 中包含的大多数标记器都是SequentialBackoffTagger的子类,但并非所有都是。在后面的菜谱中,我们将介绍一些标记器,它们不能作为回退标记链的一部分使用,例如BrillTagger。然而,这些标记器通常需要另一个标记器作为基线,而SequentialBackoffTagger通常是一个很好的选择。

序列化和反序列化训练好的标记器

由于训练标记器可能需要一段时间,而且您通常只需要进行一次训练,因此将训练好的标记器进行序列化是一个有用的方法来保存它以供以后使用。如果您的训练好的标记器名为tagger,那么以下是如何使用pickle进行序列化和反序列化的方法:

>>> import pickle
>>> f = open('tagger.pickle', 'w')
>>> pickle.dump(tagger, f)
>>> f.close()
>>> f = open('tagger.pickle', 'r')
>>> tagger = pickle.load(f)

如果您的标记器 pickle 文件位于 NLTK 数据目录中,您也可以使用nltk.data.load('tagger.pickle')来加载标记器。

参见

在下一个菜谱中,我们将结合更多的标记器与回退标记。同时,查看前两个菜谱以获取关于DefaultTaggerUnigramTagger的详细信息。

训练和组合 Ngram 标记器

除了UnigramTagger之外,还有两个额外的NgramTagger子类:BigramTaggerTrigramTaggerBigramTagger使用前一个标记作为其上下文的一部分,而TrigramTagger使用前两个标记。ngramn个项的子序列,因此BigramTagger查看两个项(前一个标记和单词),而TrigramTagger查看三个项。

这两个标记器擅长处理词性标记依赖于上下文的单词。许多单词的词性根据其使用方式不同而不同。例如,我们一直在谈论标记单词的标记器。在这种情况下,“标记”被用作动词。但标记的结果是词性标记,因此“标记”也可以作为名词。NgramTagger子类中的想法是,通过查看前一个单词和词性标记,我们可以更好地猜测当前单词的词性标记。

准备工作

请参阅本章的前两个菜谱,以获取构建train_sentstest_sents的详细信息。

如何操作...

单独使用时,BigramTaggerTrigramTagger的表现相当差。这主要是因为它们无法从句子的第一个单词(s)中学习上下文。

>>> from nltk.tag import BigramTagger, TrigramTagger
>>> bitagger = BigramTagger(train_sents)
>>> bitagger.evaluate(test_sents)
0.11336067342974315
>>> tritagger = TrigramTagger(train_sents)
>>> tritagger.evaluate(test_sents)
0.0688107058061731

它们可以做出贡献的地方在于,当我们将它们与回退标记结合使用时。这次,我们不会单独创建每个标记器,而是创建一个函数,该函数将接受train_sents、一个SequentialBackoffTagger类列表和一个可选的最终回退标记器,然后使用上一个标记器作为回退来训练每个标记器。以下是来自tag_util.py的代码:

def backoff_tagger(train_sents, tagger_classes, backoff=None):
  for cls in tagger_classes:
    backoff = cls(train_sents, backoff=backoff)
  return backoff

使用它,我们可以这样做:

>>> from tag_util import backoff_tagger
>>> backoff = DefaultTagger('NN')
>>> tagger = backoff_tagger(train_sents, [UnigramTagger, BigramTagger, TrigramTagger], backoff=backoff)
>>> tagger.evaluate(test_sents)
0.88163177206993304

因此,通过在回退链中包含BigramTaggerTrigramTagger,我们几乎提高了 1%的准确率。对于除treebank之外的其他语料库,准确率的提高可能更为显著。

它是如何工作的...

backoff_tagger函数在列表中的每个标记器类中创建一个实例,给它train_sents和上一个标记器作为回退。标记器类列表的顺序非常重要——列表中的第一个类将首先进行训练,并得到初始回退标记器。然后,该标记器将成为列表中下一个标记器类的回退标记器。返回的最后一个标记器将是列表中最后一个标记器类的实例。以下是一些澄清此链的代码:

>>> tagger._taggers[-1] == backoff
True
>>> isinstance(tagger._taggers[0], TrigramTagger)
True
>>> isinstance(tagger._taggers[1], BigramTagger)
True

因此,我们最终得到一个TrigramTagger,其第一个回退是BigramTagger。然后下一个回退将是UnigramTagger,其回退是DefaultTagger

还有更多...

backoff_tagger函数不仅与NgramTagger类一起工作。它可以用于构建包含任何SequentialBackoffTagger子类的链。

BigramTaggerTrigramTagger,因为它们是NgramTaggerContextTagger的子类,也可以接受一个模型和截断参数,就像UnigramTagger一样。但与UnigramTagger不同,模型的上下文键必须是 2 元组,其中第一个元素是历史的一部分,第二个元素是当前标记。对于BigramTagger,适当上下文键看起来像((prevtag,), word),而对于TrigramTagger,它看起来像((prevtag1, prevtag2), word)

四元组标记器

NgramTagger类可以单独使用来创建一个标记器,该标记器使用超过三个 Ngrams 作为其上下文键。

>>> from nltk.tag import NgramTagger
>>> quadtagger = NgramTagger(4, train_sents)
>>> quadtagger.evaluate(test_sents)
0.058191236779624435

它甚至比TrigramTagger还要糟糕!这里有一个QuadgramTagger的替代实现,我们可以将其包含在backoff_tagger列表中。此代码可在taggers.py中找到:

from nltk.tag import NgramTagger

class QuadgramTagger(NgramTagger):
  def __init__(self, *args, **kwargs):
    NgramTagger.__init__(self, 4, *args, **kwargs)

这基本上是BigramTaggerTrigramTagger的实现方式;它们是NgramTagger的简单子类,通过在context()方法的history参数中传递要查看的ngrams数量。

现在我们来看看它作为回退链的一部分是如何表现的:

>>> from taggers import QuadgramTagger
>>> quadtagger = backoff_tagger(train_sents, [UnigramTagger, BigramTagger, TrigramTagger, QuadgramTagger], backoff=backoff)
>>> quadtagger.evaluate(test_sents)
0.88111374919058927

实际上,当我们停止使用TrigramTagger时,它比之前稍微差一点。所以教训是,过多的上下文可能会对准确率产生负面影响。

参见

前两个配方涵盖了UnigramTagger和回退标记。

创建可能的词标记模型

如本章 训练一个一元词性标注器 菜谱中提到的,使用自定义模型与 UnigramTagger 结合使用仅应在确切了解自己在做什么的情况下进行。在这个菜谱中,我们将为最常见的单词创建一个模型,其中大部分单词无论什么情况下总是有相同的标记。

如何做到这一点...

要找到最常见的单词,我们可以使用 nltk.probability.FreqDist 来统计 treebank 语料库中的单词频率。然后,我们可以为标记过的单词创建一个 ConditionalFreqDist,其中我们统计每个单词每个标记的频率。使用这些计数,我们可以构建一个以 200 个最频繁单词作为键,每个单词的最频繁标记作为值的模型。以下是在 tag_util.py 中定义的模型创建函数:

from nltk.probability import FreqDist, ConditionalFreqDist

def word_tag_model(words, tagged_words, limit=200):
  fd = FreqDist(words)
  most_freq = fd.keys()[:limit]
  cfd = ConditionalFreqDist(tagged_words)
  return dict((word, cfd[word].max()) for word in most_freq)

要与 UnigramTagger 一起使用,我们可以这样做:

>>> from tag_util import word_tag_model
>>> from nltk.corpus import treebank
>>> model = word_tag_model(treebank.words(), treebank.tagged_words())
>>> tagger = UnigramTagger(model=model)
>>> tagger.evaluate(test_sents)
0.55972372113101665

准确率接近 56% 还可以,但远远不如训练好的 UnigramTagger。让我们尝试将其添加到我们的回退链中:

>>> default_tagger = DefaultTagger('NN')
>>> likely_tagger = UnigramTagger(model=model, backoff=default_tagger)
>>> tagger = backoff_tagger(train_sents, [UnigramTagger, BigramTagger, TrigramTagger], backoff=likely_tagger)
>>> tagger.evaluate(test_sents)
0.88163177206993304

最终的准确率与没有 likely_tagger 时完全相同。这是因为我们为了创建模型而进行的频率计算几乎与训练 UnigramTagger 时发生的情况完全相同。

它是如何工作的...

word_tag_model() 函数接受所有单词的列表、所有标记单词的列表以及我们想要用于模型的最大单词数。我们将单词列表传递给 FreqDist,该 FreqDist 统计每个单词的频率。然后,我们通过调用 fd.keys()FreqDist 中获取前 200 个单词,该函数返回按最高频率到最低频率排序的所有单词。我们将标记单词列表传递给 ConditionalFreqDist,该 ConditionalFreqDist 为每个单词创建一个标记的 FreqDist,其中单词作为 条件。最后,我们返回一个字典,将前 200 个单词映射到它们最可能的标记。

还有更多...

看起来包含这个标注器似乎没有用处,因为它没有改变准确率。但这个菜谱的目的是演示如何为 UnigramTagger 构建一个有用的模型。自定义模型构建是一种创建手动覆盖训练标注器(否则是黑盒子)的方法。通过将可能的标注器放在链的前端,我们实际上可以略微提高准确率:

>>> tagger = backoff_tagger(train_sents, [UnigramTagger, BigramTagger, TrigramTagger], backoff=default_tagger)
>>> likely_tagger = UnigramTagger(model=model, backoff=tagger)
>>> likely_tagger.evaluate(test_sents)
0.88245197496222749

将自定义模型标注器放在回退链的前端,可以让你完全控制特定单词的标注方式,同时让训练好的标注器处理其他所有事情。

参见

训练一个一元词性标注器 菜单中详细介绍了 UnigramTagger 和一个简单的自定义模型示例。有关回退标注的详细信息,请参阅之前的菜谱 结合回退标注的标注器训练和组合 Ngram 标注器

使用正则表达式进行标注

你可以使用正则表达式匹配来标记单词。例如,你可以使用 \d 匹配数字,分配标签 CD(指的是 基数词)。或者你可以匹配已知的单词模式,例如后缀 "ing"。这里有很多灵活性,但要注意不要过度指定,因为语言本身是不精确的,并且总有例外。

准备工作

为了使这个配方有意义,你应该熟悉正则表达式语法和 Python 的 re 模块。

如何做到这一点...

RegexpTagger 预期的是一个包含 2-元组的列表,其中元组的第一个元素是一个正则表达式,第二个元素是标签。以下模式可以在 tag_util.py 中找到:

patterns = [
  (r'^\d+$', 'CD'),
  (r'.*ing$', 'VBG'), # gerunds, i.e. wondering
  (r'.*ment$', 'NN'), # i.e. wonderment
  (r'.*ful$', 'JJ') # i.e. wonderful
]

一旦构建了这个模式列表,就可以将其传递给 RegexpTagger

>>> from tag_util import patterns
>>> from nltk.tag import RegexpTagger
>>> tagger = RegexpTagger(patterns)
>>> tagger.evaluate(test_sents)
0.037470321605870924

所以仅凭几个模式并不太出色,但既然 RegexpTaggerSequentialBackoffTagger 的子类,它可以作为回退链的一部分很有用,特别是如果你能够想出更多的单词模式。

它是如何工作的...

RegexpTagger 保存了初始化时给出的 patterns,然后在每次调用 choose_tag() 时,它会遍历这些模式,并使用 re.match() 返回与当前单词匹配的第一个表达式的标签。这意味着如果有两个表达式可以匹配,第一个表达式的标签将始终返回,第二个表达式甚至不会被尝试。

还有更多...

如果你提供一个模式,例如 (r'.*', 'NN')RegexpTagger 可以替换 DefaultTagger。这个模式当然应该在模式列表的末尾,否则其他模式将不会匹配。

参见

在下一个配方中,我们将介绍 AffixTagger,它学习如何根据单词的前缀和后缀进行标记。并且查看 默认标记 配方以了解 DefaultTagger 的详细信息。

前缀标记

AffixTagger 是另一个 ContextTagger 子类,但这次上下文是单词的 前缀后缀。这意味着 AffixTagger 能够根据单词开头或结尾的固定长度子串来学习标签。

如何做到这一点...

AffixTagger 的默认参数指定了三个字符的后缀,并且单词长度至少为五个字符。如果一个单词的长度小于五个字符,那么返回的标签将是 None

>>> from nltk.tag import AffixTagger
>>> tagger = AffixTagger(train_sents)
>>> tagger.evaluate(test_sents)
0.27528599179797109

所以它使用默认参数本身做得还不错。让我们通过指定三个字符的前缀来试一试:

>>> prefix_tagger = AffixTagger(train_sents, affix_length=3)
>>> prefix_tagger.evaluate(test_sents)
0.23682279300669112

要在双字符后缀上学习,代码看起来是这样的:

>>> suffix_tagger = AffixTagger(train_sents, affix_length=-2)
>>> suffix_tagger.evaluate(test_sents)
0.31953377940859057

它是如何工作的...

affix_length 的正值意味着 AffixTagger 将学习单词前缀,本质上就是 word[:affix_length]。如果 affix_length 是负值,那么将使用 word[affix_length:] 来学习后缀。

还有更多...

如果你想学习多个字符长度的前缀,可以将多个 affix taggers 结合在一个回退链中。以下是一个示例,四个 AffixTagger 类学习两个和三个字符的前缀和后缀:

>>> pre3_tagger = AffixTagger(train_sents, affix_length=3)
>>> pre3_tagger.evaluate(test_sents)
0.23682279300669112
>>> pre2_tagger = AffixTagger(train_sents, affix_length=2, backoff=pre3_tagger)
>>> pre2_tagger.evaluate(test_sents)
0.29816533563565722
>>> suf2_tagger = AffixTagger(train_sents, affix_length=-2, backoff=pre2_tagger)
>>> suf2_tagger.evaluate(test_sents)
0.32523203108137277
>>> suf3_tagger = AffixTagger(train_sents, affix_length=-3, backoff=suf2_tagger)
>>> suf3_tagger.evaluate(test_sents)
0.35924886682495144

如您所见,每次训练后准确性都会提高。

注意

前面的顺序既不是最好的,也不是最差的。我将把它留给你去探索可能性,并发现最佳的 AffixTaggeraffix_length 值的回退链。

最小词干长度

AffixTagger 也接受一个具有默认值 2min_stem_length 关键字参数。如果单词长度小于 min_stem_length 加上 affix_length 的绝对值,则 context() 方法返回 None。增加 min_stem_length 会迫使 AffixTagger 只在较长的单词上学习,而减少 min_stem_length 将允许它在较短的单词上学习。当然,对于较短的单词,affix_length 可能等于或大于单词长度,此时 AffixTagger 实际上就像一个 UnigramTagger

参见

您可以使用正则表达式手动指定前缀和后缀,如前一个食谱所示。训练单语素词性标注器训练和组合 Ngram 标注器 食谱中有关于 NgramTagger 子类的详细信息,这些子类也是 ContextTagger 的子类。

训练 Brill 标签器

BrillTagger 是一个基于转换的标注器。它是第一个不是 SequentialBackoffTagger 子类的标注器。相反,BrillTagger 使用一系列规则来纠正初始标注器的结果。这些规则根据它们纠正的错误数量减去它们产生的错误数量来评分。

如何操作...

这里是一个来自 tag_util.py 的函数,它使用 FastBrillTaggerTrainer 训练 BrillTagger。它需要一个 initial_tagger 和一个 train_sents 列表。

from nltk.tag import brill

def train_brill_tagger(initial_tagger, train_sents, **kwargs):
  sym_bounds = [(1,1), (2,2), (1,2), (1,3)]
  asym_bounds = [(-1,-1), (1,1)]
  templates = [
  brill.SymmetricProximateTokensTemplate(brill.ProximateTagsRule, *sym_bounds),
    brill.SymmetricProximateTokensTemplate(brill.ProximateWordsRule, *sym_bounds),
    brill.ProximateTokensTemplate(brill.ProximateTagsRule, *asym_bounds),
    brill.ProximateTokensTemplate(brill.ProximateWordsRule, *asym_bounds)
  ]
  trainer = brill.FastBrillTaggerTrainer(initial_tagger, templates, deterministic=True)
  return trainer.train(train_sents, **kwargs)

要使用它,我们可以从 NgramTagger 类的回退链中创建我们的 initial_tagger,然后将它传递给 train_brill_tagger() 函数以获取 BrillTagger

>>> default_tagger = DefaultTagger('NN')
>>> initial_tagger = backoff_tagger(train_sents, [UnigramTagger, BigramTagger, TrigramTagger], backoff=default_tagger)
>>> initial_tagger.evaluate(test_sents)
0.88163177206993304
>>> from tag_util import train_brill_tagger
>>> brill_tagger = train_brill_tagger(initial_tagger, train_sents)
>>> brill_tagger.evaluate(test_sents)
0.88327217785452194

因此,BrillTaggerinitial_tagger 上略微提高了准确性。

如何工作...

FastBrillTaggerTrainer 接受一个 initial_tagger 和一个 templates 列表。这些模板必须实现 BrillTemplateI 接口。NLTK 中包含的两个模板实现是 ProximateTokensTemplateSymmetricProximateTokensTemplate。每个模板都用于生成 BrillRule 子类的列表。实际生成的规则类在初始化时传递给模板。基本工作流程如下:

如何工作...

使用的两个 BrillRule 子类是 ProximateTagsRuleProximateWordsRule,它们都是 ProximateTokensRule 的子类。ProximateTagsRule 通过查看周围的标签来进行错误纠正,而 ProximateWordsRule 通过查看周围的单词。

我们传递给每个模板的 边界(start, end) 元组的列表,这些元组作为 条件 传递给每个规则。条件告诉规则它可以查看哪些标记。例如,如果条件是 (1, 1),则规则将只查看下一个标记。但如果条件是 (1, 2),则规则将查看下一个标记和它后面的标记。对于 (-1, -1),规则将只查看前面的标记。

ProximateTokensTemplate 生成 ProximateTokensRule,它会检查每个标记的给定条件以进行错误纠正。必须显式指定正负条件。另一方面,SymmetricProximateTokensTemplate 生成一对 ProximateTokensRule,其中一条规则使用给定条件,另一条规则使用条件的负值。因此,当我们向 SymmetricProximateTokensTemplate 传递一个包含正 (start, end) 元组的列表时,它也会生成一个使用 (-start, -end)ProximateTokensRule。这就是它为什么是 对称的——它生成检查标记两边的规则。

注意

ProximateTokensTemplate 不同,你不应该给 SymmetricProximateTokensTemplate 提供负边界,因为它会自行生成。仅使用正数边界与 SymmetricProximateTokensTemplate

更多...

您可以使用 FastBrillTaggerTrainer.train() 方法的 max_rules 关键字参数来控制生成的规则数量。默认值是 200。您还可以使用 min_score 关键字参数来控制使用的规则质量。默认值是 2,尽管 3 也是一个不错的选择。

注意

增加 max_rulesmin_score 将大大增加训练时间,但不一定提高准确性。请谨慎更改这些值。

跟踪

您可以通过将 trace=1 传递给构造函数来观察 FastBrillTaggerTrainer 的工作。这可以给出如下输出:

Training Brill tagger on 3000 sentences...

    Finding initial useful rules...

        Found 10709 useful rules.

    Selecting rules...

这意味着它找到了至少 min_score 分数的 10709 条规则,然后它选择最佳规则,保留不超过 max_rules

默认是 trace=0,这意味着训练器将静默工作,不打印其状态。

参考也

“训练和组合 Ngram 标签器”配方详细说明了之前使用的 initial_tagger 的构建,而“默认标签”配方解释了 default_tagger

训练 TnT 标签器

TnT 代表 Trigrams'n'Tags。它是一个基于二阶马尔可夫模型的统计标签器。您可以在 acl.ldc.upenn.edu/A/A00/A00-1031.pdf 阅读导致其实施的原始论文。

如何做...

TnT 标签器的 API 与我们之前遇到的标签器略有不同。创建它之后,你必须显式调用 train() 方法。以下是一个基本示例:

>>> from nltk.tag import tnt
>>> tnt_tagger = tnt.TnT()
>>> tnt_tagger.train(train_sents)
>>> tnt_tagger.evaluate(test_sents)
0.87580401467731495

它本身就是一个相当好的标记器,只比之前配方中的 BrillTagger 稍微不准确。但如果你在 evaluate() 之前没有调用 train(),你将得到 0% 的准确率。

它是如何工作的...

TnT 根据训练数据维护了多个内部 FreqDistConditionalFreqDist 实例。这些频率分布计算单语元、双语元和三元语。然后,在标记过程中,使用频率来计算每个单词可能标记的概率。因此,而不是构建 NgramTagger 子类的回退链,TnT 标记器使用所有 n-gram 模型一起选择最佳标记。它还尝试一次性猜测整个句子的标签,通过选择整个句子最可能的模型,基于每个可能标记的概率。

注意

训练相当快,但标记的速度比我们之前提到的其他标记器慢得多。这是因为必须进行所有浮点数学计算,以计算每个单词的标记概率。

还有更多...

TnT 接受一些可选的关键字参数。你可以传递一个用于未知单词的标记器作为 unk。如果这个标记器已经训练过,那么你也必须传递 Trained=True。否则,它将使用与传递给 train() 方法的相同数据调用 unk.train(data)。由于之前的标记器都没有公开的 train() 方法,我们建议如果你也传递了一个 unk 标记器,总是传递 Trained=True。以下是一个使用 DefaultTagger 的示例,它不需要任何训练:

>>> from nltk.tag import DefaultTagger
>>> unk = DefaultTagger('NN')
>>> tnt_tagger = tnt.TnT(unk=unk, Trained=True)
>>> tnt_tagger.train(train_sents)
>>> tnt_tagger.evaluate(test_sents)
0.89272609540254699

因此,我们的准确率提高了近 2%!你必须使用一个可以在之前没有见过该单词的情况下标记单个单词的标记器。这是因为未知标记器的 tag() 方法只与单个单词的句子一起调用。其他好的未知标记器候选人是 RegexpTaggerAffixTagger。传递一个在相同数据上训练过的 UnigramTagger 几乎没有用处,因为它将看到完全相同的单词,因此有相同的未知单词盲点。

控制束搜索

你可以修改 TnT 的另一个参数是 N,它控制标记器在尝试猜测句子标签时保持的可能解决方案的数量。N 的默认值是 1,000。增加它将大大增加标记过程中使用的内存量,但并不一定增加准确性。减少 N 将减少内存使用,但可能会降低准确性。以下是将 N 设置为 100 时发生的情况:

>>> tnt_tagger = tnt.TnT(N=100)
>>> tnt_tagger.train(train_sents)
>>> tnt_tagger.evaluate(test_sents)
0.87580401467731495

因此,准确率完全相同,但我们使用显著更少的内存来实现它。然而,不要假设减少 N 不会改变准确率;通过在自己的数据上实验来确保。

大写字母的重要性

如果你希望单词的大小写有意义,可以传递C=True。默认是C=False,这意味着所有单词都转换为小写。关于C的文档表示,将大小写视为重要可能不会提高准确性。在我的测试中,使用C=True时准确性略有增加(< 0.01%),这可能是由于大小写敏感性可以帮助识别专有名词。

参见

我们在默认标记配方中介绍了DefaultTagger,在结合标记和回退标记配方中介绍了回退标记,在训练单语词性标记器训练组合 Ngram 标记器配方中介绍了NgramTagger子类,在使用正则表达式标记配方中介绍了RegexpTagger,在词缀标记配方中介绍了AffixTagger

使用 WordNet 进行标记

如果你记得第一章中关于“在 Wordnet 中查找一个词的 synsets”的配方,WordNet synsets 指定了一个词性标签。这是一个非常有限的标签集合,许多词有多个 synsets,具有不同的词性标签,但这些信息对于标记未知词可能是有用的。WordNet 本质上是一个巨大的字典,它可能包含许多不在你的训练数据中的词。

准备工作

首先,我们需要决定如何将 WordNet 词性标签映射到我们一直在使用的宾州树库词性标签。以下是一个将一个映射到另一个的表格。有关更多详细信息,请参阅第一章中关于“在 Wordnet 中查找一个词的 synsets”的配方,文本分词和 WordNet 基础知识。其中,“s”,之前未展示,只是另一种形容词,至少在标记目的上是这样。

WordNet 标签 Treebank 标签
n NN
a JJ
s JJ
r RB
v VB

如何实现...

现在,我们可以创建一个类,该类将在 WordNet 中查找单词,然后从它找到的 synsets 中选择最常见的标签。下面定义的WordNetTagger可以在taggers.py中找到:

from nltk.tag import SequentialBackoffTagger
from nltk.corpus import wordnet
from nltk.probability import FreqDist

class WordNetTagger(SequentialBackoffTagger):
  '''
  >>> wt = WordNetTagger()
  >>> wt.tag(['food', 'is', 'great'])
  [('food', 'NN'), ('is', 'VB'), ('great', 'JJ')]
  '''
  def __init__(self, *args, **kwargs):
    SequentialBackoffTagger.__init__(self, *args, **kwargs)
    self.wordnet_tag_map = {
      'n': 'NN',
      's': 'JJ',
      'a': 'JJ',
      'r': 'RB',
      'v': 'VB'
    }
  def choose_tag(self, tokens, index, history):
    word = tokens[index]
    fd = FreqDist()
    for synset in wordnet.synsets(word):
      fd.inc(synset.pos)
    return self.wordnet_tag_map.get(fd.max())

它是如何工作的...

WordNetTagger简单地计算一个词的 synsets 中每个词性标签的数量。然后,最常见的标签通过内部映射映射到一个treebank标签。以下是一些示例用法代码:

>>> from taggers import WordNetTagger
>>> wn_tagger = WordNetTagger()
>>> wn_tagger.evaluate(train_sents)
0.18451574615215904

所以这并不太准确,但这是可以预料的。我们只有足够的信息来产生四种不同的标签,而treebank中有 36 种可能的标签。而且许多词根据其上下文可以有不同的词性标签。但如果我们将WordNetTagger放在NgramTagger回退链的末尾,那么我们可以提高DefaultTagger的准确性。

>>> from tag_util import backoff_tagger
>>> from nltk.tag import UnigramTagger, BigramTagger, TrigramTagger
>>> tagger = backoff_tagger(train_sents, [UnigramTagger, BigramTagger, TrigramTagger], backoff=wn_tagger)
>>> tagger.evaluate(test_sents)
0.88564644938484782

参见

第一章 文本分词和 WordNet 基础 中的 查找 Wordnet 中的单词同义词集 配方详细介绍了如何使用 wordnet 语料库以及它了解的词性标签类型。在 结合标签器和回退标记训练和组合 Ngram 标签器 配方中,我们讨论了使用 ngram 标签器的回退标记。

标记专有名词

使用包含的 names 语料库,我们可以创建一个简单的标签器,用于将名称标记为 专有名词

如何实现...

NamesTaggerSequentialBackoffTagger 的子类,因为它可能只在对冲链的末尾有用。在初始化时,我们创建了一个包含 names 语料库中所有名称的集合,并将每个名称转换为小写以简化查找。然后我们实现了 choose_tag() 方法,该方法简单地检查当前单词是否在 names_set 中。如果是,我们返回标签 NNP(这是 专有名词 的标签)。如果不是,我们返回 None,以便链中的下一个标签器可以对单词进行标记。以下代码可以在 taggers.py 中找到:

from nltk.tag import SequentialBackoffTagger
from nltk.corpus import names

class NamesTagger(SequentialBackoffTagger):
  def __init__(self, *args, **kwargs):
    SequentialBackoffTagger.__init__(self, *args, **kwargs)
    self.name_set = set([n.lower() for n in names.words()])
  def choose_tag(self, tokens, index, history):
    word = tokens[index]
    if word.lower() in self.name_set:
      return 'NNP'
    else:
      return None

它是如何工作的...

NamesTagger 应该相当直观。其用法也很简单:

>>> from taggers import NamesTagger
>>> nt = NamesTagger()
>>> nt.tag(['Jacob'])
[('Jacob', 'NNP')]

可能最好在 DefaultTagger 之前使用 NamesTagger,因此它位于对冲链的末尾。但它可能可以在链中的任何位置,因为它不太可能错误地标记单词。

参见

结合标签器和回退标记 的配方详细介绍了使用 SequentialBackoffTagger 子类的细节。

基于分类器的标记

ClassifierBasedPOSTagger 使用 分类 来进行词性标注。特征从单词中提取,然后传递给内部分类器。分类器对特征进行分类并返回一个标签;在这种情况下,是一个词性标签。分类将在第七章 文本分类 中详细介绍。

ClassifierBasedPOSTaggerClassifierBasedTagger 的子类,它实现了一个 特征检测器,将先前标签器中的许多技术结合成一个单一的 特征集。特征检测器找到多个长度后缀,进行一些正则表达式匹配,并查看一元组、二元组和三元组历史记录以生成每个单词的相当完整的一组特征。它生成的特征集用于训练内部分类器,并用于将单词分类为词性标签。

如何实现...

ClassifierBasedPOSTagger 的基本用法与其他 SequentialBackoffTaggger 类似。您传入训练句子,它训练一个内部分类器,然后您得到一个非常准确的标签器。

>>> from nltk.tag.sequential import ClassifierBasedPOSTagger
>>> tagger = ClassifierBasedPOSTagger(train=train_sents)
>>> tagger.evaluate(test_sents)
0.93097345132743359

注意

注意初始化的微小修改——train_sents 必须作为 train 关键字参数传递。

它是如何工作的...

ClassifierBasedPOSTagger 继承自 ClassifierBasedTagger 并仅实现一个 feature_detector() 方法。所有的训练和标记都在 ClassifierBasedTagger 中完成。它默认使用给定的训练数据训练一个 NaiveBayesClassifier。一旦这个分类器被训练,它就被用来分类由 feature_detector() 方法产生的单词特征。

注意

ClassifierBasedTagger 通常是最准确的标记器,但也是最慢的标记器之一。如果速度是一个问题,你应该坚持使用基于 NgramTagger 子类和其它简单标记器的 BrillTagger

ClassifierBasedTagger 还继承自 FeatursetTaggerI(它只是一个空类),创建了一个看起来像这样的继承树:

工作原理...

还有更多...

你可以通过传递自己的 classifier_builder 函数来使用不同的分类器而不是 NaiveBayesClassifier。例如,要使用 MaxentClassifier,你会这样做:

>>> from nltk.classify import MaxentClassifier
>>> me_tagger = ClassifierBasedPOSTagger(train=train_sents, classifier_builder=MaxentClassifier.train)
>>> me_tagger.evaluate(test_sents)
0.93093028275415501

注意

MaxentClassifier 的训练时间甚至比 NaiveBayesClassifier 更长。如果你已经安装了 scipynumpy,训练将比正常更快,但仍然比 NaiveBayesClassifier 慢。

自定义特征检测器

如果你想自己进行特征检测,有两种方法可以做到。

  1. 子类 ClassifierBasedTagger 并实现一个 feature_detector() 方法。

  2. 在初始化 ClassifierBasedTagger 时,将一个方法作为 feature_detector 关键字参数传递。

无论哪种方式,你需要一个可以接受与 choose_tag() 相同参数的特征检测方法:tokensindexhistory。但是,你返回的是一个键值特征的 dict,其中键是特征名称,值是特征值。一个非常简单的例子是一个单语素特征检测器(在 tag_util.py 中找到)。

def unigram_feature_detector(tokens, index, history):
  return {'word': tokens[index]}

然后使用第二种方法,你会将以下内容传递给 ClassifierBasedTagger 作为 feature_detector

>>> from nltk.tag.sequential import ClassifierBasedTagger
>>> from tag_util import unigram_feature_detector
>>> tagger = ClassifierBasedTagger(train=train_sents, feature_detector=unigram_feature_detector)
>>> tagger.evaluate(test_sents)
0.87338657457371038

截断概率

因为分类器总是会返回它能提供的最佳结果,所以除非你同时传递一个 cutoff_prob 来指定分类的概率阈值,否则传递一个回退标记器是没有用的。然后,如果所选标记的概率小于 cutoff_prob,则使用回退标记器。以下是一个使用 DefaultTagger 作为回退,并将 cutoff_prob 设置为 0.3 的示例:

>>> default = DefaultTagger('NN')
>>> tagger = ClassifierBasedPOSTagger(train=train_sents, backoff=default, cutoff_prob=0.3)
>>> tagger.evaluate(test_sents)
0.93110295704726964

因此,如果 ClassifierBasedPOSTagger 在其标记概率小于 30% 时始终使用 DefaultTagger,则我们可以得到略微提高的准确度。

预训练分类器

如果你想要使用一个已经训练好的分类器,那么你可以将其传递给 ClassifierBasedTaggerClassifierBasedPOSTagger 作为 classifier。在这种情况下,classifier_builder 参数被忽略,并且不会进行训练。然而,你必须确保该分类器已经使用你使用的任何 feature_detector() 方法训练过,并且可以分类特征集。

参见

第七章,文本分类将深入探讨分类问题。

第五章:提取块

在本章中,我们将涵盖:

  • 使用正则表达式进行块处理和切分

  • 使用正则表达式合并和拆分块

  • 使用正则表达式扩展和删除块

  • 使用正则表达式进行部分解析

  • 训练基于标签器的块提取器

  • 基于分类的块处理

  • 提取命名实体

  • 提取专有名词块

  • 提取地点块

  • 训练命名实体块提取器

简介

块提取部分解析是从词性标注句子中提取短语的进程。这与完整解析不同,因为我们感兴趣的是独立的短语,而不是完整的解析树。想法是通过简单地寻找特定的词性标签模式,可以从句子中提取有意义的短语。

如同第四章中所述的词性标注,我们将使用宾州树库语料库进行基本训练和测试块提取。我们还将使用 CoNLL 2000 语料库,因为它具有更简单、更灵活的格式,支持多种块类型(有关 conll2000 语料库和 IOB 标记的更多详细信息,请参阅第三章中的创建块短语语料库配方)。

使用正则表达式进行块处理和切分

使用修改后的正则表达式,我们可以定义块模式。这些是定义构成块单词类型的词性标签模式。我们还可以定义不应在块中的单词类型的模式。这些未块化的单词被称为切分

ChunkRule 指定要包含在块中的内容,而 ChinkRule 指定要从块中排除的内容。换句话说,块处理创建块,而切分则将这些块拆分。

准备工作

我们首先需要知道如何定义块模式。这些是经过修改的正则表达式,旨在匹配词性标签序列。单个标签通过括号指定,例如 <NN> 以匹配名词标签。然后可以将多个标签组合起来,例如 <DT><NN> 以匹配一个限定词后跟一个名词。在括号内可以使用正则表达式语法来匹配单个标签模式,因此您可以使用 <NN.*> 来匹配包括 NNNNS 在内的所有名词。您还可以在括号外使用正则表达式语法来匹配标签的模式。<DT>?<NN.*>+ 将匹配一个可选的限定词后跟一个或多个名词。块模式通过 tag_pattern2re_pattern() 函数内部转换为正则表达式:

>>> from nltk.chunk import tag_pattern2re_pattern
>>> tag_pattern2re_pattern('<DT>?<NN.*>+')
'(<(DT)>)?(<(NN[^\\{\\}<>]*)>)+'

您不必使用此功能进行块处理,但查看您的块模式如何转换为正则表达式可能是有用或有趣的。

如何操作...

指定一个块的模式是使用周围的括号,例如{<DT><NN>}。要指定一个 chink,您需要翻转括号,如}<VB>{。这些规则可以组合成特定短语类型的语法。下面是一个名词短语的语法,它结合了块和 chink 模式,以及解析句子"The book has many chapters"的结果:

>>> from nltk.chunk import RegexpParser
>>> chunker = RegexpParser(r'''
... NP:
...    {<DT><NN.*><.*>*<NN.*>}
...    }<VB.*>{
... ''')
>>> chunker.parse([('the', 'DT'), ('book', 'NN'), ('has', 'VBZ'), ('many', 'JJ'), ('chapters', 'NNS')])
Tree('S', [Tree('NP', [('the', 'DT'), ('book', 'NN')]), ('has', 'VBZ'), Tree('NP', [('many', 'JJ'), ('chapters', 'NNS')])])

语法告诉RegexpParser存在两个解析NP块规则。第一个块模式表示块以一个限定词开始,后面跟任何类型的名词。然后允许任何数量的其他单词,直到找到一个最后的名词。第二个模式表示动词应该被chinked,从而分离包含动词的任何大块。结果是包含两个名词短语块的树:"the book"和"many chapters"。

注意

标记的句子总是被解析成一个Tree(在nltk.tree模块中找到)。Tree的顶层节点是"S",代表句子。找到的任何块都将作为子树,其节点将引用块类型。在这种情况下,块类型是"NP"代表名词短语。可以通过调用draw()方法来绘制树,例如t.draw()

如何工作...

下面是逐步发生的事情:

  1. 句子被转换成一个扁平的Tree,如下面的图所示:如何工作...

  2. 使用Tree创建一个ChunkString

  3. RegexpParser解析语法以创建具有给定规则的NP RegexpChunkParser

  4. 创建并应用于ChunkStringChunkRule,将整个句子匹配到一个块中,如下面的图所示:如何工作...

  5. 创建并应用于相同的ChunkStringChinkRule,将大块分成两个较小的块,中间有一个动词,如下面的图所示:如何工作...

  6. ChunkString被转换回一个Tree,现在有两个NP块子树,如下面的图所示:如何工作...

您可以使用nltk.chunk.regexp中的类自己完成这项操作。ChunkRuleChinkRule都是RegexpChunkRule的子类,需要两个参数:模式和规则的描述。ChunkString是一个以扁平树开始的对象,然后通过将其传递到规则的apply()方法时修改每个规则。ChunkString可以通过to_chunkstruct()方法转换回Tree。下面是演示它的代码:

>>> from nltk.chunk.regexp import ChunkString, ChunkRule, ChinkRule
>>> from nltk.tree import Tree
>>> t = Tree('S', [('the', 'DT'), ('book', 'NN'), ('has', 'VBZ'), ('many', 'JJ'), ('chapters', 'NNS')])
>>> cs = ChunkString(t)
>>> cs
<ChunkString: '<DT><NN><VBZ><JJ><NNS>'>
>>> ur = ChunkRule('<DT><NN.*><.*>*<NN.*>', 'chunk determiners and nouns')
>>> ur.apply(cs)
>>> cs
<ChunkString: '{<DT><NN><VBZ><JJ><NNS>}'>
>>> ir = ChinkRule('<VB.*>', 'chink verbs')
>>> ir.apply(cs)
>>> cs
<ChunkString: '{<DT><NN>}<VBZ>{<JJ><NNS>}'>
>>> cs.to_chunkstruct()
Tree('S', [Tree('CHUNK', [('the', 'DT'), ('book', 'NN')]), ('has', 'VBZ'), Tree('CHUNK', [('many', 'JJ'), ('chapters', 'NNS')])])

可以通过调用cs.to_chunkstruct().draw()在每个步骤中绘制前面的树图。

还有更多...

您会注意到来自ChunkString的子树被标记为'CHUNK'而不是'NP'。这是因为之前的规则是无短语感知的;它们创建块而不需要知道它们是什么类型的块。

在内部,RegexpParser为每个词块短语类型创建一个RegexpChunkParser。所以如果你只对NP短语进行词块处理,就只有一个RegexpChunkParserRegexpChunkParser获取特定词块类型的所有规则,并按顺序应用这些规则,将'CHUNK'树转换为特定词块类型,如'NP'

下面是一些代码示例,说明了RegexpChunkParser的用法。我们将前两个规则传递给RegexpChunkParser,然后解析之前创建的相同句子树。得到的树与按顺序应用两个规则得到的树相同,只是两个子树中的'CHUNK'已被替换为'NP'。这是因为RegexpChunkParser默认的chunk_node'NP'

>>> from nltk.chunk import RegexpChunkParser
>>> chunker = RegexpChunkParser([ur, ir])
>>> chunker.parse(t)
Tree('S', [Tree('NP', [('the', 'DT'), ('book', 'NN')]), ('has', 'VBZ'), Tree('NP', [('many', 'JJ'), ('chapters', 'NNS')])])

不同的词块类型

如果你想要解析不同的词块类型,可以将它作为chunk_node传递给RegexpChunkParser。以下是刚刚看到的相同代码,但我们将'NP'子树改为'CP'(自定义短语):

>>> from nltk.chunk import RegexpChunkParser
>>> chunker = RegexpChunkParser([ur, ir], chunk_node='CP')
>>> chunker.parse(t)
Tree('S', [Tree('CP', [('the', 'DT'), ('book', 'NN')]), ('has', 'VBZ'), Tree('CP', [('many', 'JJ'), ('chapters', 'NNS')])])

当你指定多个短语类型时,RegexpParser会内部执行此操作。这将在使用正则表达式进行部分解析中介绍。

替代模式

通过在语法中使用两个词块模式并丢弃chink模式,可以得到相同的解析结果:

>>> chunker = RegexpParser(r'''
... NP:
...    {<DT><NN.*>}
...    {<JJ><NN.*>}
... ''')
>>> chunker.parse(t)
Tree('S', [Tree('NP', [('the', 'DT'), ('book', 'NN')]), ('has', 'VBZ'), Tree('NP', [('many', 'JJ'), ('chapters', 'NNS')])])

实际上,你可以将两个词块模式合并成一个模式。

>>> chunker = RegexpParser(r'''
... NP:
...    {(<DT>|<JJ>)<NN.*>}
... ''')
>>> chunker.parse(t)
Tree('S', [Tree('NP', [('the', 'DT'), ('book', 'NN')]), ('has', 'VBZ'), Tree('NP', [('many', 'JJ'), ('chapters', 'NNS')])])

你如何创建和组合模式完全取决于你。模式创建是一个试错的过程,完全取决于你的数据看起来像什么,以及哪些模式最容易表达。

带有上下文的词块规则

你还可以创建带有周围标签上下文的词块规则。例如,如果你的模式是<DT>{<NN>},它将被解析为ChunkRuleWithContext。任何在花括号两侧有标签的情况下,你都会得到一个ChunkRuleWithContext而不是ChunkRule。这可以让你更具体地确定何时解析特定类型的词块。

这是一个直接使用ChunkWithContext的示例。它接受四个参数:左侧上下文、词块模式、右侧上下文和描述:

>>> from nltk.chunk.regexp import ChunkRuleWithContext
>>> ctx = ChunkRuleWithContext('<DT>', '<NN.*>', '<.*>', 'chunk nouns only after determiners')
>>> cs = ChunkString(t)
>>> cs
<ChunkString: '<DT><NN><VBZ><JJ><NNS>'>
>>> ctx.apply(cs)
>>> cs
<ChunkString: '<DT>{<NN>}<VBZ><JJ><NNS>'>
>>> cs.to_chunkstruct()
Tree('S', [('the', 'DT'), Tree('CHUNK', [('book', 'NN')]), ('has', 'VBZ'), ('many', 'JJ'), ('chapters', 'NNS')])

这个例子中只对跟在限定词后面的名词进行词块处理,因此忽略了跟在形容词后面的名词。以下是使用RegexpParser的示例:

>>> chunker = RegexpParser(r'''
... NP:
...    <DT>{<NN.*>}
... ''')
>>> chunker.parse(t)
Tree('S', [('the', 'DT'), Tree('NP', [('book', 'NN')]), ('has', 'VBZ'), ('many', 'JJ'), ('chapters', 'NNS')])

参见

在下一个菜谱中,我们将介绍合并和拆分词块。

使用正则表达式合并和拆分词块

在这个菜谱中,我们将介绍两个额外的词块规则。MergeRule可以根据第一个词块的末尾和第二个词块的开头合并两个词块。SplitRule将根据指定的拆分模式将一个词块拆分为两个。

如何实现...

一个Sp litRule通过在两侧的模式中包围两个对立的大括号来指定。要在名词之后分割一个块,你会这样做<NN.*>}{<.*>。一个Merg eRule通过翻转大括号来指定,它将连接第一个块的末尾与左模式匹配,下一个块的开始与右模式匹配的块。要合并两个块,第一个以名词结束,第二个以名词开始,你会使用<NN.*>{}<NN.*>

注意

规则的顺序非常重要,重新排序可能会影响结果。RegexpParser从上到下逐个应用规则,因此每个规则都会应用到前一个规则产生的ChunkString

这里有一个分割和合并的例子,从下面的句子树开始:

如何做...

  1. 整个句子被分割,如下面的图所示:如何做...

  2. 每个名词之后,块被分割成多个块,如下面的树所示:如何做...

  3. 每个带有限定词的块都被分割成单独的块,创建了四个块,而之前是三个:如何做...

  4. 以名词结尾的块如果下一个块以名词开始,则与下一个块合并,将四个块减少到三个,如下面的图所示:如何做...

使用RegexpParser,代码看起来像这样:

>>> chunker = RegexpParser(r'''
... NP:
...     {<DT><.*>*<NN.*>}
...     <NN.*>}{<.*>
...     <.*>}{<DT>
...     <NN.*>{}<NN.*>
... ''')
>>> sent = [('the', 'DT'), ('sushi', 'NN'), ('roll', 'NN'), ('was', 'VBD'), ('filled', 'VBN'), ('with', 'IN'), ('the', 'DT'), ('fish', 'NN')]
>>> chunker.parse(sent)
Tree('S', [Tree('NP', [('the', 'DT'), ('sushi', 'NN'), ('roll', 'NN')]), Tree('NP', [('was', 'VBD'), ('filled', 'VBN'), ('with', 'IN')]), Tree('NP', [('the', 'DT'), ('fish', 'NN')])])

NP块的最后树如下所示:

如何做...

它是如何工作的...

MergeRuleSplitRule类接受三个参数:左模式、右模式和描述。RegexpParser负责在花括号上分割原始模式以获取左右两侧,但你也可以手动创建这些。以下是如何通过应用每个规则逐步修改原始句子的说明:

>>> from nltk.chunk.regexp import MergeRule, SplitRule
>>> cs = ChunkString(Tree('S', sent))
>>> cs
<ChunkString: '<DT><NN><NN><VBD><VBN><IN><DT><NN>'>
>>> ur = ChunkRule('<DT><.*>*<NN.*>', 'chunk determiner to noun')
>>> ur.apply(cs)
>>> cs
<ChunkString: '{<DT><NN><NN><VBD><VBN><IN><DT><NN>}'>
>>> sr1 = SplitRule('<NN.*>', '<.*>', 'split after noun')
>>> sr1.apply(cs)
>>> cs
<ChunkString: '{<DT><NN>}{<NN>}{<VBD><VBN><IN><DT><NN>}'>
>>> sr2 = SplitRule('<.*>', '<DT>', 'split before determiner')
>>> sr2.apply(cs)
>>> cs
<ChunkString: '{<DT><NN>}{<NN>}{<VBD><VBN><IN>}{<DT><NN>}'>
>>> mr = MergeRule('<NN.*>', '<NN.*>', 'merge nouns')
>>> mr.apply(cs)
>>> cs
<ChunkString: '{<DT><NN><NN>}{<VBD><VBN><IN>}{<DT><NN>}'>
>>> cs.to_chunkstruct()
Tree('S', [Tree('CHUNK', [('the', 'DT'), ('sushi', 'NN'), ('roll', 'NN')]), Tree('CHUNK', [('was', 'VBD'), ('filled', 'VBN'), ('with', 'IN')]), Tree('CHUNK', [('the', 'DT'), ('fish', 'NN')])])

还有更多...

规则的解析以及左右模式的分割是在RegexpChunkRule超类的静态parse()方法中完成的。这是由RegexpParser调用来获取传递给RegexpChunkParser的规则列表的。以下是一些解析之前使用的模式的示例:

>>> from nltk.chunk.regexp import RegexpChunkRule
>>> RegexpChunkRule.parse('{<DT><.*>*<NN.*>}')
<ChunkRule: '<DT><.*>*<NN.*>'>
>>> RegexpChunkRule.parse('<.*>}{<DT>')
<SplitRule: '<.*>', '<DT>'>
>>> RegexpChunkRule.parse('<NN.*>{}<NN.*>')
<MergeRule: '<NN.*>', '<NN.*>'>

规则描述

可以通过在规则后面的注释字符串(注释字符串必须以#开头)来指定每个规则的描述。如果没有找到注释字符串,则规则的描述将为空。以下是一个示例:

>>> RegexpChunkRule.parse('{<DT><.*>*<NN.*>} # chunk everything').descr()
'chunk everything'
>>> RegexpChunkRule.parse('{<DT><.*>*<NN.*>}').descr()
''

在传递给RegexpParser的语法字符串中也可以使用注释字符串描述。

参见

之前的配方介绍了如何使用ChunkRule以及如何将规则传递给RegexpChunkParser

使用正则表达式扩展和删除块

有三个RegexpChunkRule子类不被RegexpChunkRule.parse()支持,因此如果你想要使用它们,必须手动创建。这些规则是:

  1. ExpandLeftRule:将未解块的(词)添加到块的左侧。

  2. ExpandRightRule:将未解块的(词)添加到块的右侧。

  3. UnChunkRule:解块任何匹配的块。

如何操作...

ExpandLeft RuleExpandRightRule 都接受两个模式以及一个描述作为参数。对于 ExpandLeftRule,第一个模式是我们想要添加到块开头的词,而右侧的模式将匹配我们想要扩展的块的开头。对于 ExpandRightRule,左侧的模式应该匹配我们想要扩展的块的末尾,而右侧的模式匹配我们想要添加到块末尾的词。这个想法与 MergeRule 类似,但在这个情况下,我们是在合并词而不是其他块。

UnChunkRuleChunkRule 的反义词。任何与 UnChunkRule 模式完全匹配的块将被解块,并成为词。以下是一些使用 RegexpChunkParser 的代码示例:

>>> from nltk.chunk.regexp import ChunkRule, ExpandLeftRule, ExpandRightRule, UnChunkRule
>>> from nltk.chunk import RegexpChunkParser
>>> ur = ChunkRule('<NN>', 'single noun')
>>> el = ExpandLeftRule('<DT>', '<NN>', 'get left determiner')
>>> er = ExpandRightRule('<NN>', '<NNS>', 'get right plural noun')
>>> un = UnChunkRule('<DT><NN.*>*', 'unchunk everything')
>>> chunker = RegexpChunkParser([ur, el, er, un])
>>> sent = [('the', 'DT'), ('sushi', 'NN'), ('rolls', 'NNS')]
>>> chunker.parse(sent)
Tree('S', [('the', 'DT'), ('sushi', 'NN'), ('rolls', 'NNS')])

你会注意到最终结果是平面的句子,这正是我们开始时的样子。那是因为最终的 UnChunkRule 取消了之前规则创建的块。继续阅读以了解发生了什么一步一步的过程。

如何操作...

以下规则按以下顺序应用,从下面的句子树开始:

工作原理...

  1. 将单个名词组合成一个块,如下所示:工作原理...

  2. 将左侧的限定词扩展到以名词开头的块中,如下所示:工作原理...

  3. 将右侧的复数名词扩展到以名词结尾的块中,如下所示:工作原理...

  4. 将每个由限定词 + 名词 + 复数名词组成的块进行解块,从而得到原始句子树,如下所示:工作原理...

下面是展示每一步的代码:

>>> from nltk.chunk.regexp import ChunkString
>>> from nltk.tree import Tree
>>> cs = ChunkString(Tree('S', sent))
>>> cs
<ChunkString: '<DT><NN><NNS>'>
>>> ur.apply(cs)
>>> cs
<ChunkString: '<DT>{<NN>}<NNS>'>
>>> el.apply(cs)
>>> cs
<ChunkString: '{<DT><NN>}<NNS>'>
>>> er.apply(cs)
>>> cs
<ChunkString: '{<DT><NN><NNS>}'>
>>> un.apply(cs)
>>> cs
<ChunkString: '<DT><NN><NNS>'>

还有更多...

在实践中,你可能只需要使用前面提到的四个规则:ChunkRuleChinkRuleMergeRuleSplitRule。但如果你确实需要非常精细地控制块解析和移除,现在你知道如何使用扩展和解块规则来做到这一点。

参见

前两个配方涵盖了 RegexpChunkRule.parse()RegexpParser 支持的更常见的块规则。

使用正则表达式进行部分解析

到目前为止,我们只解析了名词短语。但 RegexpParser 支持具有多种短语类型的语法,例如 动词短语介词短语。我们可以将学到的规则应用到定义一个语法中,该语法可以与 conll2000 语料库进行评估,该语料库包含 NPVPPP 短语。

如何操作...

我们将定义一个语法来解析三种短语类型。对于名词短语,我们有一个ChunkRule,它寻找一个可选的限定词后跟一个或多个名词。然后我们有一个MergeRule,用于在名词块的前面添加一个形容词。对于介词短语,我们简单地分块任何IN词,例如“in”或“on”。对于动词短语,我们分块一个可选的情态词(例如“should”)后跟一个动词。

注意

每个语法规则后面都跟着一个#注释。这个注释被传递给每个规则作为描述。注释是可选的,但它们可以是理解规则做什么的有用注释,并且将包含在追踪输出中。

>>> chunker = RegexpParser(r'''
... NP:
... {<DT>?<NN.*>+}  # chunk optional determiner with nouns
... <JJ>{}<NN.*>  # merge adjective with noun chunk
... PP:
... {<IN>}  # chunk preposition
... VP:
... {<MD>?<VB.*>}  # chunk optional modal with verb
... ''')
>>> from nltk.corpus import conll2000
>>> score = chunker.evaluate(conll2000.chunked_sents())
>>> score.accuracy()
0.61485735457576884

当我们在chunker上调用evaluate()方法时,我们给它一个分块句子的列表,并返回一个ChunkScore对象,该对象可以给我们chunker的准确度,以及许多其他指标。

它是如何工作的...

RegexpParser将语法字符串解析成一系列规则,每个短语类型有一组规则。这些规则被用来创建一个RegexpChunkParser。规则是通过RegexpChunkRule.parse()解析的,它返回五个子类之一:ChunkRuleChinkRuleMergeRuleSplitRuleChunkRuleWithContext

现在语法已经被翻译成一系列规则,这些规则被用来将标记句子解析成Tree结构。RegexpParserChunkParserI继承,它提供了一个parse()方法来解析标记的词。每当标记的标记的一部分与分块规则匹配时,就会构建一个子树,使得标记的标记成为Tree的叶子,其节点字符串是分块标签。ChunkParserI还提供了一个evaluate()方法,该方法将给定的分块句子与parse()方法的输出进行比较,以构建并返回一个ChunkScore对象。

还有更多...

你还可以在treebank_chunk语料库上评估这个chunker

>>> from nltk.corpus import treebank_chunk
>>> treebank_score = chunker.evaluate(treebank_chunk.chunked_sents())
>>> treebank_score.accuracy()
0.49033970276008493

treebank_chunk语料库是treebank语料库的一个特殊版本,它提供了一个chunked_sents()方法。由于文件格式,常规的treebank语料库无法提供该方法。

分块评分指标

ChunkScore除了准确度之外还提供了一些其他指标。对于chunker能够猜测的分块,精确度告诉你有多少是正确的。召回率告诉你chunker在找到正确分块方面的表现如何,与总共有多少分块相比。

>>> score.precision()
0.60201948127375005
>>> score.recall()
0.60607250250584699

你还可以获取chunker遗漏的分块列表,错误找到的分块,正确的分块和猜测的分块。这些可以帮助你了解如何改进你的分块语法。

>>> len(score.missed())
47161
>>> len(score.incorrect())
47967
>>> len(score.correct())
119720
>>> len(score.guessed())
120526

如你所见,通过错误分块的数量,以及比较guessed()correct(),我们的分块器猜测实际上存在的分块更多。它还遗漏了大量正确的分块。

循环和追踪

如果你想在语法中多次应用分块规则,你可以在初始化RegexpParser时传递loop=2。默认是loop=1

要观察分块过程的内部跟踪,请将 trace=1 传递给 RegexpParser。要获取更多输出,请传递 trace=2。这将给出分块器正在执行的操作的打印输出。规则注释/描述将包含在跟踪输出中,这可以帮助你了解何时应用了哪个规则。

参见

如果觉得提出正则表达式块模式看起来工作量太大,那么请阅读下一部分内容,我们将介绍如何基于分块句子的语料库训练一个分块器。

基于标签器的分块器

训练分块器可以是一个手动指定正则表达式块模式的绝佳替代方案。而不是通过繁琐的试错过程来获取确切的正确模式,我们可以使用现有的语料库数据来训练分块器,就像我们在 第四章 中做的那样,词性标注

如何操作...

与词性标注一样,我们将使用树库语料库数据进行训练。但这次我们将使用 treebank_chunk 语料库,该语料库专门格式化以生成以树的形式呈现的分块句子。这些 chunked_sents() 将由 TagChunker 类用于训练基于标签器的分块器。TagChunker 使用辅助函数 conll_tag_chunks()Tree 列表中提取 (pos, iob) 元组列表。然后,这些 (pos, iob) 元组将用于以与 第四章 中 词性标注 相同的方式训练标签器。但与学习单词的词性标签不同,我们正在学习词性标签的 IOB 标签。以下是 chunkers.py 中的代码:

import nltk.chunk, itertools
from nltk.tag import UnigramTagger, BigramTagger
from tag_util import backoff_tagger

def conll_tag_chunks(chunk_sents):
  tagged_sents = [nltk.chunk.tree2conlltags(tree) for tree in chunk_sents]
  return [[(t, c) for (w, t, c) in sent] for sent in tagged_sents]

class TagChunker(nltk.chunk.ChunkParserI):
  def __init__(self, train_chunks, tagger_classes=[UnigramTagger, BigramTagger]):
    train_sents = conll_tag_chunks(train_chunks)
    self.tagger = backoff_tagger(train_sents, tagger_classes)

  def parse(self, tagged_sent):
    if not tagged_sent: return None
    (words, tags) = zip(*tagged_sent)
    chunks = self.tagger.tag(tags)
    wtc = itertools.izip(words, chunks)
    return nltk.chunk.conlltags2tree([(w,t,c) for (w,(t,c)) in wtc])

一旦我们有了训练好的 TagChunker,我们就可以像在之前的食谱中对 RegexpParser 进行评估一样评估 ChunkScore

>>> from chunkers import TagChunker
>>> from nltk.corpus import treebank_chunk
>>> train_chunks = treebank_chunk.chunked_sents()[:3000]
>>> test_chunks = treebank_chunk.chunked_sents()[3000:]
>>> chunker = TagChunker(train_chunks)
>>> score = chunker.evaluate(test_chunks)
>>> score.accuracy()
0.97320393352514278
>>> score.precision()
0.91665343705350055
>>> score.recall()
0.9465573770491803

非常准确!训练分块器显然是手动指定语法和正则表达式的绝佳替代方案。

它是如何工作的...

从第三章中的创建分块短语语料库配方回忆起,在创建自定义语料库中,conll2000语料库使用 IOB 标签定义分块,这些标签指定了分块的类型以及它的开始和结束位置。我们可以在这些 IOB 标签模式上训练一个词性标注器,然后使用它来驱动ChunkerI子类。但首先,我们需要将从一个语料库的chunked_sents()方法得到的Tree转换成一个词性标注器可用的格式。这就是conll_tag_chunks()所做的事情。它使用nltk.chunk.tree2conlltags()将一个句子Tree转换成一个形式为(word, pos, iob)的 3 元组列表,其中pos是词性标签,iob是一个 IOB 标签,例如B-NP用来标记名词短语的开始,或者I-NP用来标记该词位于名词短语内部。这个方法的逆操作是nltk.chunk.conlltags2tree()。以下是一些演示这些nltk.chunk函数的代码:

>>> import nltk.chunk
>>> from nltk.tree import Tree
>>> t = Tree('S', [Tree('NP', [('the', 'DT'), ('book', 'NN')])])
>>> nltk.chunk.tree2conlltags(t)
[('the', 'DT', 'B-NP'), ('book', 'NN', 'I-NP')]
>>> nltk.chunk.conlltags2tree([('the', 'DT', 'B-NP'), ('book', 'NN', 'I-NP')])
Tree('S', [Tree('NP', [('the', 'DT'), ('book', 'NN')])])

下一步是将这些 3 元组转换为标签分类器可以识别的 2 元组。因为RegexpParser使用词性标签作为分块模式,所以我们在这里也会这样做,并将词性标签作为单词来标注。通过简单地从 3 元组(word, pos, iob)中删除wordconll_tag_chunks()函数返回一个形式为(pos, iob)的 2 元组列表。当给定的示例Tree作为一个列表提供时,结果就是我们能够喂给标签分类器的格式。

>>> conll_tag_chunks([t])
[[('DT', 'B-NP'), ('NN', 'I-NP')]]

最后一步是ChunkParserI的一个子类,称为TagChunker。它使用一个内部标签分类器在一系列分块树上进行训练。这个内部标签分类器由一个UnigramTagger和一个BigramTagger组成,它们在一个回退链中使用,使用的是第四章中训练和组合 Ngram 标签分类器配方中创建的backoff_tagger()方法。

最后,ChunkerI子类必须实现一个parse()方法,该方法期望一个词性标注过的句子。我们将该句子解包成一个单词和词性标签的列表。然后,标签由标签分类器进行标注以获得 IOB 标签,这些标签随后与单词和词性标签重新组合,以创建我们可以传递给nltk.chunk.conlltags2tree()的 3 元组,从而返回一个最终的Tree

还有更多...

由于我们一直在讨论conll IOB 标签,让我们看看TagChunkerconll2000语料库上的表现:

>>> from nltk.corpus import conll2000
>>> conll_train = conll2000.chunked_sents('train.txt')
>>> conll_test = conll2000.chunked_sents('test.txt')
>>> chunker = TagChunker(conll_train)
>>> score = chunker.evaluate(conll_test)
>>> score.accuracy()
0.89505456234037617
>>> score.precision()
0.81148419743556754
>>> score.recall()
0.86441916769448635

不如treebank_chunk好,但conll2000是一个更大的语料库,所以这并不太令人惊讶。

使用不同的标签分类器

如果你想要使用不同的标签分类器与TagChunker一起使用,你可以将它们作为tagger_classes传入。例如,这里是一个仅使用UnigramTaggerTagChunker

>>> from nltk.tag import UnigramTagger
>>> uni_chunker = TagChunker(train_chunks, tagger_classes=[UnigramTagger])
>>> score = uni_chunker.evaluate(test_chunks)
>>> score.accuracy()
0.96749259243354657

tagger_classes将直接传递到backoff_tagger()函数中,这意味着它们必须是SequentialBackoffTagger的子类。在测试中,默认的tagger_classes=[UnigramTagger, BigramTagger]产生了最佳结果。

参见

在第四章 “词性标注” 的 “训练和组合 Ngram 标注器” 菜谱中,涵盖了使用 UnigramTaggerBigramTagger 的回退标注。在之前的菜谱中解释了由短语结构切分器的 evaluate() 方法返回的 ChunkScore 指标。

基于分类的短语结构切分

与大多数词性标注器不同,ClassifierBasedTagger 从特征中学习。这意味着我们可以创建一个 ClassifierChunker,它可以同时从单词和词性标注中学习,而不仅仅是像 TagChunker 那样只从词性标注中学习。

如何实现...

对于 ClassifierChunker,我们不想像在之前的菜谱中那样丢弃训练句子中的单词。相反,为了保持与训练 ClassiferBasedTagger 所需的 2 元组 (word, pos) 格式兼容,我们使用 chunk_trees2train_chunks() 函数将 nltk.chunk.tree2conlltags() 中的 (word, pos, iob) 3 元组转换为 ((word, pos), iob) 2 元组。此代码可在 chunkers.py 中找到:

import nltk.chunk
from nltk.tag import ClassifierBasedTagger

def chunk_trees2train_chunks(chunk_sents):
  tag_sents = [nltk.chunk.tree2conlltags(sent) for sent in chunk_sents]
  return [[((w,t),c) for (w,t,c) in sent] for sent in tag_sents]

接下来,我们需要一个特征检测函数传递给 ClassifierBasedTagger。我们的默认特征检测函数 prev_next_pos_iob() 知道 tokens 列表实际上是 (word, pos) 元组的列表,并且可以使用它来返回适合分类器的特征集。为了给分类器尽可能多的信息,这个特征集包含当前、前一个和下一个单词以及词性标注,以及前一个 IOB 标注。

def prev_next_pos_iob(tokens, index, history):
  word, pos = tokens[index]

  if index == 0:
    prevword, prevpos, previob = ('<START>',)*3
  else:
    prevword, prevpos = tokens[index-1]
    previob = history[index-1]

  if index == len(tokens) - 1:
    nextword, nextpos = ('<END>',)*2
  else:
    nextword, nextpos = tokens[index+1]

  feats = {
    'word': word,
    'pos': pos,
    'nextword': nextword,
    'nextpos': nextpos,
    'prevword': prevword,
    'prevpos': prevpos,
    'previob': previob
  }
  return feats

现在,我们可以定义 ClassifierChunker,它使用内部 ClassifierBasedTagger,并使用 prev_next_pos_iob() 提取的特征和来自 chunk_trees2train_chunks() 的训练句子。作为 ChunkerParserI 的子类,它实现了 parse() 方法,该方法使用 nltk.chunk.conlltags2tree() 将内部标注器产生的 ((w, t), c) 元组转换为 Tree

class ClassifierChunker(nltk.chunk.ChunkParserI):
  def __init__(self, train_sents, feature_detector=prev_next_pos_iob, **kwargs):
    if not feature_detector:
      feature_detector = self.feature_detector

    train_chunks = chunk_trees2train_chunks(train_sents)
    self.tagger = ClassifierBasedTagger(train=train_chunks,
      feature_detector=feature_detector, **kwargs)

  def parse(self, tagged_sent):
    if not tagged_sent: return None
    chunks = self.tagger.tag(tagged_sent)
    return nltk.chunk.conlltags2tree([(w,t,c) for ((w,t),c) in chunks])

使用与之前菜谱中 treebank_chunk 语料库相同的 train_chunkstest_chunks,我们可以评估 chunkers.py 中的此代码:

>>> from chunkers import ClassifierChunker
>>> chunker = ClassifierChunker(train_chunks)
>>> score = chunker.evaluate(test_chunks)
>>> score.accuracy()
0.97217331558380216
>>> score.precision()
0.92588387933830685
>>> score.recall()
0.93590163934426229

TagChunker 相比,所有分数都有所上升。让我们看看它在 conll2000 上的表现:

>>> chunker = ClassifierChunker(conll_train)
>>> score = chunker.evaluate(conll_test)
>>> score.accuracy()
0.92646220740021534
>>> score.precision()
0.87379243109102189
>>> score.recall()
0.90073546206203459

这比 TagChunker 有很大改进。

它是如何工作的...

与之前菜谱中的 TagChunker 一样,我们正在训练一个用于 IOB 标注的词性标注器。但在这个情况下,我们希望包括单词作为特征来驱动分类器。通过创建形式为 ((word, pos), iob) 的嵌套 2 元组,我们可以将单词通过标注器传递到特征检测函数。chunk_trees2train_chunks() 生成这些嵌套 2 元组,prev_next_pos_iob() 了解它们并使用每个元素作为特征。以下特征被提取:

  • 当前单词和词性标注

  • 前一个单词、词性标注和 IOB 标注

  • 下一个单词和词性标注

prev_next_pos_iob()的参数看起来与ClassifierBasedTaggerfeature_detector()方法的参数相同:tokensindexhistory。但这次,tokens将是一个包含(word, pos)二元组的列表,而history将是一个 IOB 标签的列表。如果没有前一个或下一个标记,将使用特殊特征值'<START>''<END>'

ClassifierChunker使用内部的ClassifierBasedTaggerprev_next_pos_iob()作为其默认的feature_detector。然后,将标签器的结果(以相同的嵌套二元组形式)重新格式化为三元组,使用nltk.chunk.conlltags2tree()返回一个最终的Tree

更多...

您可以通过将自定义的特征检测函数传递给ClassifierChunker作为feature_detector来使用自己的特征检测函数。tokens将包含一个(word, tag)元组的列表,而history将包含之前找到的 IOB 标签的列表。

使用不同的分类器构建器

ClassifierBasedTagger默认使用NaiveBayesClassifier.train作为其classifier_builder。但您可以通过覆盖classifier_builder关键字参数来使用任何您想要的分类器。以下是一个使用MaxentClassifier.train的示例:

>>> from nltk.classify import MaxentClassifier
>>> builder = lambda toks: MaxentClassifier.train(toks, trace=0, max_iter=10, min_lldelta=0.01)
>>> me_chunker = ClassifierChunker(train_chunks, classifier_builder=builder)
>>> score = me_chunker.evaluate(test_chunks)
>>> score.accuracy()
0.9748357452655988
>>> score.precision()
0.93794355504208615
>>> score.recall()
0.93163934426229511

不是直接使用MaxentClassifier.train,它被包装在一个lambda中,以便其输出是安静的(trace=0)并且能够在合理的时间内完成。如您所见,与使用NaiveBayesClassifier相比,分数略有不同。

参见

之前的配方基于标签器的训练介绍了使用词性标注器来训练标签器的想法。第四章词性标注中的基于分类器的标签配方描述了ClassifierBasedPOSTagger,它是ClassifierBasedTagger的子类。在第七章文本分类中,我们将详细讨论分类。

提取命名实体

命名实体识别是一种特定的块提取,它使用实体标签而不是,或者除了,块标签。常见的实体标签包括PERSONORGANIZATIONLOCATION。词性标注句子与正常块提取一样被解析成块树,但树的节点可以是实体标签而不是块短语标签。

如何做...

NLTK 附带了一个预训练的命名实体分块器。这个分块器已经在 ACE 程序的数据上进行了训练,ACE 程序是由NIST国家标准与技术研究院)赞助的自动内容提取项目,你可以在这里了解更多信息:www.itl.nist.gov/iad/894.01/tests/ace/。不幸的是,这些数据不包括在 NLTK 语料库中,但训练好的分块器是包含在内的。这个分块器可以通过nltk.chunk模块中的ne_chunk()方法使用。ne_chunk()将单个句子分块成一个Tree。以下是一个使用ne_chunk()treebank_chunk语料库中第一个标记句子的示例:

>>> from nltk.chunk import ne_chunk
>>> ne_chunk(treebank_chunk.tagged_sents()[0])
Tree('S', [Tree('PERSON', [('Pierre', 'NNP')]), Tree('ORGANIZATION', [('Vinken', 'NNP')]), (',', ','), ('61', 'CD'), ('years', 'NNS'), ('old', 'JJ'), (',', ','), ('will', 'MD'), ('join', 'VB'), ('the', 'DT'), ('board', 'NN'), ('as', 'IN'), ('a', 'DT'), ('nonexecutive', 'JJ'), ('director', 'NN'), ('Nov.', 'NNP'), ('29', 'CD'), ('.', '.')])

你可以看到找到了两个实体标签:PERSONORGANIZATION。这些子树中的每一个都包含一个被识别为PERSONORGANIZATION的单词列表。为了提取这些命名实体,我们可以编写一个简单的辅助方法,该方法将获取我们感兴趣的子树的所有叶子节点。

def sub_leaves(tree, node):
  return [t.leaves() for t in tree.subtrees(lambda s: s.node == node)]

然后,我们可以调用此方法从树中获取所有PERSONORGANIZATION叶子节点。

>>> tree = ne_chunk(treebank_chunk.tagged_sents()[0])
>>> from chunkers import sub_leaves
>>> sub_leaves(tree, 'PERSON')
[[('Pierre', 'NNP')]]
>>> sub_leaves(tree, 'ORGANIZATION')
[[('Vinken', 'NNP')]]

你可能会注意到,分块器错误地将“Vinken”分成了它自己的ORGANIZATION Tree,而不是将其包含在包含“Pierre”的PERSON Tree中。这是统计自然语言处理的情况——你不能总是期望完美。

它是如何工作的...

预训练的命名实体分块器与其他分块器类似,实际上它使用了一个由MaxentClassifier驱动的ClassifierBasedTagger来确定 IOB 标签。但它不使用B-NPI-NP IOB 标签,而是使用B-PERSONI-PERSONB-ORGANIZATIONI-ORGANIZATION等标签。它还使用O标签来标记不属于命名实体的单词(因此位于命名实体子树之外)。

还有更多...

要同时处理多个句子,可以使用batch_ne_chunk()。以下是一个示例,我们处理了treebank_chunk.tagged_sents()的前 10 个句子,并获取了ORGANIZATION sub_leaves()

>>> from nltk.chunk import batch_ne_chunk
>>> trees = batch_ne_chunk(treebank_chunk.tagged_sents()[:10])
>>> [sub_leaves(t, 'ORGANIZATION') for t in trees]
[[[('Vinken', 'NNP')]], [[('Elsevier', 'NNP')]], [[('Consolidated', 'NNP'), ('Gold', 'NNP'), ('Fields', 'NNP')]], [], [], [[('Inc.', 'NNP')], [('Micronite', 'NN')]], [[('New', 'NNP'), ('England', 'NNP'), ('Journal', 'NNP')]], [[('Lorillard', 'NNP')]], [], []]

你可以看到有几个多词的ORGANIZATION分块,例如“New England Journal”。还有一些句子没有ORGANIZATION分块,如空列表[]所示。

二进制命名实体提取

如果你不在乎要提取的特定类型的命名实体,可以将binary=True传递给ne_chunk()batch_ne_chunk()。现在,所有命名实体都将被标记为NE

>>> ne_chunk(treebank_chunk.tagged_sents()[0], binary=True)
Tree('S', [Tree('NE', [('Pierre', 'NNP'), ('Vinken', 'NNP')]), (',', ','), ('61', 'CD'), ('years', 'NNS'), ('old', 'JJ'), (',', ','), ('will', 'MD'), ('join', 'VB'), ('the', 'DT'), ('board', 'NN'), ('as', 'IN'), ('a', 'DT'), ('nonexecutive', 'JJ'), ('director', 'NN'), ('Nov.', 'NNP'), ('29', 'CD'), ('.', '.')])

如果我们获取sub_leaves(),我们可以看到“Pierre Vinken”被正确地组合成一个单一的命名实体。

>>> sub_leaves(ne_chunk(treebank_chunk.tagged_sents()[0], binary=True), 'NE')
[[('Pierre', 'NNP'), ('Vinken', 'NNP')]]

参见

在下一个菜谱中,我们将创建自己的简单命名实体分块器。

提取专有名词分块

提取命名实体的简单方法是将所有专有名词(标记为NNP)进行分块。我们可以将这些分块标记为NAME,因为专有名词的定义是人的名字、地点或事物的名称。

如何做到这一点...

使用RegexpParser,我们可以创建一个非常简单的语法,将所有专有名词组合成一个NAME片段。然后我们可以在treebank_chunk的第一个标记句子上测试这个语法,以比较之前的结果。

>>> chunker = RegexpParser(r'''
... NAME:
...   {<NNP>+}
... ''')
>>> sub_leaves(chunker.parse(treebank_chunk.tagged_sents()[0]), 'NAME')
[[('Pierre', 'NNP'), ('Vinken', 'NNP')], [('Nov.', 'NNP')]]

虽然我们得到了“Nov.”作为一个NAME片段,但这不是一个错误的结果,因为“Nov.”是月份的名称。

它是如何工作的...

NAME片段器是RegexpParser的一个简单用法,在本章的使用正则表达式进行分块和切分使用正则表达式合并和拆分片段使用正则表达式进行部分解析食谱中进行了介绍。所有标记为NNP的词序列都被组合成NAME片段。

还有更多...

如果我们想要确保只对人的名字进行分块,那么我们可以构建一个使用names语料库进行分块的PersonChunker。这个类可以在chunkers.py中找到:

import nltk.chunk
from nltk.corpus import names

class PersonChunker(nltk.chunk.ChunkParserI):
  def __init__(self):
    self.name_set = set(names.words())

  def parse(self, tagged_sent):
    iobs = []
    in_person = False

    for word, tag in tagged_sent:
      if word in self.name_set and in_person:
        iobs.append((word, tag, 'I-PERSON'))
      elif word in self.name_set:
        iobs.append((word, tag, 'B-PERSON'))
        in_person = True
      else:
        iobs.append((word, tag, 'O'))
        in_person = False

    return nltk.chunk.conlltags2tree(iobs)

PersonChunker遍历标记句子,检查每个词是否在其names_set(从names语料库构建)中。如果当前词在names_set中,那么它使用B-PERSONI-PERSON IOB 标签,具体取决于前一个词是否也在names_set中。不在names_set中的任何词都得到O IOB 标签。完成后,使用nltk.chunk.conlltags2tree()将 IOB 标签列表转换为Tree。使用它对之前的标记句子进行操作,我们得到以下结果:

>>> from chunkers import PersonChunker
>>> chunker = PersonChunker()
>>> sub_leaves(chunker.parse(treebank_chunk.tagged_sents()[0]), 'PERSON')
[[('Pierre', 'NNP')]]

我们不再得到“Nov.”,但我们也失去了“Vinken”,因为它没有在names语料库中找到。这个食谱突出了片段提取和自然语言处理一般的一些困难。

  • 如果你使用通用模式,你会得到通用结果

  • 如果你正在寻找特定的结果,你必须使用特定的数据

  • 如果你的具体数据不完整,你的结果也会不完整

参见

之前的食谱定义了sub_leaves()方法,用于显示找到的片段。在下一个食谱中,我们将介绍如何根据gazetteers语料库找到LOCATION片段。

提取位置片段

要识别位置片段,我们可以创建一个不同的ChunkParserI子类,该子类使用gazetteers语料库来识别位置词。gazetteers是一个包含以下位置词的WordListCorpusReader

  • 国家名称

  • 美国州份和缩写

  • 主要美国城市

  • 加拿大省份

  • 墨西哥州份

如何做到这一点...

LocationChunker,位于chunkers.py中,遍历一个标记句子,寻找在gazetteers语料库中找到的词。当它找到一个或多个位置词时,它使用 IOB 标签创建一个LOCATION片段。辅助方法iob_locations()是产生 IOB LOCATION标签的地方,而parse()方法将这些 IOB 标签转换为Tree

import nltk.chunk
from nltk.corpus import gazetteers

class LocationChunker(nltk.chunk.ChunkParserI):
  def __init__(self):
    self.locations = set(gazetteers.words())
    self.lookahead = 0

    for loc in self.locations:
      nwords = loc.count(' ')

      if nwords > self.lookahead:
        self.lookahead = nwords

  def iob_locations(self, tagged_sent):
    i = 0
    l = len(tagged_sent)
    inside = False

    while i < l:
      word, tag = tagged_sent[i]
      j = i + 1
      k = j + self.lookahead
      nextwords, nexttags = [], []
      loc = False

      while j < k:
        if ' '.join([word] + nextwords) in self.locations:
          if inside:
            yield word, tag, 'I-LOCATION'
          else:
            yield word, tag, 'B-LOCATION'

          for nword, ntag in zip(nextwords, nexttags):
            yield nword, ntag, 'I-LOCATION'

          loc, inside = True, True
          i = j
          break

        if j < l:
          nextword, nexttag = tagged_sent[j]
          nextwords.append(nextword)
          nexttags.append(nexttag)
          j += 1
        else:
          break

      if not loc:
        inside = False
        i += 1
        yield word, tag, 'O'

  def parse(self, tagged_sent):
    iobs = self.iob_locations(tagged_sent)
    return nltk.chunk.conlltags2tree(iobs)

我们可以使用LocationChunker将以下句子解析为两个位置:“与旧金山,CA 相比,圣何塞,CA 很冷”。

>>> from chunkers import LocationChunker
>>> t = loc.parse([('San', 'NNP'), ('Francisco', 'NNP'), ('CA', 'NNP'), ('is', 'BE'), ('cold', 'JJ'), ('compared', 'VBD'), ('to', 'TO'), ('San', 'NNP'), ('Jose', 'NNP'), ('CA', 'NNP')])
>>> sub_leaves(t, 'LOCATION')
[[('San', 'NNP'), ('Francisco', 'NNP'), ('CA', 'NNP')], [('San', 'NNP'), ('Jose', 'NNP'), ('CA', 'NNP')]]

结果是我们得到了两个LOCATION片段,正如预期的那样。

它是如何工作的...

LocationChunker 首先构建 gazetteers 语料库中所有地点的 set。然后它找到单个地点字符串中的单词最大数量,这样它就知道在解析标记句子时必须向前查看多少个单词。

parse() 方法调用一个辅助方法 iob_locations(),该方法生成形式为 (word, pos, iob) 的 3-元组,其中 iobO(如果单词不是地点),或者对于 LOCATION 分块是 B-LOCATIONI-LOCATIONiob_locations() 通过查看当前单词和下一个单词来查找地点分块,以检查组合单词是否在地点 set 中。相邻的多个地点单词然后被放入同一个 LOCATION 分块中,例如在先前的例子中,“San Francisco” 和 “CA”。

与前面的配方类似,构建一个 (word, pos, iob) 元组的列表传递给 nltk.chunk.conlltags2tree() 以返回一个 Tree 更简单、更方便。另一种方法是手动构建一个 Tree,但这需要跟踪子节点、子树以及你在 Tree 中的当前位置。

还有更多...

这个 LocationChunker 的一个优点是它不关心词性标记。只要地点单词在地点 set 中找到,任何词性标记都可以。

参考内容

在下一个配方中,我们将介绍如何使用 ieer 语料库训练一个命名实体分块器。

训练一个命名实体分块器

您可以使用 ieer 语料库训练自己的命名实体分块器,ieer 代表 信息提取—实体识别ieer)。但这需要一些额外的工作,因为 ieer 语料库有分块树,但没有单词的词性标记。

如何实现...

使用 chunkers.py 中的 ieertree2conlltags()ieer_chunked_sents() 函数,我们可以从 ieer 语料库创建命名实体分块树,以训练本章“基于分类的分块”配方中创建的 ClassifierChunker

import nltk.tag, nltk.chunk, itertools
from nltk.corpus import ieer

def ieertree2conlltags(tree, tag=nltk.tag.pos_tag):
  words, ents = zip(*tree.pos())
  iobs = []
  prev = None

  for ent in ents:
    if ent == tree.node:
      iobs.append('O')
      prev = None
    elif prev == ent:
      iobs.append('I-%s' % ent)
    else:
      iobs.append('B-%s' % ent)
      prev = ent

  words, tags = zip(*tag(words))
  return itertools.izip(words, tags, iobs)

def ieer_chunked_sents(tag=nltk.tag.pos_tag):
  for doc in ieer.parsed_docs():
    tagged = ieertree2conlltags(doc.text, tag)
    yield nltk.chunk.conlltags2tree(tagged)

我们将使用 94 句中的 80 句进行训练,其余的用于测试。然后我们可以看看它在 treebank_chunk 语料库的第一句话上的表现如何。

>>> from chunkers import ieer_chunked_sents, ClassifierChunker
>>> from nltk.corpus import treebank_chunk
>>> ieer_chunks = list(ieer_chunked_sents())
>>> len(ieer_chunks)
94
>>> chunker = ClassifierChunker(ieer_chunks[:80])
>>> chunker.parse(treebank_chunk.tagged_sents()[0])
Tree('S', [Tree('LOCATION', [('Pierre', 'NNP'), ('Vinken', 'NNP')]), (',', ','), Tree('DURATION', [('61', 'CD'), ('years', 'NNS')]), Tree('MEASURE', [('old', 'JJ')]), (',', ','), ('will', 'MD'), ('join', 'VB'), ('the', 'DT'), ('board', 'NN'), ('as', 'IN'), ('a', 'DT'), ('nonexecutive', 'JJ'), ('director', 'NN'), Tree('DATE', [('Nov.', 'NNP'), ('29', 'CD')]), ('.', '.')])

因此,它找到了正确的 DURATIONDATE,但将“Pierre Vinken”标记为 LOCATION。让我们看看它与其他 ieer 分块树相比的得分如何:

>>> score = chunker.evaluate(ieer_chunks[80:])
>>> score.accuracy()
0.88290183880706252
>>> score.precision()
0.40887174541947929
>>> score.recall()
0.50536352800953521

准确率相当不错,但精确率和召回率非常低。这意味着有很多误判和误报。

工作原理...

事实上,我们并不是在理想训练数据上工作。由 ieer_chunked_sents() 生成的 ieer 树并不完全准确。首先,没有明确的句子分隔,所以每个文档都是一个单独的树。其次,单词没有明确的标记,所以我们不得不使用 nltk.tag.pos_tag() 来猜测。

ieer语料库提供了一个parsed_docs()方法,该方法返回一个具有text属性的文档列表。这个text属性是一个文档Tree,它被转换为形式为(word, pos, iob)的 3 元组列表。为了获得这些最终的 3 元组,我们必须首先使用tree.pos()来展平Tree,它返回形式为(word, entity)的 2 元组列表,其中entity是实体标签或树的顶层标签。任何实体标签为顶层标签的单词都位于命名实体块之外,并得到 IOB 标签O。所有具有唯一实体标签的单词要么是命名实体块的开始,要么在命名实体块内部。一旦我们有了所有的 IOB 标签,我们就可以获取所有单词的词性标签,并使用itertools.izip()将单词、词性标签和 IOB 标签组合成 3 元组。

还有更多...

尽管训练数据并不理想,但ieer语料库为训练一个命名实体分块器提供了一个良好的起点。数据来自《纽约时报》和《美联社新闻稿》。ieer.parsed_docs()中的每个文档也包含一个标题属性,该属性是一个Tree

>>> from nltk.corpus import ieer
>>> ieer.parsed_docs()[0].headline
Tree('DOCUMENT', ['Kenyans', 'protest', 'tax', 'hikes'])

参见

本章中的提取命名实体配方涵盖了 NLTK 附带预训练的命名实体分块器。

第六章:转换块和树

在本章中,我们将涵盖:

  • 过滤不重要的单词

  • 纠正动词形式

  • 交换动词短语

  • 交换名词基数

  • 交换不定式短语

  • 使复数名词变为单数

  • 连接块转换

  • 将块树转换为文本

  • 展平一个深树

  • 创建一个浅树

  • 转换树节点

简介

现在你已经知道了如何从句子中获取块/短语,你将如何使用它们?本章将向你展示如何对块和树进行各种转换。块转换用于语法纠正和重新排列短语而不丢失意义。树转换为你提供了修改和展平深层解析树的方法。

这些菜谱中详细说明的功能是修改数据,而不是从数据中学习。这意味着不能不加区分地应用它们。对您想要转换的数据有深入的了解,以及一些实验,应该有助于您决定应用哪些函数以及何时应用。

在本章中,当使用术语chunk时,它可能指的是由分块器提取的实际块,或者它可能仅仅是指由标记词组成的列表形式的一个短语或句子。本章重要的是你能用块做什么,而不是它从哪里来。

过滤不重要的单词

许多最常用的单词在区分短语含义时都是不重要的。例如,在短语“the movie was terrible”中,最重要的单词是“movie”和“terrible”,而“the”和“was”几乎毫无用处。如果你把它们去掉,你仍然可以得到相同的意思,比如“movie terrible”或“terrible movie”。无论如何,情感都是一样的。在本菜谱中,我们将学习如何通过查看它们的词性标记来移除不重要的单词,并保留重要的单词。

准备工作

首先,我们需要决定哪些词性标记是重要的,哪些不是。通过查看treebank语料库中的stopwords得到以下不重要的单词和标记表:

Word Tag
a DT
all PDT
an DT
and CC
or CC
that WDT
the DT

除了 CC 之外,所有标记都以 DT 结尾。这意味着我们可以通过查看标记的后缀来过滤掉不重要的单词。

如何做到...

transforms.py中有一个名为filter_insignificant()的函数。它接受一个单独的块,该块应是一个标记词的列表,并返回一个不包含任何不标记单词的新块。它默认过滤掉以 DT 或 CC 结尾的任何标记。

def filter_insignificant(chunk, tag_suffixes=['DT', 'CC']):
  good = []

  for word, tag in chunk:
    ok = True

    for suffix in tag_suffixes:
      if tag.endswith(suffix):
        ok = False
        break

    if ok:
      good.append((word, tag))

  return good

现在,我们可以将其应用于“the terrible movie”的词性标记版本。

>>> from transforms import filter_insignificant
>>> filter_insignificant([('the', 'DT'), ('terrible', 'JJ'), ('movie', 'NN')])
[('terrible', 'JJ'), ('movie', 'NN')]

如你所见,单词“the”已从块中删除。

它是如何工作的...

filter_insignificant() 遍历块中的标记词。对于每个标签,它检查该标签是否以任何 tag_suffixes 结尾。如果是,则跳过标记词。但如果标签是可接受的,则将标记词追加到返回的新良好块中。

还有更多...

根据 filter_insignificant() 的定义,如果你觉得 DT 和 CC 不够,或者对于你的情况不正确,你可以传递自己的标签后缀。例如,你可能会决定所有格词和代词,如 "you"、"your"、"their" 和 "theirs" 都不好,而 DT 和 CC 词是可接受的。标签后缀将是 PRP 和 PRP$。以下是这个函数的一个示例:

>>> filter_insignificant([('your', 'PRP$'), ('book', 'NN'), ('is', 'VBZ'), ('great', 'JJ')], tag_suffixes=['PRP', 'PRP$'])
[('book', 'NN'), ('is', 'VBZ'), ('great', 'JJ')]

过滤不重要的词可以作为停止词过滤的良好补充,用于搜索引擎索引、查询和文本分类等目的。

参见

这个配方与 第一章 中 Filtering stopwords in a tokenized sentence 配方类似,Tokenizing Text and WordNet Basics

修正动词形式

在现实世界的语言中找到错误的动词形式相当常见。例如,"is our children learning?" 的正确形式是 "are our children learning?"。动词 "is" 应仅与单数名词一起使用,而 "are" 用于复数名词,如 "children"。我们可以通过创建根据块中是否有复数或单数名词来使用的动词修正映射来纠正这些错误。

准备工作

我们首先需要在 transforms.py 中定义动词修正映射。我们将创建两个映射,一个用于复数到单数,另一个用于单数到复数。

plural_verb_forms = {
  ('is', 'VBZ'): ('are', 'VBP'),
  ('was', 'VBD'): ('were', 'VBD')
}

singular_verb_forms = {
  ('are', 'VBP'): ('is', 'VBZ'),
  ('were', 'VBD'): ('was', 'VBD')
}

每个映射都有一个标记动词映射到另一个标记动词。这些初始映射涵盖了映射的基础,包括 is to are,was to were,反之亦然。

如何做...

transforms.py 中有一个名为 correct_verbs() 的函数。传递一个包含错误动词形式的块,你将得到一个修正后的块。它使用辅助函数 first_chunk_index() 在块中搜索 pred 返回 True 的第一个标记词的位置。

def first_chunk_index(chunk, pred, start=0, step=1):
  l = len(chunk)
  end = l if step > 0 else -1

  for i in range(start, end, step):
    if pred(chunk[i]):
      return i

  return None

def correct_verbs(chunk):
  vbidx = first_chunk_index(chunk, lambda (word, tag): tag.startswith('VB'))
  # if no verb found, do nothing
  if vbidx is None:
    return chunk

  verb, vbtag = chunk[vbidx]
  nnpred = lambda (word, tag): tag.startswith('NN')
  # find nearest noun to the right of verb
  nnidx = first_chunk_index(chunk, nnpred, start=vbidx+1)
  # if no noun found to right, look to the left
  if nnidx is None:
    nnidx = first_chunk_index(chunk, nnpred, start=vbidx-1, step=-1)
  # if no noun found, do nothing
  if nnidx is None:
    return chunk

  noun, nntag = chunk[nnidx]
  # get correct verb form and insert into chunk
  if nntag.endswith('S'):
    chunk[vbidx] = plural_verb_forms.get((verb, vbtag), (verb, vbtag))
  else:
    chunk[vbidx] = singular_verb_forms.get((verb, vbtag), (verb, vbtag))

  return chunk

当我们在一个部分标记为 "is our children learning" 的块上调用它时,我们得到正确的形式,"are our children learning"。

>>> from transforms import correct_verbs
>>> correct_verbs([('is', 'VBZ'), ('our', 'PRP$'), ('children', 'NNS'), ('learning', 'VBG')])
[('are', 'VBP'), ('our', 'PRP$'), ('children', 'NNS'), ('learning', 'VBG')]

我们也可以尝试用单数名词和错误的复数动词。

>>> correct_verbs([('our', 'PRP$'), ('child', 'NN'), ('were', 'VBD'), ('learning', 'VBG')])
[('our', 'PRP$'), ('child', 'NN'), ('was', 'VBD'), ('learning', 'VBG')]

在这种情况下,"were" 变为 "was",因为 "child" 是一个单数名词。

它是如何工作的...

correct_verbs() 函数首先在块中寻找一个动词。如果没有找到动词,则块保持不变并返回。一旦找到动词,我们就保留动词、其标签和其在块中的索引。然后我们在动词的两侧寻找最近的名词,从右侧开始,如果右侧没有找到名词,则向左查找。如果没有找到任何名词,则块保持原样返回。但如果找到了名词,那么我们将根据名词是否为复数来查找正确的动词形式。

回忆一下第四章中的词性标注,复数名词用 NNS 标注,而单数名词用 NN 标注。这意味着我们可以通过查看名词的标注是否以 S 结尾来检查名词的复数性。一旦我们得到修正后的动词形式,它就被插入到块中,以替换原始的动词形式。

为了使在块中搜索更容易,我们定义了一个名为first_chunk_index()的函数。它接受一个块、一个lambda谓词、起始索引和步长增量。谓词函数对每个标注词进行调用,直到它返回True。如果它从未返回True,则返回None。起始索引默认为零,步长增量默认为 1。正如你将在接下来的菜谱中看到的,我们可以通过覆盖start并设置step为-1 来向后搜索。这个小的实用函数将是后续转换函数的关键部分。

参见

下面的四个菜谱都使用first_chunk_index()来执行块转换。

交换动词短语

交换动词周围的词可以消除特定短语中的被动语态。例如,“the book was great”可以转换为“the great book”。

如何做到...

transforms.py中有一个名为swap_verb_phrase()的函数。它使用动词作为支点,将块的右侧与左侧交换。它使用前一个菜谱中定义的first_chunk_index()函数来找到要围绕其进行转换的动词。

def swap_verb_phrase(chunk):
  # find location of verb
  vbpred = lambda (word, tag): tag != 'VBG' and tag.startswith('VB') and len(tag) > 2
  vbidx = first_chunk_index(chunk, vbpred)

  if vbidx is None:
    return chunk

  return chunk[vbidx+1:] + chunk[:vbidx]

现在,我们可以看到它如何在词性标注的短语“the book was great”上工作。

>>> from transforms import swap_verb_phrase
>>> swap_verb_phrase([('the', 'DT'), ('book', 'NN'), ('was', 'VBD'), ('great', 'JJ')])
[('great', 'JJ'), ('the', 'DT'), ('book', 'NN')]

结果是“伟大的书”。这个短语显然在语法上不正确,所以请继续阅读,了解如何修复它。

它是如何工作的...

使用前一个菜谱中的first_chunk_index(),我们首先找到第一个匹配的动词,该动词不是现在分词(以“ing”结尾的词)并标注为 VBG。一旦我们找到动词,我们就返回带有正确左右顺序的块,并移除动词。

我们不希望围绕现在分词进行转换的原因是,现在分词通常用来描述名词,围绕一个现在分词进行转换会移除那个描述。以下是一个例子,说明为什么不在现在分词周围进行转换是一个好事:

>>> swap_verb_phrase([('this', 'DT'), ('gripping', 'VBG'), ('book', 'NN'), ('is', 'VBZ'), ('fantastic', 'JJ')])
[('fantastic', 'JJ'), ('this', 'DT'), ('gripping', 'VBG'), ('book', 'NN')]

如果我们围绕现在分词进行转换,结果将是“book is fantastic this”,我们会失去现在分词“gripping”。

还有更多...

过滤掉不重要的词可以使最终结果更易读。通过在swap_verb_phrase()之前或之后过滤,我们得到“精彩的扣人心弦的书”,而不是“精彩的这本书扣人心弦”。

>>> from transforms import swap_verb_phrase, filter_insignificant
>>> swap_verb_phrase(filter_insignificant([('this', 'DT'), ('gripping', 'VBG'), ('book', 'NN'), ('is', 'VBZ'), ('fantastic', 'JJ')]))
[('fantastic', 'JJ'), ('gripping', 'VBG'), ('book', 'NN')]
>>> filter_insignificant(swap_verb_phrase([('this', 'DT'), ('gripping', 'VBG'), ('book', 'NN'), ('is', 'VBZ'), ('fantastic', 'JJ')]))
[('fantastic', 'JJ'), ('gripping', 'VBG'), ('book', 'NN')]

不论哪种方式,我们都能得到一个没有意义损失但更短的语法块。

参见

前一个菜谱定义了first_chunk_index(),它用于在块中找到动词。

交换名词基数

在一个块中,一个基数词(标记为 CD)指的是一个数字,例如“10”。这些基数词通常位于名词之前或之后。为了归一化目的,始终将基数词放在名词之前可能很有用。

如何操作...

函数 swap_noun_cardinal() 定义在 transforms.py 中。它将任何紧接在名词之后的基数词与名词交换,使得基数词紧接在名词之前。

def swap_noun_cardinal(chunk):
  cdidx = first_chunk_index(chunk, lambda (word, tag): tag == 'CD')
  # cdidx must be > 0 and there must be a noun immediately before it
  if not cdidx or not chunk[cdidx-1][1].startswith('NN'):
    return chunk

  noun, nntag = chunk[cdidx-1]
  chunk[cdidx-1] = chunk[cdidx]
  chunk[cdidx] = noun, nntag
  return chunk

让我们在日期“Dec 10”和另一个常见短语“the top 10”上试一试。

>>> from transforms import swap_noun_cardinal
>>> swap_noun_cardinal([('Dec.', 'NNP'), ('10', 'CD')])
[('10', 'CD'), ('Dec.', 'NNP')]
>>> swap_noun_cardinal([('the', 'DT'), ('top', 'NN'), ('10', 'CD')])
[('the', 'DT'), ('10', 'CD'), ('top', 'NN')]

结果是数字现在位于名词之前,形成了“10 Dec”和“the 10 top”。

工作原理...

我们首先在块中寻找 CD 标签。如果没有找到 CD,或者 CD 位于块的开头,则块将按原样返回。CD 前面也必须有一个名词。如果我们找到一个 CD,其前面有名词,那么我们将名词和基数词就地交换。

参见

纠正动词形式 菜谱定义了 first_chunk_index() 函数,用于在块中查找标记的单词。

交换不定式短语

不定式短语的形式为“A of B”,例如“book of recipes”。这些短语通常可以转换成新的形式,同时保留相同的意思,例如“recipes book”。

如何操作...

通过寻找标记为 IN 的单词,可以找到一个不定式短语。在 transforms.py 中定义的函数 swap_infinitive_phrase() 将返回一个交换了 IN 词之后短语部分与 IN 词之前短语部分的块。

def swap_infinitive_phrase(chunk):
  inpred = lambda (word, tag): tag == 'IN' and word != 'like'
  inidx = first_chunk_index(chunk, inpred)

  if inidx is None:
    return chunk

  nnpred = lambda (word, tag): tag.startswith('NN')
  nnidx = first_chunk_index(chunk, nnpred, start=inidx, step=-1) or 0

  return chunk[:nnidx] + chunk[inidx+1:] + chunk[nnidx:inidx]

该函数现在可以用来将“book of recipes”转换成“recipes book”。

>>> from transforms import swap_infinitive_phrase
>>> swap_infinitive_phrase([('book', 'NN'), ('of', 'IN'), ('recipes', 'NNS')])
[('recipes', 'NNS'), ('book', 'NN')]

工作原理...

此函数与 交换动词短语 菜谱中描述的 swap_verb_phrase() 函数类似。inpred lambda 被传递给 first_chunk_index() 以查找标记为 IN 的单词。然后使用 nnpred 查找 IN 词之前出现的第一个名词,这样我们就可以在 IN 词之后、名词和块的开头之间插入块的部分。一个更复杂的例子应该可以演示这一点:

>>> swap_infinitive_phrase([('delicious', 'JJ'), ('book', 'NN'), ('of', 'IN'), ('recipes', 'NNS')])
[('delicious', 'JJ'), ('recipes', 'NNS'), ('book', 'NN')]

我们不希望结果是“recipes delicious book”。相反,我们希望在名词“book”之前、形容词“delicious”之后插入“recipes”。因此,需要找到 nnidx 发生在 inidx 之前。

更多内容...

你会注意到 inpred lambda 会检查单词是否不是“like”。这是因为“like”短语必须以不同的方式处理,因为以相同的方式转换会导致不规则的短语。例如,“tastes like chicken”不应该转换成“chicken tastes”。

>>> swap_infinitive_phrase([('tastes', 'VBZ'), ('like', 'IN'), ('chicken', 'NN')])
[('tastes', 'VBZ'), ('like', 'IN'), ('chicken', 'NN')]

参见

在下一个菜谱中,我们将学习如何将“recipes book”转换成更常见的“recipe book”形式。

单复数名词

正如我们在之前的食谱中看到的,转换过程可能导致诸如 "recipes book" 这样的短语。这是一个 NNS 后跟一个 NN,当短语的一个更合适的版本是 "recipe book",这是一个 NN 后跟另一个 NN。我们可以进行另一个转换来纠正这些不正确的复数名词。

如何做到这一点...

transforms.py 定义了一个名为 singularize_plural_noun() 的函数,该函数将去复数化一个复数名词(标记为 NNS),它后面跟着另一个名词。

def singularize_plural_noun(chunk):
  nnspred = lambda (word, tag): tag == 'NNS'
  nnsidx = first_chunk_index(chunk, nnspred)

  if nnsidx is not None and nnsidx+1 < len(chunk) and chunk[nnsidx+1][1][:2] == 'NN':
    noun, nnstag = chunk[nnsidx]
    chunk[nnsidx] = (noun.rstrip('s'), nnstag.rstrip('S'))

  return chunk

在 "recipes book" 上使用它,我们得到更正确的形式,"recipe book"。

>>> from transforms import singularize_plural_noun
>>> singularize_plural_noun([('recipes', 'NNS'), ('book', 'NN')])
[('recipe', 'NN'), ('book', 'NN')]

它是如何工作的...

我们首先寻找带有标签 NNS 的复数名词。如果找到,并且下一个词是一个名词(通过确保标签以 NN 开头来确定),那么我们就通过从标签和单词的右侧都去掉 "s" 来去复数化复数名词。

假设标签是大写的,所以从标签的右侧去掉一个 "S",同时从单词的右侧去掉一个 "s"。

参见

之前的食谱展示了如何一个转换可以导致一个复数名词后面跟着一个单数名词,尽管这在现实世界的文本中也可能自然发生。

连接块转换

在之前的食谱中定义的转换函数可以连接起来以规范化块。结果块通常更短,但不会失去意义。

如何做到这一点...

transforms.py 中是 transform_chunk() 函数。它接受一个单独的块和一个可选的转换函数列表。它对块上的每个转换函数逐一调用,并返回最终的块。

def transform_chunk(chunk, chain=[filter_insignificant, swap_verb_phrase, swap_infinitive_phrase, singularize_plural_noun], trace=0):
  for f in chain:
    chunk = f(chunk)

    if trace:
      print f.__name__, ':', chunk

  return chunk

在短语 "the book of recipes is delicious" 上使用它,我们得到 "delicious recipe book":

>>> from transforms import transform_chunk
>>> transform_chunk([('the', 'DT'), ('book', 'NN'), ('of', 'IN'), ('recipes', 'NNS'), ('is', 'VBZ'), ('delicious', 'JJ')])
[('delicious', 'JJ'), ('recipe', 'NN'), ('book', 'NN')]

它是如何工作的...

transform_chunk() 函数默认按顺序链接以下函数:

  • filter_insignificant()

  • swap_verb_phrase()

  • swap_infinitive_phrase()

  • singularize_plural_noun()

每个函数都转换由前一个函数产生的块,从原始块开始。

注意

应用转换函数的顺序可能很重要。通过实验自己的数据来确定哪些转换最好,以及它们应该按什么顺序应用。

更多内容...

你可以将 trace=1 传递给 transform_chunk() 以在每个步骤得到输出。

>>> from transforms import transform_chunk
>>> transform_chunk([('the', 'DT'), ('book', 'NN'), ('of', 'IN'), ('recipes', 'NNS'), ('is', 'VBZ'), ('delicious', 'JJ')], trace=1)
filter_insignificant : [('book', 'NN'), ('of', 'IN'), ('recipes', 'NNS'), ('is', 'VBZ'), ('delicious', 'JJ')]
swap_verb_phrase : [('delicious', 'JJ'), ('book', 'NN'), ('of', 'IN'), ('recipes', 'NNS')]
swap_infinitive_phrase : [('delicious', 'JJ'), ('recipes', 'NNS'), ('book', 'NN')]
singularize_plural_noun : [('delicious', 'JJ'), ('recipe', 'NN'), ('book', 'NN')]
[('delicious', 'JJ'), ('recipe', 'NN'), ('book', 'NN')]

这显示了每个转换函数的结果,然后这些结果被传递到下一个转换函数,直到返回一个最终块。

参见

使用的转换函数在上一章的食谱中定义。

将块树转换为文本

在某个时候,你可能想将一个 Tree 或子树转换回句子或块字符串。这通常很简单,除了在正确输出标点符号时。

如何做到这一点...

我们将使用 treebank_chunk 的第一个 Tree 作为我们的例子。显然的第一步是将树中的所有单词用空格连接起来。

>>> from nltk.corpus import treebank_chunk
>>> tree = treebank_chunk.chunked_sents()[0]
>>> ' '.join([w for w, t in tree.leaves()])
'Pierre Vinken , 61 years old , will join the board as a nonexecutive director Nov. 29 .'

如你所见,标点符号并不完全正确。逗号和句号被当作单独的单词处理,因此也获得了周围的空格。我们可以使用正则表达式替换来修复这个问题。这已经在transforms.py中找到的chunk_tree_to_sent()函数中实现。

import re
punct_re = re.compile(r'\s([,\.;\?])')

def chunk_tree_to_sent(tree, concat=' '):
  s = concat.join([w for w, t in tree.leaves()])
  return re.sub(punct_re, r'\g<1>', s)

使用这个函数可以得到一个更干净的句子,每个标点符号前都没有空格:

>>> from transforms import chunk_tree_to_sent
>>> chunk_tree_to_sent(tree)
'Pierre Vinken, 61 years old, will join the board as a nonexecutive director Nov. 29.'

它是如何工作的...

为了纠正标点符号前的额外空格,我们创建了一个正则表达式punct_re,它将匹配一个空格后跟任何已知的标点符号字符。由于.?是特殊字符,我们必须用\来转义它们。标点符号被括号包围,这样我们就可以使用匹配的组来进行替换。

一旦我们有了我们的正则表达式,我们就定义了chunk_tree_to_sent()函数,其第一步是使用默认为空格的连接字符将单词连接起来。然后我们可以调用re.sub()来替换所有标点符号匹配项,只保留标点符号组。这消除了标点符号前的空格,从而得到一个更正确的字符串。

更多...

我们可以通过使用nltk.tag.untag()来简化这个函数,以获取树叶中的单词,而不是使用我们自己的列表推导。

import nltk.tag, re
punct_re = re.compile(r'\s([,\.;\?])')

def chunk_tree_to_sent(tree, concat=' '):
  s = concat.join(nltk.tag.untag(tree.leaves()))
  return re.sub(punct_re, r'\g<1>', s)

参见

nltk.tag.untag()函数在第四章的默认标注食谱的结尾进行了介绍,词性标注

展平深层树

其中一些包含的语料库包含解析句子,这些句子通常是嵌套短语的深层树。不幸的是,这些树太深了,不能用于训练 chunker,因为 IOB 标签解析不是为嵌套 chunk 设计的。为了使这些树可用于 chunker 训练,我们必须将它们展平。

准备中

我们将使用treebank语料库的第一个解析句子作为我们的例子。以下是一个显示这个树有多深嵌套的图示:

准备中

你可能会注意到词性标签是树结构的一部分,而不是与单词一起包含。这将在使用Tree.pos()方法时得到处理,该方法专门用于将单词与预终端Tree节点(如词性标签)结合。

如何操作...

transforms.py中有一个名为flatten_deeptree()的函数。它接受一个单个Tree,并将返回一个新的Tree,该Tree只保留最低级别的树。它使用一个辅助函数flatten_childtrees()来完成大部分工作。

from nltk.tree import Tree

def flatten_childtrees(trees):
  children = []

  for t in trees:
    if t.height() < 3:
      children.extend(t.pos())
    elif t.height() == 3:
      children.append(Tree(t.node, t.pos()))
    else:
      children.extend(flatten_childtrees([c for c in t]))

  return children

def flatten_deeptree(tree):
  return Tree(tree.node, flatten_childtrees([c for c in tree]))

我们可以在treebank语料库的第一个解析句子上使用它来得到一个更平展的树:

>>> from nltk.corpus import treebank
>>> from transforms import flatten_deeptree
>>> flatten_deeptree(treebank.parsed_sents()[0])
Tree('S', [Tree('NP', [('Pierre', 'NNP'), ('Vinken', 'NNP')]), (',', ','), Tree('NP', [('61', 'CD'), ('years', 'NNS')]), ('old', 'JJ'), (',', ','), ('will', 'MD'), ('join', 'VB'), Tree('NP', [('the', 'DT'), ('board', 'NN')]), ('as', 'IN'), Tree('NP', [('a', 'DT'), ('nonexecutive', 'JJ'), ('director', 'NN')]), Tree('NP-TMP', [('Nov.', 'NNP'), ('29', 'CD')]), ('.', '.')])

结果是一个更平展的Tree,它只包括 NP 短语。不是 NP 短语部分的单词被分开。这个更平展的树如下所示:

如何操作...

这个 Treetreebank_chunk 语料库中的第一个块 Tree 非常相似。主要区别在于,在之前的图中,最右边的 NP Tree 被分成了两个子树,其中一个被命名为 NP-TMP。

以下是将 treebank_chunk 中的第一个树显示出来以供比较:

如何做...

如何工作...

解决方案由两个函数组成:flatten_deeptree() 通过对给定树的每个子树调用 flatten_childtrees() 来从给定的树返回一个新的 Tree

flatten_childtrees() 是一个递归函数,它深入到 Tree 中,直到找到高度等于或小于三的子树。高度小于三的 Tree 看起来像这样:

>>> from nltk.tree import Tree
>>> Tree('NNP', ['Pierre']).height()
2

如何工作...

这些短树通过 pos() 函数被转换成元组的列表。

>>> Tree('NNP', ['Pierre']).pos()
[('Pierre', 'NNP')]

我们感兴趣保留的最低级别的树的高度等于三。这些树看起来像这样:

>>> Tree('NP', [Tree('NNP', ['Pierre']), Tree('NNP', ['Vinken'])]).height()
3

如何工作...

当我们在那个树上调用 pos() 时,我们得到:

>>> Tree('NP', [Tree('NNP', ['Pierre']), Tree('NNP', ['Vinken'])]).pos()
[('Pierre', 'NNP'), ('Vinken', 'NNP')]

flatten_childtrees() 的递归性质消除了所有高度大于三的树。

更多...

展平深层 Tree 允许我们在展平的 Tree 上调用 nltk.chunk.util.tree2conlltags(),这是训练分块器的一个必要步骤。如果你在展平 Tree 之前尝试调用此函数,你会得到一个 ValueError 异常。

>>> from nltk.chunk.util import tree2conlltags
>>> tree2conlltags(treebank.parsed_sents()[0])
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/lib/python2.6/dist-packages/nltk/chunk/util.py", line 417, in tree2conlltags
    raise ValueError, "Tree is too deeply nested to be printed in CoNLL format"
ValueError: Tree is too deeply nested to be printed in CoNLL format

然而,在展平之后没有问题:

>>> tree2conlltags(flatten_deeptree(treebank.parsed_sents()[0]))
[('Pierre', 'NNP', 'B-NP'), ('Vinken', 'NNP', 'I-NP'), (',', ',', 'O'), ('61', 'CD', 'B-NP'), ('years', 'NNS', 'I-NP'), ('old', 'JJ', 'O'), (',', ',', 'O'), ('will', 'MD', 'O'), ('join', 'VB', 'O'), ('the', 'DT', 'B-NP'), ('board', 'NN', 'I-NP'), ('as', 'IN', 'O'), ('a', 'DT', 'B-NP'), ('nonexecutive', 'JJ', 'I-NP'), ('director', 'NN', 'I-NP'), ('Nov.', 'NNP', 'B-NP-TMP'), ('29', 'CD', 'I-NP-TMP'), ('.', '.', 'O')]

能够展平树,为在由深层解析树组成的语料库上训练分块器打开了可能性。

CESS-ESP 和 CESS-CAT 树库

cess_espcess_cat 语料库有解析过的句子,但没有分块句子。换句话说,它们有必须展平以训练分块器的深层树。实际上,树太深了,无法用图表显示,但可以通过显示展平前后的树的高度来演示展平过程。

>>> from nltk.corpus import cess_esp
>>> cess_esp.parsed_sents()[0].height()
22
>>> flatten_deeptree(cess_esp.parsed_sents()[0]).height()
3

参考也

在 第五章 的 基于标签器的分块器训练 菜谱中,提取分块 覆盖了使用 IOB 标签训练分块器。

创建浅层树

在上一个菜谱中,我们通过仅保留最低级别的子树来展平了一个深层的 Tree。在这个菜谱中,我们将只保留最高级别的子树。

如何做...

我们将使用 treebank 语料库中的第一个解析句子作为我们的示例。回想一下,上一个菜谱中的句子 Tree 看起来像这样:

如何做...

transforms.py 中定义的 shallow_tree() 函数消除了所有嵌套的子树,只保留顶层树节点。

from nltk.tree import Tree

def shallow_tree(tree):
  children = []

  for t in tree:
    if t.height() < 3:
      children.extend(t.pos())
    else:
      children.append(Tree(t.node, t.pos()))

  return Tree(tree.node, children)

treebank 中的第一个解析句子上使用它,结果得到一个只有两个子树的 Tree

>>> from transforms import shallow_tree
>>> shallow_tree(treebank.parsed_sents()[0])
Tree('S', [Tree('NP-SBJ', [('Pierre', 'NNP'), ('Vinken', 'NNP'), (',', ','), ('61', 'CD'), ('years', 'NNS'), ('old', 'JJ'), (',', ',')]), Tree('VP', [('will', 'MD'), ('join', 'VB'), ('the', 'DT'), ('board', 'NN'), ('as', 'IN'), ('a', 'DT'), ('nonexecutive', 'JJ'), ('director', 'NN'), ('Nov.', 'NNP'), ('29', 'CD')]), ('.', '.')])

我们可以直观地看到差异,如下面的图表和代码所示:

如何做...

>>> treebank.parsed_sents()[0].height()
7
>>> shallow_tree(treebank.parsed_sents()[0]).height()
3

与前一个示例一样,新树的高度为三,因此它可以用于训练分块器。

工作原理...

shallow_tree() 函数按顺序遍历每个顶级子树以创建新的子树。如果一个子树的 height() 小于三个,则该子树被替换为其词性标注的子节点列表。所有其他子树都被替换为一个新的 Tree,其子节点是词性标注的叶子节点。这消除了所有嵌套的子树,同时保留了顶级子树。

这个函数是前一个示例中的 flatten_deeptree() 的替代方案,当你想要保留高级别的树节点并忽略低级别节点时使用。

参见

前一个示例介绍了如何展平 Tree 并保留最低级别的子树,而不是保留最高级别的子树。

转换树节点

如前文示例所示,解析树通常具有各种 Tree 节点类型,这些类型在块树中并不存在。如果你想使用解析树来训练一个分块器,那么你可能希望通过将这些树节点转换为更常见的节点类型来减少这种多样性。

准备工作

首先,我们必须决定哪些 Tree 节点需要被转换。让我们再次看看那个第一个 Tree

准备工作

立刻就可以看到有两个可选的 NP 子树:NP-SBJ 和 NP-TMP。让我们将这两个都转换为 NP。映射将如下所示:

原始节点 新节点
NP-SBJ NP
NP-TMP NP

如何实现...

transforms.py 中有一个名为 convert_tree_nodes() 的函数。它接受两个参数:要转换的 Tree 和节点转换 映射。它返回一个新的 Tree,其中所有匹配的节点根据 映射 中的值被替换。

from nltk.tree import Tree

def convert_tree_nodes(tree, mapping):
  children = []

  for t in tree:
    if isinstance(t, Tree):
      children.append(convert_tree_nodes(t, mapping))
    else:
      children.append(t)

  node = mapping.get(tree.node, tree.node)
  return Tree(node, children)

使用前面显示的映射表,我们可以将其作为 dict 传递给 convert_tree_nodes() 并将 treebank 的第一个解析句子进行转换。

>>> from transforms import convert_tree_nodes
>>> mapping = {'NP-SBJ': 'NP', 'NP-TMP': 'NP'}
>>> convert_tree_nodes(treebank.parsed_sents()[0], mapping)
Tree('S', [Tree('NP', [Tree('NP', [Tree('NNP', ['Pierre']), Tree('NNP', ['Vinken'])]), Tree(',', [',']), Tree('ADJP', [Tree('NP', [Tree('CD', ['61']), Tree('NNS', ['years'])]), Tree('JJ', ['old'])]), Tree(',', [','])]), Tree('VP', [Tree('MD', ['will']), Tree('VP', [Tree('VB', ['join']), Tree('NP', [Tree('DT', ['the']), Tree('NN', ['board'])]), Tree('PP-CLR', [Tree('IN', ['as']), Tree('NP', [Tree('DT', ['a']), Tree('JJ', ['nonexecutive']), Tree('NN', ['director'])])]), Tree('NP', [Tree('NNP', ['Nov.']), Tree('CD', ['29'])])])]), Tree('.', ['.'])])

在下面的图中,你可以看到 NP-* 子树已被替换为 NP 子树:

如何实现...

工作原理...

convert_tree_nodes() 递归地使用 映射 转换每个子树。然后,使用转换后的节点和子节点重建 Tree,直到整个 Tree 被转换。

结果是一个全新的 Tree 实例,其中新的子树节点已被转换。

参见

前两个示例介绍了不同的解析 Tree 展平方法,这两种方法都可以产生在使用它们来训练分块器之前可能需要映射的子树。分块器训练在 第五章 的 基于标签器的分块器训练 示例中介绍,提取分块

第七章:文本分类

在本章中,我们将涵盖:

  • 词袋特征提取

  • 训练朴素贝叶斯分类器

  • 训练决策树分类器

  • 训练最大熵分类器

  • 测量分类器的精确率和召回率

  • 计算高信息词

  • 结合分类器进行投票

  • 使用多个二元分类器进行分类

简介

文本分类是将文档或文本片段进行分类的一种方法。通过检查文本中的单词使用情况,分类器可以决定将其分配给什么类标签。一个二元分类器在两个标签之间进行选择,例如正面或负面。文本可以是其中一个标签,但不能同时是两个,而多标签分类器可以给一个文本片段分配一个或多个标签。

分类是通过从标记特征集或训练数据中学习,以后来对未标记特征集进行分类。特征集基本上是特征名称特征值的键值映射。在文本分类的情况下,特征名称通常是单词,而值都是True。由于文档可能包含未知单词,并且可能的单词数量可能非常大,因此省略了未出现在文本中的单词,而不是将它们包含在具有False值的特征集中。

实例是一个单独的特征集。它代表了一个特征组合的单次出现。我们将交替使用实例特征集。一个标记特征集是一个具有已知类标签的实例,我们可以用它进行训练或评估。

词袋特征提取

文本特征提取是将本质上是一系列单词转换成分类器可用的特征集的过程。NLTK 分类器期望dict风格的特征集,因此我们必须将我们的文本转换成dict词袋模型是最简单的方法;它从一个实例的所有单词中构建一个单词出现特征集。

如何实现...

理念是将单词列表转换成一个dict,其中每个单词成为一个键,其值为Truefeatx.py中的bag_of_words()函数看起来像这样:

def bag_of_words(words):
  return dict([(word, True) for word in words])

我们可以用一个单词列表使用它,在这种情况下是分词后的句子"the quick brown fox":

>>> from featx import bag_of_words
>>> bag_of_words(['the', 'quick', 'brown', 'fox'])
{'quick': True, 'brown': True, 'the': True, 'fox': True}

结果的dict被称为词袋,因为单词没有顺序,它们在单词列表中的位置或出现的次数无关紧要。唯一重要的是单词至少出现一次。

它是如何工作的...

bag_of_words()函数是一个非常简单的列表推导,它从给定的单词构建一个dict,其中每个单词都得到True的值。

由于我们必须为每个单词分配一个值以创建dict,所以True是一个逻辑上的选择,用于表示单词的存在。如果我们知道所有可能的单词的宇宙,我们可以将值False分配给不在给定单词列表中的所有单词。但大多数时候,我们事先不知道所有可能的单词。此外,将False分配给所有可能的单词所得到的dict将会非常大(假设所有单词都是可能的)。因此,为了保持特征提取简单并使用更少的内存,我们坚持将值True分配给至少出现一次的所有单词。我们不分配False给任何单词,因为我们不知道可能的单词集合是什么;我们只知道我们给出的单词。

更多...

在默认的词袋模型中,所有单词都被同等对待。但这并不总是好主意。正如我们已经知道的,有些单词非常常见,以至于它们实际上没有意义。如果你有一组想要排除的单词,你可以使用featx.py中的bag_of_words_not_in_set()函数。

def bag_of_words_not_in_set(words, badwords):
  return bag_of_words(set(words) - set(badwords))

此函数可用于过滤停用词。以下是一个示例,其中我们从“the quick brown fox”中过滤掉单词“the”:

>>> from featx import bag_of_words_not_in_set
>>> bag_of_words_not_in_set(['the', 'quick', 'brown', 'fox'], ['the'])
{'quick': True, 'brown': True, 'fox': True}

如预期的那样,结果dict中有“quick”、“brown”和“fox”,但没有“the”。

过滤停用词

这是一个使用bag_of_words_not_in_set()函数过滤所有英语停用词的示例:

from nltk.corpus import stopwords

def bag_of_non_stopwords(words, stopfile='english'):
  badwords = stopwords.words(stopfile)
  return bag_of_words_not_in_set(words, badwords)

如果你使用的是除英语以外的语言,你可以将不同的语言文件名作为stopfile关键字参数传递。使用此函数产生的结果与上一个示例相同:

>>> from featx import bag_of_non_stopwords
>>> bag_of_non_stopwords(['the', 'quick', 'brown', 'fox'])
{'quick': True, 'brown': True, 'fox': True}

在这里,“the”是一个停用词,所以它不会出现在返回的dict中。

包括重要的二元组

除了单个单词外,通常包括重要的二元组也很有帮助。因为重要的二元组比大多数单个单词更不常见,所以在词袋模型中包括它们可以帮助分类器做出更好的决策。我们可以使用第一章中“发现词搭配”食谱中提到的BigramCollocationFinder来找到重要的二元组。featx.py中的bag_of_bigrams_words()函数将返回一个包含所有单词以及 200 个最显著二元组的dict

from nltk.collocations import BigramCollocationFinder
from nltk.metrics import BigramAssocMeasures

def bag_of_bigrams_words(words, score_fn=BigramAssocMeasures.chi_sq, n=200):
  bigram_finder = BigramCollocationFinder.from_words(words)
  bigrams = bigram_finder.nbest(score_fn, n)
  return bag_of_words(words + bigrams)

二元组将以(word1, word2)的形式出现在返回的dict中,并将具有True的值。使用与之前相同的示例单词,我们得到所有单词以及每个二元组:

>>> from featx import bag_of_bigrams_words
>>> bag_of_bigrams_words(['the', 'quick', 'brown', 'fox'])
{'brown': True, ('brown', 'fox'): True, ('the', 'quick'): True, 'fox': True, ('quick', 'brown'): True, 'quick': True, 'the': True}

你可以通过改变关键字参数n来更改找到的最大二元组数。

参见

第一章中“发现词搭配”食谱的Tokenizing Text and WordNet Basics更详细地介绍了BigramCollocationFinder。在下一个食谱中,我们将使用词袋模型创建的特征集来训练NaiveBayesClassifier

训练朴素贝叶斯分类器

现在我们可以从文本中提取特征,我们可以训练一个分类器。最容易开始的分类器是 NaiveBayesClassifier。它使用 贝叶斯定理 来预测给定特征集属于特定标签的概率。公式是:

P(label | features) = P(label) * P(features | label) / P(features)
  • P(label) 是标签发生的先验概率,这与随机特征集具有该标签的似然性相同。这是基于具有该标签的训练实例数与训练实例总数的比例。例如,如果有 60/100 的训练实例具有该标签,则该标签的先验概率是 60%。

  • P(features | label) 是给定特征集被分类为该标签的先验概率。这是基于在训练数据中哪些特征与每个标签一起发生的。

  • P(features) 是给定特征集发生的先验概率。这是随机特征集与给定特征集相同的似然性,基于训练数据中观察到的特征集。例如,如果给定特征集在 100 个训练实例中出现了两次,则先验概率是 2%。

  • P(label | features) 告诉我们给定特征应该具有该标签的概率。如果这个值很高,那么我们可以合理地确信该标签对于给定特征是正确的。

准备工作

我们将使用 movie_reviews 语料库作为我们最初的分类示例。这个语料库包含两种文本类别:posneg。这些类别是互斥的,这使得在它们上训练的分类器是一个 二元分类器。二元分类器只有两个分类标签,并且总是选择其中一个。

movie_reviews 语料库中的每个文件都是由正面或负面电影评论组成的。我们将使用每个文件作为训练和测试分类器的单个实例。由于文本的性质及其类别,我们将进行的分类是一种 情感分析。如果分类器返回 pos,则文本表达 积极情感;而如果得到 neg,则文本表达 消极情感

如何操作...

对于训练,我们首先需要创建一个标记特征集的列表。这个列表应该是 [(featureset, label)] 的形式,其中 featureset 是一个 dictlabelfeatureset 的已知类标签。featx.py 中的 label_feats_from_corpus() 函数接受一个语料库,例如 movie_reviews,以及一个 feature_detector 函数,默认为 bag_of_words。然后它构建并返回一个形式为 {label: [featureset]} 的映射。我们可以使用这个映射来创建一个标记的 训练实例测试实例 的列表。这样做的原因是因为我们可以从每个标签中获得一个公平的样本。

import collections
def label_feats_from_corpus(corp, feature_detector=bag_of_words):
  label_feats = collections.defaultdict(list)
  for label in corp.categories():
    for fileid in corp.fileids(categories=[label]):
      feats = feature_detector(corp.words(fileids=[fileid]))
      label_feats[label].append(feats)
  return label_feats

一旦我们能够得到label : feature集合的映射,我们希望构建一个标签训练实例和测试实例的列表。featx.py中的split_label_feats()函数接受从label_feats_from_corpus()返回的映射,并将每个特征集合列表分割成标签训练实例和测试实例。

def split_label_feats(lfeats, split=0.75):
  train_feats = []
  test_feats = []
  for label, feats in lfeats.iteritems():
    cutoff = int(len(feats) * split)
    train_feats.extend([(feat, label) for feat in feats[:cutoff]])
    test_feats.extend([(feat, label) for feat in feats[cutoff:]])
  return train_feats, test_feats

使用这些函数与movie_reviews语料库一起,我们可以得到训练和测试分类器所需的标签特征集合列表。

>>> from nltk.corpus import movie_reviews
>>> from featx import label_feats_from_corpus, split_label_feats
>>> movie_reviews.categories()
['neg', 'pos']
>>> lfeats = label_feats_from_corpus(movie_reviews)
>>> lfeats.keys()
['neg', 'pos']
>>> train_feats, test_feats = split_label_feats(lfeats)
>>> len(train_feats)
1500
>>> len(test_feats)
500

因此有 1,000 个pos文件,1,000 个neg文件,我们最终得到 1,500 个标签训练实例和 500 个标签测试实例,每个实例由等量的posneg组成。现在我们可以使用它的train()类方法来训练一个NaiveBayesClassifier

>>> from nltk.classify import NaiveBayesClassifier
>>> nb_classifier = NaiveBayesClassifier.train(train_feats)
>>> nb_classifier.labels()
['neg', 'pos']

让我们在几个虚构的评论上测试一下分类器。classify()方法接受一个单一参数,这个参数应该是一个特征集合。我们可以使用相同的bag_of_words()特征检测器对一个虚构的单词列表进行检测,以获取我们的特征集合。

>>> from featx import bag_of_words
>>> negfeat = bag_of_words(['the', 'plot', 'was', 'ludicrous'])
>>> nb_classifier.classify(negfeat)
'neg'
>>> posfeat = bag_of_words(['kate', 'winslet', 'is', 'accessible'])
>>> nb_classifier.classify(posfeat)
'pos'

它是如何工作的...

label_feats_from_corpus()假设语料库是分类的,并且单个文件代表一个用于特征提取的实例。它遍历每个分类标签,并使用默认为bag_of_words()feature_detector()函数从该分类中的每个文件中提取特征。它返回一个字典,其键是分类标签,值是该分类的实例列表。

注意

如果label_feats_from_corpus()函数返回一个标签特征集合的列表,而不是字典,那么获取平衡的训练数据将会更加困难。列表将按标签排序,如果你从中取一个子集,你几乎肯定会得到比另一个标签多得多的一个标签。通过返回一个字典,你可以从每个标签的特征集合中取子集。

现在我们需要使用split_label_feats()将标签特征集合分割成训练和测试实例。这个函数允许我们从每个标签中公平地抽取标签特征集合的样本,使用split关键字参数来确定样本的大小。split默认为0.75,这意味着每个标签的前三分之四的标签特征集合将用于训练,剩下的四分之一将用于测试。

一旦我们将训练和测试特征分割开,我们使用NaiveBayesClassifier.train()方法训练一个分类器。这个类方法构建两个概率分布来计算先验概率。这些被传递给NaiveBayesClassifier构造函数。label_probdist包含P(label),每个标签的先验概率。feature_probdist包含P(feature name = feature value | label)。在我们的情况下,它将存储P(word=True | label)。这两个都是基于训练数据中每个标签以及每个特征名称和值的频率来计算的。

NaiveBayesClassifier 继承自 ClassifierI,这要求子类提供 labels() 方法,以及至少一个 classify()prob_classify() 方法。以下图显示了这些和其他方法,稍后将会介绍:

如何工作...

还有更多...

我们可以使用 nltk.classify.util.accuracy() 和之前创建的 test_feats 来测试分类器的准确度。

>>> from nltk.classify.util import accuracy
>>> accuracy(nb_classifier, test_feats)
0.72799999999999998

这告诉我们,分类器正确猜测了几乎 73% 的测试特征集的标签。

分类概率

虽然 classify() 方法只返回一个标签,但你可以使用 prob_classify() 方法来获取每个标签的分类概率。如果你想要使用超过 50% 的概率阈值进行分类,这可能很有用。

>>> probs = nb_classifier.prob_classify(test_feats[0][0])
>>> probs.samples()
['neg', 'pos']
>>> probs.max()
'pos'
>>> probs.prob('pos')
0.99999996464309127
>>> probs.prob('neg')
3.5356889692409258e-08

在这种情况下,分类器表示第一个测试实例几乎 100% 可能是 pos

最具信息量的特征

NaiveBayesClassifier 有两种非常实用的方法,可以帮助你了解你的数据。这两种方法都接受一个关键字参数 n 来控制显示多少个结果。most_informative_features() 方法返回一个形式为 [(feature name, feature value)] 的列表,按信息量从大到小排序。在我们的例子中,特征值总是 True

>>> nb_classifier.most_informative_features(n=5)
[('magnificent', True), ('outstanding', True), ('insulting', True), ('vulnerable', True), ('ludicrous', True)]

show_most_informative_features() 方法将打印出 most_informative_features() 的结果,并包括特征对属于每个标签的概率。

>>> nb_classifier.show_most_informative_features(n=5)
Most Informative Features

    magnificent = True    pos : neg = 15.0 : 1.0

    outstanding = True    pos : neg = 13.6 : 1.0

    insulting = True      neg : pos = 13.0 : 1.0

    vulnerable = True     pos : neg = 12.3 : 1.0

    ludicrous = True      neg : pos = 11.8 : 1.0

每个特征对的 信息量,或 信息增益,基于每个标签发生特征对的先验概率。信息量大的特征主要出现在一个标签中,而不是另一个标签。信息量小的特征是那些在两个标签中都频繁出现的特征。

训练估计器

在训练过程中,NaiveBayesClassifier 使用 estimator 参数构建其概率分布,该参数默认为 nltk.probability.ELEProbDist。但你可以使用任何你想要的 estimator,并且有很多可供选择。唯一的限制是它必须继承自 nltk.probability.ProbDistI,并且其构造函数必须接受一个 bins 关键字参数。以下是一个使用 LaplaceProdDist 的示例:

>>> from nltk.probability import LaplaceProbDist
>>> nb_classifier = NaiveBayesClassifier.train(train_feats, estimator=LaplaceProbDist)
>>> accuracy(nb_classifier, test_feats)
0.71599999999999997

如你所见,准确度略有下降,所以请仔细选择你的 estimator

注意

你不能使用 nltk.probability.MLEProbDist 作为估计器,或者任何不接受 bins 关键字参数的 ProbDistI 子类。训练将因 TypeError: __init__() got an unexpected keyword argument 'bins' 而失败。

手动训练

你不必使用 train() 类方法来构建 NaiveBayesClassifier。你可以手动创建 label_probdistfeature_probdistlabel_probdist 应该是 ProbDistI 的一个实例,并且应该包含每个标签的先验概率。feature_probdist 应该是一个 dict,其键是形式为 (label, feature name) 的元组,其值是具有每个特征值概率的 ProbDistI 的实例。在我们的情况下,每个 ProbDistI 应该只有一个值,True=1。以下是一个使用手动构建的 DictionaryProbDist 的非常简单的例子:

>>> from nltk.probability import DictionaryProbDist
>>> label_probdist = DictionaryProbDist({'pos': 0.5, 'neg': 0.5})
>>> true_probdist = DictionaryProbDist({True: 1})
>>> feature_probdist = {('pos', 'yes'): true_probdist, ('neg', 'no'): true_probdist}
>>> classifier = NaiveBayesClassifier(label_probdist, feature_probdist)
>>> classifier.classify({'yes': True})
'pos'
>>> classifier.classify({'no': True})
'neg'

参见

在接下来的菜谱中,我们将训练另外两个分类器,即 DecisionTreeClassifierMaxentClassifier。在本章的 Measuring precision and recall of a classifier 菜谱中,我们将使用精确率和召回率而不是准确率来评估分类器。然后在 Calculating high information words 菜谱中,我们将看到仅使用最有信息量的特征如何提高分类器性能。

movie_reviews 语料库是 CategorizedPlaintextCorpusReader 的一个实例,这在 第三章 的 Creating a categorized text corpus 菜谱中有详细说明,Creating Custom Corpora

训练决策树分类器

DecisionTreeClassifier 通过创建一个树结构来工作,其中每个节点对应一个特征名称,分支对应特征值。沿着分支追踪,你会到达树的叶子,它们是分类标签。

准备工作

为了使 DecisionTreeClassifier 能够用于文本分类,你必须使用 NLTK 2.0b9 或更高版本。这是因为早期版本无法处理未知特征。如果 DecisionTreeClassifier 遇到了它之前没有见过的单词/特征,那么它会引发异常。这个错误已经被我修复,并包含在所有自 2.0b9 版本以来的 NLTK 版本中。

如何操作...

使用我们在上一个菜谱中从 movie_reviews 语料库创建的相同的 train_featstest_feats,我们可以调用 DecisionTreeClassifier.train() 类方法来获取一个训练好的分类器。我们传递 binary=True,因为我们的所有特征都是二元的:要么单词存在,要么不存在。对于其他具有多值特征的分类用例,你将想要坚持默认的 binary=False

注意

在这个上下文中,binary指的是特征值,不要与二元分类器混淆。我们的词特征是二元的,因为值要么是True,要么该词不存在。如果我们的特征可以取超过两个值,我们就必须使用binary=False。另一方面,二元分类器是一种只选择两个标签的分类器。在我们的情况下,我们正在对二元特征训练一个二元DecisionTreeClassifier。但也可以有一个具有非二元特征的二元分类器,或者一个具有二元特征的非二元分类器。

下面是训练和评估DecisionTreeClassifier准确性的代码:

>>> from nltk.classify import DecisionTreeClassifier
>>> dt_classifier = DecisionTreeClassifier.train(train_feats, binary=True, entropy_cutoff=0.8, depth_cutoff=5, support_cutoff=30)
>>> accuracy(dt_classifier, test_feats)
0.68799999999999994

小贴士

DecisionTreeClassifier的训练时间可能比NaiveBayesClassifier长得多。因此,默认参数已被覆盖,以便更快地训练。这些参数将在后面解释。

它是如何工作的...

DecisionTreeClassifier,就像NaiveBayesClassifier一样,也是ClassifierI的一个实例。在训练过程中,DecisionTreeClassifier创建一个树,其中子节点也是DecisionTreeClassifier的实例。叶节点只包含一个标签,而中间子节点包含每个特征的决策映射。这些决策将每个特征值映射到另一个DecisionTreeClassifier,该DecisionTreeClassifier本身可能包含对另一个特征的决策,或者它可能是一个带有分类标签的最终叶节点。train()类方法从叶节点开始构建这个树。然后它通过将最有信息量的特征放在顶部来优化自己,以最小化到达标签所需的决策数量。

为了分类,DecisionTreeClassifier会查看给定的特征集,并沿着树向下追踪,使用已知的特征名称和值来做出决策。因为我们创建了一个二叉树,每个DecisionTreeClassifier实例还有一个默认决策树,当分类的特征集中不存在已知特征时,它就会使用这个决策树。这在基于文本的特征集中很常见,表明在分类的文本中没有出现已知单词。这也为分类决策提供了信息。

还有更多...

传递给DecisionTreeClassifier.train()的参数可以调整以提高准确性或减少训练时间。一般来说,如果你想提高准确性,你必须接受更长的训练时间,而如果你想减少训练时间,准确性很可能会降低。

熵截止值

entropy_cutoff在树优化过程中使用。如果树中标签选择概率分布的熵大于entropy_cutoff,则进一步优化树。但如果熵低于entropy_cutoff,则停止树优化。

是结果的不确定性。当熵接近 1.0 时,不确定性增加;相反,当熵接近 0.0 时,不确定性减少。换句话说,当你有相似的概率时,熵会很高,因为每个概率都有相似的似然性(或发生的不确定性)。但是,概率差异越大,熵就越低。

熵是通过给nltk.probability.entropy()传递一个由标签计数FreqDist创建的MLEProbDist来计算的。以下是一个显示各种FreqDist值的熵的示例:

>>> from nltk.probability import FreqDist, MLEProbDist, entropy
>>> fd = FreqDist({'pos': 30, 'neg': 10})
>>> entropy(MLEProbDist(fd))
0.81127812445913283
>>> fd['neg'] = 25
>>> entropy(MLEProbDist(fd))
0.99403021147695647
>>> fd['neg'] = 30
>>> entropy(MLEProbDist(fd))
1.0
>>> fd['neg'] = 1
>>> entropy(MLEProbDist(fd))
0.20559250818508304

这一切意味着,如果标签发生非常倾斜,树不需要被细化,因为熵/不确定性低。但是,当熵大于entropy_cutoff时,树必须通过进一步的决策来细化,以减少不确定性。entropy_cutoff的值越高,准确性和训练时间都会降低。

深度截止

depth_cutoff也在细化过程中使用,以控制树的深度。最终的决策树永远不会比depth_cutoff更深。默认值是100,这意味着分类可能需要最多 100 个决策才能到达叶节点。减少depth_cutoff将减少训练时间,并且很可能会降低准确性。

支持截止

support_ cutoff控制需要多少个标记的特征集来细化树。随着DecisionTreeClassifier的自我细化,一旦标记的特征集不再对训练过程有价值,它们就会被消除。当标记的特征集数量小于或等于support_cutoff时,细化停止,至少对于树的那个部分。

另一种看待它的方法是support_cutoff指定了做出关于一个特征的决定所需的最小实例数。如果support_cutoff20,而你拥有的标记特征集少于 20 个,那么你没有足够的实例来做出好的决定,并且围绕该特征的细化必须停止。

另请参阅

之前的配方涵盖了从movie_reviews语料库创建训练和测试特征集。在下一个配方中,我们将介绍如何训练MaxentClassifier,在本章的测量分类器的精确度和召回率配方中,我们将使用精确度和召回率来评估所有分类器。

训练最大熵分类器

我们将要介绍的第三个分类器是MaxentClassifier,也称为条件指数分类器最大熵分类器使用编码将标记的特征集转换为向量。然后,使用这个编码向量来计算每个特征的权重,然后可以将这些权重组合起来,以确定特征集的最可能标签。

准备工作

MaxentClassifier 分类器需要 numpy 包,并且可选地需要 scipy 包。这是因为特征编码使用了 numpy 数组。安装 scipy 也意味着你可以使用更快的算法,这些算法消耗更少的内存。你可以在 www.scipy.org/Installing_SciPy 找到两者的安装方法。

小贴士

许多算法可能会非常消耗内存,所以在训练 MaxentClassifier 时,你可能想要关闭所有其他程序,以确保安全。

如何操作...

我们将使用之前构建的 movie_reviews 语料库中的相同的 train_featstest_feats,并调用 MaxentClassifier.train() 类方法。与 DecisionTreeClassifier 类似,MaxentClassifier.train() 有其自己的特定参数,这些参数已被调整以加快训练速度。这些参数将在稍后进行更详细的解释。

>>> from nltk.classify import MaxentClassifier
>>> me_classifier = MaxentClassifier.train(train_feats, algorithm='iis', trace=0, max_iter=1, min_lldelta=0.5)
>>> accuracy(me_classifier, test_feats)
0.5

这个分类器准确率如此低的原因是因为参数被设置为无法学习更准确的模型。这是由于使用 iis 算法训练合适模型所需的时间。使用 scipy 算法可以更快地学习到更高准确率的模型。

小贴士

如果训练过程耗时过长,你通常可以通过按 Ctrl + C 手动中断。这应该会停止当前迭代,并基于模型当前的状态返回一个分类器。

它是如何工作的...

与之前的分类器一样,MaxentClassifier 继承自 ClassifierI。根据算法的不同,MaxentClassifier.train() 会调用 nltk.classify.maxent 模块中的某个训练函数。如果没有安装 scipy,则默认算法是 iis,使用的函数是 train_maxent_classifier_with_iis()。另一种不需要 scipy 的算法是 gis,它使用 train_maxent_classifier_with_gis() 函数。gis 代表 General Iterative Scaling,而 iis 代表 Improved Iterative Scaling。如果安装了 scipy,则使用 train_maxent_classifier_with_scipy() 函数,默认算法是 cg。如果安装了 megam 并指定了 megam 算法,则使用 train_maxent_classifier_with_megam()

最大熵模型背后的基本思想是构建一些适合观察数据的概率分布,然后选择具有最高熵的任何概率分布。gisiis 算法通过迭代改进用于分类特征的权重来实现这一点。这就是 max_itermin_lldelta 参数发挥作用的地方。

max_iter 指定了要遍历并更新权重的最大迭代次数。更多的迭代通常可以提高准确率,但仅限于某个程度。最终,从一个迭代到下一个迭代的改变将达到一个平台期,进一步的迭代将变得无用。

min_lldelta 指定了在迭代改进权重之前所需的 对数似然 的最小变化。在开始训练迭代之前,创建了一个 nltk.classify.util.CutoffChecker 的实例。当调用其 check() 方法时,它使用 nltk.classify.util.log_likelihood() 等函数来决定是否达到了截止限制。对数 似然 是训练数据平均标签概率的对数(即标签平均似然的对数)。随着对数似然的增加,模型会改进。但这也将达到一个平台期,进一步的增加非常小,继续下去没有意义。指定 min_lldelta 允许你控制每次迭代在停止迭代之前必须增加多少对数似然。

更多内容...

NaiveBayesClassifier 类似,你可以通过调用 show_most_informative_features() 方法来查看最有信息量的特征。

>>> me_classifier.show_most_informative_features(n=4)
-0.740 worst==True and label is 'pos'

0.740 worst==True and label is 'neg'

0.715 bad==True and label is 'neg'

-0.715 bad==True and label is 'pos'

显示的数字是每个特征的权重。这告诉我们,单词 worstpos 标签具有 负权重,对 neg 标签具有 正权重。换句话说,如果单词 worst 出现在特征集中,那么文本被分类为 neg 的可能性很高。

Scipy 算法

当安装了 scipy 时可用的算法有:

  • CG共轭梯度 算法)——默认的 scipy 算法

  • BFGSBroyden-Fletcher-Goldfarb-Shanno 算法)——非常占用内存

  • Powell

  • LBFGSB(BFGS 的内存限制版本)

  • Nelder-Mead

这是使用 CG 算法时发生的情况:

>>> me_classifier = MaxentClassifier.train(train_feats, algorithm='cg', trace=0, max_iter=10)
>>> accuracy(me_classifier, test_feats)
0.85599999999999998

这是迄今为止最准确的分类器。

Megam 算法

如果你已经安装了 megam 包,那么你可以使用 megam 算法。它比 scipy 算法快一点,并且大约一样准确。安装说明和信息可以在 www.cs.utah.edu/~hal/megam/ 找到。可以使用 nltk.classify.megam.config_megam() 函数来指定 megam 可执行文件的位置。或者,如果 megam 可以在标准可执行路径中找到,NLTK 将自动配置它。

>>> me_classifier = MaxentClassifier.train(train_feats, algorithm='megam', trace=0, max_iter=10)
[Found megam: /usr/local/bin/megam]
>>> accuracy(me_classifier, test_feats)
0.86799999999999999

megam 算法因其准确性和训练速度而被高度推荐。

参见

本章中关于 词袋特征提取训练朴素贝叶斯分类器 的食谱展示了如何从 movie_reviews 语料库中构建训练和测试特征。在下一个食谱中,我们将介绍如何以及为什么使用精确率和召回率而不是准确率来评估分类器。

测量分类器的精确率和召回率

除了准确率之外,还有许多其他指标用于评估分类器。其中最常见的是精确率召回率。为了理解这两个指标,我们首先必须理解假阳性假阴性假阳性发生在分类器将一个特征集错误地分类为一个它不应该有的标签时。假阴性发生在分类器没有将标签分配给应该有标签的特征集时。在二元分类器中,这些错误同时发生。

这里有一个例子:分类器将一个电影评论错误地分类为pos,而它应该是neg。这算作pos标签的假阳性,以及neg标签的假阴性。如果分类器正确地猜测了neg,那么它将算作neg标签的真阳性,以及pos标签的真阴性

这如何应用于精确率和召回率?精确率缺乏假阳性,而召回率缺乏假阴性。正如您将看到的,这两个指标通常存在竞争关系:一个分类器的精确率越高,召回率就越低,反之亦然。

如何去做...

让我们计算在训练朴素贝叶斯分类器菜谱中训练的NaiveBayesClassifier的精确率和召回率。classification.py中的precision_recall()函数看起来是这样的:

import collections
from nltk import metrics

def precision_recall(classifier, testfeats):
  refsets = collections.defaultdict(set)
  testsets = collections.defaultdict(set)

  for i, (feats, label) in enumerate(testfeats):
    refsets[label].add(i)
    observed = classifier.classify(feats)
    testsets[observed].add(i)

  precisions = {}
  recalls = {}

  for label in classifier.labels():
    precisions[label] = metrics.precision(refsets[label], testsets[label])
    recalls[label] = metrics.recall(refsets[label], testsets[label])

  return precisions, recalls

此函数接受两个参数:

  1. 训练好的分类器。

  2. 带标签的测试特征,也称为黄金标准。

这些是您传递给accuracy()函数的相同参数。precision_recall()返回两个字典;第一个包含每个标签的精确率,第二个包含每个标签的召回率。以下是一个使用我们在训练朴素贝叶斯分类器菜谱中较早创建的nb_classifiertest_feats的示例用法:

>>> from classification import precision_recall
>>> nb_precisions, nb_recalls = precision_recall(nb_classifier, test_feats)
>>> nb_precisions['pos']
0.6413612565445026
>>> nb_precisions['neg']
0.9576271186440678
>>> nb_recalls['pos']
0.97999999999999998
>>> nb_recalls['neg']
0.45200000000000001

这告诉我们,虽然NaiveBayesClassifier可以正确识别大多数pos特征集(高召回率),但它也将许多neg特征集错误地分类为pos(低精确率)。这种行为导致了neg标签的高精确率但低召回率——因为neg标签并不经常给出(低召回率),当它给出时,它非常可能是正确的(高精确率)。结论可能是,有一些常见的词语倾向于pos标签,但它们在neg特征集中出现的频率足够高,以至于导致错误分类。为了纠正这种行为,我们将在下一个菜谱中只使用最有信息量的词语,即计算高信息词语

它是如何工作的...

要计算精确率和召回率,我们必须为每个标签构建两个集合。第一个集合被称为参考集,包含所有正确的值。第二个集合称为测试集,包含分类器猜测的值。这两个集合被比较以计算每个标签的精确率或召回率。

精确率定义为两个集合交集的大小除以测试集的大小。换句话说,正确猜测的测试集百分比。在 Python 中,代码是 float(len(reference.intersection(test))) / len(test)

召回率是两个集合交集的大小除以参考集的大小,或者正确猜测的参考集百分比。Python 代码是 float(len(reference.intersection(test))) / len(reference)

classification.py 中的 precision_recall() 函数遍历标记的测试特征并对每个特征进行分类。我们将特征集的 数值索引(从 0 开始)存储在已知训练标签的参考集中,并将索引存储在测试集中以猜测标签。如果分类器猜测为 pos 但训练标签是 neg,则索引存储在 neg参考集pos测试集 中。

注意

我们使用数值索引,因为特征集是不可哈希的,我们需要为每个特征集提供一个唯一的值。

nltk.metrics 包包含计算精确率和召回率的函数,所以我们实际上需要做的只是构建集合,然后调用适当的函数。

更多内容...

让我们用之前配方中训练的 MaxentClassifier 来试试:

>>> me_precisions, me_recalls = precision_recall(me_classifier, test_feats)
>>> me_precisions['pos']
0.8801652892561983
>>> me_precisions['neg']
0.85658914728682167
>>> me_recalls['pos']
0.85199999999999998
>>> me_recalls['neg']
0.88400000000000001

这个分类器比 NaiveBayesClassifier 更加全面。在这种情况下,标签偏差不太重要,原因是 MaxentClassifier 根据其内部模型来权衡其特征。更有意义的词语是那些主要出现在单个标签中的词语,它们在模型中会获得更高的权重。同时出现在两个标签中的词语会获得较低的权重,因为它们不太重要。

F 度量

F 度量定义为精确率和召回率的加权调和平均。如果 p精确率,而 r召回率,则公式为:

1/(alpha/p + (1-alpha)/r)

其中 alpha 是一个默认值为 0.5 的权重常数。你可以使用 nltk.metrics.f_measure() 来获取 F 度量。它接受与 precision()recall() 函数相同的参数:一个参考集和一个测试集。它通常用于代替准确率来衡量分类器。然而,精确率和召回率被发现是更有用的度量标准,因为 F 度量可以隐藏我们之前在 NaiveBayesClassifier 中看到的那些不平衡情况。

参见

训练朴素贝叶斯分类器 配方中,我们收集了训练和测试特征集,并训练了 NaiveBayesClassifierMaxentClassifier训练最大熵分类器 配方中进行了训练。在下一个配方中,我们将探讨消除不太重要的词语,并仅使用高信息词来创建我们的特征集。

计算高信息词

高信息单词是指强烈偏向于单个分类标签的单词。这些是我们调用 NaiveBayesClassifierMaxentClassifier 上的 show_most_informative_features() 方法时所看到的单词类型。有些令人惊讶的是,两个分类器的顶级单词是不同的。这种差异是由于每个分类器计算每个特征的重要性方式不同,实际上拥有这些不同的方法是有益的,因为我们可以将它们结合起来提高准确性,正如我们将在下一个配方中看到的,使用投票结合分类器

低信息单词是指所有标签都共有的单词。这可能有些反直觉,但消除这些单词可以从训练数据中提高准确性、精确度和召回率。这种方法之所以有效,是因为仅使用高信息单词可以减少分类器内部模型的噪声和混淆。如果所有单词/特征都高度偏向某一方向,那么分类器做出正确猜测就更容易了。

如何做到这一点...

首先,我们需要计算 movie_review 语料库中的高信息单词。我们可以使用 featx.py 中的 high_information_words() 函数来完成这项工作:

from nltk.metrics import BigramAssocMeasures
from nltk.probability import FreqDist, ConditionalFreqDist

def high_information_words(labelled_words, score_fn=BigramAssocMeasures.chi_sq, min_score=5):
  word_fd = FreqDist()
  label_word_fd = ConditionalFreqDist()

  for label, words in labelled_words:
    for word in words:
      word_fd.inc(word)
      label_word_fd[label].inc(word)

  n_xx = label_word_fd.N()
  high_info_words = set()

  for label in label_word_fd.conditions():
    n_xi = label_word_fd[label].N()
    word_scores = collections.defaultdict(int)

    for word, n_ii in label_word_fd[label].iteritems():
      n_ix = word_fd[word]
      score = score_fn(n_ii, (n_ix, n_xi), n_xx)
      word_scores[word] = score

    bestwords = [word for word, score in word_scores.iteritems() if score >= min_score]
    high_info_words |= set(bestwords)

  return high_info_words

它需要一个参数,即形如 [(label, words)] 的 2-元组列表,其中 label 是分类标签,而 words 是在该标签下出现的单词列表。它返回一个按信息量从高到低排序的高信息单词列表。

一旦我们有了高信息单词,我们就使用特征检测器函数 bag_of_words_in_set(),它也位于 featx.py 中,这将允许我们过滤掉所有低信息单词。

def bag_of_words_in_set(words, goodwords):
  return bag_of_words(set(words) & set(goodwords))

使用这个新的特征检测器,我们可以调用 label_feats_from_corpus() 并使用 split_label_feats() 获取新的 train_featstest_feats。这两个函数在本章的 训练朴素贝叶斯分类器 配方中已有介绍。

>>> from featx import high_information_words, bag_of_words_in_set
>>> labels = movie_reviews.categories()
>>> labeled_words = [(l, movie_reviews.words(categories=[l])) for l in labels]
>>> high_info_words = set(high_information_words(labeled_words))
>>> feat_det = lambda words: bag_of_words_in_set(words, high_info_words)
>>> lfeats = label_feats_from_corpus(movie_reviews, feature_detector=feat_det)
>>> train_feats, test_feats = split_label_feats(lfeats)

现在我们有了新的训练和测试特征集,让我们训练并评估一个 NaiveBayesClassifier

>>> nb_classifier = NaiveBayesClassifier.train(train_feats)
>>> accuracy(nb_classifier, test_feats)
0.91000000000000003
>>> nb_precisions, nb_recalls = precision_recall(nb_classifier, test_feats)
>>> nb_precisions['pos']
0.89883268482490275
>>> nb_precisions['neg']
0.92181069958847739
>>> nb_recalls['pos']
0.92400000000000004
>>> nb_recalls['neg']
0.89600000000000002

虽然 neg 精确度和 pos 召回率都有所下降,但 neg 召回率和 pos 精确度都有显著提高。现在准确性略高于 MaxentClassifier

它是如何工作的...

high_information_words() 函数首先计算每个单词的频率,以及每个标签内每个单词的条件频率。这就是为什么我们需要标记单词,这样我们才知道每个单词在每个标签中出现的频率。

一旦我们有了这个 FreqDistConditionalFreqDist,我们就可以根据每个标签对每个单词进行评分。默认的 score_fnnltk.metrics.BigramAssocMeasures.chi_sq(),它使用以下参数计算每个单词的卡方得分:

  1. n_ii:单词在标签中的频率。

  2. n_ix:单词在所有标签中的总频率。

  3. n_xi:在标签中出现的所有单词的总频率。

  4. n_xx:所有标签中所有单词的总频率。

考虑这些数字的最简单方式是,n_ii越接近n_ix,得分就越高。或者,一个单词在标签中出现的频率相对于其整体出现频率越高,得分就越高。

一旦我们得到了每个标签中每个单词的分数,我们可以过滤掉所有得分低于min_score阈值的单词。我们保留满足或超过阈值的单词,并返回每个标签中所有得分高的单词。

小贴士

建议尝试不同的min_score值以观察其效果。在某些情况下,更少的单词可能会使指标进一步提升,而在其他情况下,更多的单词可能更佳。

更多...

BigramAssocMeasures类中还有许多其他评分函数可用,例如phi_sq()用于 phi-square,pmi()用于点互信息,以及jaccard()用于使用 Jaccard 指数。它们都接受相同的参数,因此可以与chi_sq()互换使用。

带有高信息单词的 MaxentClassifier

让我们使用高信息单词特征集来评估MaxentClassifier

>>> me_classifier = MaxentClassifier.train(train_feats, algorithm='megam', trace=0, max_iter=10)
>>> accuracy(me_classifier, test_feats)
0.88200000000000001
>>> me_precisions, me_recalls = precision_recall(me_classifier, test_feats)
>>> me_precisions['pos']
0.88663967611336036
>>> me_precisions['neg']
0.87747035573122534
>>> me_recalls['pos']
0.876
>>> me_recalls['neg']
0.88800000000000001

如您所见,由于MaxentClassifier已经根据重要性对所有特征进行了加权,因此与NaiveBayesClassifier相比,改进幅度要小得多。但仅使用高信息单词与使用所有单词相比,仍然有积极的影响。每个标签的精确率和召回率更接近,这使得MaxentClassifier的表现更加均衡。

带有高信息单词的 DecisionTreeClassifier

现在,让我们评估DecisionTreeClassifier

>>> dt_classifier = DecisionTreeClassifier.train(train_feats, binary=True, depth_cutoff=20, support_cutoff=20, entropy_cutoff=0.01)
>>> accuracy(dt_classifier, test_feats)
0.68600000000000005
>>> dt_precisions, dt_recalls = precision_recall(dt_classifier, test_feats)
>>> dt_precisions['pos']
0.6741573033707865
>>> dt_precisions['neg']
0.69957081545064381
>>> dt_recalls['pos']
0.71999999999999997
>>> dt_recalls['neg']
0.65200000000000002

即使在更大的depth_cutoff、较小的support_cutoffentropy_cutoff下,准确率也大致相同。结果表明,DecisionTreeClassifier已经将高信息特征置于树的最顶层,只有当我们显著增加深度时,它才会得到改善。但这可能会使训练时间变得过长。

参见

我们在本章开始时介绍了词袋模型特征提取的配方。NaiveBayesClassifier最初是在训练朴素贝叶斯分类器的配方中训练的,而MaxentClassifier是在训练最大熵分类器的配方中训练的。关于精确率和召回率的详细信息可以在测量分类器的精确率和召回率的配方中找到。在接下来的两个配方中,我们将只使用高信息单词,我们将结合分类器。

结合分类器进行投票

提高分类性能的一种方法是将分类器组合起来。组合多个分类器最简单的方法是使用投票,并选择获得最多投票的标签。对于这种投票方式,最好有奇数个分类器,这样就没有平局。这意味着至少需要组合三个分类器。单个分类器也应该使用不同的算法;想法是多个算法比一个更好,许多算法的组合可以弥补单个偏差。

准备工作

由于我们需要至少三个训练好的分类器来组合,我们将使用一个NaiveBayesClassifier、一个DecisionTreeClassifier和一个MaxentClassifier,它们都是在movie_reviews语料库的最高信息词上训练的。这些都是在前面的配方中训练的,所以我们将通过投票组合这三个分类器。

如何做...

classification.py模块中,有一个MaxVoteClassifier类。

import itertools
from nltk.classify import ClassifierI
from nltk.probability import FreqDist

class MaxVoteClassifier(ClassifierI):
  def __init__(self, *classifiers):
    self._classifiers = classifiers
    self._labels = sorted(set(itertools.chain(*[c.labels() for c in classifiers])))

  def labels(self):
    return self._labels

  def classify(self, feats):
    counts = FreqDist()

    for classifier in self._classifiers:
      counts.inc(classifier.classify(feats))

    return counts.max()

要创建它,你需要传入一个你想要组合的分类器列表。一旦创建,它的工作方式就像任何其他分类器一样。尽管分类可能需要大约三倍的时间,但它应该通常至少与任何单个分类器一样准确。

>>> from classification import MaxVoteClassifier
>>> mv_classifier = MaxVoteClassifier(nb_classifier, dt_classifier, me_classifier)
>>> mv_classifier.labels()
['neg', 'pos']
>>> accuracy(mv_classifier, test_feats)
0.89600000000000002
>>> mv_precisions, mv_recalls = precision_recall(mv_classifier, test_feats)
>>> mv_precisions['pos']
0.8928571428571429
>>> mv_precisions['neg']
0.89919354838709675
>>> mv_recalls['pos']
0.90000000000000002
>>> mv_recalls['neg']
0.89200000000000002

这些指标与MaxentClassifierNaiveBayesClassifier大致相当。一些数字略好,一些略差。很可能对DecisionTreeClassifier的重大改进会产生一些更好的数字。

它是如何工作的...

MaxVoteClassifier扩展了nltk.classify.ClassifierI接口,这要求实现至少两个方法:

  • labels()函数必须返回一个可能的标签列表。这将是从初始化时传入的每个分类器的labels()的并集。

  • classify()函数接受一个特征集并返回一个标签。MaxVoteClassifier遍历其分类器,并对每个分类器调用classify(),将它们的标签记录为FreqDist中的投票。使用FreqDist.max()返回获得最多投票的标签。

虽然MaxVoteClassifier没有检查这一点,但它假设在初始化时传入的所有分类器都使用相同的标签。违反这个假设可能会导致异常行为。

参见

在前面的配方中,我们仅使用最高信息词训练了NaiveBayesClassifierMaxentClassifierDecisionTreeClassifier。在下一个配方中,我们将使用reuters语料库并组合多个二元分类器来创建一个多标签分类器。

使用多个二元分类器进行分类

到目前为止,我们专注于二元分类器,它们通过两个可能标签中的一个进行分类。用于训练二元分类器的相同技术也可以用来创建多类分类器,这是一种可以通过许多可能标签中的一个进行分类的分类器。但也有需要能够用多个标签进行分类的情况。能够返回多个标签的分类器被称为多标签分类器

创建多标签分类器的一种常见技术是将许多二元分类器结合起来,每个标签一个。你训练每个二元分类器,使其要么返回一个已知标签,要么返回其他内容以表示该标签不适用。然后你可以在你的特征集上运行所有二元分类器以收集所有适用的标签。

准备就绪

reuters语料库包含多标签文本,我们可以用它来训练和评估。

>>> from nltk.corpus import reuters
>>> len(reuters.categories())
90

我们将为每个标签训练一个二元分类器,这意味着我们最终将拥有 90 个二元分类器。

如何做到这一点...

首先,我们应该计算reuters语料库中的高信息词。这是通过featx.py中的reuters_high_info_words()函数完成的。

from nltk.corpus import reuters

def reuters_high_info_words(score_fn=BigramAssocMeasures.chi_sq):
  labeled_words = []

  for label in reuters.categories():
    labeled_words.append((label, reuters.words(categories=[label])))

  return high_information_words(labeled_words, score_fn=score_fn)

然后,我们需要根据那些高信息词来获取训练和测试特征集。这是通过featx.py中的reuters_train_test_feats()函数完成的。它默认使用bag_of_words()作为其feature_detector,但我们将使用bag_of_words_in_set()来仅使用高信息词。

def reuters_train_test_feats(feature_detector=bag_of_words):
  train_feats = []
  test_feats = []

  for fileid in reuters.fileids():
    if fileid.startswith('training'):
      featlist = train_feats
    else: # fileid.startswith('test')
      featlist = test_feats

    feats = feature_detector(reuters.words(fileid))
    labels = reuters.categories(fileid)
    featlist.append((feats, labels))

  return train_feats, test_feats

我们可以使用这两个函数来获取多标签训练和测试特征集列表。

>>> from featx import reuters_high_info_words, reuters_train_test_feats
>>> rwords = reuters_high_info_words()
>>> featdet = lambda words: bag_of_words_in_set(words, rwords)
>>> multi_train_feats, multi_test_feats = reuters_train_test_feats(featdet)

multi_train_featsmulti_test_feats是多标签特征集。这意味着它们有一个标签列表,而不是单个标签,并且它们的格式看起来像[(featureset, [label])],因为每个特征集可以有一个或多个标签。有了这些训练数据,我们可以训练多个二元分类器。classification.py中的train_binary_classifiers()函数接受一个训练函数、一个多标签特征集列表以及一个可能的标签集合,返回一个label : binary分类器的dict

def train_binary_classifiers(trainf, labelled_feats, labelset):
  pos_feats = collections.defaultdict(list)
  neg_feats = collections.defaultdict(list)
  classifiers = {}

  for feat, labels in labelled_feats:
    for label in labels:
      pos_feats[label].append(feat)

    for label in labelset - set(labels):
      neg_feats[label].append(feat)

  for label in labelset:
    postrain = [(feat, label) for feat in pos_feats[label]]
    negtrain = [(feat, '!%s' % label) for feat in neg_feats[label]]
    classifiers[label] = trainf(postrain + negtrain)

  return classifiers

要使用此函数,我们需要提供一个训练函数,它接受一个单一参数,即训练数据。这将是一个简单的lambda包装器,围绕MaxentClassifier.train(),这样我们就可以指定额外的关键字参数。

>>> from classification import train_binary_classifiers
>>> trainf = lambda train_feats: MaxentClassifier.train(train_feats, algorithm='megam', trace=0, max_iter=10)
>>> labelset = set(reuters.categories())
>>> classifiers = train_binary_classifiers(trainf, multi_train_feats, labelset)
>>> len(classifiers)
90

现在我们可以定义一个MultiBinaryClassifier,它接受一个形式为[(label, classifier)]的标签化分类器列表,其中classifier假设是一个二元分类器,它要么返回label,要么在标签不适用时返回其他内容。

from nltk.classify import MultiClassifierI

class MultiBinaryClassifier(MultiClassifierI):
  def __init__(self, *label_classifiers):
    self._label_classifiers = dict(label_classifiers)
    self._labels = sorted(self._label_classifiers.keys())

  def labels(self):
    return self._labels

  def classify(self, feats):
    lbls = set()

    for label, classifier in self._label_classifiers.iteritems():
      if classifier.classify(feats) == label:
        lbls.add(label)

    return lbls

我们可以使用我们刚刚创建的二元分类器来构建这个类。

>>> from classification import MultiBinaryClassifier
>>> multi_classifier = MultiBinaryClassifier(*classifiers.items())

为了评估这个分类器,我们可以使用精确度和召回率,但不能使用准确率。这是因为准确率函数假设单值,并且不考虑部分匹配。例如,如果多分类器对一个特征集返回三个标签,其中两个是正确的但第三个不是,那么accuracy()会将其标记为不正确。因此,我们不会使用准确率,而是使用masi 距离,它衡量两个集合之间的部分重叠。masi 距离越低,匹配度越好。因此,较低的平均 masi 距离意味着更准确的局部匹配。classification.py中的multi_metrics()函数计算每个标签的精确度和召回率,以及平均 masi 距离。

import collections
from nltk import metrics

def multi_metrics(multi_classifier, test_feats):
  mds = []
  refsets = collections.defaultdict(set)
  testsets = collections.defaultdict(set)

  for i, (feat, labels) in enumerate(test_feats):
    for label in labels:
      refsets[label].add(i)

    guessed = multi_classifier.classify(feat)

    for label in guessed:
      testsets[label].add(i)

    mds.append(metrics.masi_distance(set(labels), guessed))

  avg_md = sum(mds) / float(len(mds))
  precisions = {}
  recalls = {}

  for label in multi_classifier.labels():
    precisions[label] = metrics.precision(refsets[label], testsets[label])
    recalls[label] = metrics.recall(refsets[label], testsets[label])

  return precisions, recalls, avg_md

使用我们刚刚创建的multi_classifier,我们得到以下结果:

>>> from classification import multi_metrics
>>> multi_precisions, multi_recalls, avg_md = multi_metrics(multi_classifier, multi_test_feats)
>>> avg_md
0.18191264129488705

因此,我们的平均 masi 距离相当低,这意味着我们的多标签分类器通常非常准确。让我们看看一些精确度和召回率的例子:

>>> multi_precisions['zinc']
1.0
>>> multi_recalls['zinc']
0.84615384615384615
>>> len(reuters.fileids(categories=['zinc']))
34
>>> multi_precisions['sunseed']
0.5
>>> multi_recalls['sunseed']
0.20000000000000001
>>> len(reuters.fileids(categories=['sunseed']))
16
>>> multi_precisions['rand']
None
>>> multi_recalls['rand']
0.0
>>> len(reuters.fileids(categories=['rand']))
3

如您所见,存在相当大的值范围。但总的来说,具有更多特征集的标签将具有更高的精确度和召回率,而具有较少特征集的标签将性能较低。当分类器可学习的特征集不多时,您不能期望它表现良好。

它是如何工作的...

reuters_high_info_words()函数相当简单;它为reuters语料库的每个类别构建一个[(label, words)]列表,然后将其传递给high_information_words()函数,以返回reuters语料库中最具信息量的单词列表。

使用生成的单词集,我们使用bag_of_words_in_set()创建一个特征检测函数。然后将其传递给reuters_train_test_feats(),该函数返回两个列表,第一个列表包含所有训练文件的[(feats, labels)],第二个列表包含所有测试文件的相同内容。

接下来,我们使用train_binary_classifiers()为每个标签训练一个二元分类器。这个函数为每个标签构建两个列表,一个包含正训练特征集,另一个包含负训练特征集。正特征集是那些对标签进行分类的特征集。负特征集来自所有其他标签的正特征集。例如,对zincsunseed都是的特征集是对于其他 88 个标签的示例。一旦我们为每个标签有了正负特征集,我们就可以使用给定的训练函数为每个标签训练一个二元分类器。

使用生成的二元分类器字典,我们创建了一个MultiBinaryClassifier的实例。这个类扩展了nltk.classify.MultiClassifierI接口,该接口至少需要两个函数:

  1. labels()函数必须返回一个可能的标签列表。

  2. classify()函数接受一个特征集,并返回一个set标签。为了创建这个set,我们遍历二元分类器,每次调用classify()返回其标签时,我们就将其添加到set中。如果它返回其他内容,我们就继续。

最后,我们使用multi_metrics()函数评估多标签分类器。它与测量分类器的精确度和召回率配方中的precision_recall()函数类似,但在这个情况下我们知道分类器是MultiClassifierI的一个实例,因此可以返回多个标签。它还使用nltk.metrics.masi_ distance()跟踪每个分类标签集的 masi 距离。multi_metrics()函数返回三个值:

  1. 每个标签的精度字典。

  2. 每个标签的召回率字典。

  3. 每个特征集的平均 masi 距离。

还有更多...

reuters语料库的性质引入了类别不平衡问题。当某些标签具有非常少的特征集,而其他标签具有很多时,就会出现这个问题。那些在训练时只有少量正例的二元分类器最终会有更多的负例,因此强烈偏向于负标签。这本身并没有什么错误,因为偏差反映了数据,但负例可能会压倒分类器,以至于几乎不可能得到一个正例。有几种高级技术可以克服这个问题,但这些技术超出了本书的范围。

参见

本章中的训练最大熵分类器配方涵盖了MaxentClassifier测量分类器的精确度和召回率配方展示了如何评估分类器,而计算高信息词配方描述了如何仅使用最佳特征。

第八章:分布式处理和大型数据集处理

在本章中,我们将介绍:

  • 使用 execnet 进行分布式标记

  • 使用 execnet 进行分布式分块

  • 使用 execnet 进行并行列表处理

  • 在 Redis 中存储频率分布

  • 在 Redis 中存储条件频率分布

  • 在 Redis 中存储有序字典

  • 使用 Redis 和 execnet 进行分布式单词评分

简介

NLTK 非常适合内存中的单处理器自然语言处理。然而,有时你有很多数据要处理,并想利用多个 CPU、多核 CPU 甚至多台计算机。或者你可能想在一个持久、共享的数据库中存储频率和概率,以便多个进程可以同时访问它。对于第一种情况,我们将使用 execnet 进行 NLTK 的并行和分布式处理。对于第二种情况,你将学习如何使用 Redis 数据结构服务器/数据库来存储频率分布等。

使用 execnet 进行分布式标记

Execnet是一个用于 Python 的分布式执行库。它允许你创建用于远程代码执行的网关和通道。网关是从调用进程到远程环境的连接。远程环境可以是本地子进程或到远程节点的 SSH 连接。通道是从网关创建的,用于处理通道创建者与远程代码之间的通信。

由于许多 NLTK 过程在计算期间需要 100%的 CPU 利用率,execnet 是分配这种计算以实现最大资源使用的一个理想方式。你可以为每个 CPU 核心创建一个网关,无论这些核心是在你的本地计算机上还是分布在远程机器上。在许多情况下,你只需要在单个机器上拥有训练好的对象和数据,并在需要时将对象和数据发送到远程节点。

准备工作

你需要安装 execnet 才能使它工作。这应该像sudo pip install execnetsudo easy_install execnet一样简单。截至本文写作时,execnet 的当前版本是1.0.8。execnet 的主页,其中包含 API 文档和示例,位于codespeak.net/execnet/

如何实现...

我们首先导入所需的模块,以及将在下一节中解释的附加模块remote_tag.py。我们还需要导入pickle,这样我们就可以序列化标记器。Execnet 本身不知道如何处理诸如词性标记器之类的复杂对象,因此我们必须使用pickle.dumps()将标记器转储为字符串。我们将使用nltk.tag.pos_tag()函数使用的默认标记器,但你也可以加载并转储任何实现了TaggerI接口的预训练词性标记器。

一旦我们有了序列化的标记器,我们就通过使用 execnet.makegateway() 创建网关来启动 execnet。默认网关创建一个 Python 子进程,我们可以使用 remote_exec() 方法并传递 remote_tag 模块来创建一个 channel。有了开放的通道,我们发送序列化的标记器和 treebank 语料库的第一个标记句子。

注意

对于列表和元组等简单类型,您无需进行任何特殊的序列化,因为 execnet 已经知道如何处理内置类型的序列化。

现在如果我们调用 channel.receive(),我们会得到一个标记的句子,它与 treebank 语料库中的第一个标记句子等效,因此我们知道标记是成功的。我们通过退出网关结束,这会关闭通道并杀死子进程。

>>> import execnet, remote_tag, nltk.tag, nltk.data
>>> from nltk.corpus import treebank
>>> import cPickle as pickle
>>> tagger = pickle.dumps(nltk.data.load(nltk.tag._POS_TAGGER))
>>> gw = execnet.makegateway()
>>> channel = gw.remote_exec(remote_tag)
>>> channel.send(tagger)
>>> channel.send(treebank.sents()[0])
>>> tagged_sentence = channel.receive()
>>> tagged_sentence == treebank.tagged_sents()[0]
True
>>> gw.exit()

从视觉上看,通信过程看起来像这样:

如何做...

它是如何工作的...

网关的 remote_exec() 方法接受一个参数,该参数可以是以下三种类型之一:

  1. 要在远程执行的代码字符串。

  2. 将要序列化和远程执行的一个函数的名称。

  3. 一个模块的名称,其源将在远程执行。

我们使用 remote_tag.py 模块的第三个选项,该模块定义如下:

  import cPickle as pickle

  if __name__ == '__channelexec__':
    tagger = pickle.loads(channel.receive())

    for sentence in channel:
      channel.send(tagger.tag(sentence))

一个纯模块是一个自包含的模块。它只能访问它在执行时可以访问的 Python 模块,并且无法访问网关最初创建位置存在的任何变量或状态。要检测模块是否由 execnet 执行,您可以查看 __name__ 变量。如果它等于 '__channelexec__',则它被用于创建远程通道。这与执行 if __name__ == '__main__' 来检查模块是否在命令行上执行类似。

我们首先调用 channel.receive() 来获取序列化的 tagger,然后使用 pickle.loads() 加载它。您可能会注意到 channel 没有在任何地方导入——这是因为它包含在模块的全局命名空间中。任何 execnet 在远程执行的模块都可以访问 channel 变量,以便与 channel 创建者通信。

一旦我们有了 tagger,我们就迭代地对从通道接收到的每个标记句子进行 tag() 操作。这允许我们标记发送者想要发送的任意数量的句子,因为迭代不会停止,直到 channel 关闭。我们实际上创建的是一个用于词性标注的计算节点,该节点将 100%的资源用于标注它接收到的任何句子。只要 channel 保持开放,节点就可供处理。

更多...

这是一个简单的示例,它打开一个网关和一个通道。但 execnet 可以做更多的事情,例如打开多个通道以增加并行处理,以及通过 SSH 打开远程主机的网关以进行分布式处理。

多个通道

我们可以创建多个通道,每个网关一个,以使处理更加并行。每个网关创建一个新的子进程(如果使用 SSH 网关,则为远程解释器),我们使用每个网关的一个通道进行通信。一旦我们创建了两个通道,我们可以使用MultiChannel类将它们组合起来,这允许我们遍历通道,并创建一个接收队列以接收来自每个通道的消息。

在创建每个通道并发送标记器之后,我们遍历通道,为每个通道发送相同数量的句子进行标记。然后我们收集queue中的所有响应。调用queue.get()将返回一个包含(channel, message)的 2 元组,以防你需要知道消息来自哪个通道。

小贴士

如果你不想永远等待,你也可以传递一个timeout关键字参数,指定你想要等待的最大秒数,例如queue.get(timeout=4)。这可以是一种处理网络错误的好方法。

一旦收集到所有标记的句子,我们就可以退出网关。以下是代码:

>>> import itertools
>>> gw1 = execnet.makegateway()
>>> gw2 = execnet.makegateway()
>>> ch1 = gw1.remote_exec(remote_tag)
>>> ch1.send(tagger)
>>> ch2 = gw2.remote_exec(remote_tag)
>>> ch2.send(tagger)
>>> mch = execnet.MultiChannel([ch1, ch2])
>>> queue = mch.make_receive_queue()
>>> channels = itertools.cycle(mch)
>>> for sentence in treebank.sents()[:4]:
...    channel = channels.next()
...    channel.send(sentence)
>>> tagged_sentences = []
>>> for i in range(4):
...    channel, tagged_sentence = queue.get()
...    tagged_sentences.append(tagged_sentence)
>>> len(tagged_sentences)
4
>>> gw1.exit()
>>> gw2.exit()

本地与远程网关

默认网关规范是popen,它在本地机器上创建一个 Python 子进程。这意味着execnet.makegateway()等价于execnet.makegateway('popen')。如果你有对远程机器的无密码 SSH 访问权限,那么你可以使用execnet.makegateway('ssh=remotehost')创建一个远程网关,其中remotehost应该是机器的主机名。SSH 网关为远程执行代码启动一个新的 Python 解释器。只要你在远程执行中使用的代码是的,你只需要在远程机器上有一个 Python 解释器。

无论使用哪种网关,通道的工作方式都完全相同;唯一的区别将是通信时间。这意味着你可以将本地子进程与远程解释器混合匹配,以在网络中的多台机器上分配你的计算。有关网关的更多详细信息,请参阅 API 文档中的codespeak.net/execnet/basics.html

参见

在第四章“词性标注”中详细介绍了词性标注和标记器。在下一个菜谱中,我们将使用execnet进行分布式分块提取。

使用 execnet 进行分布式分块

在这个菜谱中,我们将对execnet网关进行分块和标记。这将与前一个菜谱中的标记非常相似,但我们将会发送两个对象而不是一个,并且我们将接收一个Tree而不是一个列表,这需要序列化和反序列化。

准备工作

如前一个菜谱中所述,你必须安装execnet

如何做到这一点...

设置代码与上一个菜谱非常相似,我们也将使用相同的序列化(pickled)tagger。首先,我们将序列化nltk.chunk.ne_chunk()使用的默认chunker,尽管任何 chunker 都可以。接下来,我们为remote_chunk模块创建一个网关,获取一个channel,并将序列化的taggerchunker发送过去。然后我们接收回一个序列化的Tree,我们可以反序列化并检查结果。最后,我们退出网关。

>>> import execnet, remote_chunk
>>> import nltk.data, nltk.tag, nltk.chunk
>>> import cPickle as pickle
>>> from nltk.corpus import treebank_chunk
>>> tagger = pickle.dumps(nltk.data.load(nltk.tag._POS_TAGGER))
>>> chunker = pickle.dumps(nltk.data.load(nltk.chunk._MULTICLASS_NE_CHUNKER))
>>> gw = execnet.makegateway()
>>> channel = gw.remote_exec(remote_chunk)
>>> channel.send(tagger)
>>> channel.send(chunker)
>>> channel.send(treebank_chunk.sents()[0])
>>> chunk_tree = pickle.loads(channel.receive())
>>> chunk_tree
Tree('S', [Tree('PERSON', [('Pierre', 'NNP')]), Tree('ORGANIZATION', [('Vinken', 'NNP')]), (',', ','), ('61', 'CD'), ('years', 'NNS'), ('old', 'JJ'), (',', ','), ('will', 'MD'), ('join', 'VB'), ('the', 'DT'), ('board', 'NN'), ('as', 'IN'), ('a', 'DT'), ('nonexecutive', 'JJ'), ('director', 'NN'), ('Nov.', 'NNP'), ('29', 'CD'), ('.', '.')])
>>> gw.exit()

这次通信略有不同。

如何做...

它是如何工作的...

remote_chunk.py模块比上一个菜谱中的remote_tag.py模块稍微复杂一些。除了接收一个序列化的tagger,它还期望接收一个实现了ChunkerI接口的序列化chunker。一旦它有了taggerchunker,它期望接收任意数量的分词句子,它会对这些句子进行标记并将它们解析成一个Tree。然后这个tree会被序列化并通过channel发送回去。

import cPickle as pickle

if __name__ == '__channelexec__':
  tagger = pickle.loads(channel.receive())
  chunker = pickle.loads(channel.receive())

  for sent in channel:
    tree = chunker.parse(tagger.tag(sent))
    channel.send(pickle.dumps(tree))

注意

由于Tree不是一个简单的内置类型,因此必须将其序列化(pickled)。

还有更多...

注意,remote_chunk模块是纯的。它的唯一外部依赖是pickle(或cPickle)模块,它是 Python 标准库的一部分。它不需要导入任何 NLTK 模块来使用taggerchunker,因为所有必要的数据都是序列化并通过channel发送的。只要你的远程代码结构是这样的,没有外部依赖,你只需要在单个机器上安装 NLTK——即启动网关并通过channel发送对象的机器。

Python 子进程

当你在运行execnet代码时查看你的任务/系统监视器(或在*nix中的top),你可能会注意到一些额外的 Python 进程。每个网关都会产生一个新的、自包含的、无共享资源的 Python 解释器进程,当你调用exit()方法时,该进程会被终止。与线程不同,这里没有共享内存需要担心,也没有全局解释器锁来减慢速度。你所拥有的只是独立的通信进程。这无论是对于本地进程还是远程进程都是成立的。你不需要担心锁定和同步,你只需要关注消息的发送和接收顺序。

参见

之前的菜谱详细解释了execnet网关和通道。在下一个菜谱中,我们将使用execnet并行处理一个列表。

使用 execnet 进行并行列表处理

这个菜谱展示了使用execnet并行处理列表的模式。这是一个函数模式,用于将列表中的每个元素映射到一个新值,使用execnet并行执行映射。

如何做...

首先,我们需要决定我们确切想要做什么。在这个例子中,我们将只是将整数加倍,但我们可以进行任何纯计算。以下是由execnet执行的模块remote_double.py,它接收一个(i, arg)的 2 元组,假设arg是一个数字,并发送回(i, arg*2)i的需求将在下一节中解释。

if __name__ == '__channelexec__':
  for (i, arg) in channel:
    channel.send((i, arg * 2))

要使用此模块将列表中的每个元素加倍,我们需要导入plists模块(下一节将解释),并使用remote_double模块和要加倍的整数列表调用plists.map()

>>> import plists, remote_double
>>> plists.map(remote_double, range(10))
[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

通道之间的通信非常简单,如下面的图所示:

如何做...

它是如何工作的...

map()函数定义在plists.py中。它接受一个纯模块、一个参数列表以及一个可选的由(spec, count)组成的 2 元组列表。默认的specs[('popen', 2)],这意味着我们将打开两个本地网关和通道。一旦这些通道打开,我们将它们放入一个itertools循环中,这创建了一个无限迭代器,一旦到达末尾就会回到开始。

现在我们可以将args中的每个参数发送到channel进行处理,由于通道是循环的,每个通道都会获得几乎均匀分布的参数。这就是i的作用——我们不知道结果将以什么顺序返回,所以将作为列表中每个arg的索引的i传递到通道并返回,以便我们可以按原始顺序组合结果。然后我们使用MultiChannel接收队列等待结果,并将它们插入一个与原始args长度相同的预填充列表中。一旦我们有了所有预期的结果,我们就可以退出网关并返回结果。

import itertools, execnet

def map(mod, args, specs=[('popen', 2)]):
  gateways = []
  channels = []

  for spec, count in specs:
    for i in range(count):
      gw = execnet.makegateway(spec)
      gateways.append(gw)
      channels.append(gw.remote_exec(mod))

  cyc = itertools.cycle(channels)

  for i, arg in enumerate(args):
    channel = cyc.next()
    channel.send((i, arg))

  mch = execnet.MultiChannel(channels)
  queue = mch.make_receive_queue()
  l = len(args)
  results = [None] * l

  for j in range(l):
    channel, (i, result) = queue.get()
    results[i] = result

  for gw in gateways:
    gw.exit()

  return results

还有更多...

您可以通过修改规格来增加并行化,如下所示:

>>> plists.map(remote_double, range(10), [('popen', 4)])
[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

然而,更多的并行化并不一定意味着更快的处理速度。它取决于可用的资源,并且你打开的网关和通道越多,所需的开销就越大。理想情况下,每个 CPU 核心应该有一个网关和通道。

只要接收并返回包含i作为第一个元素的 2 元组,您就可以使用plists.map()与任何纯模块一起使用。这种模式在您有一堆数字要处理,并希望尽可能快速地处理它们时最有用。

参见

之前的菜谱更详细地介绍了execnet的功能。

在 Redis 中存储频率分布

NLTK 中的许多类都使用了nltk.probability.FreqDist类来存储和管理频率分布。它非常有用,但它全部是在内存中,并且不提供持久化数据的方式。单个FreqDist也无法被多个进程访问。我们可以通过在 Redis 之上构建一个FreqDist来改变这一切。

Redis 是一个 数据结构 服务器,是更受欢迎的 NoSQL 数据库之一。除了其他功能外,它提供了一个网络可访问的数据库来存储字典(也称为 哈希表)。通过将 FreqDist 接口构建到 Redis 哈希表中,我们可以创建一个持久化的 FreqDist,它同时可供多个本地和远程进程访问。

注意

大多数 Redis 操作都是 原子性 的,因此甚至可以同时有多个进程向 FreqDist 写入。

准备工作

对于这个和随后的食谱,我们需要安装 Redisredis-py。Redis 的快速入门安装指南可在 code.google.com/p/redis/wiki/QuickStart 找到。为了使用哈希表,你应该安装至少版本 2.0.0(截至本文写作时的最新版本)。

可以使用 pip install rediseasy_install redis 安装 Redis Python 驱动 redis-py。确保你安装至少版本 2.0.0 以使用哈希表。redis-py 的主页是 github.com/andymccurdy/redis-py/

一旦安装完成并且 redis-server 进程正在运行,你就可以开始了。假设 redis-serverlocalhost6379 端口上运行(默认的主机和端口)。

如何实现...

FreqDist 类扩展了内置的 dict 类,这使得 FreqDist 成为一个增强的字典。FreqDist 类提供了两个额外的键方法:inc()N()inc() 方法接受一个 sample 参数作为键,以及一个可选的 count 关键字参数,默认为 1,并将 sample 的值增加 countN() 返回样本结果的数量,这是频率分布中所有值的总和。

我们可以通过扩展 RedisHashMap(将在下一节中解释)并在其上创建一个 API 兼容的类,然后实现 inc()N() 方法来在 Redis 上创建一个 API 兼容的类。由于 FreqDist 只存储整数,我们还覆盖了一些其他方法,以确保值始终是整数。这个 RedisHashFreqDist(在 redisprob.py 中定义)使用 hincrby 命令来为 inc() 方法增加 sample 值,并使用 N() 方法对哈希表中的所有值求和。

from rediscollections import RedisHashMap

class RedisHashFreqDist(RedisHashMap):
  def inc(self, sample, count=1):
    self._r.hincrby(self._name, sample, count)

  def N(self):
    return int(sum(self.values()))

  def __getitem__(self, key):
    return int(RedisHashMap.__getitem__(self, key) or 0)

  def values(self):
    return [int(v) for v in RedisHashMap.values(self)]

  def items(self):
    return [(k, int(v)) for (k, v) in RedisHashMap.items(self)]

我们可以将这个类当作一个 FreqDist 来使用。要实例化它,我们必须传递一个 Redis 连接和我们的哈希表的 namename 应该是这个特定 FreqDist 的唯一引用,这样就不会与 Redis 中的任何其他键冲突。

>>> from redis import Redis
>>> from redisprob import RedisHashFreqDist
>>> r = Redis()
>>> rhfd = RedisHashFreqDist(r, 'test')
>>> len(rhfd)
0
>>> rhfd.inc('foo')
>>> rhfd['foo']
1
>>> rhfd.items()
>>> len(rhfd)
1

注意

哈希表的名字和样本键将被编码以替换空格和 & 字符为 _。这是因为 Redis 协议使用这些字符进行通信。最好一开始就不在名字和键中包含空格。

它是如何工作的...

大部分工作都是在 rediscollections.py 中的 RedisHashMap 类中完成的,它扩展了 collections.MutableMapping,然后覆盖了所有需要 Redis 特定命令的方法。以下是使用特定 Redis 命令的每个方法的概述:

  • __len__(): 使用 hlen 命令获取哈希表中的元素数量

  • __contains__(): 使用 hexists 命令检查哈希表中是否存在一个元素

  • __getitem__(): 使用 hget 命令从哈希表中获取一个值

  • __setitem__(): 使用 hset 命令在哈希表中设置一个值

  • __delitem__(): 使用 hdel 命令从哈希表中删除一个值

  • keys(): 使用 hkeys 命令获取哈希表中的所有键

  • values(): 使用 hvals 命令获取哈希表中的所有值

  • items(): 使用 hgetall 命令获取包含哈希表中所有键和值的字典

  • clear(): 使用 delete 命令从 Redis 中删除整个哈希表

注意

扩展 collections.MutableMapping 提供了基于先前方法的许多其他 dict 兼容方法,例如 update()setdefault(),因此我们不必自己实现它们。

用于 RedisHashFreqDist 的初始化实际上在这里实现,需要一个 Redis 连接和一个哈希表名称。连接和名称都存储在内部,以便与所有后续命令一起使用。如前所述,名称和所有键中的空白字符被下划线替换,以与 Redis 网络协议兼容。

import collections, re

white = r'[\s&]+'

def encode_key(key):
  return re.sub(white, '_', key.strip())

class RedisHashMap(collections.MutableMapping):
  def __init__(self, r, name):
    self._r = r
    self._name = encode_key(name)

  def __iter__(self):
    return iter(self.items())

  def __len__(self):
    return self._r.hlen(self._name)

  def __contains__(self, key):
    return self._r.hexists(self._name, encode_key(key))

  def __getitem__(self, key):
    return self._r.hget(self._name, encode_key(key))

  def __setitem__(self, key, val):
    self._r.hset(self._name, encode_key(key), val)

  def __delitem__(self, key):
    self._r.hdel(self._name, encode_key(key))

  def keys(self):
    return self._r.hkeys(self._name)

  def values(self):
    return self._r.hvals(self._name)

  def items(self):
    return self._r.hgetall(self._name).items()

  def get(self, key, default=0):
    return self[key] or default

  def iteritems(self):
    return iter(self)

  def clear(self):
    self._r.delete(self._name)

还有更多...

RedisHashMap 可以作为一个持久化的键值字典单独使用。然而,虽然哈希表可以支持大量的键和任意字符串值,但其存储结构对于整数值和较小的键数量更为优化。但是,不要因此阻碍你充分利用 Redis。它非常快(对于网络服务器来说),并且尽力高效地编码你抛给它的任何数据。

注意

虽然 Redis 对于网络数据库来说相当快,但它将比内存中的 FreqDist 慢得多。这是无法避免的,但当你牺牲速度时,你将获得持久性和并发处理的能力。

参见

在下一个配方中,我们将基于这里创建的 Redis 频率分布创建一个条件频率分布。

存储条件频率分布到 Redis

nltk.probability.ConditionalFreqDist 类是一个 FreqDist 实例的容器,每个条件对应一个 FreqDist。它用于计算依赖于另一个条件的频率,例如另一个单词或类标签。我们在第七章的“计算高信息词”配方中使用了这个类,文本分类。在这里,我们将使用前一个配方中的 RedisHashFreqDistRedis 上创建一个兼容 API 的类。

准备工作

如前一个示例,你需要安装Redisredis-py,并运行一个redis-server实例。

如何操作...

我们在redisprob.py中定义了一个RedisConditionalHashFreqDist类,它扩展了nltk.probability.ConditionalFreqDist并重写了__contains__()__getitem__()方法。然后我们重写__getitem__()以便我们可以创建一个RedisHashFreqDist的实例而不是FreqDist,并重写__contains__()以便在检查RedisHashFreqDist是否存在之前,从rediscollections模块调用encode_key()

from nltk.probability import ConditionalFreqDist
from rediscollections import encode_key

class RedisConditionalHashFreqDist(ConditionalFreqDist):
  def __init__(self, r, name, cond_samples=None):
    self._r = r
    self._name = name
    ConditionalFreqDist.__init__(self, cond_samples)
    # initialize self._fdists for all matching keys
    for key in self._r.keys(encode_key('%s:*' % name)):
      condition = key.split(':')[1]
      self[condition] # calls self.__getitem__(condition)

  def __contains__(self, condition):
    return encode_key(condition) in self._fdists

  def __getitem__(self, condition):
    if condition not in self._fdists:
      key = '%s:%s' % (self._name, condition)
      self._fdists[condition] = RedisHashFreqDist(self._r, key)

    return self._fdists[condition]

  def clear(self):
    for fdist in self._fdists.values():
      fdist.clear()

通过传递一个Redis连接和一个基本名称,可以创建这个类的实例。之后,它的工作方式就像一个ConditionalFreqDist

>>> from redis import Redis
>>> from redisprob import RedisConditionalHashFreqDist
>>> r = Redis()
>>> rchfd = RedisConditionalHashFreqDist(r, 'condhash')
>>> rchfd.N()
0
>>> rchfd.conditions()
[]
>>> rchfd['cond1'].inc('foo')
>>> rchfd.N()
1
>>> rchfd['cond1']['foo']
1
>>> rchfd.conditions()
['cond1']
>>> rchfd.clear()

它是如何工作的...

RedisConditionalHashFreqDist使用名称前缀来引用RedisHashFreqDist实例。传递给RedisConditionalHashFreqDist的名称是一个基本名称,它与每个条件结合,为每个RedisHashFreqDist创建一个唯一的名称。例如,如果RedisConditionalHashFreqDist基本名称'condhash',而条件'cond1',那么RedisHashFreqDist的最终名称就是'condhash:cond1'。这种命名模式在初始化时用于使用keys命令查找所有现有的哈希映射。通过搜索所有匹配'condhash:*'的键,我们可以识别所有现有的条件并为每个创建一个RedisHashFreqDist实例。

注意

使用冒号组合字符串是Redis键的常见命名约定,作为定义命名空间的方式。在我们的情况下,每个RedisConditionalHashFreqDist实例定义了一个单独的哈希映射命名空间。

ConditionalFreqDist类在self._fdists中存储一个内部字典,它将condition映射到FreqDistRedisConditionalHashFreqDist类仍然使用self._fdists,但值是RedisHashFreqDist的实例而不是FreqDistself._fdists在调用ConditionalFreqDist.__init__()时创建,并在__getitem__()方法中根据需要初始化值。

更多...

RedisConditionalHashFreqDist还定义了一个clear()方法。这是一个辅助方法,它会在所有内部RedisHashFreqDist实例上调用clear()clear()方法在ConditionalFreqDist中未定义。

参见

前一个示例详细介绍了RedisHashFreqDist。例如,在第七章的计算高信息词示例中,可以看到ConditionalFreqDist的使用。

在 Redis 中存储有序字典

有序字典就像一个普通的 dict,但键是按排序函数排序的。在 Redis 的情况下,它支持有序字典,其 键是字符串,其 值是浮点分数。这种结构在需要存储所有单词和分数以供以后使用的情况下非常有用,例如在计算信息增益时(如第七章 Calculating high information words 中的配方所述,文本分类)。

准备工作

再次强调,您需要安装 Redisredis-py,并且有一个 redis-server 实例正在运行。

如何操作...

rediscollections.py 中的 RedisOrderedDict 类扩展了 collections.MutableMapping 以免费获得许多 dict 兼容的方法。然后它实现了所有需要 Redis 有序集合(也称为 Zset)命令的关键方法。

class RedisOrderedDict(collections.MutableMapping):
  def __init__(self, r, name):
    self._r = r
    self._name = encode_key(name)

  def __iter__(self):
    return iter(self.items())

  def __len__(self):
    return self._r.zcard(self._name)

  def __getitem__(self, key):
    val = self._r.zscore(self._name, encode_key(key))

    if val is None:
      raise KeyError
    else:
      return val

  def __setitem__(self, key, score):
    self._r.zadd(self._name, encode_key(key), score)

  def __delitem__(self, key):by brain feels dead

    self._r.zrem(self._name, encode_key(key))

  def keys(self, start=0, end=-1):
    # we use zrevrange to get keys sorted by high value instead of by lowest
    return self._r.zrevrange(self._name, start, end)

  def values(self, start=0, end=-1):
    return [v for (k, v) in self.items(start=start, end=end)]

  def items(self, start=0, end=-1):
    return self._r.zrevrange(self._name, start, end, withscores=True)

  def get(self, key, default=0):
    return self[key] or default

  def iteritems(self):
    return iter(self)

  def clear(self):
    self._r.delete(self._name)

您可以通过传递一个 Redis 连接和一个唯一名称来创建 RedisOrderedDict 的实例。

>>> from redis import Redis
>>> from rediscollections import RedisOrderedDict
>>> r = Redis()
>>> rod = RedisOrderedDict(r, 'test.txt')
>>> rod.get('bar')
>>> len(rod)
0
>>> rod['bar'] = 5.2
>>> rod['bar']
5.2000000000000002
>>> len(rod)
1
>>> rod.items()
[('bar', 5.2000000000000002)]
>>> rod.clear()

它是如何工作的...

大部分代码可能看起来与 RedisHashMap 类似,这是可以预料的,因为它们都扩展了 collections.MutableMapping。这里的主要区别在于 RedisOrderedSet 按浮点值对键进行排序,因此不适合像 RedisHashMap 那样存储任意键值。以下是一个概述,解释了每个关键方法及其如何与 Redis 一起工作:

  • __len__(): 使用 zcard 命令来获取有序集合中的元素数量。

  • __getitem__(): 使用 zscore 命令获取键的分数,如果键不存在,则返回 0

  • __setitem__(): 使用 zadd 命令将键添加到有序集合中,并带有给定的分数,如果键已存在,则更新分数。

  • __delitem__(): 使用 zrem 命令从有序集合中删除一个键。

  • keys(): 使用 zrevrange 命令获取有序集合中的所有键,按最高分数排序。它接受两个可选关键字参数 startend,以更有效地获取有序键的切片。

  • values(): 从 items() 方法中提取所有分数。

  • items(): 使用 zrevrange 命令来获取每个键的分数,以便按最高分数返回一个由 2-元组组成的列表。与 keys() 类似,它接受 startend 关键字参数,以有效地获取一个切片。

  • clear(): 使用 delete 命令从 Redis 中删除整个有序集合。

注意

Redis 有序集合中项的默认排序是 从低到高,因此分数最低的键排在第一位。这与您调用 sort()sorted() 时 Python 的默认列表排序相同,但当我们谈到 评分 时,这不是我们想要的。对于存储 分数,我们期望项按 从高到低 排序,这就是为什么 keys()items() 使用 zrevrange 而不是 zrange

还有更多...

如前所述,keys()items()方法接受可选的startend关键字参数,以获取结果的一部分。这使得RedisOrderedDict非常适合存储得分,然后获取前 N 个键。以下是一个简单的例子,我们分配了三个词频得分,然后获取前两个:

>>> from redis import Redis
>>> from rediscollections import RedisOrderedDict
>>> r = Redis()
>>> rod = RedisOrderedDict(r, 'scores')
>>> rod['best'] = 10
>>> rod['worst'] = 0.1
>>> rod['middle'] = 5
>>> rod.keys()
['best', 'middle', 'worst']
>>> rod.keys(start=0, end=1)
['best', 'middle']
>>> rod.clear()

参见

在第七章的计算高信息词配方中,文本分类描述了如何计算信息增益,这是一个在RedisOrderedDict中存储词频的好例子。在 Redis 中存储频率分布配方介绍了RedisRedisHashMap

使用 Redis 和 execnet 进行分布式词频计算

我们可以使用Redisexecnet一起进行分布式词频计算。在第七章的计算高信息词配方中,文本分类,我们使用FreqDistConditionalFreqDist计算了movie_reviews语料库中每个单词的信息增益。现在我们有了Redis,我们可以使用RedisHashFreqDistRedisConditionalHashFreqDist做同样的事情,然后将得分存储在RedisOrderedDict中。我们可以使用execnet来分配计数,以便从Redis中获得更好的性能。

准备工作

必须安装Redisredis-pyexecnet,并且必须在localhost上运行redis-server实例。

如何操作...

我们首先为movie_reviews语料库中的每个标签获取(label, words)元组的列表(该语料库只有posneg标签)。然后,我们使用dist_featx模块中的score_words()函数获取word_scoresword_scores是一个RedisOrderedDict的实例,我们可以看到总共有 39,764 个单词。使用keys()方法,我们可以获取前 1000 个单词,并检查前五个单词以了解它们是什么。一旦我们从word_scores中获取了我们想要的所有信息,我们就可以删除Redis中的键,因为我们不再需要这些数据了。

>>> from dist_featx import score_words
>>> from nltk.corpus import movie_reviews
>>> labels = movie_reviews.categories()
>>> labelled_words = [(l, movie_reviews.words(categories=[l])) for l in labels]
>>> word_scores = score_words(labelled_words)
>>> len(word_scores)
39764
>>> topn_words = word_scores.keys(end=1000)
>>> topn_words[0:5]
['_', 'bad', '?', 'movie', 't']
>>> from redis import Redis
>>> r = Redis()
>>> [r.delete(key) for key in ['word_fd', 'label_word_fd:neg', 'label_word_fd:pos', 'word_scores']]
[True, True, True, True]

dist_featx模块中的score_words()函数可能需要一段时间才能完成,因此请预计需要等待几分钟。使用execnetRedis的开销意味着它将比非分布式内存版本的功能花费更长的时间。

它是如何工作的...

dist_featx.py模块包含score_words()函数,该函数执行以下操作:

  1. 打开网关和通道,向每个发送初始化数据。

  2. 通过通道发送每个(label, words)元组进行计数。

  3. 向每个通道发送done消息,等待收到done回复,然后关闭通道和网关。

  4. 根据单词计数计算每个单词的得分,并将其存储在RedisOrderedDict中。

在我们计算movie_reviews语料库中单词的案例中,调用score_words()打开两个网关和通道,一个用于计数pos单词,另一个用于计数neg单词。通信如下:

它是如何工作的...

一旦计数完成,我们可以对所有单词进行评分并存储结果。代码如下:

import itertools, execnet, remote_word_count
from nltk.metrics import BigramAssocMeasures
from redis import Redis
from redisprob import RedisHashFreqDist, RedisConditionalHashFreqDist
from rediscollections import RedisOrderedDict

def score_words(labelled_words, score_fn=BigramAssocMeasures.chi_sq, host='localhost', specs=[('popen', 2)]):
  gateways = []
  channels = []

  for spec, count in specs:
    for i in range(count):
      gw = execnet.makegateway(spec)
      gateways.append(gw)
      channel = gw.remote_exec(remote_word_count)
      channel.send((host, 'word_fd', 'label_word_fd'))
      channels.append(channel)

  cyc = itertools.cycle(channels)

  for label, words in labelled_words:
    channel = cyc.next()
    channel.send((label, list(words)))

  for channel in channels:
    channel.send('done')
    assert 'done' == channel.receive()
    channel.waitclose(5)

  for gateway in gateways:
    gateway.exit()

  r = Redis(host)
  fd = RedisHashFreqDist(r, 'word_fd')
  cfd = RedisConditionalHashFreqDist(r, 'label_word_fd')
  word_scores = RedisOrderedDict(r, 'word_scores')
  n_xx = cfd.N()

  for label in cfd.conditions():
    n_xi = cfd[label].N()

    for word, n_ii in cfd[label].iteritems():
      n_ix = fd[word]

      if n_ii and n_ix and n_xi and n_xx:
        score = score_fn(n_ii, (n_ix, n_xi), n_xx)
        word_scores[word] = score

  return word_scores

注意

注意,这种方法只有在有两个标签时才会准确。如果有超过两个标签,则每个标签的单词评分应存储在单独的RedisOrderedDict实例中,每个标签一个实例。

remote_word_count.py模块看起来如下所示:

from redis import Redis
from redisprob import RedisHashFreqDist, RedisConditionalHashFreqDist

if __name__ == '__channelexec__':
  host, fd_name, cfd_name = channel.receive()
  r = Redis(host)
  fd = RedisHashFreqDist(r, fd_name)
  cfd = RedisConditionalHashFreqDist(r, cfd_name)

  for data in channel:
    if data == 'done':
      channel.send('done')
      break

    label, words = data

    for word in words:
      fd.inc(word)
      cfd[label].inc(word)

你会注意到这不仅仅是一个纯模块,因为它需要能够导入redisredisprob。原因是RedisHashFreqDistRedisConditionalHashFreqDist的实例不能被序列化并通过channel发送。相反,我们通过channel发送主机名和键名,以便在远程模块中创建实例。一旦我们有了实例,我们就可以通过channel接收两种类型的数据:

  1. 一个done消息,表示通过channel没有更多数据传入。我们回复另一个done消息,然后退出循环以关闭channel

  2. 一个包含(label, words)的 2 元组,然后我们遍历它来增加RedisHashFreqDistRedisConditionalHashFreqDist中的计数。

还有更多...

在这个特定情况下,不使用Redisexecnet计算评分会更快。然而,通过使用Redis,我们可以持久化存储评分,以便稍后检查和使用。能够手动检查所有单词计数和评分是了解数据的好方法。我们还可以调整特征提取,而无需重新计算评分。例如,你可以使用featx.bag_of_words_in_set()(在第七章,文本分类中找到)与RedisOrderedDict中的前N个单词,其中N可以是 1,000、2,000 或任何你想要的数字。如果我们的数据量很大,execnet的好处将更加明显。随着需要处理的数据量的增加,使用execnet或其他方法在多个节点之间分配计算的水平扩展变得更有价值。

参见

在第七章的计算高信息词食谱中,文本分类介绍了用于特征提取和分类的单词信息增益评分。本章的前三个食谱展示了如何使用execnet,而接下来的三个食谱分别描述了RedisHashFreqDistRedisConditionalHashFreqDistRedisOrderedDict

第九章. 解析特定数据

在本章中,我们将涵盖:

  • 使用 Dateutil 解析日期和时间

  • 时区查找和转换

  • 使用 Timex 标记时间表达式

  • 使用 lxml 从 HTML 中提取 URL

  • 清理和剥离 HTML

  • 使用 BeautifulSoup 转换 HTML 实体

  • 检测和转换字符编码

引言

本章涵盖了解析特定类型的数据,主要关注日期、时间和 HTML。幸运的是,有多个有用的库可以完成这项任务,所以我们不必深入研究复杂且过于复杂的正则表达式。这些库可以很好地补充 NLTK:

  • dateutil:提供日期/时间解析和时区转换

  • timex:可以在文本中识别时间词

  • lxmlBeautifulSoup:可以解析、清理和转换 HTML

  • chardet:检测文本的字符编码

这些库在将文本传递给 NLTK 对象之前进行预处理,或者在 NLTK 处理和提取后的文本进行后处理时非常有用。以下是一个将许多这些工具结合在一起的示例。

假设你需要解析一篇关于餐厅的博客文章。你可以使用lxmlBeautifulSoup提取文章文本、外链以及文章写作的日期和时间。然后,可以使用dateutil将这些日期和时间解析为 Python 的datetime对象。一旦你有了文章文本,你可以使用chardet确保它是 UTF-8 编码,然后在清理 HTML 并通过基于 NLTK 的词性标注、分块提取和/或文本分类进行处理之前,创建关于文章的额外元数据。如果你在餐厅有活动,你可能可以通过查看timex识别的时间词来发现这一点。这个示例的目的是说明现实世界的文本处理往往需要比基于 NLTK 的自然语言处理更多,而本章涵盖的功能可以帮助满足这些额外需求。

使用 Dateutil 解析日期和时间

如果您需要在 Python 中解析日期和时间,没有比dateutil更好的库了。parser模块可以解析比这里展示的更多格式的datetime字符串,而tz模块提供了查找时区所需的一切。这两个模块结合起来,使得将字符串解析为时区感知的datetime对象变得相当容易。

准备工作

您可以使用pipeasy_install安装dateutil,即sudo pip install dateutilsudo easy_install dateutil。完整的文档可以在labix.org/python-dateutil找到。

如何做到这一点...

让我们深入几个解析示例:

>>> from dateutil import parser
>>> parser.parse('Thu Sep 25 10:36:28 2010')
datetime.datetime(2010, 9, 25, 10, 36, 28)
>>> parser.parse('Thursday, 25\. September 2010 10:36AM')
datetime.datetime(2010, 9, 25, 10, 36)
>>> parser.parse('9/25/2010 10:36:28')
datetime.datetime(2010, 9, 25, 10, 36, 28)
>>> parser.parse('9/25/2010')
datetime.datetime(2010, 9, 25, 0, 0)
>>> parser.parse('2010-09-25T10:36:28Z')
datetime.datetime(2010, 9, 25, 10, 36, 28, tzinfo=tzutc())

如您所见,只需导入parser模块,并使用datetime字符串调用parse()函数即可。解析器将尽力返回一个合理的datetime对象,但如果它无法解析字符串,它将引发ValueError

它是如何工作的...

解析器不使用正则表达式。相反,它寻找可识别的标记,并尽力猜测这些标记代表什么。这些标记的顺序很重要,例如,一些文化使用看起来像 Month/Day/Year(默认顺序)的日期格式,而其他文化使用 Day/Month/Year 格式。为了处理这个问题,parse() 函数接受一个可选的关键字参数 dayfirst,默认为 False。如果你将其设置为 True,它可以正确解析后者的日期格式。

>>> parser.parse('25/9/2010', dayfirst=True)
datetime.datetime(2010, 9, 25, 0, 0)

还可能发生与两位数年份相关的排序问题。例如,'10-9-25' 是模糊的。由于 dateutil 默认为 Month-Day-Year 格式,'10-9-25' 被解析为 2025 年。但如果你在 parse() 中传递 yearfirst=True,它将被解析为 2010 年。

>>> parser.parse('10-9-25')
datetime.datetime(2025, 10, 9, 0, 0)
>>> parser.parse('10-9-25', yearfirst=True)
datetime.datetime(2010, 9, 25, 0, 0)

还有更多...

dateutil 解析器还可以进行 模糊解析,这允许它忽略 datetime 字符串中的无关字符。默认值为 False 时,parse() 遇到未知标记时会引发 ValueError。但如果 fuzzy=True,则通常可以返回 datetime 对象。

>>> try:
...    parser.parse('9/25/2010 at about 10:36AM')
... except ValueError:
...    'cannot parse'
'cannot parse'
>>> parser.parse('9/25/2010 at about 10:36AM', fuzzy=True)
datetime.datetime(2010, 9, 25, 10, 36)

参见

在下一个配方中,我们将使用 dateutiltz 模块来进行时区查找和转换。

时区查找和转换

dateutil 解析器返回的大多数 datetime 对象是 naive,这意味着它们没有显式的 tzinfo,它指定了时区和 UTC 偏移量。在先前的配方中,只有一个示例有 tzinfo,这是因为它是 UTC 日期和时间字符串的标准 ISO 格式。UTC 是协调世界时,它与 GMT 相同。ISO国际标准化组织,它规定了标准日期和时间格式。

Python 的 datetime 对象可以是 naiveaware。如果一个 datetime 对象有 tzinfo,则它是 aware 的。否则,datetime 是 naive 的。要使 naive datetime 对象时区感知,你必须给它一个显式的 tzinfo。然而,Python 的 datetime 库只定义了一个 tzinfo 的抽象基类,并将其留给其他人来实现 tzinfo 的创建。这就是 dateutiltz 模块发挥作用的地方——它提供了从你的操作系统时区数据中查找时区所需的一切。

准备工作

应使用 pipeasy_install 安装 dateutil。你还应该确保你的操作系统有时区数据。在 Linux 上,这通常位于 /usr/share/zoneinfo,Ubuntu 软件包称为 tzdata。如果你在 /usr/share/zoneinfo 中有多个文件和目录,例如 America/Europe/ 等,那么你应该准备好继续。以下示例显示了 Ubuntu Linux 的目录路径。

如何操作...

让我们从获取一个 UTC tzinfo 对象开始。这可以通过调用 tz.tzutc() 来完成,你可以通过调用带有 UTC datetime 对象的 utcoffset() 方法来检查偏移量是否为 0

>>> from dateutil import tz
>>> tz.tzutc()
tzutc()
>>> import datetime
>>> tz.tzutc().utcoffset(datetime.datetime.utcnow())
datetime.timedelta(0)

要获取其他时区的 tzinfo 对象,你可以将时区文件路径传递给 gettz() 函数。

>>> tz.gettz('US/Pacific')
tzfile('/usr/share/zoneinfo/US/Pacific')
>>> tz.gettz('US/Pacific').utcoffset(datetime.datetime.utcnow())
datetime.timedelta(-1, 61200)
>>> tz.gettz('Europe/Paris')
tzfile('/usr/share/zoneinfo/Europe/Paris')
>>> tz.gettz('Europe/Paris').utcoffset(datetime.datetime.utcnow())
datetime.timedelta(0, 7200)

你可以看到 UTC 偏移是 timedelta 对象,其中第一个数字是,第二个数字是

小贴士

如果你将 datetimes 存储在数据库中,将它们全部存储在 UTC 中以消除任何时区歧义是个好主意。即使数据库可以识别时区,这也是一个好习惯。

要将非 UTC 的 datetime 对象转换为 UTC,它必须成为时区感知的。如果你尝试将无知的 datetime 转换为 UTC,你会得到一个 ValueError 异常。要使无知的 datetime 时区感知,你只需使用正确的 tzinfo 调用 replace() 方法。一旦 datetime 对象有了 tzinfo,就可以通过调用 astimezone() 方法并传递 tz.tzutc() 来执行 UTC 转换。

>>> pst = tz.gettz('US/Pacific')
>>> dt = datetime.datetime(2010, 9, 25, 10, 36)
>>> dt.tzinfo
>>> dt.astimezone(tz.tzutc())
Traceback (most recent call last):
  File "/usr/lib/python2.6/doctest.py", line 1248, in __run
  compileflags, 1) in test.globs
  File "<doctest __main__[22]>", line 1, in <module>
  dt.astimezone(tz.tzutc())
ValueError: astimezone() cannot be applied to a naive datetime
>>> dt.replace(tzinfo=pst)
datetime.datetime(2010, 9, 25, 10, 36, tzinfo=tzfile('/usr/share/zoneinfo/US/Pacific'))
>>> dt.replace(tzinfo=pst).astimezone(tz.tzutc())
datetime.datetime(2010, 9, 25, 17, 36, tzinfo=tzutc())

它是如何工作的...

tzutctzfile 对象都是 tzinfo 的子类。因此,它们知道时区转换的正确 UTC 偏移(对于 tzutc 是 0)。tzfile 对象知道如何读取操作系统的 zoneinfo 文件以获取必要的偏移数据。datetime 对象的 replace() 方法做的是它的名字所暗示的——替换属性。一旦 datetime 有了一个 tzinfoastimezone() 方法将能够使用 UTC 偏移转换时间,然后使用新的 tzinfo 替换当前的 tzinfo

注意

注意,replace()astimezone() 都返回datetime 对象。它们不会修改当前对象。

更多...

你可以将 tzinfos 关键字参数传递给 dateutil 解析器以检测其他未被识别的时间区域。

>>> parser.parse('Wednesday, Aug 4, 2010 at 6:30 p.m. (CDT)', fuzzy=True)
datetime.datetime(2010, 8, 4, 18, 30)
>>> tzinfos = {'CDT': tz.gettz('US/Central')}
>>> parser.parse('Wednesday, Aug 4, 2010 at 6:30 p.m. (CDT)', fuzzy=True, tzinfos=tzinfos)
datetime.datetime(2010, 8, 4, 18, 30, tzinfo=tzfile('/usr/share/zoneinfo/US/Central'))

在第一种情况下,我们得到一个无知的 datetime,因为时区没有被识别。然而,当我们传递 tzinfos 映射时,我们得到一个时区感知的 datetime

本地时区

如果你想要查找你的本地时区,你可以调用 tz.tzlocal(),这将使用操作系统认为的本地时区。在 Ubuntu Linux 中,这通常在 /etc/timezone 文件中指定。

自定义偏移

你可以使用 tzoffset 对象创建具有自定义 UTC 偏移的 tzinfo 对象。可以创建一个一小时的自定义偏移,如下所示:

>>> tz.tzoffset('custom', 3600)
tzoffset('custom', 3600)

你必须提供名称作为第一个参数,以及以秒为单位的偏移时间作为第二个参数。

参见

之前的配方涵盖了使用 dateutil.parser 解析 datetime 字符串。

使用 Timex 标记时间表达式

NLTK 项目有一个鲜为人知的 contrib 仓库,其中包含许多其他模块,包括一个名为 timex.py 的模块,它可以标记时间表达式。时间表达式只是一些时间词,如“本周”或“下个月”。这些是相对于某个其他时间点的模糊表达式,比如文本编写的时间。timex 模块提供了一种注释文本的方法,以便可以从文本中提取这些表达式进行进一步分析。更多关于 TIMEX 的信息可以在 timex2.mitre.org/ 找到。

准备工作

timex.py 模块是 nltk_contrib 包的一部分,它独立于 NLTK 的当前版本。这意味着你需要自己安装它,或者使用书中代码下载中包含的 timex.py 模块。你也可以直接从 code.google.com/p/nltk/source/browse/trunk/nltk_contrib/nltk_contrib/timex.py 下载 timex.py

如果你想要安装整个 nltk_contrib 包,你可以从 nltk.googlecode.com/svn/trunk/ 检出源代码,并在 nltk_contrib 文件夹中执行 sudo python setup.py install。如果你这样做,你需要执行 from nltk_contrib import timex 而不是在下面的 如何操作 部分中直接执行 import timex

对于这个配方,你必须将 timex.py 模块下载到与代码其余部分相同的文件夹中,这样 import timex 就不会引发 ImportError

你还需要安装 egenix-mx-base 包。这是一个用于 Python 的 C 扩展库,所以如果你已经安装了所有正确的 Python 开发头文件,你应该能够执行 sudo pip install egenix-mx-basesudo easy_install egenix-mx-base。如果你正在运行 Ubuntu Linux,你可以改为执行 sudo apt-get install python-egenix-mxdatetime。如果这些都不起作用,你可以访问 www.egenix.com/products/python/mxBase/ 下载该包并找到安装说明。

如何操作...

使用 timex 非常简单:将一个字符串传递给 timex.tag() 函数,并返回一个带有注释的字符串。这些注释将是围绕每个时间表达式的 XML TIMEX 标签。

>>> import timex
>>> timex.tag("Let's go sometime this week")
"Let's go sometime <TIMEX2>this week</TIMEX2>"
>>> timex.tag("Tomorrow I'm going to the park.")
"<TIMEX2>Tomorrow</TIMEX2> I'm going to the park."

它是如何工作的...

timex.py 的实现基本上是超过 300 行的条件正则表达式匹配。当其中一个已知表达式匹配时,它创建一个 RelativeDateTime 对象(来自 mx.DateTime 模块)。然后,这个 RelativeDateTime 被转换回一个带有周围 TIMEX 标签的字符串,并替换文本中的原始匹配字符串。

还有更多...

timex 非常智能,不会对已经标记的表达式再次进行标记,因此可以将标记过的 TIMEX 文本传递给 tag() 函数。

>>> timex.tag("Let's go sometime <TIMEX2>this week</TIMEX2>")
"Let's go sometime <TIMEX2>this week</TIMEX2>"

相关内容

在下一个菜谱中,我们将从 HTML 中提取 URL,但可以使用相同的模块和技术来提取用于进一步处理的TIMEX标记表达式。

使用 lxml 从 HTML 中提取 URL

解析 HTML 时的一项常见任务是提取链接。这是每个通用网络爬虫的核心功能之一。有多个 Python 库用于解析 HTML,lxml是其中之一。正如你将看到的,它包含一些专门针对链接提取的出色辅助函数。

准备工作

lxml是 C 库libxml2libxslt的 Python 绑定。这使得它成为一个非常快速的 XML 和 HTML 解析库,同时仍然保持pythonic。然而,这也意味着你需要安装 C 库才能使其工作。安装说明请参阅codespe ak.net/lxml/installation.html。然而,如果你正在运行 Ubuntu Linux,安装就像sudo apt-get install python-lxml一样简单。

如何做...

lxml包含一个专门用于解析 HTML 的html模块。使用fromstring()函数,我们可以解析一个 HTML 字符串,然后获取所有链接的列表。iterlinks()方法生成形式为(element, attr, link, pos)的四元组:

  • element:这是从锚标签中提取的link的解析节点。如果你只对link感兴趣,可以忽略这个。

  • attr:这是link的来源属性,通常是href

  • link:这是从锚标签中提取的实际 URL。

  • pos:这是文档中锚标签的数字索引。第一个标签的pos0,第二个标签的pos1,依此类推。

以下是一些演示代码:

>>> from lxml import html
>>> doc = html.fromstring('Hello <a href="/world">world</a>')
>>> links = list(doc.iterlinks())
>>> len(links)
1
>>> (el, attr, link, pos) = links[0]
>>> attr
'href'
>>> link
'/world'
>>> pos
0

它是如何工作的...

lxml将 HTML 解析为ElementTree。这是一个由父节点和子节点组成的树结构,其中每个节点代表一个 HTML 标签,并包含该标签的所有相应属性。一旦创建了树,就可以迭代以查找元素,例如a锚标签。核心树处理代码位于lxml.etree模块中,而lxml.html模块只包含创建和迭代树的 HTML 特定函数。有关完整文档,请参阅 lxml 教程:codespeak.net/lxml/tutorial.html

更多内容...

你会注意到在之前的代码中,链接是相对的,这意味着它不是一个绝对 URL。在提取链接之前,我们可以通过调用带有基本 URL 的make_links_absolute()方法来将其转换为绝对 URL

>>> doc.make_links_absolute('http://hello')
>>> abslinks = list(doc.iterlinks())
>>> (el, attr, link, pos) = abslinks[0]
>>> link
'http://hello/world'

直接提取链接

如果你只想提取链接而不做其他任何事情,你可以使用 HTML 字符串调用iterlinks()函数。

>>> links = list(html.iterlinks('Hello <a href="/world">world</a>'))
>>> links[0][2]
'/world'

从 URL 或文件解析 HTML

你可以使用 parse() 函数而不是使用 fromstring() 函数来解析 HTML 字符串,通过传递一个 URL 或文件名。例如,html.parse("http://my/url")html.parse("/path/to/file")。结果将与你自己将 URL 或文件加载到字符串中然后调用 fromstring() 一样。

使用 XPaths 提取链接

你也可以使用 xpath() 方法来获取链接,而不是使用 iterlinks() 方法,这是一个从 HTML 或 XML 解析树中提取任何所需内容的一般方法。

>>> doc.xpath('//a/@href')[0]
'http://hello/world'

关于 XPath 语法,请参阅 www.w3schools.com/XPath/xpath_syntax.asp

参见

在下一个配方中,我们将介绍清理和剥离 HTML。

清理和剥离 HTML

清理文本是文本处理中不幸但完全必要的方面之一。当涉及到解析 HTML 时,你可能不想处理任何嵌入的 JavaScript 或 CSS,你只对标签和文本感兴趣。或者你可能想完全移除 HTML,只处理文本。这个配方涵盖了如何进行这两种预处理操作。

准备工作

你需要安装 lxml。请参阅前面的配方或 codespeak.net/lxml/installation.html 以获取安装说明。你还需要安装 NLTK 以剥离 HTML。

如何操作...

我们可以使用 lxml.html.clean 模块中的 clean_html() 函数来从 HTML 字符串中移除不必要的 HTML 标签和嵌入的 JavaScript。

>>> import lxml.html.clean
>>> lxml.html.clean.clean_html('<html><head></head><body onload=loadfunc()>my text</body></html>')
'<div><body>my text</body></div>'

结果会更加干净,更容易处理。使用 clean_html() 函数的完整模块路径是因为 nltk.util 模块中也有一个 clean_html() 函数,但它的用途不同。当你只想得到文本时,nltk.util.clean_html() 函数会移除所有 HTML 标签。

>>> import nltk.util
>>> nltk.util.clean_html('<div><body>my text</body></div>')
'my text'

它是如何工作的...

lxml.html.clean_html() 函数将 HTML 字符串解析成树,然后迭代并移除所有应该被移除的节点。它还使用正则表达式匹配和替换来清理节点的非必要属性(例如嵌入的 JavaScript)。

nltk.util.clean_html() 函数执行一系列正则表达式替换来移除 HTML 标签。为了安全起见,最好在清理后剥离 HTML,以确保正则表达式能够匹配。

还有更多...

lxml.html.clean 模块定义了一个默认的 Cleaner 类,当你调用 clean_html() 时会使用它。你可以通过创建自己的实例并调用其 clean_html() 方法来自定义这个类的行为。有关这个类的更多详细信息,请参阅 codespeak.net/lxml/lxmlhtml.html

参见

在前面的配方中介绍了 lxml.html 模块,用于解析 HTML 和提取链接。在下一个配方中,我们将介绍取消转义 HTML 实体。

使用 BeautifulSoup 转换 HTML 实体

HTML 实体是像 &amp;&lt; 这样的字符串。这些是具有特殊用途于 HTML 的正常 ASCII 字符的编码。例如,&lt;< 的实体。你无法在 HTML 标签内直接使用 <,因为它是一个 HTML 标签的开始字符,因此需要转义它并定义 &lt; 实体。& 的实体代码是 &amp;;正如我们刚才看到的,这是实体代码的开始字符。如果你需要处理 HTML 文档中的文本,那么你将想要将这些实体转换回它们的正常字符,这样你就可以识别并适当地处理它们。

准备工作

你需要安装 BeautifulSoup,你可以使用 sudo pip install BeautifulSoupsudo easy_install BeautifulSoup 来完成。你可以在 www.crummy.com/software/BeautifulSoup/ 上了解更多关于 BeautifulSoup 的信息。

如何做到这一点...

BeautifulSoup 是一个 HTML 解析库,它还包含一个名为 BeautifulStoneSoup 的 XML 解析器。这是我们用于实体转换可以使用的。这很简单:给定一个包含 HTML 实体的字符串创建 BeautifulStoneSoup 的实例,并指定关键字参数 convertEntities='html'。将这个实例转换成字符串,你将得到 HTML 实体的 ASCII 表示。

>>> from BeautifulSoup import BeautifulStoneSoup
>>> unicode(BeautifulStoneSoup('&lt;', convertEntities='html'))
u'<'
>>> unicode(BeautifulStoneSoup('&amp;', convertEntities='html'))
u'&'

将字符串多次运行是可以的,只要 ASCII 字符不是单独出现。如果你的字符串只是一个用于 HTML 实体的单个 ASCII 字符,那么这个字符将会丢失。

>>> unicode(BeautifulStoneSoup('<', convertEntities='html'))
u''
>>> unicode(BeautifulStoneSoup('< ', convertEntities='html'))
u'< '

为了确保字符不会丢失,只需要在字符串中有一个不是实体代码部分的字符。

它是如何工作的...

为了转换 HTML 实体,BeautifulStoneSoup 会寻找看起来像实体的标记,并用 Python 标准库中的 htmlentitydefs.name2codepoint 字典中对应的值来替换它们。如果实体标记在 HTML 标签内,或者它在一个普通字符串中,它都可以这样做。

还有更多...

BeautifulSoup 是一个优秀的 HTML 和 XML 解析器,并且可以是一个很好的 lxml 的替代品。它在处理格式不正确的 HTML 方面特别出色。你可以在 www.crummy.com/software/BeautifulSoup/documentation.html 上了解更多关于如何使用它的信息。

使用 BeautifulSoup 提取 URL

这里有一个使用 BeautifulSoup 提取 URL 的例子,就像我们在 使用 lxml 从 HTML 中提取 URL 的配方中所做的那样。你首先使用 HTML 字符串创建 soup,然后调用 findAll() 方法并传入 'a' 以获取所有锚标签,并提取 'href' 属性以获取 URL。

>>> from BeautifulSoup import BeautifulSoup
>>> soup = BeautifulSoup('Hello <a href="/world">world</a>')
>>> [a['href'] for a in soup.findAll('a')]
[u'/world']

参见

使用 lxml 从 HTML 中提取 URL 的配方中,我们介绍了如何使用 lxml 从 HTML 字符串中提取 URL,并在该配方之后介绍了 清理和去除 HTML

检测和转换字符编码

在文本处理中,一个常见的情况是找到具有非标准字符编码的文本。理想情况下,所有文本都应该是 ASCII 或 UTF-8,但这只是现实。在您有非 ASCII 或非 UTF-8 文本且不知道字符编码的情况下,您需要检测它并将文本转换为标准编码,然后再进一步处理。

准备工作

您需要安装chardet模块,使用sudo pip install chardetsudo easy_install chardet。您可以在chardet.feedparser.org/了解更多关于chardet的信息。

如何实现...

encoding.py中提供了编码检测和转换函数。这些是围绕chardet模块的简单包装函数。要检测字符串的编码,请调用encoding.detect()。您将得到一个包含两个属性的dictconfidenceencodingconfidencechardetencoding值正确性的置信度概率。

# -*- coding: utf-8 -*-
import chardet

def detect(s):
  try:
    return chardet.detect(s)
  except UnicodeDecodeError:
    return chardet.detect(s.encode('utf-8'))

  def convert(s):
    encoding = detect(s)['encoding']

    if encoding == 'utf-8':
      return unicode(s)
    else:
      return unicode(s, encoding)

下面是一个使用detect()来确定字符编码的示例代码:

>>> import encoding
>>> encoding.detect('ascii')
{'confidence': 1.0, 'encoding': 'ascii'}
>>> encoding.detect(u'abcdé')
{'confidence': 0.75249999999999995, 'encoding': 'utf-8'}
>>> encoding.detect('\222\222\223\225')
{'confidence': 0.5, 'encoding': 'windows-1252'}

要将字符串转换为标准的unicode编码,请调用encoding.convert()。这将解码字符串的原始编码,然后将其重新编码为 UTF-8。

>>> encoding.convert('ascii')
u'ascii'	
>>> encoding.convert(u'abcdé')
u'abcd\\xc3\\xa9'
>>> encoding.convert('\222\222\223\225')
u'\u2019\u2019\u201c\u2022'

它是如何工作的...

detect()函数是chardet.detect()的包装器,可以处理UnicodeDecodeError异常。在这些情况下,在尝试检测编码之前,字符串被编码为 UTF-8。

convert()函数首先调用detect()以获取encoding,然后返回一个带有encoding作为第二个参数的unicode字符串。通过将encoding传递给unicode(),字符串从原始编码解码,允许它被重新编码为标准编码。

更多内容...

模块顶部的注释# -*- coding: utf-8 -*-是给 Python 解释器的提示,告诉它代码中字符串应使用哪种编码。这对于您源代码中有非 ASCII 字符串时很有帮助,并在www.python.org/dev/peps/pep-0263/中详细记录。

转换为 ASCII

如果您想要纯 ASCII 文本,将非 ASCII 字符转换为 ASCII 等效字符,或者在没有等效字符的情况下删除,那么您可以使用unicodedata.normalize()函数。

>>> import unicodedata
>>> unicodedata.normalize('NFKD', u'abcd\xe9').encode('ascii', 'ignore')
'abcde'

将第一个参数指定为'NFKD'确保非 ASCII 字符被替换为其等效的 ASCII 版本,并且最终调用encode()时使用'ignore'作为第二个参数将移除任何多余的 Unicode 字符。

参见

在使用lxmlBeautifulSoup进行 HTML 处理之前,编码检测和转换是推荐的第一步,这包括在使用 lxml 从 HTML 中提取 URL使用 BeautifulSoup 转换 HTML 实体的食谱中。

附录 A. Penn Treebank 词性标签

以下是一个表格,列出了与 NLTK 一起分发的 treebank 语料库中出现的所有词性标签。这里显示的标签和计数是通过以下代码获得的:

>>> from nltk.probability import FreqDist
>>> from nltk.corpus import treebank
>>> fd = FreqDist()
>>> for word, tag in treebank.tagged_words():
...   fd.inc(tag)
>>> fd.items()

FreqDist fd 包含了 treebank 语料库中每个标签的所有计数。您可以通过执行 fd[tag] 来单独检查每个标签的计数,例如 fd['DT']。标点符号标签也显示出来,以及特殊标签如 -NONE-,它表示词性标签是未知的。大多数标签的描述可以在 www.ling.upenn.edu/courses/Fall_2003/ling001/penn_treebank_pos.html 找到。

词性标签 出现频率
# 16
$ 724
'' 694
, 4,886
-LRB- 120
-NONE- 6,592
-RRB- 126
. 384
: 563
`` 712
CC 2,265
CD 3,546
DT 8,165
EX 88
FW 4
IN 9,857
JJ 5,834
JJR 381
JJS 182
LS 13
MD 927
NN 13,166
NNP 9,410
NNPS 244
NNS 6,047
PDT 27
POS 824
PRP 1,716
PRP$ 766
RB 2,822
RBR 136
RBS 35
RP 216
SYM 1
TO 2,179
UH 3
VB 2,554
VBD 3,043
VBG 1,460
VBN 2,134
VBP 1,321
VBZ 2,125
WDT 445
WP 241
WP$ 14
posted @ 2025-10-23 15:17  绝不原创的飞龙  阅读(9)  评论(0)    收藏  举报