【LightGBM】shapライブラリ無しでSHAP値を出す(pred_contribの使い方)

はじめに

「このモデルは、なぜこの1件をこう予測したのか」を知りたくなることはよくあります。 その定番の道具がSHAP値なのですが、「SHAP=shapライブラリを別途インストールして使うもの」だと思っている人が多いです。

実はLightGBMだけでSHAP値は計算できますpredictpred_contrib=Trueを渡すだけです。 今回はこのpred_contribの使い方と、予測を特徴量ごとの寄与に分解して読み解く方法を見ていきます。

pred_contrib とは

pred_contribは、1件ごとの予測を「どの特徴量が予測をどれだけ押し上げた/押し下げたか」に分解してくれる機能です。中身はSHAP値(TreeSHAP)です。

特徴量重要度(feature_importance)と混同しやすいですが、見ているものが違います。

  • feature_importance … モデル全体でどの特徴量が効いているかという大局的な指標
  • pred_contrib1件ごとに、その予測の内訳を示す局所的な指標

「全体では広さが重要」まではfeature_importanceでわかりますが、「この物件の家賃予測が高いのは広さが+3.4、駅近が+1.4効いたから」という個別の説明はpred_contribの領分です。

使ってみる

題材として、物件情報から家賃(万円)を予測するモデルを作ります。 特徴量は「広さarea」「築年数age」「駅からの距離dist」「階数floor」の4つです。

import numpy as np
import lightgbm as lgb

rng = np.random.RandomState(42)
n = 3000

area  = rng.uniform(15, 80, n)     # 広さ(m2)
age   = rng.uniform(0, 40, n)      # 築年数
dist  = rng.uniform(1, 30, n)      # 駅からの距離(分)
floor = rng.randint(1, 15, n)      # 階数

# 広い/新しい/駅近/高層 ほど家賃が高い
y = 3 + 0.12*area - 0.06*age - 0.15*dist + 0.1*floor + rng.randn(n)*0.5

X = np.column_stack([area, age, dist, floor])
names = ["area", "age", "dist", "floor"]

dtr = lgb.Dataset(X, y, feature_name=names)
model = lgb.train(dict(objective="regression", num_leaves=31, learning_rate=0.05,
                       min_child_samples=20, verbose=-1),
                  dtr, num_boost_round=300)

SHAP値を出すのは1行です。predictpred_contrib=Trueを渡します。

contrib = model.predict(X, pred_contrib=True)
print(contrib.shape)   # => (3000, 5)

出力の形は(サンプル数, 特徴量数 + 1)です。 特徴量が4つなのに列が5つあるのは、**最後の列が「ベース値」**だからです。ベース値はデータ全体の平均的な予測(SHAPの期待値)で、各特徴量の寄与はここからの差分として表されます。

「寄与の合計 = 予測値」を確認する

SHAP値には「各特徴量の寄与とベース値を全部足すと、ちょうどその予測値になる」という定義的な性質(加法性)があります。pred_contribが返すのはまさにこのSHAP値なので、本当にそうなっているか確かめてみましょう。

pred = model.predict(X)
row_sum = contrib.sum(axis=1)

print("最大絶対差 |行合計 - predict|:", np.max(np.abs(row_sum - pred)).round(6))
print("ベース値:", round(contrib[0, -1], 4), "/ y の平均:", round(y.mean(), 4))
最大絶対差 |行合計 - predict|: 0.0
ベース値: 5.9818 / y の平均: 5.9818

きれいに一致しました。ベース値5.98yの平均そのもので、

予測値 = ベース値 + area の寄与 + age の寄与 + dist の寄与 + floor の寄与

という足し算で、予測を完全に再構成できているのが確認できます。

1件の予測を分解する

では実際に1件、寄与を分解してみます。「広くて駅近だが、築古で低層」な物件を見てみます。

i = 1
print("特徴量値:", dict(zip(names, np.round(X[i], 1))))
for nm, c in zip(names, contrib[i, :-1]):
    print(f"  {nm:6s} 寄与 {c:+.3f}")
print(f"  base   {contrib[i, -1]:+.3f}")
print(f"  合計 = {contrib[i].sum():.3f}  (predict={pred[i]:.3f})")
特徴量値: {'area': 76.8, 'age': 31.9, 'dist': 6.0, 'floor': 2.0}
  area   寄与 +3.439
  age    寄与 -0.682
  dist   寄与 +1.440
  floor  寄与 -0.501
  base   +5.982
  合計 = 9.677  (predict=9.677)

滝グラフ(ウォーターフォール)にすると、ベース値からどう積み上がって予測値に至るかが一目でわかります。

import matplotlib.pyplot as plt

base = contrib[i, -1]
contribs = contrib[i, :-1]
running = base

fig, ax = plt.subplots(figsize=(9, 4.8))
ax.bar(0, base, color="#888888")
for k, c in enumerate(contribs, start=1):
    color = "#E45756" if c >= 0 else "#4C78A8"   # 赤=押し上げ, 青=押し下げ
    ax.bar(k, c, bottom=running, color=color)
    running += c
ax.bar(len(names) + 1, pred[i], color="#333333")
ax.set_xticks(range(len(names) + 2))
ax.set_xticklabels(["base"] + names + ["prediction"])
ax.set_ylabel("rent (predicted)")
ax.axhline(base, color="#bbbbbb", ls="--", lw=0.8)
plt.tight_layout()
plt.show()

lightgbm-pred-contrib

読み方はこうです。

  • ベース値(平均的な家賃)5.98からスタート
  • 広さareaが+3.44、駅近distが+1.44 と大きく押し上げ(赤)
  • 築古ageが-0.68、低層floorが-0.50 で押し下げ(青)
  • 結果、この物件の予測は9.68

「広くて駅近だから高い。ただ古くて低層な分は差し引かれている」と、予測の理由をそのまま日本語で説明できるわけです。

SHAP重要度(棒グラフ)

shapライブラリでよく見る「特徴量ごとの重要度バー」もpred_contribから作れます。 この棒グラフは、**各サンプルのSHAP値の絶対値を特徴量ごとに平均したもの(mean |SHAP|)**です。全サンプルぶんの寄与があるので、平均を取るだけです。

mean_abs = np.abs(contrib[:, :-1]).mean(axis=0)   # ベース値の列は除く

for nm, v in sorted(zip(names, mean_abs), key=lambda t: -t[1]):
    print(f"{nm:6s} {v:.3f}")
area   1.969
dist   1.056
age    0.572
floor  0.316

棒グラフにするとこうなります。

import matplotlib.pyplot as plt

order = np.argsort(mean_abs)
plt.barh([names[i] for i in order], [mean_abs[i] for i in order])
plt.xlabel("mean |SHAP value|")
plt.show()

lightgbm-pred-contrib

area(広さ)が最も効いていて、次いでdist(駅からの距離)、と順当な結果です。 この「SHAP重要度」は、feature_importancesplit(分岐回数)やgain(損失改善量)とはまた別の考え方の重要度です。**値の単位が予測値と同じ(今回なら家賃の万円)**なので、「平均してだいたい何万円ぶん動かす特徴量か」と直感的に読めるのが利点です。

beeswarm plot

SHAPでよく使われるbeeswarm(summary plot)もpred_contribから作れます。 横軸がSHAP値、点の色が特徴量の値(赤=大きい・青=小さい)で、特徴量が大きいときに予測を上げるのか下げるのかまで読み取れる図です。

ポイントは点の散らし方です。点の縦の間隔を一定にして積み上げると、点が密集しているSHAP値の帯ほど縦に膨らみ、あの“ひょうたん形”になります。

import matplotlib.pyplot as plt

contribs = model.predict(X, pred_contrib=True)[:, :-1]  # ベース値の列は除く
order = np.argsort(np.abs(contribs).mean(0))            # 重要な特徴量ほど上に

SP, CAP = 0.017, 0.45   # 点の縦間隔(一定)と、行同士がぶつからないための上限

fig, ax = plt.subplots(figsize=(8, 4.6))
for row, fi in enumerate(order):
    sv, fv = contribs[:, fi], X[:, fi]
    color = (fv - fv.min()) / (fv.max() - fv.min() + 1e-9)  # 特徴量値を0-1に
    # SHAP値をビンに分け、同じビンの点を一定間隔で積む → 密度が高いほど縦に膨らむ
    jit = np.zeros(len(sv))
    idx = np.digitize(sv, np.linspace(sv.min(), sv.max(), 120))
    for b in np.unique(idx):
        m = np.where(idx == b)[0]
        off = (np.arange(len(m)) - (len(m)-1)/2) * SP
        jit[m] = np.clip(off, -CAP, CAP)
    ax.scatter(sv, row + jit, c=color, cmap="coolwarm", s=5, alpha=0.7, linewidths=0)

ax.axvline(0, color="#999999", lw=0.8)
ax.set_yticks(range(len(order))); ax.set_yticklabels([names[i] for i in order])
ax.set_xlabel("SHAP value (impact on prediction)")
sm = plt.cm.ScalarMappable(cmap="coolwarm"); sm.set_array([])
cb = fig.colorbar(sm, ax=ax, ticks=[0, 1]); cb.set_ticklabels(["low", "high"])
cb.set_label("feature value")
plt.tight_layout(); plt.show()

lightgbm-pred-contrib

読み方の例:

  • area(広さ)… 赤(広い)ほど右(予測を上げる)、青(狭い)ほど左。素直に「広いほど家賃が上がる」
  • dist(駅からの距離)… 色の向きが逆で、青(近い)ほど右。「駅が近いほど上がる」
  • age(築年数)… 赤(古い)ほど左で、「古いほど下げる」

このように、重要度の大小だけでなく効き方の向きまで読めるのがbeeswarmの強みです。

shapライブラリとの関係

LightGBMのpred_contribが計算しているのは、shapライブラリが木モデルに対して使うTreeSHAPと同じものです。実際、shap.TreeExplainerにLightGBMモデルを渡すと、内部ではモデル自身のこの計算が使われるため、値は一致します。

つまり、

  • SHAP値そのものや、今回作った棒グラフ・beeswarmは、pred_contribだけでLightGBM単体で作れる
  • shapライブラリは、それらの図を数行できれいに描ける手軽さや、force plot・依存性プロットといったより凝った/対話的な可視化を提供してくれるもの

という住み分けです。「とりあえずSHAP値を見たい・棒グラフやbeeswarmを自分で描く」くらいなら、わざわざインストールしなくてもLightGBMだけで十分、というわけです。

注意点

  • 出力の**列数は「特徴量数 + 1」**で、最後の列がベース値です。特徴量の数だけだと思って読むとズレるので注意。
  • 2値分類で使うと、寄与は確率ではなく生スコア(対数オッズ)空間で出ます。行の合計もsigmoidをかける前の値になるので、寄与の大小はそのまま比較できますが、「確率をどれだけ動かしたか」ではない点に注意してください。
  • 特徴量が非常に多い場合、pred_contribは全特徴量ぶんの寄与を返すのでメモリと計算が増えます。

まとめ

  • LightGBMのpredict(X, pred_contrib=True)で、shapライブラリ無しにSHAP値が得られる
  • 出力は(サンプル数, 特徴量数 + 1)で、最後の列がベース値
  • 各行の寄与を足すと予測値になる(予測を特徴量ごとに完全分解できる)
  • feature_importanceが「全体の重要度」なのに対し、pred_contribは「1件ごとの内訳」
  • 数値だけならLightGBM単体で十分。shapライブラリはリッチな可視化のために使う

「なぜこの予測になったのか」を1件単位で示せるので、コンペの考察や実務での説明に利用できます。

参考リンク