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

業務テキストから「属性: 値」を捉える:Pythonによる属性値ペア抽出テクニック

Tags: Python, NLP, 情報抽出, 正規表現, SpaCy, データ分析

はじめに:非構造化データからの属性値ペア抽出の課題

業務で扱うテキストデータには、データベースのように構造化されていない情報が多く含まれます。例えば、顧客からの問い合わせ内容、製品のレビュー、システムログ、報告書などです。これらのテキストには、特定の「属性」とその「値」が自然文の中に埋もれていることが少なくありません。

これらの例から、「メモリ」という属性に対して「16GB」という値、「ファイル名」という属性に対して「report.csv」という値が対応していることが分かります。このような属性値ペアを自動的に抽出できれば、非構造化データを構造化し、集計、分析、検索などのタスクに活用することが可能になります。

しかし、属性値の表現は多様です。 * 区切り文字(:, =, など)が一定でない。 * 属性名や値に表記ゆれがある(「メモリ」「RAM」など)。 * 値が単語だけでなく、フレーズや文章である場合がある。 * 属性値ペアが文中で離れて記述されている場合がある。

本記事では、Pythonを用いてこのような非構造化テキストから属性値ペアを効率的かつ柔軟に抽出するための具体的なテクニックを、コード例を交えながら解説します。正規表現、辞書、そして基本的な自然言語処理(NLP)ライブラリの活用方法に焦点を当てます。

基本的なアプローチ:正規表現によるパターンマッチング

最もシンプルかつ強力な手法の一つは、正規表現(Regular Expression)を用いることです。特定のパターンを持つ属性値ペアに対しては、正規表現で直接捕捉を試みます。

例えば、「属性名: 値」や「属性名 = 値」のような明確な区切りがあるパターンには正規表現が有効です。

import re

text = "製品情報: CPU: Intel Core i7, メモリ = 16GB, ストレージ容量: 512GB SSD."

# 「属性名: 値」または「属性名 = 値」のパターンを抽出
# ここでは属性名は英数字+スペース、値はカンマまたはピリオドまで、として簡略化
pattern = re.compile(r"([\w\s]+?)\s*[:=]\s*(.+?)[,\.]")

matches = pattern.findall(text)

for attr, value in matches:
    # 不要な前後の空白を除去
    attr = attr.strip()
    value = value.strip()
    print(f"属性: '{attr}', 値: '{value}'")

# 別のテキスト例
text_log = "ユーザーID: 12345 が 処理: ファイルダウンロード を開始。ファイル名: report.csv、サイズ: 1.2MB。"
pattern_log = re.compile(r"([\w]+)\s*[:]\s*([\w\s\.]+)") # ログ形式に合わせたパターン

matches_log = pattern_log.findall(text_log)

for attr, value in matches_log:
     print(f"属性: '{attr}', 値: '{value}'")

正規表現アプローチの考慮事項

辞書と組み合わせた柔軟な抽出

正規表現の限界を補うため、あらかじめ属性名の辞書を用意し、テキスト中に辞書内の属性名が出現するかをチェックし、その周辺から値を抽出するアプローチが考えられます。

この手法では、まず対象の属性名リスト(シノニムを含む)を定義します。次に、テキストを単語やフレーズに分割し、リスト内の属性名が登場するかを確認します。属性名が見つかったら、その直後や特定の範囲内のテキストを値として抽出します。

NLPライブラリ(例: SpaCy, NLTK)を利用すると、テキストのトークン化(単語分割)や品詞タグ付けが容易になり、より精度の高い抽出が可能になります。

import spacy

# SpaCyモデルのロード(初回実行時はダウンロードが必要: python -m spacy download en_core_web_sm)
# 日本語の場合は 'ja_core_news_sm' などを使用
try:
    nlp = spacy.load("en_core_web_sm")
except OSError:
    print("Downloading SpaCy model 'en_core_web_sm'...")
    from spacy.cli import download
    download("en_core_web_sm")
    nlp = spacy.load("en_core_web_sm")


attribute_dict = {
    "memory": ["memory", "ram"],
    "storage": ["storage", "ssd", "hdd", "disk space"],
    "cpu": ["cpu", "processor"],
    "screen size": ["screen size", "display size"],
}

def extract_attribute_value_dict(text, attribute_dict, nlp):
    doc = nlp(text)
    extracted_data = {}

    # シンプルなトークンマッチングと後続の値抽出
    for attr_canonical, attr_variations in attribute_dict.items():
        for variation in attr_variations:
            # テキスト中で属性名のバリエーションを探す
            # トークン単位で一致を見る方が頑健な場合もあるが、ここでは部分一致を許容
            if variation in text.lower():
                # 属性名の登場位置を見つける(最初の出現を仮定)
                match_index = text.lower().find(variation)
                if match_index != -1:
                    # 属性名の後の部分テキストを値として抽出
                    # 例: 属性名の直後から次の区切り文字 (,, ., \n) までを値とする
                    # より洗練された方法は後述のNLP活用を参照
                    start_index = match_index + len(variation)
                    remaining_text = text[start_index:].strip()

                    # 区切り文字を探し、値の範囲を決定
                    # ここでは簡略化のため、最初のカンマ、ピリオド、改行までを値とする
                    end_index = len(remaining_text)
                    for delimiter in [',', '.', '\n']:
                         delimiter_index = remaining_text.find(delimiter)
                         if delimiter_index != -1 and delimiter_index < end_index:
                              end_index = delimiter_index

                    value = remaining_text[:end_index].strip().rstrip(':').rstrip('=') # 区切り文字やコロン/イコールは除去

                    if value: # 値が空でなければ追加
                        extracted_data[attr_canonical] = value
                        # 最初に見つかったバリエーションで値を抽出したら、その属性は終了
                        break
        if attr_canonical in extracted_data: # 属性が見つかったら次の属性へ
             continue

    return extracted_data

text_product = "この製品のCPUはIntel Core i7で、メモリは16GB、ストレージ容量は512GB SSDです。ディスプレイサイズは13.3インチ。"
extracted = extract_attribute_value_dict(text_product, attribute_dict, nlp)
print("\n辞書+簡易抽出結果:")
print(extracted)

辞書+簡易抽出の考慮事項

NLPライブラリ(SpaCy)を活用した構造的抽出

辞書ベースのアプローチをより強力にするために、NLPライブラリによる形態素解析、品詞タグ付け、依存構造解析の結果を活用します。これにより、単語の表面的な一致だけでなく、文の中での単語の役割や関係性を理解した上で属性値ペアを抽出できます。

例えば、「[属性名] は [値] です」のような文型や、「[属性名]: [値]」のようなパターンを、単語の品詞や、単語間の依存関係(主語-述語、名詞句-前置詞句など)を利用して特定します。

import spacy

# SpaCyモデルのロード (上記でロード済みを想定)
nlp = spacy.load("en_core_web_sm")

attribute_dict_nlp = {
    "memory": ["memory", "ram"],
    "storage": ["storage", "ssd", "hdd", "disk space"],
    "cpu": ["cpu", "processor"],
    "screen size": ["screen size", "display size"],
}

def extract_attribute_value_nlp(text, attribute_dict, nlp):
    doc = nlp(text)
    extracted_data = {}

    for attr_canonical, attr_variations in attribute_dict.items():
        for variation in attr_variations:
            # SpaCyのMatcherを使って属性名(またはその一部)にマッチするスパンを見つける
            # より洗練されたMatcherパターンも可能だが、ここではトークンテキストでのシンプルマッチ
            # (例: [{"LOWER": "memory"}], [{"LOWER": "screen"}, {"LOWER": "size"}])
            # 複数のトークンからなる属性名に対応するため、テキスト内での文字列検索を使う
            variation_lower = variation.lower()
            for sent in doc.sents: # 文ごとに処理
                sent_text_lower = sent.text.lower()
                if variation_lower in sent_text_lower:
                    # マッチした属性名のトークンを見つける(最初の出現)
                    attr_span = None
                    for token in sent:
                        if variation_lower in token.text.lower():
                            # 複数の単語からなる属性名の場合、spanを正しく特定する必要がある
                            # ここでは簡単のため、マッチした単語のトークン位置を使う
                            start_token_index = token.i - sent.start
                            end_token_index = start_token_index + 1
                            # もしvariationが複数の単語なら、それに対応するトークン範囲を探す処理を追加する
                            # 例: "screen size" -> トークン "screen" と "size" が連続しているかチェック
                            variation_tokens = nlp(variation_lower)
                            if len(variation_tokens) > 1:
                                match_found = False
                                for i in range(len(sent) - len(variation_tokens) + 1):
                                    if all(sent[sent.start + i + j].text.lower() == variation_tokens[j].text for j in range(len(variation_tokens))):
                                        attr_span = sent[sent.start + i : sent.start + i + len(variation_tokens)]
                                        match_found = True
                                        break
                                if not match_found: # 連続しない場合は単語ごとのマッチで続けるかスキップ
                                    continue # このバリエーションではマッチせず次へ
                            else: # 単一単語のバリエーション
                                attr_span = sent[token.i] # 単一トークンとして扱う

                            if attr_span:
                                # 属性名トークンの直後から、関連性の高いトークンを値として抽出
                                # 例: 属性名の直後に続く名詞句や形容詞句
                                # 依存構造 (dependency parse) を利用して、より正確な関係性を捉える
                                value_tokens = []
                                current_token = attr_span[-1] # 属性名の最後のトークンから開始

                                # 属性名の直後に続くトークンを探す(品詞や依存関係をヒントに)
                                # 例: 属性名の後にコロン(:)や等号(=)があればその次、"be"動詞があればその次
                                search_start_index = attr_span.end # 属性スパンの直後から探索開始

                                if search_start_index < sent.end:
                                    potential_value_start_token = sent[search_start_index]

                                    # 簡単なパターン:属性名の直後にコロン/等号があれば、その次のトークン以降
                                    if potential_value_start_token.text in [":", "="] and search_start_index + 1 < sent.end:
                                        search_start_index += 1
                                        potential_value_start_token = sent[search_start_index]

                                    # 別の簡単なパターン:「〜は〜です」のようなbe動詞に続くケース
                                    # 属性名が主語やそれに近い位置にあり、述語(be動詞)に依存している場合などを考慮
                                    # これは依存構造解析結果を見る必要がある
                                    # 例: "CPU" --nsubj--> "is" --attr--> "Intel Core i7"
                                    # このパターンを汎用的に捉えるのは少し複雑
                                    # SpaCyの依存関係プロパティ (`token.dep_`, `token.head`) を使う
                                    # 属性名トークンから辿って値を探す

                                    # ここでは簡略化のため、属性名の直後から最初の句読点または接続詞までを値とみなす
                                    # より洗練された方法としては、属性名トークンに依存するトークンツリーを辿るなど
                                    value_span_tokens = []
                                    for token in sent[search_start_index:]:
                                        if token.is_punct or token.pos_ in ["CCONJ", "SCONJ"]: # 句読点や接続詞で停止
                                            break
                                        value_span_tokens.append(token)

                                    if value_span_tokens:
                                        # 抽出したトークン列を結合して値とする
                                        value = " ".join(t.text for t in value_span_tokens).strip()
                                        extracted_data[attr_canonical] = value
                                        break # この属性については抽出完了

        if attr_canonical in extracted_data:
             continue # 属性が見つかったら次の属性へ


    return extracted_data

text_product_nlp = "この製品のCPUはIntel Core i7で、メモリは16GB、ストレージ容量は512GB SSDです。ディスプレイサイズは13.3インチ。"
extracted_nlp = extract_attribute_value_nlp(text_product_nlp, attribute_dict_nlp, nlp)
print("\nNLP活用抽出結果:")
print(extracted_nlp)

# 依存構造の確認(例)
# doc = nlp(text_product_nlp)
# for token in doc:
#    print(token.text, token.pos_, token.dep_, token.head.text)

NLP活用アプローチの考慮事項

複数の手法の組み合わせと応用事例

実務では、これらの手法を単独で使うだけでなく、組み合わせて利用することが一般的です。

  1. 前処理: テキストの正規化(小文字化、表記ゆれ吸収、不要文字除去など)を行います。これにより、抽出精度が向上します。(参考:「Pythonによるテキスト正規化の実践:情報抽出精度を高める表記ゆれ吸収テクニック」)
  2. 正規表現による一次抽出: 比較的構造的なパターン(「属性: 値」など)を正規表現で高速に抽出します。抽出できた部分はテキストから除外します。
  3. NLPを活用した二次抽出: 正規表現で抽出できなかった部分に対して、辞書とNLP(品詞、依存構造など)を利用して、より柔軟なパターンから属性値ペアを抽出します。
  4. 後処理と検証: 抽出された値の形式を正規化したり、期待される値の範囲や形式に合っているか検証したりします。

応用事例:

実務上の考慮事項とパフォーマンス

まとめ

非構造化テキストからの属性値ペア抽出は、多様な業務データの活用において非常に重要なタスクです。本記事では、Pythonを用いてこのタスクを実現するための基本的な手法として、正規表現、辞書、そしてNLPライブラリ(SpaCy)の活用方法を解説しました。

これらのテクニックを理解し、皆様の具体的な業務課題に対して適切な手法を選択・応用することで、テキストデータに埋もれた価値ある情報を効率的に抽出できるようになるはずです。最初は簡単なパターンから着手し、徐々に複雑な表現に対応できるようルールを洗練させていくのが良いアプローチです。