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

業務テキストから数値を捉える:Pythonによる量情報抽出と正規化テクニック

Tags: Python, 自然言語処理, 情報抽出, 正規表現, 正規化

はじめに

業務で扱うテキストデータには、顧客の声、技術文書、ログファイル、市場レポートなど、様々な情報が含まれています。これらのテキストの中から、定量的な情報、すなわち数値や単位付きの「量情報」を正確に抽出することは、データに基づいた意思決定や自動化において非常に重要です。例えば、

このように、テキストの中から数値や量情報を抽出し、さらにそれを比較可能な形式に「正規化」できれば、多くの業務プロセスを効率化できます。しかし、テキスト中の数値や量表現は多様であり、単純なキーワード検索だけでは対応しきれません。本記事では、Pythonを用いて、このような数値・量情報の抽出と正規化を行うための具体的なテクニックを、正規表現と自然言語処理(NLP)ライブラリの両面から解説します。

テキスト中の数値・量情報表現の多様性

テキスト中に現れる数値や量情報は、以下のような様々な形式をとります。

これらの多様な表現から必要な情報を正確に抽出し、さらに例えば「10km」と「10000m」を同じ距離として扱えるように標準化するには、工夫が必要です。

正規表現による基本的な数値・量情報抽出

最も基本的かつ強力な手法の一つは、正規表現(Regular Expression)を用いることです。Pythonの re モジュールを使えば、特定のパターンに一致する文字列を効率的に検索・抽出できます。

単純な数値の抽出

整数や小数は比較的容易に抽出できます。

import re

text = "在庫数は123個です。価格は98.5ドルでした。損失は-5%です。"

# 整数を抽出
integers = re.findall(r'\d+', text)
print(f"抽出された整数: {integers}") # ['123', '98', '5'] - 小数の小数点以下も含まれる

# 小数点を含む数値を抽出
numbers = re.findall(r'\d+\.?\d*|\d*\.?\d+', text) # または r'-?\d+\.?\d*' で負数も考慮
print(f"抽出された数値(小数含む): {numbers}") # ['123', '98.5', '5']

# 負数、カンマ区切り、小数点を含むより一般的な数値パターン
# 例: -1,234.56
generic_number_pattern = r'-?\d{1,3}(,\d{3})*(\.\d+)?'
text_with_complex_numbers = "売上は1,234,567円、利益は-123.45ドルです。"
complex_numbers = re.findall(generic_number_pattern, text_with_complex_numbers)
# 正規表現のグループ化によりタプルで返される場合があるため、処理が必要
extracted_numbers = [m[0] + "".join(m[1:]) + (m[2] if len(m) > 2 else "") for m in re.findall(r'(-?\d{1,3}(?:,\d{3})*)(\.\d+)?', text_with_complex_numbers)]
print(f"抽出された複雑な数値: {extracted_numbers}") # ['1,234,567', '-123.45']

単位付き量情報の抽出

特定の単位が後続する数値パターンを定義します。

import re

text = "速度は100km/h、距離は500m、重さは5.2kg、時間は3時間、価格は1500円です。"

# 距離・重さ・時間の単位を含む量情報
# 単純な例として、数値の後に単位が続くパターン
quantity_pattern = r'\d+\.?\d*\s*(km|m|kg|g|時間|分|秒|円|ドル|%)'
quantities = re.findall(quantity_pattern, text)
# re.findallはグループがあるとタプルのリストを返す
# このパターンでは数値部分と単位部分が別々のグループになる
extracted_quantities = re.findall(r'(\d+\.?\d*)\s*(km|m|kg|g|時間|分|秒|円|ドル|%)', text)

print("抽出された量情報 (数値, 単位):")
for value, unit in extracted_quantities:
    print(f"  数値: {value}, 単位: {unit}")

# 注意: この単純なパターンでは "100km/h" は "100km" として抽出されます。
# より複雑な単位や表現には、より洗練された正規表現が必要になります。

正規表現の限界

正規表現は強力ですが、以下のような限界があります。

これらの課題に対処するため、NLPライブラリを活用します。

NLPライブラリ(SpaCy)を活用した量情報抽出

SpaCyのようなNLPライブラリは、テキストの構造(単語、品詞、固有表現、依存関係など)を解析する機能を提供します。これを利用することで、単なるパターン一致以上の高度な情報抽出が可能になります。

固有表現抽出(NER)の活用

多くのNLPライブラリは、数値や量に関する固有表現を認識する機能を持っています。SpaCyでは CARDINAL (基数), QUANTITY (量), MONEY (金額), DATE (日付), TIME (時間), PERCENT (パーセント) などのエンティティタイプでこれらの情報を捉えることができます。

import spacy

# 事前にモデルをダウンロードしてください (例: python -m spacy download ja_core_news_sm)
try:
    nlp = spacy.load("ja_core_news_sm")
except OSError:
    print("SpaCyモデルが見つかりません。'python -m spacy download ja_core_news_sm' を実行してください。")
    exit()

text = "価格は1,500円で、重さは約5 kg、サイズは10インチです。消費電力は50 Wで、在庫は100個あります。プロジェクトは3ヶ月で完了予定です。"

doc = nlp(text)

print("SpaCyによる固有表現抽出:")
for ent in doc.ents:
    # 数値や量に関するエンティティタイプに注目
    if ent.label_ in ["CARDINAL", "QUANTITY", "MONEY", "DATE", "TIME", "PERCENT", "ORDINAL"]:
        print(f"  テキスト: {ent.text}, ラベル: {ent.label_}")

# この例では:
# テキスト: 1,500円, ラベル: MONEY
# テキスト: 5 kg, ラベル: QUANTITY
# テキスト: 10インチ, ラベル: QUANTITY
# テキスト: 50 W, ラベル: QUANTITY
# テキスト: 100個, ラベル: QUANTITY
# テキスト: 3ヶ月, ラベル: DATE (しばしば量や期間として扱われる)
# のように抽出される可能性があります。(モデルやバージョンによります)

NERは、正規表現よりも柔軟に単位や周辺語句(「約」など)を考慮して量情報を捉えることができます。しかし、すべてのドメイン固有の量表現(例:「コア数」「スレッド数」)をデフォルトで認識するわけではありません。その場合は、カスタムエンティティを追加する必要があります。

依存構造解析の活用

数値や量情報が、テキスト中のどの対象(名詞句など)にかかっているかを知ることは、情報の意味を理解する上で重要です。「価格は5万円」であれば、「価格」が5万円であるとわかります。SpaCyの依存構造解析を利用すれば、このような主語-述語や修飾関係を調べることができます。

import spacy

try:
    nlp = spacy.load("ja_core_news_sm")
except OSError:
    print("SpaCyモデルが見つかりません。'python -m spacy download ja_core_news_sm' を実行してください。")
    exit()

text = "この製品の価格は5万円です。バッテリーの持続時間は10時間、メモリは8 GBです。"

doc = nlp(text)

print("依存構造解析による数値と対象の関連付け:")
for ent in doc.ents:
    # CARDINAL や QUANTITY などの数値/量エンティティを対象
    if ent.label_ in ["CARDINAL", "QUANTITY", "MONEY"]:
        print(f"\n対象エンティティ: {ent.text} ({ent.label_})")
        for token in ent:
            # エンティティ内の各トークンについて依存関係を調べる
            # headは依存元の単語、dep_は依存関係のラベル
            print(f"  トークン: {token.text}, 依存関係: {token.dep_}, 依存元: {token.head.text}, 依存元の品詞: {token.head.pos_}")

        # 簡単なヒューリスティックとして、数値エンティティのheadを探る
        # 例えば、数値が名詞にかかっている場合などを検出する
        if ent.root: # エンティティのルートトークン
             head_token = ent.root.head
             print(f"  エンティティのルートトークン: {ent.root.text}")
             print(f"  依存元の単語: {head_token.text}, 品詞: {head_token.pos_}, 依存関係: {ent.root.dep_}")

# 実行例 (モデルによって異なる場合があります):
# 対象エンティティ: 5万円 (MONEY)
#   トークン: 5, 依存関係: nummod, 依存元: 万円, 依存元の品詞: NOUN
#   トークン: 万, 依存関係: compound, 依存元: 円, 依存元の品詞: NOUN
#   トークン: 円, 依存関係: suffix, 依存元: 5万円, 依存元の品詞: NOUN
#   エンティティのルートトークン: 5万円 (SpaCy 3.xではエンティティ全体を一つのスパンとして扱うことが多い)
#   依存元の単語: は, 品詞: ADP, 依存関係: nsubj

# 対象エンティティ: 10時間 (QUANTITY)
#   トークン: 10, 依存関係: nummod, 依存元: 時間, 依存元の品詞: NOUN
#   トークン: 時間, 依存関係: suffix, 依存元: 10時間, 依存元の品詞: NOUN
#   エンティティのルートトークン: 10時間
#   依存元の単語: は, 品詞: ADP, 依存関係: nsubj

# 対象エンティティ: 8 GB (QUANTITY)
#   トークン: 8, 依存関係: nummod, 依存元: GB, 依存元の品詞: NOUN
#   トークン: GB, 依存関係: suffix, 依存元: 8 GB, 依存元の品詞: NOUN
#   エンティティのルートトークン: 8 GB
#   依存元の単語: は, 品詞: ADP, 依存関係: nsubj

# この情報から、「価格」が「5万円」、「バッテリーの持続時間」が「10時間」、「メモリ」が「8 GB」といった関連を推測できます。
# より堅牢な関連付けには、依存パスのパターンマッチングなど高度なテクニックが必要です。

依存構造解析を組み合わせることで、「どのような」量情報が抽出されたのかをより正確に把握することが可能になります。

Matcher/PhraseMatcherによる柔軟なパターンマッチング

SpaCyの MatcherPhraseMatcher を利用すれば、単語の並びや品詞、依存関係などの属性に基づいたカスタムパターンを定義し、テキスト中から該当箇所を抽出できます。これは正規表現とNERの中間に位置するような手法で、特定の形式を持つ量情報(例:「〜以上」「〜以下」「〜から〜まで」)を柔軟に捉えるのに役立ちます。

import spacy
from spacy.matcher import Matcher

try:
    nlp = spacy.load("ja_core_news_sm")
except OSError:
    print("SpaCyモデルが見つかりません。'python -m spacy download ja_core_news_sm' を実行してください。")
    exit()

matcher = Matcher(nlp.vocab)

# パターンの定義例: 「[数値] 以上」
pattern_more_than = [{"LIKE_NUM": True}, {"LOWER": "以上"}]
# パターンの定義例: 「[数値] 以下」
pattern_less_than = [{"LIKE_NUM": True}, {"LOWER": "以下"}]
# パターンの定義例: 「[数値] から [数値] まで」
pattern_range = [{"LIKE_NUM": True}, {"LOWER": "から"}, {"LIKE_NUM": True}, {"LOWER": "まで"}]
# LIKE_NUM: 数値っぽいトークンに一致

matcher.add("QUANTITY_MORE_THAN", [pattern_more_than])
matcher.add("QUANTITY_LESS_THAN", [pattern_less_than])
matcher.add("QUANTITY_RANGE", [pattern_range])

text = "在庫は100個以上あります。価格は5万円以下に抑えたい。サイズは10インチから15インチまで対応しています。"

doc = nlp(text)

matches = matcher(doc)

print("\nSpaCy Matcherによるカスタムパターン抽出:")
for match_id, start, end in matches:
    string_id = nlp.vocab.strings[match_id]  # パターン名を取得
    span = doc[start:end] # マッチしたスパン (単語の並び)
    print(f"  パターン名: {string_id}, マッチしたテキスト: {span.text}")

# この例では:
# パターン名: QUANTITY_MORE_THAN, マッチしたテキスト: 100個以上
# パターン名: QUANTITY_LESS_THAN, マッチしたテキスト: 5万円以下
# パターン名: QUANTITY_RANGE, マッチしたテキスト: 10インチから15インチまで
# のように抽出される可能性があります。

Matcher を活用することで、正規表現だけでは難しい、単語の意味や種類(ここでは LIKE_NUM で数値らしさ)を考慮した柔軟なパターン定義が可能になります。

抽出した量情報の正規化

テキストから量情報を抽出するだけでは不十分な場合があります。例えば、「10km」と抽出した情報と、「10000m」と抽出した情報を比較するには、単位を統一する必要があります。このプロセスを「正規化」と呼びます。

正規化の一般的なステップは以下の通りです。

  1. 数値部分のクリーンアップ: カンマ除去、全角数字の半角化などを行います。
  2. 単位の標準化: 抽出された単位を標準的な形式に変換します(例: "km" -> "km", "キログラム" -> "kg")。
  3. 単位換算: 必要に応じて、異なる単位間で値を換算します(例: "10km" -> "10000m")。
  4. 範囲/比較表現の構造化: 「100件以上」を {'value': 100, 'operator': '>=', 'unit': '件'} のような辞書形式に変換します。
import re
import unicodedata # 全角半角変換用

# 簡単な数値クリーンアップ関数
def clean_number(value_str):
    # 全角数字を半角に変換
    value_str = unicodedata.normalize('NFKC', value_str)
    # カンマを除去
    value_str = value_str.replace(',', '')
    return value_str

# 簡単な単位標準化・換算関数 (限定的な例)
# 実際にはより網羅的な辞書やライブラリが必要
unit_map = {
    'km': {'unit': 'm', 'factor': 1000},
    'm': {'unit': 'm', 'factor': 1},
    'kg': {'unit': 'g', 'factor': 1000},
    'g': {'unit': 'g', 'factor': 1},
    '時間': {'unit': '分', 'factor': 60},
    '分': {'unit': '分', 'factor': 1},
    '円': {'unit': '円', 'factor': 1},
    'ドル': {'unit': 'ドル', 'factor': 1} # 通貨換算は為替レートが必要で複雑
}

def normalize_quantity(value_str, unit_str):
    cleaned_value_str = clean_number(value_str)
    try:
        value = float(cleaned_value_str)
    except ValueError:
        return None # 数値に変換できない場合はスキップ

    standardized_unit_info = unit_map.get(unit_str)

    if standardized_unit_info:
        standardized_value = value * standardized_unit_info['factor']
        standardized_unit = standardized_unit_info['unit']
        return {'value': standardized_value, 'unit': standardized_unit, 'original_unit': unit_str}
    else:
        # 未知の単位の場合はそのまま返すか、Noneを返すなど
        return {'value': value, 'unit': unit_str, 'original_unit': unit_str}

# 正規表現で抽出したデータ例
extracted_data = [
    ('100', 'km'),
    ('5000', 'm'),
    ('5', 'kg'),
    ('1,500', '円'),
    ('3', '時間')
]

print("\n抽出データの正規化:")
normalized_data = []
for value_str, unit_str in extracted_data:
    normalized_info = normalize_quantity(value_str, unit_str)
    if normalized_info:
        normalized_data.append(normalized_info)
        print(f"  元: {value_str}{unit_str} -> 正規化: {normalized_info['value']}{normalized_info['unit']}")

# 結果例:
# 元: 100km -> 正規化: 100000.0m
# 元: 5000m -> 正規化: 5000.0m
# 元: 5kg -> 正規化: 5000.0g
# 元: 1,500円 -> 正規化: 1500.0円
# 元: 3時間 -> 正規化: 180.0分

# 範囲/比較表現の正規化例 (Matcherで抽出したテキストから)
def normalize_range_comparison(text):
    doc = nlp(text) # SpaCyで解析
    matches = matcher(doc) # 事前に定義したMatcherを使用

    results = []
    for match_id, start, end in matches:
        span = doc[start:end]
        string_id = nlp.vocab.strings[match_id]
        values = [token.text for token in span if token.like_num] # 数値部分を抽出

        if string_id == "QUANTITY_MORE_THAN":
            if values:
                results.append({'operator': '>=', 'value': clean_number(values[0]), 'text': span.text})
        elif string_id == "QUANTITY_LESS_THAN":
             if values:
                results.append({'operator': '<=', 'value': clean_number(values[0]), 'text': span.text})
        elif string_id == "QUANTITY_RANGE":
             if len(values) == 2:
                results.append({'operator': 'range', 'min': clean_number(values[0]), 'max': clean_number(values[1]), 'text': span.text})
    return results

text_ranges = "在庫は100個以上あります。価格は5万円以下に抑えたい。サイズは10インチから15インチまで対応しています。"
normalized_ranges = normalize_range_comparison(text_ranges)
print("\n範囲/比較表現の正規化:")
for res in normalized_ranges:
    print(f"  正規化結果: {res}")

# 結果例:
# 正規化結果: {'operator': '>=', 'value': '100', 'text': '100個以上'}
# 正規化結果: {'operator': '<=', 'value': '5', 'text': '5万円以下'}
# 正規化結果: {'operator': 'range', 'min': '10', 'max': '15', 'text': '10インチから15インチまで'}

単位の換算など、より高度な正規化には pint のような専用ライブラリの利用も検討できますが、小規模なタスクであれば自前の関数で対応することも可能です。重要なのは、抽出した情報が後続の処理(データベース格納、比較、分析など)で扱いやすい構造化された形式になるように変換することです。

実務への応用と考慮事項

実務での応用例

パフォーマンスとシステム設計

注意点

まとめ

本記事では、業務テキストから数値や単位付きの量情報を抽出・正規化するためのPythonによる実践的なテクニックを紹介しました。正規表現はシンプルで高速なパターンマッチングに適しており、NLPライブラリ(SpaCyなど)は固有表現認識や文脈解析によるより高精度な抽出に役立ちます。これらの手法を適切に組み合わせることで、多様な形式で表現される量情報を効果的に捉えることが可能になります。

抽出した情報は、分析やシステム連携のために正規化することが重要です。単位の統一や数値形式のクリーンアップなどを行うことで、後続処理での扱いやすさが格段に向上します。

実務でこれらのテクニックを活用する際には、抽出対象となるテキストの性質をよく理解し、正規表現、NLPライブラリ、カスタムルールなどを組み合わせて最適なアプローチを選択することが肝要です。パフォーマンス、システムへの組み込みやすさ、そして求められる精度レベルを考慮しながら、段階的に実装を進めることをお勧めします。