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

前回はfeaturetoolsを使って、簡単に特徴量の自動生成をする方法を[記事](https://qiita.com/Hyperion13fleet/items/4eaca365f28049fe11c7)にしたが、記事冒頭に記載している[CategoryEncoders](https://contrib.scikit-learn.org/category_encoders/)の方が地味によく使っている。

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

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

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


#Category Encodersとは？
[公式リファレンス](https://contrib.scikit-learn.org/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](http://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.OneHotEncoder.html#sklearn.preprocessing.OneHotEncoder)でも提供されている。
このpreprocessingの機能については別の記事でも紹介されているのでここでは特に触れない。

[【翻訳】scikit-learn 0.18 User Guide 4.3. データ前処理](https://qiita.com/nazoking@github/items/d6ac1948ee138d73fef1)
[機械学習のための前処理 (scikit-learn Preprocessing)](https://qiita.com/AfricaUmare/items/fbbbc6df11f78532932d)


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

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

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

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

こんな感じのデータ↓

```python
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なら

```py:ダメな例

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を使うのが手っ取り早い。

```py:正しい例

# 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にしたければこうする必要がある。

```py
# 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           | 2      | desktop       | 2014-01-01 00:00:00 | 1      | 0      | 0 |
| 1          | 2           | 5      | mobile        | 2014-01-01 00:17:20 | 0      | 1      | 0 |
| 2          | 3           | 4      | mobile        | 2014-01-01 00:28:10 | 0      | 1      | 0 |
| 3          | 4           | 1      | mobile        | 2014-01-01 00:44:25 | 0      | 1      | 0 |
| 4          | 5           | 4      | mobile        | 2014-01-01 01:11:30 | 0      | 1      | 0 |


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

### category_encodersなら
では、真打ち登場。

`pip install category_encoders`で事前にライブラリは入れておいてください

ドヤァアアアァァ

```py:見よ、この華麗なる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を使うべき３つの理由
ちょっと、前置きが長くなったがここで`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するのがいいかと言われるとそれはそれで最善解ではないだろうけど。。。

使い方はこんな感じ。

```py: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の数値が何に対応するかは、`.category_mapping`で確認できる。

| 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`は、３つのユニークな値がある場合、２種類のバイナリ変数があれば残りの１つは自動的に決まるので明示的に展開されないところや未知データの取り扱いが`OneHotEncorder`と異なるところだが、基本的なコンセプトとしては同じ。

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

```py: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 | customer_id_3 | device_0 | device_1 | device_2 | session_id | session_start       |
|-------|---------------|---------------|---------------|---------------|----------|----------|----------|------------|---------------------|
| 0     | 0             | 0             | 0             | 1             | 0        | 0        | 1        | 1          | 2014-01-01 00:00:00 |
| 1     | 0             | 0             | 1             | 0             | 0        | 1        | 0        | 2          | 2014-01-01 00:17:20 |
| 2     | 0             | 0             | 1             | 1             | 0        | 1        | 0        | 3          | 2014-01-01 00:28:10 |
| 3     | 0             | 1             | 0             | 0             | 0        | 1        | 0        | 4          | 2014-01-01 00:44:25 |
| 4     | 0             | 0             | 1             | 1             | 0        | 1        | 0        | 5          | 2014-01-01 01:11:30 |

device:2(Tablet)はdevice_1=1,device_2=1で表現されている。

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

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

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

# 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]) #１列に展開

```
**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を使いましょう。

```py: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`で学習しても処理可能。

```py:逆にしても処理可能
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.LeaveOneOutEncoder`や`category_encoders.TargetEncoder`は目的変数を指定してカテゴリ特徴量を変換してくれるらしい。

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

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

```py: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](https://github.com/scikit-learn-contrib/sklearn-pandas)とか複数の互換PJが進行しているっぽいのでこちらも注目して見ておきたい:clap:


おしまい

#参考
[公式リファレンス](http://contrib.scikit-learn.org/categorical-encoding/index.html)
[githubレポジトリ](https://github.com/scikit-learn-contrib/categorical-encoding)
[Target Encoderの元論文](https://kaggle2.blob.core.windows.net/forum-message-attachments/225952/7441/high%20cardinality%20categoricals.pdf)
[High cardinalityなカテゴリ特徴量の扱い方に関するkaggleでのdiscussion](https://www.kaggle.com/general/16927)