【論文紹介】Interpretable Predictions of Tree-based Ensembles via Actionable Feature Tweaking【KDD 2017】

どうも,接点QB です.

KDD 2017の論文を漁っていたら,「Interpretable Predictions」というタイトルに目を惹かれて表題の論文を読んだので紹介したいと思います. 機械学習(教師あり)の手法は「予測・分類」を目的としていて,「説明性」に関してはあまり重視されない印象があります. しかし,実際の問題を解決しようとする際には説明性を求められるケースが多いと思います. たとえば,「この人は商品Aを購入しないグループに属する事はわかりました.じゃあ,どうすればこの人に商品Aを買わせることができますか?」といったケースがあります.

今回紹介する論文で提案されている手法は,上記のような例でも対応することが出来ます.

論文の概要

問題設定と前提条件は,

  • 2値分類
  • 使用するのは決定木ベースのアンサンブル分類器

です.そして,論文の目的は

  • negativeに分類されたxを変換して,positiveに分類されるxを作成する

ことです.

Notation

特徴量ベクトル
xXRnE[x]=0,V[x]=1とする.
ラベル
xに対してy{1,+1}=Yが対応する.
特徴量ベクトルとラベルの対応を表す写像
f:XY.分類器はこの写像を推定したものf^
アンサンブル学習器
弱学習器(決定木)T1,,TKのアンサンブル学習器をTとする.

手法

まず注意してほしいのは,本稿で紹介する手法は「分類器を作成すること」ではなく「作成した分類器を使って特徴量ベクトルをより良いものに変換すること」です. なので,分類器Tは既に学習されているとします.

xがtrue negativeすなわちf(x)=f^(x)=1を満たすとします. このとき,取り組むタスク xXsuch thatf^(x)=+1 を作成することです.さらに言えば,変換されたベクトルの中でも何かしらの意味で最良のベクトルを選択するという問題に落とし込むと,求めたいベクトルは一意に定まります:

(1)x=argminx{δ(x,x)|f^(x)=1 and f^(x)=+1}.

(1)でのδは,「何かしらの基準」を測るためのコスト関数です.ユークリッド距離でもコサイン類似度でも,目的によって使い分けるのが良いと思います.

Positive and Negative Paths

決定木の根から葉ノードへの経路は,一つの特徴量xiに関する不等号条件の積み重ねによって形成されます. そして,葉ノードでnegativeかpositiveを分類します.

決定木Tkにおいてxをpositiveと分類するj番目の経路をpk,j+で表します. 簡単のため,pk,jは次のように表せると仮定します: pk,j={(x1θ1),,(xnθn)}. さらに,Pk+=jTkpk,jとします.negativeの場合のPkも同様に定義します.

さて,このパスを指定することで入力空間から特定の領域(pk,j+を指定すればpositiveに分類される領域)を切り出す事ができます. たとえば,x=[x1,x2]という特徴量ベクトルを分類する事を考えます. このとき,1つ目のノードの条件がx1θ1,2つ目のノードでの条件がx2θ2だとすると, これらの条件からXposi={(x1,x2)|x1θ1,x2θ2}という領域がpositiveと分類されるxを含む領域である事がわかります. 図1で緑になっている領域がXposiに対応します.

f:id:setten-QB:20171021204449p:plain

図1

特徴量ベクトルの構成方法

[Tolomei et al., 2016] で提案されている手法は,xから近くて,f^(x)=1となるようなxを発見する方法です.つまり(1)のxを見つけるという問題になるわけですね. その具体的な方法を説明していきます. まずはpk,j+ε-satisfactory instance (2)xj(ε)+[i]={θiεxiθiθi+εxi>θi を定義します. (2) において,xj(ε)+[i]xj(ε)+の第i要素です. また,決定木Tkにおけるxj(ε)+の集合をΓk=jPk+xj(ε)+で定義します. 注意してほしいのは,xj(ε)+Γkは必ずしもf^(xj(ε)+)=+1とはならないことです. あくまでh^k(xj(ε)+)=+1ということしか言えません(h^は決定木kでの予測ラベル).

そして,Γ=k=1KΓk(i.e. すべての決定木(弱学習器)でのε-satisfactory instance の集合)に属し f^(xj(ε)+)=+1を満たすxj(ε)+の中から, δ(x,xj(ε)+)を最小にするものをxとします. つまり, x=argminxj(ε)+Γf^(xj(ε)+)=+1δ(x,xj(ε)+) によって,xを決定します.

xj(ε)+を決定するために探索する範囲は,決定木の数Kとpositiveパスの本数の|Pk+|の積で書けるので O(K|Pk+|)になります.

実装 ―Pythonでやってみる―

擬似コードは論文に記載されているので,ここではPythonで本手法を使ってみたいとおもいます. とりあえずはirisデータでやってみます. これまでの説明は2クラスの分類問題で説明してきましたが,簡単に3クラス問題に拡張できるので,3クラス分類でやりたいと思います.

まずはデータを読み込んで,Random Forestで分類器を作ります.

import numpy as np
import pandas as pd
import scipy.stats
import copy
from sklearn import datasets
from sklearn.ensemble import RandomForestClassifier


iris = datasets.load_iris()
x = iris.data
mean_x = x.mean(axis=0)
std_x = x.std(axis=1)
x = scipy.stats.zcore(x, axis=0)
y = iris.target

rfc = RandomForestClassifier()
rfc.fit(x, y)

次に,必要な関数やパラメータを決めます.

epsilon = 0.1
aim_label = 2
labels = [0, 1, 2]

def cost_func(x, y):
    return np.linalg.norm(x - y)

def value_to_label(value_list):
    """ tree_.valueからlabelを出力する関数 """
    for i in value_list:
        if i != 0:
            label = np.where(value_list==i)[0][0]
        else:
            continue
    return label

ここからが紹介した手法のメインになります. 今回は適当にx[0]を選んで(ラベル0),ラベルを2にすることを目指します.

x_in = x[0]
y_in = y[0]

for estimator in rfc:
    if (rfc.predict(x_in) == estimator.predict(x_in)
        and estimator.predict(x_in) == y_in):
        children_left = estimator.tree_.children_left
        children_right = estimator.tree_.children_right
        feature = estimator.tree_.feature
        threshold = estimator.tree_.threshold
        # 葉ノードのID
        leaf_nodes = np.where(estimator.tree_.feature == -2)[0]
        tmp = estimator.tree_.value[leaf_nodes].reshape(
            len(leaf_nodes), len(labels))
        leaf_labels = [value_to_label(i) for i in tmp]
        positive_leaf_nodes = leaf_nodes[np.where(np.array(leaf_labels) == 2)]
        paths = {}  # パス情報
        for leaf_node in positive_leaf_nodes:
            """
            各葉ノードに対してパスを検索する
            """
            child_node = leaf_node
            parent_node = -100  # initialize
            parent_lefts = []  # 左側親ノード
            parent_rights = []  # 右側親ノード
            while (parent_node != 0):
                if (np.where(children_left == child_node)[0].shape == (0, )):
                    parent_left = -1  # 左側親ノードが存在しない場合は-1
                    parent_right = np.where(
                        children_right == child_node)[0][0]
                    parent_node = parent_right
                elif (
                    np.where(children_right == child_node)[0].shape == (0, )):
                    parent_right = -1  # 右側親ノードが存在しない場合は-1
                    parent_left = np.where(children_left == child_node)[0][0]
                    parent_node = parent_left
                parent_lefts.append(parent_left)
                parent_rights.append(parent_right)
                """ 次のステップへの処理 """
                child_node = parent_node
            paths[leaf_node] = (parent_lefts, parent_rights)

        path_info = {}
        for i in paths:
            # print("Leaf {0}".format(i))
            node_ids = []  # node ids used in the current node
            # inequality symbols used in the current node
            inequality_symbols = []
            thresholds = []  # thretholds used in the current node
            features = []  # features used in the current node
            parents_left, parents_right = paths[i]
            for idx in range(len(parents_left)):
                if (parents_left[idx] != -1):
                    """ the child node is the left child of the parent """
                    node_id = parents_left[idx]  # node id
                    node_ids.append(node_id)
                    inequality_symbols.append(0)
                    thresholds.append(threshold[node_id])
                    features.append(feature[node_id])
                elif (parents_right[idx] != -1):
                    """ the child node is the right child of the parent """
                    node_id = parents_right[idx]
                    node_ids.append(node_id)
                    inequality_symbols.append(1)
                    thresholds.append(threshold[node_id])
                    features.append(feature[node_id])
                path_info[i] = {'node_id': node_ids,
                                'inequality_symbol': inequality_symbols,
                                'threshold': thresholds,
                                'feature': features}
        """
        make epsilon satisfactory instance
        """
        for path in path_info:
            es_instance = copy.deepcopy(x_in)
            for i in range(len(path_info[path]['feature'])):
                """
                loop by node number (this is not equal to the node index)
                """
                # feature index
                feature_idx = path_info[path]['feature'][i]
                # threshold used in the current node
                threshold_value = path_info[path]['threshold'][i]

                if x_in[:, feature_idx] <= threshold_value:
                    es_instance[:, feature_idx] = threshold_value - epsilon
                elif x_in[:, feature_idx] > threshold_value:
                    es_instance[:, feature_idx] = threshold_value + epsilon
                else:
                    print('Something is wrong.')
            if estimator.predict(es_instance) == aim_label:
                if cost_func(x_in, es_instance) < delta_mini:
                    x_out = es_instance
                    delta_mini = cost_func(x_in, es_instance)
x_out = std_x * x_out + mean_x  # transform output to original distribution

これでx_outxj(ε)+が入っています.

見ていただいたら分かるように,ラベル2を出力する葉ノードとそこへ至るパスを取り出すのに結構色々と書いています. scikit-learnでもっと楽にやる方法があったらぜひ教えていただきたいです.

まとめ

よく「ブラックボックス」と言われる機械学習ですが,今回紹介した手法を使うと (どうすればpositiveなラベル方向へ改善出来るのかという事がわかるという意味で)解釈性を上げる事ができます. また,Decision Treeのアンサンブル手法であるRandom ForestやGradient Boostingは非常に手軽に試せる手法で, かつ比較的高精度な分類器を用意に作ることが出来ます.そのような分類器において解釈性の向上が得られるということは, 実際のデータマイニング応用にとても役立つと思われます.