📚 背景知識(読んでから問題へ)
| 用語 | 直感的な意味 |
|---|---|
| blend_train | Level-0モデルの学習だけに使う分割(例: 全体の80%) |
| blend_holdout | Level-0の予測を集めてLevel-1メタモデルの学習データにする分割(例: 全体の20%)。StackingのOOF予測全体に相当するが、サイズはごく一部 |
| 軽量性 | Level-0モデルの学習が「K-Fold × モデル数」回ではなく「モデル数」回で済む |
🧩 Blendingの3つの原則
⚡ K-Foldループが不要
モデル数×1回のfitで済む
Level-0モデルの学習は「モデル数」回だけ。Stackingの「モデル数×Fold数」より大幅に少ない。今日の設定では9回 vs 21回、約2.3倍の差になった。
📉 メタモデルはholdoutの一部だけ
2000行 → 400行(5分の1)
Stackingのメタモデルは学習データ全体(OOF予測)を使えるが、Blendingは切り出したblend_holdoutの行数しか使えない。これが精度差につながる。
🎲 分割seed次第で結果がブレる
K-Foldのような「巡回」がない
Stackingは学習データ全体をK-Foldで巡回するため分割への依存が薄いが、Blendingは1回の分割に依存するため、seedを変えると真のholdout AUCが動く。
🎯 問題
Day 102・103と同じChurn(銀行の顧客離反予測)データと同じ独立holdout(X_hold, y_hold)を使います。
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・103と同じ学習データ 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 |
タスク
train_test_split(test_size=0.2, stratify=y, random_state=42)でblend_train(80%)とblend_holdout(20%)に分割する。3モデル(LR・RF・GB)をblend_trainだけで学習し、blend_holdoutとX_holdへの予測を同時に作るblend_holdout側の予測を特徴量としてLogisticRegressionをメタモデルとして学習する。blend_holdout(400件)を5-Foldに分けてメタモデル自身のAUCを計算し、最終メタモデルをX_holdに適用して真のholdout AUCを計算するrandom_stateを複数回変えて実行し、真のholdout AUCのブレを確認する🔀 BlendingとStackingの構造比較
同じ3モデル・同じ2000行のデータからスタートしても、Level-0予測の作り方によってメタモデルが手にする「材料の量」が大きく変わることを図解する。
📊 AUC・データ量・fit回数の比較(実行結果)
実際にコードを実行して得た値(乱数シード固定・環境により多少前後する)。バーの軸は0.70始まり(切り捨て軸)。
真のholdout AUC 比較(全手法)
メタモデル学習に使えたデータ量
総モデルfit回数(計算コスト)
Blendingは分割seedでどれだけブレるか
6つの値(seed=42, 1〜5)の標準偏差は約0.0045、最大(0.7903)と最小(0.7770)の差は0.013。Stackingの真のholdout AUC(0.7924)はこの範囲のさらに上にあり、Blendingは「たまたま良い分割」を引かない限りStackingに届きにくいことが分かる。
💡 ヒント
Day 103のget_oof_and_test_avgは「K-Foldで学習データ全体をOOF予測に変換する」関数だった。今日はこのK-Foldのループを、たった1回のtrain_test_splitに置き換えるとどうなるかを考える。返ってくる「メタ特徴量として使える行数」が、Stackingの2000行(学習データ全体)から、Blendingではblend_holdoutのサイズ(20%=400行程度)に激減する点に注目する。
train_test_splitでblend_train・blend_holdoutを作る。各モデルはblend_trainだけでfitし、blend_holdoutとX_holdの両方にpredict_probaする(Stackingのように「Foldモデルの平均」を取る必要はない。1つのモデルしか学習していないため)- メタモデルの評価には
blend_holdoutだけをさらに5-Foldする。400件程度と少ないため、評価スコアが不安定(ブレやすい)ことを念頭に置く - fit回数はLevel-0(モデル数)+メタ評価用K-Fold+最終メタfitの合計で数える
random_stateを変えて複数回実行し、真のholdout AUCの散らばりを確認する
from sklearn.model_selection import train_test_split, KFold from sklearn.base import clone from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score X_bl_train, X_bl_hold, y_bl_train, y_bl_hold = train_test_split( X, y, test_size=0.2, stratify=___, random_state=42 ) meta_bl_hold, meta_test = {}, {} for name, model in models.items(): m = clone(model) m.fit(X_bl_train, ___) meta_bl_hold[name] = m.predict_proba(X_bl_hold)[:, 1] meta_test[name] = m.predict_proba(___)[:, 1] # ← 真のholdoutへの予測もこのモデルで作る X_meta_bl_hold = np.column_stack([meta_bl_hold[n] for n in model_names]) X_meta_test = np.column_stack([meta_test[n] for n in model_names]) # メタモデルをblend_holdout内の5-Foldで評価 kf_small = KFold(n_splits=5, shuffle=True, random_state=7) meta_oof_small = np.zeros(len(X_bl_hold)) for tr_idx, val_idx in kf_small.split(X_meta_bl_hold): m = clone(LogisticRegression()) m.fit(X_meta_bl_hold[tr_idx], y_bl_hold[___]) meta_oof_small[val_idx] = m.predict_proba(X_meta_bl_hold[val_idx])[:, 1] meta_final = LogisticRegression().fit(X_meta_bl_hold, y_bl_hold) blend_holdout_pred = meta_final.predict_proba(___)[:, 1] # ← X_meta_test を渡す
✅ 模範解答
import numpy as np import pandas as pd from sklearn.base import clone from sklearn.model_selection import KFold, train_test_split 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: blend_train/blend_holdoutに分割し、Level-0モデルを1回だけ学習 ====== X_bl_train, X_bl_hold, y_bl_train, y_bl_hold = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) # blend_train size: 1600, blend_holdout size: 400 meta_bl_hold, meta_test = {}, {} for name, model in models.items(): m = clone(model) m.fit(X_bl_train, y_bl_train) meta_bl_hold[name] = m.predict_proba(X_bl_hold)[:, 1] meta_test[name] = m.predict_proba(X_hold)[:, 1] print(f"{name:20s}: blend_holdout AUC = {roc_auc_score(y_bl_hold, meta_bl_hold[name]):.4f}" f" | true holdout AUC = {roc_auc_score(y_hold, meta_test[name]):.4f}") X_meta_bl_hold = np.column_stack([meta_bl_hold[n] for n in model_names]) X_meta_test = np.column_stack([meta_test[n] for n in model_names]) # ====== タスク3: メタモデルをblend_holdout内の5-Foldで評価 + 真のholdout AUC ====== meta = LogisticRegression() kf_small = KFold(n_splits=5, shuffle=True, random_state=7) meta_oof_small = np.zeros(len(X_bl_hold)) for tr_idx, val_idx in kf_small.split(X_meta_bl_hold): m = clone(meta) m.fit(X_meta_bl_hold[tr_idx], y_bl_hold[tr_idx]) meta_oof_small[val_idx] = m.predict_proba(X_meta_bl_hold[val_idx])[:, 1] blend_cv_auc = roc_auc_score(y_bl_hold, meta_oof_small) meta_final = clone(meta).fit(X_meta_bl_hold, y_bl_hold) blend_holdout_pred = meta_final.predict_proba(X_meta_test)[:, 1] blend_holdout_auc = roc_auc_score(y_hold, blend_holdout_pred) print(f"\nBlending: blend_holdout内5-Fold AUC = {blend_cv_auc:.4f} | 真のholdout AUC = {blend_holdout_auc:.4f}") print(f"メタモデル係数 (LR, RF, GB) = {np.round(meta_final.coef_[0], 3)}") # ====== タスク4: fit回数の比較 + 分割seedを変えたときのブレ ====== print("\n--- fit回数比較 ---") print("Blending: Level-0 fit =", len(models), " + メタ評価用5-Fold fit = 5 + 最終メタfit = 1 → 計9回") print("Stacking(Day103): Level-0 fit = 3モデル×5Fold =", 3 * 5, " + メタ評価用5-Fold fit = 5 + 最終メタfit = 1 → 計21回") print("\n--- 分割seedを変えた場合のブレ(Blendingの分散確認) ---") for seed in [1, 2, 3, 4, 5]: X_bl_train2, X_bl_hold2, y_bl_train2, y_bl_hold2 = train_test_split( X, y, test_size=0.2, stratify=y, random_state=seed ) mth, mte = {}, {} for name, model in models.items(): m = clone(model) m.fit(X_bl_train2, y_bl_train2) mth[name] = m.predict_proba(X_bl_hold2)[:, 1] mte[name] = m.predict_proba(X_hold)[:, 1] Xm_hold = np.column_stack([mth[n] for n in model_names]) Xm_test = np.column_stack([mte[n] for n in model_names]) mm = LogisticRegression().fit(Xm_hold, y_bl_hold2) pred = mm.predict_proba(Xm_test)[:, 1] print(f"seed={seed}: blend 真のholdout AUC = {roc_auc_score(y_hold, pred):.4f}")
出力イメージ(実際に実行して確認した値。乱数・環境により多少前後する):
LogisticRegression : blend_holdout AUC = 0.7243 | true holdout AUC = 0.7714 RandomForest : blend_holdout AUC = 0.7166 | true holdout AUC = 0.7670 GradientBoosting : blend_holdout AUC = 0.7066 | true holdout AUC = 0.7481 Blending: blend_holdout内5-Fold AUC = 0.7108 | 真のholdout AUC = 0.7770 メタモデル係数 (LR, RF, GB) = [1.96 1.342 1.062] --- fit回数比較 --- Blending: Level-0 fit = 3 + メタ評価用5-Fold fit = 5 + 最終メタfit = 1 → 計9回 Stacking(Day103): Level-0 fit = 15 + メタ評価用5-Fold fit = 5 + 最終メタfit = 1 → 計21回 --- 分割seedを変えた場合のブレ(Blendingの分散確認) --- seed=1: blend 真のholdout AUC = 0.7896 seed=2: blend 真のholdout AUC = 0.7903 seed=3: blend 真のholdout AUC = 0.7873 seed=4: blend 真のholdout AUC = 0.7855 seed=5: blend 真のholdout AUC = 0.7892
blend_train(1600行)の1回分の抽選だけに依存し、Stackingのように「5通りの訓練データで学習した5モデルの合議」という分散低減効果を得られません。②さらに、メタモデルの学習データがblend_holdout(400行)だけに縮小します。Stackingの2000行の5分の1しかない、これが「データを捨てている」と言われる理由です。🪜 Step-by-Step 解説
1train_test_splitでblend_train/blend_holdoutを作り、Level-0モデルを1回だけ学習する
X_bl_train, X_bl_hold, y_bl_train, y_bl_hold = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42
)
for name, model in models.items():
m = clone(model).fit(X_bl_train, y_bl_train)
meta_bl_hold[name] = m.predict_proba(X_bl_hold)[:, 1]
meta_test[name] = m.predict_proba(X_hold)[:, 1]
stratify=yを指定するのはChurn(陽性が少数派)のクラス比率をblend_train/blend_holdoutで揃え、小さいblend_holdout(400行)で評価が偏らないようにするためです。Level-0モデルはblend_trainだけを見て学習するため、学習に使う分割は1通りしかないという違いが生まれます。2メタモデルはblend_holdoutだけを使い、その中でさらに5-Fold評価する
kf_small = KFold(n_splits=5, shuffle=True, random_state=7) meta_oof_small = np.zeros(len(X_bl_hold)) for tr_idx, val_idx in kf_small.split(X_meta_bl_hold): m = clone(meta).fit(X_meta_bl_hold[tr_idx], y_bl_hold[tr_idx]) meta_oof_small[val_idx] = m.predict_proba(X_meta_bl_hold[val_idx])[:, 1]
blend_holdoutの400行だけです。5-Foldにすると1Foldあたり約80行しかなく、実行結果のblend_holdout内5-Fold AUC = 0.7108は真のholdout AUC(0.7770)より0.066も低く出ています。これはリーク(Day 103)とは逆方向の誤差で、サンプル数が少なすぎることによる評価のブレです。Blendingの弱点は「見かけが甘くなる」ことではなく「見かけの数字自体が不安定になる」ことだと分かります。3fit回数を数えて計算コストを比較する
print("Blending: 3(Level-0) + 5(メタ評価) + 1(最終メタ) = 9回") print("Stacking: 15(Level-0=3モデル×5Fold) + 5(メタ評価) + 1(最終メタ) = 21回")
4random_stateを変えて複数回実行し、Blendingの結果のブレを確認する
for seed in [1, 2, 3, 4, 5]: X_bl_train2, X_bl_hold2, y_bl_train2, y_bl_hold2 = train_test_split(X, y, test_size=0.2, stratify=y, random_state=seed) # ...(Level-0学習・メタ学習を繰り返す) print(f"seed={seed}: blend 真のholdout AUC = {auc:.4f}")
🧮 数学・統計の補足(文系向け)
数式を見たら
n_meta(Stacking) = n_train n_meta(Blending) = n_train × r
rはholdoutの比率(今日は0.2)。Stackingのメタモデル学習データ量n_train(2000行)に対し、Blendingはn_train × r(400行)しか使えない。今日の設定では2000 × 0.2 = 400行、Stackingの2000行の5分の1という「データを捨てている」度合いが、この単純な掛け算で説明できる。
🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 画像認識(CV) / ☐ 時系列(Time Series)
- Netflix Prizeでの起源: Blendingという用語はNetflix Prizeのコンペで広まった。当時は計算資源が限られており、K-Foldを回すStackingよりも1回のtrain/holdout分割で済むBlendingが好まれた
- チーム戦での使い分け: チームでコンペに参加する場合、各メンバーが自分のLevel-0モデルの「holdoutへの予測列」だけを共有すれば、モデルの実装詳細やコードを公開せずにBlendingを組める。これがKaggleのチーム戦でBlendingが好まれる理由の一つ
- 大規模データでの現実的な選択: 数百万行・多数モデルのコンペではK-Fold Stackingの計算コストが非現実的になることがある。今日の例では9回 vs 21回(約2.3倍)だったが、モデル数やFold数が増えるほどこの差は指数的に開くため、計算時間の制約が厳しい場面ではBlendingが唯一現実的な選択肢になることもある
- 精度が最優先なら基本はStacking: 今日の結果でも真のholdout AUCはStacking正しい版(0.7924)>Blending(seed=42, 0.7770)。データ量に余裕があり計算時間が許すなら、まずStackingを検討するのが定石
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| BlendingはStackingの単なる簡易版で、精度も同等だと思う | 「仕組みが似ているから性能も同じ」という直感 | 今日の実行結果ではBlending(0.7770)がStacking正しい版(0.7924)より低かった。メタモデルが使えるデータ量が2000行 vs 400行と5倍違うため、一般にBlendingはStackingよりやや不利になりやすい |
| Blendingのblend_holdout内5-Fold AUCをそのまま信じてしまう | 「CVスコアだから信頼できる」という思い込み | 今日はblend_holdout内5-Fold AUC=0.7108が真のholdout AUC(0.7770)より大きく低かった。400行というサンプルサイズの小ささが評価を不安定にしている(リークとは別種の問題) |
| train_test_splitのrandom_stateを固定していれば結果は再現できるので問題ないと考える | 「再現性がある=信頼できる」という混同 | 再現できることと、その1回の分割がたまたま良い/悪い結果を生んでいないことは別問題。今日はseedを変えるだけでholdout AUCが0.7770〜0.7903まで動いた |
| BlendingではLevel-0モデルの学習にX_hold(真のholdout)を混ぜてよいと勘違いする | 「データは多いほど良い」という発想 | X_holdはDay 103から一貫して「一度も学習に使わない」という前提のデータ。Blendingでもblend_train・blend_holdoutはあくまでX, y(学習データ)の内部分割であり、X_holdを混ぜるとholdoutの意味が失われる |
| モデル数が少なければBlendingとStackingの計算コスト差は気にしなくてよいと考える | 「今回のfit回数差は9対21で大差ない」という近視眼的判断 | モデル数・Fold数が増えるほど差は開く(例: 10モデル×10Foldなら100 vs 10で10倍差)。大規模実験で初めて効いてくるトレードオフである点を見落としやすい |
🚀 次のステップ
- 発展:
test_sizeを0.2から0.4や0.1に変えて、blend_holdoutのサイズが真のholdout AUCの安定性(seedによるブレの大きさ)にどう影響するかを確認してみる。理論上はholdoutを大きくするほどメタモデルの評価は安定するが、その分Level-0モデルの学習データ(blend_train)が減るというトレードオフも発生する - 次回予告: Day 105「外れ値の影響と対策(Huber Loss・量子回帰)」 — ここまでのアンサンブル3部作(Averaging→Stacking→Blending)を終え、次はモデルそのものが外れ値にどう対処するかというテーマに移る
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: