CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: scikit-learn の FeatureUnion を pandas の DataFrame と一緒に使う

今回は scikit-learnFeatureUnionpandasDataFrame を一緒に使うときの問題点とその解決策について。 scikit-learn の FeatureUnion は、典型的には Pipeline においてバラバラに作った複数の特徴量を一つにまとめるのに使われる機能。 この FeatureUnion を pandas の DataFrame と一緒に使おうとすると、ちょっとばかり予想外の挙動になる。 具体的には FeatureUnion の出力するデータが、本来なら DataFrame になってほしいところで numpy の ndarray 形式に変換されてしまう。 今回は、それをなんとか DataFrame に直す方法がないか調べたり模索してみた話。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.13.6
BuildVersion:   17G65
$ python -V      
Python 3.7.0
$ pip list --format=columns | egrep -i "(scikit-learn|pandas)"
pandas          0.23.4 
scikit-learn    0.20.0 

下準備

まずは今回使うパッケージをインストールしておく。

$ pip install scikit-learn pandas

FeatureUnion について

まずは前提知識として FeatureUnion について紹介する。 これは Pipeline と一緒に使うことで真価を発揮する機能で、複数の特徴量を一つにまとめ上げるのに使われる。

次のサンプルコードでは Iris データセットを題材にして Pipeline と FeatureUnion を使っている。 この中では、元々 Iris データセットに含まれていた特徴量と、それらを標準化した特徴量を並列に準備して結合している。 動作確認のために Pipeline への入力データと出力データの情報を標準出力に書き出している。

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

from sklearn import datasets
from sklearn.pipeline import FeatureUnion
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.preprocessing import FunctionTransformer


def main():
    # Iris データセットを読み込む
    dataset = datasets.load_iris()
    X, y = dataset.data, dataset.target

    # パイプラインの入力データ
    print('input:')
    print(type(X), X.shape)
    print(X[:5])

    steps = [
        # 複数の特徴量を結合する
        ('union', FeatureUnion([
            # 生の特徴量をそのまま返す
            ('raw', FunctionTransformer(lambda x: x, validate=False)),
            # 標準化した特徴量を返す
            ('scaler', StandardScaler()),
        ])),
    ]
    pipeline = Pipeline(steps=steps)
    transformed_data = pipeline.fit_transform(X)

    # パイプラインの出力データ
    print('output:')
    print(type(transformed_data), transformed_data.shape)
    print(transformed_data[:5])


if __name__ == '__main__':
    main()

論より証拠ということで上記を実行してみよう。 すると、入力時点では 4 次元 150 行だったデータが、出力時点では 8 次元 150 行になっている。 これは、元々 Iris データセットに含まれていた 4 次元の特徴量に加えて、それらを標準化した特徴量が 4 次元加わっているため。

$ python featunion.py
input:
<class 'numpy.ndarray'> (150, 4)
[[5.1 3.5 1.4 0.2]
 [4.9 3.  1.4 0.2]
 [4.7 3.2 1.3 0.2]
 [4.6 3.1 1.5 0.2]
 [5.  3.6 1.4 0.2]]
output:
<class 'numpy.ndarray'> (150, 8)
[[ 5.1         3.5         1.4         0.2        -0.90068117  1.01900435
  -1.34022653 -1.3154443 ]
 [ 4.9         3.          1.4         0.2        -1.14301691 -0.13197948
  -1.34022653 -1.3154443 ]
 [ 4.7         3.2         1.3         0.2        -1.38535265  0.32841405
  -1.39706395 -1.3154443 ]
 [ 4.6         3.1         1.5         0.2        -1.50652052  0.09821729
  -1.2833891  -1.3154443 ]
 [ 5.          3.6         1.4         0.2        -1.02184904  1.24920112
  -1.34022653 -1.3154443 ]]

このように FeatureUnion を使うと特徴量エンジニアリングの作業が簡潔に表現できる。

pandas の DataFrame と一緒に使うときの問題点について

ただ、この FeatureUnion を pandas の DataFrame と一緒に使おうとすると問題が出てくる。 具体的には FeatureUnion を通すと、入力が DataFrame でも出力が numpy の ndarray の変換されてしまうのだ。

次のサンプルコードを見てほしい。 このコードでは Pipeline の入力データを pandas の DataFrame に変換している。

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

import pandas as pd

from sklearn import datasets
from sklearn.pipeline import Pipeline
from sklearn.pipeline import FeatureUnion
from sklearn.preprocessing import FunctionTransformer


def main():
    # Iris データセットを読み込む
    dataset = datasets.load_iris()
    X, y = dataset.data, dataset.target

    # データセットを pandas の DataFrame に変換する
    X_df = pd.DataFrame(X, columns=dataset.feature_names)

    # パイプラインの入力データ
    print('input:')
    print(type(X_df))
    print(X_df.head())

    steps = [
        ('union', FeatureUnion([
            ('raw', FunctionTransformer(lambda x: x, validate=False)),
        ])),
    ]
    pipeline = Pipeline(steps=steps)
    transformed_data = pipeline.fit_transform(X_df)

    # パイプラインの出力データ
    print('output:')
    print(type(transformed_data))
    print(transformed_data[:5])


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると Pipeline の出力データが numpy の ndarray に変換されてしまっていることが分かる。

$ python dfissue.py
input:
<class 'pandas.core.frame.DataFrame'>
   sepal length (cm)        ...         petal width (cm)
0                5.1        ...                      0.2
1                4.9        ...                      0.2
2                4.7        ...                      0.2
3                4.6        ...                      0.2
4                5.0        ...                      0.2

[5 rows x 4 columns]
output:
<class 'numpy.ndarray'>
[[5.1 3.5 1.4 0.2]
 [4.9 3.  1.4 0.2]
 [4.7 3.2 1.3 0.2]
 [4.6 3.1 1.5 0.2]
 [5.  3.6 1.4 0.2]]

上記のような振る舞いになる原因は scikit-learn の以下のコードにある。 FeatureUnion では特徴量の結合に numpy.hstack() を使っているのだ。 この関数の返り値は numpy の ndarray になるので FeatureUnion の出力もそれになってしまう。

github.com

ColumnTransformer でも同じ問題が起こる

ちなみに FeatureUnion と似たような機能の ColumnTransformer を使っても同じ問題が起こる。 ColumnTransformer は処理対象のカラム (次元) を指定して特徴量が生成できる。

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

import pandas as pd

from sklearn import datasets
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import FunctionTransformer


def main():
    # Iris データセットを読み込む
    dataset = datasets.load_iris()
    X, y = dataset.data, dataset.target

    # データセットを pandas の DataFrame に変換する
    X_df = pd.DataFrame(X, columns=dataset.feature_names)

    # パイプラインの入力データ
    print('input:')
    print(type(X_df))
    print(X_df.head())

    steps = [
        ('union', ColumnTransformer([
            ('transparent', FunctionTransformer(lambda x: x, validate=False), [i for i, _ in enumerate(X_df)]),
        ])),
    ]
    pipeline = Pipeline(steps=steps)
    transformed_data = pipeline.fit_transform(X_df)

    # パイプラインの出力データ
    print('output:')
    print(type(transformed_data))
    print(transformed_data[:5])


if __name__ == '__main__':
    main()

上記を実行してみよう。 FeatureUnion のときと同じように出力データは numpy の ndarray 形式に変換されてしまった。

$ python columnissue.py 
input:
<class 'pandas.core.frame.DataFrame'>
   sepal length (cm)        ...         petal width (cm)
0                5.1        ...                      0.2
1                4.9        ...                      0.2
2                4.7        ...                      0.2
3                4.6        ...                      0.2
4                5.0        ...                      0.2

[5 rows x 4 columns]
output:
<class 'numpy.ndarray'>
[[5.1 3.5 1.4 0.2]
 [4.9 3.  1.4 0.2]
 [4.7 3.2 1.3 0.2]
 [4.6 3.1 1.5 0.2]
 [5.  3.6 1.4 0.2]]

解決方法について

この問題について調べると、同じように悩んでいる人たちがいる。 そして、例えば次のような解決策を提示している人がいた。

zablo.net

上記では、自分で Transformer と FeatureUnion を拡張することで対応している。 ただ、このやり方だと作業が大掛かりで scikit-learn の API 変更などに継続的に追随していく必要があると感じた。

そこで、別の解決策として「とてもダーティーなハック」を思いついてしまったので共有してみる。 この問題は、特徴量の結合に numpy の hstack() 関数を使っていることが原因だった。 だとすれば、この関数をモンキーパッチで一時的にすり替えてしまえば問題を回避できるのではないか?と考えた。

実際に試してみたのが次のサンプルコードになる。 このコードでは unittest.mock.patch() を使って、一時的に numpy.hstack()pd.concat() にすり替えている。 これなら出力データは pandas の DataFrame にできるのではないか?

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

from functools import partial
from unittest.mock import patch

import pandas as pd

from sklearn import datasets
from sklearn.pipeline import Pipeline
from sklearn.pipeline import FeatureUnion
from sklearn.preprocessing import FunctionTransformer


def main():
    # Iris データセットを読み込む
    dataset = datasets.load_iris()
    X, y = dataset.data, dataset.target

    # データセットを pandas の DataFrame に変換する
    X_df = pd.DataFrame(X, columns=dataset.feature_names)

    # パイプラインの入力データ
    print('input:')
    print(type(X_df))
    print(X_df.head())

    steps = [
        ('union', FeatureUnion([
            ('transparent', FunctionTransformer(lambda x: x, validate=False)),
        ])),
    ]
    pipeline = Pipeline(steps=steps)

    # numpy.hstack を一時的に pd.concat(axis=1) にパッチしてしまう
    horizontal_concat = partial(pd.concat, axis=1)
    with patch('numpy.hstack', side_effect=horizontal_concat):
        transformed_data = pipeline.fit_transform(X_df)

    print('output:')
    print(type(transformed_data))
    print(transformed_data.head())


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、見事に出力データが pandas の DataFrame になっていることが分かる。

$ python monkeypatch.py
input:
<class 'pandas.core.frame.DataFrame'>
   sepal length (cm)        ...         petal width (cm)
0                5.1        ...                      0.2
1                4.9        ...                      0.2
2                4.7        ...                      0.2
3                4.6        ...                      0.2
4                5.0        ...                      0.2

[5 rows x 4 columns]
output:
<class 'pandas.core.frame.DataFrame'>
   sepal length (cm)        ...         petal width (cm)
0                5.1        ...                      0.2
1                4.9        ...                      0.2
2                4.7        ...                      0.2
3                4.6        ...                      0.2
4                5.0        ...                      0.2

[5 rows x 4 columns]

この解決策の問題点としては、学習の過程において numpy.hstack() が使えなくなるところ。 pd.concat() は入力として pandas のオブジェクトしか渡すことができない。 もし numpy の ndarray を渡してしまうと、次のようにエラーになる。

>>> import numpy as n
>>> l1 = np.array([1, 2, 3])
>>> l2 = np.array([4, 5, 6])
>>> import pandas as pd
>>> pd.concat([l1, l2], axis=1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages/pandas/core/reshape/concat.py", line 225, in concat
    copy=copy, sort=sort)
  File "/Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages/pandas/core/reshape/concat.py", line 286, in __init__
    raise TypeError(msg)
TypeError: cannot concatenate object of type "<class 'numpy.ndarray'>"; only pd.Series, pd.DataFrame, and pd.Panel (deprecated) objs are valid

巨大なパイプラインであれば numpy.hstack() をどこかで使っている可能性は十分に考えられる。 とはいえ、一旦試して駄目だったら前述した別の解決策に切り替える、というのもありかもしれない。

いじょう。

Pythonではじめる機械学習 ―scikit-learnで学ぶ特徴量エンジニアリングと機械学習の基礎

Pythonではじめる機械学習 ―scikit-learnで学ぶ特徴量エンジニアリングと機械学習の基礎

scikit-learnとTensorFlowによる実践機械学習

scikit-learnとTensorFlowによる実践機械学習

Pythonデータサイエンスハンドブック ―Jupyter、NumPy、pandas、Matplotlib、scikit-learnを使ったデータ分析、機械学習

Pythonデータサイエンスハンドブック ―Jupyter、NumPy、pandas、Matplotlib、scikit-learnを使ったデータ分析、機械学習