機械学習モデルと結果を解釈する(Pertial Dependence)

# Abstract: 機械学習モデルと結果を解釈するための手法

##### 1. どの特徴量が重要か: モデルが重要視している要因がわかる
- feature importance 

##### 2. 各特徴量が予測にどう影響するか: 特徴量を変化させたときの予測から傾向を掴む
- partial dependence
- permutation importance

##### 3. 予測結果が出たときの特徴量の寄与: 近似したモデルを作り、各特徴の寄与を算出
- LIME(Local Interpretable Model-agnostic Explainations)
- SHAP(SHapley Additive exPlanations)

# Introduction: 機械学習の解釈性の重要が高まっている
 
現状、高精度を叩き出した機械学習は、ブラックボックスになりがちで根拠の説明を人間に提示しない

例えば、近い将来、お医者さんが機械学習モデルから導いたモデルから診断する時代になったとき

```
お医者さん「あなたは糖尿病に今後５年以内になりますよ。」
患者さん「どうしてわかったんですか？ 何が原因なんですか！？」
お医者さん「...。 AIがそう判断したからですよ...。」
患者さん「納得できません！」
```

と、なってしまう。
機械学習モデルが予測結果に対して、なぜその予測をしたのかという説明が必要。

# Environment:

### 実験環境
Python==3.6.8 
matplotlib==3.0.3 
jupyter notebook 

```
import re
import sklearn
import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
import matplotlib
%matplotlib inline

import warnings
warnings.filterwarnings('ignore')

from IPython.core.interactiveshell import InteractiveShell
InteractiveShell.ast_node_interactivity = 'all'

%reload_ext autoreload
%autoreload 2
```

### データ 
Kaggleのtitanicを使用。
適度なデータ数、カーディナリティの少なさ、解釈しやすさ、皆に認識されてる３点から採用。

[Titanic: Machine Learning from Disaster](https://www.kaggle.com/c/titanic/data)


このコンペは、タイタニック号に乗船した、各乗客の購入したチケットのクラス(Pclass1, 2, 3の順で高いクラス)や、料金(Fare)、年齢(Age)、性別(Sex)、出港地(Embarked)、部屋番号(Cabin)、チケット番号(Tichket)、乗船していた兄弟または配偶者の数(SibSp)、乗船していた親または子供の数(Parch)など情報があり、そこからタイタニック号が氷山に衝突し沈没した際生存したかどうか(Survived)を予測する。


```
PassengerId – 乗客識別ユニークID
Survived – 生存フラグ（0=死亡、1=生存）
Pclass – チケットクラス
Name – 乗客の名前
Sex – 性別（male=男性、female＝女性）
Age – 年齢
SibSp – タイタニックに同乗している兄弟/配偶者の数
parch – タイタニックに同乗している親/子供の数
ticket – チケット番号
fare – 料金
cabin – 客室番号
Embarked – 出港地（タイタニックへ乗った港）
```

```
train.head()
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/417062/bed2c569-c9d7-5c4b-4540-8c4cb01a36c2.png)


### 前処理

[Pythonでアンサンブル(スタッキング)学習 & 機械学習チュートリアル in Kaggle](https://qiita.com/hokuto_HIRANO/items/2c35a81fbc95f0e4b7c1#second-level-predictions-from-the-first-level-output)
↑前処理が簡潔で、データをあまりいじっていないので参考にさせていただきました。ありがとうございます。

基本的には前処理により以下の用にビン分割した。(ビン分割した理由は結果を人間が解釈しやすくなるため)

|　Value | Age(年齢) | 
| -------- | -------- | 
| 0 | 16歳以下 | 
| 1 | 32歳以下 |
| 2 | 48歳以下 |
| 3 | 64歳以下 |
| 4 | それ以上 |

|　Value | Fare(料金) | 
| -------- | -------- | 
| 0 | 凄い低い | 
| 1 | 低い |
| 2 | 高い |
| 3 | 凄い高い |

|　Value | Embarked(出港地) | 
| -------- | -------- | 
| 0 | S 人が多い | 
| 1 | C お金持ちが多い |
| 2 | Q 貧困が多い |

|　Value | Title(敬称) | 
| -------- | -------- | 
| 0 | Mr | 
| 1 | Miss |
| 2 | Mrs |
| 3 | Master |
| 4 | Rare |

|　Value | Sex(性別) | 
| -------- | -------- | 
| 0 | female 女性 | 
| 1 | male 男性 |

|　Value | Pclass(チケットクラス) | 
| -------- | -------- | 
| 0 | 高級 | 
| 1 | 中級 |
| 2 | 下級 |

|　Value | IsAlone(家族有無) | 
| -------- | -------- | 
| 0 | 家族なし | 
| 1 | 家族あり |

```
full_data = [train, test]

# 客室番号データがあるなら１を、欠損値なら0
train['Has_Cabin'] = train["Cabin"].apply(lambda x: 0 if type(x) == float else 1)
test['Has_Cabin'] = test["Cabin"].apply(lambda x: 0 if type(x) == float else 1)

# 家族の大きさを"タイタニックに同乗している兄弟/配偶者の数"と
# "タイタニックに同乗している親/子供の数"から定義
for dataset in full_data:
    dataset['FamilySize'] = dataset['SibSp'] + dataset['Parch'] + 1

# 家族の有無 0なら家族なし、1なら家族あり
for dataset in full_data:
    dataset['IsAlone'] = 0
    dataset.loc[dataset['FamilySize'] == 1, 'IsAlone'] = 1

# 出港地の欠損値を一番多い"S"としておく
for dataset in full_data:
    dataset['Embarked'] = dataset['Embarked'].fillna('S')

# 料金の欠損値を中央値としておく
# 料金の４グループに分ける
for dataset in full_data:
    dataset['Fare'] = dataset['Fare'].fillna(train['Fare'].median())
train['CategoricalFare'] = pd.qcut(train['Fare'], 4)

# 年齢を５グループに分ける
for dataset in full_data:
    age_avg = dataset['Age'].mean()
    age_std = dataset['Age'].std()
    age_null_count = dataset['Age'].isnull().sum()
    age_null_random_list = np.random.randint(age_avg - age_std, age_avg + age_std, size=age_null_count)
    dataset['Age'][np.isnan(dataset['Age'])] = age_null_random_list
    dataset['Age'] = dataset['Age'].astype(int)
train['CategoricalAge'] = pd.cut(train['Age'], 5)

# 正規表現で姓名を取り出す
def get_title(name):
    title_search = re.search(' ([A-Za-z]+)\.', name)
    if title_search:
        return title_search.group(1)
    return ""

for dataset in full_data:
    dataset['Title'] = dataset['Name'].apply(get_title)

# 誤字修正
    dataset['Title'] = dataset['Title'].replace(['Lady', 'Countess','Capt', 'Col','Don', 'Dr', 'Major', 'Rev', 'Sir', 'Jonkheer', 'Dona'], 'Rare')

    dataset['Title'] = dataset['Title'].replace('Mlle', 'Miss')
    dataset['Title'] = dataset['Title'].replace('Ms', 'Miss')
    dataset['Title'] = dataset['Title'].replace('Mme', 'Mrs')

for dataset in full_data:
    # 性別を2種類にラベル付 女なら０、男なら１
    dataset['Sex'] = dataset['Sex'].map( {'female': 0, 'male': 1} ).astype(int)

    # 敬称を5種類にラベル付
    title_mapping = {"Mr": 1, "Miss": 2, "Mrs": 3, "Master": 4, "Rare": 5}
    dataset['Title'] = dataset['Title'].map(title_mapping)
    dataset['Title'] = dataset['Title'].fillna(0)

    # 出港地の3種類にラベル付
    dataset['Embarked'] = dataset['Embarked'].map( {'S': 0, 'C': 1, 'Q': 2} ).astype(int)

    # 料金を４グループに分ける
    dataset.loc[ dataset['Fare'] <= 7.91, 'Fare']                               = 0
    dataset.loc[(dataset['Fare'] > 7.91) & (dataset['Fare'] <= 14.454), 'Fare'] = 1
    dataset.loc[(dataset['Fare'] > 14.454) & (dataset['Fare'] <= 31), 'Fare']   = 2
    dataset.loc[ dataset['Fare'] > 31, 'Fare']                                  = 3
    dataset['Fare'] = dataset['Fare'].astype(int)

    # 年齢を5グループに分ける
    dataset.loc[ dataset['Age'] <= 16, 'Age']                          = 0
    dataset.loc[(dataset['Age'] > 16) & (dataset['Age'] <= 32), 'Age'] = 1
    dataset.loc[(dataset['Age'] > 32) & (dataset['Age'] <= 48), 'Age'] = 2
    dataset.loc[(dataset['Age'] > 48) & (dataset['Age'] <= 64), 'Age'] = 3
    dataset.loc[ dataset['Age'] > 64, 'Age'] = 4 ;

# 必要ない特徴を削除
drop_elements = ['PassengerId', 'Name', 'Ticket', 'Cabin', 'SibSp']
train = train.drop(drop_elements, axis = 1)
train = train.drop(['CategoricalAge', 'CategoricalFare'], axis = 1)
test  = test.drop(drop_elements, axis = 1)
```



データを読み込み、学習させたいデータと正解ラベルに分ける

```
y_train = train['Survived'].ravel()
x_train = train.drop(['Survived'], axis=1)
x_test = test.values
```

### モデル作成
Gradient Boosting Decision Tree の LightGBMでモデルを作成する。

```
import lightgbm as lgbm
model = lgbm.LGBMClassifier()
model.fit(x_train, y_train)
y_pred = model.predict(x_test)
prediction = np.round(y_pred).astype(int)
```

# Method: Pertial Dependence
元のデータから算出した予測精度と、一つの特徴量を変化させた上で算出した予測精度を比較する
予測精度の減少度合いを見て、多ければ重要な特徴量だと判断することができる

![](https://i.imgur.com/IrGDfJk.png)
↑[機械学習と解釈可能性](https://speakerdeck.com/line_developers/machine-learning-and-interpretability)スライドが簡潔でわかりやすい図示だったため、借用させていただきました。ありがとうございます。


予測の差異に用いたデータをシャッフルして使用するため、再学習させる必要性がなく高速で算出できる


# Result:

### 各特徴量の値によって変動する目的変数(生存可否)の信頼区間を観測する

x軸: 各特徴量の値
y軸: 目的変数の値
薄青: 目的変数の信頼区間
黒丸: 特徴量のプロット点

```
#!pip install pdpbox
from pdpbox import pdp, get_dataset, info_plots
```

```
for column in x_train.columns:
    pdp_goals = pdp.pdp_isolate(model = model,dataset=x_train, model_features=x_train.columns, feature=column);
    pdp.pdp_plot(pdp_goals, column);
    plt.show();
```

pclass チケットクラスが下級になればなるほど、生存にブレが生じる

![](https://i.imgur.com/LpIf9Bc.png)

sex 男性だと生存にブレが生じる

![](https://i.imgur.com/rBqJ2nx.png)

age 子供はほぼ生存が決まっていて、働き盛りの世代になると生存ブレが生じる

![](https://i.imgur.com/ydWTKoI.png)

parch 家族がいるかいないかで生存の閾値が変わる

![](https://i.imgur.com/hhXDa2E.png)

fare 値段が高いと生存ブレが発生

![](https://i.imgur.com/o788H6q.png)

embarked 0は人が多いのでブレがなく、貧困は生存ブレが激しい

![](https://i.imgur.com/wHd5WJu.png)

family size 数が多いと生存ブレが生じる

![](https://i.imgur.com/Q3AnNxd.png)

isalone 家族なしだと生存ブレがないが、家族あると生存ブレが生じる

![](https://i.imgur.com/ICUfT0f.png)

title 敬称が女性だと生存ブレが激しい

![](https://i.imgur.com/LMpo0O1.png)

### 特徴量の組み合わせで目的変数の影響を観測する
暖色(黄色->紫色)ほど正の目的変数(生存可)の値の影響が大きい

```
import itertools
columns = list(x_train.columns)
combinations = list(itertools.combinations(columns, 2))
```

```
for combination in combinations:
    pair_feature = list(combination)
    interaction = pdp.pdp_interact(model=model, dataset=x_train, model_features=x_train.columns, features=pair_feature)
    try:
        pdp.pdp_interact_plot(pdp_interact_out=interaction, feature_names=pair_feature)
    except:
        pass
    plt.show()
```

女性でチケットクラス(金払い)が高いと生き残る
女性だけ見てもどの階級(クラス)でも生存可能性わりかし高く、男性は全体的に生存出来ない傾向
![](https://i.imgur.com/5lyK58k.png)

年齢が若く、クラスが高いと生き残る
クラスが低い場合、年齢の如何問わず生存可能性が落ちている
![](https://i.imgur.com/aZcaNFe.png)

家族がいて、尚且、大家族でなければ生存可能性はあがる。

![](https://i.imgur.com/toFutH6.png)

クラスと家族がいない場合は、あまり結果に影響を及ぼしていない

![](https://i.imgur.com/6GIkpSE.png)

クラスが高く、女性敬称を持つ人間が生存可能性が高い
特に結婚している女性に可能性が高くなっている

![](https://i.imgur.com/StnnCWe.png)


# Discussion:

信頼区間のグラフでは、目的変数の信頼区間が表示されることで、目的変数の如何(生存可否)問わずに、目的変数のターニングポイントになりそうな場所というのが表示された。

特徴量の組み合わせから目的変数の影響をグラフにできることで、ペアでの生存可能性をあげたり下げたりする特徴情報を解釈できるようになった。

ドメイン知識が無いデータセットには、まず簡単な前処理をしてEDAも特にせずにモデル解釈させてから、影響が強そうな新しい特徴量を作る仮説を立てる、というような特徴量エンジニアリングの指標としても優れていそうだなと思う。

# Referances:
https://speakerdeck.com/line_developers/machine-learning-and-interpretability
https://qiita.com/hokuto_HIRANO/items/2c35a81fbc95f0e4b7c1#second-level-predictions-from-the-first-level-output