📚 背景知識(読んでから問題へ)
| 用語 | 直感的な意味 |
|---|---|
| Level-0(ベースモデル) | 元の特徴量から予測を作るモデル群。今日はDay 102と同じLR・RF・GBの3モデル |
| Level-1(メタモデル) | Level-0モデルたちの予測値そのものを新しい特徴量として受け取り、最終予測を作るモデル |
| OOF特徴量 | Level-0モデルが「学習に使っていない行」に対して出した予測。リークを防ぐための必須条件 |
| in-sample予測(リーク源) | Level-0モデルが「自分が学習した行」に対して出した予測。異常に高精度で、メタモデルを誤誘導する |
🧩 Stackingの3つの原則
🧠 固定の重みではなく、メタモデルが決める
Averagingの重み最適化の一般化
Day 102の重み最適化はw1,w2,w3という固定の線形結合でした。Stackingはこの混ぜ方自体を、正解ラベルを見ながらメタモデルに学習させる、より柔軟な枠組みです。
🔒 Level-0の出力は必ずOOF予測で
in-sample予測は即リーク
学習に使った行への予測をメタ特徴量にすると、モデルの「答えを覚えている」だけの自信をメタモデルが信頼してしまう。Day 102と同じ5-Fold OOF方式を必ず使う。
⚖️ 独立したholdoutでギャップを検証
見かけのスコアと実力を分離する
CV/OOFスコアが高くても、真に未知のholdoutデータで検証しない限りリークの有無は分からない。両者のギャップが今日の判断材料になる。
🎯 問題
Day 102 と同じChurn(銀行の顧客離反予測)データを使います。今日は追加で、学習に一切使わない「本番相当」の独立したholdoutデータも用意し、CV上のスコア(見かけ)と真の性能(実力)を比較します。
import numpy as np import pandas as pd def make_churn_df(seed, n): rng_state = np.random.get_state() np.random.seed(seed) age = np.random.normal(40, 12, n).clip(18, 80) income = np.random.lognormal(10.5, 0.4, n) tenure_months = np.random.exponential(24, n).clip(0, 120) num_products = np.random.randint(1, 5, n) is_active = np.random.binomial(1, 0.6, n) logit = ( -0.05 * age + 0.00003 * income - 0.03 * tenure_months - 1.2 * is_active + 2.2 * ((age > 55) & (num_products == 1)).astype(float) + 2.0 * ((tenure_months < 6) & (num_products >= 3)).astype(float) + np.random.normal(0, 0.5, n) ) prob = 1 / (1 + np.exp(-logit)) churn = np.random.binomial(1, prob) df = pd.DataFrame({'Age': age, 'Income': income, 'TenureMonths': tenure_months, 'NumProducts': num_products, 'IsActive': is_active, 'Churn': churn}) np.random.set_state(rng_state) return df FEATURES = ['Age', 'Income', 'TenureMonths', 'NumProducts', 'IsActive'] df_train = make_churn_df(seed=42, n=2000) # Day102と同じ学習データ df_holdout = make_churn_df(seed=999, n=1000) # 学習に一切使わない「本番相当」の未知データ X, y = df_train[FEATURES].values, df_train['Churn'].values X_hold, y_hold = df_holdout[FEATURES].values, df_holdout['Churn'].values
| カラム名 | 意味 | 型 |
|---|---|---|
| Age | 顧客の年齢 | float |
| Income | 年収(円) | float |
| TenureMonths | 契約継続月数 | float |
| NumProducts | 契約中の商品数(1〜4) | int |
| IsActive | アクティブ顧客フラグ(1=活発) | int |
| Churn | 離反フラグ(1=離反、目的変数) | int |
タスク
X_holdへの予測(Foldモデルの平均)を同時に作る関数を実装するX_holdに適用して真のholdout AUCを計算する🔀 Stackingの構造 — 正しい経路 vs リーク経路
同じ3モデルからスタートしても、Level-0の予測の作り方(OOF vs in-sample)によって結果が天と地ほど変わることを図解する。
📊 AUC比較(実行結果)
実際にコードを実行して得た値(乱数シード固定・環境により多少前後する)。バーの軸は0.70始まり(切り捨て軸)。「真のholdout AUC」が唯一の本当の実力の物差し。
CV/OOFスコアと真のholdout AUCのギャップ
バーの長さはギャップの絶対値(|CV/OOF AUC − 真のholdout AUC|)を、リークあり版=100%として正規化したもの。正しい版のギャップが負(holdoutの方が高い)なのは、seed=999で生成したholdoutデータがたまたま分離しやすかったというサンプルの偶然であり、CVが構造的に性能を過小評価しているわけではない。注目すべきは符号と大きさ: 正しい版は±0.04程度の誤差レベルに収まるのに対し、リークあり版は+0.16という桁違いの過大評価が、常に「見かけが良く出る」方向にだけ発生している点。
💡 ヒント
Stackingは「Level-0モデルの予測値」を新しい特徴量とみなして、その上にもう1段モデルを乗せる操作。だからこそ「Level-0モデルの予測値がどう作られたか」が命取りになる。学習に使った行への予測(in-sample予測)と、学習に使っていない行への予測(OOF予測)とでは、同じモデル・同じ行でも数値の性質が全く異なることを念頭に、Day 102のget_oof_probaをどう拡張すれば「OOF予測」と「未知データへの予測」を両方作れるかを考える。
- Level-0の関数は、Day 102の
get_oof_probaを拡張し、Foldごとに学習したモデルでX_test(holdout)も予測して、Kモデル分の予測を平均して返すようにする - Level-1(メタモデル)の学習には、Level-0のOOF生成に使ったKFoldとは別の
random_stateのKFoldを使うと、Level-0とLevel-1のFold境界が偶然一致することを避けられる - リークあり版は、
model.fit(X, y)のあとに同じXに対してpredict_probaすればin-sample予測になる(X_holdではなくX自身への予測であることに注意) - 「CV/OOF AUC」と「真のholdout AUC」の差が大きいほど、そのCVスコアは信用できない、という物差しで評価する
from sklearn.base import clone from sklearn.model_selection import KFold from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score def get_oof_and_test_avg(model, X, y, X_test, n_splits=5, random_state=42): kf = KFold(n_splits=n_splits, shuffle=True, random_state=random_state) oof = np.zeros(len(X)) test_preds = np.zeros((n_splits, len(X_test))) for i, (tr_idx, val_idx) in enumerate(kf.split(X)): m = clone(model) m.fit(X[tr_idx], y[___]) oof[val_idx] = m.predict_proba(X[val_idx])[:, 1] test_preds[i] = m.predict_proba(___)[:, 1] # ← holdoutへの予測もこのFoldモデルで作る return oof, test_preds.mean(axis=___) # タスク3: 正しいStacking X_meta_oof = np.column_stack([oof_preds[n] for n in model_names]) X_meta_test = np.column_stack([test_preds[n] for n in model_names]) kf2 = KFold(n_splits=5, shuffle=True, random_state=7) # Level-0とは別のCV meta_oof = np.zeros(len(X)) for tr_idx, val_idx in kf2.split(X_meta_oof): m = clone(LogisticRegression()) m.fit(X_meta_oof[___], y[tr_idx]) meta_oof[val_idx] = m.predict_proba(X_meta_oof[val_idx])[:, 1] meta_final = LogisticRegression().fit(X_meta_oof, y) stack_holdout_pred = meta_final.predict_proba(___)[:, 1] # ← X_meta_test を渡す # タスク4: リークあり版 oof_leak = {} for name, model in models.items(): m = clone(model).fit(X, y) oof_leak[name] = m.predict_proba(___)[:, 1] # ← X自身への予測(in-sample)
✅ 模範解答
import numpy as np import pandas as pd from sklearn.base import clone from sklearn.model_selection import KFold from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler from sklearn.metrics import roc_auc_score # ====== データ準備(省略: 問題文のmake_churn_dfをそのまま使用) ====== df_train = make_churn_df(seed=42, n=2000) df_holdout = make_churn_df(seed=999, n=1000) X, y = df_train[FEATURES].values, df_train['Churn'].values X_hold, y_hold = df_holdout[FEATURES].values, df_holdout['Churn'].values models = { 'LogisticRegression': make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)), 'RandomForest': RandomForestClassifier(n_estimators=200, max_depth=5, random_state=42), 'GradientBoosting': GradientBoostingClassifier(n_estimators=150, max_depth=3, random_state=42), } model_names = list(models.keys()) # ====== タスク2: Level-0 の OOF予測 + holdout予測(Fold平均)を同時生成 ====== def get_oof_and_test_avg(model, X, y, X_test, n_splits=5, random_state=42): """OOF予測(train用)と、Kモデル平均によるholdout予測を同時に作る""" kf = KFold(n_splits=n_splits, shuffle=True, random_state=random_state) oof = np.zeros(len(X)) test_preds = np.zeros((n_splits, len(X_test))) for i, (tr_idx, val_idx) in enumerate(kf.split(X)): m = clone(model) m.fit(X[tr_idx], y[tr_idx]) oof[val_idx] = m.predict_proba(X[val_idx])[:, 1] test_preds[i] = m.predict_proba(X_test)[:, 1] return oof, test_preds.mean(axis=0) oof_preds, test_preds = {}, {} for name, model in models.items(): oof, test_avg = get_oof_and_test_avg(model, X, y, X_hold) oof_preds[name] = oof test_preds[name] = test_avg print(f"{name:20s}: OOF AUC = {roc_auc_score(y, oof):.4f} | holdout AUC = {roc_auc_score(y_hold, test_avg):.4f}") X_meta_oof = np.column_stack([oof_preds[n] for n in model_names]) X_meta_test = np.column_stack([test_preds[n] for n in model_names]) # ====== タスク3: 正しいStacking(Level-1もCVで評価) ====== meta = LogisticRegression() kf2 = KFold(n_splits=5, shuffle=True, random_state=7) # Level-0とは独立のCV分割 meta_oof = np.zeros(len(X)) for tr_idx, val_idx in kf2.split(X_meta_oof): m = clone(meta) m.fit(X_meta_oof[tr_idx], y[tr_idx]) meta_oof[val_idx] = m.predict_proba(X_meta_oof[val_idx])[:, 1] stack_cv_auc = roc_auc_score(y, meta_oof) meta_final = clone(meta).fit(X_meta_oof, y) stack_holdout_pred = meta_final.predict_proba(X_meta_test)[:, 1] stack_holdout_auc = roc_auc_score(y_hold, stack_holdout_pred) print(f"\n正しいStacking: CV AUC = {stack_cv_auc:.4f} | 真のholdout AUC = {stack_holdout_auc:.4f}") print(f"メタモデル係数 (LR, RF, GB) = {np.round(meta_final.coef_[0], 3)}") # ====== タスク4: リークあり版(in-sample予測をメタ特徴量にする) ====== oof_leak, test_leak = {}, {} for name, model in models.items(): m = clone(model).fit(X, y) # train全体で学習 oof_leak[name] = m.predict_proba(X)[:, 1] # 自分が学習した行への予測(in-sample) test_leak[name] = m.predict_proba(X_hold)[:, 1] print(f"{name:20s}: in-sample AUC = {roc_auc_score(y, oof_leak[name]):.4f} (学習データへの予測なので過大)") X_meta_leak = np.column_stack([oof_leak[n] for n in model_names]) X_meta_test_leak = np.column_stack([test_leak[n] for n in model_names]) meta_leak_oof = np.zeros(len(X)) for tr_idx, val_idx in kf2.split(X_meta_leak): m = clone(meta) m.fit(X_meta_leak[tr_idx], y[tr_idx]) meta_leak_oof[val_idx] = m.predict_proba(X_meta_leak[val_idx])[:, 1] leak_cv_auc = roc_auc_score(y, meta_leak_oof) meta_leak_final = clone(meta).fit(X_meta_leak, y) leak_holdout_pred = meta_leak_final.predict_proba(X_meta_test_leak)[:, 1] leak_holdout_auc = roc_auc_score(y_hold, leak_holdout_pred) print(f"\nリークあり版Stacking: CV AUC = {leak_cv_auc:.4f} | 真のholdout AUC = {leak_holdout_auc:.4f}")
出力イメージ(実際に実行して確認した値。乱数・環境により多少前後する):
LogisticRegression : OOF AUC = 0.7305 | holdout AUC = 0.7745 RandomForest : OOF AUC = 0.7480 | holdout AUC = 0.7825 GradientBoosting : OOF AUC = 0.7433 | holdout AUC = 0.7828 正しいStacking: CV AUC = 0.7493 | 真のholdout AUC = 0.7924 メタモデル係数 (LR, RF, GB) = [2.555 2.42 1.355] LogisticRegression : in-sample AUC = 0.7398 (学習データへの予測なので過大) RandomForest : in-sample AUC = 0.8419 (学習データへの予測なので過大) GradientBoosting : in-sample AUC = 0.9251 (学習データへの予測なので過大) ← 答えをほぼ覚えている リークあり版Stacking: CV AUC = 0.9281 | 真のholdout AUC = 0.7693 ← 単独RandomForest(0.7825)にも負ける
w1×LR + w2×RF + w3×GBという固定の重みで予測を混ぜる手法でした。Stackingはこの混ぜ方自体を、正解ラベルyを見ながらメタモデルに学習させる手法です。メタモデルが線形なら重み最適化とほぼ同じ発想ですが、非線形なメタモデルを使えば「LRとRFの予測が食い違うときはどちらを信じるべきか」のような条件付きの判断も学習できる点で、より柔軟です。🪜 Step-by-Step 解説
1Level-0を「OOF予測」と「holdout予測(Fold平均)」の両方を返す形に拡張する
def get_oof_and_test_avg(model, X, y, X_test, n_splits=5, random_state=42): kf = KFold(n_splits=n_splits, shuffle=True, random_state=random_state) oof = np.zeros(len(X)) test_preds = np.zeros((n_splits, len(X_test))) for i, (tr_idx, val_idx) in enumerate(kf.split(X)): m = clone(model) m.fit(X[tr_idx], y[tr_idx]) oof[val_idx] = m.predict_proba(X[val_idx])[:, 1] test_preds[i] = m.predict_proba(X_test)[:, 1] return oof, test_preds.mean(axis=0)
get_oof_probaはtrain内のOOF予測しか作っていませんでした。Stackingでは学習データ用の特徴量(OOF予測)に加えて、真のholdout用の特徴量も必要です。「Foldごとに学習した5個のモデルの予測を平均する」方式を採用しているのは、OOF予測と同じ「複数モデルの合議」という性質をholdout側にも揃えるためです。2Level-1(メタモデル)はLevel-0とは独立のKFoldで評価する
kf2 = KFold(n_splits=5, shuffle=True, random_state=7) # Level-0とは別のCV分割 meta_oof = np.zeros(len(X)) for tr_idx, val_idx in kf2.split(X_meta_oof): m = clone(meta) m.fit(X_meta_oof[tr_idx], y[tr_idx]) meta_oof[val_idx] = m.predict_proba(X_meta_oof[val_idx])[:, 1]
X_meta_oofの各行はすでに「その行を学習に使っていないLevel-0モデル」による予測なのでリークはありませんが、メタモデル自身の性能を測るときも通常のモデルと同じくCVで評価するのが筋です。random_stateをLevel-0と変えているのは、2つのCV分割の境界が完全に一致してしまう偶然を避けるためです。3リークあり版は「学習した本人への予測」を特徴量にしてしまう
m = clone(model).fit(X, y) oof_leak[name] = m.predict_proba(X)[:, 1] # 学習に使ったXそのものへの予測
mはX, y全体で学習されているため、m.predict_proba(X)は「自分が答えを知っている問題」への回答になります。特にGradientBoostingやRandomForestのような表現力の高いモデルは、この自己採点で非常に高いスコアを出せてしまいます(in-sample AUC = 0.9251)。この数値をメタモデルの入力に使うと、メタモデルは「この特徴量が高いときは正解」という本番では成立しない関係を学習してしまいます。4「CV/OOFスコア」と「真のholdoutスコア」のギャップで評価する
print(f"リークあり版: CV={leak_cv_auc:.4f} vs holdout={leak_holdout_auc:.4f} (差={leak_cv_auc-leak_holdout_auc:+.4f})") print(f"正しい版 : CV={stack_cv_auc:.4f} vs holdout={stack_holdout_auc:.4f} (差={stack_cv_auc-stack_holdout_auc:+.4f})")
+0.1588(大幅な過大評価)、正しい版のギャップが-0.0431(過大評価なし)でした。🧮 数学・統計の補足(文系向け)
数式を見たら
ŷ_meta = σ(β₀ + β₁p̂_LR + β₂p̂_RF + β₃p̂_GB)
「3モデルの予測確率p̂を入力として、ロジスティック回帰の係数βで重み付けし、シグモイド関数σで0〜1の確率に変換する」という意味。Day 102の重み付き平均w1p̂₁+w2p̂₂+w3p̂₃(Σw=1, w≥0の制約あり)と似ているが、Stackingのβには非負制約も合計1の制約もなく、シグモイドを通すため確率空間ではなくロジット(対数オッズ)空間での線形結合になっている点がAveragingとの数学的な違い。
🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 画像認識(CV) / ☐ 時系列(Time Series)
- 上位解法の定番構成: Otto Group Product Classification・Home Credit Default Riskなど多くのTabularコンペの上位解法で「Level-0に10〜20個の多様なモデル、Level-1〜2にLightGBM/LogisticRegressionのメタモデル」という多層Stackingが使われている
- Level-2・Level-3への拡張: 今日はLevel-0→Level-1の2階建てだったが、大規模コンペではLevel-1の出力をさらに別のLevel-2メタモデルに入れる3階建て構成も珍しくない。層を増やすほどリーク管理とCV設計の複雑さが増すため、今日のような「CV/OOFスコア vs 真のholdoutスコア」の検証を必ず挟むのが定石
- Stackingが効かない・悪化するケースの認識: 今日の結果ではholdout AUCでRank Average(0.7937)が最良、正しいStacking(0.7924)は僅差2位だった。ベースモデルの多様性が乏しい・データ量が少ない場面ではStackingの複雑さに見合う効果が出ないことも多く、「まずAveragingを試し、伸び代がありそうならStackingを試す」という順序が実務的
- 本番運用でのholdout検証の重要性: 今日構築した「独立したholdoutセットでCV/OOFスコアと真の性能を突き合わせる」検証フローは、Kaggleに限らず実務のMLパイプラインでリークを検出する標準的な手法
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
Level-0モデルをtrain全体で学習し、そのpredict_proba(X)をそのままメタ特徴量にする | 「学習済みモデルがあるなら、それで予測すればいい」という直感 | 学習に使った行への予測はin-sample予測でありリーク。必ずOOF方式(Day 102と同じ)でメタ特徴量を作る |
| Stackingは必ずAveragingより強いと思い込む | 「手法が複雑・高度=性能が良い」という思い込み | 今日の結果でもRank Average(holdout 0.7937)が正しいStacking(0.7924)よりわずかに良かった。ベースモデルの多様性やデータ量次第でAveragingの方が安定することも多い |
| CV/OOF AUCが高ければ「良いStackingができた」と判断する | CVスコアは常に信頼できるという前提 | リークあり版はCV AUC 0.9281という非常に高い値を出したが、真のholdout AUCは0.7693で単独モデルにも劣った。CVスコアは「正しく作られていれば」信頼できるに過ぎない |
| holdoutを一切用意せず、CV/OOFスコアだけでStackingの効果を判断する | 「Kaggleでは公式のtrain/testしかない」という思い込み | 手元のtrainデータの一部を「検証専用のholdout」として切り出し、パイプライン全体(Level-0〜Level-1)の健全性を確認する習慣が重要 |
| メタモデルにも複雑なモデル(GBDTなど)を使えば必ず良くなると思う | 「強いモデルを積めば積むほど強くなる」という発想 | メタモデルの入力はベースモデルの数だけ(今日は3列)しかなく、複雑なモデルは少ない特徴量に対して過学習しやすい。まずはLogisticRegressionのようなシンプルなメタモデルから試すのが定石 |
🚀 次のステップ
- 発展: メタモデルをLogisticRegressionから浅い
GradientBoostingClassifier(max_depth=2程度)に変えて、非線形なメタモデルがholdout AUCを改善するか、それとも3特徴量に対して過学習してholdout AUCを悪化させるかを確認してみる - 次回予告: Day 104「アンサンブル③(Blending)」 — hold-outベースのBlending。今日のStackingはKFold全体を使ってOOF予測を作る「重い」手法だったが、次回は1回のtrain/holdout分割だけで済む「軽量版」のBlendingを学び、StackingとBlendingのトレードオフ(精度 vs 実装の簡潔さ・計算コスト)を比較する
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: