締め切り駆動開発

チラシの裏です.日記代わりに研究や趣味や日常のことを書きます.

【データに潜む猫】カテゴリ特徴量のエンコード方法まとめ

カテゴリ特徴量(categorical features)を機械学習モデルで扱うためのエンコード方法についてまとめる.

f:id:kelicht:20200411231118j:plain
Cat in the Dat(Categorical features in the Dataset).

全人類が読むべき参考文献

カテゴリ特徴量の種類

そもそもカテゴリ特徴量はその名の通りカテゴリやラベルを表す特徴量であり,基本的には数値でない特徴量のこと. 例えば「年齢」とか「体重」なんかは数値なのでそのまま機械学習モデルに入力できるけど,「性別」とか「職業」なんかは数値じゃないのでそのまま機械学習モデルに入力できない. そこで,そんなカテゴリ特徴量を機械学習モデルに入力できるように数値に変換(エンコード)することを考えるのだけど,そのカテゴリ特徴量の意味や性質をちゃんと考慮して変換しないと予測に必要な情報が欠落してしまうので,適切な変換方法を選択する必要がある.

以下は,代表的なカテゴリ特徴量の種類.日本語訳は正しくないかもしれない.

二値特徴量(binary features)

その名の通り2種類の値のみをとる特徴量. 「Yes・No」,「◯◯以上・◯◯未満」,「きのこ派たけのこ派」など. 「性別(男・女)」が代表的な例だと思ってたけど,近年だと怒られが発生しそうですね...

名目特徴量(nominal features)

ある限られた種類の値(カテゴリ)をとる特徴量. 「色(赤・青・黄)」,「ペット(猫・犬・インコ・その他)」などだろうか. このとき,各カテゴリの間には順序関係がないことに注意する. 例えば,猫と犬の間に「猫 < 犬」のような順序関係は(個々人の宗教観にも依存すると思うが)一般的に存在しない. 次の順序特徴量(ordinal features)と比較して非順序特徴量(nonordinal features)と呼ぶこともあるらしい.

順序特徴量(ordinal features)

各カテゴリの間に順序関係がある特徴量. 例えば「朝食(毎日摂る・ほぼ毎日摂る・たまに摂る・摂らない)」という特徴量には,「毎日摂る > ほぼ毎日摂る > たまに摂る > 摂らない」というような順序関係があると考えることができる.

循環特徴量(cyclic features)

各カテゴリの間に循環する性質がある特徴量. わかりやすい例は「曜日(月・火・...・日)」. 各曜日の隣接関係を考えた時に,「月→火→...→日→月→...」と循環していることを考慮してエンコードする必要がある.

データセット:Categorical Feature Encoding Challenge

カテゴリ特徴量に関するkaggleコンペ「Categorical Feature Encoding Challenge」のデータセットを使って いくつかのエンコード方法を試してみる.予測タスクは二値分類問題.

www.kaggle.com

データセットに含まれる特徴量や予測値(ターゲット)の意味に関する説明はない. データセットpandasDataFrame で読み込んで,とりあえずどんな特徴量が含まれているか確認する.

import pandas as pd
train = pd.read_csv('data/train.csv')
X = train.drop(['id', 'target'], axis=1)
y = train['target']
print(X.columns)
Index(['bin_0', 'bin_1', 'bin_2', 'bin_3', 'bin_4', 'nom_0', 'nom_1', 'nom_2', 'nom_3', 'nom_4', 'nom_5', 'nom_6', 'nom_7', 'nom_8', 'nom_9', 'ord_0', 'ord_1', 'ord_2', 'ord_3', 'ord_4', 'ord_5', 'day', 'month'], dtype='object')

各特徴量は匿名化されていて,カテゴリ特徴量の種類に応じて機械的に名前がつけられている. (名前から推測できるけど)中身の値を見てみると,

  • 'bin_0', 'bin_1', 'bin_2', 'bin_3', 'bin_4' が二値特徴量,
  • 'nom_0', 'nom_1', 'nom_2', 'nom_3', 'nom_4', 'nom_5', 'nom_6', 'nom_7', 'nom_8', 'nom_9' が名目特徴量,
  • 'ord_0', 'ord_1', 'ord_2', 'ord_3', 'ord_4', 'ord_5' が順序特徴量,
  • 'day', 'month' が循環特徴量,

という感じになっている.わかりやすいですね.それぞれ具体的なカテゴリを見てみると,

for col in ['bin_4', 'nom_0', 'ord_1', 'month']:
      print(col, ':', X[col].unique())
bin_4 : ['Y' 'N']
nom_0 : ['Green' 'Blue' 'Red']
ord_1 : ['Grandmaster' 'Expert' 'Novice' 'Contributor' 'Master']
month : [ 2  8  1  4 10  3  7  9 12 11  5  6]

という感じ.このとき, 「Novice < Contributor < Expert < Master < Grandmaster」 という順序関係*1があることや, 「1月→2月→...→12月→1月→...」といったように循環する性質があることに注意する.

基本のエンコード

代表的なエンコード方法として, ラベルエンコーディングOne-Hotエンコーディングを 取り上げる*2

ラベルエンコーディング

各カテゴリにそれぞれ異なる整数値をラベルとして割り当てる. 以下はカテゴリ特徴量「Breakfast(Every day・Most days・Rarely・Never)」に対するラベルエンコーディングの一例. それぞれのカテゴリに「Never : 0」,「Rarely : 1」,「Most days : 2」,「Every day : 3」,といったように整数値を割り当てている. カテゴリ間の順序関係(Every day > Most days > Rarely > Never)と整数値の順序関係が一致していていい感じに見える.

https://i.imgur.com/tEogUAr.png
Categorical Variables | Kaggleより.

ラベルエンコーディングsklearn.prepocessingLabelEndocer として実装されている.

scikit-learn.org

LabelEndocer を使って順序特徴量 'ord_1' をラベルエンコーディングしてみると,

from sklearn.preprocessing import LabelEncoder
le = LabelEncoder()
X['ord_1_enc'] = le.fit_transform(X['ord_1'])
print(X[['ord_1','ord_1_enc']][:10])
         ord_1  ord_1_enc
0  Grandmaster          2
1  Grandmaster          2
2       Expert          1
3  Grandmaster          2
4  Grandmaster          2
5       Novice          4
6  Grandmaster          2
7       Novice          4
8       Novice          4
9       Expert          1

という感じで,「Novice : 4」,「Contributor : 0」,「Expert : 1」,「Master : 3」,「Grandmaster : 2」, というように自動的に整数値を割り当てて変換してくれる. どうやらカテゴリ名の辞書順で整数値を割り当てているようだ.

しかし,実際は「Novice < Contributor < Expert < Master < Grandmaster」 という順序関係を表現したいので, 「Novice : 0」,「Contributor : 1」,「Expert : 2」,「Master : 3」,「Grandmaster : 4」となってほしい. ちょっと強引だが,このような順序関係があらかじめわかっていれば,pandas.DataFramemap を使って実現できる.

map_dict = {'Novice' : 0, 'Contributor' : 1, 'Expert' : 2, 'Master' : 3, 'Grandmaster' : 4}
X['ord_1_enc'] = X['ord_1'].map(map_dict)
print(X[['ord_1','ord_1_enc']][:10])
         ord_1  ord_1_enc
0  Grandmaster          4
1  Grandmaster          4
2       Expert          2
3  Grandmaster          4
4  Grandmaster          4
5       Novice          0
6  Grandmaster          4
7       Novice          0
8       Novice          0
9       Expert          2

めでたしめでたし.

ところで,同じようにLabelEndocer を使って名目特徴量 'nom_0' をラベルエンコーディングしてみると,

le = LabelEncoder()
X['nom_0_enc'] = le.fit_transform(X['nom_0'])
print(X[['nom_0','nom_0_enc']][:10])
   nom_0  nom_0_enc
0  Green          1
1  Green          1
2   Blue          0
3    Red          2
4    Red          2
5   Blue          0
6  Green          1
7    Red          2
8   Blue          0
9    Red          2

となり,「Green : 1」,「Blue : 0」,「Red : 2」という感じで各カテゴリに整数値を割り当ててくれる. 一見するといい感じだが,「Blue < Green < Red」という順序関係ができてしまっているので,この特徴量に対しては適切でない. これを解決する方法の一つとして,次に紹介するOne-Hotエンコーディングがある.

One-Hotエンコーディング

カテゴリ毎にダミー特徴量を新しく作り,そのカテゴリに該当するなら 1 を,そうでないなら 0 とする. 以下はカテゴリ特徴量「Color(Red・Yellow・Green)」に対するOne-Hotエンコーディングの一例. Red,Yellow,Greenをそれぞれ表すダミー特徴量を新しく作り,対応する色の特徴量の値は 1 に,それ以外の値は 0 にしている.

https://i.imgur.com/TW5m0aJ.png
Categorical Variables | Kaggleより.

One-Hotエンコーディングsklearn.prepocessingOneHotEncoder として実装されているが pandas.get_dummies の方が簡単な気がするのでそちらを使ってみる.

pandas.pydata.org

get_dummies を使って名目特徴量 'nom_0' をOne-Hotエンコーディングしてみる. prefix_sep=':' と指定して pandas.DataFrame を渡すと,「特徴量名:カテゴリ名」という形でダミー特徴量を作ってくれる.

dum = pd.get_dummies(X[['nom_0']], prefix_sep=':')
X = pd.concat([X, dum], axis=1)
print(X[['nom_0', 'nom_0:Blue', 'nom_0:Green', 'nom_0:Red']][:10])
   nom_0  nom_0:Blue  nom_0:Green  nom_0:Red
0  Green           0            1          0
1  Green           0            1          0
2   Blue           1            0          0
3    Red           0            0          1
4    Red           0            0          1
5   Blue           1            0          0
6  Green           0            1          0
7    Red           0            0          1
8   Blue           1            0          0

'nom_0:Blue''nom_0:Green''nom_0:Red'というダミー特徴量を新しく作り, 対応する色のダミー特徴量だけ 1 が立つようにしてくれる.

機械学習モデルとして決定木などを使うならこのままでも問題ないが, ロジスティック回帰などの線形なモデルを使う場合は多重共線性(マルチコ)が問題になる. 線形モデルを使う場合は drop_first = True とすることで最初のカテゴリのダミー変数を除外できる.

dum = pd.get_dummies(X[['nom_0']], prefix_sep=':', drop_first=True)
print(pd.concat([X['nom_0'], dum], axis=1)[:10])
   nom_0  nom_0:Green  nom_0:Red
0  Green            1          0
1  Green            1          0
2   Blue            0          0
3    Red            0          1
4    Red            0          1
5   Blue            0          0
6  Green            1          0
7    Red            0          1
8   Blue            0          0
9    Red            0          1

応用っぽいエンコード

ここまでで取り上げたエンコード方法を使えば,少なくともカテゴリ特徴量を機械学習モデルの入力として扱うことができるようになる. しかし,機械学習モデルの予測精度を向上させるためには,特徴量の持つ情報をより多く表現できるようなエンコードを行うことが必要. 機械学習モデルだけでは拾い切れない情報を,特徴量のエンコードに如何にして反映させるか, というのがデータサイエンティストとしての腕の見せ所,という風潮がある(ような気がする).

統計量を用いたエンコーディング

ラベルエンコーディングのラベルとして,単なる整数値を割り当てるのではなく,各カテゴリの何らかの統計量を用いる. これによって機械学習モデルが拾えない各カテゴリの統計的な性質を表せるだけでなく,カテゴリ間の類似度のようなものも表現できる.

とりあえず簡単なところで,カテゴリ特徴量 nom_0ord_1month に対して, データセット X における各カテゴリの出現回数をデータ数で割った値(出現確率)をラベルとして割り当ててみる. 各カテゴリの出現回数は value_counts() を使うと簡単に数えることができる.

cols = ['nom_0', 'ord_1', 'month']
for col in cols:
     X[col+'_freq'] = X[col].map(dict(X[col].value_counts())) / X.shape[0]
print(X[['nom_0', 'nom_0_freq', 'ord_1', 'ord_1_freq', 'month', 'month_freq']][:10])
   nom_0  nom_0_freq        ord_1  ord_1_freq  month  month_freq
0  Green    0.424470  Grandmaster    0.258093      2    0.151017
1  Green    0.424470  Grandmaster    0.258093      8    0.062433
2   Blue    0.320553       Expert    0.083550      2    0.151017
3    Red    0.254977  Grandmaster    0.258093      1    0.136160
4    Red    0.254977  Grandmaster    0.258093      8    0.062433
5   Blue    0.320553       Novice    0.421943      2    0.151017
6  Green    0.424470  Grandmaster    0.258093      4    0.083067
7    Red    0.254977       Novice    0.421943      2    0.151017
8   Blue    0.320553       Novice    0.421943      4    0.083067
9    Red    0.254977       Expert    0.083550      2    0.151017

こうやって見てみると,†最強†の称号である「Grandmaster」がデータセット全体の25%もいることが判明する. どういうデータセットなんだこれは......

循環特徴量のエンコード

循環する性質のある特徴量(cyclic features)に対しては,三角関数(sinやcos)を使ったエンコードがよく使われるらしい. 例えば,カテゴリ特徴量「月(Jan・Feb・...・Dec)」はラベルエンコーディングにより 「Jan : 1」,「Feb : 2」,...,「Dec : 12」,というように変換してあげると直感的にもわかりやすい. しかし,実際は下図に示されるような循環する性質(Jan → Feb → ... → Dec → Jan → ...)があるので, これを考慮するために三角関数を用いるんだとか.

https://miro.medium.com/max/343/1%2A70cevmU8wNggGJEdLam1lw.png
An Overview of Encoding Techniques | Kaggleより.

三角関数を使って循環特徴量 'month'エンコードしてみる. データセットのままで既にラベルエンコーディングされているので,直接三角関数に適用できる. 具体的には,次に示すような式でエンコードを行う:


x_\mathrm{sin} = \sin(\frac{2 \pi x}{x_\max}) \\
x_\mathrm{cos} = \cos(\frac{2 \pi x}{x_\max}) \\

三角関数numpy に実装されているのでそれを使う.

X['month_sin'] = np.sin((2 * np.pi * X['month'])/(max(X['month'])))
X['month_cos'] = np.cos((2 * np.pi * X['month'])/(max(X['month'])))
print(X[['month','month_sin','month_cos']][:10])
   month  month_sin  month_cos
0      2   0.866025   0.500000
1      8  -0.866025  -0.500000
2      2   0.866025   0.500000
3      1   0.500000   0.866025
4      8  -0.866025  -0.500000
5      2   0.866025   0.500000
6      4   0.866025  -0.500000
7      2   0.866025   0.500000
8      4   0.866025  -0.500000
9      2   0.866025   0.500000

いちおうOne-Hotエンコーディングした特徴量も併用するとよいかもしれない.

ターゲットエンコーディング

予測値(ターゲット)に関する何らかの統計量を使って各カテゴリをラベルエンコーディングする. 予測精度を向上させ得るkaggler御用達テクニックの一つだが, リーク(本来参照できないターゲット情報を使うことによる過学習)が起きやすいため扱いには注意が必要. 以下の記事が詳しい.全人類が読むべき.

blog.amedama.jp

各カテゴリにおけるターゲットの平均値をラベルとして割り当てる Target Mean Encoding をやってみる. 今回は二値分類( y \in \{ 0,1 \})なので,ターゲットの平均値は  y=1 をとる確率と見なせなくもない. 順序特徴量 'ord_1' についてターゲットの平均値を計算してみると,

cats = ['Novice', 'Contributor', 'Expert', 'Master', 'Grandmaster']
for cat in cats:
     print(cat, ':', y[X['ord_1']==cat].mean())
Novice : 0.24205462028866437
Contributor : 0.2785332742413286
Expert : 0.31717534410532616
Master : 0.3550778882828931
Grandmaster : 0.40388489951955364

という感じでカテゴリの順序関係とターゲットの平均値( y=1 をとる確率)の大小関係が一致した.なんか重要な特徴量っぽい.

このままターゲットの平均値を各カテゴリのラベルとして使えそうに思えるが,この方法はリークが起きているのであまり好ましくない. すなわち,この方法ではデータセット全体でターゲットの統計量を集計しているので, 各データをエンコードするために自身に紐づいている予測値の情報をも使っているということになる. しかし,本来それは参照できない,というか,してはいけない情報なので,リークが起きて過学習に繋がる可能性がある.

リークを防ぐために,K-Fold ターゲットエンコーディングをする. 考え方はもうK-Fold 交差検証(CV)と同じで,以下の操作をK-Fold CVと同様の要領で行う:

以下は具体例. カテゴリ「A」と「B」について,Fold-2〜Fold-5 のデータを使ってそれぞれターゲットの平均値を計算して, その値を Fold-1 のデータにおける各カテゴリのラベルとして用いる.これを各 Fold について繰り返す.

https://miro.medium.com/max/1955/1%2AZKD4eZXzd_FdN0SQDszFVQ.png
An Overview of Encoding Techniques | Kaggleより.

便利なライブラリは無さそうなので,sklearn.model_selectionKFold を使ってしこしこがんばる.

from sklearn.model_selection import KFold
kf = KFold(n_splits=10)
for tr, vl in kf.split(X):
     stats = []
     for cat in cats:
             stats.append(y[tr][X.loc[tr, 'ord_1']==cat].mean())
     map_dict = dict(zip(cats, stats))
     X.loc[vl, 'ord_1_te'] = X.loc[vl, 'ord_1'].map(map_dict)
print(X[['ord_1','ord_1_te']].head(10))
print(X[['ord_1','ord_1_te']].tail(10))
         ord_1  ord_1_te
0  Grandmaster  0.404199
1  Grandmaster  0.404199
2       Expert  0.317650
3  Grandmaster  0.404199
4  Grandmaster  0.404199
5       Novice  0.241325
6  Grandmaster  0.404199
7       Novice  0.241325
8       Novice  0.241325
9       Expert  0.317650

              ord_1  ord_1_te
299990       Master  0.355656
299991  Contributor  0.277845
299992       Master  0.355656
299993  Grandmaster  0.404512
299994       Novice  0.241775
299995  Contributor  0.277845
299996       Novice  0.241775
299997       Novice  0.241775
299998       Master  0.355656
299999  Contributor  0.277845

かなりわかりづらいが,よく見るとデータセットの先頭部分と末尾部分で割り当てられたラベルとしての値が微妙に異なっている. 例えば「Grandmaster」に割り当てられた値は,データセットの先頭では 0.404199 で,末尾では 0.404512 になっている.

まとめ・今後の課題(To Do)

エンコード方法による予測精度の違いについても検証すべきなんですけどね...

実際はデータセットやモデルに依存して使うべきエンコード方法も変わってくるので, そういうのを探る作業は本当に地道というか泥臭い印象があり, データサイエンスってカッコいい名前のわりに大変だよなぁというお気持ちになるのであった.

*1:kaggleでの称号.コンペでの成績によって昇格していく.

*2:これらの他には feature hashing などがある.