GitHubコードから読み解く!DecisionTreeClassifier@scikit-learnのfeature_importances_計算方法

Taketo Kimura
MICIN Developers
Published in
23 min readJan 20, 2019

はじめに

昨今の人工知能ブームにて、機械学習によるプロダクトやサービスが、珍しくなくなってきました。
従来、人間が下してきた判断を、機械が代替することで、自動化を促す形です。
自動運転の歩行者識別や、スマホの顔認証などが、それですね。
人間の判断は、次々と機械に置き換えられていくことでしょう。

尚、機械学習製品のリリースには2つの壁があります。
それは、「精度の高さ」と「説明性の高さ」です。
また、それらには、一般にトレードオフの関係性があります。
共にクリアできれば問題ないですが、難しい場合は、そのトレードオフと向き合わなくてはなりません。
製品の目的を意識して、調整する必要があるのです。

以下は、よく知られた記事で、「もしも、火災保険の引受査定をAIが行ったら」というものです。
絵の女性は、保険加入をDENIED(拒否)されてしまいました。
理由を尋ねますが、AIは「私の判断アルゴリズムは、説明性の低いものですので何とも…。」と答えます。
果たしてユーザーは、それで納得するでしょうか?
答えは当然NOですよね。

最も直感的で、説明性の高い機械学習手法「決定木」

ここで、紹介したいのは「決定木」という機械学習手法と、その説明性についてです。

決定木は、幾つかの条件分岐を経ることにより、判断を下すアルゴリズムです。
先ほどの火災保険引受を判断する決定木を作るとすると、例えば、以下のようになります。

図から、女性が査定を通らなかったのは…

① 海岸までの距離が100m未満であったから
② 海抜が3m未満であったから

という理由からだと分かります。
判断基準が正当性はさておき、説明性は高いですね。

決定木は、このように判断基準がハッキリした、説明性の高いアルゴリズムとなります。

決定木は、どの項目が効いて判断に至ったかを「feature_importance_(重要度)」で示す

先ほどの決定木の例ですと、パッと見で判断基準が把握できますが、例えば次のパターンはどうでしょう?

scikit-learnにてロードが可能なbreast-cancerデータに対して、scikit-learnのDecisionTreeClassifierで学習した決定木の、分岐条件を可視化したものです。
各Boxには、分岐条件や、その条件が学習データにおいてもたらした結果などが記載されています。
尚、最下段以外の各Boxに記載されている、「≦」記号で表現されるものが、分割条件です。
その条件を満たすデータは左に、満たさないデータは右にと流れていきます。

この位の複雑さで、割と判断条件の把握が難しくなってきますね。
追うのが一苦労です。
決定木は、解こうとする問題によっては、10段以上もの条件を積み重ねる必要があったりもしますので、そうなると更に大変です。

試しに、breast-cancerデータに対して、判断の精度を高めるために、もう少し条件を複雑にしてみましょう。

うーん、これを解釈しようとすると、気が遠くなってきますね…。
さて、どう整理すると、判断基準が上手く解釈できるでしょうか…。

ここで役立つのが、scikit-learnのDecisionTreeClassifierクラスに組み込まれている「feature_importances_(重要度)」の計算機能です。
判断の精度向上に対して、どの説明変数が、どの位の貢献をしたであろうかを、定量的に示してくれるものです。
その定量は、決定木学習時の収集情報から、計算します。

例えば、先程紹介した、条件が最大で7段存在するものですと、下記のような「feature_importances_」の図となります。

breast cancerのデータにおいて存在する説明変数は、30種存在しますが、その各種毎に、貢献度合いが割り振られます。
貢献度合いは、貢献割合として求められ、合計値が1.0になるようスケーリングされています。

こうして、割合で示してくれると、とても分かりやすいですね。
判断根拠が分かると、予測器への信頼も高まるかと思います。

「feature_importances_」の計算方法を抽象的に理解する

さて、上記の便利な指標ですが、これはどうやって求められているか。
先ずは、抽象的に説明します。

「feature_importances_」は、breast-cancerか否かの判断に、寄与している説明変数ほど高い値が示されます。
その計算は…

① 上段にある説明変数ほど高くなる
② 登場回数が多い条件項目が高い

という根拠の下に計算されています。

①については、条件が多段になっている場合の、より上段での条件に使用される説明変数ほど、重要度が高くなるという意味です。
決定木は、より効率の良い分割条件を探索しながら、上段から下段に向けて、分割条件を重ねていくアルゴリズムです。
よって、上段から下段に条件推移していくに連れて、条件適用のデータ数が段々と少なくなっていく形となります。
つまり、上段の方が、より多くのデータを精度高く分割する、マクロ向きの条件が設定されています。
逆に、下段の条件は、一部のデータに効果的な、ミクロ向きな条件であるとも言えます。
そうした場合、全体の分割に寄与しているのは、上段の条件と言えるでしょう。
その為、より上段にある条件項目の方が重要度が高くなるのです。

②については、複数の条件がある内、より多くの条件に使用される説明変数ほど、重要度が高くなるという意味です。
重要度の計算としては、先ず、各条件について、重要度(分割貢献度)を計算します。
次に、条件への使用が重複する項目については、重要度の合算を行います。
尚、各条件毎の重要度は全て、ZERO以上の値が算出されます。
よって、登場回数が多いほど、多く合算されて、重要度が大きくなりやすいのです。
その為、仮に、決定木上の全ての条件が、ある1種類の説明変数だけでまかなわれてしまった場合、その説明変数の「feature_importanes_」値が1.0となり、他の説明変数の重要度がZEROとなります。

以上が、「feature_importanes_」の抽象的な説明となります。
尚、この「feature_importanes_」の計算アルゴリズムは、決して絶対的に正しい指標という訳ではありません。
あくまでも、ヒューリスティックに考え出された形でしょう。
例えば、お客さんに対して、この指標を報告するような場合は、そういった前提を忘れずに、慎重に向き合うと良いかと思います。

「feature_importances_」の計算方法を具体的に理解する

さて、次に、「feature_importances_」の計算方法を具体的に説明させていただこうと思います。
尚、計算方法は色々な流儀があるようで、今回はあくまで、scikit-learnのソースコードに実装されているものを紹介します。

scikit-learnでの計算方法は、以下となります。

【feature_importances_の計算方法】
① 条件分割前のデータ集合について、「不純度」を計算する
② ①に条件分割前のデータ集合のデータ数を掛ける
③ 「項目X ≦ 値XX.XX」条件を満たすデータ集合について、「不純度」を計算する
④ ③に「項目X ≦ 値XX.XX」条件を満たすデータ集合のデータ数を掛ける
⑤ 「項目X > 値XX.XX」条件を満たすデータ集合について、「不純度」を計算する
⑥ ⑤に「項目X > 値XX.XX」条件を満たすデータ集合のデータ数を掛ける
⑦ 「②-④-⑥」を計算し、その値を条件の対象となっている説明変数に加算していく
(※加算前は、各説明変数について、ZEROポイントから開始する)
⑧ ①〜⑦までの計算工程を、全条件に対して実施しながら、加算を繰り返す
⑨ ⑧の各説明変数毎の加算結果を、全説明変数の加算ポイント合計で割る
(※そうして求められた各説明変数毎のポイントは、合計が1となる)

以上。

「不純度」については、条件適用対象データにおける目的変数の値(今回の例だと、breast cancerか否か)の混ざり具合を、定量的に示す指標のことです。
数値が低いほど混ざりが少なく、数値が高いほど混ざりが多い、という風な感じです。
最も「不純度」が低くなるのは、グループデータ内に1種類の目的変数値のみが存在する状態です。
つまり、決定木の分岐条件は、条件を経て、極力「不純度」が低い分割を行っていく形です。

尚、scikit-learnのDecisionTreeClassifierクラスにおいては、「不純度」が「Gini係数」や「Cross Entropy」などから選ぶことができます。
一般的には(Default設定では)、「Gini係数」が用いられます。

尚、Gini係数の計算方法は下記となります。

【Gini係数の計算方法】

kは、目的変数の持つ値の種類数、つまり、識別のクラス数を示します。

例えば、2クラスの属性を持つデータが、100件あったとします。
内訳は、Aクラスのデータが70件、Bクラスのデータが30件だったとします。
すると、Gini係数で説明される不純度は、「1-(70/100)²-(30/100)²=0.42」となります。

もし、Aクラスのデータ数が85件、Bクラスのデータ15件だったとすると、Gini係数の値は「1-(85/100)²-(15/100)²=0.255」です。
Aクラスのデータ数が100件、Bクラスのデータ0件ならば、「1-(100/100)²-(0/100)²=0」です。
Aクラスのデータ数が50件、Bクラスのデータ50件ならば、「1-(50/100)²-(50/100)²=0.5」です。

属性別のデータ数が均衡している程、不純度が高くなり。
一方の属性に片寄っている程、不純度が低くなる。
そんな計算式となっています。

「feature_importances_」の計算方法を厳密に再現してみる

さて、上記の具体的な計算方法を、実装してみようと思います。

プログラムは、幾つかのパート別に説明を実施していきます。
ご自分のPC上で実行してみようという方は、恐縮ですが、パート別のプログラムを、最終的に連結する形で、コピー踏襲していただければと思います。

【パート1:データ準備】
先ずは、ライブラリのインポートから、データ読み込みまで実施するプログラムです。

# import lib
import numpy as np
import matplotlib.pyplot as plt
import graphviz
import pydotplus as pdp
from sklearn import tree
from sklearn.datasets import load_breast_cancer
# data load
data = load_breast_cancer()
X = data.data
y = data.target[:, np.newaxis]
X_title = data.feature_names
# check
print('np.shape(X) = [%d, %d]' % np.shape(X))
print('np.shape(y) = [%d, %d]' % np.shape(y))
print('len(X_title) = [%d]' % len(X_title))
print('np.unique(y) = %s' % np.unique(y))

上記コード実施によって、以下ログが出力されます。

np.shape(X)  = [569, 30]
np.shape(y) = [569, 1]
len(X_title) = [30]
np.unique(y) = [0 1]

scikit-learnに保持されている「breast cancer」か否かを識別するための2クラスのデータで、件数が569件、説明変数の種類数が30列の構成となっています。

【パート2:決定木学習と「feature_importances_」確認】
次に、ロードしたデータを決定木で学習し、「feature_importance_」を可視化します。

model = tree.DecisionTreeClassifier(random_state=0, max_depth=10)
model = model.fit(X, y)
feat_imp = model.feature_importances_
sort_idx = np.argsort(-feat_imp)
plt.figure(figsize=(12,5),dpi=100)
plt.rcParams["font.size"] = 20
plt.bar(np.arange(len(sort_idx)), feat_imp[sort_idx])
plt.ylim(-0.05, 1.05)
plt.xlim(-0.9, (len(sort_idx) -0.1))
plt.xticks(np.arange(len(sort_idx)))
pld.gca().set_xticklabels(X_title[sort_idx], rotation=90)
plt.ylabel('feature_importances_')
plt.grid()
plt.show()

「feature_importances_」が、バーグラフで可視化されました。
先に説明したように、30項目についての「feature_importances_」値を合計すると1.0になるようにスケールされている為、グラフは必ず0から1の間の値域に収まります。

【パート3:「不純度」等の計算の再現】
次に、「feature_importances_」を計算する前に、その計算元となる値を計算してみたいと思います。
ここからが本題です。
ロードしたデータと、モデルが保持している分割条件から、学習時の集計情報を再現してみます。

以下が整理です。

(再現計算の元となるINPUT変数)
① model.tree_.node_count:条件の数(「2^(深さ+1)-1」で求まる値、条件の無いノードも含む)
② model.tree_.feature:条件対象項目を条件毎に管理
③ model.tree_.threshold:条件値を条件毎に管理
④ model.tree_.children_left:条件を満たした場合の、次の条件インデックスを管理
⑤ model.tree_.children_right:条件を満たさなかった場合の、次の条件インデックスを管理

(再現計算の対象となるOUTPUT変数)
⑥ model.tree_.impurity:学習時に、学習データより算出された「不純度」値を管理
⑦ model.tree_.n_node_samples:学習時に、学習データより算出された条件分岐前後のデータ数を管理
⑧ model.tree_.value:学習時に、学習データより算出された条件毎の満たしたデータ数/満たさなかったデータ数を管理

より理解を深めるための補足計算になります。
尚、「不純度」値はGini係数により求めます。

# prepare variable to stock result of reproduction
class_num = len(np.unique(y))
n_node_samples_rep = np.zeros(model.tree_.node_count)
impurity_rep = np.zeros(model.tree_.node_count)
value_rep = np.zeros([model.tree_.node_count, class_num])
y_work = -np.ones([len(y), model.tree_.node_count])
# reproduction at top node
n_node_samples_rep[0] = len(y)
for class_i in range(class_num):
value_rep[0, class_i] = np.sum(y == class_i)
impurity_rep[0] = 1
for class_i in range(class_num):
impurity_rep[0] -= ((value_rep[0, class_i] / len(y)) ** 2)
y_work[:, 0] = y.ravel()
# reproduction at all node
for node_i in range(model.tree_.node_count):

y_tmp = y_work[:, node_i].copy()

if (model.tree_.children_left[node_i] > 0):

feature_tmp = model.tree_.feature[node_i]
threshold_tmp = model.tree_.threshold[node_i]

X_tmp = X[:, feature_tmp]
y_left_idx = (X_tmp <= threshold_tmp)
y_right_idx = (X_tmp > threshold_tmp)

y_left = y_tmp.copy()
y_left[y_right_idx] = -1

y_right = y_tmp.copy()
y_right[y_left_idx] = -1

node_j = model.tree_.children_left[node_i]
node_k = model.tree_.children_right[node_i]

n_node_samples_rep[node_j] = np.sum(y_left >= 0)
n_node_samples_rep[node_k] = np.sum(y_right >= 0)

for class_i in range(class_num):
value_rep[node_j, class_i] = np.sum(y_left == class_i)
value_rep[node_k, class_i] = np.sum(y_right == class_i)

impurity_rep[node_j] = 1
impurity_rep[node_k] = 1
for class_i in range(class_num):
impurity_rep[node_j] -= ((value_rep[node_j, class_i] / n_node_samples_rep[node_j]) ** 2)
impurity_rep[node_k] -= ((value_rep[node_k, class_i] / n_node_samples_rep[node_k]) ** 2)

y_work[:, node_j] = y_left.ravel()
y_work[:, node_k] = y_right.ravel()
# check value
print('model.tree_.impurity = \n%s' % model.tree_.impurity)
print('')
print('impurity_rep = \n%s' % impurity_rep)
print('')
print('model.tree_.n_node_samples = \n%s' % model.tree_.n_node_samples)
print('')
print('n_node_samples_rep = \n%s' % n_node_samples_rep.astype('int'))
print('')
print('model.tree_.value = \n%s' % model.tree_.value.reshape([np.shape(model.tree_.value)[0], np.shape(model.tree_.value)[2]]))
print('')
print('value_rep = \n%s' % value_rep)
# check diff
print()
print('diff of impurity = %.8f' % np.sum(np.abs(model.tree_.impurity - impurity_rep)))
print('diff of n_node_samples = %.8f' % np.sum(np.abs(model.tree_.n_node_samples - n_node_samples_rep)))
print('diff of value = %.8f' % np.sum(np.abs(model.tree_.value - value_rep[:, np.newaxis, :])))

最後のprint文によって、以下のLOGが確認できると思います。

diff of impurity       = 0.00000000
diff of n_node_samples = 0.00000000
diff of value = 0.00000000

scikit-learnの算出値と、再現値に差分が無いことが確認できました。

【パート4:「feature_importances_」の計算の再現】
最後に、「feature_importances_」を計算してみたいと思います。
パート3で行った計算よりも単純です。

feat_imp = np.zeros([np.shape(X)[1]])for node_i in range(model.tree_.node_count):

if (model.tree_.children_left[node_i] > 0):
node_j = model.tree_.children_left[node_i]
node_k = model.tree_.children_right[node_i]

root_n_node_samples = model.tree_.n_node_samples[node_i]
left_n_node_samples = model.tree_.n_node_samples[node_j]
right_n_node_samples = model.tree_.n_node_samples[node_k]

root_impurity = model.tree_.impurity[node_i]
left_impurity = model.tree_.impurity[node_j]
right_impurity = model.tree_.impurity[node_k]

root_feature = model.tree_.feature[node_i]

feature_importances_rep[root_feature] += ((root_n_node_samples * root_impurity ) -
(left_n_node_samples * left_impurity ) -
(right_n_node_samples * right_impurity))

normalizer = np.sum(feature_importances_rep)
if normalizer > 0.0:
# Avoid dividing by zero (e.g., when root is pure)
feature_importances_rep /= normalizer
# check value
print()
print('model.feature_importances_ = \n%s' % model.feature_importances_)
print()
print('feature_importances_rep = \n%s' % feature_importances_rep)
# check diff
print()
print('diff of feature_importances_ = %.8f' % np.sum(np.abs(model.feature_importances_ - feature_importances_rep)))

最後のprint文にて、以下のログが出力されると思います。

diff of feature_importances_ = 0.00000000

これにて、「feature_importances_」の計算が再現できました。
(お付き合いいただいた方、ご苦労さまでございました…。🙇‍♂💦)

おまけ

ちなみに、上記コードの後に、下記コードを追加すると、決定木の木構造を可視化することができます。

(Jupyter上で見たい場合)

dot_data = tree.export_graphviz(model, 
out_file=None,
feature_names=X_title,
class_names='target',
filled=True,
rounded=True,
special_characters=True)
graph = graphviz.Source(dot_data)
graph

(画像ファイルに出力したい場合)

dot_data = tree.export_graphviz(model, 
out_file=None,
feature_names=X_title,
class_names='target',
filled=True,
rounded=True,
special_characters=True)graph = pdp.graph_from_dot_data(dot_data)
graph.write_png('./graph.png')

おわりに

以上で、DecisionTreeClassifier@scikit-learnにおける、「feature_importances_」値の計算方法を説明となります。
ここまで読んで下さった方、本当にありがとうございます。

個人的に、人が書いたコードをリバースして、キッチリ確認するのは気持ちが良いです…。
今後、他アルゴリズムでもやっていこうと思います。

P.S

記事の修正を指摘してくれた同僚の土屋さんに感謝します。🙇‍♂

--

--