説明モデルSHAP概説+説明モデルを要因分析に使う時のメモ
Twitter始めました。よかったらフォローしてもらえると嬉しいです!
リンク
また、目下勉強中のため、おかしい/違うと思う/わかりにくい点など些細なことでもフィードバックをいただけるととても嬉しいです。
章立ては以下のようになります。
はじめに
昨年のニュースに、AIの判断について企業に説明責任を求めるというものがありました。是非はさておき、AI・人工知能の説明性というトピックは世間一般でも話題になっているように感じます。少し技術目線に移ると、今現在注目を集めている機械学習のモデル解釈手法の一つにSHAP(Shapley Additive exPlanation)があると思います。 論文は2017年NIPSに採択されていたり、kaggleの予測モデルの説明性を扱うコースにもSHAPが盛り込まれています。
またGoogleのサービスではSHAPの元アイデアとなるShapley値を用いてWebページの評価を行なっているとの記載があります。お墨付きのようで少し心強いですね。笑
このようにSHAPは知名度が高いと感じる一方、日本語の情報が少ないと感じていました。そのため記事を書こうと思ったのですが、こちらのメモを見かけ TreeSHAP論文の素晴らしいメモで怖気付き 論文の話は内容が重複すると思ったので、ここではSHAPの概要と実務で要因分析に用いた際に感じたこと・意識した点についてまとめたいと思います。
以下、SHAPの手法で算出される値のことをSHAP値と表現します。なお、私がSHAPを使った経験はTree系モデルのみのため、解説はTree系のモデルに偏っています。
SHAPとは
- ゲーム理論のShapley値が起源
- Shapley値は協力して行うゲームの参加者それぞれに、貢献度を割り振るための手法
- SHAPはShapley値を機械学習モデルに適用できる形にしたもの
- 機械学習では、出力結果にそれぞれの変数がどれだけ影響したかを意味する
- ユースケースは以下の2点
- 予測モデルの出力結果を説明する
- 何かの原因となる要因の分析
- (サンプル数*説明変数の数)の形で出力される
Shapley値とSHAPの関係
シャープレイ値(シャープレイち、英: Shapley value)とは、ゲーム理論において協力によって得られた利得を各プレイヤーへ公正に[1] 分配する方法の一案である。1953年にこの値を導入したロイド・シャープレーを記念して命名された。(Wikipedia引用)
こちらは、SHAPの元アイデアとなるShapley値の説明文です。簡単にいうと複数人で協力して行うゲームのスコアを、それぞれのプレーヤーの貢献度に基づいて割り振る手法です。
SHAPは、このShapley値を機械学習モデルに対して適用できるようにしたものです。論文では、説明モデルに望ましい3つの性質を持つものはただ1つであり、それがShapley値に等しい主張されています。
SHAPにおいては、予測値が複数人で協力して行うゲームのスコアに、説明変数がそれぞれのプレイヤーに、そしてSHAP値がそれぞれのプレーヤーの貢献度に対応します。
つまりSHAP値は、それぞれの説明変数がモデルの予測値にどれだけ影響を与えたかを表します。
ユースケース
kaggleの解釈性についてのコースでは、以下のようなユースケースを挙げています。
A model says a bank shouldn't loan someone money, and the bank is legally required to explain the basis for each loan rejection
A healthcare provider wants to identify what factors are driving each patient's risk of some disease so they can directly address those risk factors with targeted health interventions
拙訳
- モデルが融資を行うべきではないと判断した時に、銀行は融資を断った理由を説明する法的義務がある
- 医療提供者は、ある種の病にかかるリスクを向上させる要因を特定したがる。これにより、彼らはターゲット化した健康的介入をすることで、リスク要因に直接的に対処できるからだ。(すみません、いい訳が思い浮かびませんでした。)
前者は予測モデルの出力を説明する、SHAP本来の使い方です。こちらは冒頭の説明性のトピックに該当します。後者は何かの原因となる要因の分析です。私は実務で主に後者の用途でSHAPを用いました。
SHAPの特徴
続いて、他の主な変数重要度(Gain,Split,Permutationなど)と比較した実用上のSHAPの特徴を3点説明します。
- 入力データセットと同じ形でSHAP値が計算される
- 正負の符号付き
- 他の説明変数との組み合わせで値が決まる
1点目に、SHAPは入力したデータセットと同じ形の行列で数値が返ってきます。モデル全体に対してそれぞれの説明変数がどれだけ効いたかの粒度で出力されるため、SHAPは細かい粒度で説明変数の影響を見ることができます。なお、SHAP値をサンプルの方向に集計することで、Gainなどのような説明変数の変数重要度を算出することが可能です。
2点目に、SHAP値は正負の符号付きで返ってくるため、その説明変数が予測のどちらの向きに効いたかがわかります。
3点目に、SHAPは他の説明変数との組み合わせで値が決まります。ある説明変数Aの値が同じサンプルがあったとしても、他の説明変数との相互作用によって、説明変数AのSHAP値は変化し得ます。例えば若年層に有効な広告があった場合、その広告を見た/見ていないという説明変数は若年層のサンプルには大きなSHAP値となり、高齢層のサンプルには小さなSHAP値となることが考えられます。
上記の特徴は、モデル全体での各説明変数の寄与ではなく、それぞれのサンプルレベルで各説明変数が出力にどう影響を与えたかを説明している点に起因しています。
Shapley値計算法
ここでは、ゲーム理論におけるShapley値の計算法を説明します。
余裕があれば(勉強し直して)SHAPの論文の解説を書こうと思いますが、こちらの計算法を知っておけば大きな問題はないと考えています。
Shapley値の計算式
Shapley値の計算式で表現すると、以下のようになります。
]
N:プレイヤーの全体集合
S:プレイヤーの部分集合
v(S):部分集合Sのプレイヤーたちが参加した時のスコア
n:プレイヤーの総数
計算方法を簡単にいうと、プレイヤーが新たにゲームに加わった時のスコアの増分(貢献度)を、考えられる全てのパターン(順列)に基づいて計算して平均をとることで計算しています。
以下で例を用いて説明します。なお、こちらの説明はshiibassさんの記事を参考にさせていただきました。
例 プレイヤーA, B, Cが参加するゲームのプレイヤーAのShapley値(貢献度)を算出する。
A, B, Cがそれぞれ参加/非参加だった場合のスコアが以下の通りとします。
S(参加したプレイヤー) = スコア
S(0) = 0
S(A) = 20
S(B) = 20
S(C) = 20
S(A,B) = 60
S(A,C) = 110
S(B,C) = 100
S(A,B,C) = 120
プレイヤーが新たにゲームに加わる時のパターンは順列との類推で網羅でき、ここでは
通りとなります。
続いて、順列B -> A -> C を例とすると A参加前にはプレイヤーはBのみで、A参加後にはプレイヤーはA,Bとなるため、この順列でのAの貢献度は
(A参加後のスコア) - (A参加前のスコア)
と計算できます。
この要領で全てのパターンにおけるAの貢献度を計算すると、以下の表のようになります。
プレイヤー順列 | A参加前のスコア | A参加後のスコア | Aの貢献度 |
---|---|---|---|
A -> B -> C | S(0) = 0 | S(A) = 20 | 20 - 0 = 20 |
A -> C -> B | S(0) = 0 | S(A) = 20 | 20 - 0 = 20 |
B -> A -> C | S(B) = 20 | S(A,B) = 60 | 60 - 20 = 40 |
B -> C -> A | S(B, C) = 100 | S(A, B, C) = 120 | 120 - 100 = 20 |
C -> A -> B | S(C) = 20 | S(A, C) = 110 | 110 - 20 = 90 |
C -> B -> A | S(B, C) = 100 | S(A, B, C) = 120 | 120 - 100 = 20 |
最後に、Aの貢献度の平均値を取ると となります。 同様に計算すると、Bの貢献度は30、Cの貢献度は55となり、足し合わせると35 + 30 + 55 = 120となり、S(A,B,C)に一致します。
なお、ここではプレイヤー(説明変数の数)を3人としましたが、実際の機械学習モデルでは説明変数は数百数千となる場合もざらにあると思います。この時、計算量が階乗のオーダーで増えていくと現実的な計算量で収まらなくなるため、TreeSHAPの論文ではそれを高速化するアルゴリズムを採用しているようです。具体的な計算量は、
O(木の本数*モデル中の木の葉の最大数*木の深さ2)のオーダーです。
補足ですが、SHAPはゲーム理論のShapley値を元に
- 予測モデルを加法的線形モデルに変換する
- それぞれの説明変数を考慮する、しないを01のフラグに変換して説明モデルの入力とする
- 考慮しない変数は学習データの分布でその変数を補完して計算する
の工夫を加えたものと私は理解しています
細かい部分を簡潔に説明するのが私には困難なので、余裕があれば(勉強し直して)解説記事を書こうと思います。
(パッケージでできる)可視化例
一番下の可視化を除いて、bostonという住宅の価格のデータセットに対してSHAPを使った例です。データセットの説明はこちらにあります。
特定のサンプルのSHAP値
あるサンプルのそれぞれの説明変数が予測値をどれだけ変動させたかを可視化した図です。
まず、カラーバーの上の文字の説明から。base valueは全ての変数を考慮していない時の予測値で、学習データの目的変数の平均値となります。そしてmodel outputがそのサンプルの予測値です。
続いてカラーバーの説明です。ピンクが+方向の寄与を、青がー方向の寄与を表しています。例えば、このサンプルではLSTAT(低所得者の割合)の変数を考慮した場合としなかった場合で比較して予測値が4.98変動している(ここでは考慮することで上昇している)と見ます。
そして、base valueと全説明変数のSHAP値を足し合わせるとmodel outputになります。1
全サンプルのSHAP値
一つ目の図は、全サンプルのSHAP値を各説明変数ごとにプロットしたものです。色が説明変数の値の大小を、プロットの位置がSHAP値の大小を表します。例えば、LS
TAT(低所得者の割合)が低い変数の方が住宅価格の予測値が高くなる傾向にある事が図から読み取れます。
縦に長く伸びる形でプロットされている部分は、その辺りのSHAP値をとるサンプルが多くあることを意味します。
二つ目の図は、一番上の図を全サンプル分可視化したものです。さらにSHAP値の類似度ごとに並べ替えることが可能で、そのため上の図はいくつかのグループに分けられそうな形状をしています。
それぞれの説明変数の変数重要度
Tree系モデルのデフォルトで出力できる一般的な重要変数と同じ形式です。
SAHP値の絶対値の平均値を大きい順に並べたものです。
2変数とSHAP値の関係
2変数とSHAP値を全サンプル分プロットしたものです。図では、色がRAD、左右がRM、上下がRMのSHAP値を表しています。図を見るとRM=7.3辺りを境にSHAP値が大きく変化しているのがわかります。推測ですが、おそらく予測モデル内で木の分岐がそのRM=7.3辺りで起こり、どちらの分岐に入ったかで予測値が大きく変化していると考えられます。
2つの説明変数の交互作用効果
こちらは、2つの説明変数の交互作用効果をプロットしたものです。図を行列と見た時、対角線上のプロット群はその変数の主効果を、対角線以外のプロットは交互作用効果を表現しているようです。色は行方向の変数の値の大小を、左右が交互作用効果を表現しています。
説明モデルで要因分析する際に気づいた/意識した点
この章は特に、思うところがあればフィードバックをいただけると嬉しいです。
そして、SHAPに特化した話より説明モデルの一般的な内容が多いことに気づいたので、タイトルを変えました。以下ではSHAP値で説明していますが、ご承知おきください。
- 予測モデルの精度が高いことが望ましい
- SHAP値がばらつくことがあるので、バリアンスを小さくする工夫を行ったほうがいい
- 予測モデルの出力を説明する手法なので、因果関係を考慮しているわけではない
予測モデルの精度が高いことが望ましい
SHAP値はあくまでも予測モデルの出力結果に対して変数がどの程度効いたかを算出する手法です。なので、予測モデルの精度が低い場合、その説明モデルの出力であるSHAP値自体の真のモデルに対する信頼性も低いと考えられます。そのため、構築した予測モデルがある程度の精度を持つことが望ましいです。
SHAP値がばらつくことがあるので、バリアンスを小さくする工夫を行ったほうがいい
信頼性の観点から、なるべく出力値のバリアンスを下げる工夫を行った方がよいと思います。特にTree系モデルの問題なのですが、入力するデータセットの違いによってモデルの構造が大きく変化します。シードを変えて複数回CV(cross validation)してSHAP値を計算する実験を行ってみると、同じサンプルでもSHAP値が割とバラついたりします。そのため、シードを変えて複数回CVを行なって結果を平均するなどバリアンスを小さくする処理を行った方が良いと思います。
予測モデルの出力を説明する手法なので、因果関係を考慮しているわけではない
繰り返しですが、SHAP値はあくまで予測モデルの出力結果を解釈する手法です。そのため、説明変数と予測値になんらかの傾向が見えたとしても、それがイコール要因を表す訳ではありません。 以下、引用です。
サンゴとその捕食者の例を採り上げてみます(この話はこのtogetterの内容を基にしていますが、本記事ではあくまでも説明のための仮想例としてディテールは無視して取り扱います)。
サンゴの保全のための調査から、サンゴの生存率とサンゴの捕食者Oの個体数に以下の相関関係が示されているとしましょう。また、捕食者Oは実際にサンゴを捕食していることがフィールドでの観察から分かっているとします。
このとき、「捕食者Oの増加→サンゴの生存率の減少」という因果関係を想起するのは自然なことかもしれません。
もしこのような因果関係が存在するならば、「捕食者O」を減少させることにより「サンゴの生存率」を増加させることができそうです。
はてさてしかしながら:
より詳細な調査から、「捕食者Oは死にかけのサンゴしか食べない」ことが分かってきたとします。このとき、捕食者Oは実は生態系の中でスカベンジャー的役割を果たしていたということになります。
こうなると、「サンゴの生存率が低下→スカベンジャーである捕食者Oが増加」という逆の因果が真である可能性も出てきます。もしこの形の因果が真ならば、「捕食者Oを減少させることによりサンゴの生存率を増加させる」という保全施策は全く効果を及ぼさないことになります。(むしろ、スカベンジャーを排除することによりサンゴの健全な新陳代謝が妨げられる可能性さえあるかもしれません)
そして、このどちらの「因果の向き」がより真に近いのかは、基本的には現場での観察 and/or 介入によってしか明らかにすることはできません*7。
このように、因果関係があると思っていると実は因果が逆かもしれないという例は、ビジネスの現場でもあります。他にも、ある変数と目的変数に共通の要因となる交絡因子が絡んでくるなど、因果関係と混同するような変数間の関係性は色々あります。そのため、結果を鵜呑みにせず、データの生成過程を推測し変数同士の背後にある関係性を整理することで、今見ているものが何かを人が判断する必要があります。2因果関係にまつわる変数間の関係性については、詳しくは引用元のページで説明なされています。
余談ですが、複雑な因果関係を整理するフレームワークとして上司の方からシステムシンキングの本を紹介されました。3私は現状あまり使えていないのですが、とてもわかりやすくおすすめです。
おまけ 説明モデルを要因分析に使う際の考察
以下、簡単のため学習データの評価指標値をTS(Train Score)、検証データの評価指標値をVS(Validation Score)と表現します。
これは私の中の仮説なのですが、説明モデルで説明変数が目的変数に与える要因分析を行う場合、予測モデルは必ずしもVSが最大になる(early stopping)まで学習させない方が良いのではないかと考えています。一般にVSが最大になるまでモデルを学習させた場合、TSとVSは乖離するため、モデルがそれなりにノイズを拾っていると考えられるからです。
学習途中で定期的に評価指標をプロットしてみると、最初の方はTSとVSが同じ形で伸びていきますが、徐々にTSの方がよくなり、最終的にTSが改善し続ける一方VSの方は指標が悪化し始めます。 この過程は、自分の頭の中では下のようにイメージしています。
懸念として持っているのは、VSが最大になるまで学習を進めると学習データのノイズも相応に含んだモデルとなってしまうのではないかという点です。 そのため、要因を分析する目的で説明モデルを用いる場合は、必ずしもVSを最大にするような学習で構築したモデルが適切ではないのではないか、と考えています。
上記の説明は、VSが上がりきるまで学習させたモデルと、TSとVSの乖離が少ない学習途中のモデルのどちらが真のモデルに近いか、という話に帰着すると思います。
こちらについては、そのうち検証したいと考えています。しかし、どうすれば検証したことになるか難しい。。そもそもデータやモデル依存な部分もありますし。。学習の初期・中盤・終盤で、モデルで分岐に使用される変数やSHAP値がどう変化するかを調べたり、いい感じのシミュレーションデータがあれば、そのSHAP値と実際の目的変数に与えている貢献度を比較できる、などでしょうか。 良い方法があったり、もしくは近そうな論文などご存知の方がいらっしゃったら教えていただければ幸いです...!
ただ、DART(勾配ブースティングのモードの1つ)の論文では学習終盤に構築した木は学習序盤の木と比べてモデル全体に与える影響が小さいことが問題点として指摘されています。そのため、学習終盤の木がSHAP値に与える影響も大きくはないのかもしれませんが。
ここまで書いて今回は力尽きたので、SHAPを実際に動かす内容は次回以降に持ち越します。
文献リスト
SHAPに関する主な論文リストとパッケージは以下の通りです。私はSHAP総論とTreeSHAPしか読めていません。
lightgbm カテゴリカル変数と欠損値の扱いについて+α
一発目から自由研究をしていないのですが、ご容赦ください。笑
lightgbmのカテゴリカル変数の扱い等がチーム内で話題になったため、メモも兼ねてまとめました。
本題
話題となったのは、以下の3点です。
- label encoding1して入力するのと、カテゴリカル変数として入力する違い
- trainに存在しないが、testには存在するカテゴリーの扱い
- 欠損値の扱い
これより、順に説明します。
1. label encoding[^1]して入力するのと、カテゴリカル変数として入力する違い
label encodingして入力すると、普通の数値型変数と同様に閾値との大小関係で判定されます。カテゴリカル変数として入力すると、marugariさんがブログで紹介しているように変数A (is or is not) category_xで判定されるようです。ただ、カテゴリーに順序性がある場合は、カテゴリカル変数として扱うのはイマイチの場合があるとのこと。2 そのため、カテゴリカル変数でも順序性がある特徴はlabel encodingで、順序性がない特徴はカテゴリカル変数として入力するのが良さそうです。
下の画像は、カテゴリカル変数を持つデータで学習後に、graphvizを使って木を可視化したものです。閾値の部分がis, is notで分岐していることがわかります。
2. trainに存在しないが、testには存在するカテゴリーの扱い
is, is notで判定する仕様のため、testにのみ存在するカテゴリーは全ての分岐でis not側に振り分けられます。そのためコンペなどでは問題ないのですが、実務ではちょっとした落とし穴があります。
カテゴリー型をlightgbmの入力とする方法は以下の2種類が主流だと思います。
- scikit-learnのLebelEncoderを使い、学習時にカテゴリカル変数としてカラムを指定する
- pandasのカテゴリー型に変換して、モデルに入れる
ここで、実務で学習・予測データを別々に処理する必要がある場合は、LabelEncoderの出力番号が学習・予測データでずれてしまう場合があります。一方pandasのカテゴリー型では、元のカテゴリーの情報を保持して適切に扱ってくれるようです。そのため、問題がなければpandasのカテゴリー型に変換して学習させるのが良いと考えます。
以下、しばらくLabelEncoderとpandasのカテゴリー型の検証が続くので、興味のない人は飛ばしてください。
LabelEncoder + 入力時にカテゴリカル指定
設定
カテゴリカル変数'cat'がcなら1, それ以外なら0のデータを予測
学習データのカテゴリカル変数は'a' or 'c'
テストデータのカテゴリカル変数は'a' or 'b'のため、予測結果は0になることが期待される
>>> c1_train = np.random.choice(['a', 'c'], 100) >>> c2_train = np.random.rand(100) >>> c1_test = np.repeat(['b', 'a'], 1) >>> c2_test = np.random.rand(2) >>> columns = ['cat', 'rand'] >>> X_train = pd.DataFrame(np.c_[c1_train, c2_train], columns=columns) >>> y_train = np.where(X_train.iloc[:, 0] == 'c', 1, 0) >>> X_test = pd.DataFrame(np.c_[c1_test, c2_test], columns=columns)
label encodingの処理
>>> le = LabelEncoder() >>> X_train.cat = le.fit_transform(X_train.cat) >>> X_test.cat = le.fit_transform(X_test.cat) >>> X_train.rand = X_train.rand.astype('float')
モデル学習と予測
>>> X_data = lgb.Dataset(X_train, y_train, categorical_feature=['cat']) >>> params = {'n_estimators': 1, 'learning_rate':1} >>> model = lgb.train(params=params, train_set=X_data) >>> model.predict(X_test) array([ 1.00000001e+00, -2.14576721e-08]) # カテゴリー変数がbなのに、予測クラスが1
pandasでカテゴリー型に変換
>>> c1_train = np.random.choice(['a', 'c'], 100) >>> c2_train = np.random.rand(100) >>> c1_test = np.repeat(['b', 'a'], 1) >>> c2_test = np.random.rand(2) >>> columns = ['cat', 'rand'] >>> X_train = pd.DataFrame(np.c_[c1_train, c2_train], columns=columns) >>> y_train = np.where(X_train.iloc[:, 0] == 'c', 1, 0) >>> X_test = pd.DataFrame(np.c_[c1_test, c2_test], columns=columns)
category型への変換
>>> X_train.cat = X_train.cat.astype('category') >>> X_train.rand = X_train.rand.astype('float') >>> X_test.cat = X_test.cat.astype('category') >>> X_test.rand = X_test.rand.astype('float')
モデル学習と予測
>>> X_data = lgb.Dataset(X_train, y_train) >>> params = {'n_estimators': 1, 'learning_rate':1} >>> model = lgb.train(params=params, train_set=X_data) >>> model.predict(X_test) array([7.15255732e-09, 7.15255732e-09]) # 予測クラスがどちらも0
3. 欠損値の扱い
lightgbmでは、欠損値を一度無視して分割を探索した後に、よりロスが下がるほうの分岐に欠損値を振り分けるようです。3 そのため、例えばその変数が欠損値であるという情報が重要な場合は、明示的にその変数の最大値、最小値の外側の値を代入するなどで区別がつくようにした方が良いと思います。 また、zero_as_missingパラメータをTrueにすると、0もnullと同様に無視をして分割を探索してから、あとで振り分けるようになるとのこと。
まとめ
- label encodingは普通の数値型変数として扱われる。カテゴリカル変数として入力すると分岐がis, is notで判定される
- trainに存在しないが、testには存在するカテゴリーの扱いはis, is notで判定されるため基本は問題ない。しかし、実務などで予測データのみをLabelEncoderで変換する場合は注意が必要。
- 欠損値の扱いは、一度無視して分岐を探索後、よりロスが下がるほうに振り分ける。
おまけ
カテゴリカル変数はmax binの制約がかかるのか。
githubのdisucussionを要約すると、カテゴリー型の変数はmax_binの最大値を下回る場合は全カテゴリーが採用され、上回る場合は頻度の高い順から全データの99%を占めるまでに登場する全カテゴリーが採用されるようです。そのため、high cardinalityな変数ではかなり多数のカテゴリーとなるケースがありそうですね。