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

正規表現だけでは限界!PythonとSpaCyによる依存構造解析を活用した情報抽出

Tags: Python, 自然言語処理, SpaCy, 依存構造解析, 情報抽出

はじめに:複雑なテキストからの情報抽出の課題

業務で扱う様々なテキストデータ、例えば顧客からのフィードバック、システムログ、技術ドキュメントなどには、ビジネス上重要な情報やシステムの運用に必要な情報が含まれています。これらの非構造化あるいは半構造化されたテキストから、特定の情報を効率的かつ正確に抽出することは、データ活用の第一歩となります。

多くの場合、テキストからの情報抽出には正規表現が強力なツールとして活用されます。電話番号、メールアドレス、特定のキーワードの出現パターンなど、定型的な情報の抽出には正規表現は非常に有効です。しかし、以下のようなケースでは正規表現だけでは対応が難しくなります。

これらの課題に対応するためには、単語の表面的な並びだけでなく、単語が文中でどのような役割を果たし、互いにどのような関係にあるかを理解する必要があります。自然言語処理(NLP)における依存構造解析(Dependency Parsing)は、このような単語間の構造的な関係を明らかにする手法であり、複雑なテキストからの情報抽出に非常に有効です。

本記事では、PythonとNLPライブラリのSpaCyを用いて、依存構造解析の結果を活用し、正規表現だけでは困難なテキストパターンから情報を抽出する実践的な方法について解説します。依存構造解析の基本から具体的なコード例、そして実務での応用例までをご紹介します。

依存構造解析とは

依存構造解析は、文中の単語間の依存関係を解析し、単語が文中でどのような文法的な役割を果たしているかを明らかにする技術です。解析結果は、通常、単語が矢印で結ばれた「依存構造ツリー」や、単語ごとに「その単語がどの単語に依存しているか(head)」と「どのような依存関係にあるか(dep)」を示すリストとして表現されます。

例えば、「私は新しいパソコンを買いました。」という文を考えてみます。

依存構造解析を行うと、このような単語間の修飾・被修飾関係や主語・目的語といった文法的な役割が特定できます。SpaCyのようなライブラリでは、この解析結果を容易に取得し、プログラムから利用することができます。

SpaCyによる依存構造解析の基本

SpaCyは高速かつ高精度なNLPライブラリで、様々な言語に対応した訓練済みモデルを提供しています。テキストのトークン化、品詞タグ付け、固有表現抽出、そして依存構造解析といった主要なNLPタスクを実行できます。

まず、SpaCyをインストールし、日本語モデルをダウンロードします。

pip install spacy
python -m spacy download ja_core_news_sm # 日本語の小型モデルをダウンロード

次に、SpaCyを使ってテキストを処理し、依存構造解析の結果を見てみましょう。

import spacy

# 日本語モデルをロード
# largeモデルなど他のモデルを使う場合は適宜変更してください
nlp = spacy.load("ja_core_news_sm")

text = "私は新しいパソコンを買いました。"
doc = nlp(text)

# 解析結果の表示
print("--- 依存構造解析結果 ---")
for token in doc:
    print(f"text: {token.text}, lemma: {token.lemma_}, pos: {token.pos_}, dep: {token.dep_}, head: {token.head.text}")

# 結果例:
# --- 依存構造解析結果 ---
# text: 私, lemma: 私, pos: PRON, dep: nsubj, head: 買いました
# text: は, lemma: は, pos: ADP, dep: case, head: 私
# text: 新しい, lemma: 新しい, pos: ADJ, dep: amod, head: パソコン
# text: パソコン, lemma: パソコン, pos: NOUN, dep: obj, head: 買いました
# text: を, lemma: を, pos: ADP, dep: case, head: パソコン
# text: 買い, lemma: 買う, pos: VERB, dep: root, head: 買いました
# text: ました, lemma: ます, pos: AUX, dep: aux, head: 買い
# text: 。, lemma: 。, pos: PUNCT, dep: punct, head: 買いました

上記の出力から、各トークンに対して以下の情報が得られていることがわかります。

例えば、「私」の depnsubj(名詞主語)、head は「買いました」です。「新しい」の depamod(形容詞修飾)、head は「パソコン」です。このように、依存関係の情報を使うことで、「誰が」「何を」「どのように」といった文の構造をプログラムで捉えることが可能になります。

依存構造解析を活用した具体的な情報抽出パターン

ここからは、依存構造解析の結果を利用して、正規表現だけでは難しい情報を抽出する具体的なパターンを見ていきましょう。

パターン1:特定の単語に付随する修飾語や情報を抽出する

ある名詞に特定の形容詞や名詞句が付いている場合に、その名詞や付随する情報を抽出したいケースです。「顧客からのフィードバックで、重要な不具合について言及されている箇所を見つけたい」といった要望に対応できます。

考え方としては、目的の名詞トークンを見つけ、そのトークンに amod(形容詞修飾)、compound(複合名詞)、acl(形容詞節)などの依存関係で紐づいている子ノードや祖先ノードを調べます。

text = "新しい機能は便利ですが、古いシステムでは動作しません。"
doc = nlp(text)

target_noun = "機能"

print(f"\n--- '{target_noun}' に関連する修飾語を抽出 ---")
for token in doc:
    if token.text == target_noun and token.pos_ == "NOUN":
        print(f"対象トークン: {token.text}")
        # このトークンを修飾している単語を探す(主にheadから)
        if token.dep_ == "nsubj" or token.dep_ == "obj": # 主語や目的語として使われている場合など
            # head方向(修飾されている側)は通常、述語などになるため、ここでは子の修飾語を見る
            for child in token.children:
                 # amod: 形容詞修飾、compound: 複合名詞の一部、acl: 形容詞節
                 if child.dep_ in ["amod", "compound", "acl"]:
                     print(f"- 修飾語: {child.text} (dep: {child.dep_})")
        # あるいは、このトークンが修飾している他の単語を探す(headを辿る)
        # この例では「機能」が何かを修飾するケースは少ないため省略

# 別の例: 「重要な要素」
text_important = "システム設計において、重要な要素を特定することが大切です。"
doc_important = nlp(text_important)
target_noun_important = "要素"
print(f"\n--- '{target_noun_important}' に関連する修飾語を抽出 ---")
for token in doc_important:
    if token.text == target_noun_important and token.pos_ == "NOUN":
        print(f"対象トークン: {token.text}")
        for child in token.children:
             if child.dep_ in ["amod", "compound", "acl"]:
                 print(f"- 修飾語: {child.text} (dep: {child.dep_})")
        # このトークンが何かを修飾している場合(今回は該当なし)
        # for ancestor in token.ancestors:
        #     print(f"- {token.text}が修飾する可能性のある単語: {ancestor.text} (dep: {token.dep_} from {ancestor.text})")

# 結果例:
# --- '機能' に関連する修飾語を抽出 ---
# 対象トークン: 機能
# - 修飾語: 新しい (dep: amod)
#
# --- '要素' に関連する修飾語を抽出 ---
# 対象トークン: 要素
# - 修飾語: 重要な (dep: amod)

このコードでは、「機能」という名詞トークンを見つけ、その子ノードの中からamod(形容詞修飾)関係を持つトークンを探しています。これにより、「新しい機能」というフレーズから「新しい」を抽出することが可能になります。

パターン2:特定のアクションや状態に紐づく条件や理由を抽出する

「エラーが発生した場合、ログを出力する」「認証に失敗したため、アクセスを拒否しました」のように、特定のイベントや状態に紐づく条件や理由(接続関係)を抽出したいケースです。

考え方としては、キーとなる動詞や名詞を見つけ、それに advcl(副詞節)、ccomp(補文)、obl(斜格補語 - 前置詞句など)などの依存関係で紐づいている子ノードや祖先ノードを探します。特に、前置詞や接続詞(例: 「ため」「場合」「によって」)が重要な手がかりとなります。

text = "認証に失敗したため、アクセスを拒否しました。"
doc = nlp(text)

# 「アクセスを拒否しました」というアクションに紐づく理由を探す
target_head_verb = "拒否しました" # またはその原型

print(f"\n--- '{target_head_verb}' に紐づく理由・条件を抽出 ---")
for token in doc:
    # target_head_verbをheadとするトークン、またはtarget_head_verb自身を見る
    if token.head.text == target_head_verb or token.text == target_head_verb:
        # 例: 「〜ため」のような理由を表す表現を探す
        # このケースでは、「した」が「ため」をheadとし、「拒否しました」が「したため」をheadとする構造になっている可能性
        # 「ため」自体は「した」に依存し、「した」は「拒否しました」に依存している構造を見つける
        if token.text == "ため":
             # 「ため」が依存している単語(この場合「した」)を見つけ、さらにその子ノード(「認証に失敗し」)を探す
             reason_clause_end = token.head
             reason_clause = "".join([child.text for child in reason_clause_end.children if child.dep_ != 'punct'] + [reason_clause_end.text])
             print(f"- 理由・条件句: {reason_clause}{token.text}")

# 別の例:「エラーが発生した場合」
text_condition = "エラーが発生した場合、システムを再起動してください。"
doc_condition = nlp(text_condition)
target_head_verb_cond = "再起動してください"

print(f"\n--- '{target_head_verb_cond}' に紐づく理由・条件を抽出 ---")
for token in doc_condition:
     if token.text == "場合":
          condition_clause_end = token.head # 発生し
          condition_clause_start = [child.text for child in condition_clause_end.children if child.dep_ != 'punct'] # エラーが発生
          condition_clause = "".join(condition_clause_start + [condition_clause_end.text, token.text]) # エラーが発生した場合
          print(f"- 理由・条件句: {condition_clause}")


# 結果例:
# --- '拒否しました' に紐づく理由・条件を抽出 ---
# - 理由・条件句: 認証に失敗したため
#
# --- '再起動してください' に紐づく理由・条件を抽出 ---
# - 理由・条件句: エラーが発生した場合

この例では、特定の単語(「ため」「場合」)とその依存関係をたどることで、それに先行する理由や条件を示す節を抽出しています。依存構造を理解することで、単語の表面的な一致だけでなく、より深い意味的な関係性を捉えることが可能になります。

パターン3:否定表現を含む情報の抽出やフィルタリング

「この機能は利用できません」「〜を除く」のように、特定の情報が否定されている箇所を特定したり、否定されている情報を抽出対象から除外したりしたいケースです。

考え方としては、キーとなる単語(動詞、名詞など)に neg(否定)の依存関係を持つ子ノード(「ない」「ません」など)が付いているかを確認します。また、「〜を除く」といった特定の表現については、その表現とそれがかかる対象との依存関係を調べます。

text = "この機能はまだ利用できません。"
doc = nlp(text)

target_verb = "利用"

print(f"\n--- '{target_verb}' が否定されているか確認 ---")
for token in doc:
    if token.lemma_ == target_verb and token.pos_ == "VERB":
        is_negated = False
        for child in token.children:
            if child.dep_ == "neg":
                is_negated = True
                neg_word = child.text
                break
        if is_negated:
            print(f"- '{token.text}' は否定されています (否定語: {neg_word})")
        else:
             print(f"- '{token.text}' は否定されていません")

# 別の例:「以下の項目を除く」
text_except = "報告書には、以下の項目を除く全ての情報を記載してください。"
doc_except = nlp(text_except)

print(f"\n--- '除く' にかかる対象を抽出 ---")
for token in doc_except:
    if token.text == "除く":
        # 「除く」が依存している単語(「項目」など)を見つけ、その修飾語を探す
        head_token = token.head
        if head_token.text == "項目":
             modifier = "".join([child.text for child in head_token.children if child.dep_ != 'punct'])
             print(f"- '{token.text}' の対象: {modifier}{head_token.text}")

# 結果例:
# --- '利用' が否定されているか確認 ---
# - '利用' は否定されています (否定語: ません)
#
# --- '除く' にかかる対象を抽出 ---
# - '除く' の対象: 以下の項目

このコードでは、「利用」という動詞の原型を持つトークンを探し、その子ノードに neg 関係のトークン(「ません」)があるかを確認しています。「除く」の例では、「除く」が依存している「項目」という単語を見つけ、その修飾語である「以下の」を合わせて抽出することで、「以下の項目」が除外対象であることを特定しています。

複数のパターンを組み合わせた実装例

実際の業務では、これらのパターンを組み合わせてより複雑な情報を抽出することが一般的です。例えば、「特定の状態にある、特定の修飾語が付いたオブジェクト」や「あるアクションの、特定の条件下の対象」などを抽出したい場合です。

以下に、レビューテキストから「否定的な(または肯定的な)意見が述べられている、特定の機能に関する部分」を抽出する、という想定でコード例を示します。

import spacy

nlp = spacy.load("ja_core_news_sm")

# サンプルレビューテキスト
review_text = """
新機能は便利ですが、UIが直感的ではありません。
起動速度は改善されましたが、古いバージョンで動作していた〇〇機能が使えなくなりました。
このツール全体としては満足しています。
"""

doc = nlp(review_text)

target_features = ["機能", "UI", "ツール"]
positive_adjs = ["便利", "改善"]
negative_adjs = ["直感的ではありません", "使えなくなりました"] # 否定+状態も含む簡単な例

extracted_info = []

for sent in doc.sents: # 文単位で処理
    print(f"\nProcessing sentence: {sent.text}")
    for token in sent:
        # 特定の機能名や対象が登場するか確認
        if token.text in target_features and token.pos_ == "NOUN":
            print(f"  - Found target: {token.text}")
            related_info = {}

            # この対象に紐づく修飾語や状態を探す
            # 子ノードやhead方向の祖先ノードを辿ることで関連情報を取得
            related_tokens = [child for child in token.children] + [token.head]
            # より広範囲を見る場合は ancestors や subtree を使うことも検討

            descriptors = []
            is_negative = False
            is_positive = False

            for related_token in related_tokens:
                # 修飾語(形容詞など)
                if related_token.dep_ == "amod":
                    descriptors.append(related_token.text)
                    if related_token.lemma_ in positive_adjs:
                         is_positive = True
                    elif related_token.lemma_ in [nlp(adj)[0].lemma_ for adj in negative_adjs]: # 否定形を考慮
                         is_negative = True
                # 否定形(動詞などにかかる場合)
                # 例:「〜ではありません」のようなケースでは、「ではありません」がheadになっている可能性
                if related_token.dep_ == "neg":
                     is_negative = True
                     descriptors.append(related_token.text) # 否定語自体も情報として保持
                # 状態や結果を表す動詞など(「使えなくなった」など)
                # より複雑な判定が必要だが、ここでは単純にキーワードで判定する例
                if related_token.lemma_ in [nlp(adj)[0].lemma_ for adj in negative_adjs] and related_token.dep_ == "aux": # 使えなくなり「ました」の「ました」がauxで「使えなくなり」にかかる場合など
                     is_negative = True
                     # 状態全体を捉える(ここでは簡略化)
                     descriptors.append(related_token.text)


            # 見つかった情報をリストに追加(ここでは否定的な意見のみを抽出する例)
            if is_negative or any(d in negative_adjs for d in descriptors): # より堅牢な判定が必要
                 extracted_info.append({
                    "target": token.text,
                    "sentence": sent.text.strip(),
                    "descriptors": list(set(descriptors)), # 重複削除
                    "sentiment": "negative"
                })
            elif is_positive:
                 # オプション:肯定的な意見も抽出する場合
                  extracted_info.append({
                    "target": token.text,
                    "sentence": sent.text.strip(),
                    "descriptors": list(set(descriptors)),
                    "sentiment": "positive"
                })


print("\n--- Extracted Information ---")
for item in extracted_info:
    print(item)

# 結果例:
# Processing sentence: 新機能は便利ですが、UIが直感的ではありません。
#   - Found target: 機能
#   - Found target: UI
#
# Processing sentence: 起動速度は改善されましたが、古いバージョンで動作していた〇〇機能が使えなくなりました。
#   - Found target: 機能
#
# Processing sentence: このツール全体としては満足しています。
#   - Found target: ツール
#
# --- Extracted Information ---
# {'target': 'UI', 'sentence': '新機能は便利ですが、UIが直感的ではありません。', 'descriptors': ['ではありません'], 'sentiment': 'negative'}
# {'target': '〇〇機能', 'sentence': '起動速度は改善されましたが、古いバージョンで動作していた〇〇機能が使えなくなりました。', 'descriptors': ['使えなくなり'], 'sentiment': 'negative'}
# {'target': '機能', 'sentence': '新機能は便利ですが、UIが直感的ではありません。', 'descriptors': ['新しい'], 'sentiment': 'positive'}
# {'target': 'ツール', 'sentence': 'このツール全体としては満足しています。', 'descriptors': [], 'sentiment': 'positive'} # 適切なdescriptor抽出は別途実装が必要

このコード例では、特定の対象名詞 (target_features) が文中に現れた際に、その周辺のトークンを依存関係をたどって調べ、関連する修飾語 (descriptors) を取得しています。取得した修飾語や否定関係から、その対象に対する意見が肯定的か否定的かを判定しています。実際のアプリケーションでは、形容詞や状態動詞の辞書、否定ルールなどをより詳細に定義する必要がありますが、依存構造解析が文脈を捉える強力な手段となることがお分かりいただけるかと思います。

実務への応用と考慮事項

依存構造解析を活用した情報抽出は、様々な実務課題に応用できます。

パフォーマンスとシステム設計に関する考慮事項:

まとめ

本記事では、正規表現では対応が難しい、文脈や構造に依存したテキストからの情報抽出タスクに対して、PythonとSpaCyを用いた依存構造解析が強力な手段となることをご紹介しました。依存構造解析によって得られる単語間の関係性(依存関係、head、子ノードなど)を活用することで、単語の表面的な一致を超えた、より深い意味に基づいた情報抽出が可能になります。

特定の修飾語に紐づく情報の抽出、アクションや状態に紐づく条件や理由の抽出、否定表現の考慮といった具体的なパターンとコード例を通じて、その実践的なアプローチを示しました。これらの手法は、顧客フィードバック分析、技術ドキュメントからの要件抽出、ログ分析など、様々な実務課題に応用できます。

依存構造解析は強力ですが、処理コストや精度限界といった考慮事項も存在します。実務で利用する際には、対象データの特性や要求される精度に合わせて、他のNLP手法やルールベースのアプローチと組み合わせるなど、バランスの取れた設計が重要となります。ぜひ、お手元のテキストデータに対して、依存構造解析を活用した情報抽出を試してみてください。