pipelineとgridsearchcvを使って前処理から予測値出力の流れをシンプルに実装

# はじめに
## pipelineとは？
sklearnにはfitやtransformをメソッドとして持つオブジェクトが数多く実装されています。正規化のためのsklearn.preprocessing.StandardScalerから、分類器としてのsklearn.svm.SVCなどなど。
pipelineを用いることで、そういった共通のインターフェースを持つ機能をつなぎ合わせて1つのまとまった処理として定義し、各所で繰り返し利用することができます。

## メリットは？
1. 主要な処理を1か所(pipeline)で管理できる
2. pipelineを訓練データに対してもテストデータに対しても利用できる

特に2.のメリットが大きいですよね。
訓練データを前処理して、モデルを訓練して最適なパラメータをGridSearchで求めて、そのあと同様の処理を別途テストデータ向けに書くのは冗長ですし、一部の工程が漏れる可能性もあります。

## データと全体像
kaggleの[Ghouls, Goblins, and Ghosts... Boo!](https://www.kaggle.com/c/ghouls-goblins-and-ghosts-boo)のデータを用います。説明変数に数値・カテゴリカルデータを含んでしてデータもシンプルなためちょうど良いかなあと。尚、複数クラスの分類問題です。

機械学習では、一般的には以下のような手続きが必要とされると思います。
これをpipelineとgridsearchsvを用いてなるべくシンプルに実装していきます。

1. 前処理
    - 数値データを正規化
    - カテゴリカルデータを数値に変換
2. ハイパーパラメータのチューニング
3. テストデータを用いて予測値を出力
    - 1. の事前処理モデルを用いたテストデータ変換(つまりfitせずtransformだけ行う)
    - 2. のベストパラメータ＋学習データ全体で訓練されたモデルを用いて予測

# 実装
## データ読み込み
ごくごく普通の処理なので読み飛ばしてください。

```python
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)
```
df.head(3)
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366131/15ec286b-dcd7-f6e4-8278-877cc5d495d9.png" width=70%>

df_t.head(3)
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366131/20e41e2f-961c-9060-22a2-47ccec897933.png" width=70%>

## 変数を属性ごとに定義
```python
num_cols = ['bone_length', 'rotting_flesh', 'hair_length', 'has_soul']
cat_cols = ["color"]
tgt_col  = ["type"]
```

## パイプライン構築
### 目的変数(type)
このコンペでは、骨の長さや対象の色などの情報から、対象がゴブリンなのか、グールなのかを判別します。種族＝目的変数`type`です。
ここでは、目的変数の値を文字から数値、即ち Goblin -> 0, Ghoul -> 1 などに変換するためのパイプラインを定義しています。

```python
from sklearn.preprocessing import OrdinalEncoder
from sklearn.pipeline import Pipeline

pl_y = Pipeline([("labelise", OrdinalEncoder())])
```

`labelise`の名称は任意です。パイプラインの外から、このステップに対してパラメータを付与することができます。[名称を明示的に指定しないパイプライン](https://scikit-learn.org/stable/modules/generated/sklearn.pipeline.make_pipeline.html)も作れるようですがここでは割愛します。

また、OrdinalEncoderの代わりに[LabelBinarizer](https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.LabelBinarizer.html)が一見使えそうなのですが、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`です。以下のように定義します。


```python
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を含むパイプラインを構築します。

```python
pl = Pipeline([("ct", ct), ("SVM", SVC(random_state=0))])
```

## GridSearchCVの定義
続いて、`GridSearchCV`で試行するパラメータを定義します。
pipeline内のどのステップにモデルが格納されているか`GridSearchCV`に教える必要があるため、パラメータは以下のように、`ステップ名__パラメータ名: [パラメータ値候補]`と記載する必要があります。

```python
param = dict({"SVM__C": [1, 10, 100], 
              "SVM__kernel":["poly", "rbf", "linear"],
              "SVM__gamma": ["auto", "scale"], 
              "SVM__degree": [1, 3, 5]})
```

続いて、GridSearchCVにpipelineを分類器として与えて定義します。


```python
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]

[^1]: 1つのpipelineで構築できれば良いのですが、2019年7月時点では方法が見当たりませんでした。

```python
# 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行で、
1. テストデータの前処理
2. 学習済みモデルを用いて予測値の出力
が行えます。

```python
pred = gscv.predict(test_x)
```

## 予測値を数値→テキストに逆変換
このkaggleのコンペでは`type`データはラベル値ではなくラベル名称での提出が求められます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366131/71f09449-f47b-2afe-bff4-878ea2a72f31.png" width=50%</img>

しかしこの時点では、予測値はラベル値です。
> pred[:5]
> array([1., 2., 1., 2., 0.])

つまり、ラベル値からラベル名称への逆変換が必要なのですが、ここで目的変数の前処理用に構築したパイプラインが利用できます。

```python
pred_inv = pl_y.inverse_transform(pred.reshape(-1, 1))
pred_inv[:5]
```

> array([['Ghoul'],
       ['Goblin'],
       ['Ghoul'],
       ['Goblin'],
       ['Ghost']], dtype=object)

## 提出用ファイルの出力
```python
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つのアンサーを見つけられたようで非常に嬉しいです。