データ分析コンペなどでよく利用される Target Encoding という特徴量抽出 (Feature Extraction) の手法がある。
これは、ターゲット (目的変数) の情報に基づいて、カテゴリ変数ごとの期待値を説明変数として利用するもの。
Target Encoding には、いくつかの計算方法があり、中にはリーク (Data Leakage) のリスクが大きいものもある。
詳しくは、このブログでも以下のエントリで述べている。
blog.amedama.jp
今回のエントリでは、いくつかある計算方法の中でも OrderedTS (Ordered Target Statistics) について扱う。
OrderedTS の詳しい説明については、前述したエントリを参照してもらいたい。
本当にざっくりと説明すると、データに順序があると仮定して、各時点での期待値を説明変数とするやり方。
このやり方はリークのリスクが相対的に低いとされている。
このエントリでは、category_encoders というカテゴリ変数の特徴量抽出を扱ったパッケージにおいて OrderedTS の算出に使われる CatBoostEncoder というクラスの実装を見ていく。
さらに、CatBoostEncoder 自体は二値分類と回帰タスクにしか対応していないため、それを多値分類に拡張してみる。
使った環境は次のとおり。
$ sw_vers
ProductName: macOS
ProductVersion: 12.6.1
BuildVersion: 21G217
$ python -V
Python 3.10.8
$ pip list | grep -i category-encoders
category-encoders 2.5.1.post0
もくじ
下準備
あらかじめ category_encoders と pandas をインストールしておく。
$ pip install category_encoders pandas
二値分類を扱ったサンプルコード
まずは、二値分類のデータを想定したデータを使って振る舞いを見ていく。
以下にサンプルコードを示す。
このサンプルコードでは、フルーツの銘柄のカテゴリ変数と、それに対応する何らかの二値の目的変数を持ったデータを扱う。
具体的には CatBoostEncoder
を使って、学習データとテストデータを想定した内容に対してカテゴリ変数の OrderedTS を算出している。
目的変数は、たとえば美味しく感じたかどうかとでも考えてもらえれば良いと思う。
学習データを見ると、フルーツの銘柄ごとに美味しく感じたかどうかの割合 (期待値) が異なることが確認できる。
import pandas as pd
from category_encoders import CatBoostEncoder
def main():
data = [
("apple", 0),
("apple", 0),
("apple", 1),
("banana", 0),
("banana", 1),
("banana", 1),
("cherry", 1),
("cherry", 1),
("cherry", 1),
]
train_df = pd.DataFrame(data=data, columns=["fruits", "y"])
train_x = train_df[["fruits"]]
train_y = train_df["y"]
encoder = CatBoostEncoder(cols=["fruits"])
encoder.fit(train_x, train_y)
encoded_train = encoder.transform(train_x, train_y)
train_df.loc[:, "ordered_ts"] = encoded_train
print("== OrderedTS (train) ==")
print(train_df)
data = [
("apple",),
("apple",),
("banana",),
("banana",),
("cherry",),
("dates",),
]
test_df = pd.DataFrame(data=data, columns=["fruits"])
test_x = test_df[["fruits"]]
encoded_test = encoder.transform(test_x)
test_df.loc[:, "ordered_ts"] = encoded_test
print("== OrderedTS (train) ==")
print(test_df)
if __name__ == '__main__':
main()
上記に適当な名前をつけて実行する。
実行結果を以下に示す。
$ python catboostencoder.py
== OrderedTS (train) ==
fruits y ordered_ts
0 apple 0 0.666667
1 apple 0 0.333333
2 apple 1 0.222222
3 banana 0 0.666667
4 banana 1 0.333333
5 banana 1 0.555556
6 cherry 1 0.666667
7 cherry 1 0.833333
8 cherry 1 0.888889
== OrderedTS (train) ==
fruits ordered_ts
0 apple 0.416667
1 apple 0.416667
2 banana 0.666667
3 banana 0.666667
4 cherry 0.916667
5 dates 0.666667
上記から、学習データに対して各時点での OrderedTS が付与されていることが確認できる。
たとえば「apple」は、最初のデータ (index: 0) については期待値が 0.666667 となっている。
しかし、その次のデータ (index: 1) では 0.333333 に下がっている。
そして、最後のデータ (index: 2) では 0.222222 まで下がった。
これは、各時点で得られた「apple」の目的変数を元にして、カテゴリの期待値が更新されながら計算されていることを意味する。
また、テストデータについては、学習データから得られた最終的な期待値を使って OrderedTS が求められている。
さらに、テストデータだけに登場する初見のデータ (dates
) については全体の平均 (6 / 9 = 0.666667) で埋められている。
CatBoostEncoder の実装について
さて、基本的な考え方は分かった。
しかし、実際にどのように OrderedTS が求められているのかがまだ分からない。
先述の当ブログのエントリで扱ったナイーブな考え方では、最初の「apple」は NaN か 0 になるはずではないだろうか。
これには、少数のデータを含むカテゴリや、順序において最初の方のデータが極端な値を取らないようにするスムージングの処理が関係している。
ここからは CatBoostEncoder
の実装について見ていこう。
以下は、現時点の最新バージョン (v2.5.1) の CatBoostEncoder
の、学習データに対して OrderedTS を算出する処理である。
github.com
該当部分を以下に引用する。
初見では意図が分かりにくいと思うので、ここから一つずつ説明していく。
X[col] = (temp['cumsum'] - y + self._mean * self.a) / (temp['cumcount'] + self.a)
まず、上記で temp['cumsum']
が各時点での目的変数の累積和を表している。
そこから元の目的変数 y
を引くことで、ある時点での計算からその時点の目的変数が含まれないようにしている。
これは、ある時点での計算にその時点の目的変数が含まれるとリークが生じる (その時点で目的変数は判明していないはずなので) ため。
これは言いかえると目的変数を 1 つ前方にシフトしているのと同義になる。
同様に temp['cumcount']
は目的変数の累積カウントを表している。
さて、ナイーブな実装であれば、OrderedTS は次のようにすれば良いはずだ。
X[col] = (temp['cumsum'] - y) / (temp['cumcount'])
先ほどの式を以下に再度示す。
見ると、上記の式に self._mean
や self.a
という変数が追加された形になっていることがわかる。
つまり、これがスムージングのパラメータということになる。
X[col] = (temp['cumsum'] - y + self._mean * self.a) / (temp['cumcount'] + self.a)
self._mean
には、すべてのカテゴリをまたいだ目的変数の平均が入っている。
そして、self.a
はスムージングの強さを指定するハイパーパラメータ (デフォルトは 1
) になっている。
そして、分子には「self._mean * self.a
」が、分母には「self.a
」が加えられている。
このスムージングの考え方を端的に言い表すと「そのカテゴリに、平均の値 (self._mean
) を持ったレコードが、あらかじめ self.a
個だけ入っていることにする」となる。
そのため、先ほど各カテゴリの OrderedTS の値は、目的変数の全体の平均 (6/9 = 0.666667) から始まったわけだ。
最初から、目的変数の平均を持ったデータが 1 つだけ入っていることになっていたから。
つまり、よりスムージングを強くしたければ、self.a
を大きくすることで、平均のデータがあらかじめたくさん入っていることにすれば良い。
ちなみに、ナイーブな OrderedTS は CatBoostEncoder
のスムージングパラメータの a
に 0
を指定することで求めることができる。
import pandas as pd
from category_encoders import CatBoostEncoder
def main():
data = [
("apple", 0),
("apple", 0),
("apple", 1),
("banana", 0),
("banana", 1),
("banana", 1),
("cherry", 1),
("cherry", 1),
("cherry", 1),
]
train_df = pd.DataFrame(data=data, columns=["fruits", "y"])
train_x = train_df[["fruits"]]
train_y = train_df["y"]
encoder = CatBoostEncoder(cols=["fruits"], a=0)
encoder.fit(train_x, train_y)
encoded_train = encoder.transform(train_x, train_y)
train_df.loc[:, "ordered_ts"] = encoded_train
print("== OrderedTS (train) ==")
print(train_df)
data = [
("apple",),
("apple",),
("banana",),
("banana",),
("cherry",),
("dates",),
]
test_df = pd.DataFrame(data=data, columns=["fruits"])
test_x = test_df[["fruits"]]
encoded_test = encoder.transform(test_x)
test_df.loc[:, "ordered_ts"] = encoded_test
print("== OrderedTS (test) ==")
print(test_df)
if __name__ == '__main__':
main()
実行結果は次のとおり。
各カテゴリの最初の値が NaN になっていたり、数値が入った項目についてもより極端な値になっている。
$ python catboostencoder.py
== OrderedTS (train) ==
fruits y ordered_ts
0 apple 0 NaN
1 apple 0 0.0
2 apple 1 0.0
3 banana 0 NaN
4 banana 1 0.0
5 banana 1 0.5
6 cherry 1 NaN
7 cherry 1 1.0
8 cherry 1 1.0
== OrderedTS (test) ==
fruits ordered_ts
0 apple 0.333333
1 apple 0.333333
2 banana 0.666667
3 banana 0.666667
4 cherry 1.000000
5 dates 0.666667
これで CatBoostEncoder
の具体的な実装がわかった。
多値分類タスクへの拡張について
さて、便利な CatBoostEncoder
ではあるが、弱点もある。
それは、多値分類タスクにそのままでは対応していないところ。
Target Encoding を多値分類タスクに適用するためには、目的変数を One-Hot Encoding する必要がある。
つまり、目的変数が各クラスになる割合を One-vs-All な二値分類タスクに落としこむ。
クラスごとの二値分類タスクにした上で、それぞれで Target Encoding すれば良い。
事前に目的変数を One-Hot Encoding してクラスごとに Target Encoding する部分は愚直に実装することもできる。
ただ、それだと記述量が増えて煩雑になるので CatBoostEncoder
のラッパークラスを MultiClassCatBoostEncoder
として実現してみた。
以下にサンプルコードを示す。
import pandas as pd
from category_encoders import CatBoostEncoder
from sklearn.preprocessing import OneHotEncoder
from sklearn.base import BaseEstimator
from sklearn.base import TransformerMixin
class MultiClassCatBoostEncoder(TransformerMixin, BaseEstimator):
"""CatBoostEncoder を多値分類タスクに適用するためのラッパークラス"""
def __init__(self, *args, **kwargs):
self._args = args
self._kwargs = kwargs
self._one_hot_encoder = OneHotEncoder()
self._cat_boost_encoders = []
def fit(self, X, y=None):
if y is None:
raise TypeError("fit() missing argument: ''y''")
y_onehot = self._one_hot_encoder.fit_transform(y.values.reshape(-1, 1))
num_of_classes = y_onehot.shape[1]
for i in range(num_of_classes):
encoder = CatBoostEncoder(*self._args, **self._kwargs)
encoder.fit(X, y_onehot[:, i].toarray().reshape(-1))
self._cat_boost_encoders.append(encoder)
return self
def transform(self, X, y=None):
y_onehot = (self._one_hot_encoder.transform(y.values.reshape(-1, 1))
if y is not None else
None)
encoded_list = []
for i, encoder in enumerate(self._cat_boost_encoders):
if y_onehot is not None:
encoded_series = encoder.transform(X, y_onehot[:, i].toarray().reshape(-1))
else:
encoded_series = encoder.transform(X)
encoded_list.append(encoded_series)
concat_encoded = pd.concat(encoded_list, axis=1)
concat_encoded.columns = self._one_hot_encoder.categories_
return concat_encoded
def fit_transform(self, X, y=None, **fit_params):
if y is None:
raise TypeError("fit_transform() missing argument: ''y''")
return self.fit(X, y, **fit_params).transform(X, y)
def main():
data = [
("apple", 1),
("apple", 1),
("apple", 2),
("banana", 1),
("banana", 2),
("banana", 2),
("cherry", 2),
("cherry", 3),
("cherry", 3),
]
train_df = pd.DataFrame(data=data, columns=["fruits", "y"])
train_x = train_df[["fruits"]]
train_y = train_df["y"]
encoder = MultiClassCatBoostEncoder(cols=["fruits"])
encoded_train = encoder.fit_transform(train_x, train_y)
print("== OrderedTS (train) ==")
print(encoded_train)
data = [
("apple",),
("apple",),
("banana",),
("banana",),
("cherry",),
("dates",),
]
test_df = pd.DataFrame(data=data, columns=["fruits"])
test_x = test_df[["fruits"]]
encoded_test = encoder.transform(test_x)
print("== OrderedTS (test) ==")
print(encoded_test)
if __name__ == '__main__':
main()
実行結果は次のとおり。
それぞれのデータが、各クラスに属する期待値がクラスごとに計算されている。
テストデータで初見のカテゴリについては、各クラスの平均で埋められている。
$ python multiclass.py
== OrderedTS (train) ==
1 2 3
0 0.333333 0.444444 0.222222
1 0.666667 0.222222 0.111111
2 0.777778 0.148148 0.074074
3 0.333333 0.444444 0.222222
4 0.666667 0.222222 0.111111
5 0.444444 0.481481 0.074074
6 0.333333 0.444444 0.222222
7 0.166667 0.722222 0.111111
8 0.111111 0.481481 0.407407
== OrderedTS (test) ==
1 2 3
0 0.583333 0.361111 0.055556
1 0.583333 0.361111 0.055556
2 0.333333 0.611111 0.055556
3 0.333333 0.611111 0.055556
4 0.083333 0.361111 0.555556
5 0.333333 0.444444 0.222222
いじょう。
まとめ
今回は category_encoders の CatBoostEncoder が、どのように OrderedTS を計算しているのか確認した。
さらに、CatBoostEncoder を多値分類タスクに適用するための拡張についても紹介した。