CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: xfeat を使った特徴量エンジニアリング

今回は PFN が公開している OSS の xfeat を使った特徴量エンジニアリングについて見ていく。 xfeat には次のような特徴がある。

  • 多くの機能が scikit-learn の Transformer 互換の API で提供されている
  • 多くの機能が CuPy / CuDF に対応しているため CUDA 環境で高いパフォーマンスが得られる
  • 多くの機能がデータフレームを入力としてデータフレームを出力とした API になっている

使った環境は次のとおり。

$ sw_vers
ProductName:    macOS
ProductVersion: 12.2.1
BuildVersion:   21D62
$ uname -srm
Darwin 21.3.0 arm64
$ conda -V                         
conda 4.10.3
$ python -V        
Python 3.9.10
$ pip list | grep xfeat
xfeat           0.1.1

もくじ

下準備

AppleSilicon 版の Mac を使う場合、Python の実行環境に Miniforge を使う。 これは xfeat が依存しているいくつかのパッケージが、まだ pip からインストールできないため。

blog.amedama.jp

あらかじめ Miniforge を使って仮想環境を作る。

$ conda create -y -n venv python=3.9
$ conda activate venv

pip からインストールできない依存パッケージの LightGBM と PyArrow をインストールする。 ついでに、今回のサンプルコードで使用する scikit-learn と seaborn も入れておく。

$ conda install -y lightgbm pyarrow scikit-learn seaborn

最後に xfeat をインストールする。 現時点 (2022-02-20)では xfeat が依存しているパッケージの ml_metrics が setuptools v58 以降の環境でインストールできない。 そこで setuptools を v58 未満にダウングレードする必要がある。

$ pip install -U "setuptools<58"
$ pip install xfeat

ちなみに、Intel 版の Mac であれば以下だけでインストールできる。

$ pip install -U "setuptools<58"
$ pip install xfeat scikit-learn seaborn

インストールが終わったら Python のインタプリタを起動する。

$ python

今回、データセットには seaborn に同梱されている diamonds をサンプルとして用いる。 このデータセットにはカテゴリ変数と連続変数の両方が含まれているので、特徴量エンジニアリングの説明に都合が良い。

>>> import seaborn as sns
>>> df = sns.load_dataset('diamonds')
>>> df.head()
   carat      cut color clarity  depth  table  price     x     y     z
0   0.23    Ideal     E     SI2   61.5   55.0    326  3.95  3.98  2.43
1   0.21  Premium     E     SI1   59.8   61.0    326  3.89  3.84  2.31
2   0.23     Good     E     VS1   56.9   65.0    327  4.05  4.07  2.31
3   0.29  Premium     I     VS2   62.4   58.0    334  4.20  4.23  2.63
4   0.31     Good     J     SI2   63.3   58.0    335  4.34  4.35  2.75
>>> df.dtypes
carat       float64
cut        category
color      category
clarity    category
depth       float64
table       float64
price         int64
x           float64
y           float64
z           float64
dtype: object

最後に xfeat をインポートしたら準備は終わり。

>>> import xfeat

特定の種類の変数を取り出す

まずはデータフレームから特定の種類の変数だけを取り出す機能から見ていく。 この機能を使うとデータの型に基づいてカテゴリ変数や連続変数のカラムだけを選択できる。 この機能は、後述する Pipeline の機能と組み合わせると便利に使える。

SelectCategorical

まず、SelectCategorical を使うとカテゴリ変数だけ取り出すことができる。

>>> select_cat = xfeat.SelectCategorical()
>>> select_cat.fit_transform(df)
             cut color clarity
0          Ideal     E     SI2
1        Premium     E     SI1
2           Good     E     VS1
3        Premium     I     VS2
4           Good     J     SI2
...          ...   ...     ...
53935      Ideal     D     SI1
53936       Good     D     SI1
53937  Very Good     D     SI1
53938    Premium     H     SI2
53939      Ideal     D     SI2

[53940 rows x 3 columns]

SelectNumerical

同様に SelectNumerical を使うと連続変数が取り出せる。

>>> select_num = xfeat.SelectNumerical()
>>> select_num.fit_transform(df)
       carat  depth  table  price     x     y     z
0       0.23   61.5   55.0    326  3.95  3.98  2.43
1       0.21   59.8   61.0    326  3.89  3.84  2.31
2       0.23   56.9   65.0    327  4.05  4.07  2.31
3       0.29   62.4   58.0    334  4.20  4.23  2.63
4       0.31   63.3   58.0    335  4.34  4.35  2.75
...      ...    ...    ...    ...   ...   ...   ...
53935   0.72   60.8   57.0   2757  5.75  5.76  3.50
53936   0.72   63.1   55.0   2757  5.69  5.75  3.61
53937   0.70   62.8   60.0   2757  5.66  5.68  3.56
53938   0.86   61.0   58.0   2757  6.15  6.12  3.74
53939   0.75   62.2   55.0   2757  5.83  5.87  3.64

[53940 rows x 7 columns]

カテゴリ変数のエンコード

次にカテゴリ変数のエンコードに使える機能を見ていこう。

LabelEncoder

LabelEncoder を使うとラベルエンコードができる。 scikit-learn の sklearn.preprocessing.LabelEncoder と比べるとデータフレームをそのまま入れられるメリットがある。 デフォルトでは、入力したデータフレームに _le というサフィックスがついたカラムが追加される 1

>>> label_encoder = xfeat.LabelEncoder()
>>> label_encoder.fit_transform(df[['cut']])
             cut  cut_le
0          Ideal       0
1        Premium       1
2           Good       2
3        Premium       1
4           Good       2
...          ...     ...
53935      Ideal       0
53936       Good       2
53937  Very Good       3
53938    Premium       1
53939      Ideal       0

[53940 rows x 2 columns]

元のカラムがいらないときは、output_suffix オプションに空文字を入れると上書きできる。

>>> label_encoder = xfeat.LabelEncoder(output_suffix='')
>>> label_encoder.fit_transform(df[['cut']])
       cut
0        0
1        1
2        2
3        1
4        2
...    ...
53935    0
53936    2
53937    3
53938    1
53939    0

[53940 rows x 1 columns]

また、複数のカラムが含まれるデータフレームを渡せば、複数のカテゴリ変数を一度にエンコードできる。

>>> label_encoder.fit_transform(df[['cut', 'color', 'clarity']])
       cut  color  clarity
0        0      0        0
1        1      0        1
2        2      0        2
3        1      1        3
4        2      2        0
...    ...    ...      ...
53935    0      6        1
53936    2      6        1
53937    3      6        1
53938    1      3        0
53939    0      6        0

[53940 rows x 3 columns]

この点は、前述した SelectCategoricalPipeline の機能を組み合わせると上手く動作してくれる。

>>> pipe = xfeat.Pipeline([
...     xfeat.SelectCategorical(),
...     xfeat.LabelEncoder(output_suffix=''),
... ])
>>> pipe.fit_transform(df)
       cut  color  clarity
0        0      0        0
1        1      0        1
2        2      0        2
3        1      1        3
4        2      2        0
...    ...    ...      ...
53935    0      6        1
53936    2      6        1
53937    3      6        1
53938    1      3        0
53939    0      6        0

[53940 rows x 3 columns]

未知のデータが含まれていた際の振る舞いは、デフォルトでは -1 が入る。

>>> label_encoder = xfeat.LabelEncoder()
>>> label_encoder.fit(df[['cut']])
>>> data = {
...     'cut': ['Ideal', 'Premium', 'Very Good', 'Good', 'Fair', 'Unknown', 'Unseen'],
... }
>>> import pandas as pd
>>> new_df = pd.DataFrame(data)
>>> label_encoder.transform(new_df)
         cut  cut_le
0      Ideal       0
1    Premium       1
2  Very Good       3
3       Good       2
4       Fair       4
5    Unknown      -1
6     Unseen      -1

この振る舞いは unseen オプションを使って変更できる。 たとえば n_unique を指定すると、これまでに出現したラベルの値から連続した値が割り当てられる。

>>> label_encoder = xfeat.LabelEncoder(unseen='n_unique')
>>> label_encoder.fit(df[['cut']])
>>> label_encoder.transform(new_df)
         cut  cut_le
0      Ideal       0
1    Premium       1
2  Very Good       3
3       Good       2
4       Fair       4
5    Unknown       5
6     Unseen       5

CountEncoder

同様に CountEncoder を使うとカウントエンコードができる。 これは同じ値がデータの中にいくつ含まれるかを特徴量として生成する。

>>> count_encoder = xfeat.CountEncoder()
>>> count_encoder.fit_transform(df[['cut']])
             cut  cut_ce
0          Ideal   21551
1        Premium   13791
2           Good    4906
3        Premium   13791
4           Good    4906
...          ...     ...
53935      Ideal   21551
53936       Good    4906
53937  Very Good   12082
53938    Premium   13791
53939      Ideal   21551

[53940 rows x 2 columns]

ConcatCombination

ConcatCombination では、変数の値を連結することで新しいカテゴリ変数を作り出せる。 drop_origin オプションを True に指定すると、元の特徴量を落としたデータフレームになる。 また、組み合わせる数は r オプションで指定する。

>>> concat_combi = xfeat.ConcatCombination(drop_origin=True, r=2)
>>> concat_combi.fit_transform(df[['cut', 'color', 'clarity']].astype(str))
      cutcolor_combi cutclarity_combi colorclarity_combi
0             IdealE         IdealSI2               ESI2
1           PremiumE       PremiumSI1               ESI1
2              GoodE          GoodVS1               EVS1
3           PremiumI       PremiumVS2               IVS2
4              GoodJ          GoodSI2               JSI2
...              ...              ...                ...
53935         IdealD         IdealSI1               DSI1
53936          GoodD          GoodSI1               DSI1
53937     Very GoodD     Very GoodSI1               DSI1
53938       PremiumH       PremiumSI2               HSI2
53939         IdealD         IdealSI2               DSI2

[53940 rows x 3 columns]

上記でカラムの型を str にキャストしているのは category 型のままだと例外になるため。

>>> concat_combi.fit_transform(df[['cut', 'color', 'clarity']])
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
...
    raise TypeError(
TypeError: Cannot setitem on a Categorical with a new category (_NaN_), set the categories first

TargetEncoder

TargetEncoder を使うとターゲットエンコードができる。 データの分割方法は fold というオプションで指定できる。

>>> from sklearn.model_selection import KFold
>>> folds = KFold(n_splits=2, shuffle=False)
>>> target_encoder = xfeat.TargetEncoder(fold=folds, target_col='price')
>>> target_encoder.fit_transform(df[['cut', 'price']])
             cut  price       cut_te
0          Ideal    326  1561.627930
1        Premium    326  1949.366089
2           Good    327  1773.722046
3        Premium    334  1949.366089
4           Good    335  1773.722046
...          ...    ...          ...
53935      Ideal   2757  6113.638184
53936       Good   2757  5552.538574
53937  Very Good   2757  5893.533203
53938    Premium   2757  6663.661621
53939      Ideal   2757  6113.638184

[53940 rows x 3 columns]

なお、Target Encoding の詳細は下記のエントリに書いたことがある。

blog.amedama.jp

連続変数のエンコード

続いて連続変数のエンコードについて見ていく。

ArithmeticCombinations

ArithmeticCombinations を使うと、複数のカラムを四則演算するといった特徴量が計算できる。 たとえば、以下では 2 つのカラムを足し合わせた特徴量を生成している。

>>> add_combi = xfeat.ArithmeticCombinations(drop_origin=True, operator='+', r=2)
>>> add_combi.fit_transform(df[['x', 'y', 'z']])
       xy_combi  xz_combi  yz_combi
0          7.93      6.38      6.41
1          7.73      6.20      6.15
2          8.12      6.36      6.38
3          8.43      6.83      6.86
4          8.69      7.09      7.10
...         ...       ...       ...
53935     11.51      9.25      9.26
53936     11.44      9.30      9.36
53937     11.34      9.22      9.24
53938     12.27      9.89      9.86
53939     11.70      9.47      9.51

[53940 rows x 3 columns]

独自の加工をするエンコーダを作る

その他、LambdaEncoder を使うことで、自分で加工方法を定義したエンコーダを指定することもできる。 以下では例として値を 2 倍するエンコーダを作っている。

>>> double_encoder = xfeat.LambdaEncoder(lambda x: x * 2, drop_origin=False, output_suffix='_double')
>>> double_encoder.fit_transform(df[['x', 'y']])
          x     y  x_double  y_double
0      3.95  3.98      7.90      7.96
1      3.89  3.84      7.78      7.68
2      4.05  4.07      8.10      8.14
3      4.20  4.23      8.40      8.46
4      4.34  4.35      8.68      8.70
...     ...   ...       ...       ...
53935  5.75  5.76     11.50     11.52
53936  5.69  5.75     11.38     11.50
53937  5.66  5.68     11.32     11.36
53938  6.15  6.12     12.30     12.24
53939  5.83  5.87     11.66     11.74

[53940 rows x 4 columns]

集約特徴量

特定のカラムの値を Group By のキーにして要約統計量を計算するような特徴量は aggregation() 関数を使って計算できる。 この API は scikit-learn の Transformer 互換になっていない点に注意が必要。 以下では cut をキーにして、x, y, z の値にいくつかの統計量を計算している。

>>> df_agg, agg_cols = xfeat.aggregation(df,
...                                      group_key='cut',
...                                      group_values=['x', 'y', 'z'],
...                                      agg_methods=['sum', 'min', 'max', 'mean', 'median'],
...                                      )

結果はタプルで得られる。 最初の要素にはデータフレームが入っている。

>>> df_agg
       carat        cut color clarity  depth  table  ...  agg_mean_x_grpby_cut  agg_mean_y_grpby_cut  agg_mean_z_grpby_cut  agg_median_x_grpby_cut  agg_median_y_grpby_cut  agg_median_z_grpby_cut
0       0.23      Ideal     E     SI2   61.5   55.0  ...              5.507451              5.520080              3.401448                    5.25                    5.26                    3.23
1       0.21    Premium     E     SI1   59.8   61.0  ...              5.973887              5.944879              3.647124                    6.11                    6.06                    3.72
2       0.23       Good     E     VS1   56.9   65.0  ...              5.838785              5.850744              3.639507                    5.98                    5.99                    3.70
3       0.29    Premium     I     VS2   62.4   58.0  ...              5.973887              5.944879              3.647124                    6.11                    6.06                    3.72
4       0.31       Good     J     SI2   63.3   58.0  ...              5.838785              5.850744              3.639507                    5.98                    5.99                    3.70
...      ...        ...   ...     ...    ...    ...  ...                   ...                   ...                   ...                     ...                     ...                     ...
53935   0.72      Ideal     D     SI1   60.8   57.0  ...              5.507451              5.520080              3.401448                    5.25                    5.26                    3.23
53936   0.72       Good     D     SI1   63.1   55.0  ...              5.838785              5.850744              3.639507                    5.98                    5.99                    3.70
53937   0.70  Very Good     D     SI1   62.8   60.0  ...              5.740696              5.770026              3.559801                    5.74                    5.77                    3.56
53938   0.86    Premium     H     SI2   61.0   58.0  ...              5.973887              5.944879              3.647124                    6.11                    6.06                    3.72
53939   0.75      Ideal     D     SI2   62.2   55.0  ...              5.507451              5.520080              3.401448                    5.25                    5.26                    3.23

[53940 rows x 25 columns]

タプルの二番目の要素には、生成されたカラム名の入ったリストが入っている。

>>> from pprint import pprint
>>> pprint(agg_cols)
['agg_sum_x_grpby_cut',
 'agg_sum_y_grpby_cut',
 'agg_sum_z_grpby_cut',
 'agg_min_x_grpby_cut',
 'agg_min_y_grpby_cut',
 'agg_min_z_grpby_cut',
 'agg_max_x_grpby_cut',
 'agg_max_y_grpby_cut',
 'agg_max_z_grpby_cut',
 'agg_mean_x_grpby_cut',
 'agg_mean_y_grpby_cut',
 'agg_mean_z_grpby_cut',
 'agg_median_x_grpby_cut',
 'agg_median_y_grpby_cut',
 'agg_median_z_grpby_cut']

特徴量選択

ここまでは、主に特徴量エンジニアリングの中でも特徴量抽出 (Feature Extraction) の機能を見てきた。 ここからは特徴量選択 (Feature Selection) の機能を見ていく。

DuplicatedFeatureEliminator

DuplicatedFeatureEliminator を使うと、重複した特徴量を削除できる。 たとえば、次のようにまったく同じ値の入ったカラムが xx2 として含まれるデータフレームがあるとする。

>>> new_df = df[['x']].copy()
>>> new_df['x2'] = df['x']
>>> new_df
          x    x2
0      3.95  3.95
1      3.89  3.89
2      4.05  4.05
3      4.20  4.20
4      4.34  4.34
...     ...   ...
53935  5.75  5.75
53936  5.69  5.69
53937  5.66  5.66
53938  6.15  6.15
53939  5.83  5.83

[53940 rows x 2 columns]

重複した特徴量は、どちらかさえあれば予測には十分なはず。 DuplicatedFeatureEliminator を使うと、片方だけ残して特徴量を削除できる。

>>> dup_eliminator = xfeat.DuplicatedFeatureEliminator()
>>> dup_eliminator.fit_transform(new_df)
          x
0      3.95
1      3.89
2      4.05
3      4.20
4      4.34
...     ...
53935  5.75
53936  5.69
53937  5.66
53938  6.15
53939  5.83

[53940 rows x 1 columns]

ConstantFeatureEliminator

同様に ConstantFeatureEliminator を使うと定数になっている特徴量を削除できる。 たとえば、すべての値が 1 になっている a というカラムの入ったデータフレームを用意する。

>>> new_df = df[['x']].copy()
>>> new_df['a'] = 1
>>> new_df
          x  a
0      3.95  1
1      3.89  1
2      4.05  1
3      4.20  1
4      4.34  1
...     ... ..
53935  5.75  1
53936  5.69  1
53937  5.66  1
53938  6.15  1
53939  5.83  1

[53940 rows x 2 columns]

分散のない特徴量は予測に寄与しないはず。 DuplicatedFeatureEliminator を使うと、そのような特徴量を削除できる。

>>> const_eliminator = xfeat.ConstantFeatureEliminator()
>>> const_eliminator.fit_transform(new_df)
          x
0      3.95
1      3.89
2      4.05
3      4.20
4      4.34
...     ...
53935  5.75
53936  5.69
53937  5.66
53938  6.15
53939  5.83

[53940 rows x 1 columns]

SpearmanCorrelationEliminator

SpearmanCorrelationEliminator を使うと、高い相関を持った特徴量を削除できる。 たとえば、あるカラムに定数を加えただけのカラムを含んだデータフレームを用意する。

>>> new_df = df[['x']].copy()
>>> new_df['x2'] = df['x'] + 0.1
>>> new_df
          x    x2
0      3.95  4.05
1      3.89  3.99
2      4.05  4.15
3      4.20  4.30
4      4.34  4.44
...     ...   ...
53935  5.75  5.85
53936  5.69  5.79
53937  5.66  5.76
53938  6.15  6.25
53939  5.83  5.93

[53940 rows x 2 columns]

上記の特徴量は相関係数が 1.0 になっている。

>>> new_df.corr()
      x   x2
x   1.0  1.0
x2  1.0  1.0

極端に相関係数の高い特徴量も、予測においては片方があれば十分と考えられる。 SpearmanCorrelationEliminator を使うと片方だけ残して削除できる。

>>> corr_eliminator = xfeat.SpearmanCorrelationEliminator()
>>> corr_eliminator.fit_transform(new_df)
          x
0      3.95
1      3.89
2      4.05
3      4.20
4      4.34
...     ...
53935  5.75
53936  5.69
53937  5.66
53938  6.15
53939  5.83

[53940 rows x 1 columns]

GBDTFeatureSelector

GBDTFeatureSelector を使うと GBDT (Gradient Boosting Decision Tree) を用いて、特徴量の重要度に基づいた特徴量選択ができる。 なお、ここでいう GBDT としては LightGBM が使われている。

まず、LightGBM はカテゴリ変数をそのままだと受け付けないので、一旦ラベルエンコードしておく。

>>> pipe = xfeat.Pipeline([
...     xfeat.LabelEncoder(input_cols=['cut', 'color', 'clarity'],
...                        output_suffix=''),
... ])
>>> df = pipe.fit_transform(df)

ここでは threshold オプションに 0.5 を指定することで、重要と考えられる特徴量を 50% 残してみよう。 この値はハイパーパラメータなので、実際にはいくつかの値を試して予測精度や計算量のバランスを取っていく必要がある

>>> lgbm_params = {
...     'objective': 'regression',
...     'metric': 'rmse',
...     'verbosity': -1,
... }
>>> lgbm_fit_params = {
...     'num_boost_round': 1_000,
... }
>>> gbdt_selector = xfeat.GBDTFeatureSelector(target_col='price',
...                                           threshold=0.5,
...                                           lgbm_params=lgbm_params,
...                                           lgbm_fit_kwargs=lgbm_fit_params,
...                                           )
>>> selected_df = gbdt_selector.fit_transform(df)

上記を実行すると carat, y, depth, z という 4 つのカラムが選ばれた。 これらが、GBDT で予測する場合には重要となる特徴量の上位 50% ということ。

>>> selected_df
       carat     y  depth     z
0       0.23  3.98   61.5  2.43
1       0.21  3.84   59.8  2.31
2       0.23  4.07   56.9  2.31
3       0.29  4.23   62.4  2.63
4       0.31  4.35   63.3  2.75
...      ...   ...    ...   ...
53935   0.72  5.76   60.8  3.50
53936   0.72  5.75   63.1  3.61
53937   0.70  5.68   62.8  3.56
53938   0.86  6.12   61.0  3.74
53939   0.75  5.87   62.2  3.64

[53940 rows x 4 columns]

GBDTFeatureExplorer

GBDTFeatureSelector を使う場合、残す特徴量の割合を threshold オプションとして自分で指定する必要があった。 予測精度が最も高いものが欲しい場合であれば、GBDTFeatureExplorer を使うことで自動で探索させることもできる。

この機能は少し複雑なのでスクリプトにした。 以下にサンプルコードを示す。 以下では diamonds データセットを使って、price カラムを目的変数に RMSE のメトリックで回帰問題として解いている。 最初に xfeat を使って特徴量抽出をしており、機械的に 265 次元まで増やしている。 そして、予測精度が最も良くなるように GBDTFeatureExplorer を使って特徴量選択している。

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

from functools import partial

import pandas as pd
import numpy as np
import seaborn as sns
import xfeat
import lightgbm as lgb
import optuna
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import KFold
from sklearn.model_selection import train_test_split


def _lgbm_cv(train_x, train_y):
    """LightGBM を使った交差検証のヘルパー関数"""
    lgbm_params = {
        'objective': 'regression',
        'metric': 'rmse',
        'verbosity': -1,
    }
    train_dataset = lgb.Dataset(data=train_x,
                                label=train_y)
    folds = KFold(n_splits=5,
                  shuffle=True,
                  random_state=42)
    cv_result = lgb.cv(lgbm_params,
                       train_dataset,
                       num_boost_round=1_000,
                       folds=folds,
                       return_cvbooster=True,
                       )
    return cv_result


def _rmse(y_true, y_pred):
    """RMSE を計算するヘルパー関数"""
    mse = mean_squared_error(y_true, y_pred)
    rmse = np.sqrt(mse)
    return rmse


def _evaluate(train_x, train_y, test_x, test_y):
    """学習用データの CV とテストデータの誤差を確認するヘルパー関数"""
    cv_result = _lgbm_cv(train_x, train_y)

    cv_rmse_mean = cv_result['rmse-mean'][-1]
    print(f'CV RMSE: {cv_rmse_mean}')

    cvbooster = cv_result['cvbooster']
    y_preds = cvbooster.predict(test_x)
    y_pred = np.mean(y_preds, axis=0)
    test_rmse = _rmse(y_pred, test_y)
    print(f'Test RMSE: {test_rmse}')


def objective(df, selector, trial):
    """Optuna の目的関数"""
    # 次に試行する特徴量の組み合わせを得る
    selector.set_trial(trial)
    selector.fit(df)
    input_cols = selector.get_selected_cols()

    # 選択された特徴量から得られる Local CV のスコアを計算する
    train_x = df[input_cols].drop(['price'], axis=1)
    train_y = df['price']
    cv_result = _lgbm_cv(train_x, train_y)
    # スコアの平均を返す
    mean_score = cv_result['rmse-mean'][-1]
    return mean_score


def main():
    # データセットを読み込む
    df = sns.load_dataset('diamonds')

    # ベースとなるカラム毎の変数の種類
    categorical_cols = ['cut', 'color', 'clarity']
    numerical_cols = ['carat', 'depth', 'table', 'x', 'y', 'z']
    target_col = 'price'

    # fillna の問題があるので str にキャストする
    df = df.astype({
        cat_col: str for cat_col in categorical_cols
    })

    # カテゴリ変数の前処理
    pipe = xfeat.Pipeline([
        # カテゴリ同士の組み合わせ
        xfeat.ConcatCombination(r=2),
        # ラベルエンコード
        xfeat.LabelEncoder(output_suffix=''),
    ])
    cat_df = pipe.fit_transform(df[categorical_cols])
    # カテゴリ変数のリストを更新する
    categorical_cols = cat_df.columns.tolist()
    # 元のデータフレームと結合する
    df = pd.concat([cat_df, df[numerical_cols + [target_col]]], axis=1)
    print(f'add combination features: {len(df.columns)}')

    # カテゴリ変数を中心にした集約特徴量
    for cat_col in categorical_cols:
        df, _ = xfeat.aggregation(df,
                                  group_key=cat_col,
                                  group_values=numerical_cols,
                                  agg_methods=[
                                      'sum',
                                      'min',
                                      'max',
                                      'mean',
                                      'median',
                                  ],
                                  )
    print(f'add aggregation features: {len(df.columns)}')

    # 最終的な評価をするためにデータをホールドアウトしておく
    train_df, test_df = train_test_split(df,
                                         test_size=0.35,
                                         shuffle=True,
                                         random_state=42)

    folds = KFold(n_splits=5,
                  shuffle=True,
                  random_state=42)
    pipe = xfeat.Pipeline([
        # カウントエンコード
        xfeat.CountEncoder(input_cols=categorical_cols),
        # ターゲットエンコード
        xfeat.TargetEncoder(input_cols=categorical_cols,
                            target_col=target_col,
                            fold=folds),
        # 組み合わせ特徴量
        xfeat.ArithmeticCombinations(input_cols=numerical_cols,
                                     operator='+',
                                     r=2,
                                     output_suffix='_plus'),
        xfeat.ArithmeticCombinations(input_cols=numerical_cols,
                                     operator='*',
                                     r=2,
                                     output_suffix='_mul'),
        xfeat.ArithmeticCombinations(input_cols=numerical_cols,
                                     operator='-',
                                     r=2,
                                     output_suffix='_minus'),
        xfeat.ArithmeticCombinations(input_cols=numerical_cols,
                                     operator='/',
                                     r=2,
                                     output_suffix='_div'),
    ])
    train_df = pipe.fit_transform(train_df)
    test_df = pipe.transform(test_df)
    print(f'add some features: {len(train_df.columns)}')

    # 選択前のスコアを計算しておく
    train_x, train_y = train_df.drop(target_col, axis=1), train_df[target_col]
    test_x, test_y = test_df.drop(target_col, axis=1), test_df[target_col]
    _evaluate(train_x, train_y, test_x, test_y)

    # 学習用データセットを使って特徴量選択をする
    lgbm_params = {
        'objective': 'regression',
        'metric': 'rmse',
        'verbosity': -1,
    }
    fit_params = {
        'num_boost_round': 1_000,
    }
    selector = xfeat.GBDTFeatureExplorer(input_cols=train_df.columns.tolist(),
                                         target_col=target_col,
                                         fit_once=True,
                                         threshold_range=(0.1, 1.0),
                                         lgbm_params=lgbm_params,
                                         lgbm_fit_kwargs=fit_params,
                                         )

    # メトリックのスコアが良くなる特徴量の組み合わせを探索する
    study = optuna.create_study(direction='minimize')
    # 最適化する
    study.optimize(partial(objective, train_df, selector),
                   n_trials=10,
                   )

    # 探索で見つかった最善の組み合わせを取り出す
    selector.from_trial(study.best_trial)
    selected_cols = selector.get_selected_cols()

    # 特徴量の数をどれだけ減らせたか
    print(f'selected features: {len(selected_cols)}')

    # 選択後のスコアを計算する
    train_x = train_df[selected_cols].drop(target_col, axis=1)
    test_x = test_df[selected_cols].drop(target_col, axis=1)
    _evaluate(train_x, train_y, test_x, test_y)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python example.py 
add combination features: 13
add aggregation features: 193
add some features: 265
CV RMSE: 544.5696268027966
Test RMSE: 515.6280330475462
[I 2022-02-20 18:44:37,774] A new study created in memory with name: no-name-5d80802a-6e8c-48b1-bf1e-f2843ca3eadd
[I 2022-02-20 18:45:11,947] Trial 0 finished with value: 545.1447126832893 and parameters: {'GBDTFeatureSelector.threshold': 0.8839849802341044}. Best is trial 0 with value: 545.1447126832893.
[I 2022-02-20 18:45:39,267] Trial 1 finished with value: 546.669714703891 and parameters: {'GBDTFeatureSelector.threshold': 0.7619871719011998}. Best is trial 0 with value: 545.1447126832893.
[I 2022-02-20 18:45:56,204] Trial 2 finished with value: 539.8527700066101 and parameters: {'GBDTFeatureSelector.threshold': 0.4415951458583348}. Best is trial 2 with value: 539.8527700066101.
[I 2022-02-20 18:46:22,874] Trial 3 finished with value: 544.7853629837771 and parameters: {'GBDTFeatureSelector.threshold': 0.7281870389232026}. Best is trial 2 with value: 539.8527700066101.
[I 2022-02-20 18:46:33,338] Trial 4 finished with value: 614.75354170118 and parameters: {'GBDTFeatureSelector.threshold': 0.16941573919931688}. Best is trial 2 with value: 539.8527700066101.
[I 2022-02-20 18:46:47,241] Trial 5 finished with value: 558.923413959607 and parameters: {'GBDTFeatureSelector.threshold': 0.26183305989448025}. Best is trial 2 with value: 539.8527700066101.
[I 2022-02-20 18:47:09,066] Trial 6 finished with value: 541.8006025367876 and parameters: {'GBDTFeatureSelector.threshold': 0.5902889999794974}. Best is trial 2 with value: 539.8527700066101.
[I 2022-02-20 18:47:22,243] Trial 7 finished with value: 545.7942530448582 and parameters: {'GBDTFeatureSelector.threshold': 0.30621508734500646}. Best is trial 2 with value: 539.8527700066101.
[I 2022-02-20 18:47:35,899] Trial 8 finished with value: 543.8972261792727 and parameters: {'GBDTFeatureSelector.threshold': 0.344652063624768}. Best is trial 2 with value: 539.8527700066101.
[I 2022-02-20 18:48:02,356] Trial 9 finished with value: 546.669714703891 and parameters: {'GBDTFeatureSelector.threshold': 0.8269956186739581}. Best is trial 2 with value: 539.8527700066101.
selected features: 219
CV RMSE: 546.669714703891
Test RMSE: 515.1565138588921

上記では、元の 265 次元から 219 次元まで特徴量が削減されている。 特徴量が削減されると、計算量が削減できることから一回の実験にかかる時間も減らすことができる。 一方で、予測精度についてはホールドアウトしたテストデータに対してはほとんど変化していない。 この結果は、GBDT の場合は予測にあまり寄与しない特徴量が含まれていても、さほど予測性能に悪影響を及ぼさないという経験則と一致している。

まとめ

今回は xfeat を使った特徴量エンジニアリングのやり方について見てきた。


  1. 内部的にデータフレームはコピーされるため元のデータフレームが変更されるわけではない