CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: sklearn-pandas で scikit-learn と pandas の食べ合わせを改善する

Python を使った機械学習でよく用いられるパッケージの scikit-learn は API の入出力に numpy の配列を仮定している。 そのため、データフレームの実装である pandas と一緒に使おうとすると、色々な場面で食べ合わせの悪さを感じることになる。 今回は、その問題を sklearn-pandas というパッケージを使うことで改善を試みる。

使った環境は次の通り。

$ sw_vers                         
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ python -V        
Python 3.7.4

もくじ

下準備

まずは今回使うパッケージをインストールしておく。 seaborn についてはデータセットの読み込みにだけ用いる。

$ pip install sklearn-pandas seaborn

インストールできたら Python のインタプリタを起動しておく。

$ python

起動したら seaborn を使って Titanic データセットのデータフレームを読み込んでおく。

>>> import seaborn as sns
>>> df = sns.load_dataset('titanic')
>>> df.head()
   survived  pclass     sex   age  sibsp  parch     fare  ...  class    who adult_male  deck  embark_town alive  alone
0         0       3    male  22.0      1      0   7.2500  ...  Third    man       True   NaN  Southampton    no  False
1         1       1  female  38.0      1      0  71.2833  ...  First  woman      False     C    Cherbourg   yes  False
2         1       3  female  26.0      0      0   7.9250  ...  Third  woman      False   NaN  Southampton   yes   True
3         1       1  female  35.0      1      0  53.1000  ...  First  woman      False     C  Southampton   yes  False
4         0       3    male  35.0      0      0   8.0500  ...  Third    man       True   NaN  Southampton    no   True

[5 rows x 15 columns]

scikit-learn の API と pandas の DataFrame について

前述した通り、scikit-learn の API はその入出力に numpy の配列を仮定している。 そのため、pandas の DataFrame と一緒に使おうとすると相性があまり良くない。

例えば、LabelEncoder を使ってみることにしよう。

>>> from sklearn.preprocessing import LabelEncoder
>>> label_encoder = LabelEncoder()

先ほど読み込んだデータフレームの中から、乗客の性別を表すカラムをエンコードしてみよう。 すると、入力は pandas の Series なのに対して出力は numpy の ndarray になっていることがわかる。

>>> result = label_encoder.fit_transform(df.sex)
>>> type(result)
<class 'numpy.ndarray'>
>>> result[:5]
array([1, 0, 0, 0, 1])
>>> type(df.sex)
<class 'pandas.core.series.Series'>

このように scikit-learn と pandas を組み合わせて使うと入出力でデータの型が変わるため扱いにくいことがある。

sklearn-pandas を使って scikit-learn API をラップする

今回紹介する sklearn-pandas を使うと、両者を組み合わせたときの食べ合わせの悪さを改善できる可能性がある。

例えば sklearn-pandas では DataFrameMapper というクラスを提供している。 このクラスには scikit-learn の API をラップする機能がある。

>>> from sklearn_pandas import DataFrameMapper

DataFrameMapper は、次のように使う。 まず、処理の対象としたいデータフレームのカラム名と、適用したい scikit-learn の Transformer をタプルとして用意する。 そして、オプションの df_outTrue を指定しておく。

>>> mapper = DataFrameMapper([
...   ('sex', LabelEncoder()),
... ], df_out=True)

DataFrameMapper は scikit-learn の API を備えているため、次のように fit_transform() メソッドが使える。 このメソッドにデータフレームを渡す。 すると、次のように先ほど指定したカラムがエンコードされた上で、結果がまたデータフレームとして返ってくる。

>>> mapper.fit_transform(df)
     sex
0      1
1      0
2      0
3      0
4      1
..   ...
886    1
887    0
888    0
889    1
890    1

[891 rows x 1 columns]

結果を numpy 配列として受け取る

ちなみに、先ほど指定したオプションの df_out を指定しないとデフォルトでは numpy の配列として結果が返ってくる。 sklearn-pandas は内部的には一旦結果を numpy の配列として受け取った上で、それをデータフレームに変換している。

>>> mapper = DataFrameMapper([
...   ('sex', LabelEncoder()),
... ])
>>> mapper.fit_transform(df)
array([[1],
       [0],
       [0],
...(snip)...
       [0],
       [1],
       [1]])

指定していないカラムをそのままの状態で受け取る

先ほどの例では、処理の対象となるカラムだけが入ったデータフレームが結果として得られた。 処理対象として指定していないカラムについて、そのまま受け取りたい場合は default オプションに None を指定すれば良い。

>>> mapper = DataFrameMapper([
...   ('sex', LabelEncoder()),
... ], default=None, df_out=True)

性別 (sex) カラムについてはエンコードされており、その他のカラムについてはそのままの状態でデータフレームが返ってくる。

>>> mapper.fit_transform(df)
     sex survived pclass  age sibsp parch     fare embarked   class    who adult_male deck  embark_town alive  alone
0      1        0      3   22     1     0     7.25        S   Third    man       True  NaN  Southampton    no  False
1      0        1      1   38     1     0  71.2833        C   First  woman      False    C    Cherbourg   yes  False
2      0        1      3   26     0     0    7.925        S   Third  woman      False  NaN  Southampton   yes   True
3      0        1      1   35     1     0     53.1        S   First  woman      False    C  Southampton   yes  False
4      1        0      3   35     0     0     8.05        S   Third    man       True  NaN  Southampton    no   True
..   ...      ...    ...  ...   ...   ...      ...      ...     ...    ...        ...  ...          ...   ...    ...
886    1        0      2   27     0     0       13        S  Second    man       True  NaN  Southampton    no   True
887    0        1      1   19     0     0       30        S   First  woman      False    B  Southampton   yes   True
888    0        0      3  NaN     1     2    23.45        S   Third  woman      False  NaN  Southampton    no  False
889    1        1      1   26     0     0       30        C   First    man       True    C    Cherbourg   yes   True
890    1        0      3   32     0     0     7.75        Q   Third    man       True  NaN   Queenstown    no   True

[891 rows x 15 columns]

複数のカラムを一度に扱う

DataFrameMapper には複数のカラムを指定することもできる。 例えば性別 (sex) と客室等級 (class) を一度にエンコードしてみよう。

>>> mapper = DataFrameMapper([
...   ('sex', LabelEncoder()),
...   ('class', LabelEncoder()),
... ], df_out=True)

次の通り、両方のカラムを同時にエンコードできた。

>>> mapper.fit_transform(df)
     sex  class
0      1      2
1      0      0
2      0      2
3      0      0
4      1      2
..   ...    ...
886    1      1
887    0      0
888    0      2
889    1      0
890    1      2

[891 rows x 2 columns]

複数のカラムを一つの Transformer で処理する

ここまでの例では単一のカラムを scikit-learn の Transformer でエンコードしてきた。 今度は複数のカラムを一度に Transformer に渡す場合を試してみる。 ただし、これはあくまで Transformer が複数のカラムを受け取れることが前提となる。

例えば OneHotEncoder に性別と客室等級を渡してみよう。

>>> import numpy as np
>>> from sklearn.preprocessing import OneHotEncoder
>>> 
>>> mapper = DataFrameMapper([
...   (['sex', 'class'], OneHotEncoder(dtype=np.uint8)),
... ], df_out=True)

結果は次のようになる。 両方のカラムを別々に One-Hot エンコードしている。

>>> mapper.fit_transform(df)
     sex_class_x0_female  sex_class_x0_male  sex_class_x1_First  sex_class_x1_Second  sex_class_x1_Third
0                      0                  1                   0                    0                   1
1                      1                  0                   1                    0                   0
2                      1                  0                   0                    0                   1
3                      1                  0                   1                    0                   0
4                      0                  1                   0                    0                   1
..                   ...                ...                 ...                  ...                 ...
886                    0                  1                   0                    1                   0
887                    1                  0                   1                    0                   0
888                    1                  0                   0                    0                   1
889                    0                  1                   1                    0                   0
890                    0                  1                   0                    0                   1

[891 rows x 5 columns]

なお、上記で得られる結果は、次のようにして得られた内容と等価になる。

>>> encoder = OneHotEncoder(dtype=np.uint8)
>>> encoder.fit_transform(df[['sex', 'class']].values).toarray()
array([[0, 1, 0, 0, 1],
       [1, 0, 1, 0, 0],
       [1, 0, 0, 0, 1],
       ...,
       [1, 0, 0, 0, 1],
       [0, 1, 1, 0, 0],
       [0, 1, 0, 0, 1]], dtype=uint8)

pandas の DataFrame を入力として受け取る Transformer を扱う場合

ここまでの例では scikit-learn に組み込まれている Transformer を使ってきた。 scikit-learn 組み込みの Transformer は入力を numpy 配列と仮定して扱う。 それに対し、独自に定義した Transformer であれば入力と pandas のデータフレームと仮定することもできる。

例として、次のような独自の Transformer を定義してみる。 このクラスでは、入力を pandas の DataFrame と仮定としている。 そして、指定されたカラムに 1 を足す操作をする。 もちろん、実用性は皆無だけど、あくまでサンプルとして。

>>> import pandas as pd
>>> from sklearn.base import BaseEstimator
>>> from sklearn.base import TransformerMixin
>>> 
>>> class PlusOneTransformer(BaseEstimator, TransformerMixin):
...     def __init__(self, col):
...         self.col = col
...     def fit(self, X, y=None):
...         assert type(X) in [pd.DataFrame]
...         return self
...     def transform(self, X):
...         assert type(X) in [pd.DataFrame]
...         X_copy = X.copy()
...         X_copy[self.col] += 1
...         return X_copy
... 

上記をデフォルトのまま DataFrameMapper で扱おうとすると、次のように例外になってしまう。 これは DataFrameMapper がデフォルトで Transformer への入力を numpy の配列に変換した上で扱おうとするため。

>>> mapper = DataFrameMapper([
...   (['survived'], PlusOneTransformer('survived')),
... ], df_out=True)
>>> 
>>> mapper.fit_transform(df)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
...(snip)...
  File "<stdin>", line 5, in fit
AssertionError: ['survived']

エラーにならないようにするには、次のように input_dfTrue にする。 このオプションは Transformer の入力を pandas のデータフレームの状態で行うことを指定する。

>>> mapper = DataFrameMapper([
...   (['survived'], PlusOneTransformer('survived')),
... ], input_df=True, df_out=True)
>>> 
>>> mapper.fit_transform(df)
     survived
0           1
1           2
2           2
3           2
4           1
..        ...
886         1
887         2
888         1
889         2
890         1

[891 rows x 1 columns]

Transformer の入力次元 (dimension) の指定について

普段そんなに意識していないかもしれないけど、scikit-learn はクラスによって入力する次元の仮定が異なっていることがある。

例えば MinMaxScaler を、ここまでの例と同じように使ってみよう。 エンコードする対象のカラムは運賃 (fare) にする。

>>> from sklearn.preprocessing import MinMaxScaler
>>> 
>>> mapper = DataFrameMapper([
...   ('fare', MinMaxScaler()),
... ], df_out=True)

上記を使ってエンコードしてみよう。 すると、次のようにエラーになる。

>>> mapper.fit_transform(df)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
...
ValueError: fare: Expected 2D array, got 1D array instead:
...
Reshape your data either using array.reshape(-1, 1) if your data has a single feature or array.reshape(1, -1) if it contains a single sample.
...

上記は MinMaxScaler が入力に 2-d 配列を仮定しているために起こる。 それに対し、入力が 1-d 配列だったために例外となってしまった。

もうちょっと問題を単純に考えるために MinMaxScaler をそのまま使って問題を再現してみよう。 先ほどの例は、以下と等価になる。

>>> scaler = MinMaxScaler()
>>> scaler.fit_transform(df.fare.values)
...
ValueError: fare: Expected 2D array, got 1D array instead:
...
Reshape your data either using array.reshape(-1, 1) if your data has a single feature or array.reshape(1, -1) if it contains a single sample.

上記で渡したデータは、次のように ndim が 1 の 1-d 配列とわかる。

>>> df.fare.values.ndim
1
>>> df.fare.values[:10]
array([ 7.25  , 71.2833,  7.925 , 53.1   ,  8.05  ,  8.4583, 51.8625,
       21.075 , 11.1333, 30.0708])

実際には、次のような 2-d 配列を渡す必要がある。

>>> df.fare.values.reshape(-1, 1).ndim
2
>>> df.fare.values.reshape(-1, 1)[:10]
array([[ 7.25  ],
       [71.2833],
       [ 7.925 ],
       [53.1   ],
       [ 8.05  ],
       [ 8.4583],
       [51.8625],
       [21.075 ],
       [11.1333],
       [30.0708]])

上記のような 1-d から 2-d への変換を DataFrameMapper でどのように表現するか。 次のようにカラム名が入った配列として渡せば良い。

>>> mapper = DataFrameMapper([
...   (['fare'], MinMaxScaler()),
... ], df_out=True)
>>> 
>>> mapper.fit_transform(df)
         fare
0    0.014151
1    0.139136
2    0.015469
3    0.103644
4    0.015713
..        ...
886  0.025374
887  0.058556
888  0.045771
889  0.058556
890  0.015127

[891 rows x 1 columns]

配列として渡すか否かの違いは、以下のように考えると理解しやすいと思う。 pandas では、データフレームのスライス操作 ([]) に文字列をそのまま渡すと 1-d 表現の Series オブジェクトが返ってくる。

>>> df['fare']
0       7.2500
1      71.2833
2       7.9250
3      53.1000
4       8.0500
        ...   
886    13.0000
887    30.0000
888    23.4500
889    30.0000
890     7.7500
Name: fare, Length: 891, dtype: float64

それに対してスライス操作に、カラム名の入った配列を渡すと、こちらは 2-d 表現の DataFrame オブジェクトが返る。

>>> df[['fare']]
        fare
0     7.2500
1    71.2833
2     7.9250
3    53.1000
4     8.0500
..       ...
886  13.0000
887  30.0000
888  23.4500
889  30.0000
890   7.7500

[891 rows x 1 columns]

エンコードしたカラムに別名をつける

DataFrameMapper では、同じカラムに別々の処理を施すこともできる。 例えば、次の例では運賃 (fare) のカラムを MinMaxScalerStandardScaler で別々に標準化した結果を得ている。

>>> from sklearn.preprocessing import StandardScaler
>>> 
>>> mapper = DataFrameMapper([
...   (['fare'], MinMaxScaler()),
...   (['fare'], StandardScaler()),
... ], df_out=True)
>>> 
>>> mapper.fit_transform(df)
         fare      fare
0    0.014151 -0.502445
1    0.139136  0.786845
2    0.015469 -0.488854
3    0.103644  0.420730
4    0.015713 -0.486337
..        ...       ...
886  0.025374 -0.386671
887  0.058556 -0.044381
888  0.045771 -0.176263
889  0.058556 -0.044381
890  0.015127 -0.492378

[891 rows x 2 columns]

ただし、上記を見ると変換した結果として得られたカラムの名前が同じになってしまっていることがわかる。

区別するために別の名前をつけたいときは、次のようにする。 これまで渡すタプルの要素が 2 つだったのに対し、3 つになっている。 3 つ目の要素は辞書オブジェクトを仮定していて、カラムごとの設定を渡す。 この中で alias というキーを指定すると、変換後のカラムの名前が指定できる。

>>> mapper = DataFrameMapper([
...   (['fare'], MinMaxScaler(), {'alias': 'fare_minmax'}),
...   (['fare'], StandardScaler(), {'alias': 'fare_std'}),
... ], df_out=True)
>>> 
>>> mapper.fit_transform(df)
     fare_minmax  fare_std
0       0.014151 -0.502445
1       0.139136  0.786845
2       0.015469 -0.488854
3       0.103644  0.420730
4       0.015713 -0.486337
..           ...       ...
886     0.025374 -0.386671
887     0.058556 -0.044381
888     0.045771 -0.176263
889     0.058556 -0.044381
890     0.015127 -0.492378

[891 rows x 2 columns]

処理の対象となったカラムをそのまま残す

ちなみに、default=None を指定した場合、処理の対象となったカラムはそのままでは残らない。 Transformer を適用した結果で置換されてしまう。

>>> mapper = DataFrameMapper([
...   (['fare'], MinMaxScaler(), {'alias': 'fare_minmax'}),
...   (['fare'], StandardScaler(), {'alias': 'fare_std'}),
... ], default=None, df_out=True)
>>> 
>>> mapper.fit_transform(df).columns
Index(['fare_minmax', 'fare_std', 'survived', 'pclass', 'sex', 'age', 'sibsp',
       'parch', 'embarked', 'class', 'who', 'adult_male', 'deck',
       'embark_town', 'alive', 'alone'],
      dtype='object')

処理対象となったカラムも置換せずに残したいときは、次のように処理内容に None を指定したタプルを別途渡す必要がある。

>>> mapper = DataFrameMapper([
...   (['fare'], None),
...   (['fare'], MinMaxScaler(), {'alias': 'fare_minmax'}),
...   (['fare'], StandardScaler(), {'alias': 'fare_std'}),
... ], default=None, df_out=True)
>>> 
>>> mapper.fit_transform(df)
        fare  fare_minmax  fare_std survived pclass     sex  ...    who adult_male deck  embark_town alive  alone
0     7.2500     0.014151 -0.502445        0      3    male  ...    man       True  NaN  Southampton    no  False
1    71.2833     0.139136  0.786845        1      1  female  ...  woman      False    C    Cherbourg   yes  False
2     7.9250     0.015469 -0.488854        1      3  female  ...  woman      False  NaN  Southampton   yes   True
3    53.1000     0.103644  0.420730        1      1  female  ...  woman      False    C  Southampton   yes  False
4     8.0500     0.015713 -0.486337        0      3    male  ...    man       True  NaN  Southampton    no   True
..       ...          ...       ...      ...    ...     ...  ...    ...        ...  ...          ...   ...    ...
886  13.0000     0.025374 -0.386671        0      2    male  ...    man       True  NaN  Southampton    no   True
887  30.0000     0.058556 -0.044381        1      1  female  ...  woman      False    B  Southampton   yes   True
888  23.4500     0.045771 -0.176263        0      3  female  ...  woman      False  NaN  Southampton    no  False
889  30.0000     0.058556 -0.044381        1      1    male  ...    man       True    C    Cherbourg   yes   True
890   7.7500     0.015127 -0.492378        0      3    male  ...    man       True  NaN   Queenstown    no   True

[891 rows x 17 columns]

パイプライン的な処理について

DataFrameMapper には簡易的なパイプラインのような処理も扱える。 題材として欠損値の補完について考えてみる。

Titanic のデータセットには、いくつかのカラムに欠損値が含まれる。

>>> df.isnull().sum()
survived         0
pclass           0
sex              0
age            177
sibsp            0
parch            0
fare             0
embarked         2
class            0
who              0
adult_male       0
deck           688
embark_town      2
alive            0
alone            0
dtype: int64

かつ、scikit-learn 組み込みの Transformer には欠損値が含まれるデータを扱えないものがある。

例えば欠損値が含まれる乗船地 (embarked) を LabelEncoder でエンコードしてみよう。 すると、次のように例外になってしまう。

>>> mapper = DataFrameMapper([
...   ('embarked', LabelEncoder()),
... ], df_out=True)
>>> 
>>> mapper.fit_transform(df)
Traceback (most recent call last):
...(snip)...
TypeError: embarked: argument must be a string or number

上記の問題に対処するため、前段に欠損値を補完する処理を入れてみよう。 例として、欠損値の補完には SimpleImputer を使って既知の値とは異なる定数を用いる。 処理を多段にパイプラインのように扱うには、タプルの第 2 要素をリストにして適用したい処理を順番に指定する。

>>> from sklearn.impute import SimpleImputer
>>> 
>>> mapper = DataFrameMapper([
...     (['embarked'], [SimpleImputer(strategy='constant', fill_value='X'),
...                     LabelEncoder()])
... ], df_out=True)

上記を試すと、今度は例外にならずエンコードできる。 ただし、SimpleImputer の出力が 2-d 配列になっているせいで、今度は LabelEncoder の方が 1-d 配列にしろと怒っている。

>>> mapper.fit_transform(df)
/Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages/sklearn/preprocessing/label.py:235: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
  y = column_or_1d(y, warn=True)
     embarked
0           2
1           0
2           2
3           2
4           2
..        ...
886         2
887         2
888         2
889         0
890         1

[891 rows x 1 columns]

パイプラインの間で次元を揃えるために、次のように 2-d から 1-d に変換する Transformer を適当に用意してみる。

>>> class FlattenTransformer(BaseEstimator, TransformerMixin):
...     def fit(self, X, y=None):
...         return self
...     def transform(self, X):
...         return X.flatten()
... 

欠損値の補完とエンコードの間に処理を挟み込む。

>>> mapper = DataFrameMapper([
...     (['embarked'], [SimpleImputer(strategy='constant', fill_value='X'),
...                     FlattenTransformer(),
...                     LabelEncoder()])
... ], df_out=True)

すると、今度は警告も出ない。

>>> mapper.fit_transform(df)
     embarked
0           2
1           0
2           2
3           2
4           2
..        ...
886         2
887         2
888         2
889         0
890         1

[891 rows x 1 columns]

カラムの処理が分岐・合流する場合の処理について

先ほどは特定のカラムを多段的に処理してみたけど、これが使えるのは限定的な用途にとどまると思う。 なぜなら、カラムによって前処理の内容を変えるなど、処理の内容が分岐したり合流するときに使えないため。

例として、運賃 (fare) と乗客の年齢 (age) を主成分分析 (PCA) することを考えてみる。 ここで、運賃と乗客に別々の前処理をしたいとしたら、どうなるだろうか。

>>> from sklearn.decomposition import PCA
>>> 
>>> mapper = DataFrameMapper([
...   (['fare', 'age'], PCA()),
... ], df_out=True)

実際のところ、乗客の年齢には欠損値が含まれるため、上記はそのままだと例外になる。

>>> mapper.fit_transform(df)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
...(snip)...
ValueError: ['fare', 'age']: Input contains NaN, infinity or a value too large for dtype('float64').

しかし、もしカラムごとに別々の欠損値の補完がしたいとしたら、DataFrameMapper のパイプライン処理では表現ができない。

最初に思いつくのは DataFrameMapper 自体を scikit-learn の Pipeline に組み込んで多段で処理をすることだった。 実際にやってみよう。

>>> impute_phase = DataFrameMapper([
...     ('fare', None),
...     (['age'], SimpleImputer(strategy='mean')),
... ], df_out=True)
>>> 
>>> encode_phase = DataFrameMapper([
...   (['fare', 'age'], PCA(), {'alias': 'pca'}),
... ], df_out=True)
>>> 
>>> from sklearn.pipeline import Pipeline
>>> steps = [
...     ('impute', impute_phase),
...     ('encode', encode_phase),
... ]
>>> pipeline = Pipeline(steps)

ただ、これをやると最終的に得られる結果が numpy 配列となってしまう。 データフレームとして扱いたいために sklearn-pandas を使っているのに、これだとちょっと微妙な感じがする。

>>> pipeline.fit_transform(df)
         pca_0      pca_1
0   -25.143783  -7.055429
1    39.279465   7.294086
2   -24.366234  -3.074093
3    21.025089   4.762258
4   -24.010039   5.919725
..         ...        ...
886 -19.267217  -2.204814
887  -2.478372 -10.638953
888  -8.751318   0.224921
889  -2.298521  -3.641264
890 -24.387019   2.928423

[891 rows x 2 columns]

そこで、やや近視眼的な解決方法だけど、次のように自分で多段の処理ができるようなクラスを定義してみた。

>>> class PhasedTransformer(BaseEstimator, TransformerMixin):
...     def __init__(self, steps):
...         self.steps = steps
...     def fit_transform(self, X, y=None):
...         transformed = X
...         for _, transformer in self.steps:
...             transformed = transformer.fit_transform(transformed, y)
...         return transformed
...     def transform(self, X):
...         transformed = X
...         for _, transformer in self.steps:
...             transformed = transformer.transform(transformed)
...         return transformed
... 

これなら最終的に得られる結果もデータフレームのままとなる。

>>> transformer = PhasedTransformer(steps)
>>> transformer.fit_transform(df)
         pca_0      pca_1
0   -25.143783  -7.055429
1    39.279465   7.294086
2   -24.366234  -3.074093
3    21.025089   4.762258
4   -24.010039   5.919725
..         ...        ...
886 -19.267217  -2.204814
887  -2.478372 -10.638953
888  -8.751318   0.224921
889  -2.298521  -3.641264
890 -24.387019   2.928423

[891 rows x 2 columns]

sklearn-pandas で処理したデータを使って Titanic を予測してみる

もうちょっと複雑なパターンも試しておいた方が良いと思って、実際に予測までする一連の処理を書いてみた。

まずは欠損値の補完とエンコードの二段階で構成されたパイプラインを用意する。

>>> impute_phase = DataFrameMapper([
...     ('pclass', None),
...     ('sex', None),
...     (['age'], SimpleImputer(strategy='mean')),
...     ('sibsp', None),
...     ('parch', None),
...     ('fare', None),
...     (['embarked'], SimpleImputer(strategy='most_frequent')),
...     ('adult_male', None),
...     (['deck'], SimpleImputer(strategy='most_frequent')),
...     ('alone', None),
... ], df_out=True)
>>> 
>>> encode_phase = DataFrameMapper([
...     (['sex'], [FlattenTransformer(), LabelEncoder()]),
...     (['embarked'], OneHotEncoder()),
...     (['adult_male'], [FlattenTransformer(), LabelEncoder()]),
...     (['deck'], OneHotEncoder()),
...     (['alone'], [FlattenTransformer(), LabelEncoder()]),
... ], default=None, df_out=True)
>>> 
>>> steps = [
...     ('impute', impute_phase),
...     ('encode', encode_phase),
... ]
>>> transformer = PhasedTransformer(steps)

上記によって、以下のようなデータが得られる。

>>> features = transformer.fit_transform(df)
>>> 
>>> features.head()
   sex  embarked_x0_C  embarked_x0_Q  embarked_x0_S  adult_male  deck_x0_A  ...  alone  pclass   age  sibsp  parch     fare
0    1            0.0            0.0            1.0           1        0.0  ...      0     3.0  22.0    1.0    0.0   7.2500
1    0            1.0            0.0            0.0           0        0.0  ...      0     1.0  38.0    1.0    0.0  71.2833
2    0            0.0            0.0            1.0           0        0.0  ...      1     3.0  26.0    0.0    0.0   7.9250
3    0            0.0            0.0            1.0           0        0.0  ...      0     1.0  35.0    1.0    0.0  53.1000
4    1            0.0            0.0            1.0           1        0.0  ...      1     3.0  35.0    0.0    0.0   8.0500

[5 rows x 18 columns]

上記を 5-Fold CV と RandomForest で予測してみる。

>>> from sklearn.model_selection import StratifiedKFold
>>> from sklearn.ensemble import RandomForestClassifier
>>> from sklearn.model_selection import cross_validate
>>> 
>>> folds = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
>>> result = cross_validate(RandomForestClassifier(n_estimators=100),
...                         X=features,
...                         y=df.survived,
...                         cv=folds,
...                         )

結果は次の通り。 データを考えると妥当な内容といえるはず。

>>> result['test_score'].mean()
0.8194004369833607

まとめ

今回は sklearn-pandas を使って scikit-learn と pandas の食べ合わせの悪さを改善する方法について書いた。 ややクセがあって自分で記述が必要な部分もあるものの、場面を選べば便利なパッケージだと感じた。

また、記述方法に関して、個人的にはデータフレームを上書きしながら手続き的に処理していく一般的なやり方はあまり好きではない。 そのため、sklearn-pandas を使うと関数的に書けるところに好印象を受ける。 独自のパイプラインを組むのにパーツとして使うのも良いかもしれない。

ちなみに、ソースコードを見るとあんまりパフォーマンスチューニングについては考慮されていない印象を受ける。 加えて、numpy の配列とデータフレームの変換にかかるコストもあるので、大きいデータだと辛いかもしれない。

いじょう。