Hayami Kaito Web
~/tech

機械学習の判断を説明するしくみ:並べ替え重要度・部分依存・SHAP

published

勾配ブースティングやニューラルネットワークは、精度は高いものの、何百本もの木や何百万もの重みでできていて、人が中身を読んで「なぜその予測になったのか」を理解することはできません。しかし、ローンの審査に落ちた人には理由を伝える必要がありますし、医療の診断を助けるモデルが、病気ではなく画像の撮影装置の違いを手がかりにしていないかは確かめたいところです。

このように、モデルの判断を人に分かる形で説明する技術を 説明可能な AI(XAI)と呼びます。この記事では、表のデータでよく使われる 3 つの方法を、実際に計算して確かめます。

結論を先に書くと、次のとおりです。

  • 並べ替え重要度は、1 つの特徴をシャッフルしたら誤差がどれだけ増えるかでモデル全体での重要さを測る。ただし、相関のある特徴があると、モデルがたまたま使わなかったほうは重要度 0 になる
  • 部分依存は、ある特徴の値だけを変えたとき、予測の平均がどう変わるかをグラフにする。特徴と予測の関係の形が分かる
  • SHAP は、1 件の予測を、特徴ごとの貢献の足し算に分ける。ゲーム理論のシャープレイ値に基づき、貢献の合計は「その予測 − 予測の平均」にぴったり一致する
  • どの方法も「モデルが何を使っているか」を説明するもので、「世の中で何が原因か」を説明するものではない

説明の途中に出てくる計算例は、Python と NumPy で実際に動かした結果です。

環境

計算例を動かした環境です。

項目バージョン
Python3.13
NumPy2.5

説明の種類

モデルの説明には、大きく分けて 2 つの種類があります。

種類答える問い例
大域的な説明モデル全体として、どの特徴をどう使っているか「価格の予測では、広さがいちばん効いている」
局所的な説明この 1 件の予測は、なぜこの値になったか「この部屋は、駅に近いことで 1,000 万円高く予測された」

また、モデルの中身(木の分かれ方や重み)を使う方法と、モデルを外から何度も呼び出して、入力を変えたときの出力の変化を見るだけの方法があります。後者はどんなモデルにも使えるので、この記事では主にこちらを扱います。

説明するモデルを作る

中古マンションの価格(万円)を予測する、小さな問題を作ります。特徴は 5 つです。

  • 広さ(20〜100 m²):広いほど高い
  • 駅からの分(1〜25 分):遠いほど安い。駅から 10 分以内なら、広さの効き方が大きくなる(広い部屋ほど駅近の価値が上がる)
  • 築年数(0〜40 年):古いほど安いが、25 年を過ぎると下がらなくなる
  • 部屋数:広さとほぼ連動する(広さ ÷ 22 くらい)。価格の式には直接入っていない
  • 階数の末尾:価格とまったく関係ない数字

このデータで、深層学習以前の機械学習 で説明した 勾配ブースティング(深さ 3 の回帰木を 200 本)を学習します。ここからは、このモデルの中身は見ずに、入力を入れると予測が返ってくる「ブラックボックス」として扱います。

最初の説明の方法として、並べ替え重要度も計算します。テスト用のデータで、1 つの特徴の列だけをランダムに並べ替え(その特徴とほかの特徴・価格との関係を壊し)、予測の誤差(RMSE)がどれだけ増えるかを測ります。大きく増えるほど、モデルがその特徴に頼っていることになります。2001 年にランダムフォレストの論文で提案された方法です。

import itertools
import numpy as np

# 中古マンションの価格(万円)のデータ。部屋数は広さとほぼ連動する
rng = np.random.default_rng(0)
names = ["広さ", "駅からの分", "築年数", "部屋数", "階数の末尾"]
def make(n):
    area = rng.uniform(20, 100, n)
    walk = rng.uniform(1, 25, n)
    age = rng.uniform(0, 40, n)
    rooms = np.clip(np.round(area / 22 + 0.3 * rng.standard_normal(n)), 1, 5)
    noise = rng.integers(0, 10, n).astype(float)        # 価格に関係ない特徴
    price = 2000 + 60 * area - 80 * walk - 120 * np.minimum(age, 25) + 0.8 * area * np.maximum(10 - walk, 0) + 300 * rng.standard_normal(n)
    return np.c_[area, walk, age, rooms, noise], price
X, y = make(3000)
X_test, y_test = make(1000)

# 勾配ブースティング(深さ 3 の回帰木を 200 本)。中身を見ずに使う「ブラックボックス」として扱う
def fit_tree(X, r, depth):
    if depth == 0 or len(r) < 20:
        return r.mean()
    best = None
    for f in range(X.shape[1]):
        for t in np.unique(np.quantile(X[:, f], np.linspace(0.05, 0.95, 19))):
            left = X[:, f] <= t
            if left.all() or not left.any():
                continue
            sse = ((r[left] - r[left].mean()) ** 2).sum() + ((r[~left] - r[~left].mean()) ** 2).sum()
            if best is None or sse < best[0]:
                best = (sse, f, t, left)
    if best is None:
        return r.mean()
    _, f, t, left = best
    return (f, t, fit_tree(X[left], r[left], depth - 1), fit_tree(X[~left], r[~left], depth - 1))

def predict_tree(node, X):
    if not isinstance(node, tuple):
        return np.full(len(X), node)
    f, t, a, b = node
    left = X[:, f] <= t
    out = np.empty(len(X))
    out[left], out[~left] = predict_tree(a, X[left]), predict_tree(b, X[~left])
    return out

base = y.mean()
trees, pred = [], np.full(len(y), base)
for _ in range(200):
    tree = fit_tree(X, y - pred, 3)
    trees.append(tree)
    pred += 0.1 * predict_tree(tree, X)
model = lambda X: base + 0.1 * sum(predict_tree(t, X) for t in trees)
rmse = lambda X, y: float(np.sqrt(np.mean((model(X) - y) ** 2)))
print(round(rmse(X_test, y_test)), round(float(y_test.std())))

# 1. 並べ替え重要度:1 つの特徴だけをシャッフルしたら、誤差がどれだけ増えるか
base_err = rmse(X_test, y_test)
for f, name in enumerate(names):
    Xp = X_test.copy()
    Xp[:, f] = rng.permutation(Xp[:, f])
    print(name, round(rmse(Xp, y_test) - base_err))

実行結果(この環境では数秒かかりました):

317 1869
広さ 1699
駅からの分 654
築年数 1113
部屋数 -1
階数の末尾 1

1 行目は、テスト用のデータでの予測の誤差(317 万円)と、価格そのもののばらつき(標準偏差 1,869 万円)です。データには 300 万円の雑音を入れているので、モデルはほぼ限界まで学習できています。

2 行目からが並べ替え重要度です。広さをシャッフルすると誤差が 1,699 万円増え、いちばん重要な特徴でした。続いて築年数・駅からの分です。価格と関係のない階数の末尾は、ほぼ 0 になりました。

相関のある特徴の落とし穴

部屋数の重要度が 0 になった理由

気になるのは、部屋数の重要度が −1、つまりまったく使われていないことです。部屋数は広さとほぼ連動しているので、価格とも強く関係しています。それでも重要度が 0 になったのは、モデルが学習のときに、より正確な手がかりである広さで木を分けることを選び、部屋数を使う必要がなかったからです。

上の続きで、広さの情報を消して学習し直してみます。

# 広さを使わずに学習し直すと、部屋数が広さのかわりに使われる
X_wo, X_test_wo = X.copy(), X_test.copy()
X_wo[:, 0] = 0; X_test_wo[:, 0] = 0                    # 広さの情報を消す
trees_full = trees
trees, pred = [], np.full(len(y), base)
for _ in range(200):
    tree = fit_tree(X_wo, y - pred, 3)
    trees.append(tree)
    pred += 0.1 * predict_tree(tree, X_wo)
err = rmse(X_test_wo, y_test)
Xp = X_test_wo.copy(); Xp[:, 3] = rng.permutation(Xp[:, 3])
print("広さなし", round(err), "部屋数", round(rmse(Xp, y_test) - err))
trees = trees_full

実行結果:

広さなし 585 部屋数 1330

広さがないと誤差は 585 万円に増えますが、今度は部屋数の重要度が 1,330 万円と、2 番目に大きくなりました。部屋数が広さのかわりに使われたのです。

並べ替え重要度は、モデルがその特徴を使っているかを表すもので、その特徴が価格と関係あるかを表すものではありません。相関の強い特徴が 2 つあると、モデルによって、片方だけを使ったり、両方を少しずつ使って重要度を分け合ったりします。重要度が低いからといって、その特徴が価格と無関係だとは言えないのです。

また、相関のある特徴の片方だけをシャッフルすると、「広さ 25 m² で 5 部屋」のような、実際にはありえないデータをモデルに入れることになります。モデルはそのようなデータを学習していないので、予測があてにならず、重要度も歪むことがあります。

部分依存:特徴と予測の関係の形を見る

重要度は「どれだけ効いているか」しか分かりません。「どう効いているか」を見るのが 部分依存(PDP、2001 年)です。テスト用のデータの全員について、ある特徴の値だけを同じ値にそろえて予測し、その平均を取ります。そろえる値を少しずつ変えていくと、その特徴と予測の関係の形が分かります。上の続きです。

# 2. 部分依存:ある特徴だけを全員同じ値にそろえたときの、予測の平均
for f, grid in [(2, [0, 10, 20, 30, 40]), (1, [1, 5, 10, 15, 25])]:
    pd = []
    for v in grid:
        Xv = X_test.copy(); Xv[:, f] = v
        pd.append(round(float(model(Xv).mean())))
    print(names[f], grid, pd)

実行結果:

築年数 [0, 10, 20, 30, 40] [4422, 3324, 2079, 1524, 1490]
駅からの分 [1, 5, 10, 15, 25] [3644, 3147, 2566, 2162, 1425]

築年数を 0 年から 20 年にすると、予測の平均は 4,422 万円から 2,079 万円へと大きく下がりますが、30 年と 40 年ではほとんど変わりません。データを作ったときの「25 年を過ぎると下がらなくなる」関係を、モデルが学習していることが、中身を見ずに確かめられました。駅からの分は、遠くなるほど予測が下がっています。

部分依存は全員の平均なので、人によって効き方が違う場合(たとえば、広さの効き方が駅からの距離で変わる場合)は、その違いが平均で隠れてしまいます。1 人ずつの線を重ねて描く ICE(2015 年)を使うと、その違いも見えます。

SHAP:1 件の予測を特徴ごとの貢献に分ける

シャープレイ値の考え方

最後に、1 件の予測を説明します。「この部屋は、なぜ 5,744 万円と予測されたのか」を、「広さで +○○ 万円、駅に近いことで +○○ 万円……」のような、特徴ごとの貢献の足し算に分けたいのです。

これは、ゲーム理論の「何人かで協力して得た賞金を、それぞれの貢献に応じてどう分けるか」という問題と同じ形をしています。1953 年にシャープレイが示した シャープレイ値は、次のように分け前を決めます。

  1. 特徴(プレーヤー)が 1 つずつ順番に「参加」していくと考える
  2. ある特徴が参加したときに、予測がどれだけ変わったかを、その特徴の貢献とする
  3. 参加の 順番をすべて考えて、貢献を平均する

順番によって貢献が変わる(たとえば、広さと駅からの分が組み合わさって効く部分は、後から参加したほうの貢献になる)ので、すべての順番で平均することで、公平に分けるわけです。シャープレイ値は、「貢献の合計が全体の賞金に一致する」「まったく貢献しないプレーヤーは 0」などの、公平な分け方として望ましい性質を満たす、ただ 1 つの分け方であることが証明されています。

2017 年に発表された SHAP は、これを機械学習の説明に使いました。特徴が「参加していない」状態は、その特徴を背景のデータ(ここではテスト用のデータから選んだ 200 件)の値のままにして予測の平均を取ることで表します。

総当たりで計算する

特徴が 5 つなら、参加の順番は 5! = 120 通りなので、すべて計算できます。駅から 3 分と 20 分の、それ以外は同じ 2 つの部屋で計算してみます。上の続きです。

# 3. シャープレイ値:特徴の「参加する順番」をすべて考え、加わったときの予測の増え方を平均する
background = X_test[rng.choice(len(X_test), 200, replace=False)]
def value(x, subset):
    # subset の特徴だけを x の値にし、残りは背景のデータの値のまま予測した平均
    Xb = background.copy()
    Xb[:, list(subset)] = x[list(subset)]
    return model(Xb).mean()

def shapley(x):
    m = len(x)
    v = {s: value(x, s) for r in range(m + 1) for s in itertools.combinations(range(m), r)}
    phi = np.zeros(m)
    for perm in itertools.permutations(range(m)):      # 5! = 120 通りの順番
        s = ()
        for f in perm:
            t = tuple(sorted(s + (f,)))
            phi[f] += v[t] - v[s]
            s = t
    return phi / 120, v[()], v[tuple(range(m))]

for x in [np.array([70, 3, 5, 3, 7.0]), np.array([70, 20, 5, 3, 7.0])]:
    phi, v0, v_all = shapley(x)
    print(x, round(v0), dict(zip(names, np.round(phi).astype(int).tolist())), round(v0 + phi.sum()), round(float(model(x[None])[0])))

実行結果(この環境では数秒かかりました):

[70.  3.  5.  3.  7.] 2238 {'広さ': 801, '駅からの分': 1082, '築年数': 1613, '部屋数': 3, '階数の末尾': 7} 5744 5744
[70. 20.  5.  3.  7.] 2238 {'広さ': 756, '駅からの分': -667, '築年数': 1573, '部屋数': -2, '階数の末尾': 19} 3916 3916

左から、部屋の特徴、どの特徴も参加していないときの予測(背景のデータの予測の平均、2,238 万円)、特徴ごとの貢献、「平均 + 貢献の合計」、モデルの実際の予測です。

1 つ目の部屋(70 m²・駅から 3 分・築 5 年)の予測 5,744 万円は、平均の 2,238 万円から、築 5 年と新しいことで +1,613 万円、駅から 3 分と近いことで +1,082 万円、広さで +801 万円上がった、と分けられました。貢献の合計を平均に足すと、モデルの予測にぴったり一致します。

2 つ目の部屋は、駅から 20 分なので、駅からの分の貢献が −667 万円になりました。広さの貢献も、801 万円から 756 万円に少し下がっています。データを作ったときに「駅に近いほど広さの効き方が大きい」ようにしたので、広さと駅からの分が組み合わさって効く分の一部が、広さの貢献にも割り振られているのです。部屋数と階数の末尾は、どちらの部屋でもほぼ 0 でした。

特徴が多いときは近似する

特徴が m 個あると、順番は m! 通り、特徴の組み合わせは 2 の m 乗通りあるので、特徴が数十個になると総当たりはできません。SHAP では、組み合わせをサンプリングして近似する KernelSHAP や、決定木の構造を使って正確な値を速く求める TreeSHAP(2020 年)が使われます。勾配ブースティングのライブラリの多くは、TreeSHAP を組み込んでいます。

SHAP の値を全件について計算し、特徴ごとに絶対値の平均を取ると、並べ替え重要度のような大域的な重要度にもなります。

画像や LLM の判断の説明

画像のどこを見たか

画像のモデルでは、予測が画像のどの部分に左右されたかを、色の濃さで画像に重ねて表示する方法がよく使われます。予測の勾配を入力の画素まで戻して、勾配の大きい画素を強調する方法(サリエンシーマップ)や、最後の畳み込み層の特徴マップを、予測への勾配で重み付けして足し合わせる Grad-CAM(2017 年)などです。

狼とハスキー犬を見分けるモデルが、実は動物ではなく背景の雪を見ていた、という例は、2016 年の LIME の論文で紹介され、説明の方法の大切さを示す例としてよく引用されています。LIME は、説明したいデータの近くで入力を少しずつ変えてモデルを呼び出し、その結果に簡単な線形モデルを当てはめて、局所的な説明にする方法です。

アテンションは説明になるか

Transformer のアテンションの重みは、「どの単語を見たか」を表しているように見えるので、説明に使いたくなります。しかし、2019 年には、アテンションの重みを大きく変えても予測がほとんど変わらない場合があることが示され、アテンションの重みをそのまま説明とみなすことには注意が必要だとされています。

LLM の内部で何が計算されているかを、ニューロンや回路の単位で解き明かそうとする研究(機械論的解釈可能性)も進んでいます。2023〜2024 年には、スパースオートエンコーダーを使って、LLM の内部の表し方を、人が意味を読み取れる多数の特徴に分解する研究が発表されました。

説明を使うときの注意

説明の方法は便利ですが、次の点に注意が必要です。

  • 説明はモデルについてのもので、世の中の因果関係ではない。「この特徴を変えれば価格が上がる」とは限らない。相関のある特徴がある場合は特にそうです。原因と結果の関係を調べるには、因果推論のしくみ で説明する方法が必要です
  • 相関のある特徴は、重要度や貢献を分け合ったり、片方に寄せたりする。重要度が低くても、関係がないとは言えない
  • 「参加していない」状態の作り方(背景のデータ)で、SHAP の値は変わる。何と比べた貢献なのかを意識する
  • 方法によって結果が違うことがある。1 つの方法だけで結論を出さず、複数の方法やドメインの知識と合わせて確かめる

説明の手法の流れ

この記事で紹介した手法を年代順に並べます。

年手法・出来事ポイント
1953シャープレイ値協力して得た賞金を、参加の順番をすべて考えて公平に分ける
2001並べ替え重要度・部分依存ランダムフォレストの重要度。特徴の値を変えたときの予測の平均
2014サリエンシーマップ予測の勾配を画素まで戻して、効いている場所を示す
2015ICE部分依存を 1 件ずつの線で描く
2016LIMEデータの近くで簡単なモデルを当てはめて、局所的に説明する
2017SHAP・Grad-CAM・統合勾配シャープレイ値による説明。畳み込みの特徴マップで画像の注目箇所を示す
2019「アテンションは説明ではない」アテンションの重みを説明とみなすことへの注意
2020TreeSHAP決定木のモデルのシャープレイ値を、正確かつ速く求める
2023〜2024スパースオートエンコーダーによる LLM の解釈LLM の内部の表し方を、意味の読み取れる特徴に分解する

まとめ

  • 説明には、モデル全体についての大域的な説明と、1 件の予測についての局所的な説明がある
  • 並べ替え重要度は、1 つの特徴をシャッフルしたときの誤差の増え方で、モデルがその特徴にどれだけ頼っているかを測る
  • 相関の強い特徴があると、モデルが使わなかったほうの重要度は 0 になる。重要度が低くても、関係がないとは言えない
  • 部分依存は、特徴の値だけを変えたときの予測の平均で、特徴と予測の関係の形を見せる
  • SHAP は、シャープレイ値で 1 件の予測を特徴ごとの貢献に分ける。貢献の合計は「予測 − 平均」にぴったり一致する
  • 画像では勾配や Grad-CAM で注目箇所を示す。アテンションの重みをそのまま説明とみなすのは注意が必要
  • 説明はモデルについてのもので、世の中の因果関係を表すものではない

参考

  • L. S. Shapley, "A Value for n-Person Games", Contributions to the Theory of Games II 1953
  • L. Breiman, "Random Forests", Machine Learning 2001
  • J. H. Friedman, "Greedy Function Approximation: A Gradient Boosting Machine", The Annals of Statistics 2001
  • K. Simonyan, A. Vedaldi and A. Zisserman, "Deep Inside Convolutional Networks: Visualising Image Classification Models and Saliency Maps", ICLR Workshop 2014(arXiv:1312.6034)
  • A. Goldstein et al., "Peeking Inside the Black Box: Visualizing Statistical Learning with Plots of Individual Conditional Expectation", Journal of Computational and Graphical Statistics 2015(arXiv:1309.6392)
  • M. T. Ribeiro, S. Singh and C. Guestrin, ""Why Should I Trust You?": Explaining the Predictions of Any Classifier", KDD 2016(arXiv:1602.04938)
  • S. M. Lundberg and S.-I. Lee, "A Unified Approach to Interpreting Model Predictions", NeurIPS 2017(arXiv:1705.07874)
  • R. R. Selvaraju et al., "Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization", ICCV 2017(arXiv:1610.02391)
  • M. Sundararajan, A. Taly and Q. Yan, "Axiomatic Attribution for Deep Networks", ICML 2017(arXiv:1703.01365)
  • S. Jain and B. C. Wallace, "Attention is not Explanation", NAACL 2019(arXiv:1902.10186)
  • S. M. Lundberg et al., "From local explanations to global understanding with explainable AI for trees", Nature Machine Intelligence 2020
  • T. Bricken et al., "Towards Monosemanticity: Decomposing Language Models With Dictionary Learning", Transformer Circuits Thread 2023