CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: category_encoders の CatBoostEncoder を用いた OrderedTS の算出と多値分類タスクへの拡張について

データ分析コンペなどでよく利用される 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 を算出している。 目的変数は、たとえば美味しく感じたかどうかとでも考えてもらえれば良いと思う。 学習データを見ると、フルーツの銘柄ごとに美味しく感じたかどうかの割合 (期待値) が異なることが確認できる。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

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"]

    # 集計に使うカテゴリ変数を "cols" 引数に指定する
    encoder = CatBoostEncoder(cols=["fruits"])

    # OrderedTS を求める
    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",),
        # unseen データ
        ("dates",),
    ]
    test_df = pd.DataFrame(data=data, columns=["fruits"])
    test_x = test_df[["fruits"]]

    # OrderedTS を求める
    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._meanself.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 のスムージングパラメータの a0 を指定することで求めることができる。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

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"]

    # スムージングパラメータの「a」に 0 を指定することでナイーブな OrderedTS を求める
    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 として実現してみた。 以下にサンプルコードを示す。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

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''")

        # 多値の目的変数を One-Hot 表現に直す
        y_onehot = self._one_hot_encoder.fit_transform(y.values.reshape(-1, 1))
        # 学習データに含まれるクラス数
        num_of_classes = y_onehot.shape[1]

        # クラスごとに CatBoostEncoder を学習する
        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):
        # ラベルがあれば One-Hot 表現に直す
        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",),
        # unseen データ
        ("dates",),
    ]
    test_df = pd.DataFrame(data=data, columns=["fruits"])
    test_x = test_df[["fruits"]]

    # 各クラスに対応したエンコーダで OrderedTS を求める
    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 を多値分類タスクに適用するための拡張についても紹介した。