Python 文本分析蓝图(五)
原文:
zh.annas-archive.org/md5/c63f0fe6d74b904d41494495addce0ab译者:飞龙
第十二章:构建知识图谱
在本书中,我们一直在探索文本分析的多个蓝图。我们的目标始终是通过统计和机器学习帮助识别数据中的模式。在第十章中,我们解释了如何使用嵌入来回答类似“德国对应巴黎的是什么?”的问题。嵌入表示从训练文档中学习的某种隐含知识,基于相似性的概念。
知识库相反,由“柏林是德国的首都”形式的结构化陈述组成。在这种情况下,“首都”是两个特定实体 柏林 和 德国 之间明确定义的关系。由许多实体及其关系形成的网络在数学意义上是一个图,即知识图谱。图 12-1 展示了一个简单的知识图谱,说明了这个例子。在本章中,我们将介绍从非结构化文本中提取结构化信息并构建基本知识图谱的蓝图。
图 12-1. 一个简单的知识图谱。
What You’ll Learn and What We’ll Build
信息抽取是自然语言处理中最困难的任务之一,因为语言的复杂性和固有歧义性。因此,我们需要应用一系列不同步骤来发现实体和关系。本节中的示例用例是基于公司业务新闻文章创建知识图谱。
在本章中,我们将深入探讨 spaCy 的高级语言处理功能。我们将使用预训练的神经模型结合自定义规则进行命名实体识别、指代消解和关系抽取。我们还将解释执行实体链接的必要步骤,但不会深入到实现细节。
阅读完本章后,你将具备开始构建自己知识库的基本语言和技术知识。你可以在我们的GitHub 仓库找到本章的源代码和额外信息。
知识图谱
知识图谱是一个大型语义网络。它包括节点,如人物、地点、事件或公司,以及代表这些节点之间正式关系的边,如图 12-1 所示。
谷歌、微软、Facebook 等大公司都使用知识图谱来支持他们的搜索引擎和查询服务。¹ 现在,越来越多的公司开始构建自己的知识图谱,以获取市场洞察或为聊天机器人提供支持。但是最大的知识图谱分布在全球各地:Linked Open Data指的是网络上所有可通过统一资源标识符(URI)识别的可用数据。这是在语义网领域经过 20 年学术发展的结果(参见“语义网和 RDF”)。
节点和边的类型由本体精确定义,本体本身是一个领域术语使用的知识库。例如,公共本体 Wikidata 为 Figure 12-1 中使用的所有类型提供了定义。² 每个定义都有一个唯一的 URI(例如,“city”是http://www.wikidata.org/wiki/Q515)。事实上,Wikidata 包含了类型定义和实际对象,以可查询的格式存储。
信息提取
从文本中提取结构化信息需要几个典型步骤,如 Figure 12-2 所示。首先是命名实体识别,找到文本中的命名实体并标记其正确类型,例如,人物、组织或地点。同一实体通常会在文档中被不同变体的名称或代词多次引用。第二步是共指解析,识别和解决这些共指,以防止重复和信息丢失。
与共指解析密切相关,并且通常是下一步,是实体链接的任务。在这里,目标是将文本中的提及链接到本体中的唯一现实世界实体,例如,Berlin链接到 URIhttp://www.wikidata.org/entity/Q64。因此,任何歧义都被消除:Q64 是德国的柏林,而不是新罕布什尔州的柏林(顺便说一下,在 Wikidata 中是 Q821244)。这对于连接不同来源的信息并真正构建知识库至关重要。
图 12-2。信息提取过程。
最后一步是关系抽取,识别这些实体之间的关系。在应用场景中,你通常只会考虑几个感兴趣的关系,因为从任意文本中正确提取这种信息很困难。
最后,你可以将图存储在图数据库中,作为知识型应用程序的后端。这些图数据库将数据存储为 RDF 三元组(三元存储)或属性图形式,其中节点和边可以具有任意属性。常用的图数据库包括 GraphDB(三元存储)、Neo4j 和 Grakn(属性图形式)。
对于每个步骤,您可以选择基于规则的方法或机器学习。我们将使用 spaCy 的现有模型以及规则进行补充。不过,我们不会训练自己的模型。使用规则来提取领域特定知识的优势在于,您可以快速开始,无需训练数据。正如我们将看到的那样,结果允许进行一些非常有趣的分析。但是,如果您计划在大规模上建立企业知识库,您可能需要为命名实体和关系检测以及实体链接训练自己的模型。
引入数据集
假设您在金融业务中工作,并希望跟踪并购新闻。如果您能够自动识别公司名称及其所涉及的交易类型,并将结果存入知识库,那将是很棒的。在本章中,我们将解释有关提取公司信息的构建块。例如,我们将提取关系“公司 1 收购公司 2”。
为了模拟这样的情景,我们使用了一个公开可用的数据集,著名的Reuters-21578新闻语料库。它包含由路透社在 1987 年发布的 90 个类别的超过 20,000 篇新闻文章。选择此数据集是因为它是免费且易于获取的。实际上,它作为 NLTK 标准语料库之一可用,并且您可以简单地使用 NLTK 下载它:
import nltk
nltk.download('reuters')
我们仅处理并购类别(acq)的文章。为了满足我们的目的,我们将所有文章加载到一个DataFrame中,并按照“清理文本数据”中的蓝图进行了一些数据清洗。干净的数据对于识别命名实体和关系至关重要,因为神经模型受益于结构良好的句子。对于这个数据集,我们替换了 HTML 转义字符,删除了股票代码符号,替换了诸如mln代表million的缩写,并纠正了一些拼写错误。我们也放弃了标题,因为它们仅以大写字母编写。但完整的文章内容仍保留下来。所有清洗步骤都可以在GitHub的笔记本中找到。让我们来看一下我们DataFrame中经过清理的文章样本:
USAir Group Inc said a U.S. District Court in Pittsburgh issued a temporary
restraining order to prevent Trans World Airlines Inc from buying additional
USAir shares. USAir said the order was issued in response to its suit, charging
TWA chairman Carl Icahn and TWA violated federal laws and made misleading
statements. TWA last week said it owned 15 % of USAir's shares. It also offered
to buy the company for 52 dollars a share cash or 1.4 billion dollars.
因此,在我们制定信息提取蓝图时,这是我们心目中的数据。但是,以下各节中的大多数句子都是简化的示例,以更好地解释这些概念。
命名实体识别
在数据清洗后,我们可以开始我们信息提取过程的第一步:命名实体识别。命名实体识别在第四章中作为 spaCy 标准流水线的一部分进行了简要介绍。spaCy 是我们在本章中所有蓝图的首选库,因为它快速且具有我们将利用的可扩展 API。但您也可以使用 Stanza 或 Flair(参见“NER 的替代方案:Stanza 和 Flair”)。
spaCy 为许多语言提供了经过训练的 NER 模型。英语模型是在包含 18 种不同实体类型的大型OntoNotes5 语料库上训练的。表 12-1 列出了这些类型的一个子集。其余类型适用于数值实体。
表 12-1. OntoNotes 5 语料库的部分 NER 类型
| NER 类型 | 描述 | NER 类型 | 描述 |
|---|---|---|---|
| PERSON | 人物,包括虚构的 | PRODUCT | 车辆、武器、食品等(不包括服务) |
| NORP | 国籍或宗教或政治团体 | EVENT | 具名飓风、战役、战争、体育赛事等 |
| FAC | 设施:建筑物、机场、高速公路、桥梁等 | WORK_OF_ART | 书籍、歌曲等的标题 |
| ORG | 组织:公司、机构等 | LAW | 公布为法律的具名文件 |
| GPE | 国家、城市、州 | LANGUAGE | 任何具名语言 |
| LOCATION | 非 GPE 位置、山脉、水体 |
默认情况下,加载语言模型时会启用 NER 标记器。我们首先通过使用标准(小型)英语模型 en_core_web_sm 初始化一个 nlp 对象,并打印 NLP 流水线的组件:^(4)
nlp = spacy.load('en_core_web_sm')
print(*nlp.pipeline, sep='\n')
输出:
('tagger', <spacy.pipeline.pipes.Tagger object at 0x7f98ac6443a0>)
('parser', <spacy.pipeline.pipes.DependencyParser object at 0x7f98ac7a07c0>)
('ner', <spacy.pipeline.pipes.EntityRecognizer object at 0x7f98ac7a0760>)
处理文本后,我们可以直接通过 doc.ents 访问命名实体。每个实体都有一个文本和描述实体类型的标签。这些属性在下面代码的最后一行用于打印在文本中识别的实体列表:
text = """Hughes Tool Co Chairman W.A. Kistler said its merger with
Baker International Corp was still under consideration.
We hope to come soon to a mutual agreement, Kistler said.
The directors of Baker filed a law suit in Texas to force Hughes
to complete the merger."""
doc = nlp(text)
print(*[(e.text, e.label_) for e in doc.ents], sep=' ')
输出:
(Hughes Tool Co, ORG) (W.A. Kistler, PERSON) (Baker International Corp, ORG)
(Kistler, ORG) (Baker, PERSON) (Texas, GPE) (Hughes, ORG)
利用 spaCy 的漂亮的可视化模块 displacy,我们可以生成句子及其命名实体的视觉表示。这对检查结果非常有帮助:
from spacy import displacy
displacy.render(doc, style='ent')
输出:
总体来说,spaCy 的命名实体识别器表现很好。在我们的例子中,它能够检测到所有命名实体。然而,第二句和第三句中 Kistler 和 Baker 的标签并不正确。事实上,对于 NER 模型来说,区分人物和组织是一个挑战,因为这些实体类型的使用方式非常相似。我们将在后面的蓝图中解决这类问题,以进行基于名称的共指消解。
蓝图:使用基于规则的命名实体识别
如果您希望识别模型未经训练的领域特定实体,您当然可以使用 spaCy 自行训练您的模型。但训练模型需要大量的训练数据。通常,为自定义实体类型指定简单规则就足够了。在本节中,我们将展示如何使用规则来检测像“司法部”(或者“Justice Department”)这样的政府组织在 Reuters 数据集中的方法。
spaCy 为此提供了一个EntityRuler,这是一个流水线组件,可以与或者代替统计命名实体识别器一起使用。与正则表达式搜索相比,spaCy 的匹配引擎更强大,因为模式是在 spaCy 的标记序列上定义的,而不仅仅是字符串。因此,您可以使用任何标记属性,如词形或词性标签来构建您的模式。
所以,让我们定义一些模式规则,以匹配美国政府的部门和经常在我们的语料库中提到的证券交易委员会:
from spacy.pipeline import EntityRuler
departments = ['Justice', 'Transportation']
patterns = [{"label": "GOV",
"pattern": [{"TEXT": "U.S.", "OP": "?"},
{"TEXT": "Department"}, {"TEXT": "of"},
{"TEXT": {"IN": departments}, "ENT_TYPE": "ORG"}]},
{"label": "GOV",
"pattern": [{"TEXT": "U.S.", "OP": "?"},
{"TEXT": {"IN": departments}, "ENT_TYPE": "ORG"},
{"TEXT": "Department"}]},
{"label": "GOV",
"pattern": [{"TEXT": "Securities"}, {"TEXT": "and"},
{"TEXT": "Exchange"}, {"TEXT": "Commission"}]}]
每条规则由一个带有标签的字典组成,在我们的案例中是自定义实体类型GOV,以及令牌序列必须匹配的模式。您可以为同一标签指定多个规则,就像我们在这里所做的一样。^(5) 例如,第一条规则匹配带有文本"U.S."(可选,用"OP": "?"表示)、"Department"、"of"和"Justice"或"Transportation"的令牌序列。请注意,这些规则会对已识别出的类型ORG的实体进行进一步的细化。因此,这些模式必须在 spaCy 的命名实体模型之上而不是代替它应用。
基于这些模式,我们创建了一个EntityRuler并将其添加到我们的流水线中:
entity_ruler = EntityRuler(nlp, patterns=patterns, overwrite_ents=True)
nlp.add_pipe(entity_ruler)
现在,当我们调用nlp时,这些组织将自动用新类型GOV标记:
text = """Justice Department is an alias for the U.S. Department of Justice.
Department of Transportation and the Securities and Exchange Commission
are government organisations, but the Sales Department is not."""
doc = nlp(text)
displacy.render(doc, style='ent')
输出:
蓝图:规范化命名实体
简化不同实体提及到单一名称的解析的一种方法是规范化或标准化提及。在这里,我们将进行第一次规范化,这通常是有帮助的:移除不具体的后缀和前缀。看看这个例子:
text = "Baker International's shares climbed on the New York Stock Exchange."
doc = nlp(text)
print(*[([t.text for t in e], e.label_) for e in doc.ents], sep='\n')
输出:
(['Baker', 'International', "'s"], 'ORG')
(['the', 'New', 'York', 'Stock', 'Exchange'], 'ORG')
在第一句中,尽管所有格-s 不是公司名称的一部分,令牌序列Baker International's被检测为一个实体。类似的情况是《纽约证券交易所》中的文章。无论文章实际上是否是名称的一部分,实体有时会在提及时带有文章,有时则没有。因此,通常移除文章和所有格-apostrophe-s 简化了提及的链接。
警告
如同任何规则一样,存在着错误的可能性:想象一下《华尔街日报》或麦当劳。如果你需要保留这些情况下的冠词或者所有格-apostrophe,你必须为规则定义异常。
我们的蓝图函数展示了如何在 spaCy 中实现诸如移除前导冠词和尾随所有格-apostrophe-s 等规范化。由于我们不允许直接更新实体,我们创建了实体的副本,并将修改应用于该副本:
from spacy.tokens import Span
def norm_entities(doc):
ents = []
for ent in doc.ents:
if ent[0].pos_ == "DET": # leading article
ent = Span(doc, ent.start+1, ent.end, label=ent.label)
if ent[-1].pos_ == "PART": # trailing particle like 's
ent = Span(doc, ent.start, ent.end-1, label=ent.label)
ents.append(ent)
doc.ents = tuple(ents)
return doc
在 spaCy 中,实体是具有定义的开始和结束以及额外标签的Span对象。我们循环遍历实体,并根据需要调整实体的第一个和最后一个标记的位置。最后,我们用修改后的副本替换doc.ents。
该函数以一个 spaCy 的Doc对象(命名为doc)作为参数,并返回一个Doc。因此,我们可以将其用作另一个管道组件,简单地将其添加到现有管道中:
nlp.add_pipe(norm_entities)
现在我们可以对示例句子重复这个过程,并检查结果:
doc = nlp(text)
print(*[([t.text for t in e], e.label_) for e in doc.ents], sep='\n')
Out:
(['Baker', 'International'], 'ORG')
(['New', 'York', 'Stock', 'Exchange'], 'ORG')
合并实体标记
在许多情况下,将像前面示例中的复合名称视为单个标记是有意义的,因为它简化了句子结构。spaCy 提供了一个内置的管道函数merge_entities来实现这一目的。我们将其添加到我们的 NLP 管道中,确保每个命名实体正好只有一个标记:
from spacy.pipeline import merge_entities
nlp.add_pipe(merge_entities)
doc = nlp(text)
print(*[(t.text, t.ent_type_) for t in doc if t.ent_type_ != ''])
Out:
('Baker International', 'ORG') ('New York Stock Exchange', 'ORG')
即使合并实体在本章后期简化了我们的蓝图,这并不总是一个好主意。例如,考虑像伦敦证券交易所这样的复合实体名称。将其合并为单个标记后,这个实体与伦敦市的隐含关系将会丢失。
共指消解
在信息提取中最大的障碍之一是实体提及出现在许多不同的拼写形式中(也称为表面形式)。看看以下句子:
休斯工具公司主席 W.A.基斯勒表示其与贝克国际公司的合并仍在考虑中。基斯勒表示希望达成一致意见。贝克将迫使休斯完成合并。美国司法部的审查今天已经完成。司法部将在与证券交易委员会磋商后阻止这一合并。
如我们所见,实体通常以其全名引入,而后续提及则使用缩写版本。这是必须解决的一种共指类型,以理解正在发生的情况。图 12-3 显示了一个无(左)和有(右)统一名称的共现图。这样的共现图将在下一节中构建,是显示出现在同一文章中的实体对的可视化。
图 12-3. 同一文章的共现图在核心引用解析前(左)和后(右)的对比。
共指消解 是确定单个文本中实体不同提及的任务,例如缩写名称、别名或代词。这一步骤的结果是一组共指提及,称为提及簇,例如 {休斯工具公司, 休斯, 其}。本节的目标是识别相关的提及并在文档中进行链接。
为了这个目的,我们为指代消解和名称统一开发了几个蓝图(见图 12-4)。我们将限制自己只处理组织和个人,因为这些是我们感兴趣的实体类型。首先,我们将通过字典查找解析像SEC这样的别名。然后我们将在文档中匹配名称到第一次提及。例如,我们将从“Kistler”创建到“W.A. Kistler”的链接。之后,间接指代(回指)如第一句中的代词its将被解决。最后,我们将再次规范化已解析实体的名称。所有这些步骤将作为附加的管道函数实现。
图 12-4. 命名实体识别和指代消解的流程图。
实体链接更进一步。这里的实体提及在语义级别上被消歧,并链接到现有知识库中的唯一条目。因为实体链接本身是一项具有挑战性的任务,我们不会提供其蓝图,而只是在本节末讨论它。
蓝图:使用 spaCy 的标记扩展
我们需要一种技术上的方法,从不同实体的各个提及创建到主参照(referent)的链接。在核心 ference 解决后,例如文章示例中的“Kistler”的标记应指向“(W.A. Kistler, PERSON)”。spaCy 的扩展机制允许我们定义自定义属性,这是将此类信息与标记一起存储的完美方法。因此,我们创建了两个标记扩展ref_n(参照名称)和ref_t(参照类型)。这些属性将为每个标记初始化为 spaCy 指定的默认值:
from spacy.tokens import Token
Token.set_extension('ref_n', default='')
Token.set_extension('ref_t', default='')
下一个展示的init_coref函数确保每个类型为ORG、GOV和PERSON的实体提供一个初始参照。这种初始化对于接下来的功能是必需的:
def init_coref(doc):
for e in doc.ents:
if e.label_ in ['ORG', 'GOV', 'PERSON']:
e[0]._.ref_n, e[0]._.ref_t = e.text, e.label_
return doc
自定义属性通过标记的下划线属性访问。请注意,在merge_entities之后,每个实体提及e由一个单一标记e[0]组成,我们在其中设置了这些属性。我们也可以在实体跨度而不是标记上定义这些属性,但我们希望稍后对代词解析使用相同的机制。
蓝图:执行别名解析
我们首先的目标是解决众所周知的领域别名,比如Transportation Department代表“美国交通部”,以及像 SEC 或 TWA 这样的缩写词。解决这类别名的简单方法是使用查找字典。我们为 Reuters 语料库中的所有缩写词和一些常见别名准备了这样一个字典,并将其作为本章蓝图模块的一部分提供。^(6) 这里是一些示例查找:
from blueprints.knowledge import alias_lookup
for token in ['Transportation Department', 'DOT', 'SEC', 'TWA']:
print(token, ':', alias_lookup[token])
Out:
Transportation Department : ('U.S. Department of Transportation', 'GOV')
DOT : ('U.S. Department of Transportation', 'GOV')
SEC : ('Securities and Exchange Commission', 'GOV')
TWA : ('Trans World Airlines Inc', 'ORG')
每个令牌别名都映射到一个元组,包括实体名称和类型。下面显示的函数alias_resolver检查实体文本是否在字典中找到。如果是,将更新其ref属性为查找到的值:
def alias_resolver(doc):
"""Lookup aliases and store result in ref_t, ref_n"""
for ent in doc.ents:
token = ent[0].text
if token in alias_lookup:
a_name, a_type = alias_lookup[token]
ent[0]._.ref_n, ent[0]._.ref_t = a_name, a_type
return propagate_ent_type(doc)
解决了别名后,我们还可以纠正命名实体类型,以防其被错误标识。这是通过函数propagate_ent_type完成的。它更新所有已解析的别名,并将在下一个基于名称的指代消解蓝图中使用:
def propagate_ent_type(doc):
"""propagate entity type stored in ref_t"""
ents = []
for e in doc.ents:
if e[0]._.ref_n != '': # if e is a coreference
e = Span(doc, e.start, e.end, label=e[0]._.ref_t)
ents.append(e)
doc.ents = tuple(ents)
return doc
现在,我们将alias_resolver添加到我们的流水线中:
nlp.add_pipe(alias_resolver)
现在我们可以检查结果。为此,我们提供的蓝图包含一个实用函数display_ner,用于为doc对象中的标记创建一个DataFrame,并包括本章相关属性:
from blueprints.knowledge import display_ner
text = """The deal of Trans World Airlines is under investigation by the
U.S. Department of Transportation.
The Transportation Department will block the deal of TWA."""
doc = nlp(text)
display_ner(doc).query("ref_n != ''")[['text', 'ent_type', 'ref_n', 'ref_t']]
输出:
| 文本 | 实体类型 | 参考编号 | 参考类型 | |
|---|---|---|---|---|
| 3 | 美国国际航空公司 | ORG | 美国国际航空公司 | ORG |
| 9 | 美国交通部 | GOV | 美国交通部 | GOV |
| 12 | 交通部 | GOV | 美国交通部 | GOV |
| 18 | TWA | ORG | 美国国际航空公司 | ORG |
蓝图:解决名称变体
别名解析仅在别名在前期已知的情况下有效。但是由于文章中几乎任何名称都可能存在变体,因此构建所有这些名称的词典是不可行的。再次看一下我们介绍示例中第一句中识别的命名实体:
在这里,您会找到“Kistler”的指代“W.A. Kistler(PERSON)”,“Baker”的指代“Baker International Corp(ORG)”,以及“休斯”的指代“休斯工具公司(ORG)”。正如您所看到的,缩写的公司名称经常被误认为是人物,特别是在以模拟形式使用时。在这个蓝图中,我们将解决这些指代,并为每个提及分配正确的实体类型。
为此,我们将利用新闻文章中的一个常见模式。实体通常首先以其全名介绍,后续提及使用缩写版本。因此,我们将通过将名称与实体的第一次提及匹配来解决次要引用。当然,这是一个启发式规则,可能会产生错误的匹配。例如,休斯也可能指同一篇文章中的公司以及传奇企业家霍华德·休斯(确实是休斯工具公司的创始人)。但这类情况在我们的数据集中很少见,我们决定在正确的启发式案例中接受这种不确定性。
我们为名称匹配定义了一个简单的规则:如果所有单词按相同顺序出现在主要提及中,次要提及就匹配主要提及。为了检查这一点,下一个显示的函数name_match将次要提及m2转换为正则表达式,并在主要提及m1中搜索匹配项:
def name_match(m1, m2):
m2 = re.sub(r'[()\.]', '', m2) # ignore parentheses and dots
m2 = r'\b' + m2 + r'\b' # \b marks word boundary
m2 = re.sub(r'\s+', r'\\b.*\\b', m2)
return re.search(m2, m1, flags=re.I) is not None
例如,Hughes Co.的次要提及会被转换为'\bHughes\b.*\bCo\b',这与 Hughes Tool Co 匹配。\b确保只匹配整个单词,而不是子词如Hugh。
基于此匹配逻辑,下面展示的name_resolver函数实现了基于名称的组织和个人共指解析:
def name_resolver(doc):
"""create name-based reference to e1 as primary mention of e2"""
ents = [e for e in doc.ents if e.label_ in ['ORG', 'PERSON']]
for i, e1 in enumerate(ents):
for e2 in ents[i+1:]:
if name_match(e1[0]._.ref_n, e2[0].text):
e2[0]._.ref_n = e1[0]._.ref_n
e2[0]._.ref_t = e1[0]._.ref_t
return propagate_ent_type(doc)
首先,我们创建一个所有组织和个人实体的列表。然后,将实体e1和e2的所有对比较。该逻辑确保实体e1在文档中始终出现在e2之前。如果e2匹配e1,其指示物将自动传播到其后续共指。
我们将此函数添加到nlp流程中,并检查结果:
nlp.add_pipe(name_resolver)
doc = nlp(text)
displacy.render(doc, style='ent')
Out:
现在我们的示例中每个命名实体都具有正确的类型。我们还可以检查实体是否映射到其第一次提及:
display_ner(doc).query("ref_n != ''")[['text', 'ent_type', 'ref_n', 'ref_t']]
Out:
| text | ent_type | ref_n | ref_t | |
|---|---|---|---|---|
| 0 | Hughes Tool Co | ORG | Hughes Tool Co | ORG |
| 2 | W.A. Kistler | PERSON | W.A. Kistler | PERSON |
| 7 | Baker International Corp. | ORG | Baker International Corp. | ORG |
| 22 | Kistler | PERSON | W.A. Kistler | PERSON |
| 25 | Baker | ORG | Baker International Corp. | ORG |
| 28 | Hughes | ORG | Hughes Tool Co | ORG |
蓝图:使用 NeuralCoref 进行指代消解
在语言学中,指代是依赖于前文的词语。考虑我们示例句子的这种变化:
text = """Hughes Tool Co said its merger with Baker
was still under consideration. Hughes had a board meeting today.
W.A. Kistler mentioned that the company hopes for a mutual agreement.
He is reasonably confident."""
这里的其、公司和他是指代词。来自 Hugging Face 的NeuralCoref是一个解决这类共指的库。该算法结合基于词嵌入的特征向量(参见第十章),使用两个神经网络识别共指簇及其主要提及物。
NeuralCoref 作为 spaCy 的流水线扩展实现,因此完美地适合我们的流程。我们使用greedyness值为 0.45 创建神经共指解析器,并将其添加到我们的流水线中。greedyness控制模型的敏感性,在一些实验后,我们决定选择比默认值 0.5 稍微严格一些的值(更高的准确性,较低的召回率):
from neuralcoref import NeuralCoref
neural_coref = NeuralCoref(nlp.vocab, greedyness=0.45)
nlp.add_pipe(neural_coref, name='neural_coref')
NeuralCoref 还利用 spaCy 的扩展机制向Doc、Span和Token对象添加自定义属性。处理文本时,我们可以通过doc._.coref_clusters属性访问检测到的共指簇。在我们的示例中,已经识别出三个这样的簇:
doc = nlp(text)
print(*doc._.coref_clusters, sep='\n')
Out:
Hughes Tool Co: [Hughes Tool Co, its]
Hughes: [Hughes, the company]
W.A. Kistler: [W.A. Kistler, He]
NeuralCoref 在 Span 对象(令牌序列)上工作,因为一般的共指不仅限于命名实体。因此,蓝图函数 anaphor_coref 为每个令牌检索第一个共指集群,并搜索具有其 ref_n 属性值的第一个命名实体。在我们的案例中,这只会是组织和人物。一旦找到,它将把代词令牌的 ref_n 和 ref_t 值设置为主参考中的相同值:
def anaphor_coref(doc):
"""anaphora resolution"""
for token in doc:
# if token is coref and not already dereferenced
if token._.in_coref and token._.ref_n == '':
ref_span = token._.coref_clusters[0].main # get referred span
if len(ref_span) <= 3: # consider only short spans
for ref in ref_span: # find first dereferenced entity
if ref._.ref_n != '':
token._.ref_n = ref._.ref_n
token._.ref_t = ref._.ref_t
break
return doc
再次,我们将这个解析器加入到我们的流水线中并检查结果:
nlp.add_pipe(anaphor_coref)
doc = nlp(text)
display_ner(doc).query("ref_n != ''") \
[['text', 'ent_type', 'main_coref', 'ref_n', 'ref_t']]
Out:
| 文本 | 实体类型 | 主共指 | ref_n | ref_t | |
|---|---|---|---|---|---|
| 0 | Hughes Tool Co | 组织 | Hughes Tool Co | Hughes Tool Co | 组织 |
| 2 | 其 | Hughes Tool Co | Hughes Tool Co | 组织 | |
| 5 | Baker | 人物 | None | Baker | 人物 |
| 11 | Hughes | 组织 | Hughes | Hughes Tool Co | 组织 |
| 18 | W.A. Kistler | 人物 | W.A. Kistler | W.A. Kistler | 人物 |
| 21 | the | Hughes | Hughes Tool Co | 组织 | |
| 22 | 公司 | Hughes | Hughes Tool Co | 组织 | |
| 29 | He | W.A. Kistler | W.A. Kistler | 人物 |
现在我们的流水线包括图示 12-4 中显示的所有步骤。
警告
警惕长时间运行时间!NeuralCoref 将总体处理时间增加了 5–10 倍。因此,您应该仅在必要时使用指代消解。
名称规范化
尽管我们的名称解析在文章中统一了公司提及,但是公司名称在文章之间仍然不一致。在一篇文章中我们会看到 Hughes Tool Co.,而在另一篇文章中我们会看到 Hughes Tool。实体链接器可以用来将不同的实体提及链接到唯一的规范表示,但在没有实体链接器的情况下,我们将使用(解析后的)名称实体作为其唯一标识符。由于前面的共指解析步骤,解析后的名称总是文章中第一个,因此通常也是最完整的提及。因此,错误的可能性并不大。
不过,我们必须通过去除诸如 Co. 或 Inc. 这样的法律后缀来协调公司提及。以下函数使用正则表达式来实现此目标:
def strip_legal_suffix(text):
return re.sub(r'(\s+and)?(\s+|\b(Co|Corp|Inc|Plc|Ltd)\b\.?)*$', '', text)
print(strip_legal_suffix('Hughes Tool Co'))
Out:
Hughes Tool
最后的流水线函数 norm_names 将最终的规范化应用于存储在 ref_n 属性中的每个共指解析后的组织名称。请注意,使用这种方法,Hughes (人物) 和 Hughes (组织) 仍然会保持分开的实体。
def norm_names(doc):
for t in doc:
if t._.ref_n != '' and t._.ref_t in ['ORG']:
t._.ref_n = strip_legal_suffix(t._.ref_n)
if t._.ref_n == '':
t._.ref_t = ''
return doc
nlp.add_pipe(norm_names)
有时,命名实体识别器会错误地将法律后缀(例如 Co. 或 Inc.)单独分类为命名实体。如果这样的实体名称被剥离成空字符串,我们只需忽略它以便稍后处理。
实体链接
在前面的章节中,我们开发了一个操作流程,其目的是统一命名实体的不同提及。但所有这些都是基于字符串的,除了语法表示之外,我们没有将“美国司法部”这样的字符串与所代表的现实世界实体联系起来。相比之下,实体链接器的任务是全局解析命名实体,并将它们链接到唯一标识的现实世界实体。实体链接从“字符串到实体”的转换。^(8)
从技术上讲,这意味着每个提及都映射到一个 URI。URI 又可以用来指代现有知识库中的实体。这可以是公共本体,例如 Wikidata 或 DBpedia,也可以是公司内部的私有知识库。URI 可以是 URL(例如网页),但不一定要是。例如,美国司法部在 Wikidata 有一个 URI http://www.wikidata.org/entity/Q1553390,这也是一个包含有关该实体信息的网页。如果您构建自己的知识库,则不需要为每个 URI 创建网页;它们只需要是唯一的。顺便说一下,DBpedia 和 Wikidata 使用不同的 URI,但您将在 DBpedia 上找到对 Wikidata URI 的交叉引用。两者当然都包含指向维基百科网页的链接。
如果一个实体通过完全限定名称(如“美国司法部”)提及,实体链接就很简单。但是,“司法部”而没有“美国”就已经相当模糊,因为许多州都有“司法部”。实际意义取决于上下文,实体链接器的任务是根据上下文敏感地将这种模糊提及映射到正确的 URI。这是一个相当大的挑战,仍然是持续研究的领域。在商业项目中进行实体链接的常见解决方案是使用公共服务(参见“实体链接服务”)。
或者,您可以创建自己的实体链接器。一个简单的解决方案是基于名称的查找字典。但这种方法不考虑上下文,并且无法解决不同实体的名称歧义。为此,您需要更复杂的方法。最先进的解决方案使用嵌入和神经模型进行实体链接。例如,spaCy 还提供了这样的实体链接功能。要使用 spaCy 的实体链接器,首先必须为您指定的描述创建嵌入(参见第十章),从而捕获其语义。然后,您可以训练模型,学习将提及映射到正确 URI 的上下文敏感映射。然而,实体链接器的设置和训练超出了本章的范围。
蓝图:创建共现图
在前面的部分,我们花了很多精力来规范命名实体并至少解析文档内的核心参考。现在我们终于准备好分析实体对之间的第一个关系了:它们在文章中的共同提及。为此,我们将创建一个共现图,这是知识图的最简形式。共现图中的节点是实体,例如组织。如果两个实体在相同的上下文中提及,例如在一篇文章、一个段落或一个句子中,它们之间就会共享一个(无向)边。
图 12-5 显示了路透社语料库中一起提及的公司的共现图的部分。边的宽度可视化了共现频率。模块性,这是一种用于识别网络中紧密相关的群体或社群的结构性指标,被用来着色节点和边。^(9)
图 12-5. 从路透社语料库生成的共现图的最大连通分量。
当然,我们不知道这里的关系类型。实际上,两个实体的共同提及只是表明可能存在一些关系。除非我们真的分析句子,否则我们无法确定。但是,即使简单探索共现也可能有所启示。例如,图 12-5 中的中心节点是“证券交易委员会”,因为它在许多文章中与许多其他实体一起提及。显然,该实体在并购中扮演重要角色。不同的集群给我们留下了一些关于涉及特定交易的公司(或社群)的印象。
要绘制共现图,我们必须从文档中提取实体对。对于涵盖多个主题领域的较长文章,最好在段落甚至句子内搜索共现。但是涉及并购的路透社文章非常专注,所以我们在这里坚持使用文档级别。让我们简要地走一遍提取和可视化共现的过程。
从文档中提取共现
函数 extract_coocs 返回给定 Doc 对象中指定类型的实体对列表:
from itertools import combinations
def extract_coocs(doc, include_types):
ents = set([(e[0]._.ref_n, e[0]._.ref_t)
for e in doc.ents if e[0]._.ref_t in include_types])
yield from combinations(sorted(ents), 2)
我们首先创建一个核心解析实体名称和类型的集合。有了这个,我们使用 Python 标准库 itertools 中的 combinations 函数来创建所有实体对。每对都按字典顺序排序(sorted(ents)),以防止重复条目,比如 “(Baker, Hughes)” 和 “(Hughes, Baker)” 的出现。
为了高效处理整个数据集,我们再次使用 spaCy 的流式处理,通过调用 nlp.pipe(在第四章介绍过)。由于我们不需要在文档中解析指代关系以找出文档内的共现,因此在这里禁用了相应的组件:
batch_size = 100
coocs = []
for i in range(0, len(df), batch_size):
docs = nlp.pipe(df['text'][i:i+batch_size],
disable=['neural_coref', 'anaphor_coref'])
for j, doc in enumerate(docs):
coocs.extend([(df.index[i+j], *c)
for c in extract_coocs(doc, ['ORG', 'GOV'])])
让我们看一下第一篇文章识别出的共现:
print(*coocs[:3], sep='\n')
输出:
(10, ('Computer Terminal Systems', 'ORG'), ('Sedio N.V.', 'ORG'))
(10, ('Computer Terminal Systems', 'ORG'), ('Woodco', 'ORG'))
(10, ('Sedio N.V.', 'ORG'), ('Woodco', 'ORG'))
在信息提取中,始终建议具有某种可追溯性,以便在出现问题时识别信息的来源。因此,我们保留了文章的索引,这在我们的情况下是 Reuters 语料库的文件 ID,与每个共现元组(这里是 ID 10)一起。根据这个列表,我们生成了一个DataFrame,每个实体组合有一个条目,其频率和找到这个共现的文章 ID(限制为五个)。
coocs = [([id], *e1, *e2) for (id, e1, e2) in coocs]
cooc_df = pd.DataFrame.from_records(coocs,
columns=('article_id', 'ent1', 'type1', 'ent2', 'type2'))
cooc_df = cooc_df.groupby(['ent1', 'type1', 'ent2', 'type2'])['article_id'] \
.agg(['count', 'sum']) \
.rename(columns={'count': 'freq', 'sum': 'articles'}) \
.reset_index().sort_values('freq', ascending=False)
cooc_df['articles'] = cooc_df['articles'].map(
lambda lst: ','.join([str(a) for a in lst[:5]]))
这里是我们在语料库中发现的三对最频繁的实体:
cooc_df.head(3)
输出:
| ent1 | type1 | ent2 | type2 | freq | articles | |
|---|---|---|---|---|---|---|
| 12667 | 美国世界航空公司 | ORG | USAir 集团 | ORG | 22 | 1735,1771,1836,1862,1996 |
| 5321 | 单眼巨人 | ORG | 迪克森斯集团 | ORG | 21 | 4303,4933,6093,6402,7110 |
| 12731 | 美国交通部 | GOV | USAir 集团 | ORG | 20 | 1735,1996,2128,2546,2799 |
使用 Gephi 可视化图形
实际上,这个DataFrame已经代表了我们图形的边缘列表。对于可视化,我们更喜欢图表,这是一个用于图形分析的开源工具。因为它是交互式的,所以比 Python 的图形库 NetworkX 要好得多。^(10) 为了使用 Gephi,我们需要将图的节点和边的列表保存为 Graph Exchange XML 格式。幸运的是,NetworkX 提供了一个将图导出为这种格式的函数。因此,我们可以简单地将我们的DataFrame转换为 NetworkX 图,并将其保存为.gexf文件。我们舍弃了罕见的实体对,以保持图的紧凑性,并重新命名了频率列,因为 Gephi 会自动使用weight属性来调整边的宽度。
import networkx as nx
graph = nx.from_pandas_edgelist(
cooc_df[['ent1', 'ent2', 'articles', 'freq']] \
.query('freq > 3').rename(columns={'freq': 'weight'}),
source='ent1', target='ent2', edge_attr=True)
nx.readwrite.write_gexf(graph, 'cooc.gexf', encoding='utf-8',
prettyprint=True, version='1.2draft')
将文件导入 Gephi 后,我们仅选择了最大的组件(连接的子图),并手动删除了一些只有少数连接的节点,以清晰起见。^(11) 结果呈现在图 12-5 中。
注意
有时,最有趣的关系是不频繁的关系。例如,考虑一下即将发生的合并的第一次公告,或者过去提到过但后来被遗忘的令人惊讶的关系。先前无关的实体的突然共现可能是开始对关系进行更深入分析的信号。
关系提取
即使共现图已经为我们提供了关于公司网络的一些有趣见解,但它并没有告诉我们关系的类型。例如,考虑图中左下角由 Schlumberger、Fairchild Semiconductor 和 Fujitsu 公司组成的子图。到目前为止,我们对这些公司之间的关系一无所知;这些信息仍然隐藏在这样的句子中:
富士通希望扩展。它计划收购施伦贝尔格的工业单元傲胜公司 80%的股份。
在本节中,我们将介绍基于模式的关系抽取的两个蓝图。第一个更简单的蓝图搜索形式为“主语-谓语-宾语”的标记短语。第二个使用句子的语法结构——依赖树来以更复杂的规则获取更精确的结果。最终,我们将生成一个基于四种关系(acquires、sells、subsidiary-of和chairperson-of)的知识图谱。说实话,我们将使用较为宽松的acquires和sells定义,这样更容易识别。它们也会匹配句子如“富士通计划收购傲胜公司 80%股权”甚至“富士通撤回了收购傲胜公司的选项”。
关系抽取是一个复杂的问题,因为自然语言的歧义性和不同种类及变化的关系。基于模型的关系抽取方法是当前研究的一个热门话题。^(12) 还有一些公开可用的训练数据集,如FewRel。然而,训练一个模型来识别关系仍然主要停留在研究阶段,超出了本书的范围。
蓝图:使用短语匹配提取关系
第一个蓝图类似于基于规则的实体识别:它试图根据标记序列的模式识别关系。让我们从一个简化版本的介绍性例子开始解释这种方法。
text = """Fujitsu plans to acquire 80% of Fairchild Corp, an industrial unit
of Schlumberger."""
我们可以通过搜索以下模式来找到这个句子中的关系:
ORG {optional words, not ORG} acquire {optional words, not ORG} ORG
ORG {optional words, not ORG} unit of {optional words, not ORG} ORG
spaCy 的基于规则的匹配器允许我们找到不仅涉及文本标记而且包括它们属性(如词形或词性)的模式。要使用它,我们必须首先定义一个匹配器对象。然后,我们可以向匹配器添加带有标记模式的规则:
from spacy.matcher import Matcher
matcher = Matcher(nlp.vocab)
acq_synonyms = ['acquire', 'buy', 'purchase']
pattern = [{'_': {'ref_t': 'ORG'}}, # subject
{'_': {'ref_t': {'NOT_IN': ['ORG']}}, 'OP': '*'},
{'POS': 'VERB', 'LEMMA': {'IN': acq_synonyms}},
{'_': {'ref_t': {'NOT_IN': ['ORG']}}, 'OP': '*'},
{'_': {'ref_t': 'ORG'}}] # object
matcher.add('acquires', None, pattern)
subs_synonyms = ['subsidiary', 'unit']
pattern = [{'_': {'ref_t': 'ORG'}}, # subject
{'_': {'ref_t': {'NOT_IN': ['ORG']}},
'POS': {'NOT_IN': ['VERB']}, 'OP': '*'},
{'LOWER': {'IN': subs_synonyms}}, {'TEXT': 'of'},
{'_': {'ref_t': {'NOT_IN': ['ORG']}},
'POS': {'NOT_IN': ['VERB']}, 'OP': '*'},
{'_': {'ref_t': 'ORG'}}] # object
matcher.add('subsidiary-of', None, pattern)
第一个模式是针对acquires关系的。它返回所有由组织名称组成的跨度,后跟任意不是组织的标记,匹配几个acquire的同义词的动词,再次是任意标记,最后是第二个组织名称。subsidiary-of的第二个模式工作方式类似。
当然,这些表达式很难阅读。一个原因是我们使用了自定义属性ref_t而不是标准的ENT_TYPE。这是为了匹配没有标记为实体的指代词,例如代词。另一个原因是我们包含了一些NOT_IN子句。这是因为带有星号操作符(*)的规则总是危险的,因为它们搜索长度不受限制的模式。对标记的附加条件可以减少假匹配的风险。例如,我们希望匹配“施卢姆伯格的工业部门费尔德,但不是“富士通提到了施卢姆伯格的一个部门。”在开发规则时,您总是需要通过复杂性来换取精确性。我们将在一分钟内讨论acquires关系的问题。
现在蓝图功能extract_rel_match接收处理过的Doc对象和匹配器,并将所有匹配转换为主谓宾三元组:
def extract_rel_match(doc, matcher):
for sent in doc.sents:
for match_id, start, end in matcher(sent):
span = sent[start:end] # matched span
pred = nlp.vocab.strings[match_id] # rule name
subj, obj = span[0], span[-1]
if pred.startswith('rev-'): # reversed relation
subj, obj = obj, subj
pred = pred[4:]
yield ((subj._.ref_n, subj._.ref_t), pred,
(obj._.ref_n, obj._.ref_t))
谓词由规则名称确定;所涉及的实体仅是匹配跨度的第一个和最后一个标记。我们限制搜索到句子级别,因为在整个文档中,我们可能会发现跨多个句子的假阳性的高风险。
通常,规则按“主谓宾”的顺序匹配,但实体在文本中经常以相反的顺序出现,就像“施卢姆伯格的部门费尔德公司”。在这种情况下,关于subsidiary-of关系的实体顺序是“宾-谓-主”。extract_rel_match已经准备好处理这种情况,并在规则具有rev-前缀时切换主体和客体:
pattern = [{'_': {'ref_t': 'ORG'}}, # subject
{'LOWER': {'IN': subs_synonyms}}, # predicate
{'_': {'ref_t': 'ORG'}}] # object
matcher.add('rev-subsidiary-of', None, pattern)
现在我们能够检测到句子中的acquires和subsidiary-of的两种变体:
text = """Fujitsu plans to acquire 80% of Fairchild Corp, an industrial unit
of Schlumberger. The Schlumberger unit Fairchild Corp received an offer."""
doc = nlp(text)
print(*extract_rel_match(doc, matcher), sep='\n')
Out:
(('Fujitsu', 'ORG'), 'acquires', ('Fairchild', 'ORG'))
(('Fairchild', 'ORG'), 'subsidiary-of', ('Schlumberger', 'ORG'))
(('Fairchild', 'ORG'), 'subsidiary-of', ('Schlumberger', 'ORG'))
虽然这些规则在我们的例子中运行良好,但acquires的规则并不是很可靠。动词acquire可以出现在许多不同的实体组合中。因此,存在诸如以下这种的假阳性的高概率匹配:
text = "Fairchild Corp was acquired by Fujitsu."
print(*extract_rel_match(nlp(text), matcher), sep='\n')
Out:
(('Fairchild', 'ORG'), 'acquires', ('Fujitsu', 'ORG'))
或者这样一个:
text = "Fujitsu, a competitor of NEC, acquired Fairchild Corp."
print(*extract_rel_match(nlp(text), matcher), sep='\n')
Out:
(('NEC', 'ORG'), 'acquires', ('Fairchild', 'ORG'))
显然,我们的规则并不适用于被动从句(“被...收购”),在这种情况下,主语和宾语交换位置。我们也不能处理包含命名实体或否定的插入,因为它们会产生假匹配。要正确处理这些情况,我们需要了解句子的句法结构。而这些知识可以从依赖树中获取。
但是让我们先移除匹配器中不可靠的acquires规则:
if matcher.has_key("acquires"):
matcher.remove("acquires")
蓝图:使用依赖树提取关系
语言的语法规则对每个句子强加了一种句法结构。每个词在与其他词的关系中起特定作用。例如,名词在句子中可以是主语或宾语;这取决于它与动词的关系。在语言学理论中,句子的词汇是层级相互依存的,而在自然语言处理管道中,解析器的任务是重建这些依赖关系。^(13) 其结果是依赖树,也可以通过displacy进行可视化:
text = "Fujitsu, a competitor of NEC, acquired Fairchild Corp."
doc = nlp(text)
displacy.render(doc, style='dep',
options={'compact': False, 'distance': 100})
依赖树中的每个节点代表一个词。边缘用依赖信息标记。根节点通常是句子的谓语,本例中是acquired,有一个主语(nsubj)和一个宾语(obj)作为直接子节点。这个第一层,根加子节点,已经代表了句子“Fujitsu acquired Fairchild Corp.”的本质。
我们也来看看带有被动从句的例子。在这种情况下,助动词(auxpass)表示acquired以被动形式使用,Fairchild是被动主语(nsubjpass):
警告
依赖标签的值取决于解析器模型训练的语料库。它们还因语言而异,因为不同语言有不同的语法规则。因此,您绝对需要检查依赖解析器使用的标签集。
函数extract_rel_dep实现了一个规则,用于基于依赖关系识别基于动词的关系,例如acquires:
def extract_rel_dep(doc, pred_name, pred_synonyms, excl_prepos=[]):
for token in doc:
if token.pos_ == 'VERB' and token.lemma_ in pred_synonyms:
pred = token
passive = is_passive(pred)
subj = find_subj(pred, 'ORG', passive)
if subj is not None:
obj = find_obj(pred, 'ORG', excl_prepos)
if obj is not None:
if passive: # switch roles
obj, subj = subj, obj
yield ((subj._.ref_n, subj._.ref_t), pred_name,
(obj._.ref_n, obj._.ref_t))
主循环迭代文档中的所有标记,并搜索表明我们关系的动词。此条件与我们之前使用的平面模式规则相同。但是当我们检测到可能的谓语时,我们现在遍历依赖树以找到正确的主语和宾语。find_subj搜索谓语的左子树,而find_obj搜索谓语的右子树。这些功能未在书中打印,但您可以在本章的 GitHub 笔记本中找到它们。它们使用广度优先搜索来查找最接近的主语和宾语,因为嵌套句子可能有多个主语和宾语。最后,如果谓语表示被动从句,主语和宾语将被交换。
注意,这个函数也适用于sells关系:
text = """Fujitsu said that Schlumberger Ltd has arranged
to sell its stake in Fairchild Inc."""
doc = nlp(text)
print(*extract_rel_dep(doc, 'sells', ['sell']), sep='\n')
Out:
(('Schlumberger', 'ORG'), 'sells', ('Fairchild', 'ORG'))
在这种情况下,Fairchild Inc.是与sell依赖树中最接近的对象,并正确地被识别为所调查关系的对象。但仅仅是“最接近”并不总是足够。考虑这个例子:
实际上,这里我们有一个三方关系:Schlumberger 将 Fairchild 卖给 Fujitsu。我们的sells关系意图表达“一家公司卖出(整体或部分)另一家公司”。另一部分由acquires关系覆盖。但是,我们如何在这里检测到正确的对象呢?在这个句子中,Fujitsu 和 Fairchild 都是介词对象(依赖pobj),而 Fujitsu 是最接近的。介词是关键:Schlumberger 将某物卖给“Fujitsu”,所以这不是我们要找的关系。在提取函数中,参数excl_prepos的目的是跳过具有指定介词的对象。以下是不带(A)和带有(B)介词过滤器的输出:
print("A:", *extract_rel_dep(doc, 'sells', ['sell']))
print("B:", *extract_rel_dep(doc, 'sells', ['sell'], ['to', 'from']))
Out:
A: (('Schlumberger', 'ORG'), 'sells', ('Fujitsu', 'ORG'))
B:
让我们来看看我们的新关系提取函数在几个示例变体上的工作情况:
texts = [
"Fairchild Corp was bought by Fujitsu.", # 1
"Fujitsu, a competitor of NEC Co, acquired Fairchild Inc.", # 2
"Fujitsu is expanding." +
"The company made an offer to acquire 80% of Fairchild Inc.", # 3
"Fujitsu plans to acquire 80% of Fairchild Corp.", # 4
"Fujitsu plans not to acquire Fairchild Corp.", # 5
"The competition forced Fujitsu to acquire Fairchild Corp." # 6
]
acq_synonyms = ['acquire', 'buy', 'purchase']
for i, text in enumerate(texts):
doc = nlp(text)
rels = extract_rel_dep(doc, 'acquires', acq_synonyms, ['to', 'from'])
print(f'{i+1}:', *rels)
Out:
1: (('Fujitsu', 'ORG'), 'acquires', ('Fairchild', 'ORG'))
2: (('Fujitsu', 'ORG'), 'acquires', ('Fairchild', 'ORG'))
3: (('Fujitsu', 'ORG'), 'acquires', ('Fairchild', 'ORG'))
4: (('Fujitsu', 'ORG'), 'acquires', ('Fairchild', 'ORG'))
5: (('Fujitsu', 'ORG'), 'acquires', ('Fairchild', 'ORG'))
6:
正如我们所看到的,前四句中的关系已经被正确提取出来。然而,第 5 句包含否定,仍然返回acquires。这是一个典型的假阳性案例。我们可以扩展我们的规则以正确处理这种情况,但在我们的语料库中否定很少,我们接受了简单算法的不确定性。相比之下,第 6 句则是一个可能的假阴性的例子。尽管提到了关系,但由于这句子的主语是competition而不是公司之一,因此没有被检测到。
实际上,基于依赖的规则本质上是复杂的,每种使其更精确的方法都会导致更多的复杂性。在不使代码过于复杂的情况下,找到精度(更少的假阳性)和召回率(更少的假阴性)之间的良好平衡是一种挑战。
尽管存在这些缺陷,基于依赖的规则仍然能够产生良好的结果。然而,这一过程的最后一步取决于命名实体识别、共指消解和依赖解析的正确性,而所有这些都不能以 100%的准确率工作。因此,总会有一些假阳性和假阴性。但是,这种方法已经足够好,可以产生非常有趣的知识图谱,正如我们将在下一节中做的那样。
创建知识图谱
现在我们知道如何提取特定的关系,我们可以将所有内容整合在一起,并从整个 Reuters 语料库创建知识图谱。我们将提取组织、人员以及四个关系“acquires”、“sells”、“subsidiary-of”和“executive-of”。图 12-6 显示了带有一些选择的子图的结果图。
为了在依赖解析和命名实体识别中获得最佳结果,我们使用 spaCy 的大型模型和完整管道。如果可能,我们将使用 GPU 加速 NLP 处理:
if spacy.prefer_gpu():
print("Working on GPU.")
else:
print("No GPU found, working on CPU.")
nlp = spacy.load('en_core_web_lg')
pipes = [entity_ruler, norm_entities, merge_entities,
init_coref, alias_resolver, name_resolver,
neural_coref, anaphor_coref, norm_names]
for pipe in pipes:
nlp.add_pipe(pipe)
在我们开始信息提取过程之前,我们为“执行者”关系创建了两个类似于“子公司”关系的额外规则,并将它们添加到我们基于规则的matcher中:
ceo_synonyms = ['chairman', 'president', 'director', 'ceo', 'executive']
pattern = [{'ENT_TYPE': 'PERSON'},
{'ENT_TYPE': {'NOT_IN': ['ORG', 'PERSON']}, 'OP': '*'},
{'LOWER': {'IN': ceo_synonyms}}, {'TEXT': 'of'},
{'ENT_TYPE': {'NOT_IN': ['ORG', 'PERSON']}, 'OP': '*'},
{'ENT_TYPE': 'ORG'}]
matcher.add('executive-of', None, pattern)
pattern = [{'ENT_TYPE': 'ORG'},
{'LOWER': {'IN': ceo_synonyms}},
{'ENT_TYPE': 'PERSON'}]
matcher.add('rev-executive-of', None, pattern)
图 12-6. 从路透社语料库中提取的知识图,包括三个选定的子图(使用 Gephi 可视化)。
然后,我们定义一个函数来提取所有关系。我们的四种关系中,两种被匹配器覆盖,另外两种被基于依赖的匹配算法覆盖:
def extract_rels(doc):
yield from extract_rel_match(doc, matcher)
yield from extract_rel_dep(doc, 'acquires', acq_synonyms, ['to', 'from'])
yield from extract_rel_dep(doc, 'sells', ['sell'], ['to', 'from'])
提取关系的剩余步骤,将其转换为 NetworkX 图,并将图存储在 gexf 文件中供 Gephi 使用,基本上遵循“蓝图:创建共现图”。我们在这里跳过它们,但您将再次在 GitHub 存储库中找到完整的代码。
这里是最终数据框架的几条记录,包含了图的节点和边,正如它们被写入 gexf 文件的方式:
| 主体 | 主体类型 | 谓语 | 宾语 | 宾语类型 | 频率 | 文章 | |
|---|---|---|---|---|---|---|---|
| 883 | 泛美航空公司 | ORG | 收购 | 美国航空集团 | ORG | 7 | 2950,2948,3013,3095,1862,1836,7650 |
| 152 | 卡尔·伊坎 | PERSON | 担任执行者 | 泛美航空公司 | ORG | 3 | 1836,2799,3095 |
| 884 | 泛美航空公司 | ORG | 销售 | 美国航空集团 | ORG | 1 | 9487 |
用 Gephi 的帮助再次创建的图 12-6 中的路透社图的可视化效果。该图由许多相当小的组件(不连通的子图)组成;因为大多数公司只在一两篇新闻文章中被提及,我们仅提取了四种关系,因此简单的共现不包括在这里。我们在图中手动放大了其中三个子图。它们代表的是公司网络,这些网络已经在共现图中出现过(图 12-5),但现在我们知道了关系类型,并且得到了一个更清晰的图像。
不要盲目地信任结果
我们经历的每个处理步骤都有潜在的错误可能性。因此,存储在图中的信息并不完全可靠。事实上,这始于文章本身的数据质量。如果您仔细观察图 12-6 中左上角的示例,您会注意到图中出现了“富士通”和“Futjitsu”这两个实体。这实际上是原始文本中的拼写错误。
在图 12-6 右侧放大的子网络中,您可以发现表面上相互矛盾的信息:“皮德蒙特收购美国航空”和“美国航空收购皮德蒙特”。事实上,两者都是正确的,因为这两家企业都收购了对方的部分股份。但这也可能是其中一家相关规则或模型的错误。要追踪这种问题,有必要存储一些关于提取关系来源的信息。这就是为什么我们在每条记录中包含文章列表的原因。
最后,请注意我们的分析完全没有考虑到一个方面:信息的及时性。世界在不断变化,关系也在变化。因此,我们图中的每一条边都应该有时间戳。因此,要创建一个具有可信信息的知识库,仍然有很多工作要做,但我们的蓝图为开始提供了坚实的基础。
结语
在本章中,我们探讨了如何通过从非结构化文本中提取结构化信息来构建知识图谱。我们经历了信息提取的整个过程,从命名实体识别到通过指代消解到关系提取。
正如您所见,每一步都是一个挑战,我们总是在规则化方法和模型化方法之间做出选择。规则化方法的优势在于您无需训练数据。因此,您可以立即开始;您只需定义规则即可。但是,如果您尝试捕捉的实体类型或关系复杂难以描述,最终要么会得到过于简单并返回许多错误匹配的规则,要么是非常复杂且难以维护的规则。使用规则时,始终很难在召回率(找到大多数匹配项)和精确度(仅找到正确匹配项)之间找到良好的平衡。而且,您需要相当多的技术、语言和领域专业知识才能编写出好的规则。在实践中,您还必须测试和进行大量实验,直到您的规则足够强大以满足您的应用需求。
相比之下,基于模型的方法具有一个巨大的优势,即它们可以从训练数据中学习这些规则。当然,其缺点是您需要大量高质量的训练数据。如果这些训练数据特定于您的应用领域,那么您必须自己创建它们。在文本领域,手动标记训练数据尤其麻烦且耗时,因为有人必须先阅读和理解文本,然后才能设置标签。事实上,今天在机器学习领域,获得好的训练数据是最大的瓶颈。
缓解缺乏训练数据问题的一个可能解决方案是弱监督学习。其思想是通过像本章定义的规则或甚至通过程序生成它们的方式来创建一个大数据集。当然,由于规则并非完美,这个数据集会有噪音。但令人惊讶的是,我们可以在低质量数据上训练出高质量的模型。弱监督学习用于命名实体识别和关系提取,与本节中涵盖的许多其他主题一样,是当前的研究热点。如果您想了解更多关于信息提取和知识图谱创建的最新技术,可以查阅以下参考资料。它们为进一步阅读提供了良好的起点。
进一步阅读
-
Barrière, Caroline 的《语义网背景下的自然语言理解》。瑞士:斯普林格出版社。2016 年。https://www.springer.com/de/book/9783319413358。
-
Gao, Yuqing,Jisheng Liang,Benjamin Han,Mohamed Yakout 和 Ahmed Mohamed 的《构建大规模、准确且更新的知识图谱》。KDD 教程,2018 年。https://kdd2018tutorialt39.azurewebsites.net。
-
Han, Xu,Hao Zhu,Pengfei Yu,Ziyun Wang,Yuan Yao,Zhiyuan Liu 和 Maosong Sun 的《FewRel:一种大规模监督少样本关系分类数据集及其最新评估》。EMNLP 会议论文,2018 年。https://arxiv.org/abs/1810.10147。
-
Jurafsky, Dan 和 James H. Martin 的《语音与语言处理》。第 3 版(草案),第十八章和第二十二章。2019 年。https://web.stanford.edu/~jurafsky/slp3。
-
Lison, Pierre,Aliaksandr Hubin,Jeremy Barnes 和 Samia Touileb 的《无标注数据命名实体识别:弱监督方法》。ACL 会议论文,2020 年https://arxiv.org/abs/2004.14723。
^(1) 参见 Natasha Noy,Yuqing Gao,Anshu Jain,Anant Narayanan,Alan Patterson 和 Jamie Taylor 的《产业规模知识图谱:经验与挑战》。2019 年。https://queue.acm.org/detail.cfm?id=3332266。
^(2) 详情请见https://oreil.ly/nzhUR。
^(3) Tim Berners-Lee 等人,《语义网:对计算机有意义的新形式的 Web 内容将引发新的可能性革命》。《科学美国人》284 号 5 月 2001 年。
^(4) 星号操作符(*)将列表展开为print的单独参数。
^(5) 参见spaCy 的基于规则匹配的使用文档以了解语法的解释,并查看https://explosion.ai/demos/matcher上的交互式模式探索器。
^(6) 在本章的笔记本上,你将找到一个用于缩略语检测的额外蓝图,位于GitHub。
^(7) 更多详情请参见 Wolf(2017)的“Chatbots 的最新神经指代消解技术”。
^(8) 谷歌在 2012 年推出其知识图谱时提出了这一口号。
^(9) 你可以在本书的电子版和我们的GitHub 存储库中找到彩色插图。
^(10) 你可以在本章笔记本的 GitHub 上找到该图的 NetworkX 版本。
^(11) 更多详细信息请参阅我们本章的 GitHub 仓库。
^(12) 参见 最新技术概述。
^(13) 与依存分析器相反,成分分析器根据嵌套短语创建层次化的句子结构。
第十三章:在生产中使用文本分析
到目前为止,我们已经介绍了几个蓝图,并了解了它们在多个用例中的应用。任何分析或机器学习模型在其他人能够轻松使用时,才能发挥其最大价值。在本章中,我们将提供几个蓝图,让您可以共享我们早期章节中的文本分类器,并部署到云环境中,使任何人都能使用我们构建的内容。
假设您在本书的第十章中使用了一个蓝图来分析来自 Reddit 的各种汽车型号数据。如果您的同事对于使用同样的分析方法来研究摩托车行业感兴趣,修改数据源并重复使用代码应该是简单的。实际情况可能更为复杂,因为您的同事首先必须设置一个类似于您使用的环境,安装相同版本的 Python 和所有必需的软件包。他们可能使用的是不同的操作系统,安装步骤也会有所不同。或者考虑到您向分析的客户展示时非常满意,三个月后他们回来要求您覆盖更多行业。现在,您必须重复相同的分析,但确保代码和环境保持不变。这次分析的数据量可能更大,您的系统资源可能不足,促使您使用云计算资源。您必须在云服务提供商上执行安装步骤,这可能会迅速变得耗时。
您将学到什么,我们将构建什么
经常发生的情况是,您能够产生出色的结果,但由于其他希望使用它们的同事无法重新运行代码和复现结果,因此这些结果无法使用。在本章中,我们将向您展示一些技术,确保您的分析或算法可以轻松被任何人重复使用,包括您自己在以后的阶段。如果我们能够让其他人更轻松地使用我们分析的输出会怎样?这消除了一个额外的障碍,并增加了我们结果的可访问性。我们将向您展示如何将您的机器学习模型部署为简单的 REST API,允许任何人在其自己的工作或应用程序中使用您模型的预测。最后,我们将向您展示如何利用云基础设施实现更快的运行时或为多个应用程序和用户提供服务。由于大多数生产服务器和服务运行在 Linux 上,本章包含许多在 Linux shell 或终端中运行最佳的可执行命令和指令。但是,它们在 Windows PowerShell 中同样有效。
蓝图:使用 Conda 创建可重现的 Python 环境
本书介绍的蓝图使用 Python 和包生态系统来完成多个文本分析任务。与任何编程语言一样,Python 经常更新并支持多个版本。此外,像 Pandas、NumPy 和 SciPy 这样的常用包也有定期的发布周期,当它们升级到新版本时。尽管维护者们尽力确保新版本向后兼容,但有可能您去年完成的分析在最新版本的 Python 下无法运行。您的蓝图可能使用了最新版本库中已弃用的方法,这会导致分析无法重现,除非知道所使用库的版本。
假设您将蓝图以 Jupyter 笔记本或 Python 模块的形式与同事分享;当他们尝试运行时,可能会遇到如下常见错误:
import spacy
输出:
---------------------------------------------------------------------------
ModuleNotFoundError Traceback (most recent call last)
<ipython-input-1-76a01d9c502b> in <module>
----> 1 import spacy
ModuleNotFoundError: No module named 'spacy'
在大多数情况下,ModuleNotFoundError 可以通过手动使用命令 **pip install <module_name>** 安装所需的包来轻松解决。但想象一下,对于每个非标准包都要这样做!此命令还会安装最新版本,这可能不是您最初使用的版本。因此,确保可重复性的最佳方法是使用标准化的方式共享用于运行分析的 Python 环境。我们使用 conda 包管理器以及 Miniconda Python 发行版来解决这个问题。
注意
有几种方法可以解决创建和共享 Python 环境的问题,conda 只是其中之一。pip 是标准的 Python 包安装程序,随 Python 附带,并广泛用于安装 Python 包。venv 可用于创建虚拟环境,每个环境可以拥有其自己的 Python 版本和一组已安装的包。Conda 结合了包安装程序和环境管理器的功能,因此是我们首选的选项。重要的是要区分 conda 和 Anaconda/Miniconda 发行版。这些发行版包括 Python 和 conda,以及处理数据所需的基本包。虽然 conda 可以直接通过 pip 安装,但最简单的方法是安装 Miniconda,这是一个包含 conda、Python 和一些必需包的小型引导版本。
首先,我们必须按照以下步骤安装 Miniconda 发行版。这将创建一个基本安装,其中只包含 Python、conda 和一些基本软件包如pip、zlib等。现在,我们可以为每个项目创建单独的环境,其中只包含我们需要的软件包,并且与其他环境隔离开来。这很有用,因为您所做的任何更改,如安装额外的软件包或升级到不同的 Python 版本,不会影响任何其他项目或应用程序,因为它们使用自己的环境。可以通过以下命令执行此操作:
conda create -n env_name [list_of_packages]
执行前面的命令将使用 Miniconda 首次安装时可用的默认版本创建一个新的 Python 环境。让我们创建一个名为blueprints的环境,其中明确指定 Python 版本和要安装的附加软件包列表如下:
$ conda create -n blueprints numpy pandas scikit-learn notebook python=3.8
Collecting package metadata (current_repodata.json): - done
Solving environment: \ done
Package Plan
environment location: /home/user/miniconda3/envs/blueprints
added / updated specs:
- notebook
- numpy
- pandas
- python=3.8
- scikit-learn
The following packages will be downloaded:
package | build
---------------------------|-----------------
blas-1.0 | mkl 6 KB
intel-openmp-2020.1 | 217 780 KB
joblib-0.16.0 | py_0 210 KB
libgfortran-ng-7.3.0 | hdf63c60_0 1006 KB
mkl-2020.1 | 217 129.0 MB
mkl-service-2.3.0 | py37he904b0f_0 218 KB
mkl_fft-1.1.0 | py37h23d657b_0 143 KB
mkl_random-1.1.1 | py37h0573a6f_0 322 KB
numpy-1.18.5 | py37ha1c710e_0 5 KB
numpy-base-1.18.5 | py37hde5b4d6_0 4.1 MB
pandas-1.0.5 | py37h0573a6f_0 7.8 MB
pytz-2020.1 | py_0 184 KB
scikit-learn-0.23.1 | py37h423224d_0 5.0 MB
scipy-1.5.0 | py37h0b6359f_0 14.4 MB
threadpoolctl-2.1.0 | pyh5ca1d4c_0 17 KB
------------------------------------------------------------
Total: 163.1 MB
The following NEW packages will be INSTALLED:
_libgcc_mutex pkgs/main/linux-64::_libgcc_mutex-0.1-main
attrs pkgs/main/noarch::attrs-19.3.0-py_0
backcall pkgs/main/noarch::backcall-0.2.0-py_0
blas pkgs/main/linux-64::blas-1.0-mkl
bleach pkgs/main/noarch::bleach-3.1.5-py_0
ca-certificates pkgs/main/linux-64::ca-certificates-2020.6.24-0
(Output truncated)
执行完命令后,可以通过执行**conda activate <env_name>**来激活它,您会注意到命令提示符前缀带有环境名称。您可以进一步验证 Python 的版本与您指定的版本是否相同:
$ conda activate blueprints
(blueprints) $ python --version
Python 3.8
使用命令**conda env list**可以查看系统中所有环境的列表,如下所示。输出将包括基本环境,这是安装 Miniconda 时创建的默认环境。特定环境前的星号表示当前活动环境,在我们的例子中是刚刚创建的环境。请确保在制作蓝图时继续使用此环境:
(blueprints) $ conda env list
# conda environments:
#
base /home/user/miniconda3
blueprints * /home/user/miniconda3/envs/blueprints
conda确保每个环境可以拥有其自己版本的相同软件包,但这可能会增加存储成本,因为每个环境中将使用相同版本的每个软件包。使用硬链接在一定程度上可以减少这种影响,但在软件包使用硬编码路径的情况下可能无效。然而,我们建议在切换项目时创建另一个环境。但是,建议使用命令**conda remove --name <env_name> --all**删除未使用的环境是一个良好的实践。
这种方法的优点在于,当您想与他人共享代码时,可以指定应运行的环境。可以使用命令**conda env export > environment.yml**将环境导出为 YAML 文件。在运行此命令之前,请确保您处于所需的环境中(通过运行**conda activate <environment_name>**):
(blueprints) $ conda env export > environment.yml
(blueprints) $ cat environment.yml
name: blueprints
channels:
- defaults
dependencies:
- _libgcc_mutex=0.1=main
- attrs=19.3.0=py_0
- backcall=0.2.0=py_0
- blas=1.0=mkl
- bleach=3.1.5=py_0
- ca-certificates=2020.6.24=0
- certifi=2020.6.20=py37_0
- decorator=4.4.2=py_0
- defusedxml=0.6.0=py_0
- entrypoints=0.3=py37_0
- importlib-metadata=1.7.0=py37_0
- importlib_metadata=1.7.0=0
- intel-openmp=2020.1=217
- ipykernel=5.3.0=py37h5ca1d4c_0
(output truncated)
如输出所示,environment.yml 文件列出了环境中使用的所有包及其依赖关系。任何人都可以通过运行命令 **conda env create -f environment.yml** 使用此文件重新创建相同的环境。然而,此方法可能存在跨平台限制,因为 YAML 文件中列出的依赖关系特定于平台。因此,如果您在 Windows 系统上工作并导出了 YAML 文件,则在 macOS 系统上可能无法正常工作。
这是因为 Python 包所需的某些依赖项取决于平台。例如,Intel MKL 优化 是特定于某种架构的,可以用 OpenBLAS 库来替代。为了提供一个通用的环境文件,我们可以使用命令 **conda env export --from-history > environment.yml**,它只生成您明确请求的包的列表。运行此命令的输出如下,列出了我们在创建环境时安装的包。与先前的环境文件相比,前者还列出了像 attrs 和 backcall 这样的包,它们是 conda 环境的一部分,但并非我们请求的。当在新平台上使用此类 YAML 文件创建环境时,conda 将自动识别和安装默认包及其特定于平台的依赖关系。此外,将安装我们明确指定的包及其依赖项:
(blueprints) $ conda env export --from-history > environment.yml
(blueprints) $ cat environment.yml
name: blueprints
channels:
- defaults
dependencies:
- scikit-learn
- pandas
- notebook
- python=3.8
- numpy
prefix: /home/user/miniconda3/envs/blueprints
使用 --from-history 选项的缺点是创建的环境不是原始环境的复制品,因为基本包和依赖项是特定于平台的,因此不同。如果要使用此环境的平台与创建环境的平台相同,则不建议使用此选项。
蓝图:使用容器创建可复制的环境
虽然像 conda 这样的包管理器帮助安装多个包并管理依赖关系,但仍有几个特定于平台的二进制文件可能会妨碍可复制性。为了简化事务,我们利用了一个称为 容器 的抽象层。这个名字来源于航运业,标准尺寸的航运集装箱用于通过船舶、卡车和铁路运输各种商品。无论物品类型或运输方式如何,集装箱确保任何遵循该标准的人都可以运输这些物品。同样地,我们使用 Docker 容器来标准化我们工作的环境,并确保每次重新创建时都能生成相同的环境,无论在哪里运行或由谁运行。Docker 是实现此功能的最流行工具之一,我们将在本蓝图中使用它。图 13-1 显示了 Docker 的工作高级概述。
图 13-1. Docker 的工作流程。
我们需要从下载链接安装 Docker。一旦设置完成,请在命令行中运行 sudo docker run hello-world 来测试一切是否设置正确,您应该看到如下输出。请注意,Docker 守护程序绑定到一个 Unix 套接字,由 root 用户拥有,因此需要使用 sudo 运行所有命令。如果无法提供 root 访问权限,您也可以尝试一个实验版本的 Docker:
$ sudo docker run hello-world
Hello from Docker!
This message shows that your installation appears to be working correctly.
To generate this message, Docker took the following steps:
1\. The Docker client contacted the Docker daemon.
2\. The Docker daemon pulled the "hello-world" image from the Docker Hub.
(amd64)
3\. The Docker daemon created a new container from that image which runs the
executable that produces the output you are currently reading.
4\. The Docker daemon streamed that output to the Docker client, which sent it
to your terminal.
To try something more ambitious, you can run an Ubuntu container with:
$ docker run -it ubuntu bash
Share images, automate workflows, and more with a free Docker ID:
https://hub.docker.com/
For more examples and ideas, visit:
https://docs.docker.com/get-started/
我们可以将构建 Docker 容器与购买汽车类比。我们从预配置选项中选择开始。这些配置已经选择了一些组件,例如引擎类型(排量、燃料类型)、安全功能、设备水平等。我们可以自定义许多这些组件,例如升级到更节能的引擎或添加额外的组件,如导航系统或加热座椅。最后,我们确定我们喜欢的配置并订购汽车。类似地,我们在 Dockerfile 中指定我们想要创建的环境配置。这些配置以一组顺序执行的指令形式描述,结果是创建一个 Docker 镜像。Docker 镜像就像根据我们的 Dockerfile 创建的首选汽车配置。所有 Docker 镜像都是可扩展的,因此我们可以扩展现有的 Docker 镜像并通过添加特定步骤来自定义它。运行 Docker 镜像的最后一步会创建一个 Docker 容器,这就像根据您的首选配置交付给您的汽车一样。在这种情况下,它是一个包含操作系统及其它工具和软件包的完整环境,如在 Dockerfile 中指定的。它在硬件上运行并使用主机系统提供的接口,但与主机系统完全隔离。事实上,它是根据您设计的方式运行的服务器的最小版本。从相同镜像实例化的每个 Docker 容器无论在哪个主机系统上运行都将是相同的。这很强大,因为它允许您封装您的分析和环境,并在笔记本电脑、云端或组织的服务器上运行并期望相同的行为。
我们将创建一个 Docker 镜像,其中包含与我们分析中使用的相同的 Python 环境,这样其他人可以通过拉取镜像并实例化为容器来复现我们的分析。虽然我们可以从头开始指定我们的 Docker 镜像,但最好是从现有的镜像开始,并定制其中的某些部分来创建我们自己的版本。这样的镜像称为父镜像。一个好地方去搜索父镜像是Docker Hub 注册表,这是一个包含预构建 Docker 镜像的公共仓库。您会找到官方支持的镜像,如Jupyter 数据科学笔记本,以及像我们为第九章创建的用户创建的镜像。Docker 仓库中的每个镜像也可以直接用来运行容器。您可以使用**sudo docker search**命令搜索镜像,并添加参数以格式化结果,如下所示,在这里我们搜索可用的 Miniconda 镜像:
$ sudo docker search miniconda
NAME STARS
continuumio/miniconda3 218
continuumio/miniconda 77
conda/miniconda3 35
conda/miniconda3-centos7 7
yamitzky/miniconda-neologd 3
conda/miniconda2 2
atavares/miniconda-rocker-geospatial 2
我们看到有一个 Miniconda3 的镜像,这将是我们自己的 Dockerfile 的一个很好的起点。请注意,所有 Dockerfile 都必须以FROM关键字开头,指定它们派生自哪个镜像。如果您从头开始指定一个 Dockerfile,那么您将使用FROM scratch关键字。Miniconda 镜像和 Dockerfile 的详细信息显示了该镜像如何派生自一个 Debian 父镜像,并且只添加额外的步骤来安装和设置 conda 包管理器。在使用父 Docker 镜像时,检查它来自可信的来源非常重要。Docker Hub 提供了额外的标准,如“官方镜像”,有助于识别官方来源。
让我们按照我们的 Dockerfile 中定义的步骤来进行。我们从 Miniconda3 镜像开始,然后添加一个步骤来创建我们的自定义环境。我们使用ARG指令来指定我们 conda 环境的名称参数(blueprints)。然后,我们使用ADD将environment.yml文件从构建上下文复制到镜像中。最后,通过将**conda create**命令作为RUN的参数来创建 conda 环境:
FROM continuumio/miniconda3
# Add environment.yml to the build context and create the environment
ARG conda_env=blueprints
ADD environment.yml /tmp/environment.yml
RUN conda env create -f /tmp/environment.yml
在接下来的步骤中,我们希望确保环境在容器中被激活。因此,我们将其添加到.bashrc脚本的末尾,这将在容器启动时始终运行。我们还使用ENV指令更新PATH环境变量,以确保 conda 环境是容器内部到处使用的 Python 版本:
# Activating the environment and starting the jupyter notebook
RUN echo "source activate ${conda_env}" > ~/.bashrc
ENV PATH /opt/conda/envs/${conda_env}/bin:$PATH
在最后一步,我们希望自动启动一个 Jupyter 笔记本,以便此 Docker 容器的用户可以以交互方式运行分析。我们使用ENTRYPOINT指令来配置将作为可执行文件运行的容器。Dockerfile 中只能有一个这样的指令(如果有多个,则只有最后一个有效),它将是容器启动时要运行的最后一个命令,通常用于启动像我们要运行的 Jupyter 笔记本这样的服务器。我们指定额外的参数来在容器本身的 IP 地址(0.0.0.0)上运行服务器,在特定端口(8888)上以 root 用户身份运行(--allow-root),并且默认不在浏览器中打开(--no-browser)。当容器启动时,我们不希望它在其浏览器中打开 Jupyter 服务器。相反,我们将使用指定的端口将主机机器连接到此容器,并通过那里的浏览器访问它:
# Start jupyter server on container
EXPOSE 8888
ENTRYPOINT ["jupyter","notebook","--ip=0.0.0.0", \
"--port=8888","--allow-root","--no-browser"]
我们使用docker build命令根据我们的 Dockerfile 创建镜像。我们使用-t参数指定镜像的名称,并添加用户名和镜像名称。这在以后要引用镜像时非常有用。虽然没有必要指定用户名,但稍后我们会看到这样做的好处。在构建镜像时使用的 Dockerfile 由-f参数指定。如果未指定任何内容,则 Docker 将选择指定PATH参数所在目录中名为Dockerfile的文件。PATH参数还指定了在 Docker 守护程序上构建过程的“上下文”中查找文件的位置。此目录中的所有文件都将在构建过程中被打包为tar文件并发送到守护程序。这些文件必须包括所有要添加到镜像中的文件和工件,例如将复制到镜像以创建 conda 环境的environment.yml文件。
docker build -t username/docker_project -f Dockerfile [PATH]
执行此命令时,Docker 守护程序开始通过运行 Dockerfile 中指定的步骤来创建镜像。通常,您会在已经包含所有文件和 Dockerfile 的同一目录中执行该命令。我们使用.来引用当前目录指定PATH参数:
$ sudo docker build -t textblueprints/ch13:v1 .
Sending build context to Docker daemon 5.363MB
Step 1/8 : FROM continuumio/miniconda3
---> b4adc22212f1
Step 2/8 : ARG conda_env=blueprints
---> 959ed0c16483
Step 3/8 : ADD environment.yml /tmp/environment.yml
---> 60e039e09fa7
Step 4/8 : RUN conda env create -f /tmp/environment.yml
---> Running in 85d2f149820b
Collecting package metadata (repodata.json): ...working... done
Solving environment: ...working... done
Downloading and Extracting Packages
(output truncated)
Removing intermediate container 85d2f149820b
Step 5/8 : RUN echo "source activate ${conda_env}" > ~/.bashrc
---> e0ed2b448211
Step 6/8 : ENV PATH /opt/conda/envs/${conda_env}/bin:$PATH
---> 7068395ce2cf
Step 7/8 : EXPOSE 8888
---> Running in f78ac4aa0569
Removing intermediate container f78ac4aa0569
---> 06cfff710f8e
Step 8/8 : ENTRYPOINT ["jupyter","notebook","--ip=0.0.0.0",
"--port=8888","--allow-root","--no-browser"]
---> Running in 87852de682f4
Removing intermediate container 87852de682f4
---> 2b45bb18c071
Successfully built 2b45bb18c071
Successfully tagged textblueprints/ch13:v1
构建完成后,可以通过运行命令**sudo docker images**来检查镜像是否成功创建。你会注意到已经下载了continuumio/miniconda3镜像,并且还创建了包含你的用户名和docker_project指定的镜像。第一次构建 Docker 会花费更长时间,因为需要下载父镜像,但后续的更改和重建会快得多:
$ sudo docker images
REPOSITORY TAG IMAGE ID
textblueprints/ch13 v1 83a05579afe6
jupyter/minimal-notebook latest d94723ae86d1
continuumio/miniconda3 latest b4adc22212f1
hello-world latest bf756fb1ae65
我们可以通过运行以下命令创建这个环境的运行实例,也称为容器:
docker run -p host_port:container_port username/docker_project:tag_name
-p 参数允许端口转发,基本上将接收到的任何请求发送到 host_port 到 container_port。默认情况下,Jupyter 服务器只能访问容器内的文件和目录。但是,我们希望从运行在容器内部的 Jupyter 服务器中访问本地目录中的 Jupyter 笔记本和代码文件。我们可以使用 -v host_volume:container_volume 将本地目录附加到容器作为卷,这将在容器内创建一个新目录,指向一个本地目录。这样做可以确保当容器关闭时,对 Jupyter 笔记本的任何更改都不会丢失。这是在本地使用 Docker 容器进行工作的推荐方法。让我们通过运行以下命令启动我们的 Docker 容器:
sudo docker run -p 5000:8888 -v \
/home/user/text-blueprints/ch13/:/work textblueprints/ch13:v1
Out:
[NotebookApp] Writing notebook server cookie secret to
/root/.local/share/jupyter/runtime/notebook_cookie_secret
[NotebookApp] Serving notebooks from local directory: /
[NotebookApp] The Jupyter Notebook is running at:
[NotebookApp] http://aaef990b90a3:8888/?token=xxxxxx
[NotebookApp] or http://127.0.0.1:8888/?token=xxxxxx
[NotebookApp] Use Control-C to stop this server and shut down all kernels
(twice to skip confirmation).
[NotebookApp]
To access the notebook, open this file in a browser:
file:///root/.local/share/jupyter/runtime/nbserver-1-open.html
Or copy and paste one of these URLs:
http://aaef990b90a3:8888/?token=xxxxxx
or http://127.0.0.1:8888/?token=xxxxxx
现在您看到的日志实际上是在容器内端口 8888 上启动的 Jupyter 服务器的日志。由于我们已将主机端口映射到 5000,您可以复制 URL 并仅将端口号更改为 5000 以访问 Jupyter 服务器。您还将在这里找到一个名为 work 的目录,其中应包含来自映射的本地目录中的所有文件。您还可以通过运行命令 **sudo docker container ps** 检查所有运行中的容器的状态。我们还可以使用 --name argument 为每个运行的容器指定名称,如果不使用此选项,则 Docker 守护程序将分配一个随机创建的名称,如您在此处看到的:
$ sudo docker container ls
CONTAINER ID IMAGE STATUS NAMES
862e5b0570fe textblueprints/ch13:v1 Up About a minute musing_chaum
如果您退出运行此命令的终端窗口,则容器也将关闭。要在分离模式下运行它,只需在运行命令中添加 -d 选项。容器启动时,将打印已启动容器的容器 ID,并且您可以使用 sudo docker logs <container-id> 监视日志。我们已在此 Docker 容器中复制了用于运行分析的完整环境,在下一个蓝图中,让我们看看分享它的最佳技术。
将此镜像与任何人共享的最简单方法是将其推送到 Docker Hub 注册表。您可以注册一个免费帐户。Docker Hub 是 Docker 镜像的公共存储库,每个镜像都由用户名、镜像名称和标签唯一标识。例如,我们用作父镜像的miniconda3软件包被标识为continuumio/miniconda3:latest,您分享的任何镜像将用您的用户名标识。因此,在之前构建镜像时,指定的用户名必须与登录 Docker Hub 时使用的用户名相同。创建凭据后,您可以单击创建存储库,并选择一个名称并为存储库提供描述。在我们的情况下,我们创建了一个名为"ch13"的存储库,将包含本章的 Docker 镜像。完成后,您可以使用命令**sudo docker login**登录,并输入您的用户名和密码。为增加安全性,请按照说明安全地存储您的密码。
注意
默认情况下,在构建 Docker 镜像的过程中,PATH参数指定的所有目录和文件都是构建上下文的一部分。在前面的命令中,我们使用.符号指定路径为当前目录,但这是不必要的,因为我们只需要包含构建和容器所需的选择文件列表。例如,我们需要environment.yml但不需要 Jupyter 笔记本(.ipynb)文件。重要的是要在.dockerignore文件中指定排除的文件列表,以确保不希望的文件不会自动添加到容器中。我们的.dockerignore文件如下所示:
.git
.cache
figures
**/*.html
**/*.ipynb
**/*.css
另一件需要确保的事情是host_port(在蓝图中指定为 5000)是开放的,且系统中没有其他应用程序在使用。理想情况下,您应使用 1024 到 49151 之间的端口号作为user ports,但您也可以轻松地通过运行命令**sudo ss -tulw**来检查这一点,该命令将提供已使用端口的列表。
下一步是标记您希望与 tag_name 一起分享的图像,以识别其包含内容。在我们的情况下,我们使用 v1 标记图像,以表示这是本章的第一个版本。我们运行命令 sudo docker tag 2b45bb18c071 textblueprints/ch13:v1,其中 2b45bb18c071 是图像 ID。现在我们可以使用命令 sudo docker push textblueprints/ch13 推送我们的文件。现在任何想要运行您项目的人都可以简单地运行命令 docker pull your_username/docker_project:tag_name 来创建与您相同的环境,无论他们可能在个人工作中使用的系统如何。例如,您可以通过简单运行命令 docker pull textblueprints/ch09:v1 开始在 第九章 中的蓝图工作。然后,您可以附加包含克隆存储库的目录的卷。Docker Hub 是一个流行的公共注册表,并且在 Docker 中默认配置,但每个云提供商也有他们自己的版本,许多组织为他们内部应用程序和团队设置了私有注册表。
当使用具有多个科学计算包的 conda 环境时,Docker 镜像可能会变得很大,因此在将其推送到 Docker Hub 时可能会对带宽造成压力。一个更有效的方法是在存储库的基本路径中包含 Dockerfile。例如,包含本章代码的 GitHub 存储库包含一个 Dockerfile,该文件可以用于创建运行代码所需的精确环境。这个蓝图轻松地允许您将分析从本地系统移动到具有额外资源的云机器上,重新创建相同的工作环境。当数据大小增加或分析时间过长时,这尤其有用。
蓝图:为您的文本分析模型创建 REST API
假设您使用了提供在 第十一章 的蓝图来分析您组织中客户支持票的情绪。您的公司正在进行一项提高客户满意度的活动,他们希望向不满意的客户提供优惠券。技术团队的同事联系您寻求帮助自动化此活动。虽然他们可以拉取 Docker 容器并重现您的分析,但他们更倾向于一种更简单的方法,即提供支持票的文本并获取是否为不满意客户的响应。通过将我们的分析封装在一个 REST API 中,我们可以创建一个简单的方法,任何人都可以访问,而不需要重新运行蓝图。他们甚至不一定需要知道 Python,因为 REST API 可以从任何语言调用。在 第二章 中,我们使用了流行网站提供的 REST API 来提取数据,而在这个蓝图中,我们将创建我们自己的 REST API。
我们将利用以下三个组件来托管我们的 REST API:
-
FastAPI:一个用于构建 API 的快速 Web 框架
-
Gunicorn:处理所有传入请求的 Web 服务网关接口服务器
-
Docker:扩展我们在之前蓝图中使用的 Docker 容器
让我们创建一个名为 app 的新文件夹,我们将在其中放置所有我们需要用来提供情感预测的代码。它将遵循以下目录结构,并包含如下文件。main.py 是我们将创建 FastAPI 应用程序和情感预测方法的地方,preprocessing.py 包含我们的辅助函数。models 目录包含我们需要用来计算预测的训练模型,即 sentiment_vectorizer 和 sentiment_classification。最后,我们有 Dockerfile、environment.yml 和 start_script.sh,它们将用于部署我们的 REST API:
├── app
│ ├── main.py
│ ├── Dockerfile
│ ├── environment.yml
│ ├── models
│ │ ├── sentiment_classification.pickle
│ │ └── sentiment_vectorizer.pickle
│ ├── preprocessing.py
│ └── start_script.sh
FastAPI 是一个快速的 Python 框架,用于构建 API。它能够将来自 Web 服务器的请求重定向到 Python 中定义的特定函数。它还负责根据指定的模式验证传入的请求,并且非常适合创建简单的 REST API。我们将在这个 API 中封装我们在 第十一章 中训练的模型的 predict 函数。让我们逐步解释 main.py 文件中的代码,并说明其工作原理。您可以通过运行 pip install fastapi 安装 FastAPI,并通过运行 pip install gunicorn 安装 Gunicorn。
安装了 FastAPI 后,我们可以使用以下代码创建一个应用:
from fastapi import FastAPI
app = FastAPI()
FastAPI 库使用包含的 Web 服务器运行此应用程序,并且可以将接收到的请求路由到 Python 文件中的方法。这通过在函数定义的开头添加 @app.post 属性来指定。我们指定端点为 api/v1/sentiment,我们 Sentiment API 的第一个版本,它接受 HTTP POST 请求。API 可以随时间演变,功能发生变化,将它们分隔成不同的版本是有用的,以确保旧版本的用户不受影响:
class Sentiment(Enum):
POSITIVE = 1
NEGATIVE = 0
@app.post("/api/v1/sentiment", response_model=Review)
def predict(review: Review, model = Depends(load_model())):
text_clean = preprocessing.clean(review.text)
text_tfidf = vectorizer.transform([text_clean])
sentiment = prediction_model.predict(text_tfidf)
review.sentiment = Sentiment(sentiment.item()).name
return review
predict 方法从输入中检索文本字段,并执行预处理和向量化步骤。它使用我们之前训练的模型来预测产品评论的情感。返回的情感被指定为 Enum 类,以限制 API 可能的返回值。输入参数 review 被定义为 Review 类的一个实例。该类如下所示,包含评论文本作为必填字段,还有 reviewerID、productID 和 sentiment。FastAPI 使用 “type hints” 来猜测字段的类型 (str) 并执行必要的验证。正如我们将看到的,FastAPI 会根据 OpenAPI 规范自动生成我们 API 的 web 文档,通过这些文档可以直接测试 API。我们添加了 schema_extra 作为一个示例,以充当开发人员使用该 API 的指南:
class Review(BaseModel):
text: str
reviewerID: Optional[str] = None
asin: Optional[str] = None
sentiment: Optional[str] = None
class Config:
schema_extra = {
"example": {
"text": "This was a great purchase, saved me much time!",
"reviewerID": "A1VU337W6PKAR3",
"productID": "B00K0TIC56"
}
}
你可能已经注意到函数定义中使用了 Depends 关键字。这允许我们在调用函数之前加载必需的依赖项或其他资源。这被视为另一个 Python 函数,并在此定义:
def load_model():
try:
print('Calling Depends Function')
global prediction_model, vectorizer
prediction_model = pickle.load(
open('models/sentiment_classification.pickle', 'rb'))
vectorizer = pickle.load(open('models/tfidf_vectorizer.pickle', 'rb'))
print('Models have been loaded')
except Exception as e:
raise ValueError('No model here')
注意
Pickle 是 Python 序列化框架之一,是模型保存/导出的常见方式之一。其他标准化格式包括 joblib 和 ONNX。一些深度学习框架使用它们自己的导出格式。例如,TensorFlow 使用 SavedModel,而 PyTorch 使用 pickle,但实现了自己的 save() 函数。根据你使用的模型保存/导出类型,适应加载和预测函数非常重要。
在开发过程中,FastAPI 可以与任何 Web 服务器一起运行(例如 uvicorn),但建议使用成熟的、支持多个工作线程的完整的 Web 服务网关接口(WSGI)服务器。我们选择使用 Gunicorn 作为我们的 WSGI 服务器,因为它提供了一个可以接收请求并重定向到 FastAPI 应用的 HTTP 服务器。
安装后,可以输入以下命令运行它:
gunicorn -w 3 -b :5000 -t 5 -k uvicorn.workers.UvicornWorker main:app
-w 参数用于指定要运行的工作进程数量,本例中为三个工作者。-b 参数指定了 WSGI 服务器监听的端口,-t 表示超时值,超过五秒后服务器将杀死并重新启动应用程序,以防止其无响应。-k 参数指定了要调用以运行应用程序的工作者类的实例(uvicorn),通过引用 Python 模块(main)和名称(app)来指定。
在部署 API 之前,我们必须重新检查 environment.yml 文件。在第一版本中,我们描述了生成和分享 environment.yml 文件的方法,以确保你的分析可复现。然而,在将代码部署到生产环境时,不建议使用此方法。虽然导出的 environment.yml 文件是一个起点,但我们必须手动检查并确保它不包含未使用的包。还重要的是指定包的确切版本号,以确保包更新不会干扰你的生产部署。我们使用一个名为 Vulture 的 Python 代码分析工具,它可以识别未使用的包以及其他死代码片段。让我们为 app 文件夹运行此分析:
vulture app/
输出:
app/main.py:11: unused variable 'POSITIVE' (60% confidence)
app/main.py:12: unused variable 'NEGATIVE' (60% confidence)
app/main.py:16: unused variable 'reviewerID' (60% confidence)
app/main.py:17: unused variable 'asin' (60% confidence)
app/main.py:20: unused class 'Config' (60% confidence)
app/main.py:21: unused variable 'schema_extra' (60% confidence)
app/main.py:40: unused variable 'model' (100% confidence)
app/main.py:44: unused attribute 'sentiment' (60% confidence)
app/preprocessing.py:30: unused import 'spacy' (90% confidence)
app/preprocessing.py:34: unused function 'display_nlp' (60% confidence)
除了潜在问题列表,Vulture 还提供了置信度分数。请使用识别出的问题作为检查这些导入的指针。在上一个示例中,我们知道我们定义的类变量用于验证 API 的输入并且肯定被使用。我们可以看到,即使 spacy 和 display_nlp 是预处理模块的一部分,但它们并未在我们的应用程序中使用。我们可以选择将它们和相应的依赖项从 YAML 文件中删除。
您还可以通过运行 **conda list** 命令确定 conda 环境中每个软件包的版本,然后使用此信息创建最终清理后的环境 YAML 文件,如下所示:
name: sentiment-app
channels:
- conda-forge
dependencies:
- python==3.8
- fastapi==0.59.0
- pandas==1.0.5
- scikit-learn==0.23.2
- gunicorn==20.0.4
- uvicorn==0.11.3
作为最后一步,我们可以将 API Docker 化,以便更容易地在其自己的容器中运行整个应用程序,特别是当我们想要在云提供商上托管它时,正如我们将在下一个蓝图中看到的那样。我们对比之前的蓝图在 Dockerfile 中进行了两个更改,如下所示:
# Copy files required for deploying service to app folder in container
COPY . /app
WORKDIR /app
使用上述指令将当前 app 文件夹的所有内容复制到 Docker 镜像中,其中包含部署和运行 REST API 所需的所有文件。然后使用 WORKDIR 指令将容器中的当前目录更改为 app 文件夹:
# Start WSGI server on container
EXPOSE 5000
RUN ["chmod", "+x", "start_script.sh"]
ENTRYPOINT [ "/bin/bash", "-c" ]
CMD ["./start_script.sh"]
然后,我们提供了运行 WSGI 服务器的步骤,首先在容器上公开端口 5000。接下来,我们在 start_script 上启用权限,以便 Docker 守护程序在容器启动时执行它。我们使用 ENTRYPOINT(用于启动要运行脚本的 bash shell)和 CMD(用于将实际脚本指定为传递给 bash shell 的参数)的组合,它激活 conda 环境并启动 Gunicorn 服务器。由于我们在 Docker 容器中运行服务器,我们做了一个小改变,指定将 access-logfile 写入到 STDOUT (-) 以确保我们仍然可以查看它们:
#!/bin/bash
source activate my_env_name
GUNICORN_CMD_ARGS="--access-logfile -" gunicorn -w 3 -b :5000 -t 5 \
-k uvicorn.workers.UvicornWorker main:app -
我们构建 Docker 镜像并按照之前蓝图中的相同步骤运行它。这将导致一个正在运行的 Docker 容器,其中 Gunicorn WSGI 服务器正在运行 FastAPI 应用程序。我们必须确保从容器所在的主机系统转发一个端口:
$ sudo docker run -p 5000:5000 textblueprints/sentiment-app:v1
[INFO] Starting gunicorn 20.0.4
[INFO] Listening at: http://0.0.0.0:5000 (11)
[INFO] Using worker: sync
[INFO] Booting worker with pid: 14
我们可以从不同的程序调用正在运行 API 的容器。在单独的终端窗口或 IDE 中,创建一个测试方法,调用 API 并传入一个样本评论以检查响应。我们使用本地 IP 以端口 5000 发起调用,该端口被转发到容器的端口 5000,从中我们接收响应,如下所示:
import requests
import json
url = 'http://0.0.0.0:5000/api/v1/sentiment'
data = {
'text':
'I could not ask for a better system for my small greenhouse, \
easy to set up and nozzles do very well',
'reviewerID': 'A1VU337W6PKAR3',
'productID': 'B00K0TIC56'
}
input_data = json.dumps(data)
headers = {'content-type': 'application/json', 'Accept-Charset': 'UTF-8'}
r = requests.post(url, data=input_data, headers=headers)
print(r.text)
Out:
{
"prediction": "POSITIVE"
}
我们可以看到我们的 API 生成了预期的响应。让我们也检查一下这个 API 的文档,可以在http://localhost:5000/docs找到。它应该生成一个页面,如图 13-2 所示,并且点击我们的 /api/v1/sentiment 方法的链接将提供关于如何调用该方法的额外细节,并且还有尝试的选项。这使得其他人可以提供不同的文本输入并查看 API 生成的结果,而无需编写任何代码。
Docker 容器始终以非特权模式启动,这意味着即使有终端错误,它也只会限制在容器内部,而不会对主机系统产生任何影响。因此,我们可以安全地在容器内部以根用户身份运行服务器,而不必担心对主机系统造成影响。
图 13-2. 由 FastAPI 提供的 API 规范和测试。
您可以运行前面讨论的 sudo docker tag 和 sudo docker push 命令的组合来共享 REST API。您的同事可以轻松地拉取此 Docker 镜像来运行 API,并使用它识别不满意的客户通过提供他们的支持票证。在下一个蓝图中,我们将在云服务提供商上运行 Docker 镜像,并使其在互联网上可用。
蓝图:使用云服务提供者部署和扩展您的 API
部署机器学习模型并监视其性能是一项复杂的任务,并包括多种工具选项。这是一个不断创新的领域,不断努力使数据科学家和开发人员更容易。有几个云服务提供商和多种部署和托管 API 的方式。本蓝图介绍了一种简单的方式来部署我们在之前蓝图中创建的 Docker 容器,使用 Kubernetes。Kubernetes 是一个开源技术,提供功能来部署和管理 Docker 容器到任何底层物理或虚拟基础设施。在本蓝图中,我们将使用 Google Cloud Platform (GCP),但大多数主要提供商都支持 Kubernetes。我们可以直接将 Docker 容器部署到云服务,并使 REST API 对任何人都可用。但是,我们选择在 Kubernetes 集群内部部署它,因为这样可以轻松地扩展和缩小部署。
你可以在 GCP 上注册一个免费帐户。通过与云服务提供商的注册,你可以租用第三方提供的计算资源,并需要提供你的计费详情。在此蓝图期间,我们将保持在免费层限制内,但重要的是要密切关注你的使用情况,以确保你没有因忘记关闭某些云资源而被收费!完成注册流程后,你可以通过访问 GCP 控制台 的计费部分进行检查。在使用此蓝图之前,请确保你有一个包含 REST API 的 Docker 镜像,并已将其推送并可用于 Docker Hub 或任何其他容器注册表中。
让我们从理解如何部署 REST API 开始,如 图 13-3 所示。我们将使用 GCP 创建一个可扩展的计算集群。这只是由称为 节点 的个体服务器集合。所示的计算集群有三个这样的节点,但可以根据需要进行扩展。我们将使用 Kubernetes 将 REST API 部署到集群的每个节点上。假设我们从三个节点开始,这将创建三个 Docker 容器的副本,每个运行在一个节点上。这些容器仍未暴露给互联网,我们利用 Kubernetes 运行负载均衡器服务,该服务提供了通往互联网的网关,并根据其利用率重定向请求到每个容器。除了简化我们的部署流程外,使用 Kubernetes 还可以自动创建额外的实例来处理节点故障和流量波动。
图 13-3. Kubernetes 架构图。
让我们在 GCP 中创建一个项目,用于我们的部署。访问 Google Cloud,选择右上角的创建项目选项,并使用你选择的名称创建一个项目(我们选择情感 REST API)。项目创建完成后,点击左上角的导航菜单,进入名为 Kubernetes Engine 的服务,如 图 13-4 所示。你需要点击启用计费链接,并选择在注册时设置的付款账户。你也可以直接点击计费选项卡,并为你的项目设置计费信息。假设你正在使用免费试用运行此蓝图,你将不会被收费。在此之后,需要几分钟来为我们的项目启用此功能。完成后,我们就可以继续进行部署了。
图 13-4. 在 GCP 控制台的 Kubernetes Engine 选项中启用计费。
我们可以继续使用 Google Cloud Platform 的Web 控制台或命令行工具进行工作。尽管提供的功能保持不变,但为了简洁起见并使您能够复制命令,我们选择在蓝图中使用命令行界面描述步骤。请按照说明安装 Google Cloud SDK,然后运行以下命令使用 Kubernetes 命令行工具:
gcloud components install kubectl
在新的终端窗口中,我们首先通过运行**gcloud auth login**来验证我们的用户账户。这将打开浏览器并将您重定向到 Google 认证页面。完成后,在此终端窗口中不会再次询问您。我们配置项目和计算区域,选择我们刚刚创建的项目,并从所有可用选项中选择靠近您的位置;我们选择了 us-central1-a:
gcloud config set project sentiment-rest-api
gcloud config set compute/zone us-central1-a
我们的下一步是创建一个 Google Kubernetes Engine 计算集群。这个计算集群将用于部署我们的 Docker 容器。让我们创建一个包含三个节点的集群,并请求一个类型为 n1-standard-1 的机器。这种类型的机器配备了 3.75GB 的 RAM 和 1 个 CPU。我们可以请求更强大的机器,但对于我们的 API 来说,这应该足够了:
gcloud container clusters create \ sentiment-app-cluster --num-nodes 3 \
--machine-type n1-standard-1
GCP 中的每个容器集群都配备了HorizontalPodAutoscaling,它负责监控 CPU 利用率并在需要时添加机器。请求的机器将被配置并分配给集群,一旦执行完毕,您可以通过运行gcloud compute instances list来验证正在运行的计算实例:
$ gcloud compute instances list
NAME ZONE MACHINE_TYPE STATUS
gke-sentiment-app-cluste-default-pool us-central1-a n1-standard-1 RUNNING
gke-sentiment-app-cluste-default-pool us-central1-a n1-standard-1 RUNNING
gke-sentiment-app-cluste-default-pool us-central1-a n1-standard-1 RUNNING
现在我们的集群已经启动并运行,我们将通过 Kubernetes 将我们在前一个蓝图中创建的 Docker 镜像部署到这个集群。我们的 Docker 镜像在 Docker Hub 上可用,并通过username/project_name:tag唯一标识。我们通过以下命令将我们的部署命名为sentiment-app:
kubectl create deployment sentiment-app --image=textblueprints/sentiment-app:v0.1
一旦启动,我们可以通过命令kubectl get pods确认它正在运行,这将显示我们有一个正在运行的 pod。在这里,一个 pod 类似于一个容器;换句话说,一个 pod 等同于提供的镜像的一个运行中的容器。但是,我们有一个三节点集群,我们可以轻松地部署更多我们的 Docker 镜像实例。让我们使用以下命令将其扩展到三个副本:
kubectl scale deployment sentiment-app --replicas=3
您可以验证其他 Pod 是否已经开始运行了。有时,由于容器部署到集群中的节点,可能会有延迟,并且您可以使用命令kubectl describe pods找到详细信息。通过拥有多个副本,我们使我们的 REST API 在发生故障时仍然可以持续可用。例如,假设其中一个 Pod 因错误而停机;仍将有两个实例提供 API。Kubernetes 还将自动在发生故障时创建另一个 Pod 以维护所需的状态。这也是因为 REST API 是无状态的,并且在其他情况下需要额外的故障处理。
虽然我们已经部署并扩展了 REST API,但我们尚未将其提供给互联网。在这最后一步中,我们将添加一个名为sentiment-app-loadbalancer的LoadBalancer服务,它作为 HTTP 服务器将 REST API 暴露给互联网,并根据流量将请求定向到三个 Pod。重要的是要区分参数port,这是LoadBalancer暴露的端口,和target-port,这是每个容器暴露的端口:
kubectl expose deployment sentiment-app --name=sentiment-app-loadbalancer
--type=LoadBalancer --port 5000 --target-port 5000
如果运行kubectl get service命令,它将提供运行的所有 Kubernetes 服务的列表,包括sentiment-app-loadbalancer。需要注意的参数是EXTERNAL-IP,它可以用于访问我们的 API。可以使用链接http://[EXTERNAL-IP]:5000/apidocs访问sentiment-app,该链接将提供 Swagger 文档,并且可以向http://[EXTERNAL-IP]:5000/api/v1/sentiment发出请求:
$ kubectl expose deployment sentiment-app --name=sentiment-app-loadbalancer \
--type=LoadBalancer --port 5000 --target-port 5000
service "sentiment-app-loadbalancer" exposed
$ kubectl get service
NAME TYPE CLUSTER-IP EXTERNAL-IP
kubernetes ClusterIP 10.3.240.1 <none>
sentiment-app-loadbalancer LoadBalancer 10.3.248.29 34.72.142.113
假设您重新训练了模型并希望通过 API 提供最新版本。我们必须使用新标签(v0.2)构建新的 Docker 映像,然后使用命令kubectl set image将映像设置为该标签,Kubernetes 将以滚动方式自动更新集群中的 Pod。这确保了我们的 REST API 将始终可用,但也使用了滚动策略来部署新版本。
当我们想要关闭部署和集群时,可以运行以下命令首先删除LoadBalancer服务,然后拆除集群。这也将释放您正在使用的所有计算实例:
kubectl delete service sentiment-app-loadbalancer
gcloud container clusters delete sentiment-app-cluster
这个蓝图提供了一种简单的方法来使用云资源部署和扩展您的机器学习模型,并且不涵盖可以对生产部署至关重要的几个其他方面。通过持续监控诸如准确性之类的参数并添加重新训练的触发器,可以跟踪模型的性能。为了确保预测的质量,必须有足够的测试用例和其他质量检查,然后才能从 API 返回结果。此外,良好的软件设计必须提供身份验证、身份管理和安全性,这应该是任何公开可用的 API 的一部分。
蓝图:自动版本控制和部署构建
在之前的蓝图中,我们创建了我们的 REST API 的第一个部署。考虑到您现在可以访问额外的数据并重新训练模型以达到更高的准确性水平。我们希望使用 GitHub Actions 提供一种自动化方式来部署更新到您的 API。由于本书和 sentiment-app 的代码都托管在 GitHub 上,因此使用 GitHub Actions 是合理的,但根据环境的不同,您也可以使用其他工具,比如 GitLab。
我们假设您已经在重新训练后保存了模型文件。让我们检查我们的新模型文件,并对 main.py 进行任何额外的更改。您可以在 Git 存储库 上看到这些增加。一旦所有更改都已经检入,我们决定满意并准备部署这个新版本。我们必须使用 git tag v0.2 命令将当前状态标记为我们要部署的状态。这将绑定标签名(v0.2)到当前提交历史中的特定点。标签通常应遵循 语义化版本,其中版本号以 MAJOR.MINOR.PATCH 形式分配,通常用于标识给定软件模块的更新。一旦分配了标签,可以进行额外的更改,但这些更改不会被视为已标记状态的一部分。它始终指向原始提交。我们可以通过运行 git push origin tag-name 将创建的标签推送到存储库。
使用 GitHub Actions,我们创建了一个部署流水线,使用标记存储库的事件来触发部署流水线的启动。此流水线定义在位于文件夹 .github/workflow/ 中的 main.yml 文件中,并定义了每次分配新标记时运行的步骤。因此,每当我们想要发布 API 的新版本时,我们可以创建一个新的标记并将其推送到存储库。
让我们来看一下部署步骤:
name: sentiment-app-deploy
on:
push:
tags:
- '*'
jobs:
build:
name: build
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
文件以一个名称开始,用于识别 GitHub 工作流程,而 on 关键字则指定了触发部署的事件。在这种情况下,我们指定只有包含标签的 Git 推送命令才会启动此部署。这样可以确保我们不会在每次提交时都进行部署,并且通过标签控制 API 的部署。我们还可以选择仅构建特定的标签,例如主要版本修订。jobs 指定了必须运行的一系列步骤,并设置了 GitHub 用于执行操作的环境。build 参数定义了要使用的构建机器类型(ubuntu),以及整个步骤系列的超时值(设为 10 分钟)。
接下来,我们将第一组操作指定如下:
- name: Checkout
uses: actions/checkout@v2
- name: build and push image
uses: docker/build-push-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: sidhusmart/sentiment-app
tag_with_ref: true
add_git_labels: true
push: ${{ startsWith(github.ref, 'refs/tags/') }}
- name: Get the Tag Name
id: source_details
run: |-
echo ::set-output name=TAG_NAME::${GITHUB_REF#refs/tags/}
第一步通常是检出,它在构建机器上检出最新的代码。下一步是使用来自标签的最新提交构建 Docker 容器,并将其推送到 Docker Hub 注册表。docker/build-push-action@v1 是一个已经在GitHub Marketplace中可用的 GitHub 操作,我们重复使用它。注意使用密码来传递用户凭据。您可以通过访问 GitHub 存储库的“设置”>“密码”选项卡来加密和存储您的部署所需的用户凭据,如图 13-5 所示。这使我们能够保持安全性并启用无需任何密码提示的自动构建。我们使用与 Git 提交中使用的相同标签对 Docker 镜像进行标记。我们添加另一个步骤来获取标签,并将其设置为环境变量TAG_NAME,这将在更新群集时使用。
图 13-5. 使用密码向存储库添加凭据。
对于部署步骤,我们必须连接到正在运行的 GCP 集群并更新我们用于部署的映像。首先,我们必须将PROJECT_ID、LOCATION_NAME、CLUSTER_NAME和GCLOUD_AUTH添加到密码中以启用此操作。我们将这些编码为密码,以确保我们的云部署项目细节不会公开存储。您可以通过使用提供的说明来获取GCLOUD_AUTH,并将下载的密钥中的值添加为此字段的密码。
部署的下一步包括在构建机器上设置gcloud实用程序,并使用它获取 Kubernetes 配置文件:
# Setup gcloud CLI
- uses: GoogleCloudPlatform/github-actions/setup-gcloud@master
with:
version: '290.0.1'
service_account_key: ${{ secrets.GCLOUD_AUTH }}
project_id: ${{ secrets.PROJECT_ID }}
# Get the GKE credentials so we can deploy to the cluster
- run: |-
gcloud container clusters get-credentials ${{ secrets.CLUSTER_NAME }} \
--zone ${{ secrets.LOCATION_ZONE }}
最后,我们使用最新的 Docker 镜像更新 Kubernetes 部署。这是我们使用TAG_NAME来标识我们在第二步推送的最新发布的地方。最后,我们添加一个操作来监视我们集群中滚动的状态:
# Deploy the Docker image to the GKE cluster
- name: Deploy
run: |-
kubectl set image --record deployment.apps/sentiment-app \
sentiment-app=textblueprints/sentiment-app:\
${{ steps.source_details.outputs.TAG_NAME }}
# Verify that deployment completed
- name: Verify Deployment
run: |-
kubectl rollout status deployment.apps/sentiment-app
kubectl get services -o wide
使用存储库的“操作”选项卡可以跟踪构建流水线的各个阶段,如图 13-6 所示。在部署流水线的末尾,应该可以在相同的网址上获取更新后的 API 版本,并且还可以通过访问 API 文档来进行测试。
当代码和模型文件足够小以打包到 Docker 镜像中时,此技术效果很好。如果我们使用深度学习模型,情况通常并非如此,不建议创建大型 Docker 容器。在这种情况下,我们仍然使用 Docker 容器来打包和部署我们的 API,但模型文件驻留在主机系统上,并且可以附加到 Kubernetes 集群上。对于云部署,这使用像Google 持久磁盘这样的持久存储。在这种情况下,我们可以通过执行集群更新并更改附加的卷来执行模型更新。
图 13-6. 通过推送 Git 标签启动的 GitHub 部署工作流程。
总结
我们引入了多个蓝图,旨在允许您分享使用本书前几章创建的分析和项目。我们首先向您展示如何创建可复制的 conda 环境,这将使您的队友或其他学习者能够轻松重现您的结果。借助 Docker 环境的帮助,我们甚至可以更轻松地分享您的分析,创建一个完整的环境,无论合作者使用的平台或基础设施如何,都能正常工作。如果有人希望将您的分析结果集成到他们的产品或服务中,我们可以将机器学习模型封装为一个可以从任何语言或平台调用的 REST API。最后,我们提供了一个蓝图,可以轻松创建您的 API 的云部署,根据使用情况进行扩展或缩减。这种云部署可以轻松更新为模型的新版本或额外功能。在增加每一层抽象的同时,我们使分析对不同(和更广泛的)受众可访问,并减少了暴露的细节量。
进一步阅读
- Scully, D, et al. 机器学习系统中的隐藏技术债务。https://papers.nips.cc/paper/2015/file/86df7dcfd896fcaf2674f757a2463eba-Paper.pdf