Day 102 — アンサンブル①(Averaging)— Simple Average・Rank Average・重み最適化

2026-07-23 青 / Phase 4 コーディング Simple Average / Rank Average / 重み最適化

📚 背景知識(読んでから問題へ)

🔵
Day 090〜101は「1つのモデルをどう強くするか」を追求してきました。今日からの3日間(Day 102〜104)は視点を変え、「複数のモデルの予測をどう組み合わせるか」というアンサンブルの技術に入ります。今日はその中で最もシンプルな Averaging(平均を取る) です。
💡
3人の天気予報士が、それぞれ違う情報源(雲・気圧・統計)を見て予報するケースを想像してください。間違え方の相関が低いほど、平均を取ったときに外れが打ち消し合います。逆に3人が同じ情報源だけを見ていたら、平均しても何も改善しません。アンサンブルには「多様性(Diversity)」が命です。
用語直感的な意味
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予測)の合成データを使います。年齢×契約商品数契約期間×契約商品数にあえて非線形な相互作用を仕込み、線形モデルと木ベースのモデルが互いに異なる間違え方をするように設計しています。

使用データ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

タスク

1
【理論】複数モデルの平均がなぜ単独モデルより安定・改善するのかを「多様性」「エラーの相関」の観点で説明する。Simple AverageとRank Averageの違い、Rank Averageが有効な場面を説明する
2
【実装】LogisticRegression(要標準化)・RandomForestClassifierGradientBoostingClassifierの5-Fold OOF予測確率を作成し、モデルごとのOOF AUCを比較する
3
【実装】3モデルのOOF予測をSimple Averageで統合し、AUCを計算する
4
【実装】3モデルのOOF予測をRank Average(0〜1の順位に変換して平均)で統合し、Simple Averageと比較する
5
【実装/分析/戦略】重みw1,w2,w3Σw=1, w≥0)をグリッドサーチしOOF AUCを最大化する重みを見つける。3手法を比較し、同じOOFで選んで同じOOFで評価することの問題をDay 101と関連づけて考察する

🔀 Averagingの全体フロー

モデルの中身には一切触れず、3モデルそれぞれの「出力(予測確率)」だけを混ぜ合わせる。混ぜ方が3種類(Simple / Rank / 重み最適化)ある。

① 3モデルそれぞれで5-Fold OOF予測を作る(Day 051・065と同じ方式) LogisticRegression RandomForest GradientBoosting ② 3つのOOF予測確率ベクトル(それぞれ違う間違え方をする) ↓ ↓ ↓ ③ 3つの混ぜ方で統合する Simple Average 算術平均(そのまま平均) Rank Average 順位(0〜1)に変換してから平均 重み最適化 w1,w2,w3をグリッドサーチ ④ OOF AUCで3手法を比較する

📊 AUC比較(実行結果)

実際にコードを実行して得た値(乱数シード固定・環境により多少前後する)。バーの軸は差を見やすくするため0.70始まり(切り捨て軸)。多様性のあるモデルを混ぜると、単独最強モデルより改善する。

LogisticRegression(単独)
0.7305
非線形交互作用を拾えない
GradientBoosting(単独)
0.7433
交互作用を捉える
RandomForest(単独・最強)
0.7480
交互作用を捉える
Simple Average
0.7539
単独最強モデルより改善
Rank Average
0.7540
Simple Averageと同等〜微増
重み最適化(グリッド)
0.7547
最良、ただし選択バイアスに注意

モデル間の予測相関(実行結果): LogisticRegression–GradientBoosting = 0.735(最も低い=多様性が高い)、RandomForest–GradientBoosting = 0.877(最も高い=似た間違え方をしがち)。相関が低い組み合わせほどAveragingの改善幅が大きい。

💡 ヒント

ヒント1方向性

Day 090〜101までは「1つのモデルの中身」を改善してきたが、今日はモデルの中身には一切触れない。3つの別々のモデルをそれぞれDay 051・065と同じOOF方式で学習させ、その出力(予測確率)だけを混ぜ合わせる。「混ぜて効果があるのは、モデルの間違え方が違うときだけ」という前提を忘れずに、まずは3モデルそれぞれのOOF AUCを比較して、本当に違う間違え方をしていそうか確認してから進めるとよい。

ヒント2アプローチ
  • 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に正規化してから平均する
  • 重み探索: w1w2を0〜1の範囲でグリッド(例: 0.05刻み)に動かし、w3 = 1 - w1 - w2w3 < 0ならスキップ)として、roc_auc_scoreが最大になる組み合わせを探す
ヒント3コード骨格
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/k)σ² + (1-1/k)ρσ²という形になる。誤差の相関ρが0(完全無相関)なら分散はσ²/kまで小さくなるが、ρ=1(同じ間違え方)ならいくら平均しても分散は減らない。今日の例ではLogisticRegressionが非線形な交互作用を拾えず、RandomForestGradientBoostingは木構造で自然に拾えるため、3モデルの間違え方の性質が異なり、平均に意味が生まれる。AUCは予測値の大きさではなく順位だけで決まる指標のため、Rank Averageはモデルごとのキャリブレーション(自信の付け方)の違いに引きずられずに済む。特に自分のモデル+公開サブミッションのように出所の異なる予測を混ぜたいときに有効。
🧠
戦略問題(タスク5後半): 重み探索はDay 101のNestedCVと同じ構造の問題を抱える。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)
    + ...
)
🔑
なぜこうするか: アンサンブルの効果を体感するには、モデルどうしが本当に違う間違え方をする状況が必要。線形の関係しかないデータだと線形モデルもGBDTも似た予測をしてしまう。「年齢×契約商品数」のような掛け算的な(AND条件の)相互作用を仕込むことで、線形モデルが原理的に拾えず木ベースのモデルが得意なパターンを意図的に作っている。

2clone()で毎Fold「まっさら」なモデルを使う

m = clone(model)
m.fit(X[tr_idx], y[tr_idx])
🧪
なぜこうするか: 1つのモデルインスタンスをそのまま5回fitすると、前のFoldの学習結果が残ってしまう可能性がある。clone()は同じハイパーパラメータを持つ未学習の新しいインスタンスを作る関数で、毎Foldの最初に呼ぶことでFoldごとに完全に独立した学習を保証する。

3Simple Averageは「まず最初に試す」ベースライン

simple_avg = np.mean(list(oof_preds.values()), axis=0)
🧪
なぜこうするか: 実装が1行で済み重みの調整も不要なため、アンサンブルを試すときに最初に確認すべき基準値になる。多様性のあるモデル3つを均等に混ぜるだけでも、個別の最強モデル単体を上回ることが多い。

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次元のシンプルなグリッドサーチに落とし込んでいる。

🧮 数学・統計の補足(文系向け)

📐
アンサンブルの分散低減を直感で: 3人の天気予報士が、それぞれ違う情報源(雲・気圧・統計)を見て予報するケースを思い出してください。3人の外れ方の相関が低いほど、平均を取ったときに外れが打ち消し合い安定した予報になります。3人が同じ情報源だけを見ていたら、まとめて同じ日に外れるので平均しても意味がありません。「モデルを増やす」より「違う種類のモデルを増やす」ことがアンサンブルでは重要です。
📏
Rank Averageを直感で: 100点満点のテストと20点満点のテストの点数を、そのまま平均するのは不公平です。20点満点で満点を取っても「20」にしかならず、100点満点の平均点に埋もれてしまいます。正しく比較するなら「クラスの中で何番目か(順位)」に変換してから比べるべきです。Rank Averageはまさにこれを行い、モデルごとの採点基準(キャリブレーション)の違いを「順位」という共通のものさしに変換することで無効化しています。

数式を見たら

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.minimizemethod='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・リーク防止。今日は「予測を単純に混ぜる」ところまでだったが、次回は「予測を新しい特徴量として、別のモデル(メタモデル)に学習させる」スタッキングに進む

📝 自己評価(解いた後に記入)

自分の回答・気づき・メモ: