Day 066 — ブレンディング(Blending)と アンサンブル実践 — 加重平均・多数決・スタッキングの選択基準

2026-06-16 緑 / Phase 2 コーディング Blending・加重平均・scipy.optimize・アンサンブル手法選択

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

🎯
Day 066 — Day 065 のスタッキングに続き、今日は ブレンディング(Blending) とアンサンブル手法の全体像を学びます。「どの手法をいつ使うか」の判断基準まで習得します。

ブレンディングとは?

複数モデルの予測確率を重み付きで足し合わせる手法。スタッキングの OOF 生成をせず、ホールドアウト(holdout)セットを使って重みを最適化する。

ブレンディングの式

最終予測 = w1 × pred_xgb + w2 × pred_lgb + … (制約: w1 + w2 + … = 1 かつ 各 wi ≥ 0)

スタッキングとの違い

観点ブレンディングスタッキング
重みの決め方 holdout で最適化 or 手動 メタモデルが OOF から学習
必要データ train が holdout 分だけ減る train データを全部使える(OOF)
実装難易度 簡単(OOF ループ不要) 複雑(OOF ループが必要)
計算時間 速い(1回学習) 遅い(N_fold × N_models 回学習)
過学習リスク holdout が小さいと高い OOF なので比較的低い

🔀 4種類のアンサンブル手法

単純平均
speed: ★★★★★
全モデルを均等に扱う
np.mean(preds, axis=0)

向き: 時間がない時・ベースライン
加重平均(手動)
speed: ★★★★☆
CV 精度が高いモデルを重視
w_i = cv_score_i / sum(cv_scores)

向き: 精度差が明確な場合
ブレンディング(最適化)
speed: ★★★☆☆
holdout で scipy.optimize
minimize(-AUC, w)

向き: コンペ最終盤
スタッキング
speed: ★★☆☆☆
OOF 予測 + メタモデル学習
StackingClassifier

向き: データが十分な場合

✂️ ブレンディング用データ分割

全データ n=6,000 の分割方法

X_train (64%)
モデルを fit
X_hold (16%)
重みを最適化
X_test (20%)
最終評価のみ
処理の流れ
Step 1 X_train で4モデルを fit Step 2 X_hold で重みを最適化 Step 3 X_test で最終評価 重要: holdout と test の役割分担 holdout → 重みチューニング(train には使わない) test → 最終評価のみ(チューニングには絶対使わない)

📊 アンサンブルによる AUC 改善の目安

Titanic 風データ(n=6,000)での典型的な AUC

LogisticRegression(単体)
baseline
≈ 0.830
RandomForest(単体)
+0.03
≈ 0.860
XGBoost(単体)
+0.05
≈ 0.880
LightGBM(単体)
+0.06
≈ 0.885
単純平均アンサンブル
+0.07
≈ 0.890
最適重みブレンディング
+0.08
≈ 0.893

単純平均 vs 最適重みブレンド の AUC 改善量(典型的パターン)

+0.001 +0.003 +0.005 単純平均 +0.001 Titanic +0.002 HousePrices +0.003 最適重み +0.005 大規模 +0.008 ケース別の典型的な AUC 改善量

⚖️ 最適重みの可視化(典型的な例)

scipy.optimize で求まる典型的な重み配分(モデルの精度差によって変動する)

LightGBM
0.42
0.420
XGBoost
0.38
0.380
RandomForest
0.15
0.150
LogisticRegression
0.05
0.050
💡
読み方: LightGBM の重みが最大 → holdout 上での AUC が最も高かった。LR の重みが低い → GBDT に比べて精度が低いが、完全に0にはならない(多様性に貢献)。

注意: 1モデルに 0.9 以上の重みが集中する場合は、そのモデルの選択だけで十分かもしれない。

重みの円グラフ(典型例)

LGB 42% XGB 38% RF 15% LR 5%

🗂️ データスキーマ(Titanic 風 n=6,000)

列名値の範囲説明前処理
Pclassint1, 2, 3旅客クラスなし
Sexint0 / 1性別(LabelEncode済)LabelEncoder
Agefloat1〜80年齢なし(LR は ScalerをPipelineで)
SibSpint0〜8同乗兄弟/配偶者数なし
Parchint0〜6同乗親/子供数なし
Farefloat0〜∞運賃LRのみScaler
Embarkedint0〜2乗船港(LabelEncode済)LabelEncoder
Cabin_typeint0〜5キャビン種別(LabelEncode済)LabelEncoder
Survivedint0 / 1生存(目的変数)
⚠️
ブレンディングでは各モデルが 確率(predict_proba[:, 1]) を出力することが重要。predict(0/1)では情報が失われる。

📝 問題

セットアップコード(最初に実行)

import numpy as np
import pandas as pd
import xgboost as xgb
import lightgbm as lgb
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder, StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.metrics import roc_auc_score
from scipy.optimize import minimize
import warnings
warnings.filterwarnings('ignore')

np.random.seed(42)
n = 6000
data = pd.DataFrame({
    'Pclass':     np.random.choice([1, 2, 3], n, p=[0.24, 0.21, 0.55]),
    'Sex':        np.random.choice(['male', 'female'], n, p=[0.65, 0.35]),
    'Age':        np.random.normal(29.7, 14.5, n).clip(1, 80),
    'SibSp':      np.random.choice(range(9), n, p=[0.68,0.23,0.04,0.02,0.01,0.01,0.005,0.005,0.01]),
    'Parch':      np.random.choice(range(7), n, p=[0.76,0.13,0.09,0.005,0.005,0.005,0.005]),
    'Fare':       np.abs(np.random.normal(32, 50, n)),
    'Embarked':   np.random.choice(['S', 'C', 'Q'], n, p=[0.72, 0.19, 0.09]),
    'Cabin_type': np.random.choice(['A','B','C','D','E','None'], n,
                                   p=[0.05,0.08,0.12,0.1,0.07,0.58]),
})
data['Survived'] = (
    (data['Sex'] == 'female') * 0.55 + (data['Pclass'] == 1) * 0.25 +
    (data['Age'] < 16) * 0.2 + np.random.normal(0, 0.12, n)
) > 0.4
data['Survived'] = data['Survived'].astype(int)

for col in ['Sex', 'Embarked', 'Cabin_type']:
    data[col] = LabelEncoder().fit_transform(data[col])

features = ['Pclass', 'Sex', 'Age', 'SibSp', 'Parch', 'Fare', 'Embarked', 'Cabin_type']
X = data[features].values
y = data['Survived'].values

X_temp, X_test, y_temp, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
X_train, X_hold, y_train, y_hold = train_test_split(X_temp, y_temp, test_size=0.2, random_state=42, stratify=y_temp)
print("train:", X_train.shape, "holdout:", X_hold.shape, "test:", X_test.shape)

問1 — 4モデルで学習・holdout/test の確率予測を取得せよ

xgb_model = xgb.XGBClassifier(
    n_estimators=300, max_depth=6, learning_rate=0.05,
    subsample=0.8, colsample_bytree=0.8, random_state=42, eval_metric='logloss'
)
# ここを実装: fit → hold_pred_xgb, test_pred_xgb を取得

lgb_model = lgb.LGBMClassifier(
    n_estimators=300, num_leaves=63, learning_rate=0.05,
    min_child_samples=20, random_state=42, verbose=-1
)
# ここを実装

rf_model = RandomForestClassifier(n_estimators=200, max_depth=8, random_state=42, n_jobs=-1)
# ここを実装

lr_pipe = Pipeline([('sc', StandardScaler()), ('clf', LogisticRegression(C=1.0, random_state=42, max_iter=1000))])
# ここを実装

問2 — 単純平均アンサンブルの AUC を計算せよ

blend_hold_simple = ???  # ここを実装
print(f"Simple Average holdout AUC: {roc_auc_score(y_hold, blend_hold_simple):.4f}")

問3 — 最適重み探索関数を実装せよ

def optimize_weights(preds_list, y_true):
    n_models = len(preds_list)

    def neg_auc(weights):
        # ここを実装
        pass

    init_weights = np.array([1/n_models] * n_models)
    constraints = {'type': 'eq', 'fun': lambda w: np.sum(w) - 1}
    bounds = [(0, 1)] * n_models

    result = minimize(neg_auc, init_weights, method='SLSQP',
                      constraints=constraints, bounds=bounds)
    return result.x

hold_preds = [hold_pred_xgb, hold_pred_lgb, hold_pred_rf, hold_pred_lr]
optimal_weights = optimize_weights(hold_preds, y_hold)
print("最適重み:", [f"{w:.3f}" for w in optimal_weights])

問4 — test データで最終評価・手法比較表を完成させよ

手法holdout AUCtest AUC
XGBoost(単体)??
LightGBM(単体)??
RandomForest(単体)??
LogisticRegression(単体)??
単純平均??
最適重みブレンディング??

問5 — 考察(自分の言葉で答えよ)

  1. 最適重みで LightGBM が最も高い重みになった場合、それは何を意味するか?
  2. ブレンディングとスタッキングをどう使い分けるか?(制限時間・データ量・精度の観点で)

💡 ヒント

ヒント1(方向性)
  • predict_proba(X)[:, 1] で正例確率を取得する
  • 単純平均は np.mean([p1, p2, p3, p4], axis=0) で計算できる
  • minimize の目的関数は -roc_auc_score(...) を返す(最小化 = AUC 最大化)
  • 重みの正規化: weights = np.clip(w, 0, 1); weights /= weights.sum()
ヒント2(アプローチ)
# 問1の実装例(XGBのみ)
xgb_model.fit(X_train, y_train)
hold_pred_xgb = xgb_model.predict_proba(X_hold)[:, 1]
test_pred_xgb = xgb_model.predict_proba(X_test)[:, 1]

# 問3の neg_auc 関数
def neg_auc(weights):
    weights = np.clip(weights, 0, 1)
    weights /= weights.sum()
    blended = sum(w * p for w, p in zip(weights, preds_list))
    return -roc_auc_score(y_true, blended)
ヒント3(コード骨格)
# 問4 test評価
test_preds = [test_pred_xgb, test_pred_lgb, test_pred_rf, test_pred_lr]
blend_test_simple  = np.mean(test_preds, axis=0)
blend_test_optimal = sum(w * p for w, p in zip(optimal_weights, test_preds))

print(f"単純平均 test AUC:      {roc_auc_score(y_test, blend_test_simple):.4f}")
print(f"最適重みブレンド test:  {roc_auc_score(y_test, blend_test_optimal):.4f}")

模範解答

import numpy as np
import pandas as pd
import xgboost as xgb
import lightgbm as lgb
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder, StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.metrics import roc_auc_score
from scipy.optimize import minimize
import warnings
warnings.filterwarnings('ignore')

# ── データ準備 ──
np.random.seed(42)
n = 6000
data = pd.DataFrame({
    'Pclass':     np.random.choice([1, 2, 3], n, p=[0.24, 0.21, 0.55]),
    'Sex':        np.random.choice(['male', 'female'], n, p=[0.65, 0.35]),
    'Age':        np.random.normal(29.7, 14.5, n).clip(1, 80),
    'SibSp':      np.random.choice(range(9), n, p=[0.68,0.23,0.04,0.02,0.01,0.01,0.005,0.005,0.01]),
    'Parch':      np.random.choice(range(7), n, p=[0.76,0.13,0.09,0.005,0.005,0.005,0.005]),
    'Fare':       np.abs(np.random.normal(32, 50, n)),
    'Embarked':   np.random.choice(['S', 'C', 'Q'], n, p=[0.72, 0.19, 0.09]),
    'Cabin_type': np.random.choice(['A','B','C','D','E','None'], n,
                                   p=[0.05,0.08,0.12,0.1,0.07,0.58]),
})
data['Survived'] = (
    (data['Sex'] == 'female') * 0.55 + (data['Pclass'] == 1) * 0.25 +
    (data['Age'] < 16) * 0.2 + np.random.normal(0, 0.12, n)
) > 0.4
data['Survived'] = data['Survived'].astype(int)

for col in ['Sex', 'Embarked', 'Cabin_type']:
    data[col] = LabelEncoder().fit_transform(data[col])

features = ['Pclass', 'Sex', 'Age', 'SibSp', 'Parch', 'Fare', 'Embarked', 'Cabin_type']
X = data[features].values
y = data['Survived'].values

X_temp, X_test, y_temp, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
X_train, X_hold, y_train, y_hold = train_test_split(X_temp, y_temp, test_size=0.2, random_state=42, stratify=y_temp)

# ── モデル定義 ──
models = {
    'XGBoost':     xgb.XGBClassifier(n_estimators=300, max_depth=6, learning_rate=0.05,
                                      subsample=0.8, colsample_bytree=0.8, random_state=42, eval_metric='logloss'),
    'LightGBM':    lgb.LGBMClassifier(n_estimators=300, num_leaves=63, learning_rate=0.05,
                                       min_child_samples=20, random_state=42, verbose=-1),
    'RandomForest':RandomForestClassifier(n_estimators=200, max_depth=8, random_state=42, n_jobs=-1),
    'LR':          Pipeline([('sc', StandardScaler()), ('clf', LogisticRegression(C=1.0, random_state=42, max_iter=1000))]),
}

hold_preds_dict = {}
test_preds_dict  = {}

print("=" * 55)
print("各モデルの学習と予測")
print("=" * 55)
for name, model in models.items():
    model.fit(X_train, y_train)
    hold_preds_dict[name] = model.predict_proba(X_hold)[:, 1]
    test_preds_dict[name]  = model.predict_proba(X_test)[:, 1]
    h = roc_auc_score(y_hold, hold_preds_dict[name])
    t = roc_auc_score(y_test,  test_preds_dict[name])
    print(f"  {name:<15} holdout AUC: {h:.4f}  test AUC: {t:.4f}")

# ── 単純平均 ──
hold_preds_list = list(hold_preds_dict.values())
test_preds_list  = list(test_preds_dict.values())

blend_hold_simple = np.mean(hold_preds_list, axis=0)
blend_test_simple  = np.mean(test_preds_list, axis=0)

# ── 最適重み探索 ──
def optimize_weights(preds_list, y_true):
    n_models = len(preds_list)

    def neg_auc(weights):
        weights = np.clip(weights, 0, 1)
        weights /= weights.sum()
        blended = sum(w * p for w, p in zip(weights, preds_list))
        return -roc_auc_score(y_true, blended)

    init_weights = np.array([1/n_models] * n_models)
    constraints = {'type': 'eq', 'fun': lambda w: np.sum(w) - 1}
    bounds = [(0, 1)] * n_models
    result = minimize(neg_auc, init_weights, method='SLSQP',
                      constraints=constraints, bounds=bounds)
    return result.x

optimal_weights = optimize_weights(hold_preds_list, y_hold)
model_names = list(models.keys())

print("\n最適重み:")
for name, w in zip(model_names, optimal_weights):
    bar = "█" * int(w * 30)
    print(f"  {name:<15}: {w:.4f}  {bar}")

blend_hold_opt = sum(w * p for w, p in zip(optimal_weights, hold_preds_list))
blend_test_opt  = sum(w * p for w, p in zip(optimal_weights, test_preds_list))

# ── 精度比較表 ──
print("\n" + "=" * 60)
print(f"{'手法':<24} {'holdout AUC':<15} {'test AUC'}")
print("-" * 55)
for name in model_names:
    h = roc_auc_score(y_hold, hold_preds_dict[name])
    t = roc_auc_score(y_test,  test_preds_dict[name])
    print(f"  {name:<22} {h:.4f}          {t:.4f}")
print(f"  {'単純平均':<22} {roc_auc_score(y_hold, blend_hold_simple):.4f}          {roc_auc_score(y_test, blend_test_simple):.4f}")
print(f"  {'最適重みブレンド':<22} {roc_auc_score(y_hold, blend_hold_opt):.4f}          {roc_auc_score(y_test, blend_test_opt):.4f}")

🪜 Step-by-Step 解説

1なぜ train/holdout/test の3分割が必要か

# ブレンディング学習の流れ
# 1. X_train → モデルを fit
# 2. X_hold  → holdout 上で重みを最適化(ここが"チューニングデータ")
# 3. X_test  → 最終評価(holdout に触れたことがない)

# 2分割(train/test)しかない場合は? → test で重みを最適化したら leakage

2単純平均の実装

# axis=0 は「モデル方向に平均」を意味する
# shape: (n_models, n_samples) → (n_samples,)
blend = np.mean([p1, p2, p3, p4], axis=0)

print(blend.shape)  # (1200,)
print(blend[:3])    # 例: [0.72, 0.31, 0.88] — 各サンプルの平均予測確率

3scipy.optimize で重みを探索

# minimize は「値を小さくしたい」最適化ツール
# AUC を大きくしたい → -AUC を最小化する(符号反転の trick)

result = minimize(
    neg_auc,              # 目的関数: -AUC を返す
    init_weights,         # 初期値(均等重み)
    method='SLSQP',       # 制約付き最適化アルゴリズム
    constraints=constraints,  # sum(w) = 1
    bounds=bounds         # 0 ≤ w_i ≤ 1
)
optimal_weights = result.x
print(f"最適化成功: {result.success}")  # True であることを確認

4重みを test に適用

# holdout で最適化した重みをそのまま test に適用
blend_test = sum(w * p for w, p in zip(optimal_weights, test_preds_list))

# 注意: test AUC が holdout AUC より著しく低い場合は holdout 過学習の可能性
diff = roc_auc_score(y_hold, blend_hold_opt) - roc_auc_score(y_test, blend_test)
if diff > 0.01:
    print("⚠️ holdout が小さすぎる可能性あり。単純平均を検討する")

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

重み付き平均とは

種類計算例(テスト3科目)結果
通常の平均 (80 + 90 + 70) / 3 80点
重み付き平均(得意科目を重視) 0.5×80 + 0.35×90 + 0.15×70 82点

ブレンディングは「精度が高いモデルの点数をより信頼する」のと同じ。

ブレンドの分散減少効果

💡
直感的な説明: Var(w1×A + w2×B) = w1²×Var(A) + w2²×Var(B) + 2×w1×w2×Cov(A,B)

A と B の相関(Cov)が低いほど、合計の分散が小さくなる。これは株式のポートフォリオ理論と同じ原理(異なる株を持つことでリスクが下がる)。

実務的な意味: XGBoost と LogisticRegression は「間違えるサンプル」のパターンが違う。だから組み合わせると互いの弱点を補い合える。

🏆 Kaggleでの実践的な使い方

場面推奨手法理由
提出締め切り1日前 単純平均 / 加重平均 速い・過学習リスクが低い
データが十分(n>5,000) スタッキング OOF で全データを活用できる
データが少ない(n<1,000) ブレンディング holdout は小さくなるが OOF よりマシな場合も
コンペ最終盤の追い込み 最適化ブレンディング 0.001 の精度差が順位に影響する
NN + GBDT の組み合わせ ブレンディング / スタッキング 最も多様性が高く最大の効果
# Kaggle 最終提出前チェックリスト
# 1. holdout AUC ≈ test AUC であることを確認(差 > 0.01 は危険信号)
# 2. 最適重みが特定モデルに 0.9 以上集中していないか確認
# 3. 単純平均と比較して改善幅が +0.001 未満なら単純平均を選ぶ
# 4. 異なる seed で複数回 optimize して重みが安定しているか確認

# ランク平均(Rank Averaging)— 確率スケールが違うモデルを組み合わせる時に有効
from scipy.stats import rankdata

def rank_average(preds_list):
    """確率をランクに変換してから平均 — スケール差に頑健"""
    ranked = [rankdata(p) / len(p) for p in preds_list]
    return np.mean(ranked, axis=0)

🚫 よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
holdout で重みを決めて holdout でも最終評価する 同じデータを使ってしまう 必ず別の test データで最終評価する
重みの合計が1でなくてもよいと思う AUC は相対的だから 合計=1 の制約がないと重みの解釈が難しい
test_preds を optimize_weights に渡す 手元にあるから leakage! holdout のみを使う
predict(0/1)でブレンディングする 実装が簡単だから 情報損失あり。必ず predict_proba[:, 1] を使う
モデルを増やせば常に改善すると思う 直感的に正しく見える 似たモデルを増やすと holdout 過学習しやすくなる
最適重みが0に近いモデルを削除する 貢献度が低いから不要と思う 多様性への貢献は AUC だけでは測れない。慎重に判断する

🚀 次のステップ

  • 発展: ランク平均(Rank Averaging)— 確率を 0〜1 のランクに変換して平均する。スケールの違いに頑健で、NNとGBDTを組み合わせる時に特に有効。
  • 次回予告(Day 067): 特徴量重要度と SHAP — GBDTの予測根拠を解釈する

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

✍️

理解度: [ ] 完全理解   [ ] おおむね理解   [ ] 要復習

自分の回答:

気づき・メモ: