📚 背景知識(読んでから問題へ)
🎯
Day 066 — Day 065 のスタッキングに続き、今日は ブレンディング(Blending) とアンサンブル手法の全体像を学びます。「どの手法をいつ使うか」の判断基準まで習得します。
ブレンディングとは?
複数モデルの予測確率を重み付きで足し合わせる手法。スタッキングの OOF 生成をせず、ホールドアウト(holdout)セットを使って重みを最適化する。
ブレンディングの式
スタッキングとの違い
| 観点 | ブレンディング | スタッキング |
|---|---|---|
| 重みの決め方 | 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
モデルを fit
X_hold (16%)
重みを最適化
重みを最適化
X_test (20%)
最終評価のみ
最終評価のみ
処理の流れ
📊 アンサンブルによる AUC 改善の目安
Titanic 風データ(n=6,000)での典型的な AUC
単純平均 vs 最適重みブレンド の AUC 改善量(典型的パターン)
⚖️ 最適重みの可視化(典型的な例)
scipy.optimize で求まる典型的な重み配分(モデルの精度差によって変動する)
LightGBM
0.420
XGBoost
0.380
RandomForest
0.150
LogisticRegression
0.050
💡
読み方: LightGBM の重みが最大 → holdout 上での AUC が最も高かった。LR の重みが低い → GBDT に比べて精度が低いが、完全に0にはならない(多様性に貢献)。
注意: 1モデルに 0.9 以上の重みが集中する場合は、そのモデルの選択だけで十分かもしれない。
注意: 1モデルに 0.9 以上の重みが集中する場合は、そのモデルの選択だけで十分かもしれない。
重みの円グラフ(典型例)
🗂️ データスキーマ(Titanic 風 n=6,000)
| 列名 | 型 | 値の範囲 | 説明 | 前処理 |
|---|---|---|---|---|
Pclass | int | 1, 2, 3 | 旅客クラス | なし |
Sex | int | 0 / 1 | 性別(LabelEncode済) | LabelEncoder |
Age | float | 1〜80 | 年齢 | なし(LR は ScalerをPipelineで) |
SibSp | int | 0〜8 | 同乗兄弟/配偶者数 | なし |
Parch | int | 0〜6 | 同乗親/子供数 | なし |
Fare | float | 0〜∞ | 運賃 | LRのみScaler |
Embarked | int | 0〜2 | 乗船港(LabelEncode済) | LabelEncoder |
Cabin_type | int | 0〜5 | キャビン種別(LabelEncode済) | LabelEncoder |
Survived | int | 0 / 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 AUC | test AUC |
|---|---|---|
| XGBoost(単体) | ? | ? |
| LightGBM(単体) | ? | ? |
| RandomForest(単体) | ? | ? |
| LogisticRegression(単体) | ? | ? |
| 単純平均 | ? | ? |
| 最適重みブレンディング | ? | ? |
問5 — 考察(自分の言葉で答えよ)
- 最適重みで LightGBM が最も高い重みになった場合、それは何を意味するか?
- ブレンディングとスタッキングをどう使い分けるか?(制限時間・データ量・精度の観点で)
💡 ヒント
ヒント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 は「間違えるサンプル」のパターンが違う。だから組み合わせると互いの弱点を補い合える。
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の予測根拠を解釈する
📋 自己評価(解いた後に記入)
✍️
理解度: [ ] 完全理解 [ ] おおむね理解 [ ] 要復習
自分の回答:
気づき・メモ: