📚 背景知識(読んでから問題へ)
| 用語 | 直感的な意味 |
|---|---|
| Simple Average | 各モデルの予測確率をそのまま算術平均する、最初に試すべきベースライン |
| Rank Average | 各モデルの予測を「順位(0〜1のパーセンタイル)」に変換してから平均する。AUCのような順位ベースの指標で真価を発揮 |
| 重み最適化(Weighted Average) | 性能の良いモデルに大きな重みを与える。Σw=1, w≥0の制約下でOOF評価指標を最大化する重みを探索 |
| 多様性(Diversity) | モデル間の予測誤差の相関(ρ)が低いこと。ρが低いほどAveragingの分散低減効果が大きい |
🧩 Averagingの3つの原則
🌦️ 誤差の相関が低いモデルを混ぜる
似たモデルを増やしても意味がない
線形モデルと木ベースのモデルのように、得意・不得意が違うモデルを組み合わせるほど、平均を取ったときの分散低減効果が大きくなる。
📏 Rank Averageでスケールを無効化
キャリブレーションの違いに強い
AUCは順位だけで決まる指標。予測を順位(パーセンタイル)に変換してから平均すれば、モデルごとの出力スケールの違いに引きずられない。
⚖️ 重み探索は「小さなNestedCV問題」
Day 101の教訓がここでも生きる
同じOOFで重みを選び、同じOOFで評価するとやや楽観的になる。探索空間が小さければ影響は軽微だが、原則は忘れないこと。
🎯 問題
銀行の顧客離反予測(Churn予測)の合成データを使います。年齢×契約商品数、契約期間×契約商品数にあえて非線形な相互作用を仕込み、線形モデルと木ベースのモデルが互いに異なる間違え方をするように設計しています。
import numpy as np import pandas as pd np.random.seed(42) n = 2000 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) # 1〜4種類の契約商品数 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) # 高齢×契約1種類 → 離反しやすい(非線形) + 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, })
| カラム名 | 意味 | 型 |
|---|---|---|
| Age | 顧客の年齢 | float |
| Income | 年収(円) | float |
| TenureMonths | 契約継続月数 | float |
| NumProducts | 契約中の商品数(1〜4) | int |
| IsActive | アクティブ顧客フラグ(1=活発) | int |
| Churn | 離反フラグ(1=離反、目的変数) | int |
タスク
LogisticRegression(要標準化)・RandomForestClassifier・GradientBoostingClassifierの5-Fold OOF予測確率を作成し、モデルごとのOOF AUCを比較するw1,w2,w3(Σw=1, w≥0)をグリッドサーチしOOF AUCを最大化する重みを見つける。3手法を比較し、同じOOFで選んで同じOOFで評価することの問題をDay 101と関連づけて考察する🔀 Averagingの全体フロー
モデルの中身には一切触れず、3モデルそれぞれの「出力(予測確率)」だけを混ぜ合わせる。混ぜ方が3種類(Simple / Rank / 重み最適化)ある。
📊 AUC比較(実行結果)
実際にコードを実行して得た値(乱数シード固定・環境により多少前後する)。バーの軸は差を見やすくするため0.70始まり(切り捨て軸)。多様性のあるモデルを混ぜると、単独最強モデルより改善する。
モデル間の予測相関(実行結果): LogisticRegression–GradientBoosting = 0.735(最も低い=多様性が高い)、RandomForest–GradientBoosting = 0.877(最も高い=似た間違え方をしがち)。相関が低い組み合わせほどAveragingの改善幅が大きい。
💡 ヒント
Day 090〜101までは「1つのモデルの中身」を改善してきたが、今日はモデルの中身には一切触れない。3つの別々のモデルをそれぞれDay 051・065と同じOOF方式で学習させ、その出力(予測確率)だけを混ぜ合わせる。「混ぜて効果があるのは、モデルの間違え方が違うときだけ」という前提を忘れずに、まずは3モデルそれぞれのOOF AUCを比較して、本当に違う間違え方をしていそうか確認してから進めるとよい。
- OOF予測の作り方はDay 051・065と同じ:
KFold(n_splits=5)でループし、sklearn.base.clone(model)で毎Fold新しい未学習のモデルを作ってからfitする LogisticRegressionは特徴量のスケールが大きく違うと収束しづらいため、make_pipeline(StandardScaler(), LogisticRegression())で標準化を挟む- Simple Average:
np.mean([oof_1, oof_2, oof_3], axis=0) - Rank Average:
scipy.stats.rankdata(pred)で各予測を1〜nの順位に変換し、(rank - 1) / (n - 1)で0〜1に正規化してから平均する - 重み探索:
w1とw2を0〜1の範囲でグリッド(例: 0.05刻み)に動かし、w3 = 1 - w1 - w2(w3 < 0ならスキップ)として、roc_auc_scoreが最大になる組み合わせを探す
from sklearn.base import clone from sklearn.model_selection import KFold from sklearn.metrics import roc_auc_score from scipy.stats import rankdata def get_oof_proba(model, X, y, n_splits=5, random_state=42): kf = KFold(n_splits=n_splits, shuffle=True, random_state=random_state) oof = np.zeros(len(X)) for tr_idx, val_idx in kf.split(X): m = clone(model) m.fit(X[tr_idx], y[___]) oof[val_idx] = m.predict_proba(X[val_idx])[:, ___] return oof # タスク2: 3モデルのOOF予測 oof_preds = {} for name, model in models.items(): oof_preds[name] = get_oof_proba(___, X, y) print(name, roc_auc_score(y, oof_preds[name])) # タスク3: Simple Average simple_avg = np.mean(list(oof_preds.values()), axis=___) # タスク4: Rank Average def to_rank01(pred): return (rankdata(pred) - 1) / (len(pred) - ___) rank_avg = np.mean([to_rank01(p) for p in oof_preds.values()], axis=0) # タスク5: 重み最適化(グリッドサーチ) best_auc, best_weights = -1, None for w1 in np.arange(0, 1.01, 0.05): for w2 in np.arange(0, 1.01 - w1, 0.05): w3 = 1 - w1 - w2 blend = w1 * oof_preds['LogisticRegression'] + w2 * oof_preds['RandomForest'] + w3 * oof_preds['___'] auc = roc_auc_score(y, blend) if auc > best_auc: best_auc, best_weights = auc, (w1, w2, w3)
✅ 模範解答
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 from scipy.stats import rankdata # ====== データ準備(Churn予測、非線形な相互作用を仕込んだ合成データ) ====== np.random.seed(42) n = 2000 # age / income / tenure_months / num_products / is_active は上記と同一(省略) 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}) FEATURES = ['Age', 'Income', 'TenureMonths', 'NumProducts', 'IsActive'] X = df[FEATURES].values y = df['Churn'].values # ====== タスク2: 3モデルのOOF予測確率を作成 ====== def get_oof_proba(model, X, y, n_splits=5, random_state=42): """5-Fold CVで、まだ見ていないFoldの予測確率だけを集めてOOF配列を作る""" kf = KFold(n_splits=n_splits, shuffle=True, random_state=random_state) oof = np.zeros(len(X)) for tr_idx, val_idx in kf.split(X): m = clone(model) # 前Foldの学習状態を持ち越さないよう、毎回まっさらなモデルを作る m.fit(X[tr_idx], y[tr_idx]) oof[val_idx] = m.predict_proba(X[val_idx])[:, 1] return oof 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), } oof_preds = {} print("--- タスク2: モデルごとのOOF AUC ---") for name, model in models.items(): oof_preds[name] = get_oof_proba(model, X, y) auc = roc_auc_score(y, oof_preds[name]) print(f" {name:20s}: AUC = {auc:.4f}") corr = pd.DataFrame(oof_preds).corr() # 多様性の目安(低いほど良い) # ====== タスク3: Simple Average ====== simple_avg = np.mean(list(oof_preds.values()), axis=0) simple_avg_auc = roc_auc_score(y, simple_avg) # ====== タスク4: Rank Average ====== def to_rank01(pred): """予測値を0〜1の順位(パーセンタイル)に変換する""" return (rankdata(pred) - 1) / (len(pred) - 1) rank_preds = {name: to_rank01(p) for name, p in oof_preds.items()} rank_avg = np.mean(list(rank_preds.values()), axis=0) rank_avg_auc = roc_auc_score(y, rank_avg) print(f"\nSimple Average AUC : {simple_avg_auc:.4f}") print(f"Rank Average AUC : {rank_avg_auc:.4f}") # ====== タスク5: 重み最適化(グリッドサーチ、w1+w2+w3=1, w>=0) ====== model_names = list(oof_preds.keys()) best_auc, best_weights = -1.0, None step = 0.05 for w1 in np.arange(0, 1.0001, step): for w2 in np.arange(0, 1.0001 - w1, step): w3 = 1 - w1 - w2 if w3 < -1e-9: continue w3 = max(w3, 0.0) blend = (w1 * oof_preds[model_names[0]] + w2 * oof_preds[model_names[1]] + w3 * oof_preds[model_names[2]]) auc = roc_auc_score(y, blend) if auc > best_auc: best_auc, best_weights = auc, (round(w1,2), round(w2,2), round(w3,2)) print(f"\n最適重み {dict(zip(model_names, best_weights))}") print(f"重み最適化後のAUC: {best_auc:.4f}")
出力イメージ(実際に実行して確認した値。乱数・環境により多少前後する):
--- タスク2: モデルごとのOOF AUC ---
LogisticRegression : AUC = 0.7305 ← 非線形な相互作用を拾えず、やや弱い
RandomForest : AUC = 0.7480
GradientBoosting : AUC = 0.7433
Simple Average AUC : 0.7539 ← 個別最強モデル(RandomForest)より改善
Rank Average AUC : 0.7540 ← Simple Averageとほぼ同等〜わずかに良い
最適重み {'LogisticRegression': 0.25, 'RandomForest': 0.55, 'GradientBoosting': 0.2}
重み最適化後のAUC: 0.7547 ← 3手法の中で最良(ただし選択バイアスに注意)
(1/k)σ² + (1-1/k)ρσ²という形になる。誤差の相関ρが0(完全無相関)なら分散はσ²/kまで小さくなるが、ρ=1(同じ間違え方)ならいくら平均しても分散は減らない。今日の例ではLogisticRegressionが非線形な交互作用を拾えず、RandomForest・GradientBoostingは木構造で自然に拾えるため、3モデルの間違え方の性質が異なり、平均に意味が生まれる。AUCは予測値の大きさではなく順位だけで決まる指標のため、Rank Averageはモデルごとのキャリブレーション(自信の付け方)の違いに引きずられずに済む。特に自分のモデル+公開サブミッションのように出所の異なる予測を混ぜたいときに有効。best_weightsの選択にもbest_aucの報告にも同じOOF予測を使っているため、選択バイアスが乗る。厳密には外側CVでテスト用の区画を確保し、内側で重みを選んでから外側で評価すべき。ただし今回の探索空間は実質2自由度しかなく、Day 100〜101の「7個のk候補×Optuna数十回試行」に比べれば選択バイアスの影響は相対的に小さい。それでも「同じデータで選んで同じデータで評価している」という構造は変わらないため、最終報告の場面では重み探索に使っていないhold-outデータでも一度確認するのが安全。🪜 Step-by-Step 解説
1あえて「多様性」が生まれるデータを設計する
logit = (
...
+ 2.2 * ((age > 55) & (num_products == 1)).astype(float)
+ 2.0 * ((tenure_months < 6) & (num_products >= 3)).astype(float)
+ ...
)
2clone()で毎Fold「まっさら」なモデルを使う
m = clone(model) m.fit(X[tr_idx], y[tr_idx])
fitすると、前のFoldの学習結果が残ってしまう可能性がある。clone()は同じハイパーパラメータを持つ未学習の新しいインスタンスを作る関数で、毎Foldの最初に呼ぶことでFoldごとに完全に独立した学習を保証する。3Simple Averageは「まず最初に試す」ベースライン
simple_avg = np.mean(list(oof_preds.values()), axis=0)
4Rank Averageで「スケールの違い」を無効化する
def to_rank01(pred): return (rankdata(pred) - 1) / (len(pred) - 1)
rankdataは各予測値を「小さい方から数えて何番目か」という順位に変換する。(rank-1)/(n-1)で0〜1に正規化することで、元の予測値がどんなスケール・分布であっても「相対的な位置」だけを表す共通の物差しに揃えられる。AUCは順位だけで決まる指標なので情報のロスがなく、スケールの違いによる歪みを防げる。5重みは「非負・合計1」のグリッドで探す
for w1 in np.arange(0, 1.0001, step): for w2 in np.arange(0, 1.0001 - w1, step): w3 = 1 - w1 - w2 ...
w≥0かつΣw=1という制約により、結果を「各モデルをどれくらいの割合で信頼したか」という解釈可能な重み付き平均に保てる。制約を外すとOOF上のAUCは一見良くなることがあるが、それはOOFのノイズに過剰適合しただけの可能性が高く、未知データでの性能はむしろ悪化しがち。w1,w2だけをグリッドで動かしw3を自動的に決めることで、3次元の探索を2次元のシンプルなグリッドサーチに落とし込んでいる。🧮 数学・統計の補足(文系向け)
数式を見たら
Var(f̄) = (1/k)σ² + (1 − 1/k)ρσ²
「k個のモデルの予測誤差の分散がすべてσ²で等しく、モデル間の誤差の相関がρのとき、平均予測f̄の誤差分散はこの式になる」という意味。ρ=0(完全無相関)なら第2項が消え分散はσ²/kまで小さくなる(モデルを増やすほど改善)。ρ=1(完全に同じ間違え方)なら分散はσ²のままで、モデルをいくら増やしても一切減らない。
🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 画像認識(CV) / ☐ 時系列(Time Series)
- AUC系コンペでのRank Average: IEEE-CIS Fraud Detection・Home Credit Default Riskなど、AUCで評価される不正検知・与信系コンペの上位解法では「複数モデルのOOF/テスト予測をRank Averageでブレンドした」という記述が非常に頻繁に登場する
- 「公開Notebookのサブミッションをブレンドする」戦略: モデルの中身が分からない公開カーネルのサブミッションCSVでも、予測値だけがあればRank Average/Simple Averageで自分のモデルと混ぜられる。Kaggle特有の実務的なテクニック
- 相関行列によるモデル選定: 今日のコードの
corr()のように、アンサンブルに加える前に各モデルのOOF予測どうしの相関を確認し、相関が高すぎるモデルは候補から外す・重みを下げるのが上位陣の定石 - 重み最適化の発展形: モデル数が増えると
scipy.optimize.minimize(method='SLSQP'、等式制約Σw=1を指定)や、Optunaで重みを探索する手法もよく使われる
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| モデルを増やせば増やすほど良くなると思い込む | 「多いほど強い」という直感的なイメージ | 誤差相関の高いモデルを追加してもAveragingの恩恵はほとんどない。むしろ重み探索の選択バイアスが増える |
| Simple Averageは常に安全だと思い込む | シンプルな手法だから無難に見える | モデルごとの出力スケール・キャリブレーションが大きく異なると、1つのモデルが平均を実質的に支配してしまうことがある |
| Rank Averageなら常にSimple Averageより良いと思う | 「高度な手法だから優れている」という思い込み | Rank Averageは順位だけで決まる評価指標(AUCなど)で効果を発揮する。RMSEのような回帰指標にはそのままでは使えない |
| 重み最適化の最良重み・最良スコアをそのまま最終報告値として使う | ハイパーパラメータ探索と性能評価を同じデータで行う問題に気づいていない | 同じOOFで選んで同じOOFで評価しているためDay 101と同種の選択バイアスが乗る。探索空間が小さいため影響は小さいことが多いが、検証を分けるのが厳密 |
OOF方式を使わず学習データ全体へのpredict_probaを平均する | 「学習済みモデルの予測を使えば十分」と考える | 学習に使った行への予測はリークを含みAUCが過大評価される。アンサンブルの評価には必ずOOF方式の予測を使う |
🚀 次のステップ
- 発展:
w≥0の非負制約を外し負の重みを許した場合にOOF AUCがどう変化するか、また未知データ(別シードのhold-out)で評価するとどうなるかを比較してみる - 次回予告: Day 103「アンサンブル②(Stacking)」 — Level-1/Level-2・リーク防止。今日は「予測を単純に混ぜる」ところまでだったが、次回は「予測を新しい特徴量として、別のモデル(メタモデル)に学習させる」スタッキングに進む
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: