随着spaCy v2.0的发布候选版本日益临近,我们兴奋地实现了一些最后的重要功能。其中最好的改进之一是一个用于添加流水线组件以及向Doc、Span和Token对象注册扩展属性的新系统。本文将向您介绍这一新功能,并通过一个示例扩展包spacymoji作为结尾。
spaCy是一个用于Python中高级自然语言处理的开源库。新版本可通过spacy-nightly在pip上获取。要尝试本文中的示例,您需要最新版本2.0.0a17。有关新功能的详细信息,请参阅此页面。有关新模型的概述,请参阅模型目录。
设置自定义属性
先前版本的spaCy相当难以扩展,尤其是核心的Doc、Token和Span对象。它们不能直接实例化,因此创建有用的子类需要涉及大量复杂的抽象。继承也并不能令人满意,因为它无法组合不同的定制化需求。我们希望允许人们为spaCy开发扩展,并确保这些扩展可以协同使用。如果每个扩展都要求spaCy返回一个不同的Doc子类,那么就无法做到这一点。
为了解决这个问题,我们引入了一个新的动态字段,允许在运行时添加新的属性、属性和方法:
import spacy
from spacy.tokens import Doc
Doc.set_extension("is_greeting", default=False)
nlp = spacy.load("en_core_web_sm")
doc = nlp(u"hello world")
doc._.is_greeting = True
我们认为._属性在可读性和明确性之间取得了良好的平衡。扩展应该易于使用,但同时也应清楚哪些是内置的,哪些不是——否则,您将无法追溯所读代码的文档或实现。._属性还确保了对spaCy的更新不会因命名空间冲突而破坏扩展代码。
另一个在扩展开发中一直缺失的是修改处理流水线的便捷方式。早期版本的spaCy硬编码了流水线,因为只支持英语。spaCy v1.0允许在运行时更改流水线,但这在很大程度上对用户是隐藏的:您在文本上调用nlp,然后一系列操作就发生了——但具体是什么呢?如果您需要添加一个应在词性标注和句法分析之间运行的处理过程,您必须深入研究spaCy的内部结构。在spaCy v2.0中,终于有了一个API可以做到这一点,而且非常简单:
向流水线添加自定义组件
nlp = spacy.load("en_core_web_sm")
component = MyComponent()
nlp.add_pipe(component, after="tagger")
doc = nlp(u"This is a sentence")
自定义流水线组件
从根本上说,流水线是一个按顺序在Doc上调用的函数列表。流水线可以由模型设置,并由用户修改。一个流水线组件可以是一个持有状态的复杂类,也可以是一个向Doc添加内容并返回它的非常简单的Python函数。
在底层,当您在文本字符串上调用nlp时,spaCy执行以下步骤:
doc = nlp.make_doc(u'This is a sentence') # 从原始文本创建一个Doc
for name, proc in nlp.pipeline: # 按顺序遍历组件
doc = proc(doc) # 在Doc上调用每个组件
nlp对象是Language的一个实例,它包含了您正在使用的语言的数据和标注方案,以及一个预定义的组件流水线,如标注器、解析器和实体识别器。如果您加载了一个模型,Language实例还可以访问模型的二进制数据。所有这些都特定于每个模型,并在模型的meta.json中定义——例如,一个西班牙语NER模型需要的权重、语言数据和流水线组件与英语解析和标注模型不同。这也是为什么流水线状态总是由Language类持有。spacy.load()将所有这些组合在一起,并返回一个具有设置好的流水线和访问二进制数据权限的Language实例。
v2.0中的spaCy流水线简单地是一个(name, function)元组的列表,描述了组件名称和要在Doc对象上调用的函数:
>>> nlp.pipeline
[('tagger', <spacy.pipeline.Tagger>), ('parser', <spacy.pipeline.DependencyParser>), ('ner', <spacy.pipeline.EntityRecognizer>)]
为了方便修改流水线,有几个内置方法来获取、添加、替换、重命名或移除单个组件。spaCy的默认流水线组件,如标注器、解析器和实体识别器,现在都遵循相同的、一致的API,并且是Pipe的子类。如果您正在开发自己的组件,使用Pipe API将使其完全可训练和可序列化。一个组件至少需要是一个接受Doc并返回它的可调用对象:
def my_component(doc):
print("文档长度为 {} 个字符,包含 {} 个词符。".format(len(doc.text), len(doc)))
return doc
然后可以使用nlp.add_pipe()方法将该组件添加到流水线的任何位置。参数before、after、first和last允许您指定组件名称以在新组件之前或之后插入,或者告诉spaCy将其插入到流水线的最前面(即直接在分词之后)或最后面。
nlp = spacy.load("en_core_web_sm")
nlp.add_pipe(my_component, name="print_length", last=True)
doc = nlp(u"This is a sentence.")
Doc、Token和Span上的扩展属性
当您实现自己的、用于修改Doc的流水线组件时,您通常希望扩展API,以便您添加的信息可以方便地访问。spaCy v2.0引入了一种新机制,允许您注册自己的属性、属性和方法,这些将在._命名空间中可用,例如,doc._.my_attr。可以通过set_extension()方法注册的扩展主要有三种类型:
- 属性扩展:为属性设置一个默认值,该值可以被覆盖。
- 属性扩展:定义一个getter和一个可选的setter函数。
- 方法扩展:分配一个函数,该函数作为对象方法可用。
Doc.set_extension("hello_attr", default=True)
Doc.set_extension("hello_property", getter=get_value, setter=set_value)
Doc.set_extension("hello_method", method=lambda doc, name: "Hi {}!".format(name))
doc._.hello_attr # True
doc._.hello_property # get_value的返回值
doc._.hello_method("Ines") # 'Hi Ines!'
能够轻松地将自定义数据写入Doc、Token和Span,意味着使用spaCy的应用程序可以充分利用内置数据结构和Doc对象作为包含所有信息的单一事实来源的优势:
- 在分词和解析过程中不会丢失信息,因此您可以始终将标注与原始字符串关联起来。
- Token和Span是Doc的视图,因此它们始终保持最新且一致。
- 通过
doc.c可以高效地在C级别访问底层的TokenC*数组。 - API可以标准化为传递Doc对象,并在必要时从中读取和写入。更少的签名使函数更具可重用性和可组合性。
例如,假设您的数据包含地理信息,如国家名称,并且您使用spaCy提取这些名称并添加更多细节,如国家的首都或GPS坐标。或者,您的应用程序需要使用spaCy的命名实体识别器查找公众人物的名字,并检查维基百科上是否有关于他们的页面。
以前,您通常会对文本运行spaCy以获取您感兴趣的信息,将其保存到数据库,然后稍后向其中添加更多数据。这很有效,但这也意味着您丢失了与原始文档的所有引用。或者,您可以序列化您的文档,并将附加数据与其各自的词符索引引用一起存储。同样,这很有效,但总体而言这是一个相当不尽人意的解决方案。在spaCy v2.0中,您可以简单地将所有这些数据写入文档、词符或跨度上的自定义属性,使用您选择的名称。例如,token._.country_capital、span._.wikipedia_url或doc._.included_persons。
以下示例展示了一个简单的流水线组件,它使用REST Countries API获取所有国家,在文档中查找国家名称,合并匹配的跨度,分配实体标签GPE(地缘政治实体),并将国家的首都、纬度/经度坐标和一个布尔值is_country添加到词符属性中。您也可以在GitHub上找到一个更详细的版本。
import requests
from spacy.tokens import Token, Span
from spacy.matcher import PhraseMatcher
class Countries(object):
name = 'countries' # 流水线中显示的组件名称
def __init__(self, nlp, label="GPE"):
# 从API请求所有国家数据
r = requests.get("https://restcountries.eu/rest/v2/all")
# 为便于查找创建字典
self.countries = {c['name']: c for c in r.json()}
# 初始化匹配器并为所有国家名称添加模式
self.matcher = PhraseMatcher(nlp.vocab)
self.matcher.add("COUNTRIES", None, *[nlp(c) for c in self.countries.keys()])
# 从词汇表获取标签ID
self.label = nlp.vocab.strings[label]
# 在Token上注册扩展
Token.set_extension("is_country", default=False)
Token.set_extension("country_capital")
Token.set_extension("country_latlng")
def __call__(self, doc):
matches = self.matcher(doc)
spans = [] # 稍后保留跨度以便合并
for _, start, end in matches:
# 为匹配的国家创建Span并分配标签
entity = Span(doc, start, end, label=self.label)
spans.append(entity)
for token in entity: # 设置词符属性的值
token._.set("is_country", True)
token._.set("country_capital", self.countries[entity.text]["capital"])
token._.set("country_latlng", self.countries[entity.text]["latlng"])
# 覆盖doc.ents并添加实体——不要替换!
doc.ents = list(doc.ents) + spans
for span in spans:
span.merge() # 最后合并所有跨度以避免索引不匹配
return doc # 不要忘记返回Doc!
该示例还使用了spaCy的PhraseMatcher,这是v2.0中引入的另一个很酷的功能。与词符模式不同,短语匹配器可以接受Doc对象列表,让您快速高效地匹配大型术语列表。当您将该组件添加到流水线并处理文本时,所有国家都会自动标记为GPE实体,并且自定义属性在词符上可用:
nlp = spacy.load("en_core_web_sm")
component = Countries(nlp)
nlp.add_pipe(component, before="tagger")
doc = nlp(u"Some text about Colombia and the Czech Republic")
print([(ent.text, ent.label_) for ent in doc.ents])
# [('Colombia', 'GPE'), ('Czech Republic', 'GPE')]
print([(token.text, token._.country_capital) for token in doc if token._.is_country])
# [('Colombia', 'Bogotá'), ('Czech Republic', 'Prague')]
在这种情况下,我们能够通过一次对REST API的请求获取所有数据。但是,您也可以通过单个对象上的getter函数实现API请求,或添加一个方法属性来传递额外的参数。或者,考虑一个Token方法,它接受另一个国家名称或GPS坐标,并计算到该词符所属国家的距离?这一切现在都成为可能了!
使用getter和setter,您还可以在Doc和Span上实现引用自定义Token属性的属性——例如,文档是否包含国家。由于getter仅在您访问属性时被调用,您可以在这里引用Token的is_country属性,该属性已在处理步骤中设置。完整的实现请参见完整示例。
has_country = lambda tokens: any([token._.is_country for token in tokens])
Doc.set_extension("has_country", getter=has_country)
Span.set_extension("has_country", getter=has_country)
spaCy扩展
为自定义扩展提供一个直观的API和一个清晰定义的输入/输出(Doc/Doc),也有助于使更大的代码库更易于维护,并允许开发者与他人分享他们的扩展并可靠地测试它们。这对于使用spaCy的团队很重要,对于希望发布自己的包、扩展和插件的开发者也是如此。
我们希望这种新架构将有助于鼓励一个spaCy组件的社区生态系统,以覆盖任何潜在的使用案例——无论多么具体。组件可以范围广泛,从为方便起见添加相当琐碎属性的简单扩展,到利用外部库(如PyTorch、scikit-learn和TensorFlow)的复杂模型。用户可能希望拥有许多组件,我们很乐意能够提供更多与spaCy一起打包的内置流水线组件——例如,更好的句子边界检测、语义角色标注和情感分析。但同时,也存在明确的需求,需要使spaCy针对特定用例可扩展,使其与其他库更好地互操作,并将所有这些结合起来以更新和训练统计模型。
示例:使用spacymoji处理Emoji
长期以来,为spaCy添加更好的emoji支持一直是我“某天要做的酷事”清单上的项目。Emoji很有趣,包含许多相关的语义信息,而且据说现在在Twitter文本中比连字符更常见。在过去的两年里,它们也变得更加复杂。除了常规的emoji字符及其Unicode表示外,您现在还可以使用肤色修饰符,它们放在常规emoji之后,并产生一个可见字符。例如,👍 + 🏿 = 👍🏿。此外,一些字符可以形成“ZWJ序列”,例如,由零宽连接符连接的多个emoji合并成一个符号。例如,👨 + ZWJ + 🎤 = 👨🎤(官方名称为“男歌手”,我称之为“Bowie”)。
从v2.0开始,spaCy的分词器将所有emoji和其他符号拆分为独立的词符,使它们更容易与文本的其余部分分开。然而,emoji的Unicode范围相当随意且经常更新。spaCy的分词器使用的\p{Other_Symbol}或\p{So}类别是一个很好的近似,但它也包括其他图标和装饰符号。所以,如果您只想处理emoji,除了匹配一个精确的列表外别无他法。幸运的是,emoji包在这里为我们提供了支持。
spacymoji是一个spaCy扩展和流水线组件,它检测文本中的单个emoji和序列,将它们合并成一个词符,并为Doc、Span和Token分配自定义属性。例如,您可以检查一个文档或跨度是否包含emoji,检查一个词符是否为emoji,并检索其人类可读的描述。
import spacy
from spacymoji import Emoji
nlp = spacy.load('en')
emoji = Emoji(nlp)
nlp.add_pipe(emoji, first=True)
doc = nlp(u"This is a test 😻 👍🏿")
assert doc._.has_emoji
assert len(doc._.emoji) == 2
assert doc[2:5]._.has_emoji
assert doc[4]._.is_emoji
assert doc[5]._.emoji_desc == u'thumbs up dark skin tone'
assert doc._.emoji[1] == (u'👍🏿', 5, u'thumbs up dark skin tone')
通过将该组件添加为流水线中的第一个组件,跨度在分词后立即合并,并在文档被解析之前完成。如果您的文本包含大量emoji,这甚至可能使解析器的准确性得到不错的提升,因为解析器每个emoji只看到一个词符。
spacymoji组件使用PhraseMatcher在emoji查找表中查找精确的emoji序列的出现位置,并生成相应的emoji跨度。如果emoji由多个字符组成——例如,带有肤色修饰符的emoji或组合的ZWJ序列——它还会将它们合并成一个词符。emoji快捷方式,如:thumbs_up:,被转换为人类可读的描述,可作为token._.emoji_desc使用。您也可以传入您自己的查找表,将emoji映射到自定义描述。
后续步骤
如果您受到启发并想构建自己的扩展,请参阅本指南以获取一些提示、技巧和最佳实践。随着深度学习工具和技术的发展,现在有许多用于预测各种类型NLP标注的模型。用于共指消解、信息提取和摘要等任务的模型现在可以轻松地用于驱动spaCy扩展——您所要做的就是添加扩展属性,并将模型挂接到流水线中。我们期待着看到您构建的成果!
资源
- spaCy v2.0: v2.0 (alpha) 中的新特性
- 自定义流水线:使用指南 (alpha)
- 代码示例:组件和属性
- spacymoji:GitHub上的扩展FINISHED