第七章-从文本中提取信息
对于任何给定的问题,都可能有人在某处写下了答案。以电子形式提供的自然语言文本数量确实惊人,而且每天都在增加。然而,自然语言的复杂性使得获取文本中的信息非常困难。NLP的技术水平离从无限制的文本构建通用意义表示还有很长的路要走。如果我们把精力集中在有限的一系列问题或“实体关系”上,比如“不同的设施在哪里”或“哪家公司雇用了谁”,我们就能取得重大进展。本章的目标是回答以下问题:
1.如何构建一个从非结构化文本中提取结构化数据(如表)的系统?
2.有哪些健壮的方法可以识别文本中描述的实体和关系?
3.哪些语料库适合这项工作,我们如何使用它们来训练和评估我们的模型?
在此过程中,我们将应用前两章中的技术来解决分块和命名实体识别的问题。
7.1 信息提取
信息有多种形状和大小。一种重要的形式是结构化数据,其中存在规则且可预测的实体和关系组织。
例如,我们可能对公司和地点之间的关系感兴趣。 鉴于某家公司,我们希望能够确定其开展业务的地点; 相反,在给定位置的情况下,我们希望了解哪些公司在该位置开展业务。 如果我们的数据是表格形式,例如1.1中的示例,那么回答这些查询很简单。
如果这个位置数据作为元组列表(实体,关系,实体)存储在Python中,则问题是“哪些组织在亚特兰大运行?” 可翻译如下:
locs=[('Omnicom', 'IN', 'New York'),
('DDB Needham', 'IN', 'New York'),
('Kaplan Thaler Group', 'IN', 'New York'),
('BBDO South', 'IN', 'Atlanta'),
('Georgia-Pacific', 'IN', 'Atlanta')]
query = [e1 for (e1, rel, e2) in locs if e2=='Atlanta']
如果我们试图从文本中获取类似的信息,事情就会变得更加棘手。 例如,请考虑以下代码段(corpus.ieer里的某个文件)
如果您通读(1),您将收集回答示例问题所需的信息。但是我们如何让机器充分了解(1)返回table1.2中的答案?这显然是一项艰巨的任务。与table1.1不同,(1)不包含将组织名称与位置名称链接的结构。
解决这个问题的一种方法是建立一个非常普遍的意义表示(第十章)
在本章中,我们采用不同的方法,事先决定我们只会在文本中查找非常具体的信息,例如组织和位置之间的关系。
我们首先将自然语言句子的非结构化数据转换为1.1的结构化数据,而不是尝试使用像(1)这样的文本来直接回答问题。
然后我们获得了强大的查询工具(如SQL)的好处。
这种从文本中获取意义的方法称为信息提取。
信息提取有许多应用,包括商业智能,简历收集,媒体分析,情感检测,专利检索和电子邮件扫描。
当前研究中一个特别重要的领域涉及尝试从电子可用的科学文献中提取结构化数据,特别是在生物学和医学领域。
7.1.1信息提取架构
图1.1显示了简单信息提取系统的体系结构。
它首先使用第三章和第五章中讨论的几个过程处理文档:首先,使用句子分割器将文档的原始文本分成句子,并且使用分词器将每个句子进一步细分为单词。
接下来,每个句子都标有词性标签,这将在下一步命名实体检测中证明非常有用。
在这一步中,我们在每个句子中搜索可能有趣的实体。
最后,我们使用关系检测来搜索文本中不同实体之间的可能关系。
要执行前三个任务,我们可以定义一个简单的函数,简单地将NLTK的默认句子分割器,单词标记器和词性标注器连接在一起.
接下来,在命名实体检测中,我们对可能参与彼此有趣关系的实体进行细分和标记。
最后,在关系提取中,我们搜索文本中彼此接近的实体对之间的特定模式,并使用这些模式来构建记录实体之间关系的元组。
7.2分块
我们将用于实体检测的基本技术是分块,分段和标记多标记序列,如2.1所示。
较小的框显示字级标记化和词性标记,而大框显示更高级别的分块。
这些较大的盒子中的每一个都称为块。 与省略空格的标记化一样,分块通常选择标记的子集。与标记化一样,由chunker生成的片段在源文本中不重叠。
7.2.1 名词短语分块
我们将首先考虑名词短语分块或NP分块的任务,其中我们搜索对应于单个名词短语的块。例如,这里有一些华尔街日报文本,NP块使用括号标记:
NP分块最有用的信息来源之一是词性标签。这是在我们的信息提取系统中执行词性标注的动机之一。
sentence = [("the", "DT"), ("little", "JJ"), ("yellow", "JJ"),
("dog", "NN"), ("barked", "VBD"), ("at", "IN"), ("the", "DT"), ("cat", "NN")]
grammar = "NP: {<DT>?<JJ>*<NN>}"
cp = nltk.RegexpParser(grammar)
result = cp.parse(sentence)
result.draw()
7.2.2 标签模式
构成块语法的规则使用标记模式来描述带标记的单词序列。标记模式是使用尖括号分隔的词性标记序列,例如
我们可以使用上面第一个标记图案的略微细化来匹配这些名词短语,即
JJ.*可以匹配形容词,以及其比较级最高级,NN.*可以匹配常用名词单复数,专有名词单复数
但是,很容易找到许多更复杂的例子,这条匹配规则就不适用了,如下:
7.2.3 用正则表达式分块
2.3展示了一个由两条规则组成的简单块语法。第一条规则匹配一个可选的限定词或所有格代词,零或多个形容词,然后是名词。第二条规则匹配一个或多个专有名词。我们还定义了一个被分块[1]的示例语句,并在这个输入[2]上运行分块程序。
grammar = r"""
NP: {<DT|PP\$>?<JJ>*<NN>}
{<NNP>+}
"""
cp = nltk.RegexpParser(grammar)
sentence = [("Rapunzel", "NNP"), ("let", "VBD"), ("down", "RP"),
("her", "PP$"), ("long", "JJ"), ("golden", "JJ"), ("hair", "NN")]
result=cp.parse(sentence)
print(result)
result.draw()
如果标记模式在重叠的位置匹配,则最左边的匹配优先。例如,如果我们将匹配两个连续名词的规则应用到包含三个连续名词的文本中,那么只有前两个名词将被分块:
nouns = [("money", "NN"), ("market", "NN"), ("fund", "NN")]
grammar = "NP: {<NN>+} # Chunk two consecutive nouns"
cp = nltk.RegexpParser(grammar)
result=cp.parse(nouns)
print(result)
result.draw()
7.2.4 探索文本语料库
在2中,我们看到了如何查询标记语料库以提取与特定词性标签序列匹配的短语。我们可以使用chunker更轻松地完成相同的工作,如下所示
cp = nltk.RegexpParser('CHUNK: {<V.*> <TO> <V.*>}')
for sent in brown.tagged_sents():
tree = cp.parse(sent)
for subtree in tree.subtrees():
if subtree.label() == 'CHUNK':
print(subtree)
7.2.5 分块
Chinking是从块中删除一个令牌序列的过程。如果匹配的令牌序列跨越整个块,则删除整个块;如果令牌序列出现在块的中间,这些令牌将被删除,留下两个之前只有一个令牌的块。如果序列位于块的外围,则删除这些标记,并保留较小的块。这三种可能性在2.1中进行了说明。
grammar = r"""
NP:
{<.*>+} # Chunk everything
}<VBD|IN>+{ # Chink sequences of VBD and IN
"""
sentence = [("the", "DT"), ("little", "JJ"), ("yellow", "JJ"),
("dog", "NN"), ("barked", "VBD"), ("at", "IN"), ("the", "DT"), ("cat", "NN")]
cp = nltk.RegexpParser(grammar)
result=cp.parse(sentence)
result.draw()
7.2.6 表示块:标签与树
I (inside), O (outside), or B (begin).
标记表示块结构
IOB标记已经成为在文件中表示块结构的标准方法,我们也将使用这种格式。以下是2.5中的信息如何显示在文件中:
在这种表示中,每行有一个标记,每个标记都带有词性标记和块标记。这种格式允许我们表示多个块类型,只要这些块没有重叠。正如我们前面看到的,块结构也可以使用树来表示。这样做的好处是,每个块都是可以直接操作的组成部分。如2.6所示:
树表示块结构
注意:NLTK使用树来表示块的内部表示,但提供了将这些树读取和写入IOB格式的方法。
开发和评价分块器
7.3 开发和评价分块器
现在您已经了解了分块的功能,但我们还没有解释如何评估分块器。像往常一样,这需要适当注释的语料库。我们首先看一下将IOB格式转换为NLTK树的机制,然后讨论如何使用分块语料库在更大规模上完成此操作。我们将看到如何评估chunker相对于语料库的准确性,然后查看一些更多数据驱动的方法来搜索NP块。我们始终关注的重点是扩大分组的覆盖范围。
7.3.1阅读IOB格式和CoNLL 2000语料库
转换函数chunk.conllstr2tree()从这些多行字符串之一构建树表示。
此外,它允许我们选择要使用的三种块类型的任何子集,这里仅用于NP块:
text = '''
he PRP B-NP
accepted VBD B-VP
the DT B-NP
position NN I-NP
of IN B-PP
vice NN B-NP
chairman NN I-NP
of IN B-PP
Carlyle NNP B-NP
Group NNP I-NP
, , O
a DT B-NP
merchant NN I-NP
banking NN I-NP
concern NN I-NP
. . O
'''
nltk.chunk.conllstr2tree(text, chunk_types=['NP']).draw()
nltk.chunk.conllstr2tree(text).draw()
我们可以使用NLTK语料库模块访问更多的分块文本。CoNLL 2000语料库包含270k字的华尔街日报文本,分为“训练”和“测试”部分,用IOB格式的词性标签和块标签注释。我们可以使用nltk.corpus.conll2000访问数据。下面是读取语料库的“训练”部分的第100个句子的例子:
print(conll2000.chunked_sents('train.txt')[99])
正如您所看到的,CoNLL 2000语料库包含三种块类型:名词语块,我们已经看过了; 动词语块,如“has already delivered”; 介词语块,例如“because of”。
由于我们现在只对NP块感兴趣,我们可以使用chunk_types参数来选择它们:
7.3.2简单的评价和基准
现在,我们可以访问分块语料库,我们可以评估chunkers。我们首先为简单的块解析器cp(chunkparser)建立一个不创建分块的基线:
IOB标签准确度表示超过三分之一的单词被标记为O,即不在NP块中。但是,由于我们的标记器没有找到任何块,因此其精度,召回率和f度量均为零。现在让我们尝试一个正则表达式chunker,它寻找以名词短语标签(例如CD,DT和JJ)为特征的字母开头的标签。(初级的正则表达式分块器)
在3.1中,我们定义了UnigramChunker类,它使用unigram标记符来标记带有块标记的句子。此类中的大多数代码仅用于在NLTK的ChunkParserI接口使用的块树表示和嵌入式标记器使用的IOB表示之间来回转换。该类定义了两个方法:在构建新的UnigramChunker时调用的构造函数; 以及用于分块新句子的解析方法
# 使用训练语料找到对每个词性标记最有可能的块标记(I、O或B)
# 可以用unigram标注器建立一个分块器,但不是要确定每个词的正确词性标记,
# 而是给定每个词的词性标记,尝试确定正确的块标记
class UnigramChunker(nltk.ChunkParserI):
def __init__(self, train_sents):
train_data = [[(t, c) for w, t, c in nltk.chunk.tree2conlltags(sent)]
for sent in train_sents]
self.tagger = nltk.UnigramTagger(train_data)
def parse(self, sentence):
pos_tags = [pos for (word, pos) in sentence]
tagged_pos_tags = self.tagger.tag(pos_tags)
chunktags = [chunktag for (pos, chunktag) in tagged_pos_tags]
# 为词性标注IOB块标记
conlltags = [(word, pos, chunktag) for ((word, pos), chunktag)
in zip(sentence, chunktags)]
# 转换成分块树状图
return nltk.chunk.conlltags2tree(conlltags)
构造函数需要一个训练句列表,这些句子将以块树的形式出现。它首先将训练数据转换为适合训练标记器的形式,使用tree2conlltags将每个块树映射到word,tag,chunk三元组列表。然后,它使用转换后的训练数据来训练unigram标记器,并将其存储在self.tagger中供以后使用。
解析方法将标记的句子作为其输入,并从该句子中提取词性标签开始。然后,它使用在构造函数中训练的tagger self.tagger,使用IOB块标记标记词性标记。接下来,它提取块标签,并将它们与原始句子组合,以产生conlltags。最后,它使用conlltags2tree将结果转换回块树。
现在我们有了UnigramChunker,我们可以使用CoNLL 2000语料库对其进行训练,并测试其最终性能:
这个组合相当不错,整体f-measure得分为83%。
让我们看一下它的学习内容,使用它的unigram标记器为语料库中出现的每个词性标签分配一个标签:
它发现大多数标点符号都出现在NP块之外,除了#和和WP$)出现在NP块的开头,而名词类型(NN、NNP、NNPS、NNS)大多出现在NP块内部
构建了一个unigram chunker后,很容易构建一个bigram chunker:我们只需将类名更改为BigramChunker,并修改3.1中的行来构造一个BigramTagger而不是一个UnigramTagger。
由此产生的chunker性能略高于unigram chunker:
基于正则表达式的chunkers和n-gram chunkers都决定了基于词性标签完全创建的块。
但是,有时词性标签不足以确定句子应该如何分块。 例如,请考虑以下两个陈述
基于分类器的NP chunker的基本代码如3.2所示。它由两个类组成:
第一个类几乎与1.5中的ConsecutivePosTagger类相同。唯一的两个区别是它调用了一个不同的特征提取器,它使用的是MaxentClassifier而不是NaiveBayesClassifier。
第二个类基本上是一个围绕tagger类的包装器,它将它变成一个chunker。
在训练期间,该第二类将训练语料库中的块树映射到标签序列中;在parse()方法中,它将标记器提供的标记序列转换回块树
剩下要填写的唯一部分是特征提取器。我们首先定义一个简单的特征提取器,它只提供当前令牌的词性标记。使用这个特征提取器,我们的基于分类器的chunker与unigram chunker非常相似,正如其性能所反映的那样。