テキストデータ抽出テクニック

独自の情報を抽出する:SpaCyによるカスタムエンティティ抽出ルール構築

Tags: Python, 自然言語処理, SpaCy, 情報抽出, カスタムエンティティ抽出

はじめに

業務で扱うテキストデータから、特定の種類の情報、例えば製品の固有コード、プロジェクト名、特定のフォーマットで記述された日付や金額、顧客レビュー中の特定の属性といった情報を抽出したい、というニーズは少なくないかと存じます。自然言語処理(NLP)の分野では、このような特定の情報群を「固有表現(Named Entity)」と呼び、その抽出は固有表現抽出(Named Entity Recognition; NER)というタスクとして広く研究・応用されています。

汎用的な固有表現抽出モデル(人名、組織名、地名など)は多くのNLPライブラリで提供されていますが、皆さんの業務固有のテキストデータに現れる独自の情報を抽出するには、標準的なモデルだけでは不十分な場合があります。正規表現も強力なツールですが、テキストのバリエーションに対応するには複雑になりすぎたり、限界があったりします。

本記事では、Pythonで広く使われているNLPライブラリであるSpaCyを活用し、独自のルールに基づいてテキストから特定の情報を抽出する「ルールベースカスタムエンティティ抽出」の手法について解説します。NLPライブラリの利用経験が少なくても、Pythonでの開発経験があれば理解できるよう、具体的なコード例を豊富に交えながら進めてまいります。

カスタムエンティティ抽出とは

一般的な固有表現抽出が、あらかじめ定義されたカテゴリ(人名、組織名、日付など)に属する単語やフレーズを識別するのに対し、カスタムエンティティ抽出は、ユーザーが定義した独自のカテゴリに属する情報をテキストから見つけ出すプロセスです。

例えば、以下のようなテキストから特定の情報を抽出したいとします。

これらの情報は、標準的なNERモデルでは「固有表現」として認識されない可能性が高いです。しかし、特定のパターンやキーワード、構造に着目することで、コンピュータに識別させることが可能になります。

なぜルールベースアプローチか?

カスタムエンティティ抽出の手法としては、大きく分けて以下の2つが考えられます。

  1. 統計的手法/機械学習アプローチ: 大量の教師データ(人間が正解のエンティティにタグ付けしたテキスト)を用意し、そのデータに基づいてモデルを学習させる方法です。高精度が期待できますが、データ準備にコストがかかり、なぜその結果になったのかがブラックボックスになりがちです。
  2. ルールベースアプローチ: あらかじめ人間がテキストの特徴を分析し、特定のパターンやキーワードの組み合わせといった「ルール」を定義する方法です。

本記事で取り上げるルールベースアプローチには、以下のような利点があります。

一方で、テキスト表現の多様性に対応するのが難しい、ルールの数が増えるとメンテナンスが大変になる、といった限界もあります。しかし、比較的フォーマットが決まっているテキスト(ログ、報告書、特定の入力フォームからのデータなど)や、対象となるエンティティの種類が限られている場合には、非常に効果的なアプローチです。

SpaCyによるルールベースカスタムエンティティ抽出

SpaCyは、ルールベースのマッチング機能として主に以下の2つを提供しています。これらを活用することで、柔軟なルール定義が可能になります。

  1. Matcher: トークンレベルのパターンマッチングを行います。単語そのものだけでなく、品詞、形態、フラグ(数字であるか、大文字であるかなど)といったトークンの属性に基づいた複雑なパターンを定義できます。
  2. PhraseMatcher: 単語のリストや句のリストに一致するテキストを見つけます。特定の用語集や辞書に基づいてエンティティを抽出したい場合に適しています。

Matcherを使ったパターン定義

Matcherは、トークンのシーケンスに対するパターンを定義します。パターンは辞書のリストで表現され、各辞書はシーケンス内の1つのトークンに適用される属性と値を指定します。

例えば、「"数量: " に続く数字」というパターンを考えます。

import spacy

# 日本語モデルをロード
# なければ python -m spacy download ja_core_news_sm を実行
nlp = spacy.load("ja_core_news_sm")

from spacy.matcher import Matcher

# Matcherオブジェクトを初期化。共有の語彙(Vocab)を渡す
matcher = Matcher(nlp.vocab)

# パターン定義
# トークン属性のリストとして定義します。
# 各辞書が1トークンに対応
pattern = [
    {"LOWER": "数量"}, # 1つ目のトークンは小文字で"数量"
    {"IS_PUNCT": True, "OP": "?"}, # 句読点が0個または1個(例: ":")
    {"IS_SPACE": True, "OP": "*"}, # スペースが0個以上
    {"LIKE_NUM": True} # 数字のように見えるトークン
]

# マッチャーにパターンを追加
# 第一引数はルールのID(文字列)、第二引数はマッチした際に実行する関数(今回はNone)、第三引数以降はパターン
matcher.add("QUANTITY_PATTERN", None, pattern)

# テキストの処理
text = "在庫リスト:数量: 150個、単価: 200円"
doc = nlp(text)

# マッチングの実行
matches = matcher(doc)

# 結果の表示
print("Matched Spans:")
for match_id, start, end in matches:
    span = doc[start:end] # マッチした範囲のSpanオブジェクトを取得
    print(f"  Match ID: {nlp.vocab.strings[match_id]}, Span: {span.text}, Start: {start}, End: {end}")

このコードでは、Matcherを使って「数量」という単語に続いて数字が来るパターンを定義しています。LIKE_NUMは「150個」のような数字を含むトークンにもマッチするため、より実用的なパターンを記述できます。OPキーは、そのトークンパターンがテキスト中で出現する回数を制御します (?: 0回または1回, *: 0回以上, +: 1回以上)。

PhraseMatcherを使った辞書マッチング

特定の単語リストやフレーズリストに一致するテキストを抽出したい場合は、PhraseMatcherが便利です。これは内部的に効率的なTrieデータ構造を利用しており、大きな辞書でも高速にマッチングが可能です。

例えば、特定の製品名のリストから製品名を抽出する場合を考えます。

import spacy

nlp = spacy.load("ja_core_news_sm")

from spacy.matcher import PhraseMatcher

# PhraseMatcherオブジェクトを初期化
matcher = PhraseMatcher(nlp.vocab)

# 抽出したい製品名のリスト
product_list = ["製品 XYZ-Alpha", "サービス Beta-V2", "旧モデル ABC"]

# リストの各要素をSpaCyのDocオブジェクトに変換
patterns = [nlp(text) for text in product_list]

# マッチャーにパターンを追加
# 第一引数はルールのID(文字列)、第二引数はマッチした際に実行する関数(今回はNone)、第三引数以降はパターン(Docオブジェクトのリスト)
matcher.add("PRODUCT_LIST", None, *patterns) # *patterns でリストを展開して渡す

# テキストの処理
text = "先日購入した製品 XYZ-Alpha が期待通りでした。新しいサービス Beta-V2 も試したいです。"
doc = nlp(text)

# マッチングの実行
matches = matcher(doc)

# 結果の表示
print("Matched Spans:")
for match_id, start, end in matches:
    span = doc[start:end]
    print(f"  Match ID: {nlp.vocab.strings[match_id]}, Span: {span.text}, Start: {start}, End: {end}")

PhraseMatcherは、単語リスト内のフレーズと完全に一致するテキスト部分を見つけます。これは、特定のキーワードや用語集に基づいたエンティティ抽出に非常に有効です。

マッチ結果をエンティティとして追加

MatcherPhraseMatcherで見つけた一致箇所を、SpaCyのDocオブジェクトの.ents属性にカスタムエンティティとして追加することで、後続の処理で活用しやすくなります。これは通常、カスタムパイプラインコンポーネントとして実装します。

import spacy
from spacy.matcher import Matcher, PhraseMatcher
from spacy.tokens import Span

# 日本語モデルをロード
nlp = spacy.load("ja_core_news_sm")

# マッチャーとフレーズマッチャーを準備
matcher = Matcher(nlp.vocab)
phrase_matcher = PhraseMatcher(nlp.vocab)

# 例:特定の注文番号パターン (例: XX999-YYYYQZ)
order_pattern = [{"TEXT": {"REGEX": "[A-Z]{2}[0-9]{3}"}}, {"TEXT": "-"}, {"TEXT": {"REGEX": "[0-9]{4}Q[1-4]"}}]
matcher.add("ORDER_ID", None, order_pattern)

# 例:特定の部署名リスト
department_list = ["営業部", "開発部", "広報部"]
department_patterns = [nlp(text) for text in department_list]
phrase_matcher.add("DEPARTMENT", None, *department_patterns)

# カスタムパイプラインコンポーネントの定義
@spacy.Language.component("custom_entity_extractor")
def custom_entity_extractor_component(doc):
    """MatcherとPhraseMatcherの結果をエンティティとしてDocに追加するカスタムコンポーネント"""

    # Matcherによるマッチング
    matches = matcher(doc)
    # PhraseMatcherによるマッチング
    phrase_matches = phrase_matcher(doc)

    # 新しいエンティティのリストを作成
    # 既存のエンティティ(もしあれば)は残しておく
    new_ents = list(doc.ents)

    # Matcherの結果をSpanオブジェクトに変換し、新しいエンティティリストに追加
    for match_id, start, end in matches:
        span_text = doc[start:end].text
        # 重複や既存エンティティとの重なりを避ける処理(ここではシンプルに直接追加)
        # 実際には、既存のdoc.entsと新しいspanの範囲を比較し、
        # 重複しないか、あるいは優先順位に基づいて追加するかを判断します。
        try:
             # 新しいSpanオブジェクトを作成し、エンティティタイプを指定
             new_ent = Span(doc, start, end, label=nlp.vocab.strings[match_id])
             new_ents.append(new_ent)
        except ValueError:
             # 同一範囲に既存エンティティがあるなどでSpan作成に失敗する場合がある
             print(f"Warning: Could not add span '{span_text}' as entity.")
             pass


    # PhraseMatcherの結果をSpanオブジェクトに変換し、新しいエンティティリストに追加
    for match_id, start, end in phrase_matches:
        span_text = doc[start:end].text
        try:
            new_ent = Span(doc, start, end, label=nlp.vocab.strings[match_id])
            new_ents.append(new_ent)
        except ValueError:
            print(f"Warning: Could not add span '{span_text}' as entity.")
            pass

    # Docオブジェクトのエンティティを更新
    # 重複や競合を適切に処理するためには、スパンのソートや重複チェックが必要です。
    # SpaCy v3.xでは、EntSegmenterなどのコンポーネントを組み合わせるのが推奨される場合もあります。
    # ここでは簡易的にセットに変換して重複をなくし、元のdoc.entsも加えて結合します。
    # SpaCyのSpanオブジェクトはハッシュ化可能ですが、エンティティリストとして扱う際は注意が必要です。
    # 理想的には、範囲とラベルをキーにした辞書などを使うと重複排除やソートが容易です。
    # 例:start, end, label をタプルにしてsetで重複排除後、再度Spanに変換し直す
    seen_spans = set()
    final_ents = []
    for ent in sorted(new_ents, key=lambda e: (e.start, e.end)): # 開始位置でソート
         span_key = (ent.start, ent.end, ent.label)
         if span_key not in seen_spans:
             final_ents.append(ent)
             seen_spans.add(span_key)

    doc.ents = final_ents

    return doc

# パイプラインにカスタムコンポーネントを追加
# 既存の'ner'コンポーネントより前に実行することで、カスタムエンティティを先に検出できます。
# 'after': 'ner' とすると、既存のNERの結果を上書き/拡張する形になります。
nlp.add_pipe("custom_entity_extractor", after="ner") # 既存のNERの後に追加する例

# テキストの処理
text = "開発部の山田さんが注文番号 AB123-2023Q3 の件で営業部と連携中です。"
doc = nlp(text)

# 結果の表示
print("Entities:")
for ent in doc.ents:
    print(f"  {ent.text} ({ent.label_})")

この例では、Matcherで注文番号のパターンを、PhraseMatcherで部署名のリストを定義し、これらのマッチ結果をcustom_entity_extractorというカスタムパイプラインコンポーネント内でdoc.entsに追加しています。このようにパイプラインに組み込むことで、標準のトークナイゼーションやその他の処理フローの中でカスタム抽出を実行できるようになります。

より高度なパターン定義

Matcherのパターン定義では、トークンの様々な属性や、正規表現を使用できます。これにより、より複雑なテキスト構造に対応できます。

よく使われるトークン属性の例:

これらの属性とオペレーター(OP: ?, *, +, ! - 否定)を組み合わせることで、柔軟なパターンを作成できます。例えば、「動詞に続く名詞句」といった依存構造に基づいたパターンも定義可能です(ただし、依存構造解析の結果が正確であることが前提となります)。

実務への応用例

ルールベースカスタムエンティティ抽出は、様々な業務課題に応用できます。

パフォーマンスと考慮事項

ルールベースアプローチは、ルールの数や複雑さ、そして対象となるテキストの量によってパフォーマンスが変動します。

システムに組み込む際は、抽出処理にかかる時間を考慮し、バッチ処理にするかリアルタイム処理にするか、あるいは非同期処理を取り入れるかなどを検討する必要があります。また、抽出結果の品質を継続的にモニタリングし、必要に応じてルールを改善していく運用体制も重要です。

まとめ

本記事では、SpaCyライブラリを用いたルールベースのカスタムエンティティ抽出手法について解説しました。MatcherPhraseMatcherを活用することで、業務固有のテキストデータから特定の情報を効率的に抽出するシステムを構築することが可能です。

これらの手法は、正規表現だけでは対応が難しいが、統計的手法のための大量データ準備は困難、といった場合に強力な選択肢となります。コード例を参考に、ぜひ皆様の業務課題におけるテキストからの情報抽出に挑戦していただければ幸いです。ルールの構築とメンテナンスには労力が伴いますが、対象ドメインの専門知識を活かせる点が大きな魅力と言えるでしょう。

複雑なケースや高い汎用性が求められる場合には、機械学習アプローチや、ルールベースと統計的手法を組み合わせたハイブリッドなアプローチも視野に入れることになりますが、まずはルールベースでスモールスタートしてみるのも良い方法です。