CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: Polars で各種エンコーダを実装したライブラリ「Shirokumas」を作った

最近は Polars が気に入っていて、主にプライベートで使っている。 ただ、エコシステムという観点では Pandas に比べて発展途上の段階にあると思う。 そこで、今回は発展の一助として「Shirokumas」というライブラリを作ってみた。

github.com

どんなライブラリかというと、現時点の機能では Pandas の category_encoders 1 のサブセットに相当する。 より具体的には、scikit-learn のスタイルで書かれた特徴量抽出をするための基本的なエンコーダを実装してある。

特徴としては、同じ処理を完了するまでにかかる時間が短いこと。 Pandas のエコシステムで使われるフレームワークとパフォーマンスを比較したグラフを以下に示す。 グラフから、比較対象の概ね 1/10 以下の時間で処理を完了できることが分かる。 詳細については、このエントリの後半に記述している。

処理にかかる時間の比較

今回のエントリを書くにあたって、使った環境は次のとおり。

$ sw_vers
ProductName:        macOS
ProductVersion:     13.2.1
BuildVersion:       22D68
$ uname -srm
Darwin 22.3.0 arm64
$ python -V
Python 3.9.16
$ pip list | egrep -i "(polars|shirokuma)"
polars                        0.16.8
shirokumas                    0.0.1

注意点

現時点では、以下の点に注意が必要となる。

  • 作ったばかりのライブラリなので、不具合があったり機能が不足している恐れがある
  • Polars の API 自体がさほど安定していないため、今後のバージョンアップによって動作しなくなる恐れがある

もくじ

下準備

あらかじめ pip を使って PyPI から shirokumas をインストールする。

$ pip install shirokumas

Python のインタプリタを起動する。

$ python

shirokumas をインポートしておく。

>>> import shirokumas as sk

使い方

ここからは実装したエンコーダの使い方をひとつずつ説明していく。 パラメータや振る舞いについては category_encoders を参考にしているところが多い。

OrdinalEncoder

まずは OrdinalEncoder から。 これは Ordinal/Label Encoding と呼ばれるエンコード手法を実装している。 具体的には、カテゴリ変数の特定のクラスを特定の整数に対応させるもの。

あらかじめサンプルとなるデータフレームを用意しておく。 これにはカテゴリ変数の "fruits" というカラムが含まれる。

>>> import polars as pl
>>> train_df = pl.DataFrame({"fruits": ["apple", "banana", "cherry"]})

エンコーダをインスタンス化する。

>>> encoder = sk.OrdinalEncoder()

データフレームをエンコーダで学習・変換する。

>>> encoder.fit_transform(train_df)
shape: (3, 1)
┌────────┐
│ fruits │
│ ---    │
│ i64    │
╞════════╡
│ 1      │
│ 2      │
│ 3      │
└────────┘

エンコードされる整数は 1 から連続しており、カテゴリが登場した順番に割り振られる。

対応関係はエンコーダに記録されているので、新規のデータフレームを変換するときにも同じ対応関係が使われる。

>>> test_df = pl.DataFrame({"fruits": ["cherry", "banana", "apple"]})
>>> encoder.transform(test_df)
shape: (3, 1)
┌────────┐
│ fruits │
│ ---    │
│ i64    │
╞════════╡
│ 3      │
│ 2      │
│ 1      │
└────────┘

また、mappings 引数を指定することで、対応関係を明示的に指定することもできる。

>>> encoder = sk.OrdinalEncoder(mappings={
...     "fruits": {
...         "apple": 10,
...         "banana": 20,
...         "cherry": 30,
...     }
... })
>>> encoder.fit_transform(train_df)
shape: (3, 1)
┌────────┐
│ fruits │
│ ---    │
│ i64    │
╞════════╡
│ 10     │
│ 20     │
│ 30     │
└────────┘

デフォルトでは、学習データに登場しなかった未知の値や None については定数に置き換えられる。 未知の値は -1 で、None については -2 になる。

>>> test_df = pl.DataFrame({"fruits": ["unseen", None, "apple"]})
>>> encoder.transform(test_df)
shape: (3, 1)
┌────────┐
│ fruits │
│ ---    │
│ i64    │
╞════════╡
│ -1     │
│ -2     │
│ 10     │
└────────┘

未知の値や None に遭遇したときのハンドリングを変更することもできる。 たとえば None を変換しようとしたときエラーにしたいなら handle_missing 引数に "error" を指定する。

>>> train_df = pl.DataFrame({"fruits": ["apple", "banana", "cherry"]})
>>> encoder = sk.OrdinalEncoder(handle_missing="error")
>>> encoder.fit(train_df)
OrdinalEncoder(cols=['fruits'], handle_missing='error',
               mappings={'fruits': {'apple': 1, 'banana': 2, 'cherry': 3}})

未知の値に遭遇すると、次のように ValueError がスローされる。

>>> test_df = pl.DataFrame({"fruits": ["apple", None, "banana"]})
>>> encoder.transform(test_df)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
...
ValueError: Columns to be encoded can not contain null

同様に、未知の値を変換しようとしたときエラーにしたいなら handle_unknown 引数に "error" を指定する。

encoder = sk.OrdinalEncoder(handle_unknown="error")
encoder.fit(train_df)

test_df = pl.DataFrame({"fruits": ["apple", "blueberry", "banana"]})
>>> encoder.transform(test_df)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
...
ValueError: Columns to be encoded can not contain unknown value

また、デフォルトでは渡された DataFrame のすべてのカラムをカテゴリ変数として処理しようとする 2。 もし、特定のカラムだけを処理したいなら cols 引数にカラム名を指定する。

train_df = pl.DataFrame({
    "fruits": ["apple", "banana", "cherry"],
    "vegetables": ["avocados", "broccoli", "carrots"],
})
encoder = sk.OrdinalEncoder(cols=["vegetables"])
>>> encoder.fit_transform(train_df)
shape: (3, 1)
┌────────────┐
│ vegetables │
│ ---        │
│ i64        │
╞════════════╡
│ 1          │
│ 2          │
│ 3          │
└────────────┘

OneHotEncoder

続いては OneHotEncoder について。 これは OneHot Encoding と呼ばれるエンコード手法を実装している。 このエンコード手法では、カテゴリ変数があるクラスかどうかを真偽値で表現したカラムが、クラスの数だけ作成される。

実際にやってみよう。

>>> train_df = pl.DataFrame({"fruits": ["apple", "banana", "cherry"]})
>>> encoder = sk.OneHotEncoder()
>>> encoder.fit_transform(train_df)
shape: (3, 3)
┌──────────────┬───────────────┬───────────────┐
│ fruits_apple ┆ fruits_banana ┆ fruits_cherry │
│ ---          ┆ ---           ┆ ---           │
│ boolboolbool          │
╞══════════════╪═══════════════╪═══════════════╡
│ true         ┆ false         ┆ false         │
│ false        ┆ true          ┆ false         │
│ false        ┆ false         ┆ true          │
└──────────────┴───────────────┴───────────────┘

CountEncoder

続いては CountEncoder について。 これは Count/Frequency Encoding と呼ばれるエンコード手法を実装している。 このエンコード手法では、カテゴリ変数の各クラスの出現回数が特徴量として使われる。

実際に試してみよう。 元のデータフレームには "apple" が 2 回、"banana" が 3 回、"cherry" が 1 回登場する。

>>> train_df = pl.DataFrame({"fruits": [
...     "apple",
...     "apple",
...     "banana",
...     "banana",
...     "banana",
...     "cherry",
... ]})
>>> encoder = sk.CountEncoder()
>>> encoder.fit_transform(train_df)
shape: (6, 1)
┌────────┐
│ fruits │
│ ---    │
│ i64    │
╞════════╡
│ 2      │
│ 2      │
│ 3      │
│ 3      │
│ 3      │
│ 1      │
└────────┘

上記から、元のデータフレームに含まれる各フルーツの出現回数が変換後のデータフレームで置き換えられていることがわかる。

NullEncoder

続いては NullEncoder について。 Null Encoding ...と呼ぶのかは分からないけど、あるカラムの値が欠損値かどうかも有益な特徴量になることがある。 なので、このエンコーダはカラムの値が None かどうかを真偽値で表現する。

>>> train_df = pl.DataFrame({"fruits": ["apple", None, "cherry"]})
>>> encoder = sk.NullEncoder()
>>> encoder.fit_transform(train_df)
shape: (3, 1)
┌────────┐
│ fruits │
│ ---    │
│ bool   │
╞════════╡
│ false  │
│ true   │
│ false  │
└────────┘

TargetEncoder

続いては TargetEncoder について。 これは Target/Likelihood (Mean) Encoding と呼ばれる手法を実装している。 目的変数を特徴量抽出に使う、いわゆる教師あり (Supervised) な手法になる。

基本的なアイデアは、カテゴリごとに目的変数の平均値を求めたら良い特徴量になるのでは、というもの。 ただし、学習データをすべて使って計算してしまう (Greedy Target Statistics) と CV でリークが生じて楽観的な見積もりになってしまう。 そこで、データを分割して Fold ごとに計算する (Hold-out Target Statistics) といった工夫が必要になる。

詳しくは下記のエントリを参考にしてもらいたい。

blog.amedama.jp

実際に試してみよう。 今回は学習データについて、説明変数だけでなく目的変数も定義しておく。

>>> train_x = pl.DataFrame(
...     {
...         "fruits": ["apple", "banana", "banana", "apple"],
...     }
... )
>>> train_y = pl.Series(
...     name="target",
...     values=[1, 0, 1, 1],
... )

また、前述したとおりリークを防ぐためにデータを分割して計算する必要がある。 そこで、分割方法を指定するために scikit-learn の KFold オブジェクトを用意する。

>>> from sklearn.model_selection import KFold
>>> folds = KFold(n_splits=4, shuffle=False)

今回は分かりやすさのために 4 行の学習データを 4 分割する。 順番に、学習データのうち 3 行が統計量の計算に用いられ、1 行が計算した統計量で埋められる。

>>> encoder = sk.TargetEncoder(folds=folds)
>>> encoder.fit(train_x, train_y)
TargetEncoder(folds=KFold(n_splits=4, random_state=None, shuffle=False))
>>> encoder.transform(train_x)
shape: (4, 1)
┌────────┐
│ fruits │
│ ---    │
│ f64    │
╞════════╡
│ 1.0    │
│ 1.0    │
│ 0.0    │
│ 1.0    │
└────────┘

各 1 行を目隠ししながらカテゴリごとの平均値を計算して埋めていくと上記のような対応になることがわかるはず。

新規のデータをエンコードするときは、学習データ全体で計算した各カテゴリの平均値が使われる。 未知のデータは学習データ全体の目的変数の平均値 (Global Mean) で埋められる。 今回であれば学習データ全体の平均は 3/4 = 0.75 になる。

test_x = pl.DataFrame(
    {
        "fruits": ["apple", "banana", "cherry"],
    }
)
>>> encoder.transform(test_x)
shape: (3, 1)
┌────────┐
│ fruits │
│ ---    │
│ f64    │
╞════════╡
│ 1.0    │
│ 0.5    │
│ 0.75   │
└────────┘

上記の "cherry" は学習データには含まれていなかった未知の値なので 0.75 に置き換えられている。

また、Target Encoding は過学習を防ぐために Smoothing が用いられる場合がある。 Shirokumas では、empirical bayesian と m-probability estimate という 2 種類の Smoothing を実装している。 それぞれの Smoothing の詳細については、以下のエントリを参考してもらいたい。 なお、デフォルトでは Smoothing しないので明示的に指定する必要がある。

blog.amedama.jp

まず、m-probability estimate を使うときは smoothing_method 引数に "m-estimate" を指定する。 このとき、Smoothing の特性を決めるパラメータも存在している。 そこで、smoothing_params に辞書で "m" というキーを使って浮動小数点を指定する。 なお、指定しないときはデフォルトで 1.0 が使われる3

encoder = sk.TargetEncoder(
    folds=folds,
    smoothing_method="m-estimate",
    smoothing_params={
        "m": 1.0,
    },
)
>>> encoder.fit_transform(train_x, train_y)
shape: (4, 1)
┌──────────┐
│ fruits   │
│ ---      │
│ f64      │
╞══════════╡
│ 0.833333 │
│ 1.0      │
│ 0.333333 │
│ 0.833333 │
└──────────┘

もうひとつの Smoothing 手法の empirical bayesian を使うときは smoothing_method"eb" を指定する。 Smoothing の特性を決めるパラメータには "k""f" という 2 つのキーで整数が必要になる。 指定されないときは、それぞれ 2010 が使われる 4

>>> encoder = sk.TargetEncoder(
...     folds=folds,
...     smoothing_method="eb",
...     smoothing_params={
...         "k": 1,
...         "f": 1,
...     },
... )
>>> encoder.fit_transform(train_x, train_y)
shape: (4, 1)
┌──────────┐
│ fruits   │
│ ---      │
│ f64      │
╞══════════╡
│ 0.833333 │
│ 1.0      │
│ 0.333333 │
│ 0.833333 │
└──────────┘

適切な Smoothing の有無やパラメータは、利用するデータやモデルに依存するため色々と試してみる必要があるはず。

ちなみに Out-of-Fold での計算と、上記 2 種類の Smoothing の両方を実装したフレームワークは、私が知る限り現時点 (2023-02-26) で他に無いと思われる。

AggregateEncoder

続いては AggregateEncoder について。 これについては具体的に何 Encoding と呼ぶべきなのか私にも分からない 5。 日本語では、この手法で作られた特徴量のことを集約特徴量と呼ぶことが多い。 やっていることは Target Encoding と少し似ているが、集計する対象が目的変数ではなく説明変数になっている。 つまり教師なし (Unsupervised) な特徴量抽出の手法になる。

実際に使ってみよう。 まずはサンプルデータとしてカテゴリ変数と連続変数の両方を含むデータフレームを用意する。 これらはいずれも説明変数であり、目的変数は別にあると考えてもらいたい。 たとえば、そのフルーツが美味しいかどうかみたいな。

>>> train_df = pl.DataFrame(
...     {
...         "fruits": ["apple", "apple", "banana", "banana", "cherry"],
...         "price": [100, 200, 300, 400, 500],
...     }
... )

エンコーダは colsagg_exprs 引数の指定が必須になる。 cols には groupby() するのに使われるカラムを指定し、agg_exprs には agg() するのに使われる式を指定する。 agg_exprs については集計した後に作られるカラムのサフィックスを辞書のキーとして指定する。

>>> encoder = sk.AggregateEncoder(
...     cols=[
...         "fruits",
...     ],
...     agg_exprs={
...         "mean": pl.col("price").mean(),
...         "max": pl.col("price").max(),
...     },
... )

上記であれば fruits カラムについて groupby() して price カラムの平均値と最大値をエンコードする、という意味になる。

変換してみると、カテゴリごとの平均値と最大値にエンコードされることがわかる。

>>> encoder.fit_transform(train_df)
shape: (5, 2)
┌─────────────┬────────────┐
│ fruits_mean ┆ fruits_max │
│ ---         ┆ ---        │
│ f64         ┆ i64        │
╞═════════════╪════════════╡
│ 150.0200        │
│ 150.0200        │
│ 350.0400        │
│ 350.0400        │
│ 500.0500        │
└─────────────┴────────────┘

パフォーマンスについて

さて、ここまでで一通り現時点で実装されているエンコーダの説明が終わった。 続いては、パフォーマンスについて雑にベンチマークして確かめてみよう。 長いので結果だけ知りたい場合は下の方にスクロールしてもらうとグラフがある。

データセットについてはカテゴリ変数がそれなりに含まれている Diamonds を使う。 カテゴリ変数のカーディナリティが低い点については目をつぶることにする。

$ wget https://raw.githubusercontent.com/mwaskom/seaborn-data/master/diamonds.csv

比較対象とするエンコーダなどをインストールしておく。

$ pip install ipython pandas category_encoders nyaggle xfeat
$ pip list | egrep "(pandas|scikit-learn|category-encoders|nyaggle|xfeat)"
category-encoders                 2.6.0
nyaggle                           0.1.5
pandas                            1.5.3
scikit-learn                      1.2.1
xfeat                             0.1.1

IPython のインタプリタを起動する。

$ ipython

必要なパッケージをインポートしておく。

import polars as pl
import shirokumas as sk
import category_encoders as ce
import xfeat

データセットを読み込む。

raw_df = pl.read_csv("diamonds.csv")

そのままだとデータサイズが小さいので連結して大きくしておく。

pl_df = pl.concat([raw_df for _ in range(200)])

比較対象として使う Pandas のデータフレームも用意する。

pd_df = pl_df.to_pandas()

Polars のデータフレームはメモリ上で 930MB ほどのサイズになっている。

pl_df.dtypes
[Float64, Utf8, Utf8, Utf8, Float64, Float64, Int64, Float64, Float64, Float64]
pl_df.estimated_size(unit="mb")
930.0697555541992

Pandas のデータフレームはメモリ上で 2.4GB ほどのサイズになっている。

pd_df.info(memory_usage="deep")
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 10788000 entries, 0 to 10787999
Data columns (total 10 columns):
 #   Column   Dtype  
---  ------   -----  
 0   carat    float64
 1   cut      object 
 2   color    object 
 3   clarity  object 
 4   depth    float64
 5   table    float64
 6   price    int64  
 7   x        float64
 8   y        float64
 9   z        float64
dtypes: float64(6), int64(1), object(3)
memory usage: 2.4 GB

OrdinalEncoder

まずは OrdinalEncoder から確認する。

カテゴリ変数のカラムである cut, color, clarity をエンコードする。 先頭をエンコードして出力を確かめておく。

pl_encoder = sk.OrdinalEncoder(cols=["cut", "color", "clarity"])
pl_encoder.fit_transform(pl_df[:5])
shape: (5, 3)
┌─────┬───────┬─────────┐
│ cut ┆ color ┆ clarity │
│ --- ┆ ---   ┆ ---     │
│ i64 ┆ i64   ┆ i64     │
╞═════╪═══════╪═════════╡
│ 111       │
│ 212       │
│ 313       │
│ 224       │
│ 331       │
└─────┴───────┴─────────┘

データ全体をエンコードするのにかかる時間を %timeit マジックコマンドで確認する。

%timeit pl_encoder.fit_transform(pl_df)
758 ms ± 8.65 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

758 ms ± 8.65 ms という結果が得られた。

続いては比較対象として category_encoders の OrdinalEncoder を使う。 こちらも先頭部分をエンコードしてみて出力を確認する。

pd_encoder = ce.OrdinalEncoder(cols=["cut", "color", "clarity"])
pd_encoder.fit_transform(pd_df.iloc[:5])
   carat  cut  color  clarity  depth  table  price     x     y     z
0   0.23    1      1        1   61.5   55.0    326  3.95  3.98  2.43
1   0.21    2      1        2   59.8   61.0    326  3.89  3.84  2.31
2   0.23    3      1        3   56.9   65.0    327  4.05  4.07  2.31
3   0.29    2      2        4   62.4   58.0    334  4.20  4.23  2.63
4   0.31    3      3        1   63.3   58.0    335  4.34  4.35  2.75

category_encoders についてはエンコードに使われないカラムも一緒に返ってくることがわかる。 ただし、エンコードした cut, color, clarity については同じ結果が得られていることが確認できる。

データ全体をエンコードしてかかる時間を確認してみよう。

%timeit pd_encoder.fit_transform(pd_df)
8.47 s ± 21.6 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

8.47 s ± 21.6 ms という結果が得られた。

OneHotEncoder

続いては OneHotEncoder を試してみよう。

使うカラムは先ほどと同じ。 先頭部分をエンコードして出力を確かめる。

pl_encoder = sk.OneHotEncoder(cols=["cut", "color", "clarity"])
pl_encoder.fit_transform(pl_df[:5])
shape: (5, 10)
┌─────────┬───────────┬────────┬───────┬─────┬───────────┬───────────┬───────────┬───────────┐
│ cut_Ide ┆ cut_Premi ┆ cut_Go ┆ color ┆ ... ┆ clarity_S ┆ clarity_S ┆ clarity_V ┆ clarity_V │
│ al      ┆ um        ┆ od     ┆ _E    ┆     ┆ I2        ┆ I1        ┆ S1        ┆ S2        │
│ ---     ┆ ---       ┆ ---    ┆ ---   ┆     ┆ ---       ┆ ---       ┆ ---       ┆ ---       │
│ boolboolboolbool  ┆     ┆ boolboolboolbool      │
╞═════════╪═══════════╪════════╪═══════╪═════╪═══════════╪═══════════╪═══════════╪═══════════╡
│ true    ┆ false     ┆ false  ┆ true  ┆ ... ┆ true      ┆ false     ┆ false     ┆ false     │
│ false   ┆ true      ┆ false  ┆ true  ┆ ... ┆ false     ┆ true      ┆ false     ┆ false     │
│ false   ┆ false     ┆ true   ┆ true  ┆ ... ┆ false     ┆ false     ┆ true      ┆ false     │
│ false   ┆ true      ┆ false  ┆ false ┆ ... ┆ false     ┆ false     ┆ false     ┆ true      │
│ false   ┆ false     ┆ true   ┆ false ┆ ... ┆ true      ┆ false     ┆ false     ┆ false     │
└─────────┴───────────┴────────┴───────┴─────┴───────────┴───────────┴───────────┴───────────┘

データ全体をエンコードして時間を確認する。

%timeit pl_encoder.fit_transform(pl_df)
836 ms ± 15.2 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

836 ms ± 15.2 ms という結果が得られた。

同様に category_encoders の OneHotEncoder でも確かめる。

pd_encoder = ce.OneHotEncoder(cols=["cut", "color", "clarity"])
pd_encoder.fit_transform(pd_df.iloc[:5])
   carat  cut_1  cut_2  cut_3  color_1  ...  table  price     x     y     z
0   0.23      1      0      0        1  ...   55.0    326  3.95  3.98  2.43
1   0.21      0      1      0        1  ...   61.0    326  3.89  3.84  2.31
2   0.23      0      0      1        1  ...   65.0    327  4.05  4.07  2.31
3   0.29      0      1      0        0  ...   58.0    334  4.20  4.23  2.63
4   0.31      0      0      1        0  ...   58.0    335  4.34  4.35  2.75

[5 rows x 17 columns]

データ全体をエンコードする。

%timeit pd_encoder.fit_transform(pd_df)
20.5 s ± 912 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

こちらは 20.5 s ± 912 ms という結果が得られた。

CountEncoder

続いては CountEncoder を試してみよう。

pl_encoder = sk.CountEncoder(cols=["cut", "color", "clarity"])
pl_encoder.fit_transform(pl_df[:5])
shape: (5, 3)
┌─────┬───────┬─────────┐
│ cut ┆ color ┆ clarity │
│ --- ┆ ---   ┆ ---     │
│ i64 ┆ i64   ┆ i64     │
╞═════╪═══════╪═════════╡
│ 132       │
│ 231       │
│ 231       │
│ 211       │
│ 212       │
└─────┴───────┴─────────┘
%timeit pl_encoder.fit_transform(pl_df)
508 ms ± 5.05 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

508 ms ± 5.05 ms という結果が得られた。

続いては category_encoder の CountEncoder で試す。

pd_encoder = ce.CountEncoder(cols=["cut", "color", "clarity"])
pd_encoder.fit_transform(pd_df.iloc[:5])
   carat  cut  color  clarity  depth  table  price     x     y     z
0   0.23    1      3        2   61.5   55.0    326  3.95  3.98  2.43
1   0.21    2      3        1   59.8   61.0    326  3.89  3.84  2.31
2   0.23    2      3        1   56.9   65.0    327  4.05  4.07  2.31
3   0.29    2      1        1   62.4   58.0    334  4.20  4.23  2.63
4   0.31    2      1        2   63.3   58.0    335  4.34  4.35  2.75
%timeit pd_encoder.fit_transform(pd_df)
17.2 s ± 64.2 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

17.2 s ± 64.2 ms という結果が得られた。

TargetEncoder

続いては TargetEncoder で試してみる。 条件は 5 fold で Smoothing には k=20, f=10 の empirical bayesian を使う。 カラムについては cut だけを使用した。

from sklearn.model_selection import KFold
folds = KFold(n_splits=5, shuffle=False)
pl_encoder = sk.TargetEncoder(
    folds=folds,
    cols=["cut"],
    smoothing_method="eb",
    smoothing_params={
        "k": 20,
        "f": 10,
    }
)
pl_x = pl_df.select(pl.exclude("price"))
pl_y = pl_df.get_column("price")
pl_encoder.fit_transform(pl_x[:5], pl_y[:5])
shape: (5, 1)
┌────────────┐
│ cut        │
│ ---        │
│ f64        │
╞════════════╡
│ 330.5      │
│ 330.95538  │
│ 330.868015 │
│ 328.174729 │
│ 328.087364 │
└────────────┘

データ全体をエンコードする。

%timeit pl_encoder.fit_transform(pl_x, pl_y)
2.91 s ± 114 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

2.91 s ± 114 ms という結果が得られた。

続いては nyaggle 6 の TargetEncoder で試す。 category_encoders の TargetEncoder を使わないのは Out-of-Fold での計算をサポートしていないため。 ただし nyaggle は category_encoders の TargetEncoder をラップした実装になっている。

from nyaggle.feature.category_encoder.target_encoder import TargetEncoder
pd_encoder = TargetEncoder(
    cv=folds,
    cols=["cut"],
    smoothing=10.,
    min_samples_leaf=20.,
)
pd_x = pd_df.drop(["price"], axis=1)
pd_y = pd_df["price"]
pd_encoder.fit_transform(pd_x.iloc[:5], pd_y.iloc[:5])
   carat         cut color clarity  depth  table     x     y     z
0   0.23  330.500000     E     SI2   61.5   55.0  3.95  3.98  2.43
1   0.21  330.955380     E     SI1   59.8   61.0  3.89  3.84  2.31
2   0.23  330.868015     E     VS1   56.9   65.0  4.05  4.07  2.31
3   0.29  328.174729     I     VS2   62.4   58.0  4.20  4.23  2.63
4   0.31  328.087364     J     SI2   63.3   58.0  4.34  4.35  2.75

cut カラムの結果が、先ほど Shirokumas で計算した結果とまったく同じ点に注目してもらいたい。

データ全体をエンコードする。

%timeit pd_encoder.fit_transform(pd_x, pd_y)
40.3 s ± 305 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

40.3 s ± 305 ms という結果が得られた。

AggregateEncoder

続いては AggregateEncoder を試す。 cut カラムについては price カラムの平均をエンコードする。

pl_encoder = sk.AggregateEncoder(
    cols=["cut"],
    agg_exprs={
        "mean": pl.col("carat").mean(),
    }
)
pl_encoder.fit_transform(pl_df[:5])
shape: (5, 1)
┌──────────┐
│ cut_mean │
│ ---      │
│ f64      │
╞══════════╡
│ 0.23     │
│ 0.25     │
│ 0.27     │
│ 0.25     │
│ 0.27     │
└──────────┘

データ全体を計算する。

%timeit pl_encoder.fit_transform(pl_df)
196 ms ± 8.08 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

196 ms ± 8.08 ms という結果が得られた。

比較対象としては xfeat 7 の aggregation() 関数を使う。 というのも、このエンコード手法が実装されているフレームワークの例を他に知らないため。

agg_df, _ = xfeat.aggregation(
    pd_df[:5],
    group_key="cut",
    group_values=["carat"],
    agg_methods=["mean"],
)
agg_df.iloc[:, -1]
0    0.23
1    0.25
2    0.27
3    0.25
4    0.27
Name: agg_mean_carat_grpby_cut, dtype: float64

計算結果は先ほど Shirokumas で求めたものと一致している。

%%timeit
xfeat.aggregation(
    pd_df,
    group_key="cut",
    group_values=["carat"],
    agg_methods=["mean"],
)
1.44 s ± 17.2 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

1.44 s ± 17.2 ms という結果が得られた。

結果

ここまでの内容をグラフにまとめるとこんな感じ。 左側の縦軸の単位はミリ秒。

処理にかかる時間の比較

まとめ

今回は Polars 版の category_encoders といえる「Shirokumas」について紹介した。 また、Pandas と対応するフレームワークとの比較では、エンコードにかかる時間の短縮が確認できた。

もちろん、パフォーマンスに関しては GPU を使ったフレームワーク (cuDF/cuML など) を使った方が高速に処理できる場合が多いかと思う。 とはいえ、特別なアクセラレータなしにこれだけのパフォーマンス向上が望めるのは、やはり大きなインパクトがあるのではないだろうか。


  1. https://contrib.scikit-learn.org/category_encoders/
  2. category_encoders ではデフォルトで category 型だけを対象にするはず
  3. category_encoders のデフォルト値を参考にした
  4. category_encoders のデフォルト値を参考にした
  5. この手法を紹介している論文があったら教えてもらいたい
  6. https://github.com/nyanp/nyaggle
  7. https://github.com/pfnet-research/xfeat