【詳解】SHAPで機械学習モデルの予測根拠を解釈する

こんばんは!今日はモデルによる予測結果の解釈性を向上させる方法の1つであるSHAPを試してみたいと思います。
私自身機械学習・データサイエンスを絶賛勉強中ですので、記事内容に謝りがあればご指摘いただけますと幸いです。

実ビジネスの現場でモデリングををしていると必ず聞かれるのが、「なぜこのデータはスコアが高いのか?」ということ。この結果の解釈性を担保する時に、まず考えることは、線形モデルを利用することだと思いますが、変数の合わせが評価できないため、決定木系モデルと比較して表現力が落ちてしまう点が欠点だと思っています。

私が決定木系モデルでよく使うのはKaggleなどでも常連の勾配ブースティングモデルであるLightGBM。どうにかこのモデルでも結果の解釈性を高めることはできないか?と考えて調べている時に見つけたのがこの「SHAP」です。

今回はKaggleで公開されているタイタニックの乗船者データを使って、SHAPで結果の解釈を試みてみます。

人工知能と機械学習の人気オンライン講座

SHAPとは?

SHAPは、SHapely Additive exPlanationsの略称。

SHAPは、2017年の論文「A Unified Approach to Interpreting Model Predictions」で提唱されました。こちらから原文が読めます。

簡単に書いていることをまとめると、

・近年のディープニューラルネットワークやアンサンブルモデルなどの高精度なモデルは、高精度が出せる反面で解釈性の問題を抱えている。

・このような場合にはしばしば精度を犠牲にしてより解釈性の高い線形モデルが選択される。

・しかし、近年のビッグデータの活用が広がる中で、より複雑なモデルの需要は高まっており、このトレードオフを解消する意義は大きい。

・SHAPはこの問題を解決する新しい統合的なアプローチである。

・SHAPは以下の3つのことを実現する。
 1、モデルに予測結果の説明性を加える(Additive Feature attribution method)。既存の解釈性を向上させる様々な手法を統合する手法となる。

 2、ゲーム理論で勝利への貢献度合いを評価する方法を取り入れ、SHAP値としてFeature Importanceを表現する。

 3、より人間の直感に近い解釈を提供する。

・Additive Feature Attribute Methodでは2値の線形関数の重ね合わせとして説明可能なモデルに近似する。代表的な手法としては、LIME, Deep-LIFT, Layer-Wise Relevance Propagation などがある。

・Additive Feature Attribute Methodは以下の3の性質を満たす。
 1、局所の正確性:各変数それぞれについて見た時には正確なモデルである。
 2、貢献していない変数に対しては貢献度を与えない。
 3、一貫性:モデルが変わった時でも、同じ変数に対しては同じ貢献度が与えられる。(他の変数の影響を受けない)

・古典的なShapely Valueでは、特徴量のサブセット全ての組み合わせに対して、そのサブセットをモデルに含めた場合と、含めなかった場合の精度の差を計算して重要度を算出する。

・しかし、全ての組み合わせに対して考えると計算ボリュームが膨大になるため、SHAPでは計算にLIMEなどのAdditive Feature Attribute Methodの考え方を取り入れることで、重要度の計算を近似している。このため、Additive Feature Attribute Methodの特徴も引き継いでいる。

・SHAPは、さらにモデルの系統に応じて、KernelSHAP, LinearSHAP, Low-OrderSHAP, DeepSHAP, MaxSHAPの手法に分けられ、それぞれのモデル系統に最適な近似計算を行い高速化する。

・論文ではSHAPと他手法による変数重要度の出力を比較し、SHAPがより人間の直感に近い結果を導くことを確認した。

・次はさらに別のモデル系統に対して高速なSHAP手法を展開する.

ちょっと長くなってしまいましたが、とても理解が深まる論文ですね、、
この最後に書かれている別のモデル系統のSHAPというのが、のちに発表されるTreeSHAPのことですね。こちらは今後記事にしたいと思います。
(本記事のハンズオンはTreeSHAPを用いているのですが・・笑 しかしSHAPの概念を理解することの方がまずは大切でしょう・・)

あとは、この論文で書かれていたか忘れましたが、SHAPのさらに素晴らしいところは、変数重要度をデータ個別に出せる点です。例えば、モデル全体としてある変数Xが重要変数だったとしても、それが個別のデータAのスコアに寄与しているとは限りませんよね。

これも以下でどういう使い方ができるかみてみたいと思います。

【ゼロからおさらい】統計学の基礎講座

タイタニックデータでSHAPを試す

それでは概要を掴んだところで、実際にこのSHAPを使ってみたいと思います。

と、ここから先はKaggle上のKernelでもまとめていますので、こちらをKaggle Kernel上でCopy&Editしていただければ、その場ですぐに動かしてみることができます^^

Kaggle Notebook

https://www.kaggle.com/yuu113/shap-method-practice-with-titanic-data?scriptVersionId=21775128

データのロード

データはKaggleからダウンロードしたものをローカルに置いて試してみていただければと思います。

df_train = pd.read_csv('/kaggle/input/titanic/train.csv')
df_test = pd.read_csv('/kaggle/input/titanic/test.csv')
display(df_train.info())
display(df_train.head())

データ前処理

今回は、SHAPの使い方を学ぶことが目的なので、前処理はさっくりコードだけ書いておきます。

ちなみに、以下はデータ分布の外観

sns.pairplot(df_train)
def feature_engineering(df):
    # Null Value Handling
    df["Age"].fillna(df["Age"].median(),inplace=True)
    df["Embarked"].fillna(df['Embarked'].mode()[0], inplace = True)
    df = df.fillna(-1)
    
    # Feature Encoding
    df["Sex"] = df["Sex"].map({'male':1,'female':0}).fillna(-1).astype(int)
    df["Embarked"] = df["Embarked"].map({'S':0,'C':1,'Q':2}).astype(int)
    df["Cabin"] = df["Cabin"].str[0].map({'T':0,'G':1,'F':2,'E':3,'D':4,'C':5,'B':6,'A':7}).fillna(-1).astype(int)
    
    # Binning
    bins_age = np.linspace(0, 100, 10)
    df["AgeBin"] = np.digitize(df["Age"], bins=bins_age)
    
    df["FareBin"] = 0
    df["FareBin"][(df["Fare"]>=0)&(df["Fare"]<10)] = 1
    df["FareBin"][(df["Fare"]>=10)&(df["Fare"]<20)] = 2
    df["FareBin"][(df["Fare"]>=20)&(df["Fare"]<30)] = 3
    df["FareBin"][(df["Fare"]>=30)&(df["Fare"]<40)] = 4
    df["FareBin"][(df["Fare"]>=40)&(df["Fare"]<50)] = 5
    df["FareBin"][(df["Fare"]>=50)&(df["Fare"]<100)] = 6
    df["FareBin"][(df["Fare"]>=100)] = 7

    # Create New Features (Optional)
    df['FamilySize'] = df['SibSp'] + df['Parch'] + 1
    df['Title'] = -1
    df['Title'][df["Name"].str.contains("Mr")] = 0
    df['Title'][df["Name"].str.contains("Master")] = 1
    df['Title'][df["Name"].str.contains("Miss")] = 2
    df['Title'][df["Name"].str.contains("Mrs")] = 3
    
    # Drop unsed columns
    del df["Age"]
    del df["Fare"]
    del df["Ticket"]
    
    return df

df_train_fe = feature_engineering(df_train)
df_test_fe = feature_engineering(df_test)

モデル構築(学習)

exclude_columns = [
    'Name',
    'Ticket',
    'PassengerId',
    'Survived'
]

evals_result = {}
features = [c for c in df_train_fe.columns if c not in exclude_columns]
target = df_train_fe['Survived']
print(len(target))

gc.collect()

X_train, X_test, y_train, y_test = train_test_split(df_train_fe[features], target, test_size=0.2, random_state=440)

param = {   
    'boost': 'gbdt',
    'learning_rate': 0.008,
    'feature_fraction':0.20,
    'bagging_freq':1,
    'bagging_fraction':1,
    'max_depth': -1,
    'num_leaves':17,
    'lambda_l2': 0.9,
    'lambda_l1': 0.9,
    'max_bin':200,
    'metric':{'auc','binary_logloss'},
#    'metric':{'binary_logloss'},
    'tree_learner': 'serial',
    'objective': 'binary',
    'verbosity': 1,
}

oof = np.zeros(len(df_train_fe))
predictions = np.zeros(len(df_test_fe))
feature_importance_train = pd.DataFrame()

lgb_train = lgb.Dataset(X_train, y_train)
lgb_valid = lgb.Dataset(X_test, y_test)
num_round = 10000
clf = lgb.train(param, lgb_train, num_round, valid_sets = [lgb_train, lgb_valid],
      verbose_eval=100, early_stopping_rounds = 1000, evals_result = evals_result)
oof = clf.predict(X_test, num_iteration=clf.best_iteration)

## Prediction
predictions = clf.predict(df_test_fe[features], num_iteration=clf.best_iteration)

# Visualize Metrics
axL = lgb.plot_metric(evals_result, metric='auc')
axL.set_title('AUC')
axL.set_xlabel('Iterations')
axL.set_ylim(0,1.1)
axR = lgb.plot_metric(evals_result, metric='binary_logloss')        
axR.set_title('Binary_Logloss')
axR.set_xlabel('Iterations')
plt.show()

# Importance
fold_importance_train = pd.DataFrame()
fold_importance_train["feature"] = features
fold_importance_train["importance"] = clf.feature_importance()
fold_importance_train["fold"] = 1
feature_importance_train = pd.concat([feature_importance_train, fold_importance_train], axis=0)

precisions, recalls, thresholds = precision_recall_curve(y_test, oof)

fig = plt.figure(figsize=(14,4))

### Threshold vs Precision/Recall
ax = fig.add_subplot(1,2,1)
ax.plot(thresholds, precisions[:-1], "b--", label="Precision")
ax.plot(thresholds, recalls[:-1], "g--", label="Recall")
ax.set_title("Threshold vs Precision/Recall")
ax.set_xlabel("Threshold")
ax.legend(loc="center left")
ax.set_ylim([0,1.1])
ax.grid()
fig.show()

### Precision-Recall Curve
ax = fig.add_subplot(1,2,2)
ax.step(recalls, precisions, color='b', alpha=0.2, where='post')
ax.fill_between(recalls, precisions, step='post', alpha=0.2, color='b')
ax.set_title('Precision-Recall Curve')
ax.set_xlabel('Recall')
ax.set_ylabel('Precision')
ax.set_ylim([0.0, 1.05])
ax.set_xlim([0.0, 1.0])
ax.grid()
fig.show()

### スコア分布
df_train_result = pd.DataFrame()
df_train_result['Actual Result'] = y_test
df_train_result['Prediction Score'] = oof
df_train_result = df_train_result.sort_values('Prediction Score',ascending=False).sort_index(ascending=False)
df_target = df_train_result[df_train_result['Actual Result']==1]
df_nontarget = df_train_result[df_train_result['Actual Result']==0]

### Show Importance
cols = (feature_importance_train[["feature","importance"]]
        .groupby("feature")
        .mean()
        .sort_values(by="importance", ascending=False)[:1000].index)
best_features = feature_importance_train.loc[feature_importance_train.feature.isin(cols)]

plt.figure(figsize=(14,5))
sns.barplot(x="importance", y="feature", 
            data=best_features.sort_values(by="importance",ascending=False))
plt.title('LightGBM Features (averaged over folds)')
plt.tight_layout()

さて、モデルができたところで、いよいよSHAPの出番です。

SHAP Valueの計算

ちなみに、SHAPの利用には、NodeJSのインストールが必要です。未インストールの場合は、shap.initjs()でエラーがおきますので、済ませておいてください。

shap.initjs()
explainer = shap.TreeExplainer(clf)
shap_values = explainer.shap_values(X_train)

先の論文紹介でもありましたが、SHAPのExplainerは複数ありますが、今回はLightGBMなので利用するのはTreeExplainerです。explainer.shap_valuesでSHAP Valueの計算が完了します。とても高度なことをしているのにコードでかくと、たったの1行・・・改めてライブラリってすごいですね・・。

以下は、SHAP Valueを利用した様々な可視化方法をさらっとご紹介していきます。オプション含めた詳細な利用方法は、SHAPのマニュアルから確認できます。

https://shap.readthedocs.io/en/latest/

SHAPの活用:Force Plot (1)

Force Plotでは、データ個別に、判定要因を可視化することができます。

出力されている値はSHAP ValueでLight GBMなどが出力する確率ではない(理解です)。Base Valueに対して個別の変数がどれだけSHAP Valueを押し上げたか/押し下げたかが一目でわかります。

shap.force_plot(explainer.expected_value[1], shap_values[1][0,:], X_train.iloc[0,:])

SHAPの活用:Force Plot (2)

このプロットでは、全レコードのSHAP Valueの算出傾向を確認することができます。

Order By Similarityとすると、特徴量の分布が似た者通しで並べてくれますので、これで特徴のパターンを大まかに把握することができそうです。

shap.force_plot(base_value=explainer.expected_value[1], shap_values=shap_values[1], features=X_train.columns)

SHAP Valueで並び替えることも可能。

SHAPの活用:Decision Plot

もう一つ、Force Plotと同様に個別データに対して、判定結果を説明するための関数がこのDecision Plot。

Force Plotよりもきっちりと全ての情報を取得できます。

shap.decision_plot(explainer.expected_value[1], shap_values[1][0,:], X_train.iloc[0,:])

SHAPの活用:Summary Plot

Summary Plotでは、モデル全体としての特徴量重要度を確認できます。

この特徴量重要度は、決定木系アルゴリズムでこれまでよく用いられていたGain、Split、Permutationなどとは異なる、SHAP Importanceが可視化されています。

SHAP Importanceで評価された特徴量重要度の順序は、しばしばそのほかの方法で評価された重要度と異なります。

ここは自分も勉強中(分かる方がいれば教えていただきたい・・・!)なのですが、SHAP Importanceで評価する方がより正しく真の貢献度を評価できているのかなと思っています。

タイタニックのデータにおいても、ゲインで評価したImportanceでは、FareBin(運賃)がもっとも重要度が高いと評価されていますが、SHAP Importanceでは、Title、Sex(性別関連)がもっとも重要度が高いです。こちらの方がより直感にあっているように感じます。(実際のところは、どうなんでしょう・・・)

shap.summary_plot(shap_values, X_train)
SHAP Importance
GainによるFeature Importance

感覚的にはPermutationのImportanceと考え方が近いのかなと思ったのですが、この点に関して英文でまとめていらっしゃる方がいましたので、引用させていただきます。

SHAP feature importance is an alternative to permutation feature importance. There is a big difference between both importance measures: Permutation feature importance is based on the decrease in model performance. SHAP is based on magnitude of feature attributions.

https://christophm.github.io/interpretable-ml-book/shap.html

PermutationのImportanceはモデルの精度の上昇/下降を評価するのに対して、SHAPは特徴量の貢献度を評価している、とのこと。また、SHAPはPermutation Importanceを代替するものであると述べられている。

うーん、実際のところ、どう考えるべきなんだろうか。違いをちゃんと勉強してみます。

SHAPの活用:Dependency Plot

Dependency Plotでは、各特徴量とSHAP Valueの関係の確認に加えて、その特徴量ともっとも組み合わせで効いた特徴量も確認することができます。1つの特徴量を選択すると、もう片方の特徴量は自動選択されます。

shap.dependence_plot("AgeBin",shap_values[1], X_train)

いかがでしたでしょうか。SHAPを使うと、これまでブラックボックスで見えなかったモデルの予測根拠を把握することができるようになります。実務でもガンガン使っていこうと思います!

みんなのAI講座 ゼロからPythonで学ぶ人工知能と機械学習

本日も最後までご覧いただきありがとうございました!

おしまい

この記事を気に入っていただけたらシェアをお願いします!

コメントを残す

メールアドレスが公開されることはありません。

ABOUT US

Yuu113
初めまして。Yuu113と申します。 兵庫県出身、東京でシステムエンジニアをしております。現在は主にデータ分析、機械学習を活用してビジネスモデリングに取り組んでいます。日々学んだことや経験したことを整理していきたいと思い、ブログを始めました。旅行、カメラ、IT技術、江戸文化が大好きですので、これらについても記事にしていきたいと思っています。