はじめに
pipelineとは?
sklearnにはfitやtransformをメソッドとして持つオブジェクトが数多く実装されています。正規化のためのsklearn.preprocessing.StandardScalerから、分類器としてのsklearn.svm.SVCなどなど。
pipelineを用いることで、そういった共通のインターフェースを持つ機能をつなぎ合わせて1つのまとまった処理として定義し、各所で繰り返し利用することができます。
メリットは?
- 主要な処理を1か所(pipeline)で管理できる
- pipelineを訓練データに対してもテストデータに対しても利用できる
特に2.のメリットが大きいですよね。
訓練データを前処理して、モデルを訓練して最適なパラメータをGridSearchで求めて、そのあと同様の処理を別途テストデータ向けに書くのは冗長ですし、一部の工程が漏れる可能性もあります。
データと全体像
kaggleのGhouls, Goblins, and Ghosts... Boo!のデータを用います。説明変数に数値・カテゴリカルデータを含んでしてデータもシンプルなためちょうど良いかなあと。尚、複数クラスの分類問題です。
機械学習では、一般的には以下のような手続きが必要とされると思います。
これをpipelineとgridsearchsvを用いてなるべくシンプルに実装していきます。
- 前処理
- 数値データを正規化
- カテゴリカルデータを数値に変換
- ハイパーパラメータのチューニング
- テストデータを用いて予測値を出力
-
- の事前処理モデルを用いたテストデータ変換(つまりfitせずtransformだけ行う)
-
- のベストパラメータ+学習データ全体で訓練されたモデルを用いて予測
-
実装
データ読み込み
ごくごく普通の処理なので読み飛ばしてください。
import pandas as pd
import numpy as np
df = pd.read_csv("train.csv")
df_t = pd.read_csv("test.csv")
df.head(3)
df_t.head(3)
変数を属性ごとに定義
num_cols = ['bone_length', 'rotting_flesh', 'hair_length', 'has_soul']
cat_cols = ["color"]
tgt_col = ["type"]
パイプライン構築
目的変数(type)
このコンペでは、骨の長さや対象の色などの情報から、対象がゴブリンなのか、グールなのかを判別します。種族=目的変数type
です。
ここでは、目的変数の値を文字から数値、即ち Goblin -> 0, Ghoul -> 1 などに変換するためのパイプラインを定義しています。
from sklearn.preprocessing import OrdinalEncoder
from sklearn.pipeline import Pipeline
pl_y = Pipeline([("labelise", OrdinalEncoder())])
labelise
の名称は任意です。パイプラインの外から、このステップに対してパラメータを付与することができます。名称を明示的に指定しないパイプラインも作れるようですがここでは割愛します。
また、OrdinalEncoderの代わりにLabelBinarizerが一見使えそうなのですが、pipelineには対応していないようで、pipeline実行時に以下のエラーが出てしまいます。
fit_transform() takes 2 positional arguments but 3 were given with LabelBinarizer
説明変数
説明変数にはbone_length
などの数値データ、そしてcolor
などのカテゴリカルデータが含まれています。
ここでは、数値データには正規化を、カテゴリカルデータにはダミー変数への変換を適用するパイプラインを構築し、SVMに学習データとして流し込むパイプラインを定義します。
sub-pipeline(make_column_transformer)
pipelineはあるステップにおいて一様の処理しか行えないため、このままではカラム毎に適用する処理をコントロールすることができません。
そこで用いるのがmake_column_transformer
です。以下のように定義します。
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.compose import make_column_transformer
ct = make_column_transformer(
(StandardScaler(), num_cols), # (procedure, cols)
(OneHotEncoder(sparse=False, drop="first"), cat_cols)
)
尚、Collinearを避けるためにダミー変数のうち1つのカラムをドロップしています。
pipeline
column transformerを含むパイプラインを構築します。
pl = Pipeline([("ct", ct), ("SVM", SVC(random_state=0))])
GridSearchCVの定義
続いて、GridSearchCV
で試行するパラメータを定義します。
pipeline内のどのステップにモデルが格納されているかGridSearchCV
に教える必要があるため、パラメータは以下のように、ステップ名__パラメータ名: [パラメータ値候補]
と記載する必要があります。
param = dict({"SVM__C": [1, 10, 100],
"SVM__kernel":["poly", "rbf", "linear"],
"SVM__gamma": ["auto", "scale"],
"SVM__degree": [1, 3, 5]})
続いて、GridSearchCVにpipelineを分類器として与えて定義します。
gscv = GridSearchCV(pl, param, cv=10, refit=True, iid=False)
refit=True
: クロスバリデーション実施後、ベストパラメータ(best_params_)と訓練データ全体を用いて学習し直す。このモデルがテストデータを用いた予測で用いられる。
iid=False
: 省略すると警告(上位バージョンでデフォルト値が変わる予定だから明示的に指定しとけ的な)出るので明示的に指定。iid=False
で、クロスバリデーションの精度を10foldの平均値として計算します。
GridSearchCVの実行
gscv.fit()
に訓練データとラベルデータを与えて実行します。
ラベルデータは事前に定義した目的変数用のpipelineであるpl_y
を呼び、前処理された結果がgscv.fit()
に流れるようにします。2つのpipelineが直列に繋がっている形です。1
# execute grid search
gscv.fit(train_x, # 訓練データ
np.reshape(pl_y.fit_transform(train_y), (-1)))# テストデータ
# show the result
print("Best Score: {}, Best parameters: {}".format(np.round(gscv.best_score_, 4), gscv.best_params_))
Best Score: 0.7381, Best parameters: {'SVM__C': 1, 'SVM__degree': 1, 'SVM__gamma': 'auto', 'SVM__kernel': 'poly'}
テストデータを用いた予測値の出力
この工程が最も感動したポイントですが、以下の1行で、
- テストデータの前処理
- 学習済みモデルを用いて予測値の出力
が行えます。
pred = gscv.predict(test_x)
予測値を数値→テキストに逆変換
このkaggleのコンペではtype
データはラベル値ではなくラベル名称での提出が求められます。
しかしこの時点では、予測値はラベル値です。
pred[:5]
array([1., 2., 1., 2., 0.])
つまり、ラベル値からラベル名称への逆変換が必要なのですが、ここで目的変数の前処理用に構築したパイプラインが利用できます。
pred_inv = pl_y.inverse_transform(pred.reshape(-1, 1))
pred_inv[:5]
array([['Ghoul'],
['Goblin'],
['Ghoul'],
['Goblin'],
['Ghost']], dtype=object)
提出用ファイルの出力
pd.concat([df_t["id"],
pd.DataFrame(pl_y.inverse_transform(pred.reshape(-1, 1)).reshape(-1), columns=["type"])],
axis=1).to_csv("./submit.csv", index=False)
感想
jupyterを使って探索的にコードを書いていると、同じような処理を何度も書くはめになりイマイチだなあと常々思っていたのですが、そのモヤモヤに対する1つのアンサーを見つけられたようで非常に嬉しいです。
-
1つのpipelineで構築できれば良いのですが、2019年7月時点では方法が見当たりませんでした。 ↩