spaCy v2.0:自定义流水线组件与扩展属性实战

2 阅读13分钟

随着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()方法将该组件添加到流水线的任何位置。参数beforeafterfirstlast允许您指定组件名称以在新组件之前或之后插入,或者告诉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()方法注册的扩展主要有三种类型:

  1. 属性扩展:为属性设置一个默认值,该值可以被覆盖。
  2. 属性扩展:定义一个getter和一个可选的setter函数。
  3. 方法扩展:分配一个函数,该函数作为对象方法可用。
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_capitalspan._.wikipedia_urldoc._.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