誰もが書くあのパイプライン、疑ったことはありますか

scikit-learnを数か月使っていると、誰もが似たようなコードを書きます。数値列はSimpleImputer + StandardScaler、それ以外はSimpleImputer + OneHotEncoder。ColumnTransformerがdtypeで分岐してくれるので、列を一つひとつ確認しなくても動きます。

短く、再利用可能で、信頼できる。そこが問題です。

本記事は「正しく動いているように見える」パイプラインを意図的に壊してみる実験から始まります。8,000行の合成チャーン顧客データをシード0〜4で5つ生成し(学習6,000 / ホールドアウト2,000)、dtypeベースのパイプラインが原理的に見えない罠を4つ仕込みました。結果は次の表のとおりです。

バリアントCV AUCホールドアウト AUC
素朴な自動パイプライン0.9790.564
+ 地域をカテゴリ化0.9820.624
+ 欠損インジケータ追加0.9840.670
+ 日付をパース0.9800.585
+ ID列を削除0.6400.632
4つの修正すべて0.8330.826

クラッシュも警告もなく、すべての数値が「強いモデル」に見えました。実務でこの表を初めて見ると背筋が寒くなりますね。

Data scientist inspecting a churn dataset table with hidden ID and date columns highlighted Software Concept Art

罠4つをコードで再現する

仕込んだ罠は次のとおりです。

  1. 数値に偽装したカテゴリ変数: region_codeは1〜12の整数ですが、地域ごとのチャーン率は順序と無関係に異なります。パイプラインはこれを単なる測定値としてスケーリングしてしまいます。
  2. 情報を持つ欠損値: support_callsはチャーン顧客の約45%、非チャーン顧客の8%で欠損しています。中央値補完でこの信号が消えます。
  3. テキストで保存された日付: signup_dateがYYYY-MM-DD文字列のため、日付ごとにOne-Hot列が生成されます(6,000行でユニーク値1,931個)。
  4. ラベルを漏らすID: 学習データではcustomer_idが結果の順序とおおむね相関するように付与されました。実システムでエクスポート順や時刻が結果と相関する状況の代役です。ホールドアウトではIDとチャーンは無関係です。

これを自動で検出するスキャナを一つ作ってみましょう。モデリング前にテーブルを走査し、人間が読める警告を出力する関数です。

import pandas as pd
import numpy as np

def scan_table(df: pd.DataFrame, target: str, id_like_suffixes=("id", "_no", "_code")) -> None:
    """モデリング前にテーブルをスキャンし、データリークのリスクを警告します。"""
    y = df[target]

    for col in df.columns:
        if col == target:
            continue
        s = df[col]

        # 1) ID疑い列: 名前がidで終わる、またはユニーク値比率が極端に高い
        if col.lower().endswith(id_like_suffixes) or s.nunique() / len(s) > 0.9:
            corr = pd.to_numeric(s, errors="coerce").corr(y)
            if corr is not None and abs(corr) > 0.3:
                print(f"[警告] '{col}' 列がターゲットと相関 {corr:.2f}。IDリークの疑い。")

        # 2) 数値に偽装したカテゴリ変数: 整数だがユニーク値が少なく順序に意味がない
        if pd.api.types.is_integer_dtype(s) and 2 <= s.nunique() <= 30:
            print(f"[検討] '{col}' は整数ですがカテゴリ変数の可能性があります。コード/郵便番号/地域IDか確認。")

        # 3) 情報を持つ欠損値: 欠損の有無でターゲット比率が大きく異なる
        if s.isna().any():
            rate_missing = y[s.isna()].mean()
            rate_present = y[s.notna()].mean()
            if abs(rate_missing - rate_present) > 0.1:
                print(f"[警告] '{col}' の欠損有無でターゲット比率差 {abs(rate_missing - rate_present):.2f}。欠損インジケータ追加を推奨。")

        # 4) ユニーク値爆発テキスト列: 日付/名前/自由テキスト
        if s.dtype == object and s.nunique() > 100:
            print(f"[警告] '{col}' テキスト列のユニーク値 {s.nunique()}個。One-Hotではなく別処理が必要。")


def gap_warning(cv_score: float, holdout_score: float, threshold: float = 0.1) -> None:
    """CVとホールドアウトの乖離が大きい場合、リーク/暗記を疑います。"""
    gap = cv_score - holdout_score
    if gap > threshold:
        print(f"[警告] CV({cv_score:.2f})がホールドアウト({holdout_score:.2f})より {gap:.2f} 高い。リークまたは暗記の疑い。")

# 使用例
# scan_table(train_df, target="churned")
# gap_warning(0.979, 0.564)

シード0では、スキャナは仕込んだ4列(customer_id、region_code、support_calls、signup_date)を毎回正確に検出し、tenure_months、monthly_spend、plan_typeには触れませんでした。gap_warning(0.979, 0.564)はヘッドラインの問題を一文で指摘します。「交差検証(0.98)がホールドアウト(0.56)よりはるかに高い。リークまたは暗記を疑ってください。」

しきい値が完璧かというと、そうではありません。重要なのはコードを書かない人でも「IDが漏れるとはどういう形か」を知らなくてよいという点です。ツールが文章で教えてくれるからです。

Python scikit-learn pipeline code on laptop screen showing ColumnTransformer with numeric and categorical branches Dev Environment Setup

一つの修正では足りません

表をもう一度見てください。「+ ID列を削除」の行で交差検証が正直になります(0.640 対 新規データ0.632)。検証数値としては望ましい姿です。しかしホールドアウトAUCは0.632にとどまります。残り3つの問題がそのまま残っているからです。

ロジスティック回帰での単一修正効果は次のとおりでした。

  • 欠損インジケータ: 0.564 → 0.670(最大の単一改善)
  • 地域をカテゴリ化: 0.564 → 0.624
  • 日付パース: 0.564 → 0.585(ごくわずか)
  • ID削除: CVは正直になるがホールドアウトは0.632

4つすべてを適用して初めて0.826に到達し、そこでようやく正直なCV(0.833)とホールドアウトが手を取り合います。「一つ直せば済む」という錯覚が最も危険です。

より良いモデルなら救えるのか

部分的には。勾配ブースティングは素朴なパイプラインで0.614と、ロジスティック回帰(0.564)よりわずかに良いものの依然として貧弱です。ID削除が最も効果的で(0.739)、4つすべて適用で0.809に達します。ただし勾配ブースティングでは地域修正(0.607)はノイズ範囲内であり、欠損インジケータのバリアントはシード間のばらつきが大きい(std 0.084)。この2行は個別に読まないでください。一貫した発見はID効果と全修正の結果です。

このアプローチの限界

  • 合成データ一つ: 結果は自作の生成器一つから出ています。効果の方向は転移するでしょうが、大きさは実データで変わります。よく知る実テーブルで同じバリアントを回してください。
  • 誇張されたIDリーク: 失敗が見えるようにIDを意図的に強く相関させました。実際のリークは通常もっと弱いです。
  • モデル2種のみ: 他のモデルは異なる反応をするかもしれません。将来データが到着する形のままホールドアウトを作り、繰り返してください。
  • 検出器の誤検知率は未検証: 罠が強くクリーンだったため検出は容易でした。実データでは人間の判断が必要な警告が出ます(フラグされたIDが無害なことも、偏ったカテゴリが本物のこともあります)。警告は「自動修正」ではなく「確認の合図」として扱ってください。

日本の開発現場では、特に2番(情報を持つ欠損値)と4番(エクスポート順と相関するID)が頻出します。運用DBからダンプした順序がラベルと相関しているケースは少なくありません。モデリング前にscan_tableのようなスキャナをCIパイプラインに組み込んでおくと、こうした事故を早期に発見できます。データパイプライン設計に興味があれば、プロトタイプログインの信頼構築ガイドのように「自動化に検証を埋め込む」思考法が参考になります。

Server dashboard comparing cross-validation AUC 0.98 against holdout AUC 0.56 to visualize leakage gap Development Concept Image

自動化は検証ではありません

8行のパイプラインは良い出発点であり、ひどい終着点です。この実験でそのパイプラインは0.98を叩き出しましたが、新規データで0.56に落ち、正直な0.83に到達するには一つではなく四つの修正が必要でした。自動化は時間を節約しましたが、検証はしてくれませんでした。だから私は検証を自動化の中に組み込みました。

こうした問題を自分で見つけられない人のためのツールを作るなら、まさにこの部分をツールに入れるべきです。

信頼する前に投げかける5つの問い

  1. 未来データのようなホールドアウトはあるか? 学習データ内のリークは学習データ上の交差検証では捕まりません。時間基準や別エクスポートでホールドアウトを作ってください。
  2. モデルが最も依存する列はどれか? 上位の係数や順列重要度を見てください。ID、行番号、タイムスタンプが1位なら止めてください。
  3. 数値列が実はカテゴリ変数ではないか? コード、郵便番号、地域IDは数量ではない数値です。
  4. 欠損はランダムか? 欠損時と存在時のターゲット比率を比較してください。異なれば欠損インジケータを追加してください。
  5. ユニーク値が数千個のテキスト列はあるか? 日付・名前・自由テキストはOne-Hotではなく別処理が必要です。

次のステップ学習方向

  • sklearn.pipeline.PipelineにカスタムFunctionTransformerで欠損インジケータと日付パーサを組み込んでみてください。
  • TimeSeriesSplitで時間ベースのホールドアウトを作り、上の実験を再現してみてください。
  • permutation_importanceで列ごとの依存度を測定し、ID系列を自動除去する前処理ステップを設計してみてください。
  • Android側でAIエージェントを作っているなら、ADK for KotlinでAndroid AIエージェントを作るも同じ「自動化に検証を埋め込む」観点から読む価値があります。

根拠資料: My Machine Learning Pipeline Cheated, and Cross-Validation Never Noticed

本コンテンツは、信頼性の高い情報源をもとにAIツールを活用して作成され、編集者によるレビューを経て公開されています。専門家によるアドバイスの代替となるものではありません。