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

正規表現だけでは難しい:PythonとNLPによる複雑条件テキストパターン抽出

Tags: Python, NLP, テキスト抽出, SpaCy, 依存構造解析

はじめに:正規表現の限界とNLPによる拡張

業務でテキストデータから特定の情報を抽出する際、正規表現は強力なツールです。単純なパターンや形式が固定されたテキストからの抽出には非常に有効ですが、自然言語、特に非構造化テキストにおいて、特定の単語が存在するかどうかだけでなく、その単語が文脈の中でどのような役割を果たしているか特定の条件を満たす別の単語とどのような関係にあるか、といった複雑な条件を考慮した抽出は、正規表現だけでは困難になることが少なくありません。

例えば、「新しい機能について問い合わせたいが、既存の契約に関する問い合わせではない」というテキストから、「新しい機能に関する問い合わせ」のみを抽出したい場合、単に「問い合わせ」という単語を正規表現でマッチさせるだけでは不十分です。「既存の契約に関する」という否定的な文脈や条件を除外する必要があります。このような場合、単語そのもののマッチングだけでなく、文の構造や単語間の意味的な関係を理解する自然言語処理(NLP)の技術が有効になります。

本記事では、PythonとNLPライブラリ(主にSpaCyを例に解説します)を活用し、正規表現では対応が難しい複雑な条件や文脈を考慮したテキストパターン抽出を実現する手法について解説します。品詞情報や依存構造解析などのNLPが提供する情報を利用することで、より柔軟で精度の高い情報抽出が可能になることを示します。

NLPによるテキストの構造理解:品詞タグと依存構造

自然言語処理ライブラリは、単なる文字列の分割(トークン化)だけでなく、テキストの言語的な構造を解析する機能を提供します。テキスト抽出において特に有用なのが、品詞タグ付け(Part-of-Speech Tagging, POS Tagging)依存構造解析(Dependency Parsing)です。

品詞タグ付け

品詞タグ付けは、文中の各単語(トークン)に、その単語が名詞、動詞、形容詞、副詞、助詞など、どのような品詞であるかを示すタグを付与する処理です。例えば、「私は新しい機能について問い合わせたいです」という文であれば、「私」は名詞、「新しい」は形容詞、「機能」は名詞、「問い合わせたい」は動詞、といった情報が得られます。

この情報を使うことで、「ある単語の直後にある名詞を抽出する」といった、正規表現では表現しにくい品詞に基づいたパターンを定義できるようになります。

依存構造解析

依存構造解析は、文中の単語間の文法的な依存関係を分析し、どの単語がどの単語を修飾しているか、主語は何か、目的語は何か、といった構造を明らかにする処理です。解析結果は、単語と単語を矢印で結んだツリー構造やグラフ構造で表現されることが一般的です。

例えば、「顧客が製品の不具合を報告した」という文を解析すると、「報告した」が述語の中心となり、「顧客」がその主語(nsubj: nominal subject)、「不具合」が目的語(obj: object)、「製品の」が「不具合」を修飾する(nmod: nominal modifier)といった関係性が分かります。

これらの依存関係の情報を使うことで、「特定の動詞の主語を抽出する」「ある名詞を修飾している形容詞を抽出する」といった、単語間の文法的な役割や関係性に基づいた複雑なパターンを定義できるようになります。これは、正規表現が単語の並び(シーケンス)に焦点を当てるのに対し、依存構造は単語間の論理的なつながりに焦点を当てる点で大きく異なります。

PythonとSpaCyによる実装例

ここでは、PythonのNLPライブラリであるSpaCyを用いて、上記の品詞情報や依存構造情報を活用したテキスト抽出の具体例を示します。SpaCyは高速で、依存構造解析なども高い精度で実行できるため、実務での利用に適しています。

まず、SpaCyのインストールと日本語モデルのダウンロードが必要です。

pip install spacy
python -m spacy download ja_core_news_sm

以下のコード例では、ja_core_news_smモデルを使用します。

1. 品詞タグを利用した抽出

特定の品詞の後に続く名詞を抽出する例です。「〜に関するお問い合わせ」のようなパターンで、「お問い合わせ」の前にくる名詞を抽出することを想定します。

import spacy

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

text = "新しい機能に関するお問い合わせは、技術サポートまでご連絡ください。"
doc = nlp(text)

extracted_topics = []
# トークンごとに処理
for token in doc:
    # 現在のトークンが「に関する」(助詞+動詞の接尾辞など、モデルにより異なる場合あり)に近く、
    # かつその前のトークンが名詞であるパターンを探す
    # ここでは簡略化し、「お問い合わせ」の直前の名詞を抽出する例とします
    if token.text == "お問い合わせ":
        # 直前のトークンが存在し、かつ名詞であれば抽出
        if token.i > 0 and doc[token.i - 1].pos_ == "NOUN":
             extracted_topics.append(doc[token.i - 1].text)

print(f"元のテキスト: {text}")
print(f"抽出された話題(品詞ベース): {extracted_topics}")

text2 = "お見積りに関するご連絡をお待ちしております。"
doc2 = nlp(text2)
extracted_topics2 = []
for token in doc2:
     if token.text == "ご連絡": # モデルによっては「ご連絡」も名詞と捉えられる場合がある
        if token.i > 0 and doc2[token.i - 1].pos_ == "NOUN":
            extracted_topics2.append(doc2[token.i - 1].text)

# SpaCyのMatcherと属性パターンを使うとより構造的に記述できます
from spacy.matcher import Matcher

matcher = Matcher(nlp.vocab)
# パターン例: [名詞] + [助詞等] + [名詞/動詞] (例: 機能に関するお問い合わせ)
# SpaCyのDoc, Token, Spanオブジェクトの属性を使います
pattern = [
    {"POS": "NOUN"},
    {"TEXT": "に関する", "OP": "?"}, # 「に関する」はオプショナル
    {"POS": {"IN": ["NOUN", "VERB"]}, "TEXT": "お問い合わせ"} # 「お問い合わせ」が名詞か動詞の場合
]
matcher.add("QUERY_PATTERN", [pattern])

doc3 = nlp("新しい機能に関するお問い合わせは、既存契約についてのお問い合わせではありません。")
matches = matcher(doc3)

extracted_phrases = []
for match_id, start, end in matches:
    span = doc3[start:end] # マッチしたSpanオブジェクト
    # マッチしたSpanの最初のトークン(名詞)を抽出対象とする
    extracted_phrases.append(span[0].text)

print(f"\n元のテキスト: {doc3.text}")
print(f"抽出された話題(Matcher 品詞ベース): {extracted_phrases}")

この例では、「お問い合わせ」という単語の直前の単語が名詞である場合にその名詞を抽出しています。token.pos_で品詞タグを取得できます。Matcherを使うことで、より柔軟なトークンの並びと属性を組み合わせたパターンを定義できます。

2. 依存構造を利用した抽出(主語・目的語の特定)

特定の動詞の主語や目的語を抽出する例です。「[主語]が[目的語]を[動詞]した」という文脈から、[主語]と[目的語]を抽出することを想定します。

import spacy

nlp = spacy.load("ja_core_news_sm")

text = "顧客が製品の不具合を報告した。"
doc = nlp(text)

extracted_info = {}

for token in doc:
    # 動詞(VERB)を探す
    if token.pos_ == "VERB":
        subject = ""
        object = ""
        # その動詞に依存する子ノード(トークン)を調べる
        for child in token.children:
            # 子ノードが主語(名詞句、nominals subject)であれば
            if child.dep_ == "nsubj":
                 # 主語ノードとその修飾語を含むSpanを取得
                 subject = ''.join(t.text for t in child.subtree)
            # 子ノードが目的語(オブジェクト、object)であれば
            elif child.dep_ == "obj":
                 # 目的語ノードとその修飾語を含むSpanを取得
                 object = ''.join(t.text for t in child.subtree)

        if subject or object:
             extracted_info[token.text] = {"subject": subject, "object": object}


print(f"\n元のテキスト: {text}")
print(f"抽出された情報(依存構造ベース、動詞ごと): {extracted_info}")

text2 = "新しいチームがシステムを開発しました。"
doc2 = nlp(text2)
extracted_info2 = {}
for token in doc2:
     if token.pos_ == "VERB":
        subject = ""
        object = ""
        for child in token.children:
            if child.dep_ == "nsubj":
                 subject = ''.join(t.text for t in child.subtree)
            elif child.dep_ == "obj":
                 object = ''.join(t.text for t in child.subtree)
        if subject or object:
             extracted_info2[token.text] = {"subject": subject, "object": object}

print(f"\n元のテキスト: {text2}")
print(f"抽出された情報(依存構造ベース、動詞ごと): {extracted_info2}")

# 特定の動詞(例: 「報告した」)の主語と目的語を抽出するパターン
matcher_dep = Matcher(nlp.vocab)
# パターン例: [主語] + [目的語] + [特定の動詞]
# 主語と目的語は、特定の動詞に依存しているノードとして定義する
# 依存関係を使う場合は、規則ベースのMatcherよりもカスタムロジックの方が柔軟な場合が多いですが、
# Matcherでトークンの属性(dep_など)を使うことも可能です。
# より複雑な依存関係のパターンマッチングには、PhraseMatcherやカスタムコンポーネントが適していることもあります。
# ここでは、単純なMatcherと依存関係情報の組み合わせの基本を示します。

# SpaCyのTokenオブジェクトは head (親ノード), children (子ノード), dep_ (依存関係ラベル) などの属性を持ちます。
# これらを活用して、特定の単語(例えば動詞)に依存するノードを探し、その依存関係ラベルをチェックするコードを書くのが一般的です。

# 例: 「報告した」という動詞を見つけ、そのnsubjとobjの子ノードを探す
doc3 = nlp("担当者が新しい問題を報告しました。")
target_verb = "報告しました" # ターゲットとなる動詞のテキスト

subject = None
obj = None

for token in doc3:
    if token.text == target_verb and token.pos_ == "VERB":
        for child in token.children:
            if child.dep_ == "nsubj":
                subject = ''.join(t.text for t in child.subtree)
            elif child.dep_ == "obj":
                obj = ''.join(t.text for t in child.subtree)
        break # 目的の動詞が見つかったら終了

print(f"\n元のテキスト: {doc3.text}")
print(f"「{target_verb}」の主語: {subject}")
print(f"「{target_verb}」の目的語: {obj}")

この例では、動詞トークンを見つけ、そのchildren属性を使って依存する子ノードを調べ、dep_属性で依存関係のラベルを確認しています。nsubjが主語、objが目的語に対応します(依存関係ラベルはモデルや言語によって異なります)。.subtreeを使うと、そのノードとその依存するノード(修飾語など)を含むSpanを取得できます。

3. 複雑な条件を組み合わせた抽出(否定条件の考慮など)

「〜について問い合わせたいが、〜ではない」といった、特定の単語やパターンが存在するが、同時に別の特定の条件(否定や例外など)を満たす場合は除外・抽出するといった、より複雑なケースを考えます。

これは、上記の品詞タグや依存構造の情報に加え、文全体の構造特定の単語が持つ属性(例: 否定辞に依存しているかなど)を組み合わせて判断する必要があります。SpaCyでは、Doc、Span、Tokenオブジェクトの持つ様々な属性や、それらの間の関係性(.ancestors, .children, .head, .conjunctsなど)を利用して、複雑なルールを構築できます。

例:「新しい機能について問い合わせたいが、既存の契約に関する問い合わせではない」から「新しい機能に関する問い合わせ」を抽出する(「既存の契約に関する問い合わせ」は除外)。

この場合、「問い合わせ」という動詞を見つけ、その目的語や関連する名詞句を特定します。さらに、その「問い合わせ」や関連句が、否定的な文脈(例: 「〜ではない」という表現に依存しているかなど)にあるかどうかをチェックする必要があります。

import spacy

nlp = spacy.load("ja_core_news_sm")

text = "新しい機能について問い合わせたいですが、既存の契約に関する問い合わせではありません。"
doc = nlp(text)

extracted_queries = []

# 「問い合わせ」という単語を含むトークンを探す
for token in doc:
    if "問い合わせ" in token.text: # 部分一致で検索
        # この「問い合わせ」トークンを含むSpan(より大きな句や文節)を考慮する
        # 簡単化のため、ここでは「問い合わせ」を含むトークン自体に着目し、
        # そのトークンが否定の文脈にあるかを確認します。
        # より厳密には、そのトークンが属する句や文節全体を評価する必要があります。

        is_negative = False
        # トークンの祖先(headを遡る)に否定関連の単語や依存関係があるかチェック
        # 例: 「ありません」のような否定動詞に依存しているか
        # SpaCyのモデルによって依存関係ラベルは異なりますが、negationなどが使われます。
        # ここでは、「ありません」という単語が同じ文に含まれるか、という単純なチェックをします
        # より正確には依存関係ツリーを辿るべきです
        for other_token in doc:
            if other_token.text in ["ない", "ません", "ず", "なく"] and token.is_ancestor(other_token):
                 # あるいは、もっと直接的に「ありません」などの否定語に依存しているかチェック
                 # 例: token.head.dep_ == "neg" (これは英語モデルの例。日本語モデルの適切なラベルを確認する必要があります)
                 # 日本語モデルでは、複合動詞の一部であったり、助動詞であったりするため、判定が難しい場合があります
                 # 例として、「ではありません」全体が助動詞や補助動詞として扱われる場合、そのheadをチェック
                 if other_token.text == "ありません": # あるいは doc[-2].text == "ありません" など文末の否定をチェック
                     is_negative = True
                     break

        # あるいは、より直接的に「ではありません」のような否定表現を含むか、その周辺をチェック
        span_around_query = doc[max(0, token.i-3) : min(len(doc), token.i+3)] # 周辺3トークンを見る
        if "ありません" in [t.text for t in span_around_query]:
             is_negative = True # 周辺に否定表現があるとする(簡略化)

        # 否定の文脈になければ、抽出リストに追加
        if not is_negative:
            # 抽出する情報として、問い合わせに関連する名詞句を取得することを試みる
            # 簡単化のため、問い合わせトークンとその前の数トークンを抽出
            query_phrase = doc[max(0, token.i-2):token.i+1].text # 例: 「機能について問い合わせ」
            extracted_queries.append(query_phrase)


print(f"\n元のテキスト: {text}")
print(f"抽出された問い合わせ(否定考慮、簡易ロジック): {extracted_queries}")

text2 = "サービスの仕様に関するお問い合わせは受け付けておりますが、支払いに関するお問い合わせは対象外です。"
doc2 = nlp(text2)
extracted_queries2 = []
for token in doc2:
    if "お問い合わせ" in token.text:
        is_negative = False
        span_around_query = doc2[max(0, token.i-3) : min(len(doc2), token.i+3)]
        # 例:「対象外です」のような表現をチェック
        if "対象外" in [t.text for t in span_around_query]:
             is_negative = True

        if not is_negative:
             query_phrase = doc2[max(0, token.i-2):token.i+1].text
             extracted_queries2.append(query_phrase)

print(f"\n元のテキスト: {text2}")
print(f"抽出された問い合わせ(否定考慮、簡易ロジック): {extracted_queries2}")

上記の例は非常に簡易的なものですが、SpaCyのTokenオブジェクトの属性(text, pos_, dep_, head, children, ancestorsなど)や、Spanオブジェクト、Matcherなどを組み合わせることで、より洗練された複雑な条件付き抽出ルールを構築することが可能です。

例えば、ある単語(例: 不具合)が、特定の動詞(例: 報告)の目的語(dep_ == "obj")であり、かつ、その動詞の主語(dep_ == "nsubj")が特定の名詞リスト(例: 顧客、ユーザー)に含まれる場合にのみ、「不具合」とその修飾語を抽出する、といったルールを作成できます。

# より依存関係を積極的に使う例
import spacy

nlp = spacy.load("ja_core_news_sm")

text = "顧客が製品の重大な不具合を報告しました。"
doc = nlp(text)

target_verb_text = "報告しました"
extracted_bug = None

for token in doc:
    # ターゲットの動詞を探す
    if token.text == target_verb_text and token.pos_ == "VERB":
        subject = None
        bug_object = None

        # 動詞の子ノードをチェック
        for child in token.children:
            # 主語を探す
            if child.dep_ == "nsubj":
                subject = child
            # 目的語を探す
            elif child.dep_ == "obj":
                bug_object = child

        # 主語が存在し、かつ特定のリストに含まれるかチェック
        if subject and subject.text in ["顧客", "ユーザー", "担当者"]:
            # 目的語が存在し、かつ「不具合」に関連する単語であるかチェック
            if bug_object and ("不具合" in bug_object.text or "エラー" in bug_object.text):
                 # 目的語(不具合)が見つかれば、そのサブツリー全体を抽出(修飾語含む)
                 extracted_bug = ''.join(t.text for t in bug_object.subtree)
                 break # 抽出できたら終了

print(f"\n元のテキスト: {text}")
print(f"抽出された不具合(条件付き、依存構造ベース): {extracted_bug}")

text2 = "開発チームが新しい機能を報告しました。" # この場合は「不具合」ではないので抽出されない
doc2 = nlp(text2)
target_verb_text2 = "報告しました"
extracted_bug2 = None

for token in doc2:
    if token.text == target_verb_text2 and token.pos_ == "VERB":
        subject = None
        bug_object = None
        for child in token.children:
            if child.dep_ == "nsubj":
                subject = child
            elif child.dep_ == "obj":
                bug_object = child

        if subject and subject.text in ["顧客", "ユーザー", "担当者"]:
            if bug_object and ("不具合" in bug_object.text or "エラー" in bug_object.text):
                 extracted_bug2 = ''.join(t.text for t in bug_object.subtree)
                 break

print(f"\n元のテキスト: {text2}")
print(f"抽出された不具合(条件付き、依存構造ベース): {extracted_bug2}")

この例のように、依存構造を活用することで、「誰が(主語)」「何を(目的語)」「どうした(動詞)」といった文の基本的な構造を捉え、特定の役割を持つ単語や句を条件付きで抽出することが可能になります。

実務への応用と考慮事項

実務への応用例

考慮事項

まとめ

本記事では、正規表現だけでは対応が難しい、複雑な条件や文脈を考慮したテキストパターン抽出について、PythonとSpaCyライブラリを活用した手法を解説しました。品詞タグ付けや依存構造解析といったNLPの基本的な機能を利用することで、単語そのもののマッチングを超え、単語の文法的な役割や単語間の関係性に基づいた抽出ルールを構築できることを示しました。

依存構造解析によって得られる主語-述語-目的語の関係や修飾関係などの情報は、テキストの意味的な構造を捉える上で非常に強力です。これらの情報を組み合わせることで、「特定の担当者からの報告で、かつ特定の種類の問題に言及している箇所を抽出する」「特定の製品に関する問い合わせだが、ネガティブなフィードバックではないものを選別する」といった、より実務的な課題に対応できる柔軟な情報抽出システムを構築することが可能になります。

NLPライブラリの機能を理解し、正規表現と組み合わせて適切に利用することで、テキストデータからの情報抽出の可能性は大きく広がります。本記事が、皆様の業務におけるテキストデータ活用の参考となれば幸いです。