Day 108 — SHAP値の解釈 — グローバル/ローカル解釈・Kaggleでの活用

2026-07-29 青 / Phase 4 コーディング→戦略 SHAP / ローカル解釈 / リーク検出

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

🔵
Day 107ではmean(|SHAP値|)を使い、「全体としてどの特徴量が効いているか」(グローバル解釈)を見ました。しかしSHAPの本当の強みはそこだけではありません。SHAPには加法性(additivity)という性質があり、「1件ごとの予測」を特徴量の足し算に完全に分解できます。

予測値 = base_value + Σ φi

base_value(学習データ全体の平均予測)に、各特徴量の寄与 φi を1つずつ足すと、その1件の予測値にぴったり一致する

⚠️
この分解を1件ずつ見るのがローカル解釈です。今日はこれをKaggleの実務で最も役立つ場面——データリークの検出——に応用します。1つの特徴量が「ほぼ全ての予測」で圧倒的なSHAP寄与を占めているなら、「本当に強い特徴量」なのか「本来知り得ないはずの情報が紛れ込んでいる(リーク)」なのかを疑う必要があります。
用語直感的な意味
加法性(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」という説明が数学的に正当化される。今日のコードでも実際に検算して確認する。

1件ずつの説明

🔍 ローカル解釈

「なぜこの予測になったか」

グローバル重要度だけでは見えない「サンプルごとの寄与パターンの多様性・一貫性」を可視化する。誤分類サンプルの原因分析にも使われる。

リーク検出

🚨 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 そのものを表す

タスク

1
ベースラインモデル: RandomForestClassifier(n_estimators=300, max_depth=6)を学習し、test AUCを算出する
2
SHAP計算: shap.TreeExplainerでSHAP値とexpected_value(base_value)を取得する
3
グローバル解釈: mean(|SHAP値|)で6特徴量をランキングする
4
ローカル解釈: 「自信を持って正解した予測」「最も迷った予測」の2件について、base_value + Σ(SHAP値) = predict_probaを検算しながら寄与を分解する
5
リーク検出: days_since_last_loginのSHAP占有率を全testサンプルで計算し、credit_scoreと比較する
6
是正: 疑わしい特徴量を除外して再学習し、AUCと重要度ランキングの変化を確認、対応方針を戦略的にまとめる

📐 グローバル重要度(mean(|SHAP値|)、リーク列を含む状態)

実際に計算した値をもとに作図(最大値=100%として正規化)。days_since_last_loginが2位の10倍以上という異様な支配を見せている。

6特徴量: mean(|SHAP値|) 正規化比較(リーク列を含む) 100% 0% login日数 0.4294 credit_score 0.0374 tenure 0.0190 late_pay 0.0188 noise1 0.0035 noise2 0.0015 login日数(赤)が正当な信号3種(青)を合計しても及ばないほど突出している

🪜 ローカル解釈: 2件のサンプル分解(base_value → 予測値)

base_value=0.5276(≒学習データの平均解約率)から出発し、各特徴量のSHAP値を足し引きして最終的な予測確率にたどり着く様子。棒は中心から左(マイナス寄与・赤)または右(プラス寄与・緑)に伸びる。

① 自信を持って正解した予測(idx=137, 実際のy=0, 予測確率=0.0038)

days_since_last_login
-0.3898
credit_score
-0.0801
tenure_months
-0.0270
num_late_payments
-0.0155
noise1
-0.0105
noise2
-0.0009

0.5276 − 0.3898 − 0.0801 − 0.0270 − 0.0155 − 0.0105 − 0.0009 = 0.0038 = predict_proba と完全一致

② 最も迷った予測(idx=234, 実際のy=0, 予測確率=0.1077)

days_since_last_login
-0.4920
credit_score
+0.0599
tenure_months
+0.0156
num_late_payments
-0.0117
noise2
+0.0051
noise1
+0.0032

0.5276 − 0.4920 + 0.0599 + 0.0156 − 0.0117 + 0.0051 + 0.0032 = 0.1077 = predict_proba と完全一致

🚨
2件に共通するパターン: 「自信を持って正解」でも「最も迷った予測」でも、days_since_last_loginが他のどの特徴量よりも圧倒的に大きい寄与を持つ点は変わらない。本物の複雑なシグナルなら、サンプルによって主役の特徴量が入れ替わるのが自然——これがリークを疑う具体的な根拠になる。

🔎 リーク検出とその後の是正

SHAP占有率(全600件のtestサンプル)

days_since_last_login: 平均占有率
84.4%
最小でも72.99%
days_since_last_login: 占有率>50%の割合
100%
600/600件すべて
credit_score: 平均占有率
7.2%
占有率>50%は0件

Test AUC: リーク列の有無で比較

リーク列を含む(是正前)
1.0000
完璧すぎる=要注意サイン
リーク列を除外(是正後)
0.8167
これが本来の実力

是正後のグローバル重要度 mean(|SHAP値|)

credit_score
0.1409
最上位
num_late_payments
0.0805
tenure_months
0.0704
noise1
0.0161
ノイズ
noise2
0.0090
ノイズ

除外後の重要度ランキングは、データ生成時に意図した3つの正当な信号(credit_score・num_late_payments・tenure_months)にきれいに一致する。これが「本来のモデルの実力」。

💡 ヒント

ヒント1方向性

グローバル重要度(mean(|SHAP値|))だけを見ると「圧倒的に効いている特徴量がある」ことは分かるが、それが「本物の強いシグナル」か「リーク」かまでは区別できない。両者はグローバルな見た目がそっくりになることがある。この2つを見分ける手がかりは、その特徴量の寄与が「一部のサンプルだけ突出している」のか「ほぼ全サンプルで一貫して支配的」なのかという分布の形にある。1件ずつ分解するローカル解釈だからこそ、この違いが見えてくる。

ヒント2アプローチ
  • 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_valueexplainer.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)でサンプルごとにベクトル計算できる
ヒント3コード骨格
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
🧠
考察(タスク6): AUC=1.0000という完璧すぎるスコア、単一特徴量が2位の10倍以上を占めるグローバル重要度、全testサンプルで占有率50%を超えるローカル解釈——3つの兆候が揃った時点でdays_since_last_loginはリークと判断できる。除外後のAUC=0.8167こそが信頼できる実力であり、Public/Privateのリーダーボードで再現できるスコアはこちら。

🪜 Step-by-Step 解説

1ベースラインモデルを学習し、AUCの異常な高さに気づく

rf.fit(X_train, y_train)
# Test AUC: 1.0000
🚨
なぜこうするか: 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%
📊
なぜこうするか: 「一部だけ突出」ではなく「全600件で例外なく50%を超える占有率」という結果が、Step 3の印象を数値で裏付ける。正当な特徴量である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 が主要因に
🔧
なぜこうするか: 除外するとAUCは1.0000から0.8167まで大きく低下した。これはモデル性能の大部分がその1列に依存していた直接的な証拠。除外後の重要度は、データ生成時に意図した3つの正当な信号にきれいに一致しており、これが本来のモデルの実力。0.8167というスコアの方が、リーダーボードで再現できる信頼できる実力。

6Kaggleコンペでの対応を戦略的に考える

🧭
なぜこうするか: コンペ本番でこのパターンに遭遇したら、「その特徴量が予測時点で本当に手に入るデータか」を規約・データ説明・タイムスタンプに照らして確認する。Public LBだけが異常に高い、単一特徴量の重要度が突出している、テストデータでは欠損している、といった兆候が重なれば、除外して安全側に倒す判断が上位進出への近道になる。

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

🧾
直感的な理解: base_value + Σ(SHAP値) = 予測値という式は、レシートの合計金額の内訳明細のようなもの。「税抜合計(base_value)」に「商品Aの金額」「商品Bの金額」…を1つずつ足していくと、最終的な会計金額(予測値)にぴったり一致する。もしレシートの中で「商品Aの金額だけがどのレシートでも合計の80%以上を占めている」なら、それは「商品Aがよほど高額」か「実は他の商品の金額を商品Aに二重計上してしまっている(会計ミス)」のどちらかを疑うべき、という状況に似ている。
📝
身近な例(リーク検出): テストの点数を予測するモデルを作ったのに、特徴量の中に「本番の試験の得点そのもの(を少し丸めた値)」が紛れ込んでいたとする。モデルは当然その特徴量だけで完璧に予測でき、他の特徴量(勉強時間・出席率など)はほとんど使われない。SHAPのローカル分解は、まさにこの「答えを見ているだけの特徴量」をあぶり出す道具。

数式を見たら

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メダル圏内)を目指す総合演習に取り組む

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

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