Ahogrammer

Deep Dive Into NLP, ML and Cloud

spaCyのSpanRulerを使ったルールベースの固有表現認識

一月ほど前の話になりますが、spaCy v3.3.1がリリースされました。いくつかの機能の追加とバグフィックスが行われているのですが、その1つとしてSpanRulerと呼ばれるコンポーネントが追加されています。このコンポーネントはルールベースで固有表現認識などを行うための機能を備えています。日本語での解説を見かけなかったので、本記事で簡単に紹介します。

SpanRulerとは?

spaCyでルールベースの固有表現認識をする場合、EntityRulerがよく使われてきました。EntityRulerはパターンに基づいて固有表現認識をするためのコンポーネントです。統計的なモデルと組み合わせて使うこともできるので、認識性能の向上にも役立ちます。パターンの書き方は、次に示すように、文字列を指定する方法と辞書を指定する方法があります。ラベルに関しては"label"キーで指定します。

# 文字列マッチ
{"label": "ORG", "pattern": "Apple"}

# 辞書によるパターンの指定
{"label": "GPE", "pattern": [{"LOWER": "san"}, {"LOWER": "francisco"}]}

後の説明のために、EntityRulerの使い方を簡単に紹介しておきましょう。典型的には、nlpオブジェクトのadd_pipeメソッドを呼び出してパイプラインにコンポーネントを追加します。そうすることで、doc.entsからマッチした固有表現を得ることができます。次のコードでは、先ほど紹介した2つの方法でパターンを指定しています。

from spacy.lang.ja import Japanese

nlp = Japanese()
ruler = nlp.add_pipe("entity_ruler")
patterns = [
    {"label": "FOOD", "pattern": "ナポリタン"},
    {"label": "FOOD", "pattern": [
        {"POS": "NOUN", "OP": "+"},
        {"TEXT": "スパゲティ"}
    ]}
]
ruler.add_patterns(patterns)

doc = nlp("ナポリタンとたらこスパゲティと和風きのこスパゲティ")
for ent in doc.ents:
    print(ent.text, ent.label_)

結果は次のようになります。

ナポリタン FOOD
たらこスパゲティ FOOD
和風きのこスパゲティ FOOD

SpanRulerEntityRulerと同様にルールベースで固有表現認識などをできるのですが、より一般化されたコンポーネントである点が異なります。EntityRulerでは認識したエンティティをdoc.entsに追加しましたが、SpanRulerではdoc.spansdoc.entsにスパンを追加できます。doc.entsは重複を許さないのに対し、doc.spansは次の画像に示すような重複を許容できます。

重複したスパン(Spancat: a new approach for span labelingより)

また、doc.entsは重複を除去するためにスパンのフィルタリングをしているのですが、SpanRulerでは独自のフィルタリングアルゴリズムを適用できる点が異なります。デフォルトではfilter_spans関数を使って、スパンが重なっている場合は、短いスパンよりも1番長いスパンを優先するようにフィルタリングしていますが、このアルゴリズムを変えることができるのです。

SpanRulerの使い方

EntityRulerと同じく、SpanRulerも通常はnlp.add_pipeで追加します。nlpオブジェクトがテキストに対して呼び出されると、doc内でマッチを見つけ、指定されたパターンラベルをエンティティラベルとして、doc.spans["ruler"]にスパンとして追加します。デフォルトのキー名は"ruler"ですが、設定により変更可能です。doc.entsとは異なり、doc.spansでは重複が許容されるのでフィルタリングは必要ありませんが、オプションでスパンにフィルタリングとソートを適用できます。

import spacy

nlp = spacy.blank("en")
ruler = nlp.add_pipe("span_ruler")
patterns = [
    {"label": "ORG", "pattern": "Apple"},
    {"label": "GPE", "pattern": [
        {"LOWER": "san"},
        {"LOWER": "francisco"}
    ]}
]
ruler.add_patterns(patterns)

doc = nlp("Apple is opening its first big office in San Francisco.")
print(doc.ents)
print([(span.text, span.label_) for span in doc.spans["ruler"]])

出力は次のようになります。デフォルトではdoc.spansに認識結果が格納されていることがわかります。

()
[('Apple', 'ORG'), ('San Francisco', 'GPE')]

上の例では、doc.entsが空でしたが、コンポーネントの設定としてannotate_ents: Trueを渡すことでdoc.entsに結果を格納できます。

nlp = spacy.blank("en")
config = {"annotate_ents": True}
ruler = nlp.add_pipe("span_ruler", config=config)
ruler.add_patterns(patterns)

doc = nlp("Apple is opening its first big office in San Francisco.")
print(doc.ents)
print(doc.spans)
(Apple, San Francisco)
{'ruler': [Apple, San Francisco]}

デフォルトのフィルタリングは1番長いスパンを残しましたが、カスタムフィルターを書けば、残すスパンを変更できます。まずは、デフォルトの場合について見てみましょう。今回の場合、パターンとして「San Francisco」「Giants」「San Francisco Giants」の3つを登録しています。

nlp = spacy.blank("en")
config = {"annotate_ents": True}
ruler = nlp.add_pipe("span_ruler", config=config)
patterns = [
    {"label": "GPE", "pattern": [
        {"LOWER": "san"},
        {"LOWER": "francisco"}
    ]},
    {"label": "ORG", "pattern": [{"LOWER": "giants"}]},
    {"label": "ORG", "pattern": [
        {"LOWER": "san"},
        {"LOWER": "francisco"},
        {"LOWER": "giants"}
    ]},
]
ruler.add_patterns(patterns)

doc = nlp("San Francisco Giants")
print(doc.ents)

結果は次のようになります。1番長いスパンを残していることがわかります。

(San Francisco Giants,)

では、カスタムフィルターを書いて、適用してみましょう。コンポーネントの設定にents_filterを渡すことで、カスタムフィルターを指定できます。

import itertools
from typing import Iterable, List


def filter_spans(spans: Iterable["Span"]) -> List["Span"]:
    sorted_spans = sorted(spans, key=lambda span: span.end)
    result = sorted_spans[:1]
    for span in sorted_spans[1:]:
        if result[-1].end <= span.start:
            result.append(span)
    return result


def filter_chain_spans(*spans: Iterable["Span"]) -> List["Span"]:
    return filter_spans(itertools.chain(*spans))


@spacy.registry.misc("spacy.maximized_spans_filter.v1")
def make_maximized_spans_filter():
    return filter_chain_spans


nlp = spacy.blank("en")
config = {
    "annotate_ents": True,
    "ents_filter": {'@misc': 'spacy.maximized_spans_filter.v1'}
}
ruler = nlp.add_pipe("span_ruler", config=config)
ruler.add_patterns(patterns)

doc = nlp("San Francisco Giants")
print(doc.ents)

結果は次のようになりました。フィルターを渡すことで、結果が変わることを確認できました。

(San Francisco, Giants)

ここで紹介した以外にも、いくつかのオプションを設定として渡すことができます。詳細は、SpanRulerを参照してください。

参考資料