雑記 in hibernation

頭の整理と備忘録

知る人ぞ知る(?)WOE変換をフックアップ

統計・機械学習における変数変換の手法の一つに”WOE(Weight of Evidence)変換”という方法があります。金融工学の世界で好んで利用される手法らしいですが、他分野の方にはいまいち耳馴染みがないワードだと思います。ということで、「WOE変換なんて聞いたことないよ」という方に向けて、手法の実装を紹介したいと思います。自分の頭の整理・備忘録も兼ねます。

1. WOE変換とは

変換対象となるカテゴリの説明変数に対し、目的変数の分布を元にカテゴリ値を割り当てる手法です。ターゲットエンコーディングに近いイメージですね(ターゲットエンコーディングとの違いについては、次回の記事で描くかもです)。

連続値の変数に対しては基本的にビニングとセットで利用されることが多く、統計ソフトのSASではビニングプロシージャ内で離散化からカテゴリ値の割り当てまでやってくれちゃう上に、離散化の調整や評価にWOE値(もしくはIV:IVについては、ここでは解説しません)の情報を使っていることもあり、どこまでがWOE変換の範囲なのかと考えると若干ややこしいです。が、WOE変換の厳密な対象範囲としては「水準にカテゴリ値を割り当てる」部分(のはず)です。WOE変換は主に分類問題で利用されます(誤りがあればご指摘願います)。

2. WOE変換の使いどころ

目的変数に対して単調増加でない説明変数を考えます。例えば、個人顧客の何かしらの債権に対するデフォルト予測モデルを作るとしましょう(正しく金融工学的な例題ですね)。そして、モデルの構築にあたり説明変数に顧客の年齢層を採用することを検討しているとします。年齢層に対してPD(probability of default:デフォルト率)が線形に単調増加(減少)する関係であれば、リスク判別モデルの採用変数としては望ましいところですが、データ分析の現実はそれほど甘くありません。実際に得られた年齢層に対するPDが20代前半と30代前半に局所的なピークをもつ双峰の分布だったとします。この変数の年齢層の値をそのまま利用してロジスティック回帰等の線形モデルでモデリングしようとした場合、リスクと年齢層の関係を「年齢層が増加(減少)するほどリスクが増加する」と見做して表現することになり、実際のリスク分布を正しくモデルに反映できているといえません。さて、どうしたものか(決定木とか非線形のモデル使えば良くね?は一旦無しにしてください、、、)。こんな時に活躍するのがWOE変換です。

まず対象の変数が連続値である場合、グループ化してカテゴリ変数とします(「年齢層」の場合はすでにある年齢帯をグループ化したカテゴリ変数になっていますが、例えば変数が「年齢」であった場合は、年齢層や年代といった形にグルーピングします)。そして、各ビンに新たにカテゴリ値を割り当てます。この時、WOE変換を用いて各カテゴリの「PDに対する影響の強さ」を定量化してカテゴリ値として割り当てることで、線形モデルで説明可能な形式へ変換することができます。

f:id:toeming:20210925181411p:plain
引用:https://www.suri.co.jp/pdf/thesis-20081117-01.pdf

WOE変換はビニングとセットで扱うことで非線形の連続値の判別力を高める効果が期待できる他、欠損を一つのカテゴリとして扱うことで欠損値への柔軟な対応が可能になったり、ケースによってはOne-Hotエンコーディングよりも効果的にカテゴリ変数をエンコードできることがある、といった利点があります。


2. WOE変換の定義

説明のため、3つのカテゴリからなる変数を例とします。この変数の水準A~Cについて、サンプルサイズを下表の「全件数」「非デフォルト件数」「デフォルト件数」列のように定義します。ちなみに、リスク(PD)及びオッズは「リスク」「オッズ」列のようになります。

※添字でなくアンスコでごちゃごちゃとしたネーミングになっているのは、はてなTex記法とMarkdownの表形式の併用が色々と面倒だったからです、、、

カテゴリ名 全件数 非デフォルト件数 デフォルト件数 リスク(=PD) オッズ
A N_A N_A_ndef N_A_def PD_A = N_A_def / N_A odds_A = N_A_ndef / N_A_def
B N_B N_B_ndef N_B_def PD_B = N_B_def / N_B odds_B = N_B_ndef / N_B_def
C N_C N_C_ndef N_C_def PD_C = N_C_def / N_C odds_C = N_C_ndef / N_C_def
全体 N N_ndef N_def PD = N_def / N odds = N_ndef / N_def


これに対し、カテゴリAのWOE値は以下の式で定義できます。


WOE =\log{ \frac{\frac{N\_A\_ndef}{N\_ndef}}{\frac{N\_A\_def}{N\_def}} }\\

対数関数内の分母は「デフォルトサンプル全件に占めるカテゴリAの割合」であり、分子は「非デフォルトサンプル全件に占めるカテゴリAの割合」です。

一般に、あるカテゴリのWOE値の定義を言葉にすれば、「非デフォルトサンプルの件数に占める対象カテゴリの割合」を「デフォルトサンプルの件数に占める対象カテゴリの割合」で割ったもの、ということになります。

WOE変換に関する詳しい解説は、以下のドキュメントをご覧ください。

https://www.suri.co.jp/pdf/thesis-20081117-01.pdf

こちらのページもめっちゃ参考になります。 lazdera.hatenablog.com



さて、ちょっとおまけです。先程のカテゴリAのWOE値の式を変形してみましょう。



WOE \\
=\log{ \frac{\frac{N\_A\_ndef}{N\_ndef}}{\frac{N\_A\_def}{N\_def}} }\\
=\log{ ( \frac{N\_A\_ndef}{N\_A\_def} ÷ \frac{N\_ndef}{N\_def}} )\\
=\log{ \frac{odds\_A}{odds} }\\

この式から、WOE値は対象カテゴリと全体の対数オッズ比である、とも解釈できます。この辺りがターゲットエンコーディングとの違いになるのですが、だからなんやねん、というのは次回の記事で書きたいと思います。

3. 実装してみる

ということで、WOE変換を実装してみます。例題として、タイタニック生存予測のデータセットを利用します。年齢(Age)に対して年代ごとにカテゴリを分け、生存(Survived)=1を先述の説明で言うところのデフォルトと見做し、それぞれにWOEを出してみましょう。こんな感じになります。

まず、お決まりのインポート諸々から。

# ライブラリのインポート
import pandas as pd
import numpy as np

# データセットの読み込み
dataset_path = "/titanic/train.csv"
input_data = pd.read_csv(dataset_path)
input_data.head()


年齢を年代ごとにカテゴリ変数化します。年代の変数は"Age_cls"と名付けます。

# 年齢から年代変数を作成
input_data['Age_cls'] = pd.cut(input_data['Age'], [0, 10,20,30,40,50,60,70], labels=False)
input_data['Age_cls'] = (input_data['Age_cls']*10).astype("str")
input_data['Age_cls'] = input_data['Age_cls'].str.replace("0.0", '0代')
print(input_data["Age_cls"].value_counts())

data = input_data[["PassengerId","Survived", "Age_cls"]]
data
PassengerId Survived Age_cls
0 1 0 20代
1 2 1 30代
2 3 1 20代
3 4 1 30代
4 5 0 30代
... ... ... ...
886 887 0 20代
887 888 1 10代
888 889 0 nan
889 890 1 20代
890 891 0 30代


年代の水準ごとの0/1の件数をカウントします。「2. WOE変換の定義」の項でいうところの、「非デフォルト件数」「デフォルト件数」がこれにあたります。

# Age_clsの水準ごとに0/1をカウント
cnt_data = data.groupby(["Age_cls","Survived"]).count()
cnt_data = cnt_data.pivot_table(values=['PassengerId'], index=['Age_cls'], columns=['Survived'])
cnt_data.columns = ["num_0","num_1"]
cnt_data.reset_index(inplace=True)
cnt_data
Age_cls num_0 num_1
0 0代 26 38
1 10代 71 44
2 20代 146 84
3 30代 86 69
4 40代 53 33
5 50代 25 17
6 60代 13 4
7 nan 129 53


リスク(PD)を水準の件数に対するデフォルトサンプルの割合で計算します。

また、「非デフォルトサンプルの件数に占める対象カテゴリの割合」をcomp_ratio_0、「デフォルトサンプルの件数に占める対象カテゴリの割合」をcomp_ratio_1として算出し、comp_ratio_0÷comp_ratio_1でWOE値を算出します。

# リスク(PD)を計算
cnt_data["PD"] = cnt_data["num_0"] / (cnt_data["num_0"] + cnt_data["num_1"])
# 0/1の構成比を計算
cnt_data["comp_ratio_0"] = cnt_data["num_0"] / cnt_data["num_0"].sum()
cnt_data["comp_ratio_1"] = cnt_data["num_1"] / cnt_data["num_1"].sum()
# WOEを計算
cnt_data["WOE"] = cnt_data["comp_ratio_0"]/cnt_data["comp_ratio_1"]
cnt_data["WOE"] = cnt_data["WOE"].apply(np.log)
cnt_data
Age_cls num_0 num_1 PD comp_ratio_0 comp_ratio_1 WOE
0 0代 26 38 0.40625 0.047359 0.111111 -0.852777
1 10代 71 44 0.617391 0.129326 0.128655 0.005203
2 20代 146 84 0.634783 0.265938 0.245614 0.079502
3 30代 86 69 0.554839 0.156648 0.201754 -0.253047
4 40代 53 33 0.616279 0.096539 0.096491 0.000497
5 50代 25 17 0.595238 0.045537 0.049708 -0.087625
6 60代 13 4 0.764706 0.023679 0.011696 0.705367
7 nan 129 53 0.708791 0.234973 0.154971 0.416233


こんな感じで、年齢→年代→WOE値、といった流れで、変数をWOE値に変換することができました。あとはモデルの入力変数にするなりなんなり、ってな感じです。

計算するだけならそんなに難しくはないですね。


4. おわりに

WOE変換のご紹介でした。特徴量エンジニアリングの参考にしていただければと思います。 ターゲットエンコーディングとの違いについては次の記事ででも書いてみたいと思います。