1. 我们如何能构建一个系统,从非结构化文本中提取结构化数据?
2. 有哪些稳健的方法识别一个文本中描述的实体和关系?
3. 哪些语料库适合这项工作,我们如何使用它们来训练和评估我们的模型?
7.1 信息提取
#一个重要的形式是结构化数据:实体和关系的可预 测的规范的结构。
#如果这个位置数据被作为一个元组(entity, relation, entity)的链表存储在 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')]
print([e1 for (e1, rel, e2) in locs if e2=='Atlanta'])
#建立一个非常通用的意义重现
#首先将自然语言句子这样 的非结构化数据转换成表 7-1 的结构化数据。
#然后,利用强大的查询工具,如 SQL。这种从文本获取意义的方法被称为信息提取。
#信息提取有许多应用,包括商业智能、简历收获、媒体分析、情感检测、专利检索、电子邮件扫描。
#当前研究的一个特别重要的领域是提取出电子科学文献的结构化数据,特别是 在生物学和医学领域。
信息提取结构
#首先,使用句子分割器将该文档的原始文本分割成句,使用分词器将每个句子进一步细分为词。
#接下来,对每个句子进行词性标注,在下一步命名实体识别 中将证 明这是非常有益的。
#在这一步,我们寻找每个句子中提到的潜在的有趣的实体。
#最后,我们 使用关系识别搜索文本中不同实体间的可能关系。
#要执行前面三项任务,我们可以定义一个函数,简单地连接 NLTK 中默认的句子分割器,分词器和词性标注器
import nltk
import re
import pprint
def ie_preprocess(document):
sentences = nltk.sent_tokenize(document)#分句
sentences = [nltk.word_tokenize(sent) for sent in sentences]#分词
sentences = [nltk.pos_tag(sent) for sent in sentences]#词性标注
#接下来,命名实体识别中,我们分割和标注可能组成一个有趣关系的实体
#最后,在提取关系时,我们搜索对文本中出现在附近的实体对之间的特殊模式,并使用 这些模式建立元组记录实体之间的关系。
7.2 分块
#将用于实体识别的基本技术是分块(chunking)
名词短语分块
#NP-分块(NP-chunking)
#NP-块被定义为不 包含其他的 NP-块
#个名词的任何介词短语或从句将不包括在相应的 NP-块内, 因为它们几乎可以肯定包含更多的名词短语。
#NP-分块信息最有用的来源之一是词性标记。这是在我们的信息提取系统中进行词性标 注的动机之一。
#为了创建一个 NP-块,我们将首先定义一个块语法,规则句子应如何分块。
#在本例中,我们将用一个正则 表达式规则定义一个简单的语法。
#这条规则是说一个 NP-块由一个可选的限定词(DT) 后面跟着任何数目的形容词(JJ)然后是一个名词(NN)组成。
#使用此语法,我们创建了 一个块分析器,测试我们的例句4。结果是一棵树,我们可以输出5或图形显示6。
sentence = [("the", "DT"), ("little", "JJ"), ("yellow", "JJ"), ("dog", "NN"),
("barked", "VBD"), ("at", "IN"), ("the", "DT"), ("cat", "NN")]
grammer = "NP: {?*}"
cp = nltk.RegexpParser(grammer)
result = cp.parse(sentence)
print(result)
result.draw()
标记模式
#组成一个块语法的规则使用标记模式来描述已标注的词的序列。
#一个标记模式是一个用 尖括号分隔的词性标记序列,如?*。
#标记模式类似于正则表达式模式
sentence =[("another", "DT"), ("sharp", "JJ"), ("dive", "NN"),
("trade", "NN"), ("figures", "NNS"),
("any", "DT"), ("new", "JJ"), ("policy", "NN"), ("measures", "NNS"),
("earlier", "JJR"), ("stages", "NNS"),
("Panamanian", "JJ"), ("dictator", "NN"), ("Manuel", "NNp"), ("Noriega", "NNP")]
grammer = "NP: {?*+}"
cp = nltk.RegexpParser(grammer)
result = cp.parse(sentence)
print(result)
result.draw()
用正则表达式分块
#要找到一个给定的句子的块结构,RegexpParser 分块器以一个没有标识符被分块的平面结构开始。
#轮流应用分块规则,依次更新块结构。所有的规则都被调用后,返回块结构。
#一个由 2 个规则组成的简单的块语法
#第一条规则匹配一个可选的限定词 或所有格代名词,零个或多个形容词,然后跟一个名词。
#第二条规则匹配一个或多个专有名词、
grammar = r"""NP:
{?*}
{+}"""
cp = nltk.RegexpParser(grammer)
# $符号是正则表达式中的一个特殊字符,必须使用转义符\来匹配 PP$标记。
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: {}"
cp = nltk.RegexpParser(grammar)
result = cp.parse(nouns)
print(result)
result.draw()
探索文本语料库
#使用分块器在已标注的语料库中提取匹配的特定的词性标记序列的短语
brown = nltk.corpus.brown
def find_chunks(cp):
for sent in brown.tagged_sents():
tree = cp.parse(sent)
for subtree in tree.subtrees():
if subtree.label() == 'CHUNK':
print(subtree)
cp = nltk.RegexpParser('CHUNK: {}')
print(find_chunks(cp))
加缝隙
#为不包括在一大块中的一个标识 符序列定义一个缝隙。在下面的例子中,barked/VBD at/IN是一个缝隙:
#[ the/DT little/JJ yellow/JJ dog/NN ] barked/VBD at/IN [ the/DT cat/NN ]
#加缝隙是从一大块中去除一个标识符序列的过程。
#如果匹配的标识符序列贯穿一整块,那么这一整块会被去除;
#如果标识符序列出现在块中间,这些标识符会被去除,在以前只有一个块的地方留下两个块。
#如果序列在块的周边,这些标记被去除,留下一个较小的块。
#简单的加缝器。
grammar = r"""
NP:
{+} # Chunk everything
}+{ # 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)
print(result)
块的表示:标记与树
#作为标注和分析之间的中间状态,块结构可以使用标记或树来表示。
#使用最 广泛的表示是 IOB 标记。
#每个标识符被用三个特殊的块标签之一标注,I(i nsi de,内部),O(outsi de,外部)或B(begin ,开始)
7.3 开发和评估分块器
读取 IOB 格式与 CoNLL2000 分块语料库
#使用 corpora 模块,我们可以加载已标注的《华尔街日报》文本,然后使用 IOB 符号分块。
#这个语料库提供的块类型有 NP,VP 和 PP。
#转换函数 chunk.conllstr2tree()用这些多行字符串建立一个树表示。
#此外,它允许我们选择使用三个块类型的任何子集,这里只是 NP 块:
#使用 NLTK的 corpus 模块访问较大量的已分块文本。
#CoNLL2000 分块语料 库包含 27 万词的《华尔街日报文本》,分为“训练”和“测试”两部分,标注有词性标记和 IOB 格式分块标记。
#我们可以使用 nltk.corpus.conll2000 访问这些数据。
#下面是一个读 取语料库的“训练”部分的 100 个句子的例子:
from nltk.corpus import conll2000
print(conll2000.chunked_sents('train.txt')[99])
print(conll2000.chunked_sents('train.txt', chunk_types=['NP'])[99])
简单评估和基准
#访问一个已分块语料,可以评估分块器
#为琐碎的不创建任何 块的块分析器 cp 建立一个基准(baseline):
from nltk.corpus import conll2000
cp = nltk.RegexpParser("")
test_sents = conll2000.chunked_sents('test.txt', chunk_types=['NP'])
print(cp.evaluate(test_sents))
#IOB 标记准确性表明超过三分之一的词被标注为 O,即没有在 NP 块中。
#然而,由于我 们的标注器没有找到任何块,其精度、召回率和 F-度量均为零。
#尝试一个初级 的正则表达式分块器,查找以名词短语标记的特征字母(如 CD、DT 和 JJ)开头的标记。
grammar = r"NP: {+}"
cp = nltk.RegexpParser(grammar)
print(cp.evaluate(test_sents))
#我们定义了 UnigramChunker 类,使用 unigram 标注器给句子加块标记。
#这个类的大部分代码只是用来在 NLTK的 ChunkParserI接口使用的分块树表示和嵌入式标注器使用的 IOB 表示之间镜像转换。
#类定义了两个方法:一个构造函数,当我们建立 一个新的 UnigramChunker 时调用;
#一个 parse 方法,用来给新句子分块。
#使用unigram标注器对名词短语分块
class UnigramChunker(nltk.ChunkParserI):
#构造函数需要训练句子的一个链表,这将是块树的形式
def __init__(self, train_sents):
#首先将训练数据转换成适 合训练标注器的形式,使用 tree2conlltags 映射每个块树到一个词,标记,块三元组的链 表。
train_data = [[(t, c) for w, t, c in nltk.chunk.tree2conlltags(sent)]
for sent in train_sents]
#然后使用转换好的训练数据训练一个 unigram 标注器,并存储在 self.tagger供以后使 用。
self.tagger = nltk.UnigramTagger(train_data)
# parse 方法取一个已标注的句子作为其输入,以从那句话提取词性标记开始。
def parse(self, sentence):
pos_tags = [pos for (word,pos) in sentence]
#然后使 用在构造函数中训练过的标注器 self.tagger,为词性标记标注 IOB 块标记
tagged_pos_tags = self.tagger.tag(pos_tags)
#提取 块标记,与原句组合,产生 conlltags
chunktags = [chunktag for (pos, chunktag) in tagged_pos_tags]
conlltags = [(word, pos, chunktag) for ((word,pos),chunktag)
in zip(sentence, chunktags)]
#使用 conlltags2tree 将结果转换成一个块树
return nltk.chunk.conlltags2tree(conlltags)
#现在我们有了 UnigramChunker,可以使用 CoNLL2000 分块语料库训练它,并测试其性能
test_sents = conll2000.chunked_sents('test.txt', chunk_types=['NP'])
train_sents = conll2000.chunked_sents('train.txt', chunk_types=['NP'])
unigram_chunker = UnigramChunker(train_sents)
print(unigram_chunker.evaluate(test_sents))
postags = sorted(set(pos for sent in train_sents
for (word,pos) in sent.leaves()))
print(unigram_chunker.tagger.tag(postags)[:5])
训练基于分类器的分块器
#无论是基于正则表达式的分块器还是 n-gram 分块器,决定创建什么块完全基于词性标记
#有时词性标记不足以确定一个句子应如何分块。
#如 果我们想最大限度地提升分块的性能,我们需要使用词的内容信息作为词性标记的补充
#我们包含词的内容信息的方法之一是使用基于分类器的标注器对句子分块。
7.4 语言结构中的递归
用级联分块器构建嵌套结构
#到目前为止,我们的块结构一直是相对平的。已标注标识符组成的树在如 NP 这样的块 节点下任意组合。
#然而,只需创建一个包含递归规则的多级的块语法,就可以建立任意深度 的块结构。
#一个分块器,处理NP,PP,VP和S。
grammar = r"""
NP: {+} # Chunk sequences of DT, JJ, NN
PP: {} # Chunk prepositions followed by NP
VP: {+$} # Chunk verbs and their arguments
CLAUSE: {} # Chunk NP, VP
"""
cp = nltk.RegexpParser(grammar)
sentence = [("Mary", "NN"), ("saw", "VBD"), ("the", "DT"), ("cat", "NN"),
("sit", "VB"), ("on", "IN"), ("the", "DT"), ("mat", "NN")]
result = cp.parse(sentence)
print(result)
result.draw()
#这一结果丢掉了 saw 为首的 VP。它还有其他缺陷。
sentence = [("John", "NNP"), ("thinks", "VBZ"), ("Mary", "NN"),
("saw", "VBD"), ("the", "DT"), ("cat", "NN"), ("sit", "VB"),
("on", "IN"), ("the", "DT"), ("mat", "NN")]
print(cp.parse(sentence))
#解决方案是:让分块器在它的模式中循环:尝试完所有模式之后,重复此过程。
#我们添加一个可选的第二个参数 loop 指定这套模式应该循环的次数:
cp = nltk.RegexpParser(grammar, loop=2)
print(cp.parse(sentence))
树
#树是一组连接的加标签节点,从一个特殊的根节点沿一条唯一的路径到达每个节点。
# (S
# (NP Alice)
# (VP
# (V chased)
# (NP
# (Det the)
# (N rabbit))))
#在 NLTK 中,我们创建了一棵树,通过给一个节点添加标签和一个孩子链表
tree1 = nltk.Tree('NP', ['Alice'])
print(tree1)
tree2 = nltk.Tree('NP', ['the', 'rabbit'])
print(tree2)
#可以将这些不断合并成更大的树
tree3 = nltk.Tree('VP', ['chased', tree2])
print(tree3)
tree4 = nltk.Tree('S', [tree1, tree3])
print(tree4)
#树对象的一些的方法:
print(tree4[1])
print(tree4[1].label())
print(tree4.leaves())
print(tree4[1][1][1])
tree4.draw()
树遍历
#使用递归函数来遍历树是标准的做法
#递归函数遍历树。
def traverse(t):
try:
t.label()
except AttributeError:
print(t, end=" ")
else:
# Now we know that t.node is defined
print('(', t.label(), end=" ")
for child in t:
traverse(child)
print(')', end=" ")
t = nltk.Tree('S', [['NP', ['Alice']],['VP', ['chased']]])
#t = nltk.Tree('(S (NP Alice) (VP chased (NP the rabbit)))')
traverse(t)
7.5 命名实体识别
#命名实体(NEs)
#命名实体是确切的名词短语,指示特 定类型的个体,如组织、人、日期等。
#命名实体识别(NER)系统的目标是识别所有文字提及的命名实体。
#可以分解成两个子 任务:确定 NE 的边界和确定其类型。
#命名实体识别经常是信息提取中关系识别的前奏,它 也有助于其他任务。
#我们如何识别命名实体呢?一个办法是查找一个适当的名称列表。
#例如:识别地点时, 我们可以使用地名辞典,如亚历山大地名辞典或盖蒂地名辞典
#人或组织的名称的情况更加困难。任何这些名称的列表都肯定覆盖不全
#困难的另一个原因是许多命名实体措辞有歧义。
#May 和 North 可能分别是日期和地点类 型的命名实体的,但也可以都是人名
#更大的挑战来自如 Stanford University 这样的多词名称和包含其他名称的名称
#NLTK提供了一个已经训练好的可以识别命名实体的分类器,使用函数 nltk.ne_chun k()访问。
#如果我们设置参数 binary=True,那么命名实体只被标注为 NE;
#否则,分类 器会添加类型标签,如 PERSON, ORGANIZATION, and GPE。
sent= nltk.corpus.treebank.tagged_sents()[22]
print(nltk.ne_chunk(sent, binary=True))
print(nltk.ne_chunk(sent))
7.6 关系抽取
#一旦文本中的命名实体已被识别,我们就可以提取它们之间存在的关系。
#我们通常会寻找指定类型的命名实体之间的关系。
#进行这一任务的方法之一是首先寻找所有 (X, α, Y)形式的三元组,其中 X 和 Y 是指定类型的命名实体,α表示 X 和 Y 之间关系的 字符串。
#然后我们可以使用正则表达式从α的实体中抽出我们正在查找的关系。
#搜索包含词 in 的字符串。
#特殊的正则表达式(?!\b.+ing\b)是一个否定预测先行断言,
#允许 我们忽略如 success in supervising the transition of 中的字符串,其中 in 后面跟一个动名词。
IN = re.compile(r'.*\bin\b(?!\b.+ing)')
for doc in nltk.corpus.ieer.parsed_docs('NYT_19980315'):
for rel in nltk.sem.extract_rels('ORG', 'LOC', doc,
corpus='ieer', pattern = IN):
print(nltk.sem.rtuple(rel))
7.7 小结
信息提取系统搜索大量非结构化文本,寻找特定类型的实体和关系,并用它们来填充有 组织的数据库。这些数据库就可以用来寻找特定问题的答案。
信息提取系统的典型结构以断句开始,然后是分词和词性标注。接下来在产生的数据中 搜索特定类型的实体。最后,信息提取系统着眼于文本中提到的相互临近的实体,并试 图确定这些实体之间是否有指定的关系。
实体识别通常采用分块器,它分割多标识符序列,并用适当的实体类型给它们加标签。常见的实体类型包括组织、人员、地点、日期、时间、货币、GPE(地缘政治实体)。
用基于规则的系统可以构建分块器,例如:NLTK中提供的 RegexpParser类;或使用机器学习技术,如本章介绍的 ConsecutiveNPChunker。在这两种情况中,词性标 记往往是搜索块时的一个非常重要的特征。
虽然分块器专门用来建立相对平坦的数据结构,其中没有任何两个块允许重叠,但它们可以被串联在一起,建立嵌套结构。
关系抽取可以使用基于规则的系统,它通常查找文本中的连结实体和相关的词的特定模式;或使用机器学习系统,通常尝试从训练语料自动学习这种模式。