見出し画像

spaCy入門 (2) - ルールベースマッチング

以下の記事を参考に書いてます。

Rule-based matching · spaCy Usage Documentation

前回

1. ルールベースマッチング

「spaCy」のルールベースマッチングを使用すると、探している単語やフレーズを見つけられるだけでなく、ドキュメント内のトークンの関係を知ることができます。つまり、周囲のトークンを分析したり、スパンを単一のトークンにマージしたり、名前付きエンティティを追加したりできます。

【情報】 「ルールベース」と「モデルの学習」のどちらが良いか
モデルの学習」は学習データがあり、それを使ってシステムを汎化できる場合に役立ちます。ローカルコンテキストに手がかりがある場合は、特に機能します。「個人名」「会社名」などがこれにあたります。

ルールベース」は、検索すべきサンプルが有限である場合、または、ルールや正規表現で明確にトークンを表現できる場合役立ちます。「国名」「IPアドレス」「URL」などがこれにあたります。

複雑なタスクの場合、「モデルの学習」をお勧めしますが、学習データが必要になるため、多くの状況では「ルールベース」を選ぶことになります。両方のアプローチを組み合わせて、モデルをルールで改善して精度を高めることもできます。
情報】MacherとPhraseMatcherはいつ使用すべきか
Matcher」は、語彙の属性や演算子を使用して、探したいトークンを抽象的な表現で記述できます。たとえば、「名詞 +  動詞 (loveまたはlike) + 限定詞 (オプション) + 10文字以上のトークン」といった条件で検索できます。ただし、「PhraseMatcher」ほど高速ではありません。

PhraseMatcher」は、検索するための大規模な用語リストを既に持っている場合に役立ちます。LOWER属性で照合して、大文字と小文字を区別しない高速照合を行うこともできます。

2. Macher

「spaCy」は、ルールベースマッチングを行う「Macher」を提供しています。ルールは、トークンのアノテーション(textやtag_など)やフラグ(IS_PUNCTなど)を参照できます。パターンをエンティティIDに関連付けて、基本的なエンティティのリンクや明確化もできます。

2-1. パターンの追加

「3つのトークンの組み合わせ」を検索するには、パターンを次のように記述します。

(1) 小文字が「hello」と一致するトークン(例: "Hello" or "HELLO")。
(2) is_punctがTrueであるトークン(つまり句読点)。
(3) 小文字が「world」と一致するトークン(例: "World" or "WORLD")。

[{"LOWER": "hello"}, {"IS_PUNCT": True}, {"LOWER": "world"}]


【重要】 パターン作成時は、各辞書が1つのトークンを表すことに注意してください。 spaCyのトークンがパターンで定義したトークンと一致しない場合、パターンは結果を生成しません。複雑なパターンを作成する時は、以下のコードでトークンを確認してください。
doc = nlp("A complex-example,!")
print([token.text for token in doc])


はじめに、「Matcher」を語彙で初期化します。Matcherは、操作するドキュメントと同じ語彙を共有する必要があります。そして、matcher.add()でパターンを追加します。第1引数が「パターンID」、第3引数が「パターン」です。第2引数は、成功時にコールバックされるon_match関数を渡します。

import spacy
from spacy.matcher import Matcher

nlp = spacy.load("en_core_web_sm")
matcher = Matcher(nlp.vocab)

pattern = [{"LOWER": "hello"}, {"IS_PUNCT": True}, {"LOWER": "world"}]
matcher.add("HelloWorld", None, pattern)

doc = nlp("Hello, world! Hello world!")
matches = matcher(doc)
for match_id, start, end in matches:
    string_id = nlp.vocab.strings[match_id] 
    span = doc[start:end] 
    print(match_id, string_id, start, end, span.text)
HelloWorld 0 3 Hello, world

matchesは、(match_id, start, end)のリストを返します。
match_idはパターンIDのハッシュ値で、nlp.vocab.strings[match_id]で文字列に変換できます。startとendはドキュメントの場所で、doc[start:end]でテキストのスパンを取得できます。

複数のパターンを追加することもできます。

matcher.add("HelloWorld", None,
    [{"LOWER": "hello"}, {"IS_PUNCT": True}, {"LOWER": "world"}],
    [{"LOWER": "hello"}, {"LOWER": "world"}])

2-2. 使用可能なトークン属性

Matcherで使用可能なトークン属性は、次のとおりです。

・ORTH : unicode - テキスト
・TEXT : unicode - テキスト
・LOWER : unicode テキストの小文字
・LENGTH : int - テキスト長
・IS_ALPHA, IS_ASCII, IS_DIGIT : bool - 英字、ASCII文字、数字かどうか
・IS_LOWER, IS_UPPER, IS_TITLE : bool - 小文字、大文字、タイトルかどうか
・IS_PUNCT, IS_SPACE, IS_STOP : bool - 句読点、空白、ストップワードかどうか
・IS_SENT_START : bool - 文の始めかどうか
・SPACY : bool - 末尾にスペースがあるか
・LIKE_NUM, LIKE_URL, LIKE_EMAIL : bool - 番号、URL、電子メールに似ているかどうか
・POS, TAG, DEP, LEMMA, SHAPE : unicode - 品詞タグ、依存関係ラベル、補題、形状(アノテーション仕様を参照)
・ENT_TYPE : unicode - エンティティラベル


【情報】 属性名の大文字か小文字は重要か
重要ではありません。spaCyは名前を内部的に正規化するため、{"LOWER": "text"}と{"lower": "text"}はどちらも同じ結果になります。 
【情報】 Matcher Explorer
Matcher Explorer」を使用することで、トークンパターンをインタラクティブに作成し、ルールベースのMatcherをテストできます。

2-3. 拡張パターン

パターンには、単一の文字列だけでなく、辞書を指定することもできます。これによって、複数の文字列を指定したり、最小文字長を指定したりできます。

・IN : any - 属性値はリストのメンバー
・NOT_IN :  any - 属性値はリストのメンバーではない
・==, >=, <=, >, < : int, float - 属性値が等しい、大きいまたは等しい、小さいまたは等しい、大きいまたは小さい​

2-4. 正規表現

スペルごとに新しいパターンを追加せずに、単語の様々なスペルを一致させたい場合は、「正規表現」が使えます。

pattern = [
    {"TEXT": {"REGEX": "^[Uu](\.?|nited)$"}},
    {"TEXT": {"REGEX": "^[Ss](\.?|tates)$"}},
    {"LOWER": "president"}]

REGEX演算子を使用すると、カスタム属性を含む任意の属性文字列値のルールを定義できます。TEXT、LOWER、TAGなどの属性に適用する必要があります。

# 異なるスペルのテキストと一致
pattern = [{"TEXT": {"REGEX": "deff?in[ia]tely"}}]

# Vで始まるきめタグと一致
pattern = [{"TAG": {"REGEX": "^V"}}]

# カスタム属性値と一致
pattern = [{"_": {"country": {"REGEX": "^[Uu](nited|\.?) ?[Ss](tates|\.?)$"}}}]
【重要】 REGEX演算子の使用時は、テキスト全体ではなく、単一のトークンで動作することに注意してください。

2-5. 全文の正規表現のマッチング

式が複数のトークンに適用される場合の簡単な解決策は、doc.textをre.finditerと照合し、doc.char_span()を使用して、照合の文字インデックスからスパンを作成することです。一致した文字が1つ以上の有効なトークンにマップされていない場合、doc.char_spanはNoneを返します。

import spacy
import re

nlp = spacy.load("en_core_web_sm")
doc = nlp("The United States of America (USA) are commonly known as the United States (U.S. or US) or America.")

expression = r"[Uu](nited|\.?) ?[Ss](tates|\.?)"
for match in re.finditer(expression, doc.text):
    start, end = match.span()
    span = doc.char_span(start, end)
    if span is not None:
        print("Found match:", span.text)

2-6. 演算子と数量詞

Matcherでは、「OP」キーとして指定された「数量詞」を使用することもできます。「数量詞」を使用すると、照合するトークンのシーケンスを定義できます。1つ以上の句読点、またはオプションのトークンを指定します。

・! : 正確に0回一致するように要求することにより、パターンを否定
・? : 0回または1回一致させることにより、パターンをオプションにする
・+ : パターンが1回以上一致する必要がある
・* : パターンが0回以上一致するようにする​

2-7. ワイルドカード

ワイルドカードとして空の辞書{}を使用することもできます。

[{"ORTH": "User"}, {"ORTH": "name"}, {"ORTH": ":"}, {}]

2-8. パターンの検証とデバッグ

Matcherは、validate = Trueオプションを使用して、JSONスキーマに対してパターンを検証できます。これは、開発中のパターンのデバッグ、特にサポートされていない属性のキャッチに役立ちます。

import spacy
from spacy.matcher import Matcher

nlp = spacy.load("en_core_web_sm")
matcher = Matcher(nlp.vocab, validate=True)

pattern = [{"LOWER": "hello"}, {"IS_PUNCT": True}, {"CASEINSENSITIVE": "world"}]
matcher.add("HelloWorld", None, pattern)

# エラー発生
# MatchPatternError: Invalid token patterns for matcher rule 'HelloWorld'
# Pattern 0:
# - Additional properties are not allowed ('CASEINSENSITIVE' was unexpected) [2]

2-9. on_matchルールの追加

より現実的な例に進むために、ブログ記事の大規模なコーパスで作業していて、「Google I/O」(spaCyは ['Google', 'I', '/', 'O'] としてトークン化)のすべての言及に一致させたいとします。 安全のため、誰かが「Google i/o」と書いた場合に備えて、大文字のバージョンのみを照合します。

from spacy.lang.en import English
from spacy.matcher import Matcher
from spacy.tokens import Span

nlp = English()
matcher = Matcher(nlp.vocab)

def add_event_ent(matcher, doc, i, matches):
    # 現在の一致を取得し、エンティティラベル、開始および終了のタプルを作成
    # エンティティをドキュメントのエンティティに追加
    match_id, start, end = matches[i]
    entity = Span(doc, start, end, label="EVENT")
    doc.ents += (entity,)
    print(entity.text)

pattern = [{"ORTH": "Google"}, {"ORTH": "I"}, {"ORTH": "/"}, {"ORTH": "O"}]
matcher.add("GoogleIO", add_event_ent, pattern)
doc = nlp("This is a text about Google I/O")
matches = matcher(doc)

ちなみによく似たロジックがEntityRulerに実装されています。また、重複する一致の処理も処理します。そうでない場合は、自分で処理する必要があります。

これで、ドキュメントでMatcherを呼び出すことができます。パターンは、テキストで発生する順序で照合されます。次に、Matcherは一致を繰り返し処理し、一致したIDのコールバックを検索して、それを呼び出します。

doc = nlp(YOUR_TEXT_HERE)
matcher(doc)

コールバックが呼び出されると、Matcher自体、ドキュメント、現在の一致の位置、および一致の合計リストの4つの引数が渡されます。これにより、一致したフレーズのセット全体を考慮したコールバックを記述できるため、重複やその他の競合を任意の方法で解決できます。

・matcher : Matcher  - Matcherインスタンス
・doc : Doc - ドキュメント
・i : int - 現在の一致のインデックス (matches[i]).
・matches : list - 一致を説明する(match_id、start、end)タプルのリスト。 一致タプルは、スパンdoc [start:end]を記述。

2-10. カスタムパイプラインコンポーネント

データに、残りのHTML改行(<br>や<BR/>など)などの厄介な前処理アーティファクトも含まれているとします。テキストを分析しやすくするために、それらを1つのトークンにマージしてフラグを付け、後で無視できるようにします。理想的には、これはテキストを処理するときにすべて自動的に実行される必要があります。これを実現するには、各Docオブジェクトで呼び出される「カスタムパイプラインコンポーネント」を追加し、残りのHTMLスパンをマージして、トークンに属性bad_htmlを設定します。

import spacy
from spacy.matcher import Matcher
from spacy.tokens import Token

# コンポーネントはnlpオブジェクトを介して共有語彙で初期化する必要があるため、クラスを使用している
class BadHTMLMerger(object):
    def __init__(self, nlp):
        # 新しいトークン拡張機能を登録して、不正なHTMLにフラグを付ける
        Token.set_extension("bad_html", default=False)
        self.matcher = Matcher(nlp.vocab)
        self.matcher.add(
            "BAD_HTML",
            None,
            [{"ORTH": "<"}, {"LOWER": "br"}, {"ORTH": ">"}],
            [{"ORTH": "<"}, {"LOWER": "br/"}, {"ORTH": ">"}],
        )

    def __call__(self, doc):
        # コンポーネントがドキュメントで呼び出されたときに呼び出される
        matches = self.matcher(doc)
        spans = []  # Collect the matched spans here
        for match_id, start, end in matches:
            spans.append(doc[start:end])
        with doc.retokenize() as retokenizer:
            for span in spans:
                retokenizer.merge(span)
                for token in span:
                    token._.bad_html = True  # トークンを不正なHTMLとしてマーク
        return doc

nlp = spacy.load("en_core_web_sm")
html_merger = BadHTMLMerger(nlp)
nlp.add_pipe(html_merger, last=True)  # パイプラインにコンポーネントを追加
doc = nlp("Hello<br>world! <br/> This is a test.")
for token in doc:
    print(token.text, token._.bad_html)

パターンをコンポーネントにハードコーディングする代わりに、パターンを含むJSONファイルへのパスをコンポーネントに持たせることもできます。 これにより、アプリに応じて、さまざまなパターンでコンポーネントを再利用できます。

html_merger = BadHTMLMerger(nlp, path="/path/to/patterns.json")
【情報】 パイプラインの処理
カスタムパイプラインコンポーネントと拡張属性を作成する方法の詳細と例については、使用ガイドを参照してください。

2-11. 例 : 言語アノテーションの使用

ユーザーのコメントを分析していて、Facebookについて人々が何を言っているかを知りたいとします。「Facebookis」または「Facebookwas」に続く形容詞を見つけることから始めたいと思います。これは明らかに非常に基本的なソリューションですが、高速であり、データの内容を把握するための優れた方法です。パターンは次のようになります。

[{"LOWER": "facebook"}, {"LEMMA": "be"}, {"POS": "ADV", "OP": "*"}, {"POS": "ADJ"}]

これは、小文字の形式が「facebook」(Facebook、facebook、FACEBOOKなど)に一致するトークン、その後に「be」(たとえば、is、was、または 's)の語尾を持つトークン、オプションの副詞、形容詞が続きます。ここで言語アノテーションを使用すると、「Facebook’s annoying ads」ではなく、「Facebook’s annoying」に一致するようにspaCyに指示できるため、特に便利です。オプションの副詞は、「pretty awful」や「very nice」などの強意語の形容詞を見逃さないようにします。

結果の概要をすばやく把握するには、一致を含むすべての文を収集し、displaCyビジュアライザーを使用してレンダリングします。コールバック関数では、各一致の開始と終了、および親ドキュメントにアクセスできます。これにより、一致を含む文doc[start:end.sent]を判別し、文内の一致したスパンの開始と終了を計算できます。「手動」モードでdisplaCyを使用すると、レンダリングするテキストとエンティティを含む辞書のリストを渡すことができます。

import spacy
from spacy import displacy
from spacy.matcher import Matcher

nlp = spacy.load("en_core_web_sm")
matcher = Matcher(nlp.vocab)
matched_sents = []  # Collect data of matched sentences to be visualized

def collect_sents(matcher, doc, i, matches):
    match_id, start, end = matches[i]
    span = doc[start:end]  # Matched span
    sent = span.sent  # Sentence containing matched span
    # Append mock entity for match in displaCy style to matched_sents
    # get the match span by ofsetting the start and end of the span with the
    # start and end of the sentence in the doc
    match_ents = [{
        "start": span.start_char - sent.start_char,
        "end": span.end_char - sent.start_char,
        "label": "MATCH",
    }]
    matched_sents.append({"text": sent.text, "ents": match_ents})

pattern = [{"LOWER": "facebook"}, {"LEMMA": "be"}, {"POS": "ADV", "OP": "*"},
    {"POS": "ADJ"}]
matcher.add("FacebookIs", collect_sents, pattern)  # add pattern
doc = nlp("I'd say that Facebook is evil. – Facebook is pretty cool, right?")
matches = matcher(doc)

# Serve visualization of sentences containing match with displaCy
# set manual=True to make displaCy render straight from a dictionary
# (if you're not running the code within a Jupyer environment, you can
# use displacy.serve instead)
displacy.render(matched_sents, style="ent", manual=True)

2-12. 例 : 電話番号

電話番号には様々な形式があり、照合するのは難しいことがよくあります。spaCyは数字のシーケンスをそのまま残し、空白と句読点でのみ分割します。これは、国の慣習に応じて、特定の句読点で囲まれた特定の長さの番号シーケンスで一致するパターンで探す必要があることを意味します。

IS_DIGITは、ここではあまり役に立ちません。しかし、SHAPEフラグを使用できます。各dは1桁を表します。

[{"ORTH": "("}, {"SHAPE": "ddd"}, {"ORTH": ")"}, {"SHAPE": "dddd"},
{"ORTH": "-", "OP": "?"}, {"SHAPE": "dddd"}]

これは、(123)4567 8901 または (123)4567-8901 の電話番号と一致します。 (123)456 789 のようなフォーマットにも一致させるために、「dddd」の代わりに「ddd」を使用して2番目のパターンを追加できます。一部の値をハードコーディングすることにより、特定の国固有の番号のみに一致させることもできます。 たとえば、ドイツの国際番号の最も一般的な形式に一致するパターンは次のとおりです。

[{"ORTH": "+"}, {"ORTH": "49"}, {"ORTH": "(", "OP": "?"}, {"SHAPE": "dddd"},
{"ORTH": ")", "OP": "?"}, {"SHAPE": "dddd", "LENGTH": 6}]

形式によっては、このような広範なルールセットを作成する方が、モデルを学習するよりも優れていることがよくあります。より予測可能な結果が得られ、変更と拡張がはるかに簡単で、学習データも必要ありません。

import spacy
from spacy.matcher import Matcher

nlp = spacy.load("en_core_web_sm")
matcher = Matcher(nlp.vocab)
pattern = [{"ORTH": "("}, {"SHAPE": "ddd"}, {"ORTH": ")"}, {"SHAPE": "ddd"},
    {"ORTH": "-", "OP": "?"}, {"SHAPE": "ddd"}]
matcher.add("PHONE_NUMBER", None, pattern)

doc = nlp("Call me at (123) 456 789 or (123) 456 789!")
print([t.text for t in doc])
matches = matcher(doc)
for match_id, start, end in matches:
    span = doc[start:end]
    print(span.text)

2-13. 例 : ソーシャルメディア上のハッシュタグと絵文字

ソーシャルメディアの投稿、特にツイートは、あつかいが難しい場合があります。それらは非常に短く、様々な絵文字やハッシュタグが含まれています。プレーンテキストを見るだけで、多くの貴重なセマンティック情報が失われます。

ブランド名や製品に言及している投稿など、特定のトピックに関するソーシャルメディアの投稿の大規模なサンプルを抽出したとします。データ探索の最初のステップとして、特定の絵文字を含む投稿を除外し、それらを使用して、表現された感情がポジティブかネガティブかに基づいて、一般的な感情スコアを割り当てます。😀または😞。 また、後で無視または分析できるように、#MondayMotivationなどのハッシュタグを検索、マージ、およびラベル付けする必要があります。

デフォルトでは、spaCyのトークナイザーは絵文字を個別のトークンに分割します。これは、1つ以上の絵文字トークンのパターンを作成できることを意味します。有効なハッシュタグは通常、#と空白のないASCII文字のシーケンスで構成されているため、簡単に一致させることができます。

from spacy.lang.en import English
from spacy.matcher import Matcher

nlp = English()  # We only want the tokenizer, so no need to load a model
matcher = Matcher(nlp.vocab)

pos_emoji = ["😀", "😃", "😂", "🤣", "😊", "😍"]  # Positive emoji
neg_emoji = ["😞", "😠", "😩", "😢", "😭", "😒"]  # Negative emoji

# Add patterns to match one or more emoji tokens
pos_patterns = [[{"ORTH": emoji}] for emoji in pos_emoji]
neg_patterns = [[{"ORTH": emoji}] for emoji in neg_emoji]

# Function to label the sentiment
def label_sentiment(matcher, doc, i, matches):
    match_id, start, end = matches[i]
    if doc.vocab.strings[match_id] == "HAPPY":  # Don't forget to get string!
        doc.sentiment += 0.1  # Add 0.1 for positive sentiment
    elif doc.vocab.strings[match_id] == "SAD":
        doc.sentiment -= 0.1  # Subtract 0.1 for negative sentiment

matcher.add("HAPPY", label_sentiment, *pos_patterns)  # Add positive pattern
matcher.add("SAD", label_sentiment, *neg_patterns)  # Add negative pattern

# Add pattern for valid hashtag, i.e. '#' plus any ASCII token
matcher.add("HASHTAG", None, [{"ORTH": "#"}, {"IS_ASCII": True}])

doc = nlp("Hello world 😀 #MondayMotivation")
matches = matcher(doc)
for match_id, start, end in matches:
    string_id = doc.vocab.strings[match_id]  # Look up string ID
    span = doc[start:end]
    print(string_id, span.text)

on_matchコールバックは各一致のIDを受け取るため、同じ関数を使用して、ポジティブパターンとネガティブパターンの両方の感情割り当てを処理できます。簡単にするために、0.1ポイントを加算または減算します。このように、スコアには、正と負の絵文字も含めて、絵文字の組み合わせも反映されます。

Emojipediaのようなライブラリを使用すると、各絵文字の簡単な説明を取得することもできます。

from emojipedia import Emojipedia  # Installation: pip install emojipedia
from spacy.tokens import Span  # Get the global Span object

Span.set_extension("emoji_desc", default=None)  # Register the custom attribute

def label_sentiment(matcher, doc, i, matches):
    match_id, start, end = matches[i]
    if doc.vocab.strings[match_id] == "HAPPY":  # Don't forget to get string!
        doc.sentiment += 0.1  # Add 0.1 for positive sentiment
    elif doc.vocab.strings[match_id] == "SAD":
        doc.sentiment -= 0.1  # Subtract 0.1 for negative sentiment
    span = doc[start:end]
    emoji = Emojipedia.search(span[0].text)  # Get data for emoji
    span._.emoji_desc = emoji.title  # Assign emoji description

ハッシュタグにラベルを付けるために、それぞれのトークンに設定されたカスタム属性を使用できます。

import spacy
from spacy.matcher import Matcher
from spacy.tokens import Token

nlp = spacy.load("en_core_web_sm")
matcher = Matcher(nlp.vocab)

# Add pattern for valid hashtag, i.e. '#' plus any ASCII token
matcher.add("HASHTAG", None, [{"ORTH": "#"}, {"IS_ASCII": True}])

# Register token extension
Token.set_extension("is_hashtag", default=False)

doc = nlp("Hello world 😀 #MondayMotivation")
matches = matcher(doc)
hashtags = []
for match_id, start, end in matches:
    if doc.vocab.strings[match_id] == "HASHTAG":
        hashtags.append(doc[start:end])
with doc.retokenize() as retokenizer:
    for span in hashtags:
        retokenizer.merge(span)
        for token in span:
            token._.is_hashtag = True

for token in doc:
    print(token.text, token._.is_hashtag)

ソーシャルメディアの投稿のストリームを処理するには、Language.pipeを使用できます。これにより、Matcher.pipeに渡すことができるDocオブジェクトのストリームが返されます。

docs = nlp.pipe(LOTS_OF_TWEETS)
matches = matcher.pipe(docs)

3. PhraseMatcher

大きな用語リストを照合する必要がある場合は、「PhraseMatcher」を使用して、トークンパターンの代わりにDocオブジェクトを作成することもできます。Docパターンには、単一または複数のトークンを含めることができます。

3-1. フレーズパターンの追加

import spacy
from spacy.matcher import PhraseMatcher

nlp = spacy.load('en_core_web_sm')

# PhraseMatcherの作成と追加
matcher = PhraseMatcher(nlp.vocab)
terms = ["Barack Obama", "Angela Merkel", "Washington, D.C."]
patterns = [nlp.make_doc(text) for text in terms]
matcher.add("TerminologyList", None, *patterns)

doc = nlp("German Chancellor Angela Merkel and US President Barack Obama "
    "converse in the Oval Office inside the White House in Washington, D.C.")

matches = matcher(doc)
for match_id, start, end in matches:
    span = doc[start:end]
    print(span.text)

照合するパターンとテキストの両方を処理するためにspaCyが使用されるため、特定のトークン化について心配する必要はありません。たとえば、nlp( "Washington, DC")を渡すだけで、書き込む必要はありません。

【情報】 パターン作成に関する注意
パターンを作成するには、各フレーズをnlpオブジェクトで処理する必要があります。モデルをロードしている場合、ループまたはリスト内包でこれを行うと、簡単に非効率になり、遅くなる可能性があります。トークン化と字句属性のみが必要な場合は、代わりにnlp.make_docを実行できます。これにより、トークナイザーのみが実行されます。さらに速度を上げるために、テキストをストリームとして処理するnlp.tokenizer.pipe()を使用することもできます。
- patterns = [nlp(term) for term in LOTS_OF_TERMS]
+ patterns = [nlp.make_doc(term) for term in LOTS_OF_TERMS]
+ patterns = list(nlp.tokenizer.pipe(LOTS_OF_TERMS))

3-2. 他のトークン属性のマッチング

デフォルトでは、「PhraseMatcher」は逐語的なトークンテキストと一致します。初期化時にattr引数を設定することにより、フレーズパターンを一致したドキュメントと比較するときにMatcherが使用するトークン属性を変更できます。たとえば、属性LOWERを使用すると、Token.lowerで一致し、大文字と小文字を区別しない一致パターンを作成できます。

from spacy.lang.en import English
from spacy.matcher import PhraseMatcher

nlp = English()
matcher = PhraseMatcher(nlp.vocab, attr="LOWER")
patterns = [nlp.make_doc(name) for name in ["Angela Merkel", "Barack Obama"]]
matcher.add("Names", None, *patterns)

doc = nlp("angela merkel and us president barack Obama")
for match_id, start, end in matcher(doc):
    print("Matched based on lowercase token text:", doc[start:end])
【情報】 パターン作成に関する注意
ここでの例では、nlp.make_docを使用して、他のパイプラインコンポーネントを実行せずに、Docオブジェクトパターンを可能な限り効率的に作成します。照合するトークン属性がパイプラインコンポーネントによって設定されている場合は、パターンを作成するときにパイプラインコンポーネントが実行されていることを確認してください。たとえば、POSまたはLEMMAで一致させるには、パターンDocオブジェクトに、タガーによって設定された品詞タグが必要です。nlp.make_docの代わりにパターンテキストでnlpオブジェクトを呼び出すか、nlp.disable_pipesを使用してコンポーネントを選択的に無効にすることができます。

もう1つの考えられる使用例は、IPアドレスなどの番号トークンをその形状に基づいて照合することです。つまり、これらの文字列がどのようにトークン化されるかを心配する必要はなく、いくつかの例に基づいてトークンとトークンの組み合わせを見つけることができます。 ここでは、ddd.d.d.dとddd.ddd.d.dの形状を一致させています。

from spacy.lang.en import English
from spacy.matcher import PhraseMatcher

nlp = English()
matcher = PhraseMatcher(nlp.vocab, attr="SHAPE")
matcher.add("IP", None, nlp("127.0.0.1"), nlp("127.127.0.0"))

doc = nlp("Often the router will have an IP address such as 192.168.1.1 or 192.168.2.1.")
for match_id, start, end in matcher(doc):
    print("Matched based on token shape:", doc[start:end])

理論的には、POSなどの属性についても同じことが機能します。たとえば、品詞タグに基づいて一致するパターンnlp("I like cats")は、「Ilovedogs」の一致を返します。IS_PUNCTなどのブールフラグを照合して、パターンと同じ句読点トークンと非句読点トークンのシーケンスを持つフレーズを照合することもできます。ただし、これは混乱する可能性があり、1つまたは2つのトークンパターンを作成するよりも多くの利点はありません。

4. EntityRuler

「EntityRuler」は、パターン辞書に基づいて「名前付きエンティティ」を追加できるコンポーネントです。

4-1. エンティティパターン

「エンティティパターン」は、パターンが一致した場合に割り当てるラベル「label」とパターン「pattern」の2つのキーを持つ辞書です。 パターンは、次の2種類があります。

(1) フレーズパターン

{"label": "ORG", "pattern": "Apple"}

(2) トークンパターン

{"label": "GPE", "pattern": [{"LOWER": "san"}, {"LOWER": "francisco"}]}

4-2. EntityRulerの使用

「EntityRuler」は、nlp.add_pipeを介して追加するパイプラインコンポーネントです。nlpオブジェクトがテキストで呼び出される度に、ドキュメント内で一致するものが検索され、指定されたパターンラベルをエンティティラベルとして使用して、doc.entsに追加されます。一致するものが重複する場合は、ほとんどのトークンに一致するパターンが優先されます。それらも同じ長さである場合は、ドキュメントで最初に発生するパターンが優先されます。

from spacy.lang.en import English
from spacy.pipeline import EntityRuler

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

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

「EntityRuler」は、spaCyの既存の統計モデルと統合し、名前付きエンティティレコグナイザーを強化するように設計されています。「ner」コンポーネントの前に追加された場合、エンティティ認識機能は既存のエンティティスパンを尊重し、その周りの予測を調整します。これにより、場合によっては精度が大幅に向上します。「ner」コンポーネントの後に追加された場合、EntityRulerは、モデルによって予測された既存のエンティティと重複しない場合にのみ、doc.entsにスパンを追加します。 重複するエンティティを上書きするには、初期化時にoverwrite_ents = Trueを設定できます。

import spacy
from spacy.pipeline import EntityRuler

nlp = spacy.load("en_core_web_sm")
ruler = EntityRuler(nlp)
patterns = [{"label": "ORG", "pattern": "MyCorp Inc."}]
ruler.add_patterns(patterns)
nlp.add_pipe(ruler)

doc = nlp("MyCorp Inc. is a company in the U.S.")
print([(ent.text, ent.label_) for ent in doc.ents])

4-2. EntityRulerパターンの検証とデバッグ

EntityRulerは、validate = Trueオプションを使用して、JSONスキーマに対してパターンを検証できます。 詳細については、パターンの検証とデバッグを参照してください。

from spacy.lang.en import English
from spacy.pipeline import EntityRuler

nlp = English()
ruler = EntityRuler(nlp)
patterns = [
    {"label": "ORG", "pattern": "Apple", "id": "apple"},
    {"label": "GPE", "pattern": [{"LOWER": "san"}, {"LOWER": "francisco"}], "id": "san-francisco"},
    {"label": "GPE", "pattern": [{"LOWER": "san"}, {"LOWER": "fran"}], "id": "san-francisco"}]
ruler.add_patterns(patterns)
nlp.add_pipe(ruler)

doc1 = nlp("Apple is opening its first big office in San Francisco.")
print([(ent.text, ent.label_, ent.ent_id_) for ent in doc1.ents])

doc2 = nlp("Apple is opening its first big office in San Fran.")
print([(ent.text, ent.label_, ent.ent_id_) for ent in doc2.ents])

id属性がEntityRulerパターンに含まれている場合、一致したエンティティのent_id_プロパティは、パターンで指定されたidに設定されます。したがって、上記の例では、「SanFrancisco」と「SanFran」が両方とも同じエンティティであることが簡単にわかります。

4-3. パターンファイルの使用

to_diskとfrom_diskを使用することで、パターンをJSONL(改行区切りのJSON)に読み書きできます。

{"label": "ORG", "pattern": "Apple"}
{"label": "GPE", "pattern": [{"LOWER": "san"}, {"LOWER": "francisco"}]}
ruler.to_disk("./patterns.jsonl")
new_ruler = EntityRuler(nlp).from_disk("./patterns.jsonl")
【情報】 Prodigyとの統合
Prodigyアノテーションツールを使用している場合は、名前付きエンティティとテキスト分類ラベルをブートストラップすることで、これらのパターンファイルを認識できる可能性があります。EntityRulerのパターンは同じ構文に従うため、spaCyで既存のProdigyパターンファイルを使用できます。その逆も可能です。

「EntityRuler」がパイプラインに追加されているnlpオブジェクトを保存すると、そのパターンはモデルディレクトリに自動的にエクスポートされます。

nlp = spacy.load("en_core_web_sm")
ruler = EntityRuler(nlp)
ruler.add_patterns([{"label": "ORG", "pattern": "Apple"}])
nlp.add_pipe(ruler)
nlp.to_disk("/path/to/model")

保存されたモデルには、meta.jsonの「pipeline」設定に「entity_ruler」が含まれ、モデルディレクトリにはパターンを含むファイルentityruler.jsonlが含まれています。モデルを再度ロードすると、EntityRulerを含むすべてのパイプラインコンポーネントが復元され、逆シリアル化されます。 これにより、バイナリの重みとルールが含まれた強力なモデルパッケージを出荷できます。

4-4. 多数のフレーズパターンの使用

大量のフレーズパターン(約10000を超える)を使用する場合は、EntityRulerのadd_patterns関数がどのように機能するかを理解しておくと便利です。フレーズパターンごとに、EntityRulerはnlpオブジェクトを呼び出してdocオブジェクトを作成します。これは、たとえばPOSタガーを使用して既存のパイプラインの最後にEntityRulerを追加しようとし、パターンのPOS署名に基づいて一致を抽出する場合に発生します。

この場合、EntityRulerにphrase_matcher_attr = "POS"の構成値を渡します。

大きなリスト内のすべてのパターンで完全な言語パイプラインを実行すると、線形にスケーリングされるため、大量のフレーズパターンで長い時間がかかる可能性があります。

spaCy 2.2.4以降、add_patterns関数は、すべてのフレーズパターンでnlp.pipeを使用するようにリファクタリングされ、それぞれ5,000〜100,000のフレーズパターンで約10倍から20倍の速度が得られるようになりました。このスピードアップがあっても、add_patterns関数にはまだ長い時間がかかる可能性があります。

この関数をより高速に実行するための簡単な回避策は、フレーズパターンを追加するときに他の言語パイプを無効にすることです。

entityruler = EntityRuler(nlp)
patterns = [{"label": "TEST", "pattern": str(i)} for i in range(100000)]

other_pipes = [p for p in nlp.pipe_names if p != "tagger"]
with nlp.disable_pipes(*other_pipes):
    entityruler.add_patterns(patterns)

5. ルールとモデルの組み合わせ

ルールとモデルは様々な方法で組み合わせることができます。 ルールは、特定のトークンのタグ、エンティティ、または文の境界を事前設定することにより、モデルの精度を向上させるために使用できます。モデルは通常、これらの事前設定されたアノテーションを尊重します。これにより、他の決定の精度が向上する場合があります。モデルの後にルールを使用して、一般的なエラーを修正することもできます。最後に、ルールは、より抽象的なロジックを実装するために、モデルによって設定された属性を参照できます。

5-1. 例 : 名前付きエンティティの展開

事前学習された名前付きエンティティ認識モデルを使用してテキストから情報を抽出すると、予測されるスパンに、探しているエンティティの一部しか含まれていない場合があります。これは、モデルがエンティティを誤って予測した場合に発生することがあります。また、元の学習コーパスで定義されたエンティティタイプの方法が、アプリに必要な方法と一致しない場合にも発生します。

たとえば、学習を受けたコーパスspaCyの英語モデルでは、PERSONエンティティを「Mr.」のような肩書きのない単なる人物名として定義しています。これは、エンティティタイプをナレッジベースに簡単に解決できるため、理にかなっています。しかし、アプリにタイトルを含むフルネームが必要な場合はどうでしょうか。

import spacy
​
nlp = spacy.load("en_core_web_sm")
doc = nlp("Dr. Alex Smith chaired first board meeting of Acme Corp Inc.")
print([(ent.text, ent.label_) for ent in doc.ents])

タイトルを含むスパンの例を増やしてモデルを更新することで、PERSONエンティティの新しい定義をモデルに教えることはできますが、これは最も効率的なアプローチではない可能性があります。既存のモデルは200万語以上で学習されているため、エンティティタイプの定義を完全に変更するには、多くの学習サンプルが必要になる場合があります。ただし、予測されたPERSONエンティティがすでにある場合は、ルールを使用して、タイトルが付いているかどうかを確認し、付いている場合は、エンティティスパンを1トークン拡張できます。結局のところ、このサンプルのすべてのタイトルに共通しているのは、それらが発生した場合、それらはpersonエンティティの直前の前のトークンで発生するということです。

from spacy.tokens import Span

def expand_person_entities(doc):
    new_ents = []
    for ent in doc.ents:
        # Only check for title if it's a person and not the first token
        if ent.label_ == "PERSON" and ent.start != 0:
            prev_token = doc[ent.start - 1]
            if prev_token.text in ("Dr", "Dr.", "Mr", "Mr.", "Ms", "Ms."):
                new_ent = Span(doc, ent.start - 1, ent.end, label=ent.label)
                new_ents.append(new_ent)
            else:
                new_ents.append(ent)
        else:
            new_ents.append(ent)
    doc.ents = new_ents
    return doc

上記の関数は、Docオブジェクトを受け取り、そのdoc.entsを変更して、それを返します。これはまさにパイプラインコンポーネントが行うことなので、nlpオブジェクトでテキストを処理するときに自動的に実行されるようにするために、nlp.add_pipeを使用して現在のパイプラインに追加できます。

import spacy
from spacy.tokens import Span

nlp = spacy.load("en_core_web_sm")

def expand_person_entities(doc):
    new_ents = []
    for ent in doc.ents:
        if ent.label_ == "PERSON" and ent.start != 0:
            prev_token = doc[ent.start - 1]
            if prev_token.text in ("Dr", "Dr.", "Mr", "Mr.", "Ms", "Ms."):
                new_ent = Span(doc, ent.start - 1, ent.end, label=ent.label)
                new_ents.append(new_ent)
        else:
            new_ents.append(ent)
    doc.ents = new_ents
    return doc

# Add the component after the named entity recognizer
nlp.add_pipe(expand_person_entities, after='ner')

doc = nlp("Dr. Alex Smith chaired first board meeting of Acme Corp Inc.")
print([(ent.text, ent.label_) for ent in doc.ents])

別のアプローチは、._.person_titleのような拡張属性に追加し、それをSpanオブジェクト(doc.entsのエンティティスパンを含む)に追加することです。ここでの利点は、エンティティテキストがそのまま残り、ナレッジベースで名前を検索するために引き続き使用できることです。次の関数は、Spanオブジェクトを受け取り、前のトークンがPERSONエンティティであるかどうかを確認し、見つかった場合はタイトルを返します。Span.doc属性を使用すると、スパンの親ドキュメントに簡単にアクセスできます。

def get_person_title(span):
    if span.label_ == "PERSON" and span.start != 0:
        prev_token = span.doc[span.start - 1]
        if prev_token.text in ("Dr", "Dr.", "Mr", "Mr.", "Ms", "Ms."):
            return prev_token.text

これで、Span.set_extensionメソッドを使用して、getter関数としてget_person_titleを使用し、カスタム拡張属性「person_title」を追加できます。

import spacy
from spacy.tokens import Span

nlp = spacy.load("en_core_web_sm")

def get_person_title(span):
    if span.label_ == "PERSON" and span.start != 0:
        prev_token = span.doc[span.start - 1]
        if prev_token.text in ("Dr", "Dr.", "Mr", "Mr.", "Ms", "Ms."):
            return prev_token.text

# Register the Span extension as 'person_title'
Span.set_extension("person_title", getter=get_person_title)

doc = nlp("Dr Alex Smith chaired first board meeting of Acme Corp Inc.")
print([(ent.text, ent.label_, ent._.person_title) for ent in doc.ents

5-2. 例 : エンティティ、品詞タグ、および依存関係の解析を使用する

たとえば、専門家の経歴を解析して、個人名と会社名を抽出し、それが現在働いている会社なのか、以前の会社なのかを考えてみましょう。1つのアプローチは、CURRENT_ORGとPREVIOUS_ORGを予測するように名前付きエンティティ認識機能を学習することですが、この区別は非常に微妙であり、エンティティ認識機能が学習に苦労する可能性があります。「Acme Corp Inc.」については何もありません 本質的に「現在」または「以前」です。

ただし、文の構文にはいくつかの非常に重要な手がかりがあります。「work」などのトリガーワード、過去形か現在形か、会社名が付けられているかどうか、人が主語かどうかを確認できます。この情報はすべて、品詞タグと依存関係の解析で利用できます。

import spacy

nlp = spacy.load("en_core_web_sm")
doc = nlp("Alex Smith worked at Acme Corp Inc.")
print([(ent.text, ent.label_) for ent in doc.ents])

このサンプルでは、「worked」は文のルートであり、過去形の動詞です。その主題は、働いた人である「アレックス・スミス」です。「at Acme Corp Inc.」 動詞「worked」に付けられた前置詞句です。この関係を抽出するには、まず、予測されたPERSONエンティティを調べ、その頭を見つけて、「仕事」などのトリガーワードに関連付けられているかどうかを確認します。次に、頭に付けられた前置詞句と、それらにORGエンティティが含まれているかどうかを確認できます。最後に、会社の所属が現在のものであるかどうかを判断するために、頭の品詞タグを確認できます。

person_entities = [ent for ent in doc.ents if ent.label_ == "PERSON"]
for ent in person_entities:
    # Because the entity is a spans, we need to use its root token. The head
    # is the syntactic governor of the person, e.g. the verb
    head = ent.root.head
    if head.lemma_ == "work":
        # Check if the children contain a preposition
        preps = [token for token in head.children if token.dep_ == "prep"]
        for prep in preps:
            # Check if tokens part of ORG entities are in the preposition's
            # children, e.g. at -> Acme Corp Inc.
            orgs = [token for token in prep.children if token.ent_type_ == "ORG"]
            # If the verb is in past tense, the company was a previous company
            print({'person': ent, 'orgs': orgs, 'past': head.tag_ == "VBD"})

テキストを処理するときにこのロジックを自動的に適用するために、カスタムパイプラインコンポーネントとしてnlpオブジェクトに追加できます。上記のロジックでは、エンティティが単一のトークンにマージされることも想定されています。spaCyには、それを処理する便利な組み込みのmerge_entitiesが付属しています。結果を出力するだけでなく、エンティティSpanのカスタム属性(たとえば、._.orgsまたは._.prev_orgsと._.current_orgs)に書き込むこともできます。

import spacy
from spacy.pipeline import merge_entities
from spacy import displacy

nlp = spacy.load("en_core_web_sm")

def extract_person_orgs(doc):
    person_entities = [ent for ent in doc.ents if ent.label_ == "PERSON"]
    for ent in person_entities:
        head = ent.root.head
        if head.lemma_ == "work":
            preps = [token for token in head.children if token.dep_ == "prep"]
            for prep in preps:
                orgs = [token for token in prep.children if token.ent_type_ == "ORG"]
                print({'person': ent, 'orgs': orgs, 'past': head.tag_ == "VBD"})
    return doc

# To make the entities easier to work with, we'll merge them into single tokens
nlp.add_pipe(merge_entities)
nlp.add_pipe(extract_person_orgs)

doc = nlp("Alex Smith worked at Acme Corp Inc.")
# If you're not in a Jupyter / IPython environment, use displacy.serve
displacy.render(doc, options={'fine_grained': True})

上記の文の構造を「機能していた」などに変更すると、現在のロジックが失敗し、会社が過去の組織として正しく検出されないことがわかります。これは、ルートが分詞であり、時制情報が添付の助動詞「was」にあるためです。

これを解決するために、ルールを調整して、上記の構造もチェックできます。

def extract_person_orgs(doc):
    person_entities = [ent for ent in doc.ents if ent.label_ == "PERSON"]
    for ent in person_entities:
        head = ent.root.head
        if head.lemma_ == "work":
            preps = [token for token in head.children if token.dep_ == "prep"]
            for prep in preps:
                orgs = [t for t in prep.children if t.ent_type_ == "ORG"]
                aux = [token for token in head.children if token.dep_ == "aux"]
                past_aux = any(t.tag_ == "VBD" for t in aux)
                past = head.tag_ == "VBD" or head.tag_ == "VBG" and past_aux
                print({'person': ent, 'orgs': orgs, 'past': past})
    return doc

最終的なルールベースのシステムでは、データで発生する構造のタイプをカバーするために、いくつかの異なるコードパスが作成される可能性があります。

次回



この記事が気に入ったらサポートをしてみませんか?