自然语言处理实战——信息提取与知识图谱

203 阅读47分钟

本章内容:

  • 从文本中提取命名实体
  • 使用依存句法分析理解句子的结构
  • 将依存句法树转换为知识
  • 从文本中构建知识图谱

在第10章中,你学习了如何使用大型变换器生成听起来聪明的词语。但仅靠语言模型,它们只是通过预测下一个看起来合适的词来“伪装”成聪明。直到你给你的AI提供关于世界的事实和知识,它才能对现实世界进行推理。在第2章中,你学到了如何做到这一点,虽然那时你还没有意识到。你能够为词语标注词性及其在句子意义中的逻辑角色(依存句法树)。这个老派的标注算法,正是让你的生成语言模型(AI)了解现实世界所需的全部。 本章的目标是教会你的机器人理解它所读取的内容。然后,你将把这种理解转化为一个灵活的数据结构,用来存储知识,这个数据结构被称为知识图谱。之后,你的机器人可以使用这些知识来做决策,并对世界发表聪明的看法。

正确解析文本中的实体,并发现它们之间的关系,是你从文本中提取事实的关键。知识图谱,也叫知识数据库或语义网,是将知识以概念之间的关系形式存储的数据库。虽然你可以使用关系数据库来存储这些关系和概念,但有时使用图数据结构会更合适。图中的节点将是实体,而边则表示这些实体之间的关系。

你可以在图11.1中看到一个简单的知识图谱示例。

image.png

你从自然语言文本中提取的每个事实,都可以用来在知识图谱的节点之间创建新的连接。有时,事实甚至可以用来在你的图谱中创建新的节点或实体。你可以使用知识图谱通过查询语言(如GraphQL、Cypher,甚至SQL)回答关于事物之间关系的问题。这是将NLP管道(例如聊天机器人)与现实世界事实结合的最可靠方式之一。

你将需要一个知识图谱来可靠地对LLM生成的文本进行事实核查。事实证明,传统的“好老式AI”(GOFAI)是开发真正的AI系统的关键,这些系统不仅仅是模仿它们在网上阅读的所有废话。如果你保持一个聊天机器人知道或相信的事实数据库,你就可以对自然语言文本进行事实核查。你不仅应该对人类编写的文本保持怀疑态度,也应该对你自己的NLP管道或AI生成的文本保持怀疑。有了知识图谱或事实数据库,你的AI算法将能够进行自我审视,让你知道它们所告诉你的信息是否可能具有某种程度的真实性,并提供它们告诉你和用户的信息来源。

你的AI还可以使用知识图谱来填补大语言模型中的常识知识空白,或许,这也能稍微实现大语言模型和AI的部分宣传。这是NLP链条中的缺失环节,你需要它来创建真正的AI。你可以使用知识图谱以编程方式生成合理的文本,因为这些文本是基于你数据库中的事实。你甚至可以推断出关于世界的新事实或逻辑推论,这些推论尚未包含在你的知识库中。

你可能记得在深度学习模型的前向传播或预测主题中听到过推理。深度学习语言模型使用统计学来估算或猜测你给它的文本中的下一个词语。而深度学习研究人员希望有一天,神经网络能够匹配人类在逻辑推理和世界推断方面的自然能力。但这不可行,因为单词本身并不包含机器处理所需的所有关于世界的知识,以做出事实上正确的推理。因此,你将使用一种行之有效的逻辑推理方法,称为符号推理。

如果你熟悉编译器的概念,那么你可能会将依存树看作是解析树或抽象语法树(AST)。AST定义了机器语言表达式或程序的逻辑。你将使用自然语言依存树来提取自然语言文本中的逻辑关系。这些逻辑将帮助你为统计深度学习模型提供基础,使它们能够做的事情不仅仅是像前几章那样对世界进行统计“猜测”。

11.1 确立基础

一旦你有了知识图谱,你的聊天机器人和AI代理就可以以一种可解释的方式正确推理关于世界的信息。如果你能从你的深度学习模型生成的文本中提取事实,你可以检查这些文本是否与你在知识图谱中收集的知识一致。

“确立基础”是将LLM(大语言模型)响应固定在现实世界知识中的过程。这可以通过相关的事实和来源或与用户相关的信息来实现。在上一章中,你学到了确立LLM基础的一种方法:从你的知识库中检索并提供非结构化文本。在本章中,你将看到另一种有用的方法来使用结构化知识——知识图谱。

确立基础还可以以其他方式帮助你的NLP管道。使用知识图谱作为算法推理部分,可以使你的语言模型专注于它最擅长的任务:生成合理的、语法正确的文本。这使你可以微调你的语言模型,使其具有你想要的语气,而不必试图构建一个变色龙一样的模型,假装理解和推理世界。你的知识图谱可以设计为仅包含你希望AI理解的关于世界的事实——无论是你心中的现实世界事实,还是你正在创建的虚构世界。通过将推理与语言分离,你可以创建一个既听起来正确又实际正确的NLP管道。

在使用知识图谱进行确立基础的过程中,还有一些常见的术语。有时,这个过程被称为符号推理,相对于机器学习模型的概率推理。第一阶逻辑——或称为GOFAI(Good Old-Fashioned AI)——是一种符号推理系统。这是构建专家系统和定理证明器的首选方法,早在现代数据和处理能力可用于机器学习和深度学习之前,GOFAI就已经存在。GOFAI试图在不使用概率、矩阵,甚至不使用数字位和字节的情况下,接近计算机智能。它假设智能生物是以符号的方式思考的:“大脑可以被视为根据正式规则操作信息位的设备。”它的从业者在1960年代取得了显著成功,但也遇到了这一方法的局限性。如今,GOFAI又回到潮流中,研究人员试图构建我们可以依赖做出重要决策的通用智能系统。只是现在,他们可以将神经(概率)方法与符号方法结合使用。

确立NLP管道基础的另一个优势是,你可以使用知识库中的事实来解释它的推理过程。如果你让一个没有确立基础的LLM解释为什么它说了不合理的话,它只会通过编造越来越多的荒谬理由,继续为自己(和你)挖坑。你在前几章中看到过这种情况,当LLM自信地编造(虚构)不存在的、但看似合理的参考文献和虚构人物,以解释它们荒唐言论的来源。创建一个你可以信任的AI的关键是给它打下推理的基础,使用知识图谱。这个确立基础过程中第一个,也是可能最重要的算法是知识提取。

11.1.1 回归老派方法:使用模式进行信息提取

在本章中,我们将回顾一些你在早期章节中看到过的方法,比如正则表达式。为什么要回到手工编写的正则表达式和模式呢?因为你的统计或数据驱动的NLP方法是有局限的。你希望你的机器学习管道能够做一些基本的事情,比如回答逻辑问题,或者执行某些操作,如根据NLP指令安排会议。而机器学习在这里会失效。

此外,正如你在这里看到的,你可以定义一组紧凑的条件检查(正则表达式)来从自然语言字符串中提取关键信息。而且它可以适用于广泛的问题。模式匹配(和正则表达式)依然是信息提取及相关任务的最先进方法。

好了,不再赘述。让我们开始知识提取和确立基础的旅程吧!但首先,我们需要覆盖处理文档中的一个重要步骤,以生成适合你知识提取管道的输入。我们需要将文本分解成更小的单元。

11.2 首先:将文本分割成句子

在你开始从原始文本中提取知识之前,需要先将其拆分成可以处理的部分。文档切分对于创建半结构化的数据非常有用,这样的数据可以使文档更易于搜索、过滤和排序,便于信息检索。而对于信息提取,特别是当你需要提取关系以构建知识库(例如卡内基梅隆大学的NELL(Never-Ending Language Learner知识库)或Freebase等)时,你需要将文本分解成可能包含一个或两个事实的部分。当你将自然语言文本划分为有意义的片段时,这个过程被称为分段。得到的片段可以是短语、句子、引号、段落,甚至是长文档的整个章节。

句子是大多数信息提取问题中最常见的切分单位。句子通常以几种符号之一作为标点符号(如.、?、!或换行符)。语法正确的英语句子必须包含一个主语(名词)和一个动词,这意味着它们通常至少包含一个值得提取的事实。句子通常是自包含的意义单元,大多数信息不依赖于前面的文本就能表达清楚。

除了促进信息提取,你还可以将其中一些陈述或句子标记为对话的一部分,或者适合在对话中进行回复。使用句子分割器使你能够在更长的文本(如书籍)上训练你的聊天机器人。适当选择这些书籍能够让你的聊天机器人比纯粹通过Twitter流或IRC聊天训练的聊天机器人拥有更具文学性和智能的风格。这些书籍还为你的聊天机器人提供了更广泛的训练文档,从而帮助它建立关于世界的常识性知识。

句子分割有助于将事实彼此隔离,是信息提取管道的第一步。大多数句子表达一个单一连贯的思想,通常是关于现实世界中的事物。而最重要的是,所有自然语言都有句子或某种形式的逻辑连贯的文本段落,并且有一种广泛共享的生成句子的过程(即一套语法“规则”或习惯)。

然而,分割文本并识别句子边界比你想象的要复杂。以英语为例,单一的标点符号或字符序列并不能总是标志着句子的结束。

11.2.1 为什么 split('.!?') 不行?

即使是人类读者,在以下引号中的每个句子边界可能也会有困难。这里有一些例句,大多数人类可能会倾向于将它们拆分成多个句子:

她大喊“它就在这里!”但我还是继续寻找句子的边界。

我愣住了,看着“我怎么到这儿的?”,“我在哪里?”、“我还活着吗?”等话语在屏幕上飘过。

作者写道:“‘我不认为它是有意识的。’图灵说。”

即便是人类读者,也会在这些引号、嵌套引号以及故事中的故事中找不到合适的句子边界。更多类似的“句子分割边界案例”可以在TM-Town网站上找到。3

技术文本特别难以分割成句子,因为工程师、科学家和数学家通常使用句号和感叹号来表示许多其他东西,而不是句子的结束。当我们尝试在这本书中找到句子边界时,我们不得不手动修正多个提取出来的句子。

如果我们能像电报一样写英语,在每个句子末尾加上 STOP 或独特的标点符号就好了。但因为我们不是这样做的,所以你需要一些比单纯的 split('.!?') 更复杂的 NLP。希望你已经在脑海中想出了一个解决方案。如果是的话,它可能是基于你在本书中使用的两种 NLP 方法之一:

  1. 手动编程的算法(正则表达式和模式匹配)
  2. 统计模型(基于数据的模型或机器学习)

我们通过展示如何使用正则表达式以及更高级的方法来找到句子边界,利用句子分割问题重新回顾这两种方法。你将使用本书的文本作为训练和测试集,来展示一些挑战。幸运的是,我们没有在句子中间插入换行符来手动换行,例如像报纸栏目的布局那样。否则,这个问题会更加复杂。实际上,这本书的大部分源文本(以 AsciiDoc 格式编写)使用了“传统”的句子分隔符(每个句子结束后两个空格)或者每个句子单独放在一行。这是为了我们可以将这本书作为训练和测试集来使用,测试你的分割器。

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

正则表达式只是表达“如果...则...”规则树(正则语法规则)的一种简便方式,用于在字符串中查找字符模式。正如我们在第一章和第二章中提到的,正则表达式(正则语法)是一种特别简洁的方式,能够指定有限状态机的结构。

任何形式语法都可以被机器以两种方式使用:

  1. 识别与该语法匹配的内容
  2. 生成一个新的符号序列

这种形式语法和有限状态机的模式匹配方法具有一些其他非常棒的特点。真正的有限状态机保证在有限的步骤内最终停止(停机)。因此,如果你使用正则表达式作为模式匹配器,你知道你总会收到一个关于是否在字符串中找到匹配的答案。它永远不会陷入永久循环……只要你不“作弊”并在正则表达式中使用预查(lookahead)或回溯(lookbehind)。由于正则表达式是确定性的,它总是返回一个匹配或不匹配的结果,意味着它永远不会给你低于100%的信心或匹配概率。

因此,你会确保你的正则表达式匹配器处理每个字符,并且仅当字符匹配时才继续处理下一个字符——有点像严格的列车员在座位间走动检查车票。如果你没有车票,列车员就会停下来,宣布存在问题,不匹配,并拒绝继续前进、预查或回溯,直到问题解决。列车乘客没有回头路,正则表达式也是如此。

在这个例子中,我们的正则表达式或有限状态机(FSM)只有一个目的:识别句子边界。如果你搜索“句子分割器”这个关键词,4你很可能会找到一些旨在捕捉最常见句子边界的正则表达式。以下是一些正则表达式,它们经过组合和增强,可以为你提供一个快速、通用的句子分割器。

以下正则表达式可以处理一些“正常”的句子:

>>> re.split(r'[!.?]+[\s$]+',
...     "Hello World.... Are you there?!?! I'm going to Mars!")
['Hello World', 'Are you there', "I'm going to Mars!"]

不幸的是,这种 re.split 方法会吞掉(消耗掉)句子终止符。注意到“Hello World”结尾的省略号和句号在返回的列表中消失了吗?分割器只会返回句子终止符,前提是它是文档或字符串中的最后一个字符。不过,一个假设句子以空白字符结尾的正则表达式在忽略双重嵌套引号中的句号方面表现得相当好:

>>> re.split(
...    r'[!.?]+[\s$]+',
...    "The author wrote "'It isn't conscious.' Turing said."")
['The author wrote "'It isn't conscious.' Turing said."']

看到返回的列表中只包含一个句子,并且没有破坏引号中的内容吗?不幸的是,这个正则表达式模式也会忽略在引号中的句号,这些句号可能是一个实际句子的终结符,所以任何以引号结尾的句子都会与随后的句子合并。如果你的句子分割器依赖于准确的句子拆分,那么这可能会减少后续信息提取步骤的准确性。

那么对于短信、推文等带有缩写文本、非正式标点和表情符号的情况呢?

匆忙的人类往往将句子挤在一起,句号周围没有空格。以下的正则表达式可以处理短信中的句号,这些句号的两边有字母,并且它能够安全跳过数值:

>>> re.split(r'(?<!\d).|.(?!\d)', "I went to GT.You?")
['I went to GT', 'You?']

即使将这两个正则表达式组合成一个复杂的表达式,比如 r'(?<!\d).|.(?!\d|( )[\s$]',也不足以正确处理所有句子。如果你解析本章的AsciiDoc文本,它将会出现几处错误。你需要在正则表达式模式中添加更多的预查和回溯,以提高它作为句子分割器的准确性。你已经被警告过了!

如果为了寻找所有的边界情况并围绕它们设计规则而感到繁琐,那是因为它确实很繁琐。更好的句子分割方法是使用经过标注的句子集训练的机器学习算法。通常,逻辑回归或单层神经网络(感知机)就足够了。6多个包中包含了你可以用来改善句子分割器的统计模型。SpaCy7和Punkt(在NLTK中)8都具有良好的句子分割器。你可以猜猜我们用的是哪个。9

SpaCy 提供了一个句子分割器,它是内置于默认解析器管道中的,是进行关键任务应用的最佳选择。它几乎总是最准确、最健壮、最具性能的选择。以下是如何使用 SpaCy 将文本分割为句子:

>>> import spacy
>>> nlp = spacy.load('en_core_web_md')
>>> doc = nlp("Are you an M.D. Dr. Gebru? either way you are brilliant.")
>>> [s.text for s in doc.sents]
['Are you an M.D. Dr. Gebru?', 'either way you are brilliant.']

SpaCy 的准确性依赖于依赖解析。依赖解析器识别每个词是如何依赖于句子图中的其他词的,就像你在小学时学过的那种图。拥有这种依赖结构以及词标记嵌入帮助 SpaCy 句子分割器准确地处理歧义的标点和大写。所有这些复杂性都需要处理能力和时间。当你只处理几个句子时,速度并不重要,但如果你想解析本书第九章的 AsciiDoc 手稿呢?

>>> from nlpia2.text_processing.extractors import extract_lines
>>> t0 = time.time(); lines = extract_lines(
...     9, nlp=nlp); t1=time.time()            #1
>>> t1 - t0
15.98...
>>> t0 = time.time(); lines = extract_lines(9, nlp=None); t1=time.time()
>>> t1 - t0
0.022...
#1 第一个参数可以是 AΔOC 文件的路径或章节号。

哇,真慢!SpaCy 比正则表达式慢约 700 倍。如果你有数百万个文档,而不仅仅是这一章的文本,那么你可能需要做一些不同的事情。例如,在一个医学记录解析项目中,我们需要切换到正则表达式分词器和句子分割器。正则表达式解析器将我们的处理时间从几周缩短到几天,但它也降低了我们 NLP 流水线的其余部分的准确性。

SpaCy 现在允许你启用或禁用你想要的任何流水线组件。它还有一个不依赖于 SpaCy 流水线其他元素(如词嵌入和命名实体识别器)的统计句子分割器。当你想加速 SpaCy 的 NLP 流水线时,可以删除所有不需要的组件,只保留你想要的流水线元素。

首先,查看 spaCy NLP 管道的 pipeline 属性,看看默认有哪些组件。然后,使用 exclude 关键字参数来清理管道:

>>> nlp.pipeline
[('tok2vec', <spacy.pipeline.tok2vec.Tok2Vec at 0x...>),
 ('tagger', <spacy.pipeline.tagger.Tagger at 0x7...>),
 ('parser', <spacy.pipeline.dep_parser.DependencyParser at 0x...>),
 ('attribute_ruler',
  <spacy.pipeline.attributeruler.AttributeRuler at 0x...>),
 ('lemmatizer',
  <spacy.lang.en.lemmatizer.EnglishLemmatizer at 0x...>),
 ('ner', <spacy.pipeline.ner.EntityRecognizer at 0x...>)]
>>> nlp = spacy.load("en_core_web_md", exclude=[
...    'tok2vec', 'parser', 'lemmatizer',                #1
...    'ner', 'tagger', 'attribute_ruler'])
>>> nlp.pipeline         #2
[]
#1 tok2vec、ner 和 lemmatizer 算法可能是最慢的部分。
#2 如果你成功移除了所有内容,应该看到一个空列表。

现在,你已经清理了管道,可以重新添加你需要的重要部分。在这一章的速度测试中,你的 NLP 管道只需要 senter 管道元素。senter 管道是统计句子分割器:

>>> import time
>>> nlp.enable_pipe('senter')
>>> nlp.pipeline
[('senter', <spacy.pipeline.senter.SentenceRecognizer at 0x...>)]
>>> t0 = time.time(); lines2 = extract_lines(nlp=nlp); t1=time.time()
>>> t1 - t0
2.3...

这是一个显著的时间节省——在八核 i7 笔记本上,速度从 16 秒减少到 2.3 秒。统计句子分割器比完整的 spaCy 管道快大约五倍。正则表达式方法仍然会更快,但统计句子分割器会更准确。你可以通过比较句子的列表来估算这两种算法的准确性,看它们是否产生了相同的分割。虽然这不能告诉你哪种方法正确地分割了特定的文本行,但至少你会看到当两个 spaCy 管道一致时:

>>> import pandas as pd
>>> df_regex = pd.DataFrame(lines)       #1
>>> df_spacy = pd.DataFrame(lines2)           #2
>>> (df_regex['sents_regex'][df_regex.is_body]
...  == df_spacy['sents_spacy'][df_spacy.is_body]
... ).sum() / df_regex.is_body.sum()
0.76
#1 正则表达式句子分割器分割的文本行
#2 spaCy 句子分割器分割的文本行

看起来大约 93% 的书中的句子在慢速和快速管道中是以相同的方式进行分割的。可以查看一些示例分割,看看哪种方法更适合你的使用场景:

>>> df_regex['sents_regex'].iloc[59]
37               [_Transformers_ are changing the world.]
                              ...

>>> df_spacy['sents_spacy'].iloc[59]
37             [_, Transformers_ are changing the world.]
                              ...

看起来开头有下划线字符(_)的句子对于快速的统计分割器来说稍微更难处理。所以,当你解析 Markdown 或 AsciiDoc 文本文件时,可能需要使用完整的 spaCy 模型。格式化字符会让统计分割器困惑,特别是当它没有接受过类似文本的训练时。

11.2.3 句子语义

现在你已经将文本分割成包含离散事实的句子,接下来可以开始提取这些事实并将它们结构化为知识图谱。首先,创建第9章所有句子的BERT嵌入热图:

>>> import pandas as pd
>>> url = 'https://gitlab.com/tangibleai/nlpia2/-/raw/main/'
>>> url += 'src/nlpia2/data/nlpia_lines.csv'       #1
>>> df = pd.read_csv(url, index_col=0)
>>> df9 = df[df.chapter == 9].copy()
>>> df9.shape
(2028, 24)
#1 这个数据文件包含了本书草稿的AsciiΔoc文本行。

查看这个数据框。它有一些列,包含每行文本的标签。你可以使用这些标签来过滤出不想处理的行:

>>> pd.options.display.max_colwidth=25
>>> df9[['text', 'is_title', 'is_body', 'is_bullet']]
                           text  is_title  is_body  is_bullet
19057  = Stackable deep lear...      True    False      False
...                         ...       ...      ...        ...
21080  * By keeping the inpu...     False    False       True
21081  * Transformers combin...     False    False       True
21082  * The GPT transformer...     False    False       True
21083  * Despite being more ...     False    False       True
21084  * If you chose a pret...     False    False       True

现在,你可以使用 is_body 标签来处理手稿中的所有句子。这些行应该大多包含完整的句子,以便你可以将它们语义上进行比较,查看我们表达类似内容的频率热图。你可以利用你对BERT等Transformer模型的理解,获取比spaCy更有意义的文本表示:

>>> texts = df9.text[df9.is_body]
>>> texts.shape
(672,)
>>> from sentence_transformers import SentenceTransformer
>>> minibert = SentenceTransformer('all-MiniLM-L12-v2')
>>> vecs = minibert.encode(list(texts))
>>> vecs.shape
(672, 384)

MiniLM模型是一个多用途的BERT Transformer,已经经过优化和“蒸馏”。它提供了高精度和速度,且从Hugging Face下载不需要太长时间。现在,你已经有了689段文本(大部分是单个句子)。MiniLM语言模型已经将它们嵌入到一个384维的向量空间中。如你在第6章中所学,嵌入向量的语义相似度通过归一化的点积来计算:

>>> from numpy.linalg import norm
>>> dfe = pd.DataFrame([list(v / norm(v)) for v in vecs])
>>> cos_sim = dfe.values.dot(dfe.values.T)
>>> cos_sim.shape
(672, 672)

现在,你得到了一个方阵,每行和每列都对应着一个文本片段及其BERT嵌入向量。矩阵中的每个单元格包含该对嵌入向量之间的余弦相似度。若将列和行标记为文本片段的前几个字符,将更容易通过热图解释这些数据:

>>> labels = list(texts.str[:14].values)
>>> cos_sim = pd.DataFrame(cos_sim, columns=labels, index=labels)
                This chapter c  _Transformers_  ...  A personalized
This chapter c        1.000000        0.187846  ...        0.073603
_Transformers_        0.187846        1.000000  ...       -0.010858
The increased         0.149517        0.735687  ...        0.064736
...                        ...             ...  ...             ...
So here's a qu        0.124551        0.151740  ...        0.418388
And even if yo        0.093767        0.080934  ...        0.522452
A personalized        0.073603       -0.010858  ...        1.000000

像往常一样,余弦相似度的值范围在0和1之间,大部分值都小于0.85(85%),除非是那些表达基本相同意思的句子。因此,85%将是识别冗余句子的一个良好阈值,这些句子可能需要合并或重新措辞,以提高书籍中写作的质量。以下是这些余弦相似度值的热图效果:

>>> import seaborn as sns
>>> from matplotlib import pyplot as plt
>>> sns.heatmap(cos_sim)
<Axes: >
>>> plt.xticks(rotation=-35, ha='left')
>>> plt.show(block=False)

这将帮助你更直观地看到文本片段之间的相似性,并在句子级别上进行有效的优化。

image.png

似乎只有一个小块的白热化相似度,大约在第9章的60%处,可能接近那一行开始的地方:“Epoch: 13 … .”这一行对应着Transformer训练过程中的输出文本,因此不奇怪一个自然语言模型会将这些机器生成的行视为语义上相似。毕竟,BERT语言模型只是在对你说:“对我来说这完全是天书。”脚本中用于标记手稿中行作为自然语言或软件块的正则表达式似乎效果不好。如果你改进了 nlpia2.text_processing.extractors 中的正则表达式,你就可以让你的热图跳过这些无关的代码行。而且,AsciiDoc文件是结构化数据,所以它们应该是机器可读的,而不需要任何正则表达式的猜测。如果有一个更新的Python库来解析AsciiDoc文本,那就好了。

这是第3章文本的另一个热图。你在这里看到了什么有趣的东西吗?

image.png

注意到那个巨大的深红色交叉(在打印中是灰色交叉),横跨了整章吗?这意味着交叉中间的文本与章节中的其他所有文本有很大不同。你能猜到为什么吗?那部分包含一句话,开头是“Ernqnov … ,”这是“Python禅”(import this)中的加密行。而在那个位置的微小白色矩形表明,每一行加密的诗句与它附近的行非常相似。

语义热图是发现文本数据结构的一种方式,但如果你想从文本中创造知识,你需要更进一步。你的下一步是使用句子的向量表示,创建一个实体之间“连接”的“图”。在现实世界中,实体通过事实相关联。我们对世界的心理模型是一个信念网络或知识图谱——一个连接你所了解的所有事物的网络。

11.3 知识提取管道

一旦你将句子组织好,就可以开始从自然语言文本中提取概念和关系。例如,假设一个聊天机器人用户说:“提醒我星期一阅读AI Index。”你希望这个语句触发一个日历条目或警报,安排在当前日期后的下一个星期一。但这可不是那么简单。

为了用自然语言触发正确的动作,你需要一个像NLU(自然语言理解)管道或解析器那样的工具,它比Transformer或大型语言模型更精确。你需要知道,“me”代表一个特定类型的命名实体:一个人。命名实体是指代现实世界中特定事物(如人、地点或物品)的自然语言术语或n-gram。听起来很熟悉吧?在英语语法中,表示人、地点或物品的词类(POS)是名词。因此,你会看到spaCy与命名实体的标记关联的POS标签是NOUN。

而且,聊天机器人应该知道它可以通过替换用户名或其他身份信息来展开或解析这个词。你还需要让聊天机器人识别出aiindex.org是一个缩写的URL,这是一个命名实体——某个特定事物的名称,比如一个网站或公司。而且它需要知道,这种命名实体的标准化拼写可能是aiindex.orgaiindex.org,甚至可能是www.aiindex.org。同样,你还需要让聊天机器人识别出“Monday”是星期几中的一天(另一种命名实体,称为事件),并能够在日历上找到它。

为了让聊天机器人正确回应这个简单的请求,你还需要让它提取出命名实体“me”和命令“remind”之间的关系。它甚至需要识别出句子中隐含的主语“you”,指的是聊天机器人,另一个人类命名实体。最后,你需要教会聊天机器人,提醒是发生在未来的,所以它应该找到最近的一个星期一来创建提醒。

这只是一个简单的用例。你可以使用自己的常识知识或你希望AI了解的领域知识从零构建一个图谱。但是,如果你能够从文本中提取知识,你就可以更快地构建更大的知识图谱。此外,你还需要这个算法来核对语言模型生成的任何文本。知识提取需要四个主要步骤,如图11.2所示。

image.png

幸运的是,spaCy语言模型包括了知识提取的构建模块:命名实体识别、共指解析和关系提取。你只需要知道如何将这些步骤的结果结合起来,将各个部分连接在一起。让我们通过看一篇关于AI伦理领域思想领袖Timnit Gebru的文章来逐步了解每个阶段的过程。我们将继续使用前一节中初始化的spaCy NLP模型。首先,我们从Wikipedia下载关于Timnit Gebru的文章。

清单11.1 使用nlpia2_wikipedia下载Wikipedia文章

>>> !pip install nlpia2_wikipedia      #1
>>> import wikipedia as wiki
>>> page = wiki.page('Timnit Gebru')
>>> text = page.content
#1 在Jupyter或iPython控制台中,可以使用感叹号来运行shell命令,如pip。

在本章中,您将对这篇文章进行信息提取的测试。如果在你有机会复现这些示例之前文章内容发生了更改,您可以从GitLab上的nlpia2项目中检索该文本的缓存版本,文件路径是src/nlpia2/data/wikigebru.txt

你听说过Timnit Gebru吗?她在AI和NLP领域非常有名,写过几篇有影响力的论文,甚至在ChatGPT开始传播误信息之前,就已经预测并警告过大型语言模型的危险:

>>> i1 = text.index('Stochastic')
>>> text[i1:i1+51]
'Stochastic Parrots: Can Language Models Be Too Big?'

这个标题很有意思。显然,这是她的上司们感兴趣并希望出版的研究论文。但你不想浏览整个Wikipedia页面,去寻找关于“随机鹦鹉”和AI伦理专家Timnit Gebru的有趣信息。信息提取管道可以自动识别出有趣的命名实体,帮助你提取相关事实。如果你使用命名实体解析模型提取关于实体(如人、地点、物品,甚至日期)的事实,你可以构建一个更合理、更具伦理性的语言模型。你甚至可以通过使用NLP管道识别X平台(推文)中隐藏在代词背后的Timnit Gebru的提及,来支持她在伦理AI方面的使命。

11.4 实体识别

提取某个事物的知识的第一步是找到指向你想了解的事物的字符串。在自然语言文本中,最重要的事物是人名、地名和物品名。在语言学中,命名的事物称为命名实体。这些不仅仅是名字,它们也可能是日期、地点,或者任何可以放入你的知识图谱中的信息。与句子类似,命名实体识别(NER)任务通常有两种方法:模式匹配方法和神经方法。

你会发现,在某些情况下,正则表达式比神经网络更为精确,甚至可以与神经网络一样精确。以下是一些值得花费精力编写“手工”正则表达式的定量信息:

  • GPS位置
  • 日期
  • 价格
  • 数字

接下来,我们将快速绕道,学习如何提取这些数值数据。

11.4.1 基于模式的实体识别:提取GPS位置

GPS位置是你希望使用正则表达式从文本中提取的典型数值数据。GPS位置通常以纬度和经度的数值对形式出现,有时还包括一个表示海拔高度的第三个数字(但我们现在忽略这个)。我们只提取以度数表示的十进制纬度–经度对。这适用于许多Google Maps的URL。虽然URL在技术上并不是自然语言,但它们通常是非结构化文本数据的一部分,你希望提取这些信息,以便你的聊天机器人可以了解地点和物品。

在清单11.2中,我们将使用之前示例中的十进制数字模式,但更加严格,确保纬度(+/- 90°)和经度(+/- 180°)的值在有效范围内。你不能向北超过北极(+90°),也不能向南超过南极(-90°)。如果你从英国格林威治向东航行180°(+180°经度),你将到达国际日期变更线,在那里你也是从格林威治向西180°(-180°)的位置。

清单11.2 GPS坐标的正则表达式

>>> import re
>>> lat = r'([-]?[0-9]?[0-9][.][0-9]{2,10})'
>>> lon = r'([-]?1?[0-9]?[0-9][.][0-9]{2,10})'
>>> sep = r'[,/ ]{1,3}'
>>> re_gps = re.compile(lat + sep + lon)

>>> re_gps.findall('http://...maps/@34.0551066,-118.2496763...')
[(34.0551066, -118.2496763)]

>>> re_gps.findall("https://www.openstreetmap.org/#map=10/5.9666/116.0566")
[('5.9666', '116.0566')]

>>> re_gps.findall("Zig Zag Cafe is at 45.344, -121.9431 on my GPS.")
[('45.3440', '-121.9431')]

数值数据非常容易提取,尤其是当这些数字作为机器可读字符串的一部分时。URL和其他机器可读字符串通常将数字(如纬度和经度)按照可预测的顺序、格式和单位组织起来,这使得我们的提取变得非常简单。

然而,如果我们想要提取人的名字、国籍、地点以及其他没有标准格式的信息,事情就变得更加复杂。我们当然可以考虑所有可能的名字、地点和组织,但保持这些集合的更新会是一项非常繁琐的任务。为此,我们将需要使用神经网络方法。

11.4.2 使用spaCy进行命名实体识别

由于命名实体识别(NER)是一个基础任务,你可以想象,在神经网络出现之前,研究人员已经尝试过高效地进行命名实体识别。然而,神经网络极大地提升了命名实体识别在文本中的执行速度和准确性。需要注意的是,识别和分类命名实体并不像你想象的那样简单。命名实体识别最常见的挑战之一是分词,或者说是定义命名实体的边界(例如,识别“New York”是一个命名实体,还是两个独立的命名实体)。另一个更棘手的问题是实体类型的分类。例如,“Washington”这个名字可以用来指代一个人(例如,作家Washington Irving)、一个地点(例如,华盛顿DC)、一个组织(例如,华盛顿邮报),甚至一个体育队(例如,华盛顿指挥官队)。

因此,你可以看到,实体的上下文——无论是出现在它之前还是之后的单词——都会对识别结果产生影响。这就是为什么使用神经网络进行命名实体识别的流行方法包括多层卷积神经网络(CNN)、双向变换器(如BERT)或双向LSTM。最后一种方法,结合了条件随机场(CRF)技术,正是spaCy在其命名实体识别模块中所使用的方法。

当然,你并不需要了解如何构建神经网络就能从文本中提取命名实体。spaCy在处理文本时,会创建一个文档对象(doc),其ents属性包含所有识别出的命名实体:

>>> doc = nlp(text)    #1
>>> doc.ents[:6]               #2
(Timnit Gebru, Amharic, 13, May 1983, Ethiopian, Black in AI)
#1 运行spaCy对Timnit Gebru的维基百科文章进行处理,该文章也可以在GitLab的nlpia2/src/nlpia2/data/wikigebru.txt中找到
#2 获取维基百科文章中的前六个命名实体

命名实体识别的挑战与一个更基础的问题密切相关:词性标注(POS)。要在句子中识别命名实体,你需要知道每个单词的词性。如前所述,在英语语法中,表示人、地点或物品的词性是名词(Noun),而命名实体通常是专有名词——指代特定人、地点或物品的名词。英语中的动词(Verb)表示关系,动词将作为边缘将命名实体连接在一起,形成知识图谱。

词性标注对我们管道中的下一个阶段——依存句法分析也至关重要。为了确定句子中不同实体之间的关系,你需要识别句中的动词。

幸运的是,spaCy在你将文本传入后就已经为你完成了这一工作:

>>> first_sentence = list(doc.sents)[0]
>>> ' '.join(['{}_{}'.format(tok, tok.pos_) for tok in first_sentence])
'Timnit_PROPN Gebru_PROPN (_PUNCT Amharic_PROPN :_PUNCT ትምኒት_NOUN ገብሩ_ADV
↪ ;_PUNCT Tigrinya_PROPN :_PUNCT  _SPACE ትምኒት_NOUN ገብሩ_PROPN )_PUNCT
↪ born_VERB 13_NUM May_PROPN 1983_NUM is_AUX an_DET Eritrean_ADJ↪
↪ Ethiopian_PROPN -_PUNCT born_VERB computer_NOUN scientist_NOUN↪
↪ who_PRON works_VERB on_ADP algorithmic_ADJ bias_NOUN and_CCONJ↪
↪ data_NOUN mining_NOUN ._PUNCT'

你能理解这个输出吗?PUNCTNOUNVERB 都很容易理解,你大概可以猜到 PROPN 代表的是专有名词。那么 CCONJ 是什么呢?幸运的是,spaCy可以为你解释:

>>> spacy.explain('CCONJ')
'coordinating conjunction'

spaCy还提供了每个词元的 tag_ 属性。pos_ 标签给你提供了词元的词性,而 tag_ 则给你提供了更多关于该词元的信息和细节。让我们看一个例子:

>>> ' '.join(['{}_{}'.format(tok, tok.tag_) for tok in first_sentence])
'Timnit_NNP Gebru_NNP (_-LRB- Amharic_NNP :_: ትምኒት_NN ገብሩ_RB ;_:
↪ Tigrinya_NNP :_:  __SP ትምኒት_NN ገብሩ_NNP )_-RRB- born_VBN 13_CDMay_NNP 1983_CD is_VBZ an_DT Eritrean_JJ Ethiopian_NNP -_HYPH↪
↪ born_VBN computer_NN scientist_NN who_WP works_VBZ on_IN↪
↪ algorithmic_JJ bias_NN and_CC data_NNS mining_NN ._.'

哇,这看起来要复杂得多。你大致可以理解 PROPNNNP 之间的联系,但 VBZ 是什么呢?

>>> spacy.explain('VBZ')
'verb, 3rd person singular present'

这些信息确实更多,尽管它们以一种更晦涩的形式呈现。

让我们将所有关于词元的信息汇总到一个表格中:

>>> import pandas as pd
>>> def token_dict(token):
...    return dict(TOK=token.text,
...        POS=token.pos_, TAG=token.tag_,
...        ENT_TYPE=token.ent_type_, DEP=token.dep_,
...        children=[c for c in token.children])
>>> token_dict(doc[0])
{'TOK': 'Gebru', 'POS': 'PROPN', 'TAG': 'NNP',
 'ENT_TYPE': 'PERSON', 'DEP': 'nsubjpass', 'children': []}

现在,你有了一个可以用于提取你感兴趣的标签的函数,适用于任何句子或文本(文档)。如果你将一个字典列表强制转换为DataFrame,你就可以并排看到词元和标签的序列:

>>> def doc2df(doc):
...    return pd.DataFrame([token_dict(tok) for tok in doc])
>>> pd.options.display.max_colwidth=20
>>> doc2df(doc)
            TOK    POS    TAG ENT_TYPE       DEP
0        Timnit  PROPN    NNP           compound
1         Gebru  PROPN    NNP              nsubj      #1
2             (  PUNCT  -LRBpunct
3       Amharic  PROPN    NNP              appos
         ...    ...    ...      ...       ...
3277     Timnit  PROPN    NNP      ORG  compound      #2
3278      Gebru  PROPN    NNP      ORG      pobj
3279         at    ADP     IN               prep
3280  Wikimedia  PROPN    NNP      FAC  compound      #3
3281    Commons  PROPN    NNP      FAC      pobj

#1 开始时,“Timnit Gebru”并未被识别为命名实体,而是被识别为一个专有名词。 #2 最终,“Timnit Gebru”被识别为命名实体,但被错误地分类为组织。 #3 “Wikimedia”被错误地分类为设施(例如建筑物、机场或高速公路)。

你已经熟悉了词元的POS和TAG标签。第四列ENT_TYPE为你提供了该词元属于哪种命名实体类型的信息。许多命名实体跨越多个词元,例如Timnit Gebru,它跨越了两个词元。你可以看到,spaCy的小型模型在这方面表现不佳;它在文本的开头没有将Timnit Gebru识别为命名实体。当spaCy最终在维基百科文章的后面识别出它时,却将其实体类型标记为组织。

一个更大的spaCy模型应该能够稍微提高你的准确度,尤其是对于那些在训练spaCy时使用的语料库中不常见的单词:

>>> nlp = spacy.load('en_core_web_lg')
>>> doc = nlp(text)
>>> doc2df(doc)
            TOK    POS    TAG ENT_TYPE       DEP
0        Timnit  PROPN    NNP   PERSON  compound
1         Gebru  PROPN    NNP   PERSON     nsubj
2             (  PUNCT  -LRBpunct
3       Amharic  PROPN    NNP     NORP     appos
4             :  PUNCT      :              punct
         ...    ...    ...      ...       ...
3278     Timnit  PROPN    NNP   PERSON  compound
3279      Gebru  PROPN    NNP   PERSON      pobj
3280         at    ADP     IN               prep
3281  Wikimedia  PROPN    NNP      ORG  compound
3282    Commons  PROPN    NNP      ORG      pobj

看起来好多了!Timnit Gebru现在被正确分类为“PERSON”(人名),Wikimedia也被正确标记为“ORG”(组织)。所以,这通常会是你的知识提取管道中的第一个算法:spaCy语言模型,它会对你的文本进行分词,并为每个词元标注你需要的语言学特征,以便进行知识提取。

一旦你理解了命名实体识别的工作原理,你可以扩展你想要识别的名词和名词短语的种类,并将它们纳入你的知识图谱中。这可以帮助你泛化你的知识图谱,创建一个更具智能性的NLP管道。

但你还没有使用DataFrame中的最后一列 DEP(依存关系)。DEP标签表示词元在依存树中的角色。在继续进行依存句法分析和关系提取之前,你需要了解如何处理知识提取管道中的第2步:共指消解。

11.5 共指消解

假设你正在对一段文本进行命名实体识别(NER),并获得了模型识别出的实体列表。仔细检查后,你会发现超过一半的实体是重复的,因为它们指的是相同的事物!这时,共指消解就派上用场了,它能够识别句子中所有的名词提及。这将有助于在你的知识图谱中合并相同事物的提及,而不是创建冗余的节点和边,从而可能导致错误的关系。你能在下面的示例句子中看到“Timnit Gebru”的共指吗?

示例 11.3 来自“Timnit Gebru”维基百科文章的摘录
>>> i0 = text.index('In a six')
>>> text_gebru = text[i0:i0+308]    #1
>>> text_gebru
"In a six-page mail sent to an internal collaboration list, Gebru \
describes how she was summoned to a meeting at short notice where \
she was asked to withdraw the paper and she requested to know the \
names and reasons of everyone who made that decision, along with \
advice for how to revise it to Google's liking."

#1 这段文本是任意选取的,但你将需要使用这段文字来复现结果。

现在,你从“Timnit Gebru”维基百科文章中手动提取了一段任意的文本,这篇文章是在示例11.1中下载的。如果你的文本与此不符,可能是因为“Timnit Gebru”的维基百科文章已被大幅编辑。在这种情况下,你可以在GitLab上的nlpia2仓库中的src/nlpia2/data/wikigebru.txt找到本章所用的原始文章文本。

作为人类,读到这段文本时,你能理解“Gebru”、“she”和“her”都指的是同一个人。但这段常识推理和共指消解对机器来说要困难得多。如果“she”出现在“Gebru”之前(这种语言模式称为前指),自动共指消解对于机器来说尤其困难。然而,基于变换器的Coreferee语言模型能够处理这种具有挑战性的共指消解。

这段关于Dr. Gebru的长句对人类来说是一个相对简单的阅读理解挑战。但想想看这样一个更短且更难的句子:“市议员拒绝给示威者发放许可证,因为他们担心暴力。”在这个句子中,“他们”指的是谁?我们的常识告诉我们它指的是“市议员”,对我们来说似乎很容易,但对于深度学习模型来说,识别这种提及的任务竟然是出乎意料的困难。这类NLU挑战首次由Hector Levesque提出,被称为Winograd模式挑战(WSC)。这些以及其他常识推理或常识推断问题,将是评估任何声称具备智能的NLP管道的关键。

现在你了解了这个难题的本质,你已经准备好迎接这个艰难的挑战。深层问题需要深度学习!但不要被关于LLM的宣传所迷惑。你无法仅通过生成模型,如ChatGPT,来实现常识推理。与其使用大型语言模型,你将能够通过使用更小、更高效的基于变换器的语言模型(如RoBERTa),结合spaCy包中的传统语言学算法和逻辑,来取得更好的结果。

11.5.1 使用spaCy进行共指消解

spacy-experimental 包含了共指消解算法,位于 CoreferenceResolver 类中,但它尚未集成到spaCy的核心包中。如果你已经安装了 spacy-experimental,可以通过 spacy.load('en_coreference_web_trf') 来加载共指消解管道。或者,你也可以使用一个名为 Coreferee 的自定义spaCy管道扩展来进行共指消解。spaCy的开发者最近接管了Coreferee插件的维护工作,因此随着spaCy的演化和发展,Coreferee应该能够保持更新。而且Coreferee包含了适用于英语、法语、波兰语和德语的最先进的预训练语言模型。如果你能找到你的语言的标注训练集,还可以扩展Coreferee以支持其他语言。

Coreferee插件是一个基于变换器的共指消解模型,因此你需要下载一个基于变换器的spaCy语言模型。为了确保大型变换器语言模型与环境中的其他包兼容,你需要在一个单独的Python虚拟环境中安装Coreferee。Coreferee包要求使用spaCy 3.0.0到3.5.4版本。而本书其他章节使用的 nlpia2 包要求spaCy 3.7.5版本。这意味着如果你在安装 nlpia2 之后安装 coreferee,pip很可能会降级spaCy版本到早期版本。你可能可以在安装Coreferee并下载变换器语言模型后,安装一个较新的spaCy次版本,但这样做并不推荐。如果不注意版本不兼容的警告信息,你可能会发现生产管道以难以察觉的方式出现问题。

对于生产环境,建议创建一个全新的Python虚拟环境,并在其中安装Coreferee。你可能还需要在该虚拟环境中安装IPython和Jupyter,以确保你在REPL控制台和Jupyter Notebook中使用的是激活的虚拟环境进行测试:

$ pip install virtualenv
$ python -m virtualenv .venv
$ source .venv/bin/activate

现在,你可以安装并测试Coreferee了。你可能还希望在同一虚拟环境中安装IPython和Jupyter,以确保不会意外从其他未激活的虚拟环境中启动REPL或Jupyter Notebook。不要安装spaCy;否则,你可能会得到与Coreferee使用的预训练变换器管道不兼容的版本:

$ pip install coreferee ipython jupyter                 #1
$ python -c 'import spacy; print(spacy.__version__)'
3.5.4

#1 不要安装spaCy;否则,你将得到与NumPy和PyTorch不兼容的版本。

注意,spaCy的版本并不是最新的。Coreferee包会安装用于构建Coreferee语言模型二进制包的spaCy精确版本。

为了实现最先进的准确度,Coreferee构建于业界最强大、最准确的命名实体识别(NER)语言模型之上。对于英语,这意味着你需要下载spaCy的'en_core_web_lg'语言模型和基于RoBERTa的变换器语言模型,名为'en_core_web_trf'。像其他spaCy语言模型一样,你必须在Coreferee加载并运行之前先下载en_core_web_trftrf后缀表示这个语言模型是spaCy工具箱中的最新添加,结合了变换器神经网络到管道中。这个语言模型非常大,所以你可能不希望运行cli.download()函数超过必要的次数:

$ python -m spacy download en_core_web_lg        #1
$ python -m spacy download en_core_web_trf     #2
$ python -m coreferee install en

#1 en_core_web_lg语言模型约600 MB,因此下载可能需要一些时间。

#2 en_core_web_trf语言模型约500 MB。

当你下载英语变换器模型en_core_web_trf时,你会注意到像NumPy和PyTorch等其他包也被安装了。这些包必须与用于构建Coreferee共指消解模型的版本完全一致。这就是为什么在安装这些包之前,你要创建一个单独的环境。

现在,既然你已经下载并安装了相对较大的语言模型,并且它们包含在Coreferee包中,你就可以开始进行最先进的共指消解了。

Listing 11.4 来自维基百科文本的共指链

>>> import spacy, coreferee                   #1
>>> nlptrf = spacy.load('en_core_web_trf')
>>> nlptrf.add_pipe('coreferee')
<coreferee.manager.CorefereeBroker at 0x...>
>>> doc_gebru = nlptrf(text_gebru)            #2
>>> doc_gebru._.coref_chains
[0: [13], [16], [26], [34],
 1: [51], [56]]
>>> doc_gebru._.coref_chains.print()
0: Gebru(13), she(16), she(26), she(34)    #3
1: advice(51), it(56)

#1 你必须在加载en_core_web_trf语言模型之前导入Coreferee。

#2 括号中的整数是标记的索引或位置整数。

#3 第一个链是“Gebru”,在位置16、26和34处被共指为“she”。

你基于变换器的Coreferee管道能够找到两个共指链,将实体的提及在两个独立的链中关联起来。在Python中,链通常指的是一个列表的列表。Coreferee中使用链来存储实体,因为每个引用可能通过一个标记列表(n-gram)来表示。这个句子中的两个链代表了两个不同的现实世界对象:Gebru和advice。参考Listing 11.3,看看包含这两条共指链的文本。位置13的Gebru标记与三个“she”代词相连接,分别位于位置16、26和34。而advice标记与位置56的“it”相连接。

为了更容易地推理文本中的这些实体,你可能需要一些辅助函数来可视化共指链。最开始,你可以通过在每个句子上方显示标记的索引号作为标题,来简化这个过程:

>>> def stringify_coreferences(doc):
...     i, headers, sents = 0, [], []
...     for sent in doc.sents:
...         headers.append('')
...         sents.append('')
...         for t in sent:
...             tok = t.text + t.whitespace_
...             idx = str(i)
...             if len(idx) >= len(tok):
...                 idx = ' ' * len(tok)
...             else:
...                 idx += ' ' * (len(tok) - len(idx))
...             headers[-1] += idx
...             sents[-1] += tok
...             i += 1
...     return headers, sents

这个函数会将你的spaCy.doc分割成换行字符串,同时遵守句子边界并保留可迭代的spaCy.doc结构。一旦你获得了标题和句子字符串的配对,你将需要将标题字符串与其匹配的标记文本表示配对,并显示标记索引号在每个标记上方。这样做将有助于你追踪共指链之间的连接关系。为了使这些标记序列更美观,你可能需要将它们根据终端控制台宽度进行换行:

>>> def wrap_header_strings(headers, sents, width=70):
...     lines = []
...     for h, s in zip(headers, sents):
...         i = 0
...         while i < len(s):
...             i += width
...             lines.append(h[i-width:i])
...             lines.append(s[i-width:i])
...             lines.append('')
...     return lines

这个函数将交替配对标题和句子字符串,同时将其换行到默认的70个字符宽度。现在,你已经准备好显示你的共指信息以及它们的标记索引号,帮助你验证Coreferee的共指链:

>>> headers, sents = stringify_coreferences(doc_gebru)
>>> lines = wrap_header_strings(headers, sents)
>>> print('\n'.join(lines))
0  1 2   4    5    6    7  8  9        10            11    13    14
In a six-page mail sent to an internal collaboration list, Gebru descr

     15  16  17  18       19   21      22 23    24     25    26  27  2
ibes how she was summoned to a meeting at short notice where she was a

8    29 30       31  32    33  34  35        36 37   38  39    40  41
sked to withdraw the paper and she requested to know the names and rea

     42 43       44  45   46   47        49    50   51     52  53  54
sons of everyone who made that decision, along with advice for how to

55     56 57 58    59 60
revise it to Google's liking

>>> doc_gebru._.coref_chains.print()
0: Gebru(13), she(16), she(26), she(34)
1: advice(51), it(56)

在Listing 11.4中,你发现标记13、16、26和34都指代了相同的实体:Gebru。再次检查这些标记索引号,以确保它们与你对句子的理解相匹配。现在,你已经将这篇维基百科文章中的Gebru提及的所有实例都整合在这一句话中了,并且可以使用这些共指信息来提取有关她的重要关系和事实。

你可以在段落、章节甚至整篇关于Dr. Gebru的文章中做同样的事情。你可以确定,在你的文档中,每一条长共指链都会代表一个该文章中重要的实体。在关于她的维基百科文章中,Timnit Gebru很可能会有一条非常长的共指链。找到这些关于她的所有提及将是你信息提取管道中的第一步,帮助你自动识别关于Timnit Gebru的事实,这些事实将通过与她相关的每一个共指链来呈现。

11.5.2 实体名称标准化

与共指解析密切相关的一个话题是实体的标准化。实体的标准化表示通常是一个字符串,甚至包括日期等数值信息。例如,Timnit Gebru的出生日期的标准化ISO格式为1983-05-13。实体的标准化表示使得你的知识库能够将世界上发生的所有不同事件与该事件发生的日期连接到图中的同一个节点(实体)。

你可以对其他命名实体做同样的处理。你需要纠正拼写错误,并尽量解决对象、动物、人物、地点等名称的歧义。例如,旧金山可能在不同的地方被称为San Fran、SF、’Frisco或Fog City。命名实体的标准化确保了拼写和命名的变体不会污染你的实体名称词汇,避免产生混淆或冗余的名称。

知识图谱应该以相同的方式对每种类型的实体进行标准化,防止多个不同的实体类型共享相同的“名称”。你不希望数据库中有多个指向同一个实体的姓名条目。更重要的是,标准化应该一致地应用——无论是在将新事实写入知识库时,还是在读取或查询知识库时。

如果你决定在数据库填充后更改标准化方法,知识库中现有实体的数据应该进行迁移或修改,以遵循新的标准化方案。无模式数据库(键值存储),如用于存储知识图谱或知识库的数据库,仍然需要进行迁移操作,这一点并不比关系型数据库轻松。毕竟,无模式数据库在底层也是关系型数据库的接口包装。

11.6 依存句法分析

在上一节中,你学习了如何识别和标注文本中的命名实体。现在,你将学习如何找到这些实体之间的关系。一个典型的句子可能包含多种类型的命名实体,如地理实体、组织、人物、政治实体、时间(包括日期)、人造物、事件和自然现象。而且一个句子通常还包含多个关系——关于句中命名实体之间关系的事实。

NLP研究者已经识别出了两个独立的问题或模型,可以用来识别句子中的单词如何相互作用以构建意义:依存句法分析和成分句法分析。依存句法分析将赋予你的NLP管道像你在小学时学过的那样绘制句子的能力。这些树形数据结构为你的模型提供了句子逻辑和语法的表示。这将帮助你的应用和机器人更聪明地理解句子,并根据句子采取行动。

成分句法分析是另一种技术,它关注的是识别句子中的成分子短语。虽然依存句法分析处理的是单词之间的关系,成分句法分析的目标是将一个句子解析为一系列成分。这些成分可以是名词短语(如“我的新电脑”)或动词短语(如“有内存问题”)。它的方法是自上而下的,尝试迭代地将成分分解成更小的单元及它们之间的关系。尽管成分句法分析能够捕捉句子更多的句法信息,但它的计算结果较慢,且更难解释。因此,我们现在将重点关注依存句法分析。

但等等,你可能会问,理解实体之间的关系和绘制句子结构图为什么如此重要。毕竟,你可能已经忘记了如何自己创建这些图表,而且可能在实际生活中从未使用过它们。但这只是因为你已经内化了这种世界模型。我们需要在机器人中创建这种理解,这样它们就能像你一样不加思考地做同样的事情,从简单的任务,比如语法检查,到复杂的虚拟助手工作。

基本上,依存句法分析将帮助你的NLP管道在第1章中提到的所有应用中……变得更好。你有没有注意到,当聊天机器人试图理解简单句子或进行有意义的对话时,它们经常会“翻车”?一旦你开始问它们关于它们“说的”单词的逻辑或推理时,它们就会结巴。聊天机器人开发者和对话设计师通过使用基于规则的聊天机器人来进行有实质内容的对话,比如治疗和教学。当用户尝试讨论一些尚未编程进机器人的内容时,像PaLM和GPT-3这样的开放式神经网络模型才会被使用。而语言模型的训练目标是引导对话回到机器人了解并具有规则的内容上。

依存句法分析,顾名思义,依赖于句子中单词之间的依存关系——即它们的语法关系、短语关系或其他自定义关系——来提取信息。在依存分析树中,我们指的是句子中单词对之间的语法关系,其中一个单词充当中心词,另一个单词充当依赖词。在句子中,只有一个单词不依赖于其他任何单词,这个单词称为根(ROOT)。根是依存树的起点,就像森林中的树木主根开始生长其树干和枝条一样。一个单词可以有37种依存关系,这些关系是从斯坦福通用依存系统(Stanford Universal Dependencies)中适配过来的。

spaCy包知道如何识别单词和短语之间的这些关系,甚至可以为你绘制依存关系图。让我们尝试对一个简单的句子进行依存句法分析:

>>> text = "Gebru was unethically fired from her Ethical AI team."
>>> doc = nlp(text)
>>> doc2df(doc)
           TOK    POS   TAG ENT_TYPE        DEP
0        Gebru  PROPN   NNP   PERSON  nsubjpass
1          was    AUX   VBD             auxpass
2  unethically    ADV    RB              advmod
3        fired   VERB   VBN                ROOT
4         from    ADP    IN                prep
5          her   PRON  PRP$                poss
6      Ethical  PROPN   NNP      ORG   compound
7           AI  PROPN   NNP      ORG   compound
8         team   NOUN    NN                pobj
9            .  PUNCT     .               punct

你可以看到,句子的ROOT是动词“fired”。这是因为在我们的句子中,动词“fired”恰好是主语-动词-宾语三元组中的主动词。而单词“Gebru”在依存关系(DEP)中充当被动名词主语(nsubjpass)角色。是否可以利用“fired”和“Gebru”之间的依存关系来构建知识图谱中的关系或事实?children属性提供了一个列表,列出了所有依赖于特定标记的单词。这些依存关系是将标记连接在一起,构建事实的关键。

如果你想在你的token_dict函数中显示每个标记的子节点(即依赖的单词),你需要包含children属性:

>>> def token_dict2(token):
...    d = token_dict(token)
...    d['children'] = list(token.children)    #1
...    return d
>>> token_dict2(doc[0])
OrderedDict([('TOK', 'Gebru'),
             ('POS', 'PROPN'),
             ('TAG', 'NNP'),
             ('ENT_TYPE', 'PERSON'),
             ('DEP', 'nsubjpass'),
             ('children', [])])
#1 `children`属性是一个生成器,因此你需要使用`list`类型来运行这个生成器。

你可能会觉得奇怪,标记“Gebru”在这个句子中没有任何子节点(依赖词)。毕竟,它是句子的主语。自然语言语法规则中的子父关系可能一开始会让你感到困惑,但你可以使用displacydoc2df函数来帮助你建立一种心理模型,理解单词之间是如何相互依赖的。

重新定义doc2df函数,加入children属性作为一列,这样你就可以看到句子中是否有其他单词有依赖词(子节点):

>>> def doc2df(doc):
...     df = pd.DataFrame([token_dict2(t) for t in doc])
...     return df.set_index('TOK')
>>> doc2df(doc)
               POS   TAG ENT_TYPE        DEP             children
TOK
Gebru        PROPN   NNP   PERSON  nsubjpass                   []
was            AUX   VBD             auxpass                   []
unethically    ADV    RB              advmod                   []
fired         VERB   VBN                ROOT  [Gebru, was, une...
from           ADP    IN                prep               [team]
her           PRON  PRP$                poss                   []
Ethical      PROPN   NNP      ORG   compound                   []
AI           PROPN   NNP      ORG   compound            [Ethical]
team          NOUN    NN                pobj            [her, AI]
.            PUNCT     .               punct                   []

看起来句子的根“fired”有最多的子节点。动词“fired”是句子中最重要的词,所有其他词都依赖于它。每个依存树中的单词都与句子中其他某个单词连接。要查看这一点,你需要检查句子根“fired”中的长子节点列表:

>>> doc2df(doc)['children']['fired']
[Gebru, was, unethically, from, .]

句子的根“fired”分支到单词“Gebru”和其他几个单词,包括“from”。而“from”又依赖于“team”,“team”依赖于“her”,“her”依赖于“AI”。“AI”依赖于“Ethical”。你可以看到,子节点修改它们的父节点。

依存树的ROOT是句子的主动词。这通常是你会发现子节点最多的标记。动词在知识图谱中转化为关系,而子节点则成为该关系中的对象。标记“Gebru”是被动动词“fired”的子节点,所以你知道她是被解雇的人,但这个句子并没有说明是谁负责解雇她。由于你不知道动词“fired”的主语,所以无法确定谁应该承担那个描述行为的“不道德”(unethically)副词。

现在是依存关系图大展身手的时候了!我们将使用spaCy的一个子库——displacy。它可以生成可缩放的矢量图形(SVG)字符串(或完整的HTML页面),并可以在浏览器中作为图像查看。这种可视化可以帮助你找到使用树结构创建关系抽取标签模式的方法。

示例 11.5 依存树的可视化

>>> from spacy.displacy import render
>>> sentence = "Gebru was unethically fired from her ethical AI team."
>>> parsed_sent = nlp(sentence)
>>> with open('gebru.xhtml', 'w') as f:
...     f.write(render(docs=parsed_sent, page=True, options=dict(compact=True)))

当你打开这个文件时,你应该会看到类似于图11.3的内容。

image.png

在句子的开头,单词“Gebru”在依存树中被识别为一个专有名词。一个箭头将动词“fired”连接到它的依赖词“Gebru”,其中nsubjpass关系表明“Gebru”是句子的名词主语。单词“was”被标记为“fired”的auxpass依赖词,而“unethically”则作为副词依赖词与“fired”相连。一个句子依存关系图应该以句子的主谓动词开始,并在依存树中创建依存关系箭头,将句子中的所有单词作为树的叶子。接下来,在我们解释依存句法分析和关系抽取之间的联系之前,先简单了解一下另一个我们可以使用的工具:句法成分分析。

11.6.1 使用benepar进行句法成分分析

Berkeley Neural Parser 和 Stanza 一直是提取文本中成分关系的首选工具。让我们来探索其中的一个:Berkeley Neural Parser。这个解析器无法独立使用,它需要与spaCy或NLTK结合加载,并使用它们现有的模型。你可以使用spaCy作为分词器和依存树解析器,因为它在不断改进。以下代码展示了如何下载该解析器:

示例 11.6 下载必要的包

>>> import benepar
>>> benepar.download('benepar_en3')

下载完包后,我们可以使用一个示例句子来测试,但首先我们需要将benepar添加到spaCy的处理管道中:

>>> import spacy
>>> nlp = spacy.load("en_core_web_md")
>>> if spacy.__version__.startswith('2'):
...     nlp.add_pipe(benepar.BeneparComponent("benepar_en3"))
... else:
...     nlp.add_pipe("benepar", config={"model": "benepar_en3"})
>>> doc = nlp("She and five others coauthored a research paper, 'On the Dangers of Stochastic Parrots: Can Language Models Be Too Big?'")
>>> sent = list(doc.sents)[0]
>>> print(sent._.parse_string)
(S (NP (NP (PRP She)) (CC and) (NP (CD five) (NNS others))) (VP (VBD coauthored) (NP (NP (DT a) (NN research) (NN paper)) (, ,) (`` ') (PP (IN On) (NP (NP (DT the) (NNS Dangers)) (PP (IN of) (NP (NNP Stochastic) (NNPS Parrots))))) (: :) (MD Can) (NP (NN Language) (NNS Models)) (VP (VB Be) (ADJP (RB Too) (JJ Big))))) (. ?) ('' '))

这看起来有点晦涩,对吧?在这个例子中,我们为测试句子生成了一个解析字符串。解析字符串包括各种短语以及句子中标记的词性标签(POS标签)。你可能会注意到,常见的标签包括NP(名词短语)、VP(动词短语)、S(句子)和PP(介词短语)。现在,你可以看到从成分分析器的输出中提取信息会更加困难。然而,它对于识别句子中的所有短语并将其用于句子简化或总结是非常有用的。

现在你已经知道如何提取句子的句法结构了。那么,这将如何帮助你在智能聊天机器人的开发中呢?

11.7 从依存句法分析到关系抽取

我们已经来到了一个关键阶段——帮助我们的机器人从它所阅读的内容中学习。让我们从维基百科取一个句子作为例子:“In 1983, Stanislav Petrov, a lieutenant colonel of the Soviet Air Defense Forces, saved the world from nuclear war.” 如果你在历史课上阅读或听到这样的内容后做笔记,你可能会将其重新表述并在脑中建立起概念或单词之间的联系。你可能会将其简化为一个知识点——你从中“得到”的东西。你希望你的机器人做同样的事情。你希望它“记录”下它所学到的内容,例如Stanislav Petrov是一个中校这一事实。这可以以如下形式存储在数据结构中:

('Stanislav Petrov', 'is-a', 'lieutenant colonel')

这是两个命名实体节点('Stanislav Petrov' 和 'lieutenant colonel')以及它们之间的关系或连接('is-a')的一个例子,这种形式通常在知识图谱或知识库中表示。当这种关系以符合RDF标准(资源描述框架)的形式存储时,它被称为RDF三元组。历史上,这些RDF三元组通常存储在XML文件中,但它们也可以存储在任何能够以(主题、关系、对象)形式承载三元组图的文件格式或数据库中。这些三元组的集合将构成你的知识图谱!

接下来,我们将使用我们已经掌握的两种方法——模式和机器学习——来为你的知识图谱创建一些素材。

11.7.1 基于模式的关系抽取

还记得你如何使用正则表达式来提取字符模式吗?词汇模式就像正则表达式,但它是针对单词而不是字符的。你不再使用字符类,而是使用词类。例如,你可能有一个词汇模式来匹配所有单数名词(NN POS标签),而不是匹配小写字符。某些种子句子已经标注了从中提取的正确关系(事实)。可以使用POS模式来查找相似的句子,其中主语、宾语,甚至是关系词可能会发生变化。

从文本中提取关系的最简单方法是使用ROOT词的nsubj和dobj标签,查找所有的主语-动词-宾语三元组。但我们可以做一些更复杂的事情。如果你想从维基百科中提取历史人物之间的会议相关信息怎么办?你可以使用spaCy包通过两种不同的方式来匹配这些模式,无论你想匹配多少模式,都能在O(1)(常数时间)内完成:

  • PhraseMatcher — 用于任何单词/标签序列模式。
  • Matcher — 用于POS标签序列模式。

我们从后者开始。

首先,我们来看一个示例句子,并查看每个单词的POS标签,如下列表所示。

Listing 11.7 spaCy标签字符串的辅助函数
>>> doc_dataframe(nlp("In 1541 Desoto met the Pascagoula."))
         ORTH       LEMMA    POS  TAG    DEP
0          In          in    ADP   IN   prep
1        1541        1541    NUM   CD   pobj
2      Desoto      desoto  PROPN  NNP  nsubj
3         met        meet   VERB  VBD   ROOT
4         the         the    DET   DT    det
5  Pascagoula  pascagoula  PROPN  NNP   dobj
6           .           .  PUNCT    .  punct

现在,你可以看到POS或TAG特征的序列,这将构成一个良好的模式。如果你在寻找人物与组织之间的“has-met”关系,你可能希望允许这样的模式:PROPN met PROPNPROPN met the PROPNPROPN met with the PROPN,以及PROPN often meets with PROPN。你可以分别指定这些模式,或者尝试通过在适当的名词之间使用一些*?运算符来捕捉它们:

'PROPN ANYWORD? met ANYWORD? ANYWORD? PROPN'

spaCy中的模式类似于这个伪代码,但它们更强大、更灵活。spaCy的模式非常类似于用于标记的正则表达式(请参见Listing 11.8中的示例)。像正则表达式一样,你必须非常详细地说明你希望在标记序列的每个位置上匹配哪些单词特征。在spaCy模式中,你使用一个字典列表来捕捉每个单词或标记的所有词性和其他特征。

Listing 11.8 示例spaCy POS模式
>>> pattern = [
...     {'POS': {'IN': ['NOUN', 'PROPN']}, 'OP': '+'},
...     {'IS_ALPHA': True, 'OP': '*'},
...     {'LEMMA': 'meet'},
...     {'IS_ALPHA': True, 'OP': '*'},
...     {'POS': {'IN': ['NOUN', 'PROPN']}, 'OP': '+'}]

然后,你可以从解析后的句子中提取所需的标记。

Listing 11.9 使用spaCy创建POS模式匹配器
>>> from spacy.matcher import Matcher
>>> doc = nlp("In 1541 Desoto met the Pascagoula.")
>>> matcher = Matcher(nlp.vocab)
>>> matcher.add(
...     key='met',
...     patterns=[pattern])
>>> matches = matcher(doc)
>>> matches
[(12280034159272152371, 2, 6)]    #1
>>> start = matches[0][1]
>>> stop = matches[0][2]
>>> doc[start:stop]           #2
Desoto met the Pascagoula
#1 这是一个包含3元组的列表:跨度 IΔ,起始标记索引和结束标记索引
#2 spaCy允许你在标记索引上对文档对象进行切片,就像你对Python列表一样

通过这种方式,你可以提取你需要的标记,并利用这些信息来识别关系或构建知识图谱。

spaCy匹配器会列出模式匹配的3元组,每个3元组包含匹配ID整数、匹配的开始和结束标记索引(位置)。因此,你从原始句子中提取了一个匹配并创建了模式,但维基百科中类似的句子怎么样呢?

Listing 11.10 使用POS模式匹配器
>>> doc = nlp("October 24: Lewis and Clark met their first Mandan Chief, Big White.")
>>> m = matcher(doc)[0]
>>> m
(12280034159272152371, 3, 11)

>>> doc[m[1]:m[2]]
Lewis and Clark met their first Mandan Chief

>>> doc = nlp("On 11 October 1986, Gorbachev and Reagan met at Höfði house")
>>> matcher(doc)
[]                #1
#1 该模式没有匹配到维基百科句子中的任何子字符串。

你需要添加第二个模式,允许动词出现在主语和宾语名词之后。

Listing 11.11 组合模式以处理更多变化
>>> doc = nlp(
...     "On 11 October 1986, Gorbachev and Reagan met at Hofoi house"
...     )
>>> pattern = [
...     {'POS': {'IN': ['NOUN', 'PROPN']}, 'OP': '+'},
...     {'LEMMA': 'and'},
...     {'POS': {'IN': ['NOUN', 'PROPN']}, 'OP': '+'},
...     {'IS_ALPHA': True, 'OP': '*'},
...     {'LEMMA': 'meet'}
...     ]
>>> matcher.add('met', None, pattern)     #1
>>> matches = matcher(doc)
>>> pd.DataFrame(matches, columns=)
[(1433..., 5, 9),
 (1433..., 5, 11),
 (1433..., 7, 11),
 (1433..., 5, 12)]                         #2

>>> doc[m[-1][1]:m[-1][2]]                 #3
Gorbachev and Reagan met at Hofoi house
#1 添加了一个新的模式,而不移除之前的模式
#2 '+' 运算符增加了重叠替代匹配的数量
#3 最长的匹配是匹配列表中的最后一个

现在,你已经有了实体和关系。你甚至可以构建一个模式,减少对中间动词(如“met”)的限制,并对两边的人物和团体名称施加更多限制。这样做可能会让你识别出更多的动词,这些动词意味着某人或某团体已经与另一个会面,比如动词“knows”或甚至是被动语句,如“had a conversation”或“became acquainted with”。然后,你可以使用这些新动词来添加两边新专有名词之间的关系。

但你可以看到,你正逐渐偏离了原始种子关系模式的原意。这种现象被称为语义漂移。为了确保在新句子中发现的新关系与原始种子(示例)关系真正相似,你通常需要将主语、关系和宾语的词义约束为与种子句子中的词义相似。做到这一点的最佳方法是使用某种词汇的向量表示。幸运的是,spaCy不仅会标注解析文档中的单词的POS和依赖树信息,还会附带一个Word2Vec词向量。你可以利用这个词向量防止连接动词和两边的专有名词在语义上偏离原始种子模式太远。

使用词汇和短语的语义向量表示,使得自动信息抽取的准确度足以自动构建大型知识库。但是,需要人工监督和策划,以解决自然语言文本中的大量歧义。

11.7.2 神经关系抽取

现在你已经了解了基于模式的关系抽取方法,你可以想象,研究人员已经尝试使用神经网络做相同的事情。神经关系抽取任务通常分为两类:闭合关系抽取开放关系抽取

闭合关系抽取

在闭合关系抽取中,模型只从给定的关系类型列表中提取关系。这种方法的优点包括可以最小化获取不真实或奇怪的实体之间关系标签的风险,从而提高你对这些标签在现实中使用的信心。然而,这种方法的限制在于,它需要人工标注者为每种文本类别提供相关标签,这显然是一个繁琐且昂贵的过程。

开放关系抽取

在开放关系抽取中,模型会尝试为文本中的命名实体生成一组可能的标签。这种方法适用于处理大规模和一般未知的文本,如维基百科文章和新闻条目。

在过去几年中,深度神经网络的实验在三元组抽取上取得了显著的成果,因此如今大多数相关研究都集中在神经方法上。不幸的是,目前并没有像管道中的前几个阶段那样,关于关系抽取的现成解决方案。此外,你的关系抽取通常会非常有针对性。在大多数情况下,你不会想要抽取实体之间的所有可能关系,而只是那些与你正在执行的任务相关的关系。例如,你可能想要从一组制药文档中提取药物之间的相互作用。

目前用于提取关系的最先进模型之一是 基于知识嵌入的语言理解(LUKE) 。LUKE使用实体感知注意力,这意味着它的训练数据包括了每个标记是否为实体的信息。它还被训练能够“猜测”维基百科数据集中被掩盖的实体(而不像BERT模型那样只是猜测所有被掩盖的单词)。

spaCy也提供了一些基础设施,用于创建你自己的关系抽取组件,但这需要相当多的工作,本书中不予涉及。幸运的是,像Sofie Van Landeghem这样的作者已经创建了很好的资源供你学习,如果你希望为特定需求定制训练一个关系抽取器,完全可以参考这些资源。

训练你的关系抽取模型

在训练你的关系抽取器时,你需要标注好的数据,其中与你的任务相关的关系被正确标记,以便模型能够学习识别它们。但大规模数据集的创建和标注是困难的,因此值得检查一些现有的数据集,这些数据集用于基准测试和微调最先进的模型,看看是否已经有你需要的数据。

DocRED斯坦福大学的TACRED 是关系抽取方法的事实标准基准数据集和模型,因为它们的数据规模和知识图谱的通用性。斯坦福的 文本分析大会关系抽取数据集(TACRED) 包含超过100,000个示例自然语言段落,并与对应的关系和实体配对。它涵盖了41种关系类型。在过去的几年里,研究人员通过使用如 Re-TACREDDocRED 等数据集,改进了TACRED的数据质量,并减少了关系类别中的歧义。

文档关系抽取数据集(DocRED) 扩展了可以用于关系抽取的自然语言文本的广度,因为它包括需要解析多个句子的关系。用于训练DocRED的训练和验证数据集是文档级关系抽取中最大的人工标注数据集之一。DocRED中的大部分人工标注知识图谱数据包含在 Wikidata 知识库中,对应的自然语言文本示例可以在维基百科的归档版本中找到。

现在,你对如何将非结构化文本转化为一组事实有了更清晰的认识。接下来是我们管道的最后阶段:构建知识数据库。

11.8 构建你的知识库

你已经从文本中提取了关系。你可以将它们都放入一个大表格中,但我们仍然在讨论知识图谱。是什么使得这种特定的数据结构方式如此强大呢?

让我们回到前面提到的 Stanislav Petrov。如果我们想回答这样一个问题:“Stanislav Petrov 的军衔是什么?” 一个单一的关系三元组(Stanislav Petrov, is-a, lieutenant colonel)并不足以回答这个问题,因为你的问答系统还需要知道“lieutenant colonel”(中校)是一种军衔。然而,如果你将知识以图谱的形式组织起来,回答这个问题就变得可能了。查看图 11.4,理解它是如何实现的。

image.png

右上方较暗的边缘和节点将“lieutenant colonel”(中校)实体与“military rank”(军衔)连接起来,这可以通过图 11.4 中展示的其他事实推断出来。它也可以通过数据库中其他军事组织成员的关系推断出来,或者可以推断出,属于军事组织的人的职称通常是军衔。这个从知识图谱中推导新事实的逻辑操作叫做知识图谱推理(knowledge graph inference)。推理可以通过开发者为特定常识性逻辑关系硬编码的图查询来完成。图查询与关系数据库中的 SQL 查询相当,不同之处在于,图查询允许在多个关系上进行递归查询。

知识库问答(QA)的任务就是寻找方法,回答需要跨越图谱或关系数据库中多个关系的查询。例如,关于 Stanislav 中校军衔的推理或查询,你的知识图谱必须已经包含有关军事和军衔的事实。如果知识库中还包含有关人物职称以及人们如何与职业相关的事实,这将更有帮助。现在,你可能已经看到,如何通过一个知识库,机器能够理解某些陈述的意义,比没有这些知识时要强得多。如果没有这个知识库,许多像这样的简单陈述中的事实将会对你的聊天机器人来说完全是空白。

这看起来可能不太明显,但实际上这是一个大问题。如果你曾与一个聊天机器人互动,而它根本无法理解“天哪,哪边是上方”——字面意义上的——你会理解其中的困难。在 AI 研究中,最令人畏惧的挑战之一就是编制和高效查询常识性知识的知识图谱。我们在日常对话中理所当然地使用常识性知识。

人类在获得语言能力之前,就开始获取大部分常识性知识。我们小时候并不会写下“白天是如何开始的,夜晚通常会在日落后到来”这样的内容,也不会编辑 Wikipedia 文章去讲解“空腹应该只吃食物,而不是土壤或石块”。这使得机器很难找到可以阅读并学习的常识性知识语料库。并且,并没有专门的常识性知识的 Wikipedia 文章可供你的机器人进行信息提取。部分常识性知识甚至是本能的,硬编码在我们的 DNA 中。

各种各样的事实关系存在于事物和人之间,比如kind-of(种类)、is-used-for(用于)、has-a(拥有)、is-famous-for(以...闻名)、was-born(出生于)和has-profession(拥有职业)。卡内基梅隆大学的“永无止境语言学习”机器人(NELL)几乎完全专注于提取有关“kind-of”关系的信息。

大多数知识库会对这些关系所定义的字符串进行标准化,因此,kind oftype of 会被分配一个标准化的字符串或 ID 来表示特定的关系。一些知识库还会标准化表示知识库中对象的名词,使用前文提到的共指解析(coreference resolution)。因此,双字组 Stanislav Petrov 可能会被分配一个特定的 ID。如果自然语言处理管道认为 S. PetrovLt Col Petrov 也指的是同一个人,它们也会被分配到这个 ID。

11.8.1 大型知识图谱

如果你听说过思维导图,你可能已经知道这种工具可以为你提供一个相当不错的知识图谱的心理模型:它展示了你大脑中概念之间的连接。为了给你一个更具体的知识图谱概念模型,你可以探索网络上最古老的公开知识图谱:由我们在上一节遇到的机器人创建的 NELL 图谱。

nlpia2 Python 包包含了几个工具,使得 NELL 知识图谱变得更容易理解。稍后在本章中,你将看到这些工具的具体细节,帮助你美化你正在处理的任何知识图谱:

>>> import pandas as pd
>>> pd.options.display.max_colwidth = 20
>>> from nlpia2.nell import read_nell_tsv, simplify_names
>>> df = read_nell_tsv(nrows=1000)
>>> df[df.columns[:4]].head()
                entity            relation                value iteration
0  concept:biotechc...     generalizations  concept:biotechc...      1103
1  concept:company:...  concept:companyceo  concept:ceo:lesl...      1115
2  concept:company:...     generalizations  concept:retailstore      1097
3  concept:company:...     generalizations      concept:company      1104
4  concept:biotechc...     generalizations  concept:biotechc...      1095

这些实体名称非常精确并且在一个层级结构内定义得很清晰,就像文件路径或 Python 中的命名空间变量名一样。所有的实体和数值名称都以 concept: 开头,因此你可以从名称字符串中去除这一部分,使数据更易于操作。为了进一步简化,你可以消除命名空间层级,仅关注层级中的最后一个名称:

>>> pd.options.display.max_colwidth = 40
>>> df['entity'].str.split(':').str[1:].str.join(':')
0        biotechcompany:aspect_medical_systems
1                       company:limited_brands
2                       company:limited_brands
3                       company:limited_brands
4                biotechcompany:calavo_growers
                        ...
>>> df['entity'].str.split(':').str[-1]
0        aspect_medical_systems
1                limited_brands
2                limited_brands
3                limited_brands
4                calavo_growers
                 ...

nlpia2.nell 模块将事物的名称进一步简化,使得在网络图中浏览知识图谱变得更加容易。否则,实体名称可能会占满图表的宽度,导致彼此拥挤:

>>> df = simplify_names(df)               #1
>>> df[df.columns[[0, 1, 2, 4]]].head()
                   entity relation           value   prob
0  aspect_medical_systems     is_a  biotechcompany  0.924
1          limited_brands      ceo   leslie_wexner  0.938
2          limited_brands     is_a     retailstore  0.990
3          limited_brands     is_a         company  1.000
4          calavo_growers     is_a  biotechcompany  0.983
#1 使用 str.replace() 方法缩短实体、关系和值的名称

NELL 从 Twitter 上抓取文本,因此事实的拼写和表述方式可能会有所不同。在 NELL 中,实体、关系和对象的名称已经通过小写化并去除所有标点符号(如撇号和连字符)进行了标准化。只有专有名词允许保留空格,以帮助区分包含空格的名称和被合并在一起的名称。然而,在 NELL 中,正如 Word2Vec 标记标识符一样,专有名词是通过下划线(_)字符连接的。

实体和关系名称就像 Python 中的变量名一样。你希望能够像查询数据库字段名一样查询它们,因此它们不应具有歧义的拼写。原始的 NELL 数据集包含每个三元组的一行。三元组可以像简洁且定义明确的句子一样被读取。知识三元组描述了一个实体在世界上关于某一特定事实的单独信息。

最基本的知识三元组由实体、关系和值组成。知识三元组的第一个元素给出了该事实所涉及的实体的名称。第二列关系包含与世界上某个其他特质(形容词)或对象(名词)的关系,称为其值。关系通常是以“is”或“has”开头或暗示这些词的动词短语。第三列值包含该关系某个特质的标识符。值是关系的对象,并且是一个命名实体,正如三元组的主语(实体)一样。

由于 NELL 众包了知识库的策划工作,你还可以使用概率值或置信度来推断冲突的信息片段。此外,NELL 还有九列关于该事实的信息。它列出了用于引用特定实体、关系或值的所有替代表达方式。NELL 还标识了创建该事实的迭代(循环通过 Twitter)。最后一列提供了数据来源——列出了所有创建该事实的文本。

NELL 包含关于 800 多种独特关系和超过 200 万个实体的事实。因为 Twitter 主要涉及人、地点和商业,所以它是一个很好的知识库,可以用来增强其他的常识知识库。它对于事实核查著名人物、企业或常成为虚假信息传播目标的地点也很有用。甚至还有一个经度-纬度关系,可以用来验证与地点相关的事实:

>>> islatlon = df['relation'] == 'latlon'
>>> df[islatlon].head()
               entity relation                 value
241          cheveron   latlon      40.4459,-79.9577
528        licancabur   latlon   -22.83333,-67.88333
1817             tacl   latlon     13.53333,37.48333
2967            okmok   latlon  53.448195,-168.15472
2975  redoubt_volcano   latlon   60.48528,-152.74306

现在,你已经了解了如何将事实组织成知识图谱。但当我们需要使用这些知识时——例如,回答问题——该怎么办呢?这就是本章最后一节要讨论的内容。

11.9 在知识图谱中寻找答案

现在,我们的事实已经组织成图数据库,如何检索这些知识呢?就像任何数据库一样,图数据库也有专门的查询语言来从中提取信息。就像 SQL 及其不同方言用于查询关系型数据库一样,存在一系列语言,比如 SPARQL(SPARQL 协议和 RDF 查询语言)、Cypher 和 AQL,用于查询图数据库。在本书中,我们将重点介绍 SPARQL,因为它已被开源社区作为标准采用。其他语言,如 Cypher 和 AQL,则用于查询特定的图知识库,如 Neo4j 和 ArangoDB。

作为我们的知识库,我们将使用比 NELL 更大的知识图谱:Wikidata,这是 Wikipedia 的知识数据库版本。它包含超过 1 亿个数据项(实体和关系),并由志愿编辑和机器人维护,和所有其他 Wikimedia 项目一样。

在 Wikidata 中,实体之间的关系称为属性。Wikidata 系统中有超过 11,000 个属性,每个属性都有自己的 P-id,一个用于在查询中表示该属性的唯一标识符。同样,每个实体都有自己唯一的 Q-id。你可以通过使用 Wikidata 的 REST API 来轻松获取任何 Wikipedia 文章的 Q-id:

>>> def get_wikidata_qid(wikiarticle, wikisite="enwiki"):
...     WIKIDATA_URL='https://www.wikidata.org/w/api.php'
...     resp = requests.get(WIKIDATA_URL, timeout=5, params={
...         'action': 'wbgetentities',
...         'titles': wikiarticle,
...         'sites': wikisite,
...         'props': '',
...         'format': 'json'
...     }).json()
...     return list(resp['entities'])[0]

>>> tg_qid = get_wikidata_qid('Timnit Gebru')
>>> tg_qid
'Q59753117'

你可以通过访问 Wikidata(www.wikidata.org/entity/Q597…)来确认你的发现,在那里你可以找到这个实体的更多属性,如实例类型和雇主。这些 RDF 属性将把你的相关 Wikidata 实体链接在一个不断扩展的知识网络中。如你所见,这只是一个简单的 GET 查询,仅在我们已经拥有实体名称并希望找到 Q-id(或反之)时有效。对于更复杂的查询,我们需要使用 SPARQL。现在,你可以编写你的第一个查询!

假设你想知道 Timnit Gebru 在她关于随机鹦鹉的著名论文中的合著者是谁。如果你不完全记得论文的名称,实际上可以通过一个简单的查询找到它。为此,你需要一些属性和实体的 ID——为简化起见,我们只在代码中列出它们:

>>> NOTABLE_WORK_PID = 'P800'        #1
>>> INSTANCE_OF_PID = 'P31'            #2
>>> SCH_ARTICLE_QID= 'Q13442814'        #3
>>> query = f"""
...     SELECT ?article WHERE {{
...         wd:{tg_qid} wdt:{NOTABLE_WORK_PID} ?article.
...         ?article wdt:{INSTANCE_OF_PID} wd:{SCH_ARTICLE_QID}
...
...         SERVICE wikibase:label {{ bd:serviceParam
...                            wikibase:language "en". }}
...         }}
... """
#1 “Notable work” 属性 IΔ
#2 “Instance of” 属性 IΔ
#3 “Scholarly article” 实体 IΔ

警告:

不要忘记在 f-string 中双重转义大括号!并且你不能在 f-string 中使用反斜杠作为转义字符。相反,必须双重大括号: 错误——f"{" 正确——f"{{"

如果你熟悉 jinja2 包,使用 Python 的 f-string 来填充 jinja2 模板时要小心;你需要四个大括号来创建一个字面意义上的双大括号。

虽然看起来有些难以理解,这个查询的意思是:找到实体 A,使得 Timnit Gebru 的著作中包含 A,且 A 是一篇学术文章。你可以看到每个关系条件是如何在 SPARQL 中编码的,操作数 wd: 之前是实体的 Q-id,操作数 wdt: 之前是属性的 P-id。每个关系约束的形式是 ENTITY has-property ENTITY。

现在,使用 Wikidata 的 SPARQL API 来检索查询结果。为此,你将使用一个专用的 SPARQLWrapper 包来简化查询过程。首先,设置你的 wrapper:

>>> from SPARQLWrapper import SPARQLWrapper, JSON
>>>
>>> endpoint_url = "https://query.wikidata.org/sparql"
>>> sparql = SPARQLWrapper(endpoint_url)
>>> sparql.setReturnFormat(JSON)             #1
#1 返回查询结果为 JSON 字符串

设置完毕后,你可以执行查询并检查响应:

>>> sparql.setQuery(query)
>>> result = sparql.queryAndConvert()
>>> result
{'head': {'vars': ['article', 'articleLabel']},
 'results': {'bindings': [{'article': {'type': 'uri',
     'value': 'http://www.wikidata.org/entity/Q105943036'},
    'articleLabel': {'xml:lang': 'en',
     'type': 'literal',
     'value': 'On the Dangers of Stochastic Parrots:
     Can Language Models Be Too Big?🦜'}}]}}

这看起来没错!现在你已经得到了文章的 Q-id,可以通过使用文章的作者属性来获取它的作者:

>>> import re
>>> uri = result['results']['bindings'][0]['article']['value']
>>> match_id = re.search(r'entity/(Q\d+)', uri)
>>> article_qid = match_id.group(1)
>>> AUTHOR_PID = 'P50'
>>>
>>> query = f"""
...      SELECT ?author ?authorLabel WHERE {{
...      wd:{article_qid} wdt:{AUTHOR_PID} ?author.
...      SERVICE wikibase:label {{ bd:serviceParam wikibase:language "en". }}
...      }}
...      """
>>> sparql.setQuery(query)
>>> result = sparql.queryAndConvert()['results']['bindings']
>>> authors = [record['authorLabel']['value'] for record in result]
>>> authors
['Timnit Gebru', 'Margaret Mitchell', 'Emily M. Bender']

现在你得到了问题的答案!

你也可以通过将查询嵌套在一起,避免做两个查询,从而得到相同的结果,像这样:

>>> query = """
... SELECT ?author ?authorLabel WHERE {
...     {
...     SELECT ?article WHERE {
...         wd:Q59753117 wdt:P800 ?article.
...         ?article wdt:P31 wd:Q13442814.
...         }
...     }
...     ?article wdt:P50 ?author.
...     SERVICE wikibase:label {
...         bd:serviceParam wikibase:language "en".
...         }
... }
... """

SPARQL 是一种功能强大的查询语言,它的功能远不止于简单的查询。Wikidata 本身有一个非常好的 SPARQL 手册。22 你越深入使用 SPARQL 探索 Wikidata,就会发现它在 NLP 应用中的更多用途。它是你可以自动评估 NLP 管道向用户断言的事实的质量和正确性的少数方法之一。

11.9.1 从问题到查询

你已经成功地在知识数据库中找到了一个相当复杂问题的答案。如果你的数据库是关系型数据库,或者你只有非结构化文本,这几乎是不可能做到的。然而,找到答案花费了我们不少的时间和两个 SPARQL 查询。那我们如何将自然语言问题转化为像 SPARQL 这样的结构化查询语言呢?

你已经在第九章做过这种转化了。将人类语言转化为机器语言要比人类语言之间的翻译更具挑战性,但对机器来说,基本上是同一个问题。而且,现在你知道,变换器(transformers)擅长将一种语言转化为另一种语言(玩笑话)。作为大型变换器的 LLM,尤其擅长这种转化。Sachin Sharma 创建了一个很棒的例子,展示了如何使用另一种图形数据库——ArangoDB 来构建知识图谱。他利用 OpenAI 的模型实现了对他创建的数据库进行自然语言问答。

11.10 自测

  1. 提供一个问题示例,说明使用图数据库比使用关系型数据库更容易回答的情况。
  2. 将 networkx 有向图转换为一个包含两列(source_nodetarget_node)的 pandas DataFrame 边列表。检索单个源节点的所有目标节点 ID 需要多长时间?对于这些新源节点,检索所有目标节点又需要多长时间?你会如何通过索引加速 pandas 图查询?
  3. 创建一个 spaCy Matcher,用于在有关 Timnit Gebru 的维基百科文章中找到更多与她的工作地点相关的命名实体。你能检索到多少个?
  4. 你能想到图数据库可以完成而关系型数据库无法完成的查询吗?相反,关系型数据库能做图数据库做不到的事情吗?
  5. 使用大语言模型从自然语言生成一个 SPARQL Wikidata 查询。它在不编辑代码的情况下正确工作了吗?对于需要在你的知识图谱中进行五次关系(边)遍历的查询,它能正确工作吗?
  6. 使用 extractors.pyheatmaps.py(在 nlpia2.text_processing 中)为你自己的一篇长文档(例如,关于 NLP 的 Mastodon 微博帖子序列)提取的句子创建 BERT 相似度热图。编辑 heatmaps.py 代码进行改进,使其能集中显示非常相似的行。提示:你可以使用非线性函数来缩放余弦相似度值,并使用阈值将相似度值重置为零。
  7. 在核心指代可视化基础上,使用 HTML 高亮显示核心指代链,或者在网络(图)图或树状图中通过边连接它们。

总结

  • 你可以构建一个知识图谱来存储实体之间的关系。
  • 你可以使用基于规则的方法(如正则表达式)或基于神经网络的方法,从非结构化文本中提取和隔离信息。
  • 词性标注和依存句法分析可以帮助你提取句子中提到的实体之间的关系。
  • 像 SPARQL 这样的语言可以帮助你在知识图谱中找到所需的信息。