📚 背景知識(読んでから問題へ)
mean(|SHAP値|)を使い、「全体としてどの特徴量が効いているか」(グローバル解釈)を見ました。しかしSHAPの本当の強みはそこだけではありません。SHAPには加法性(additivity)という性質があり、「1件ごとの予測」を特徴量の足し算に完全に分解できます。予測値 = base_value + Σ φi
base_value(学習データ全体の平均予測)に、各特徴量の寄与 φi を1つずつ足すと、その1件の予測値にぴったり一致する
| 用語 | 直感的な意味 |
|---|---|
| 加法性(additivity) | base_value + Σ(SHAP値) = 予測値が厳密に成り立つ性質。「特徴量ごとの寄与」という解釈を数学的に正当化する |
| base_value(期待値) | 学習データ全体で計算された「特徴量の情報が一切ない場合の予測値」。explainer.expected_valueで取得 |
| ローカル解釈(Local Interpretation) | 1件の予測を特徴量ごとの寄与に分解して説明すること。Waterfall Plot・Force Plotが代表的な可視化 |
| SHAP占有率(本日の実践的指標) | ある1つの特徴量の|φi|が、その予測の Σ|φi| 全体に占める割合。多くのサンプルで異常に高い特徴量はリークを疑う対象になる |
🧩 ローカル解釈を支える3つの視点
➕ 加法性
base_value + Σφ = 予測値
近似ではなく厳密に成立する等式。この性質があるからこそ「特徴量Xの寄与は+0.05」という説明が数学的に正当化される。今日のコードでも実際に検算して確認する。
🔍 ローカル解釈
「なぜこの予測になったか」
グローバル重要度だけでは見えない「サンプルごとの寄与パターンの多様性・一貫性」を可視化する。誤分類サンプルの原因分析にも使われる。
🚨 SHAP占有率
1列が全予測を支配していないか
特定の1特徴量の|φ|が全サンプルで一貫して支配的(例: 占有率>50%が100%のサンプルで成立)なら、リークの可能性を定量的に疑える。
🎯 問題
顧客の解約(チャーン)予測を模した合成データ。6個の特徴量のうち3つは正当な信号、2つは無関係なノイズ、1つ(days_since_last_login)は「解約が確定した後にしか値が動かない情報が誤って混入したリーク特徴量」という設定。
import numpy as np import pandas as pd from sklearn.model_selection import train_test_split RANDOM_STATE = 42 rng = np.random.RandomState(RANDOM_STATE) n = 2000 credit_score = rng.normal(650, 80, n) tenure_months = rng.exponential(24, n).clip(0, 120) num_late_payments = rng.poisson(1.5, n) logit = ( -0.015 * (credit_score - 650) - 0.03 * tenure_months + 0.6 * num_late_payments + rng.normal(0, 1.0, n) ) p_true = 1 / (1 + np.exp(-logit)) y = (rng.uniform(size=n) < p_true).astype(int) noise1 = rng.normal(0, 1, n) noise2 = rng.normal(0, 1, n) # リーク特徴量: 解約が確定した後にしか値が動かない情報を模擬 days_since_last_login = np.where( y == 1, rng.normal(5, 1, n), rng.normal(60, 10, n), ) df = pd.DataFrame({ "credit_score": credit_score, "tenure_months": tenure_months, "num_late_payments": num_late_payments.astype(float), "noise1": noise1, "noise2": noise2, "days_since_last_login": days_since_last_login, }) FEATURES = list(df.columns) X = df[FEATURES].values X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, stratify=y, random_state=RANDOM_STATE )
| カラム | 意味 | 真の関係 |
|---|---|---|
| credit_score / tenure_months / num_late_payments | 解約ロジットに実際に使われた正当な信号 | yと本物の関係を持つ |
| noise1 / noise2 | 標準正規分布からの独立な乱数 | yと完全に無関係 |
| days_since_last_login | 解約後(y=1)は平均5日・継続(y=0)は平均60日という「未来の確定情報」を模したリーク列 | ほぼ y そのものを表す |
タスク
RandomForestClassifier(n_estimators=300, max_depth=6)を学習し、test AUCを算出するshap.TreeExplainerでSHAP値とexpected_value(base_value)を取得するmean(|SHAP値|)で6特徴量をランキングするbase_value + Σ(SHAP値) = predict_probaを検算しながら寄与を分解するdays_since_last_loginのSHAP占有率を全testサンプルで計算し、credit_scoreと比較する📐 グローバル重要度(mean(|SHAP値|)、リーク列を含む状態)
実際に計算した値をもとに作図(最大値=100%として正規化)。days_since_last_loginが2位の10倍以上という異様な支配を見せている。
🪜 ローカル解釈: 2件のサンプル分解(base_value → 予測値)
base_value=0.5276(≒学習データの平均解約率)から出発し、各特徴量のSHAP値を足し引きして最終的な予測確率にたどり着く様子。棒は中心から左(マイナス寄与・赤)または右(プラス寄与・緑)に伸びる。
① 自信を持って正解した予測(idx=137, 実際のy=0, 予測確率=0.0038)
0.5276 − 0.3898 − 0.0801 − 0.0270 − 0.0155 − 0.0105 − 0.0009 = 0.0038 = predict_proba と完全一致
② 最も迷った予測(idx=234, 実際のy=0, 予測確率=0.1077)
0.5276 − 0.4920 + 0.0599 + 0.0156 − 0.0117 + 0.0051 + 0.0032 = 0.1077 = predict_proba と完全一致
days_since_last_loginが他のどの特徴量よりも圧倒的に大きい寄与を持つ点は変わらない。本物の複雑なシグナルなら、サンプルによって主役の特徴量が入れ替わるのが自然——これがリークを疑う具体的な根拠になる。🔎 リーク検出とその後の是正
SHAP占有率(全600件のtestサンプル)
Test AUC: リーク列の有無で比較
是正後のグローバル重要度 mean(|SHAP値|)
除外後の重要度ランキングは、データ生成時に意図した3つの正当な信号(credit_score・num_late_payments・tenure_months)にきれいに一致する。これが「本来のモデルの実力」。
💡 ヒント
グローバル重要度(mean(|SHAP値|))だけを見ると「圧倒的に効いている特徴量がある」ことは分かるが、それが「本物の強いシグナル」か「リーク」かまでは区別できない。両者はグローバルな見た目がそっくりになることがある。この2つを見分ける手がかりは、その特徴量の寄与が「一部のサンプルだけ突出している」のか「ほぼ全サンプルで一貫して支配的」なのかという分布の形にある。1件ずつ分解するローカル解釈だからこそ、この違いが見えてくる。
explainer = shap.TreeExplainer(model)、shap_values = explainer.shap_values(X_test)。バージョンによって返り値は(n_samples, n_features)のリスト(クラスごと)か(n_samples, n_features, n_classes)の3次元配列。isinstanceや.ndimで分岐するbase_valueはexplainer.expected_value。こちらもクラスごとにリスト/配列になっている場合があるため、陽性クラス(インデックス1)分を取り出す- 1件のローカル分解を検算するには
base_value + sv[idx].sum()を計算し、model.predict_proba(X_test)[idx, 1]と一致するか確認する - 「最も迷った予測」は
np.argmin(np.abs(proba - 0.5))、「最も自信を持って正解した予測」は正解サンプルの中でnp.abs(proba - 0.5)が最大のものを探す - SHAP占有率は
np.abs(sv[:, leak_idx]) / np.abs(sv).sum(axis=1)でサンプルごとにベクトル計算できる
from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import roc_auc_score import shap # 1. ベースライン rf = RandomForestClassifier(n_estimators=300, max_depth=6, random_state=42, n_jobs=-1) rf.fit(X_train, y_train) proba_test = rf.predict_proba(X_test)[:, 1] # 2. SHAP計算(バージョン差異に対応) explainer = shap.TreeExplainer(rf) shap_values = explainer.shap_values(X_test) if isinstance(shap_values, list): sv = shap_values[1] base_value = explainer.expected_value[1] else: sv = shap_values[:, :, 1] if shap_values.ndim == 3 else shap_values ev = explainer.expected_value base_value = ev[1] if np.ndim(ev) > 0 else ev # 4. ローカル(2件) correct_mask = (rf.predict(X_test) == y_test) conf_correct_idx = np.argmax(np.where(correct_mask, np.abs(proba_test - 0.5), -1)) uncertain_idx = np.argmin(np.abs(proba_test - 0.5)) # 5. リーク検出 abs_sv = np.abs(sv) total_abs = abs_sv.sum(axis=1) leak_idx = FEATURES.index("days_since_last_login") leak_ratio = abs_sv[:, leak_idx] / total_abs
✅ 模範解答
import numpy as np import pandas as pd from sklearn.model_selection import train_test_split from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import roc_auc_score import shap RANDOM_STATE = 42 rng = np.random.RandomState(RANDOM_STATE) # ====== データ準備 ====== n = 2000 credit_score = rng.normal(650, 80, n) tenure_months = rng.exponential(24, n).clip(0, 120) num_late_payments = rng.poisson(1.5, n) logit = ( -0.015 * (credit_score - 650) - 0.03 * tenure_months + 0.6 * num_late_payments + rng.normal(0, 1.0, n) ) p_true = 1 / (1 + np.exp(-logit)) y = (rng.uniform(size=n) < p_true).astype(int) noise1 = rng.normal(0, 1, n) noise2 = rng.normal(0, 1, n) days_since_last_login = np.where(y == 1, rng.normal(5, 1, n), rng.normal(60, 10, n)) df = pd.DataFrame({ "credit_score": credit_score, "tenure_months": tenure_months, "num_late_payments": num_late_payments.astype(float), "noise1": noise1, "noise2": noise2, "days_since_last_login": days_since_last_login, }) FEATURES = list(df.columns) X = df[FEATURES].values X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, stratify=y, random_state=RANDOM_STATE) # ====== 1. ベースラインモデル ====== rf = RandomForestClassifier(n_estimators=300, max_depth=6, random_state=RANDOM_STATE, n_jobs=-1) rf.fit(X_train, y_train) proba_test = rf.predict_proba(X_test)[:, 1] auc = roc_auc_score(y_test, proba_test) print(f"Test AUC: {auc:.4f}") # ====== 2. SHAP計算 ====== explainer = shap.TreeExplainer(rf) shap_values = explainer.shap_values(X_test) if isinstance(shap_values, list): sv = shap_values[1]; base_value = explainer.expected_value[1] else: sv = shap_values[:, :, 1] if shap_values.ndim == 3 else shap_values ev = explainer.expected_value base_value = ev[1] if np.ndim(ev) > 0 else ev # ====== 3. グローバル解釈 ====== mean_abs_shap = pd.Series(np.abs(sv).mean(axis=0), index=FEATURES).sort_values(ascending=False) # ====== 4. ローカル解釈(2件) ====== correct_mask = (rf.predict(X_test) == y_test) conf_correct_idx = np.argmax(np.where(correct_mask, np.abs(proba_test - 0.5), -1)) uncertain_idx = np.argmin(np.abs(proba_test - 0.5)) for label, idx in [("自信を持って正解", conf_correct_idx), ("最も迷った予測", uncertain_idx)]: contrib = pd.Series(sv[idx], index=FEATURES).sort_values(key=np.abs, ascending=False) print(f"base_value {base_value:.4f} + sum(SHAP) = {base_value + contrib.sum():.4f}") # ====== 5. リーク検出 ====== abs_sv = np.abs(sv) total_abs = abs_sv.sum(axis=1) leak_idx = FEATURES.index("days_since_last_login") leak_ratio = abs_sv[:, leak_idx] / total_abs credit_ratio = abs_sv[:, FEATURES.index("credit_score")] / total_abs # ====== 6. 是正 ====== FEATURES_NO_LEAK = [f for f in FEATURES if f != "days_since_last_login"] X_no_leak = df[FEATURES_NO_LEAK].values Xtr2, Xte2, ytr2, yte2 = train_test_split(X_no_leak, y, test_size=0.3, stratify=y, random_state=RANDOM_STATE) rf2 = RandomForestClassifier(n_estimators=300, max_depth=6, random_state=RANDOM_STATE, n_jobs=-1) rf2.fit(Xtr2, ytr2) auc2 = roc_auc_score(yte2, rf2.predict_proba(Xte2)[:, 1]) print(f"リーク除外後のTest AUC: {auc2:.4f} (除外前: {auc:.4f})")
実際に実行して得た出力(RANDOM_STATE=42固定・環境により多少前後する):
Test AUC: 1.0000 === グローバル: mean(|SHAP値|) === days_since_last_login 0.4294 credit_score 0.0374 tenure_months 0.0190 num_late_payments 0.0188 noise1 0.0035 noise2 0.0015 === ローカル: 自信を持って正解 (idx=137, proba=0.0038) === base_value 0.5276 + sum(SHAP) = 0.0038 (predict_proba=0.0038) === ローカル: 最も迷った予測 (idx=234, proba=0.1077) === base_value 0.5276 + sum(SHAP) = 0.1077 (predict_proba=0.1077) === SHAP占有率の比較 === days_since_last_login: 平均=0.8439 最小=0.7299 占有率>0.5の割合=1.0000 credit_score: 平均=0.0722 占有率>0.5の割合=0.0000 リーク除外後のTest AUC: 0.8167 (除外前: 1.0000) === リーク除外後の mean(|SHAP値|) === credit_score 0.1409 num_late_payments 0.0805 tenure_months 0.0704 noise1 0.0161 noise2 0.0090
days_since_last_loginはリークと判断できる。除外後のAUC=0.8167こそが信頼できる実力であり、Public/Privateのリーダーボードで再現できるスコアはこちら。🪜 Step-by-Step 解説
1ベースラインモデルを学習し、AUCの異常な高さに気づく
rf.fit(X_train, y_train)
# Test AUC: 1.0000
logitにはノイズ項rng.normal(0, 1.0, n)が含まれており、理論上100%の精度は出ないはず。実務でこの数値を見たら、喜ぶ前に「何かがおかしい」と疑う姿勢が重要。2グローバル解釈で怪しい特徴量に当たりをつける
days_since_last_login 0.4294 ← 2位の10倍以上 credit_score 0.0374
days_since_last_loginのmean(|SHAP値|)が他を圧倒している。ここまで極端な支配は「非常に強いシグナル」と「リーク」のどちらでも起こり得るため、グローバル解釈だけでは断定できない。3ローカル解釈で「一貫性」を確認する
自信を持って正解 (idx=137): days_since_last_login -0.3898(全SHAPの中で圧倒的に最大) 最も迷った予測 (idx=234): days_since_last_login -0.4920(同様に圧倒的に最大)
days_since_last_loginが支配的だった。本物の複雑なシグナルなら、サンプルによって主役の特徴量が入れ替わる多様性が出るのが自然。特定の1列が常にトップというのは、その列が「他の特徴量では代替できない、ほぼ答えそのものの情報」を持っていることを示唆する。base_value + Σ(SHAP) = predict_probaが寸分違わず一致したことも、この分解が数学的に正しいことの裏付け。4占有率を全サンプルで定量化する
days_since_last_login: 平均占有率=0.8439, 最小=0.7299, 占有率>0.5の割合=100% credit_score: 平均占有率=0.0722, 占有率>0.5の割合=0%
credit_scoreは最大でも50%を超えることが一度もない。「1つの特徴量がほぼ全ての予測でSHAP占有率50%超を占め続ける」は、リークを疑う具体的で再現可能な基準として使える。5特徴量を除外して「本来のモデル」を確認する
リーク除外後のTest AUC: 0.8167(除外前: 1.0000) credit_score 0.1409, num_late_payments 0.0805, tenure_months 0.0704 が主要因に
6Kaggleコンペでの対応を戦略的に考える
🧮 数学・統計の補足(文系向け)
base_value + Σ(SHAP値) = 予測値という式は、レシートの合計金額の内訳明細のようなもの。「税抜合計(base_value)」に「商品Aの金額」「商品Bの金額」…を1つずつ足していくと、最終的な会計金額(予測値)にぴったり一致する。もしレシートの中で「商品Aの金額だけがどのレシートでも合計の80%以上を占めている」なら、それは「商品Aがよほど高額」か「実は他の商品の金額を商品Aに二重計上してしまっている(会計ミス)」のどちらかを疑うべき、という状況に似ている。数式を見たら
f(x) = φ₀ + Σᵢ φᵢ(x)
「モデルの予測f(x)は、ベースラインφ₀(=base_value)に、各特徴量の寄与φᵢを足し合わせたものに等しい」という意味。この等式は近似ではなく厳密に成立する(浮動小数点誤差を除く)のがSHAPの数学的な強みであり、Permutation Importance等の他の手法にはない性質。
🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 画像認識(CV) / ☑ 時系列(Time Series)
- リーク検出の一次スクリーニング: 新しい特徴量を追加した直後にCVスコアが不自然に跳ね上がったら、SHAPのグローバル重要度でその特徴量が突出していないかをまず確認する。「ほぼ全サンプルで占有率50%超」なら要注意
- 誤分類サンプルのローカル解釈: モデルが外した予測(False Positive/Negative)を数件選び、SHAP waterfallで「なぜそう予測したか」を確認する。上位解法は「モデルが苦手なサブグループ」をこの方法で特定し、専用の特徴量や後処理を追加している
- Solution Write-upでの説明: コンペ終了後のDiscussion投稿では、SHAP summary plot(グローバル、ビースウォーム図)とForce Plot(ローカル、代表サンプル数件)を並べて「モデルが何を学習したか」を説明するのが定番の構成
- Dependence Plotで交互作用を見る: 1つの特徴量のSHAP値を横軸=特徴量の値、縦軸=SHAP値でプロットし、色を別の特徴量で塗ると、2つの特徴量の交互作用が可視化される。GBDTが暗黙に学習した非線形な関係を発見する手段として上位陣がよく使う
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| グローバル重要度が高い特徴量は、それだけで「良い特徴量」と判断してよい | mean(|SHAP値|)という1つの数値にまとめてしまうため、分布の形が見えなくなる | 今日のように、グローバルでは「強いシグナル」も「リーク」も同じように高い値になる。ローカル解釈で個々のサンプルのパターンを見て初めて区別できる |
base_valueはモデルの切片(intercept)のようなものだと思う | 線形回帰の切片と概念が似ているため混同しやすい | base_valueは「学習データ全体に対するモデルの平均的な出力」であり、モデルのパラメータではなくデータに依存する値。今回は0.5276で、y_trainの平均解約率0.5293とほぼ一致している |
| AUC=1.0000は「良いモデルが作れた」という意味だと思う | 高いスコア=良い結果、という直感が働くため | 完璧すぎるスコアはリークの典型的な兆候。今日のケースでは除外後のAUC=0.8167こそが実力であり、1.0000という数字自体が調査すべき異常値だった |
| SHAPのローカル分解は1件ずつ手作業で見るしかなく、大規模データでは非現実的だと思う | Force Plotなど個別サンプルの可視化のイメージが強いため | 今日行ったように「占有率」等の指標をベクトル演算で全サンプルに一括計算すれば、数千〜数万件でも統計的にリーク兆候を検出できる。個別確認は疑わしい特徴量が絞られた後の最終確認で十分 |
| リークを見つけたら常に特徴量を削除するのが正解だと思う | 「リーク=悪」という単純化された理解にとどまるため | 特徴量が予測時点で正当に入手可能かどうかの確認が先。時系列データで「未来の集計」が混入していないか、結合キーがテスト時のデータ生成過程と一致しているかを検証した上で、正当と確認できれば残してよい場合もある |
🚀 次のステップ
- 発展:
days_since_last_loginのSHAP Dependence Plot(横軸=特徴量の値、縦軸=SHAP値)を作図し、値がある閾値(約30日あたり)を境に寄与がほぼ二値的に切り替わる「不自然に鋭い境界」があることを確認してみる。本物の連続的なシグナルなら、通常はもっとなだらかな関係になる - 次回予告: Day 109「Phase 4 総復習」 — XGBoost/LightGBM/CatBoost・Optuna・アンサンブル・特徴量選択・SHAPまで、Phase 4で学んだ手法を1つのTabularコンペ相当のパイプラインに統合し、上位10%(Silverメダル圏内)を目指す総合演習に取り組む
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: