Python
scikit-learn
pandas
前処理

Category Encodersでカテゴリ特徴量をストレスなく変換する

前回はfeaturetoolsを使って、簡単に特徴量の自動生成をする方法を記事にしたが、記事冒頭に記載しているCategoryEncodersの方が地味によく使っている。

いや、なんだったら本当はこっちのCategoryEndodersの方が特徴量生成をする上では重要なんじゃないかとすら思っている。

これが該当する人は読むといいかも?

  • 文字列がデータに入っているとモデルがエラーになるからいつも除外している
  • 除外はしてないけど、数値にEncodeするのが毎回だるい、死にたい
  • sklearn.preprocessingよ、なんでお前pd.DataFrameで投げたのにnp.arrayで返してくんねん!!!
  • いや、待てと。そもそもなんでOneHotEncoderってクラス名のくせに、ダイレクトに文字からOnehotに展開してくれへんねん!!そこは気ぃ効かせてくださいよ!
  • いや、待て待て。文字列を含むカテゴリ変数は普通、1つだけでは収まらんねん。いっぱいあんねん。それ1つづつ展開させるつもりっすか?マジっすか?
  • 商品名をカテゴリ変数として持たせたいんです。5000品番はあるんです。でも私のメモリは1GBしかないんです。OneHotに展開したら即メモリがOverflowしちゃうんです。
  • えぇぇ!!pd.get_dummiesってあんまり使わない方がいいんです??

Category Encodersとは?

公式リファレンスによれば、

A set of scikit-learn-style transformers for encoding categorical variables into numeric with different techniques. Currently implemented are:
Ordinal
One-Hot
Binary
Helmert Contrast
Sum Contrast
Polynomial Contrast
Backward Difference Contrast
Hashing
BaseN
LeaveOneOut
Target Encoding

  • カテゴリ特徴量をいくつかの変換方法でNumeric型の特徴量に変換するモジュール
  • scikit-learnの変換スタイルで使える(fit,transformみたいなやつやな)

とのこと。比較的最近公開されたモジュールらしく、リファレンスが書きかけだったりソースコードにちょいちょい誤字があったりするけど私はそんなこと気にしない。

ちなみに同じような機能は、scikit-learnのpreprocessingでも提供されている。
このpreprocessingの機能については別の記事でも紹介されているのでここでは特に触れない。

【翻訳】scikit-learn 0.18 User Guide 4.3. データ前処理
機械学習のための前処理 (scikit-learn Preprocessing)

わざわざCategory Encoders使う必要ありますかねぇ?

わざわざ記事に書くぐらいなんで使う必要あるという結論に至るわけですが、まぁ聞いてください。。。

具体的にsklearn.preprocessingとこのcategory_encodersの違いを示せばきっとこっちを使いたくなるでしょう。

ワタシ、今までモデリング用のデータ作る時いつもpreprocessing使ってたデスヨ。
でも、もう使ってないデスヨ。。あまりにcategory_encordersが良い子だから。。。

具体的にサンプルデータとしてfeaturetoolsにあるデモデータを流用しつつ、どう違うのか見ていく。

こんな感じのデータ↓

import featuretools as ft

# load data
data = ft.demo.load_mock_customer()
df_session = data['sessions']

df_session.head()

head結果

Index session_id customer_id device session_start
0 1 1 desktop 2014-01-01 00:00:00
1 2 1 desktop 2014-01-01 00:17:20
2 3 5 mobile 2014-01-01 00:28:10
3 4 3 mobile 2014-01-01 00:43:20
4 5 2 tablet 2014-01-01 01:10:25

このdeviceのカラムをモデルに突っ込むために文字列をNumeric型に変える必要があるケースを考える。

カテゴリ特徴量をOneHotに展開する

device列を存在する文字のユニーク数と同じだけ横持ちに展開するOneHotEncoding(0 or 1のバイナリ型)をやってみる。

scikit-learnなら

ダメな例
from sklearn import preprocessing as pp

target_col = 'device'
ohe = pp.OneHotEncoder()
ohe.fit(df_session[target_col])
ohe.transform(df_session[target_col])

>ValueError: could not convert string to float: 'desktop'

ぎょえぇ。。OneHotEncoderはdevice列を事前に数値に変換しとかないと変換できないぃ!!
しかも、指定した列だけしかできないぃ。

ちゃんとやろうとすると
LabelBinarizerを使うのが手っ取り早い。

正しい例
# LabelBinarizerを使う
target_col = 'device'
lb = pp.LabelBinarizer()
arr_device_onehot = lb.fit_transform(df_session[target_col])
arr_device_onehot

>array([[1, 0, 0],
>       [1, 0, 0],
>       ...
>       [1, 0, 0]])

出力結果を見ても分かるけど、np.arrayで返してくる。いや、うん。まぁ、えぇけど。。

つまり、OneHotEncodeした結果をpd.Dataframeにしたければこうする必要がある。

# pd.DataFrameに変換して元のpd.DataFrameにくっつける
df_device = pd.DataFrame(arr_device_onehot,columns=lb.classes_)
df_session_onehot = pd.concat([df_session,df_device],axis=1)
df_session_onehot.head()

Index session_id customer_id device session_start desktop mobile tablet
0 1 1 desktop 2014-01-01 00:00:00 1 0 0
1 2 1 desktop 2014-01-01 00:17:20 1 0 0
2 3 5 mobile 2014-01-01 00:28:10 0 1 0
3 4 3 mobile 2014-01-01 00:43:20 0 1 0
4 5 2 tablet 2014-01-01 01:10:25 0 0 1

め、め、めんどくせぇ:angry:!!!!
こんなん、他にカテゴリ化したい変数が10個とかあったら発狂:scream:待った無しやん。。。
(ほんとはもっといい方法があるけど私が知らないだけだったらゴメンね、scikit-learn)

category_encodersなら

では、真打ち登場。

ドヤァアアアァァ

見よ、この華麗なるOneHotEncodingを!!
import category_encoders as ce

# Eoncodeしたい列をリストで指定。もちろん複数指定可能。
list_cols = ['device']

# OneHotEncodeしたい列を指定。Nullや不明の場合の補完方法も指定。
ce_ohe = ce.OneHotEncoder(cols=list_cols,handle_unknown='impute')

# pd.DataFrameをそのまま突っ込む
df_session_ce_onehot = ce_ohe.fit_transform(df_session)

df_session_ce_onehot.head()

Index device_0 device_1 device_2 device_-1 session_id customer_id session_start
0 1 0 0 0 1 1 2014-01-01 00:00:00
1 1 0 0 0 2 1 2014-01-01 00:17:20
2 0 1 0 0 3 5 2014-01-01 00:28:10
3 0 1 0 0 4 3 2014-01-01 00:43:20
4 0 0 1 0 5 2 2014-01-01 01:10:25

まず、そもそもpd.DataFrameで投げたらpd.DataFrameで返してきてくれる!!これよ!!ワシぁ、これを待ってたのよ!!

しかも、変換したい列をリストで渡せば全て{カラム名}_{カラム内の値}の形式でOneHotに展開してくれる。これで、変換したい列がいくらあっても(メモリに制約がない限り)簡単に変換できる!

scikit-learnにできないことを平然とやってのけるッ!! そこにシビれる あこがれるゥ:couplekiss:!!

Category Encodersを使うべき3つの理由

ちょっと、前置きが長くなったがここでcategory_encodersをカテゴリ特徴量の前処理モジュールとして最優先で使うべき理由をまとめておく。

# 理由
1 pandasのフォーマットがサポートされているので、np.array → pd.DataFrameのような変換が不要
2 数値に変換する列を指定して変換が可能
3 scikit-learnのpreprocessingよりも多くの変換アルゴリズムが用意されている

上の例でも見たように、#1や#2が特に病みつきになる便利さ。#3もシチュエーションに合わせて、多様な変換ができるから今更preprocessingを使う理由が見つからない。。

Category Encodersを活用するシチュエーション3選

①RandomForestやGBMみたいな木構造系のモデルを使うから、Ordinal EncodeでOKな場合

category_encoders.OrdinalEncoderを用いるケースが実用上、最頻出な気がする。

このパターンは、重回帰モデルのような線形のモデルでは、カテゴリ変数を序数に変換すると結果がワケワカランことになるので、変換した後のモデルを木構造系のモデルにすることを決めている場合にしか使えない。が、変数をスパースに展開せずに数値型に変換できるのでめちゃ便利。

特に、貧弱なメモリでデータを取り扱っている場合、自然言語のようなスパースなデータを特徴量としてOneHotに展開するとすぐにスパースな行列が生成されて即MemoryOverflowになるため、スパースな行列にならないように情報を保持する方法を知っておくのはすごく重要。

まぁ、自然言語をOrdinalEncodeするのがいいかと言われるとそれはそれで最善解ではないだろうけど。。。

使い方はこんな感じ。

Ordinal_Encode
import category_encoders as ce

# Eoncodeしたい列をリストで指定。もちろん複数指定可能。
list_cols = ['device']

# 序数をカテゴリに付与して変換
ce_oe = ce.OrdinalEncoder(cols=list_cols,handle_unknown='impute')
df_session_ce_ordinal = ce_oe.fit_transform(df_session)

df_session_ce_ordinal.head()

head結果

Index session_id customer_id session_start device
0 1 1 2014-01-01 00:00:00 0
1 2 1 2014-01-01 00:17:20 0
2 3 5 2014-01-01 00:28:10 1
3 4 3 2014-01-01 00:43:20 1
4 5 2 2014-01-01 01:10:25 2

deviceが数値に無事変わっている。
ちなみにdeviceの数値が何に対応するかは、.cateogory_mappingで確認できる。
ごちゃごちゃした構造で入っているので、簡易的に下記の関数を使ってDataFrameで確認できるようにしている。

def get_ordinal_mapping(obj):
    """Ordinal Encodingの対応をpd.DataFrameで返す
    param: obj : category_encodersのインスタンス
    return pd.DataFrame
    """
    listx = list()
    for x in obj.category_mapping:
        listx.extend([tuple([x['col']])+ i for i in x['mapping']])
    df_ord_map = pd.DataFrame(listx,columns=['column','label','ord_num'])
    return df_ord_map

get_ordinal_mapping(ce_oe)

Index column label ord_num
0 device desktop 0
1 device mobile 1
2 device tablet 2

②カーディナリティ低めなカテゴリ特徴量を変換する場合

都道府県や性別のようにユニークな値の種類が限られている(カーディナリティが低い)カテゴリ特徴量は、OneHotに展開してもメモリに影響を与えにくいのでOneHotEncodingで事足りる。

上で紹介したようにcategory_encoders.OneHotEncoderもあるが、category_encoders.BinaryEncoderもある。

category_encoders.BinaryEncoderは、3つのユニークな値がある場合、2種類のバイナリ変数があれば残りの1つは自動的に決まるので明示的に展開されないところや未知データの取り扱いがOneHotEncorderと異なるところだが、基本的なコンセプトとしては同じ。

OneHotに展開している限り、線形モデルでも非線形モデルでも利用可能なので応用範囲は広い。

BinaryEncoder
list_cols = ['customer_id','device']
# Binary表現を用いる
bine = ce.BinaryEncoder(cols=list_cols,handle_unknown='impute')

bine.fit_transform(df_session).head()

head結果

Index customer_id_0 customer_id_1 customer_id_2 device_0 device_1 session_id session_start
0 0 0 0 0 0 1 2014-01-01 00:00:00
1 0 0 0 0 0 2 2014-01-01 00:17:20
2 0 0 1 0 1 3 2014-01-01 00:28:10
3 0 1 0 0 1 4 2014-01-01 00:43:20
4 0 1 1 1 0 5 2014-01-01 01:10:25

device:2(Tablet)を表す列は明示的に存在しない。

ちなみに、pandasにはget_dummiesというOneHotに特徴量を展開してくれる便利なメソッドがあるが、あまり使わないようにしている。

なぜなら、モデリング時点で存在しない番号や名称が予測を適用するタイミングで現れたり、逆にモデリング時には存在していたものが予測時点でなくなっている場合、双方のデータの列数が一致しなくなり使い物にならないから。

つまり、こういうこと
target_col = 'device'
# 意図的に出現パターンが異なるデータを2種類作る

# desktop,mobile,tablet全てが含まれるパターン
df_train = df_session.head(4)

# desktopしか含まれないパターン
df_test = df_session.tail(2)

pd.get_dummies(df_train[target_col]) #3列に展開
pd.get_dummies(df_test[target_col]) #1列に展開

df_train

Index desktop mobile tablet
0 1 0 0
1 1 0 0
2 0 1 0
3 0 1 0
4 0 0 1

df_test

Index desktop
0 1
1 1

これでは、df_trainで作ったモデルをdf_testに当てはめることはできない。モデリングすることが前提なら素直にcategory_encordersを使いましょう。

category_encoderの場合は処理可能
target_col = ['device']
ohe = ce.OneHotEncoder(cols=target_col,handle_unknown='impute') #imputeを指定すると明示的にfitdataに含まれない要素が入って来た場合に[列名]_-1列に1が立つ
ohe.fit(df_train)
# trainに含まれている要素がなくても変換可能
ohe.transform(df_test)
Index device_0 device_1 device_-1 session_id customer_id session_start
33 1 0 0 34 3 2014-01-01 08:28:05
34 1 0 0 35 1 2014-01-01 08:45:25

逆にdesktopしか含まれないdf_testで学習しても処理可能。

逆にしても処理可能
target_col = ['device']
ohe2 = ce.OneHotEncoder(cols=target_col,handle_unknown='impute')
ohe2.fit(df_test)
ohe2.transform(df_train)

Index device_0 device_-1 session_id customer_id session_start
0 1 0 1 1 2014-01-01 00:00:00
1 1 0 2 1 2014-01-01 00:17:20
2 0 1 3 5 2014-01-01 00:28:10
3 0 1 4 3 2014-01-01 00:43:20

うーむ、素晴らしい!!

③カーディナリティ高めなカテゴリ特徴量を変換する場合

さて、問題はこのケース。顧客コードや店番号、製品番号などOneHotに展開していたらメモリがどれだけあっても足りないようなカーディナリティの高いカテゴリ特徴量をどう捌くべきかという問題。
一つ目の回答としては①で示したようにOrdinalEncoderを使うという方法。まぁだいたいこれでいいのでは?と思っているものの他にもやり方はいくつかあるようで。

category_encoders.LeaveOneOutEncodercategory_encoders.TargetEncoderは目的変数を指定してカテゴリ特徴量を変換してくれるらしい。

どちらも実装のリファレンスがkaggleから派生しているので、理論がはっきりあって作られたというより、有効そうだから実装しているといった印象。

Targetに設定したyの平均を対象のカテゴリごとに計算するのがメインコンセプト。
スムージングしてたりするけど、kaggleのコメントを見てるとあんまり成果が出ている変換方法でもなさそう。

LeaveOneOutの用例
# 適当にTarget列を生成
df_session['target'] = np.random.normal(loc=0,scale=1,size=len(df_session))

# カテゴリ毎にtarget平均をとるように変換
list_cols = ['customer_id','device'] #試しに複数列で実施
loo = ce.LeaveOneOutEncoder(cols=list_cols)
loo.fit_transform(X=df_session,y=df_session['target'])# target列をyに指定

Index session_id session_start target customer_id device
0 1 2014-01-01 00:00:00 -0.591633 -0.126731 -0.774547
1 2 2014-01-01 00:17:20 -1.091073 -0.126731 -0.774547
2 3 2014-01-01 00:28:10 -0.788976 -1.047591 -0.201740
3 4 2014-01-01 00:43:20 -0.749195 -0.813062 -0.201740
4 5 2014-01-01 01:10:25 0.289323 -0.540192 -0.212356
5 6 2014-01-01 01:22:20 -0.014197 -0.126731 -0.774547

うーむ確かに、平均値っぽく変換されている。

df_session.groupby(['device'],as_index=False)['target'].mean()叩いて出したカテゴリ平均値と一致しているので、やはり単純にカテゴリ毎に平均とってるだけだな。。。

微妙だ。。。

素直にOrdinalEncoderで変換しよ。

まとめ

category_encodersを使うと面倒なカテゴリ特徴量をストレスなく変換できる。

グレートですよこいつはァ!!

このcategory_encodersはgithub上で日々更新されているのだけれど、よくよくレポジトリを掘っていくとこのモジュールの他にもsklearn-pandasとか複数の互換PJが進行しているっぽいのでこちらも注目して見ておきたい:clap:

おしまい

参考

公式リファレンス
githubレポジトリ
Target Encoderの元論文
High cardinalityなカテゴリ特徴量の扱い方に関するkaggleでのdiscussion