Digital Reactor
機械学習

機械学習モデルの説明可能性手法の比較:SHAPとLIMEの出力の一貫性評価

機械学習モデルの説明可能性手法の比較:SHAPとLIMEの出力の一貫性評価

はじめに

「このローンの申込がなぜ否決になったのか」を説明できないモデルは、精度が高くても本番に載せられないことがあります。信用スコアリングの実務では、個々の予測の理由を示すことがモデル採用の条件になる場面が珍しくありません。そこで使われるのがSHAP(SHapley Additive exPlanations)やLIME(Local Interpretable Model-agnostic Explanations)といった説明手法(Explainable AI, XAI)ですが、両者には見過ごされやすい違いがあります。同じモデルの同じ予測を説明しても、LIMEは実行のたびに結果が変わりうるという点です。説明が毎回揺れるようでは、説明を受け取る側の信頼をかえって損ねます。この記事では、この「説明の一貫性」に絞って2つの手法を実験で比較します。

対象読者:

  • 機械学習エンジニア
  • データサイエンティスト
  • AIの導入を検討しているビジネスパーソン

記事のポイント:

  • SHAPとLIMEのローカルな説明手法としての特徴を理解する
  • 実験結果に基づき、各手法の長所と短所を把握する
  • 実務での使い分けの指針を得る

検証する仮説

SHAPはShapley値という一意に定まる定義に沿って寄与を計算するため、同じモデルの同じデータポイントに対する説明は毎回同じになるはずです。一方LIMEは、対象点の周辺をランダムにサンプリングして局所モデルを当てはめる手法なので、実行ごとに説明が変動しえます。

この変動は次元数が上がるほど大きくなると予想できます。高次元では同じサンプル数で近傍を覆う密度が薄くなり、局所近似が不安定になるためです。裏を返せば、サンプル数を増やせば高次元でもLIMEの一貫性は改善するはずです。この記事では、低次元と高次元のデータでこれらの仮説を確かめます。

SHAPとLIMEが説明を作る仕組み

SHAP値のローカルな説明

SHAPは、個々の予測に対して各特徴量の貢献度を計算します:

ϕi(x)=SN{i}S!(nS1)!n![f(S{i},x)f(S,x)]\phi_i(x) = \sum_{S \subseteq N \setminus \{i\}} \frac{|S|!(n-|S|-1)!}{n!}[f(S \cup \{i\}, x) - f(S, x)]

ここで:

  • ϕi(x)\phi_i(x) は特定のデータポイント x における特徴量 i のSHAP値
  • f(S,x)f(S, x) は特徴量集合 S のみを使用した予測値

SHAPはゲーム理論のShapley値に基づいており、特徴量の寄与を一意に定まる形で評価できます。

LIMEのローカルな説明

LIMEは、予測対象の周辺で局所的に解釈可能なモデルを作成します:

explanation(x)=arg mingGL(f,g,πx)+Ω(g)\text{explanation}(x) = \argmin_{g \in G} L(f, g, \pi_x) + \Omega(g)

ここで:

  • πx\pi_x は x の周辺での重み(距離が近いほど大きな重み)
  • gg は局所的な線形モデル

LIMEの近似モデルはデータのサンプリングに依存するため、異なる実行ごとに結果が変化します。

5次元と100次元で一貫性を測る

次元数の影響を見るため、5次元と100次元の合成データセットを用意しました。いずれも5,000サンプルで、特徴量の内訳は独立な特徴量が60%、冗長な特徴量が20%、繰り返し特徴量が20%です。モデルにはランダムフォレストを使います。

一貫性は、同じデータポイントに対して説明を5回計算し、実行間の相関係数で測ります。値が1に近いほど、何度実行しても同じ説明が返ってくることを意味します。LIMEはサンプル数を1000、5000、20000と変えて評価し、いずれもテストデータから選んだ100サンプルで計算しました。

サンプル数1000のLIMEは高次元で0.72まで落ちる

一貫性スコアは次のとおりです。

手法5次元100次元
SHAP約1.0約1.0
LIME(サンプル数1000)約1.00.72
LIME(サンプル数5000)約1.00.93
LIME(サンプル数20000)約1.00.97

5次元では両手法とも一貫性スコアがほぼ1.0で、どちらを使っても説明は安定します。差が出るのは100次元です。SHAPは約1.0を維持しますが、これはTreeExplainerの計算が決定的で、実行ごとに結果が変わりえない以上、当然の結果です。

LIMEはサンプル数1000で0.72まで下がり、5000で0.93、20000で0.97と、サンプル数を増やすほど回復します。仮説のとおり、高次元ではサンプリングの密度が局所近似の安定性を左右し、その不足はサンプル数で補えることが確認できました。

まとめ

低次元では両手法とも安定しており、一貫性の観点ではどちらを選んでも差はありません。高次元ではSHAPが安定を保つ一方、LIMEはサンプル数しだいで一貫性が大きく変わります。20000サンプルまで増やせばSHAPに近い水準に届きますが、その分計算時間は長くなります。

落とし穴になるのはデフォルト値です。今回使ったLIMEの実装では explain_instance のサンプル数のデフォルトが5000で、100次元では一貫性が0.93にとどまります。デフォルト設定のまま高次元データに適用すると、同じ予測に対して実行のたびに違う説明が返り、その説明を根拠に判断する側を混乱させます。LIMEを本番の説明に使うなら、導入前に同じデータポイントへの説明を5回ほど計算して相関を取り、自分のデータの次元数に対してサンプル数が足りているかを確認しておくことを勧めます。

コード

from collections import defaultdict
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import japanize_matplotlib
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.datasets import make_classification
from sklearn.metrics import accuracy_score
import shap
import lime
import lime.lime_tabular
import traceback

# 再現性のために乱数シードを設定
np.random.seed(42)

def generate_synthetic_data(n_samples=5000, n_features=5):
    """合成データセットを生成する関数"""
    try:
        n_informative = int(n_features * 0.6)
        n_redundant = int(n_features * 0.2)
        n_repeated = n_features - n_informative - n_redundant

        X, y = make_classification(
            n_samples=n_samples,
            n_features=n_features,
            n_informative=n_informative,
            n_redundant=n_redundant,
            n_repeated=n_repeated,
            random_state=42,
            class_sep=1.5,
            weights=[0.7, 0.3]
        )

        feature_names = [f'Feature{i+1}' for i in range(X.shape[1])]
        X = pd.DataFrame(X, columns=feature_names)
        return X, y
    except Exception as e:
        print(f"Error in generate_synthetic_data: {str(e)}")
        print(traceback.format_exc())
        raise

def evaluate_local_consistency(model, X, n_runs=5, lime_num_samples_list=[1000, 5000, 20000]):
    """ローカルな説明の一貫性を評価する関数"""
    n_features = len(X.columns)
    shap_values_runs = []
    lime_values_runs = defaultdict(list)

    for _ in range(n_runs):
        # SHAP値の計算(ローカル)
        explainer = shap.TreeExplainer(model)
        shap_values = explainer.shap_values(X)[:, :, 1]
        shap_values_runs.append(shap_values)

        # LIME説明の計算(ローカル)
        explainer = lime.lime_tabular.LimeTabularExplainer(
            X.values,
            feature_names=X.columns,
            class_names=['Class0', 'Class1'],
            mode='classification'
        )

        for lime_num_samples in lime_num_samples_list:
            lime_values = np.zeros((len(X), n_features))
            for i in range(len(X)):
                exp = explainer.explain_instance(
                    X.iloc[i].values,
                    model.predict_proba,
                    num_features=n_features,
                    num_samples=lime_num_samples
                )
                importance_dict = dict(exp.local_exp[1])
                for feat_id in range(n_features):
                    lime_values[i, feat_id] = importance_dict.get(feat_id, 0)
            lime_values_runs[lime_num_samples].append(lime_values)

    # 一貫性スコアの計算
    shap_consistency = np.mean([
        np.corrcoef(
            shap_values_runs[0].flatten(), 
            shap_values_runs[i].flatten()
        )[0, 1]
        for i in range(1, n_runs)
    ])
    lime_consistency = {}
    for lime_num_samples in lime_num_samples_list:
        lime_consistency[lime_num_samples] = np.mean([
            np.corrcoef(
                lime_values_runs[lime_num_samples][0].flatten(), 
                lime_values_runs[lime_num_samples][i].flatten()
            )[0, 1] 
            for i in range(1, n_runs)
        ])

    return shap_consistency, lime_consistency

def main():
    # 低次元、高次元のデータについて、SHAP, LIME(num_sampling=1000, 5000, 20000)での比較
    for n_features in [5, 100]:
        X_low, y_low = generate_synthetic_data(n_samples=5000, n_features=n_features)

        X_train_low, X_test_low, y_train_low, y_test_low = train_test_split(X_low, y_low, test_size=0.2, random_state=42)
        model_low = RandomForestClassifier(n_estimators=100, random_state=42)
        model_low.fit(X_train_low, y_train_low)

        shap_consistency_low, lime_consistency_low = evaluate_local_consistency(model_low, X_test_low.head(100), lime_num_samples_list=[1000, 5000, 20000])

        # plot
        plt.figure(figsize=(10, 5))
        labels = ['SHAP', 'LIME(1000)', 'LIME(5000)', 'LIME(20000)']
        values = [shap_consistency_low, lime_consistency_low[1000], lime_consistency_low[5000], lime_consistency_low[20000]]
        plt.bar(labels, values)
        plt.title(f'Local Consistency Comparison (n_features={n_features})')
        plt.xlabel('Explanation Method')
        plt.ylabel('Consistency Score')
        plt.savefig(f'local_consistency_comparison_n_features_{n_features}.png')

if __name__ == "__main__":
    main()

関連記事

← 技術ブログ一覧へ