誰もが書くあのパイプライン、疑ったことはありますか
scikit-learnを数か月使っていると、誰もが似たようなコードを書きます。数値列はSimpleImputer + StandardScaler、それ以外はSimpleImputer + OneHotEncoder。ColumnTransformerがdtypeで分岐してくれるので、列を一つひとつ確認しなくても動きます。
短く、再利用可能で、信頼できる。そこが問題です。
本記事は「正しく動いているように見える」パイプラインを意図的に壊してみる実験から始まります。8,000行の合成チャーン顧客データをシード0〜4で5つ生成し(学習6,000 / ホールドアウト2,000)、dtypeベースのパイプラインが原理的に見えない罠を4つ仕込みました。結果は次の表のとおりです。
| バリアント | CV AUC | ホールドアウト AUC |
|---|---|---|
| 素朴な自動パイプライン | 0.979 | 0.564 |
| + 地域をカテゴリ化 | 0.982 | 0.624 |
| + 欠損インジケータ追加 | 0.984 | 0.670 |
| + 日付をパース | 0.980 | 0.585 |
| + ID列を削除 | 0.640 | 0.632 |
| 4つの修正すべて | 0.833 | 0.826 |
クラッシュも警告もなく、すべての数値が「強いモデル」に見えました。実務でこの表を初めて見ると背筋が寒くなりますね。

罠4つをコードで再現する
仕込んだ罠は次のとおりです。
- 数値に偽装したカテゴリ変数:
region_codeは1〜12の整数ですが、地域ごとのチャーン率は順序と無関係に異なります。パイプラインはこれを単なる測定値としてスケーリングしてしまいます。 - 情報を持つ欠損値:
support_callsはチャーン顧客の約45%、非チャーン顧客の8%で欠損しています。中央値補完でこの信号が消えます。 - テキストで保存された日付:
signup_dateがYYYY-MM-DD文字列のため、日付ごとにOne-Hot列が生成されます(6,000行でユニーク値1,931個)。 - ラベルを漏らす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が漏れるとはどういう形か」を知らなくてよいという点です。ツールが文章で教えてくれるからです。

一つの修正では足りません
表をもう一度見てください。「+ 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パイプラインに組み込んでおくと、こうした事故を早期に発見できます。データパイプライン設計に興味があれば、プロトタイプログインの信頼構築ガイドのように「自動化に検証を埋め込む」思考法が参考になります。

自動化は検証ではありません
8行のパイプラインは良い出発点であり、ひどい終着点です。この実験でそのパイプラインは0.98を叩き出しましたが、新規データで0.56に落ち、正直な0.83に到達するには一つではなく四つの修正が必要でした。自動化は時間を節約しましたが、検証はしてくれませんでした。だから私は検証を自動化の中に組み込みました。
こうした問題を自分で見つけられない人のためのツールを作るなら、まさにこの部分をツールに入れるべきです。
信頼する前に投げかける5つの問い
- 未来データのようなホールドアウトはあるか? 学習データ内のリークは学習データ上の交差検証では捕まりません。時間基準や別エクスポートでホールドアウトを作ってください。
- モデルが最も依存する列はどれか? 上位の係数や順列重要度を見てください。ID、行番号、タイムスタンプが1位なら止めてください。
- 数値列が実はカテゴリ変数ではないか? コード、郵便番号、地域IDは数量ではない数値です。
- 欠損はランダムか? 欠損時と存在時のターゲット比率を比較してください。異なれば欠損インジケータを追加してください。
- ユニーク値が数千個のテキスト列はあるか? 日付・名前・自由テキストは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