📚 背景知識(読んでから問題へ)
🎯
Day 067 — GBDTモデルの「ブラックボックス」を開く2つのツール: feature_importances_(素早い重要度確認)と SHAP値(理論的に保証された公正な解釈)を習得します。
なぜ解釈性が必要か
LightGBM / XGBoost は高精度だが「なぜその予測をしたか」が分からない。
特徴量重要度と SHAP を使うことで次の3つが可能になる:
- 特徴量エンジニアリングの評価: 新しく作った特徴量が有効かを定量確認
- データリークの検出: 本来重要でないはずの特徴量が上位に来たら危険信号
- コンペ考察の投稿: 「なぜこの特徴量が重要か」を説明してノートブックを充実させる
📊 特徴量重要度の3種類
gain(利得)
推奨 ★★★
各特徴量が分岐に使われた時の平均不純度改善量
分岐回数に左右されない。最も信頼性が高い指標。
分岐回数に左右されない。最も信頼性が高い指標。
importance_type='gain'
split(分岐回数)
注意 ★★☆
各特徴量が分岐に使われた回数
連続値(Fare等)は分岐しやすく偏りが出る。
連続値(Fare等)は分岐しやすく偏りが出る。
importance_type='split'
cover(カバレッジ)
参考 ★☆☆
各特徴量の分岐でカバーしたサンプル数
あまり使われない。gain で代替できることが多い。
あまり使われない。gain で代替できることが多い。
importance_type='cover'
| 観点 | gain | split |
|---|---|---|
| 高カーディナリティ(Fare等)に対する偏り | 低い(平均化されるため) | 高い(分岐しやすいから) |
| 特徴量の「実質的な貢献度」を反映するか | Yes | No(回数なので) |
| XGBoost デフォルト | — | weight(=split相当) |
| LightGBM デフォルト | — | split |
⚠️
LightGBM と XGBoost はデフォルトが split になっていることが多い。必ず
importance_type='gain' を明示的に指定する習慣をつけよう。🔮 SHAPの概念
予測値の分解式
例: 基準値 0.45 + Sex(+0.35) + Pclass(+0.18) + Age(-0.08) + … = 生存確率 0.90
feature_importances_ vs SHAP
| 観点 | feature_importances_ | SHAP |
|---|---|---|
| 計算速度 | 超高速(モデルに組み込み済み) | TreeExplainer は比較的速い |
| 解釈レベル | グローバルのみ | グローバル + ローカル(1サンプル) |
| 理論的保証 | 偏りの可能性あり | Shapley値: 公正性・一貫性が数学的に保証 |
| 相互作用の検出 | できない | SHAP Interaction Values で可能 |
| Kaggleでの主用途 | 素早い特徴量選択のベースライン | 精密な特徴量選択・リーク検出・考察投稿 |
📈 特徴量重要度の典型的な分布(Titanic 風データ)
gain ベースの重要度(上位10特徴量)
💡
観察点: SibSp + Parch を合成した FamilySize は両者の単独重要度より高い(特徴量エンジニアリングの効果)。
IsAlone は FamilySize と情報が重複するため重要度が低い。
gain vs split の差異(典型例)
同じモデルでも計算方法で順位が変わる(Fare の扱いに注目)
🌊 Waterfall プロットの読み方(1サンプル例)
例: テストデータの1人目「女性・1等・31歳・運賃高め」→ 生存確率 0.92
基準値 0.45 からの積み上げ
💡
読み方: 基準値(0.45)から始まり、各特徴量の SHAP 値が積み上がって最終予測(0.92)になる。
Sex=female の +0.35 が最大の押し上げ要因。Age=31(成人)は -0.08 でわずかに押し下げ(未成年より生存率が低いため)。
🗂️ データスキーマ(Titanic 風 n=5,000)
| 列名 | 型 | 値の範囲 | 説明 | SHAP 重要度(典型) |
|---|---|---|---|---|
Pclass | int | 1, 2, 3 | 旅客クラス | 高 |
Sex | int | 0 / 1 | 性別(LabelEncode済) | 最高 |
Age | float | 1〜80 | 年齢 | 高 |
SibSp | int | 0〜8 | 同乗兄弟/配偶者数 | 低 |
Parch | int | 0〜6 | 同乗親/子供数 | 低 |
Fare | float | 0〜∞ | 運賃 | 中 |
Embarked | int | 0〜2 | 乗船港(LabelEncode済) | 低 |
Cabin_type | int | 0〜5 | キャビン種別(LabelEncode済) | 低 |
FamilySize | int | 1〜15 | 派生特徴量: SibSp+Parch+1 | 中 |
IsAlone | int | 0 / 1 | 派生特徴量: FamilySize==1 | 低(重複) |
Survived | int | 0 / 1 | 生存(目的変数) | — |
💡
FamilySize と IsAlone は同じ情報から作られているため情報が重複する。SHAP で重要度を確認し、どちらか一方を削除するか両方残すかを判断する。
📝 問題
セットアップコード(最初に実行)
import numpy as np
import pandas as pd
import lightgbm as lgb
import shap
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
from sklearn.metrics import roc_auc_score
import warnings
warnings.filterwarnings('ignore')
np.random.seed(42)
n = 5000
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]),
'FamilySize': None,
'IsAlone': None,
})
data['FamilySize'] = data['SibSp'] + data['Parch'] + 1
data['IsAlone'] = (data['FamilySize'] == 1).astype(int)
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', 'FamilySize', 'IsAlone']
X = data[features]
y = data['Survived']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2,
random_state=42, stratify=y)
print("train:", X_train.shape, "test:", X_test.shape)
問1 — LightGBM を学習し、gain と split の特徴量重要度を DataFrame にまとめよ
model_gain = lgb.LGBMClassifier(
n_estimators=500, num_leaves=63, learning_rate=0.05,
min_child_samples=20, random_state=42, verbose=-1, importance_type='gain'
)
# ここを実装: fit → gain_imp, split_imp を取得し imp_df を作る
# imp_df の列: feature, gain, split
# gain で降順ソート
問2 — gain ベースの横棒グラフを描け(上位10特徴量、降順)
# ここを実装
# plt.barh で横棒グラフ
# タイトル: 'Top 10 Feature Importances (LightGBM, gain)'
問3 — SHAP 値を計算し、beeswarm プロットを描け
# ここを実装
# shap.TreeExplainer(model_gain) → explainer
# explainer(X_test) → shap_values
# shap.plots.beeswarm(shap_values, max_display=10)
問4 — テストデータ最初のサンプルの予測を SHAP waterfall で説明せよ
idx = 0
print(f"予測確率: {model_gain.predict_proba(X_test.iloc[[idx]])[:, 1][0]:.4f}")
print(f"実際の値: {y_test.iloc[idx]}")
# ここを実装: shap.plots.waterfall(shap_values[idx])
問5 — 考察
- gain と split で重要度の順位が大きく違う特徴量はあったか? あった場合、なぜそうなるか説明せよ
- SHAP 値から「予測に最も影響する特徴量」は何か? タイタニックのドメイン知識と一致するか?
💡 ヒント
ヒント1(方向性)
importance_type='gain'をLGBMClassifierのコンストラクタに渡す- split の重要度は別インスタンスで取得するか、
model.set_params(importance_type='split').fit(...)で再フィット shap.TreeExplainer(model)→explainer(X_test)で SHAP オブジェクトを取得- beeswarm は1行:
shap.plots.beeswarm(shap_values, max_display=10)
ヒント2(アプローチ)
# gain と split を別々のモデルで取得
model_gain = lgb.LGBMClassifier(..., importance_type='gain')
model_gain.fit(X_train, y_train)
gain_imp = model_gain.feature_importances_
model_split = lgb.LGBMClassifier(..., importance_type='split')
model_split.fit(X_train, y_train)
split_imp = model_split.feature_importances_
imp_df = pd.DataFrame({
'feature': features,
'gain': gain_imp,
'split': split_imp
}).sort_values('gain', ascending=False).reset_index(drop=True)
ヒント3(コード骨格)
# 問2: 棒グラフ
top10 = imp_df.head(10).sort_values('gain') # ascending=True で下から上に
fig, ax = plt.subplots(figsize=(8, 5))
ax.barh(top10['feature'], top10['gain'], color='#22c55e')
ax.set_xlabel('Feature Importance (gain)')
ax.set_title('Top 10 Feature Importances (LightGBM, gain)')
plt.tight_layout(); plt.show()
# 問3: SHAP
explainer = shap.TreeExplainer(model_gain)
shap_values = explainer(X_test)
shap.plots.beeswarm(shap_values, max_display=10)
# 問4: waterfall
shap.plots.waterfall(shap_values[0])
✅ 模範解答
import numpy as np
import pandas as pd
import lightgbm as lgb
import shap
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
from sklearn.metrics import roc_auc_score
import warnings
warnings.filterwarnings('ignore')
# ── データ準備(セットアップと同じ) ──
np.random.seed(42)
n = 5000
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]),
'FamilySize': None,
'IsAlone': None,
})
data['FamilySize'] = data['SibSp'] + data['Parch'] + 1
data['IsAlone'] = (data['FamilySize'] == 1).astype(int)
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', 'FamilySize', 'IsAlone']
X = data[features]
y = data['Survived']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2,
random_state=42, stratify=y)
# ── 問1: 特徴量重要度 ──
model_gain = lgb.LGBMClassifier(
n_estimators=500, num_leaves=63, learning_rate=0.05,
min_child_samples=20, random_state=42, verbose=-1, importance_type='gain'
)
model_gain.fit(X_train, y_train)
gain_imp = model_gain.feature_importances_
model_split = lgb.LGBMClassifier(
n_estimators=500, num_leaves=63, learning_rate=0.05,
min_child_samples=20, random_state=42, verbose=-1, importance_type='split'
)
model_split.fit(X_train, y_train)
split_imp = model_split.feature_importances_
imp_df = pd.DataFrame({
'feature': features,
'gain': gain_imp,
'split': split_imp,
}).sort_values('gain', ascending=False).reset_index(drop=True)
print("=" * 60)
print(f"{'Feature':<14} {'Gain':>10} {'Split':>8}")
print("-" * 60)
for _, row in imp_df.iterrows():
print(f" {row['feature']:<12} {row['gain']:>10.2f} {row['split']:>8.0f}")
auc = roc_auc_score(y_test, model_gain.predict_proba(X_test)[:, 1])
print(f"\nTest AUC: {auc:.4f}")
# ── 問2: 棒グラフ ──
top10 = imp_df.head(10).sort_values('gain')
fig, ax = plt.subplots(figsize=(8, 5))
ax.barh(top10['feature'], top10['gain'], color='#22c55e', alpha=0.85)
ax.set_xlabel('Feature Importance (gain)', fontsize=12)
ax.set_title('Top 10 Feature Importances (LightGBM, gain)', fontsize=13)
ax.grid(axis='x', alpha=0.3)
plt.tight_layout()
plt.savefig('feature_importance.png', dpi=120)
plt.show()
# ── 問3: SHAP beeswarm ──
explainer = shap.TreeExplainer(model_gain)
shap_values = explainer(X_test)
shap.plots.beeswarm(shap_values, max_display=10)
# ── 問4: waterfall(1サンプル) ──
idx = 0
prob = model_gain.predict_proba(X_test.iloc[[idx]])[:, 1][0]
actual = y_test.iloc[idx]
print(f"\nサンプル #{idx}")
print(f" 予測確率: {prob:.4f}({'生存予測' if prob > 0.5 else '死亡予測'})")
print(f" 実際の値: {'生存(1)' if actual == 1 else '死亡(0)'}")
shap.plots.waterfall(shap_values[idx])
# ── ボーナス: SHAP ベースの特徴量選択 ──
mean_abs_shap = np.abs(shap_values.values).mean(axis=0)
shap_imp_df = pd.DataFrame({
'feature': features,
'mean_abs_shap': mean_abs_shap
}).sort_values('mean_abs_shap', ascending=False)
print("\nSHAP 重要度(mean |SHAP|)")
for _, row in shap_imp_df.iterrows():
bar = "█" * int(row['mean_abs_shap'] * 100)
print(f" {row['feature']:<14}: {row['mean_abs_shap']:.4f} {bar}")
# 低重要度特徴量(IsAlone, SibSp, Parch など)を削除して再学習
low_imp_features = shap_imp_df[shap_imp_df['mean_abs_shap'] < 0.01]['feature'].tolist()
if low_imp_features:
top_features = [f for f in features if f not in low_imp_features]
model_reduced = lgb.LGBMClassifier(n_estimators=500, random_state=42, verbose=-1)
model_reduced.fit(X_train[top_features], y_train)
auc_reduced = roc_auc_score(y_test, model_reduced.predict_proba(X_test[top_features])[:, 1])
print(f"\n全特徴量 AUC: {auc:.4f}")
print(f"低重要度除外 AUC: {auc_reduced:.4f}")
print(f"除外した特徴量: {low_imp_features}")
🪜 Step-by-Step 解説
1なぜ gain を使うべきか(split の罠)
# Fare は連続値でカーディナリティが高い
# → 決定木が「1000円で分割、1001円で分割、…」と細かく分岐しがち
# → split(分岐回数)が多くなり、重要度が水増しされる
# gain の場合:
# → 各分岐の「実際の不純度改善量」を計算して平均
# → 分岐回数に左右されない信頼性の高い指標
# 結果の違い例(Titanic風データ)
# gain 順位: Sex > Pclass > Age > Fare > FamilySize
# split 順位: Sex > Pclass > Age > Fare > FamilySize ←大体同じだが
# Fareの split だけ Ageより高くなることがある
2SHAP の TreeExplainer とは
# TreeExplainer は決定木系に特化した高速なSHAP実装
# 全サンプルのSHAP値を O(TLD²) で計算(T=ツリー数, L=葉, D=深さ)
explainer = shap.TreeExplainer(model_gain)
shap_values = explainer(X_test) # Explanation オブジェクト
# .values: SHAP 値の行列 (n_samples, n_features)
# .base_values: 基準値(全体の平均予測値)
# .data: 特徴量の実際の値
print(f"SHAP values shape: {shap_values.values.shape}") # (1000, 10)
print(f"base_value(基準値): {shap_values.base_values[0]:.4f}") # ≈ 平均生存率
print(f"SHAP合計 ≈ 予測値: {shap_values.values[0].sum() + shap_values.base_values[0]:.4f}")
3beeswarm プロットの読み方
# beeswarm プロットの各要素
# - Y軸: 特徴量(|SHAP|の平均で降順)
# - X軸: SHAP値(正 = 生存確率を押し上げ、負 = 押し下げ)
# - 色: 特徴量の値(赤 = 高い値、青 = 低い値)
# - 点の密度: 多くのサンプルが同じ SHAP値を持つ = 点が重なる
# 読み方の例
# Sex の赤い点(female=1)が右(+方向)に集中している
# → 女性であることが生存確率を大きく押し上げる
# Pclass の青い点(1等=1)が右に、赤い点(3等=3)が左にある
# → 1等 → 生存UP、3等 → 生存DOWN
4SHAP を使った特徴量選択の手順
# Step A: 全特徴量で学習
# Step B: mean |SHAP| で重要度をランキング
mean_abs_shap = np.abs(shap_values.values).mean(axis=0)
# Step C: 重要度が低い特徴量を候補としてリスト
# Step D: 候補を除外して再学習 → AUC を比較
# Step E: AUC が維持されていれば除外を決定
# Kaggle の実践: IsAlone は FamilySize と情報重複
# → IsAlone を削除しても AUC がほぼ変わらないことが多い
# → シンプルなモデルの方が汎化しやすい
📐 数学・統計の補足(文系向け)
Shapley値とは(ゲーム理論から)
💡
例え話: 4人でプロジェクトを完成させた。報酬をどう分けるか?
「A が参加した場合」と「参加しなかった場合」の収益差を、全ての参加の順番の組み合わせで平均する。
機械学習では: 特徴量 = メンバー、予測値 = 報酬。「この特徴量を使った場合と使わない場合の予測差」を全ての特徴量の組み合わせで平均したものが SHAP 値。
「A が参加した場合」と「参加しなかった場合」の収益差を、全ての参加の順番の組み合わせで平均する。
機械学習では: 特徴量 = メンバー、予測値 = 報酬。「この特徴量を使った場合と使わない場合の予測差」を全ての特徴量の組み合わせで平均したものが SHAP 値。
不純度(gini)とは
| 状態 | クラス比率 | Gini 不純度 |
|---|---|---|
| 完全に混ざっている | 生存50% / 死亡50% | 0.50(最大) |
| 一方に偏っている | 生存80% / 死亡20% | 0.32 |
| 完全に分かれている | 生存100% / 死亡0% | 0.00(最小) |
gain = 分岐後の Gini 不純度の改善量。大きいほど「効果的な分岐」を作れる特徴量。
🏆 Kaggleでの実践的な使い方
| 活用場面 | 方法 | 具体例 |
|---|---|---|
| 特徴量選択 | mean_abs_shap が低い特徴量を削除 → 再学習でAUC比較 | 100個 → 50個に削減してノイズ除去 |
| 特徴量エンジニアリングの検証 | 新特徴量の SHAP 重要度を確認 | FamilySize が SibSp+Parch より有効かどうか判断 |
| データリーク検出 | 本来重要でない特徴量の重要度が異常に高い | IDや日付が上位に来たら要注意 |
| コンペ考察の投稿 | SHAP プロットをノートブックに掲載 | 「この特徴量が重要な理由」を説明してスコアを高める |
| アンサンブルの多様性確認 | 2モデルの SHAP 重要度の相関を確認 | 重要度が異なれば多様性が高く、ブレンドが効果的 |
# Kaggle コンペでよく使うパターン
# 特徴量選択のループ
n_top = 7
top_features = shap_imp_df.head(n_top)['feature'].tolist()
model_top = lgb.LGBMClassifier(n_estimators=500, random_state=42, verbose=-1)
model_top.fit(X_train[top_features], y_train)
auc_top = roc_auc_score(y_test, model_top.predict_proba(X_test[top_features])[:, 1])
print(f"Top {n_top} features AUC: {auc_top:.4f} (全特徴量: {auc:.4f})")
# ランク平均 SHAP(複数モデルのSHAP重要度を平均してより安定した特徴量選択)
# shap1 = shap.TreeExplainer(lgb_model)(X_test).values
# shap2 = shap.TreeExplainer(xgb_model)(X_test).values
# mean_shap = (np.abs(shap1).mean(0) + np.abs(shap2).mean(0)) / 2
🚫 よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| split の重要度で特徴量を選択する | LightGBM のデフォルトが split | importance_type='gain' を明示する。または SHAP を使う |
| 重要度が低い特徴量を即削除する | 低い = 不要と思い込む | 削除 → 再学習 → AUC比較を必ず行う |
| 訓練データで SHAP を計算する | 手元にあるから | テストデータ(またはOOF)で計算する |
| ローカル SHAP の1サンプルだけで全体を判断する | Waterfall が分かりやすく印象的 | まず beeswarm(グローバル)で全体傾向を確認する |
| SHAP 値が大きい特徴量を無条件で強化する | 大きい = 重要 = 改善できると思う | 正方向・負方向の関係を確認し、非線形パターンを探す |
| FamilySize と IsAlone を両方使い続ける | 多い方が良いと思う | 情報が重複するので SHAP で確認後、どちらか削除を検討 |
🚀 次のステップ
- 発展: SHAP Interaction Values — 2つの特徴量の「掛け算効果」を測る。
shap.plots.scatter(shap_values[:, 'Age'], color=shap_values[:, 'Sex'])で視覚化。 - 次回予告(Day 068): Phase 2 総復習 — House Prices コンペ End-to-End パイプライン(提出まで)
📋 自己評価(解いた後に記入)
✍️
理解度: [ ] 完全理解 [ ] おおむね理解 [ ] 要復習
自分の回答:
気づき・メモ: