CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: scikit-learn の set_output API で pandas との食べ合わせが改善された

これまで scikit-learn が提供する TransformerMixin の実装 1 は、出力に NumPy 配列を仮定していた。 そのため、pandas の DataFrame を入力しても出力は NumPy 配列になってしまい、使い勝手が良くないという問題があった。 この問題は、特に PipelineColumnTransformer を使って処理を組むときに顕在化しやすい。

しかし、scikit-learn v1.2 で set_output API が追加されたことで、この状況に改善が見られた。 そこで、今回は set_output API の使い方について書いてみる。

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

$ sw_vers    
ProductName:        macOS
ProductVersion:     13.6.2
BuildVersion:       22G320
$ python -V
Python 3.10.13
$ pip list | egrep "(pandas|scikit-learn)"
pandas          2.1.3
scikit-learn    1.3.2

もくじ

下準備

まずは必要なパッケージをインストールしておく。

$ pip install scikit-learn pandas

そして Python のインタプリタを起動しておこう。

$ python

scikit-learn と pandas の食べ合わせの悪さについて

初めに、前述した scikit-learn と pandas の食べ合わせの悪さについて確認しておく。

最初にサンプルとして Iris データセットを pandas の DataFrame の形式で読み込む。

>>> from sklearn.datasets import load_iris
>>> X, y = load_iris(as_frame=True, return_X_y=True)
>>> type(X)
<class 'pandas.core.frame.DataFrame'>
>>> X.head()
   sepal length (cm)  sepal width (cm)  petal length (cm)  petal width (cm)
0                5.1               3.5                1.4               0.2
1                4.9               3.0                1.4               0.2
2                4.7               3.2                1.3               0.2
3                4.6               3.1                1.5               0.2
4                5.0               3.6                1.4               0.2
>>> type(y)
<class 'pandas.core.series.Series'>
>>> y.head()
0    0
1    0
2    0
3    0
4    0
Name: target, dtype: int64

そして scikit-learn の TransformerMixin を実装したサンプルとして StandardScaler のインスタンスを用意する。

>>> from sklearn.preprocessing import StandardScaler
>>> scaler = StandardScaler()

先ほど読み込んだ Iris データセットの DataFrame を学習および変換してみよう。

>>> X_scaled = scaler.fit_transform(X)

すると、返り値は NumPy 配列になってしまう。

>>> type(X_scaled)
<class 'numpy.ndarray'>
>>> X_scaled[:5]
array([[-0.90068117,  1.01900435, -1.34022653, -1.3154443 ],
       [-1.14301691, -0.13197948, -1.34022653, -1.3154443 ],
       [-1.38535265,  0.32841405, -1.39706395, -1.3154443 ],
       [-1.50652052,  0.09821729, -1.2833891 , -1.3154443 ],
       [-1.02184904,  1.24920112, -1.34022653, -1.3154443 ]])

pandas の DataFrame を入れたのに返ってくるのが NumPy 配列だと、特にパイプライン的に処理を繰り返すような場面で使い勝手が良くない。

TransformerMixin#set_output(transform="pandas") を指定する

では、次に今回の主題となる set_output API を使ってみよう。

やることは単純で、先ほど用意した StandardScaler インスタンスに対して set_output() メソッドを呼ぶ。 このとき、引数として transform="pandas" を指定するのがポイントになる。

>>> scaler.set_output(transform="pandas")
StandardScaler()

この状態で、もう一度 DataFrame を入力として学習と変換をしてみよう。

>>> X_scaled = scaler.fit_transform(X)

すると、今度は返ってくる値の型が pandas の DataFrame になっている。

>>> type(X_scaled)
<class 'pandas.core.frame.DataFrame'>
>>> X_scaled.head()
   sepal length (cm)  sepal width (cm)  petal length (cm)  petal width (cm)
0          -0.900681          1.019004          -1.340227         -1.315444
1          -1.143017         -0.131979          -1.340227         -1.315444
2          -1.385353          0.328414          -1.397064         -1.315444
3          -1.506521          0.098217          -1.283389         -1.315444
4          -1.021849          1.249201          -1.340227         -1.315444

このように TransformerMixin のサブクラスは、set_output API を使うことで pandas の DataFrame を返すことができるようになった。

ちなみに学習済みのインスタンスについて、途中から返す値の型を変えることもできる。 試しに set_output() メソッドで transform="default" を指定して元に戻してみよう。

>>> scaler.set_output(transform="default")
StandardScaler()

そして既存のデータを学習せずに変換だけしてみる。 すると、今度は NumPy 配列が返ってきた。

>>> X_scaled = scaler.transform(X)
>>> type(X_scaled)
<class 'numpy.ndarray'>
>>> X_scaled[:5]
array([[-0.90068117,  1.01900435, -1.34022653, -1.3154443 ],
       [-1.14301691, -0.13197948, -1.34022653, -1.3154443 ],
       [-1.38535265,  0.32841405, -1.39706395, -1.3154443 ],
       [-1.50652052,  0.09821729, -1.2833891 , -1.3154443 ],
       [-1.02184904,  1.24920112, -1.34022653, -1.3154443 ]])

scikit-learn の Pipeline と組み合わせて使う

また、set_output API は Pipeline についても対応している。 試しに PCAStandardScaler を直列に実行する Pipeline を用意して試してみよう。

先ほどはインスタンスの生成と set_output() メソッドの呼び出しを行で分けていた。 今度はメソッドチェーンで一気に設定してみよう。

>>> from sklearn.decomposition import PCA
>>> from sklearn.pipeline import Pipeline
>>> steps = [
...     ("pca", PCA()),
...     ("normalize", StandardScaler()),
... ]
>>> pipeline = Pipeline(steps=steps).set_output(transform="pandas")

作成した Pipeline でデータの学習と変換をしてみよう。

>>> X_transformed = pipeline.fit_transform(X)
>>> type(X_transformed)
<class 'pandas.core.frame.DataFrame'>
>>> X_transformed.head()
       pca0      pca1      pca2      pca3
0 -1.309711  0.650541 -0.100152 -0.014703
1 -1.324357 -0.360512 -0.755094 -0.643570
2 -1.409674 -0.295230  0.064222 -0.129774
3 -1.339582 -0.648304  0.113227  0.491164
4 -1.331469  0.665527  0.323182  0.398117

ちゃんと pandas の DataFrame で返ってきていることが確認できる。

最終段を BaseEstimator のサブクラスにした上で予測まで扱えることも確認しておこう。 先ほどのパイプラインの最終段に LogisticRegression を挿入する。

>>> from sklearn.linear_model import LogisticRegression
>>> steps = [
...     ("pca", PCA()),
...     ("normalize", StandardScaler()),
...     ("lr", LogisticRegression()),
... ]
>>> pipeline = Pipeline(steps=steps).set_output(transform="pandas")

データを train_test_split() 関数で分割する。

>>> from sklearn.model_selection import train_test_split
>>> X_train, X_test, y_train, y_test = train_test_split(X, y, stratify=y, random_state=42)

学習データを Pipeline に学習させる。

>>> pipeline.fit(X_train, y_train)
Pipeline(steps=[('pca', PCA()), ('normalize', StandardScaler()),
                ('lr', LogisticRegression())])

テストデータを Pipeline に推論させる。

>>> y_pred = pipeline.predict(X_test)

推論した内容を Accuracy で評価する。

>>> from sklearn.metrics import accuracy_score
>>> accuracy_score(y_test, y_pred)
0.8947368421052632

ひとまず予測の精度については横に置いておくとして、ちゃんと機能していそうなことが確認できた。

scikit-learn の ColumnTransformer と組み合わせて使う

さらに実用上は、特定のカラムに絞って処理をする ColumnTransformer と組み合わせて使うことになるはず。 この点も確認しておこう。

カテゴリ変数の処理をしたいので Diamonds データセットを読み込んでおく。

>>> from sklearn.datasets import fetch_openml
>>> X, y = fetch_openml(
...     "diamonds",
...     version=1,
...     as_frame=True,
...     return_X_y=True,
...     parser="pandas"
... )
>>> X.head()
   carat      cut color clarity  depth  table     x     y     z
0   0.23    Ideal     E     SI2   61.5   55.0  3.95  3.98  2.43
1   0.21  Premium     E     SI1   59.8   61.0  3.89  3.84  2.31
2   0.23     Good     E     VS1   56.9   65.0  4.05  4.07  2.31
3   0.29  Premium     I     VS2   62.4   58.0  4.20  4.23  2.63
4   0.31     Good     J     SI2   63.3   58.0  4.34  4.35  2.75
>>> y.head()
0    326
1    326
2    327
3    334
4    335
Name: price, dtype: int64

ちなみに、常に TransformerMixin を実装したクラスで pandas の DataFrame を返してほしいときは、次のようにしてグローバルに設定できる。

>>> from sklearn import set_config
>>> set_config(transform_output="pandas")

カテゴリ変数には OneHotEncoder を、連続変数には StandardScaler をかける ColumnTransformer を次のように用意する。

>>> from sklearn.preprocessing import OneHotEncoder
>>> from sklearn.preprocessing import StandardScaler
>>> ct_settings = [
...     ("cat_onehot", OneHotEncoder(sparse_output=False), ["cut", "color", "clarity"]),
...     ("num_normalize", StandardScaler(), ["carat", "depth", "table", "x", "y", "z"]),
... ]
>>> ct = ColumnTransformer(ct_settings)

試しにデータを学習および変換してみると、次のように結果が pandas の DataFrame で返ってくる。

>>> ct.fit_transform(X).head()
   cat_onehot__cut_Fair  cat_onehot__cut_Good  ...  num_normalize__y  num_normalize__z
0                   0.0                   0.0  ...         -1.536196         -1.571129
1                   0.0                   0.0  ...         -1.658774         -1.741175
2                   0.0                   1.0  ...         -1.457395         -1.741175
3                   0.0                   0.0  ...         -1.317305         -1.287720
4                   0.0                   1.0  ...         -1.212238         -1.117674

[5 rows x 26 columns]

上記の ColumnTransformer のインスタンスを、さらに Pipeline に組み込んでみよう。 最終段には RandomForestRegressor を配置する。

>>> from sklearn.ensemble import RandomForestRegressor
>>> steps = [
...     ("preprocessing", ct),
...     ("rf", RandomForestRegressor(n_jobs=-1)),
... ]
>>> pipeline = Pipeline(steps)

データを Random 5-Fold で RMSE について交差検証してみよう。

>>> from sklearn.model_selection import cross_validate
>>> from sklearn.model_selection import KFold
>>> folds = KFold(n_splits=5, shuffle=True, random_state=42)
>>> cv_result = cross_validate(pipeline, X, y, cv=folds, scoring="neg_root_mean_squared_error")

すると、次のようにテストデータのスコアが得られた。

>>> cv_result["test_score"]
array([-552.59282602, -536.20769256, -582.69130436, -559.43303878,
       -533.75354186])

上記についても性能の高低は別として、エンドツーエンドで評価まで動作することが確認できた。

まとめ

今回は scikit-learn v1.2 で追加された set_output API を試してみた。 TransformerMixin を実装したクラスが pandas の DataFrame を返せるようになったことで両者の食べ合わせが以前よりも良くなった。

参考

scikit-learn.org


  1. 具体例として OneHotEncoderStandardScaler などが挙げられる