Pythonで学ぶデータ整形

Python正規表現を活用した複雑なテキストデータクリーニング:パターン抽出と標準化の最適化

Tags: Python, データクリーニング, 正規表現, Pandas, テキスト処理

はじめに

データサイエンスやデータエンジニアリングの分野において、テキストデータのクリーニングは、分析や機械学習モデル構築の成否を左右する重要なプロセスです。非構造化データであるテキストは、その多様な表現形式や表記ゆれにより、他のデータ型と比較してクリーニングが特に困難な場合があります。本記事では、Pythonの強力な正規表現(Regular Expression; RegEx)機能を活用し、Pandasデータフレーム上での複雑なテキストデータクリーニングを、実践的なコード例とともに解説します。

読者の皆様は、PythonおよびPandasの基本的な使用経験をお持ちであり、正規表現の基本的な構文についてもご存知であると想定しております。そのため、本記事では、単なる基本構文の解説に留まらず、実務で遭遇しがちなエッジケースへの対応、大規模データにおけるパフォーマンス最適化、そして再現性の高いコード記述といった、より高度で実践的なアプローチに焦点を当てていきます。

1. 正規表現とPandas strアクセサの基礎再確認

PandasのSeriesオブジェクトには、文字列操作に特化したstrアクセサが提供されており、これによりベクトル化された高速な正規表現処理が可能です。strアクセサは、str.contains(), str.extract(), str.replace(), str.findall()など、正規表現と組み合わせることで強力な機能を発揮します。

基本的な抽出と置換

まずは、基本的な正規表現の利用例として、ログデータからの情報抽出と不要な文字列の除去を考えます。

import pandas as pd
import re

# サンプルデータフレームの作成
data = {
    'log_message': [
        '2023-10-27 10:00:01 [INFO] User "alice@example.com" logged in from 192.168.1.100.',
        '2023-10-27 10:00:05 [WARN] Failed login attempt for "bob@mail.org" from 10.0.0.50. (Error: AUTH_FAIL)',
        '2023-10-27 10:00:10 [ERROR] Database connection lost.',
        '2023-10-27 10:00:15 [INFO] Processing report for "charlie@mymail.net".'
    ]
}
df = pd.DataFrame(data)

print("--- 元のデータ ---")
print(df)

# [INFO], [WARN], [ERROR] などのログレベルを抽出
# str.extract()はキャプチャグループの内容をDataFrameとして返します
df['log_level'] = df['log_message'].str.extract(r'\[(INFO|WARN|ERROR)\]')

# メールアドレスを抽出
# メールのローカルパートとドメインをそれぞれ別のカラムに抽出
email_pattern = r'"([a-zA-Z0-9._%+-]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})"'
extracted_emails = df['log_message'].str.extract(email_pattern)
df['email_local_part'] = extracted_emails[0]
df['email_domain'] = extracted_emails[1]

# IPアドレスを抽出
df['ip_address'] = df['log_message'].str.extract(r'from (\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})')

# ログメッセージから日付、時刻、レベルを削除
# str.replace()を用いて不要な部分を空文字列に置換
df['cleaned_message'] = df['log_message'].str.replace(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \[(INFO|WARN|ERROR)\] ', '', regex=True)

print("\n--- クリーニング後のデータ ---")
print(df.head())

この例では、str.extract()を用いて括弧で囲まれたグループの内容を新しいカラムに抽出し、str.replace()を用いて特定のパターンを削除しています。str.extract()は、複数のキャプチャグループがある場合にそれぞれのグループをカラムとして持つDataFrameを返すため、複雑な構造を持つ文字列から複数の要素を同時に抽出する際に非常に有用です。

2. 複雑なテキストパターン抽出とデータ標準化

実務では、単一のシンプルなパターンでは対応できないような、表記ゆれや複雑な構造を持つテキストデータを扱うことが頻繁にあります。ここでは、非捕獲グループ、肯定/否定先読み・後読み、条件分岐といった高度な正規表現テクニックを用いて、このような課題に対処します。

住所データの標準化

住所データは、都道府県名が省略されたり、「丁目」「番地」「号」などの表記が揺れたりするため、正規表現を用いた標準化の良い例です。

import pandas as pd
import re

data = {
    'address': [
        '東京都新宿区西新宿2-8-1 新宿野村ビル',
        '大阪市北区梅田1丁目3番地1号 大阪駅前第1ビル',
        '北海道札幌市中央区北一条西2-1',
        '福岡市博多区博多駅中央街1-1 アミュプラザ博多',
        '青森県青森市新町2-1-3' # 都道府県があるが、それ以外はシンプル
    ]
}
df_address = pd.DataFrame(data)

print("--- 元の住所データ ---")
print(df_address)

# 住所から丁目、番地、号を柔軟に抽出する正規表現
# (?:...) は非捕獲グループで、マッチはするが抽出結果には含まれない
# (\d+) は数字をキャプチャグループとして抽出
# (?:丁目|番地)? は「丁目」または「番地」が0回か1回出現することを示す
# (?:番地|号)? は「番地」または「号」が0回か1回出現することを示す
# このパターンは、X-Y-Z, X丁目Y番地Z号, X-Y番地 など多様な形式に対応
address_pattern = r'(\d+)(?:丁目|番地)?(?:-(\d+))?(?:番地)?(?:-(\d+))?(?:号)?'

# str.extract()で複数の部分を抽出
extracted_parts = df_address['address'].str.extract(address_pattern)

# 抽出された各部分を結合して標準化された形式を作成
# NaNは空文字列として扱い、ハイフンで結合
df_address['standardized_address_num'] = extracted_parts[0].fillna('') + \
                                         extracted_parts[1].fillna('').apply(lambda x: f"-{x}" if x else '') + \
                                         extracted_parts[2].fillna('').apply(lambda x: f"-{x}" if x else '')

print("\n--- 住所抽出・標準化後のデータ ---")
print(df_address.head())

# より複雑な例: 都道府県名も抽出する(表記揺れ対応)
# ここでは都道府県を抽出するパターンとそれ以外のパターンを組み合わせます。
# 都道府県は必ずしも含まれないため、非捕獲グループでオプションとします。
prefecture_pattern = r'^(?:(東京都|北海道|神奈川県|大阪府|京都府|千葉県|埼玉県|愛知県|福岡県|兵庫県|静岡県|その他[都道府県])?)(.*)$'
# 都道府県名リストは実際のデータに合わせて調整が必要です。

# str.extract()で都道府県と残りの住所を分離
prefecture_extracted = df_address['address'].str.extract(prefecture_pattern)
df_address['prefecture'] = prefecture_extracted[0]
df_address['remaining_address'] = prefecture_extracted[1]

# 処理後のDataFrame
print("\n--- 都道府県分離後のデータ ---")
print(df_address.head())

この例では、re.compile()で正規表現オブジェクトを事前にコンパイルし、str.extract()で複数グループを抽出、さらに抽出した内容を結合して標準化しています。特に、(\d+)(?:丁目|番地)?(?:-(\d+))?(?:番地)?(?:-(\d+))?(?:号)?のようなパターンは、?:で非捕獲グループを使用し、「丁目」「番地」「号」といった文字をマッチ対象としつつ、抽出結果には含めないことで、数字部分のみを正確に取得できるように設計しています。

3. 大規模データにおける正規表現処理のパフォーマンス最適化

大規模なデータセットに対して正規表現を適用する場合、処理速度は非常に重要な要素となります。re.compile()による正規表現のコンパイルと、Pandasのベクトル化された操作を適切に利用することで、パフォーマンスを大幅に向上させることが可能です。

re.compile()の活用

正規表現パターンをre.compile()でコンパイルすると、Pythonは正規表現を内部的なバイトコードに変換し、以降の検索処理が高速化されます。同じパターンを複数回適用する場合に特に有効です。

import pandas as pd
import re
import time

# 大規模データセットのシミュレーション
num_rows = 100000
data_large = {
    'text_data': [
        'User "user_123@domain.com" accessed resource X from 192.168.0.1.',
        'Guest "visitor_abc@example.org" viewed page Y from 10.0.0.254.',
        'Admin "admin@internal.net" modified config Z from 172.16.1.10.'
    ] * (num_rows // 3)
}
df_large = pd.DataFrame(data_large)

email_pattern_str = r'([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})'

# コンパイルなしの場合
start_time = time.time()
df_large['email_no_compile'] = df_large['text_data'].str.extract(email_pattern_str)
end_time = time.time()
print(f"コンパイルなしの処理時間: {end_time - start_time:.4f}秒")

# コンパイルありの場合
compiled_email_pattern = re.compile(email_pattern_str)
start_time = time.time()
# str.extract()はre.Patternオブジェクトを直接受け取ることはできないため、apply()で適用
# ただし、apply()はベクトル化されたstrアクセサよりも遅い場合があることに注意
df_large['email_with_compile_apply'] = df_large['text_data'].apply(lambda x: compiled_email_pattern.search(x).group(1) if compiled_email_pattern.search(x) else None)
end_time = time.time()
print(f"コンパイルあり(apply使用)の処理時間: {end_time - start_time:.4f}秒")

# Pandasのstr.extract()は内部で正規表現を最適化して処理するため、
# 多くのケースでstrアクセサのメソッドを直接使用することが最も高速です。
# str.extract()は内部的にパターンをコンパイルしているため、明示的なre.compileは不要な場合が多いです。
# re.compileが真価を発揮するのは、Pythonのreモジュール関数(re.search, re.findallなど)を
# apply()等で繰り返し呼び出すようなシナリオです。

# 最適な方法は多くの場合Pandasのstrアクセサを直接利用することです。
start_time = time.time()
df_large['email_pandas_native'] = df_large['text_data'].str.extract(email_pattern_str)
end_time = time.time()
print(f"Pandas str.extract()ネイティブの処理時間: {end_time - start_time:.4f}秒 (最も推奨)")

print("\n--- 抽出結果の確認 (一部) ---")
print(df_large[['email_no_compile', 'email_with_compile_apply', 'email_pandas_native']].head())

上記の例では、Pandasのstr.extract()が内部的に最適化された正規表現処理を行うため、明示的なre.compile()apply()の組み合わせよりも高速な結果となることが示されます。re.compile()が真に効果を発揮するのは、df.apply(lambda x: re.compile(pattern).search(x))のように、Pythonのreモジュール関数をapplyで繰り返し呼び出す場合や、大量の非Pandas文字列処理を行う場合です。Pandasデータフレームでは、可能な限りstrアクセサのメソッドを直接利用することが推奨されます。

str.replace()のCallable引数

str.replace()は、置換文字列として文字列だけでなく、関数(callable)も受け取ることができます。これにより、マッチした内容に基づいて動的に置換ロジックを適用でき、複雑な置換処理を効率的に行えます。

import pandas as pd

data = {
    'product_code': [
        'ABC-1234',
        'DEF_5678',
        'GHI9012',
        'XYZ-9876-V2',
        'MNO/3456',
        'PQR_Invalid_Code'
    ]
}
df_codes = pd.DataFrame(data)

print("--- 元の商品コードデータ ---")
print(df_codes)

# 商品コードからアルファベット部分と数字部分を抽出し、標準形式「ABC1234」に変換
# アルファベットと数字のパターンを定義
# (?:[_-/])? は、ハイフン、アンダースコア、スラッシュのいずれかが0回または1回出現する非捕獲グループ
pattern_code = r'([A-Z]+)(?:[_-/])?(\d+)'

def replace_func(match):
    # matchオブジェクトからキャプチャグループを取得し、結合する
    # 例: match.group(1)が"ABC", match.group(2)が"1234"
    return match.group(1) + match.group(2)

# str.replace()の第2引数にcallableを渡す
# regex=True を忘れずに指定
df_codes['standardized_code'] = df_codes['product_code'].str.replace(
    pattern_code, replace_func, regex=True
)

# マッチしなかった場合は元の値を保持
# str.replace(regex=True) はマッチしない行はそのまま残します
# 必要に応じて、マッチしなかった行を別途処理するロジックを追加
# 例: マッチしなかったものをNaNにする場合は、事前にextractしてjoinする方が確実
# extracted = df_codes['product_code'].str.extract(pattern_code)
# df_codes['standardized_code_alt'] = extracted[0] + extracted[1]

print("\n--- 標準化後の商品コードデータ ---")
print(df_codes)

str.replace()にcallableを渡すことで、単なる固定文字列置換では実現できない柔軟なデータ整形が可能となります。これは、マッチした内容に応じて、大文字小文字を変換したり、特定の計算を適用したりする場合に特に有用です。

4. エッジケースへの対応と堅牢なクリーニングロジック

実世界のデータはしばしば不完全であったり、予期せぬフォーマットを含んでいたりします。このようなエッジケースに対して堅牢なクリーニングロジックを構築することは、データ処理パイプラインの安定性を保つ上で不可欠です。

マッチしない場合の挙動と欠損値処理

str.extract()は、パターンにマッチしない場合、対応するカラムにNaN(Not a Number)を挿入します。このNaNを適切に処理することで、後続の分析や処理がスムーズに進みます。

import pandas as pd

data = {
    'customer_id': [
        'CUST-001',
        'CUSTOMER-002', # 不適切なフォーマット
        'ID_003',
        'CUST-004',
        '005', # 数字のみ
        None # 欠損値
    ]
}
df_customer = pd.DataFrame(data)

print("--- 元の顧客IDデータ ---")
print(df_customer)

# 顧客IDから数字部分のみを抽出するパターン
# CUST- または CUSTOMER- の後に数字が続くことを想定
# (?:CUST|CUSTOMER)[_-]? は非捕獲グループで、CUSTまたはCUSTOMERがハイフンかアンダースコアに続き0回か1回出現
id_pattern = r'(?:CUST|CUSTOMER)[_-]?(\d+)'

# str.extract()で数字部分を抽出
extracted_id = df_customer['customer_id'].str.extract(id_pattern)
df_customer['extracted_id'] = extracted_id[0]

# マッチしなかった(NaNになった)エントリを特定の値に置き換える
# 例えば、マッチしなかったIDは'INVALID'とする
df_customer['cleaned_id'] = df_customer['extracted_id'].fillna('INVALID')

print("\n--- 抽出・欠損値処理後の顧客IDデータ ---")
print(df_customer)

str.extract()NaNを返す特性を利用し、fillna()などのPandasのメソッドと組み合わせることで、マッチしなかったエントリを特定の値に置き換えたり、後続の処理から除外したりすることができます。

複数ステップでのクリーニングとエラーハンドリング

複雑なクリーニング要件を満たすためには、単一の正規表現では不十分な場合があります。複数の正規表現やロジックを組み合わせた多段階の処理が必要となることがあります。また、一部のデータで予期せぬエラーが発生する可能性も考慮し、エラーハンドリングを組み込むことも重要です。

import pandas as pd
import re

data = {
    'feedback': [
        'Good product. Contact me at support@example.com!',
        'Bad service. Call 090-1234-5678 please.',
        'Feedback without contact info.',
        'Email is test@mail.co.jp. Phone: (03)1234-5678.',
        'Invalid input @!#$' # 予期せぬ入力
    ]
}
df_feedback = pd.DataFrame(data)

print("--- 元のフィードバックデータ ---")
print(df_feedback)

# 1. メールアドレスの抽出
email_pattern = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
df_feedback['extracted_email'] = df_feedback['feedback'].str.findall(email_pattern).str[0] # findallでリストとして取得後、最初の要素

# 2. 電話番号の抽出(複数のフォーマットに対応)
phone_pattern = r'(?:0\d{1,3}-?\d{1,4}-?\d{4}|\(\d{2,3}\)\d{1,4}-?\d{4})'
df_feedback['extracted_phone'] = df_feedback['feedback'].str.findall(phone_pattern).str[0]

# 3. 抽出した情報を元データから削除
# 抽出したメールアドレスと電話番号をまとめて空文字列に置換
# re.escape() を使用して、抽出した文字列を正規表現の特殊文字として扱わないようにする
def remove_extracted_info(row):
    text = row['feedback']
    if pd.notna(row['extracted_email']):
        text = re.sub(re.escape(row['extracted_email']), '', text)
    if pd.notna(row['extracted_phone']):
        text = re.sub(re.escape(row['extracted_phone']), '', text)
    # その他の特殊文字や余分なスペースをクリーニング
    text = re.sub(r'[!@#$]', '', text) # 特定の記号を削除
    text = re.sub(r'\s+', ' ', text).strip() # 複数スペースを単一スペースに、前後スペース除去
    return text

df_feedback['cleaned_feedback'] = df_feedback.apply(remove_extracted_info, axis=1)

print("\n--- クリーニング後のフィードバックデータ ---")
print(df_feedback.head())

この例では、メールアドレスと電話番号をそれぞれ別の正規表現で抽出し、その後、抽出した情報を元のテキストから削除しています。re.escape()は、抽出した文字列自体が正規表現の特殊文字を含んでいた場合に、それが正規表現として解釈されないようにエスケープするために使用されます。これにより、堅牢な置換処理を実現します。

5. 再現性とテストを考慮したコード設計

データクリーニングのコードは、再現性が高く、変更に強く、そしてテスト可能であるべきです。特に正規表現はデバッグが難しいため、ロジックを関数化し、テストを通じてその正確性を保証することが重要です。

クリーニングロジックの関数化とテスト

複雑なクリーニング処理は、独立した関数として定義することで、コードの可読性と再利用性が向上します。さらに、テストデータを準備し、その関数が期待通りに動作するかを検証することで、コードの信頼性を高めることができます。

import pandas as pd
import re

# 正規表現パターンを定数として定義
EMAIL_PATTERN = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
PHONE_PATTERN = r'(?:0\d{1,3}-?\d{1,4}-?\d{4}|\(\d{2,3}\)\d{1,4}-?\d{4})'

def clean_feedback_text(text: str) -> str:
    """
    フィードバックテキストからメールアドレスと電話番号を抽出し、元のテキストから削除します。
    その後、不要な記号と余分なスペースを整理します。
    """
    if not isinstance(text, str):
        # NaNやNoneなど文字列でない場合は空文字列を返すか、エラー処理を強化
        return ""

    # メールアドレスを抽出
    extracted_email = re.findall(EMAIL_PATTERN, text)
    email = extracted_email[0] if extracted_email else None

    # 電話番号を抽出
    extracted_phone = re.findall(PHONE_PATTERN, text)
    phone = extracted_phone[0] if extracted_phone else None

    # 抽出した情報を元のテキストから削除
    if email:
        text = re.sub(re.escape(email), '', text)
    if phone:
        text = re.sub(re.escape(phone), '', text)

    # その他の特殊文字や余分なスペースをクリーニング
    text = re.sub(r'[!@#$]', '', text)
    text = re.sub(r'\s+', ' ', text).strip()
    return text

def extract_contacts(text: str) -> dict:
    """
    テキストからメールアドレスと電話番号を抽出し、辞書形式で返します。
    """
    if not isinstance(text, str):
        return {"email": None, "phone": None}

    email = re.findall(EMAIL_PATTERN, text)
    phone = re.findall(PHONE_PATTERN, text)

    return {
        "email": email[0] if email else None,
        "phone": phone[0] if phone else None
    }

# テストデータ
test_data = [
    'Good product. Contact me at support@example.com!',
    'Bad service. Call 090-1234-5678 please.',
    'Feedback without contact info.',
    'Email is test@mail.co.jp. Phone: (03)1234-5678.',
    'Invalid input @!#$',
    None # 欠損値のテスト
]

# 関数の適用と結果の確認
cleaned_texts = [clean_feedback_text(text) for text in test_data]
extracted_contacts_list = [extract_contacts(text) for text in test_data]

df_test_results = pd.DataFrame({
    'original_text': test_data,
    'cleaned_text': cleaned_texts,
    'extracted_contacts': extracted_contacts_list
})

print("--- クリーニング関数と抽出関数のテスト結果 ---")
print(df_test_results)

# Pandas Seriesへの適用例
df_example = pd.DataFrame({'feedback': test_data})
df_example['cleaned_feedback'] = df_example['feedback'].apply(clean_feedback_text)
df_example = pd.concat([df_example, df_example['feedback'].apply(extract_contacts).apply(pd.Series)], axis=1)

print("\n--- Pandas DataFrameへの適用結果 ---")
print(df_example)

正規表現パターンを定数として定義し、クリーニングロジックを関数にカプセル化することで、コードの保守性が向上します。また、個別のテストケースを用意して関数を検証することは、本番環境にデプロイする前に潜在的な問題を特定するために不可欠です。実際のプロジェクトでは、pytestのようなテストフレームワークを活用し、網羅的なユニットテストを記述することが強く推奨されます。

まとめ

本記事では、Pythonの正規表現とPandasを組み合わせた、複雑なテキストデータクリーニングの実践的なアプローチについて解説しました。単なる基本構文の利用に留まらず、以下の点に焦点を当ててきました。

テキストデータクリーニングは、データ品質を担保し、後続のデータ分析や機械学習モデルの精度向上に直結する重要なステップです。本記事で紹介した実践的なテクニックと設計思想が、皆様のデータクリーニング作業の一助となれば幸いです。複雑なテキストデータに果敢に挑み、その潜在的な価値を最大限に引き出してください。