【5行で書ける】高度なハイパーパラメータサーチとその可視化

機械学習・DeepLearningにおけるハイパーパラメータサーチをもっと簡単に・高度にするモジュールを作ったので、紹介します。

![readme_00.PNG](https://qiita-image-store.s3.amazonaws.com/0/208177/865e5c94-86d8-cb82-730e-05e15a82c979.png)

* __このモジュールが役に立ちそうな人__
    * ハイパーパラメータサーチとそれに伴う周辺の作業をできるだけ簡単にしたい人。
    * 高度なサーチアルゴリズムを手軽に試したい人。
    
* __できること__
   * scikit-learnライクなシンプルインターフェースでハイパーパラメータサーチに加えて以下が実行可能。
       * ログの管理
       * ログの可視化
       * 特徴量選択
   * scikit-learnには搭載されていない以下のアルゴリズムをサポート。
       * ベイズ最適化 (GpyOpt)
       * 遺伝的アルゴリズム
       * Sequential Model Based Global Optimization (Hyperopt)

# __なぜ作ったか？__
機械学習・DeepLearningをするにあたって、避けて通れないハイパーパラメータサーチ。これをやっていると、以下のように思うことがありませんか？


* サーチのログ管理とその可視化も、サーチクラスのなかでやって欲しい！
    *  サーチのクラスに含まれていないことが多く、自分で組まなければならない。
* ハイパーパラメータサーチのついでに、特徴量選択をやって欲しい！
    * これもサーチのクラスに含まれていないことが多い。
* ベイズ最適化や遺伝的アルゴリズムなどの高度なアルゴリズムを試したい！
    * それぞれをサポートするライブラリはあるものの、インターフェースがまちまちなので、気軽に試せない。


実際にこれをやろうとすると、それなりの量のコードを書かなければなりません。
「これらを5行くらいで書けないものか？」と思い、[cvopt (cross validation optimizer)](https://github.com/genfifth/cvopt)というpythonモジュールを作ってみました。

## Quick start: 5行で書いてみた

```python
param_distributions ={"C": search_numeric(0, 3, "float"), "tol" : search_numeric(0, 4, "float"), 
                      "penalty": search_category(['l1', 'l2'])}
opt = SimpleoptCV(estimator=LogisticRegression(), param_distributions=param_distributions, 
                  backend="bayesopt")
opt.fit(Xtrain, ytrain, feature_groups=[0,0,0,1,1])
```

... 流石に5行だと無理がありますね。
ただ普通に書くよりは大分シンプルになっています。

# __インストール__
pipでインストールできます。

```bash
pip install Gpy
pip install cvopt
```

# __使い方__
## 1. サーチ in scikit-learn
ここでは例として、scikit-learnの`LogisticRegression`（表形式データに対する分類）のサーチを行います。なおログをnotebook上で可視化したい場合、bokehというライブラリを内部で使うので、事前に以下を実行します。

```python
from bokeh.io import output_notebook
output_notebook() 
```

### 1.1. ハイパーパラメータの設定
数値パラメータは[search_numeric](https://genfifth.github.io/cvopt/modules/search_setting/cvopt.search_setting.search_numeric.html#cvopt.search_setting.search_numeric)、カテゴリカルパラメータは[search_category](https://genfifth.github.io/cvopt/modules/search_setting/cvopt.search_setting.search_category.html#cvopt.search_setting.search_category)という関数を使って、範囲を設定します。

```python
param_distributions = {
    "C": search_numeric(0.01, 3.0, "float"),   # サーチ範囲min, max, 変数のタイプ("int" or "float")
    "tol" : search_numeric(0.0001, 0.001, "float"),  
    "penalty": search_category(['l1', 'l2']),  # サーチ対象カテゴリのリスト
    "class_weight" : search_category([None, "balanced"]),
    }
```

また上記以外にもいくつかの設定方法が可能です。[サンプルnotebook](https://github.com/genfifth/cvopt/blob/master/notebooks/basic_usage_jp.ipynb)に記載があります。


### 1.2. 特徴量選択の設定 (オプション)
特徴量選択は特徴量のグループ：`feature_group`毎に選択するか否かをサーチします。
特徴量選択をする際には、このグループをどう分けるかを指定する必要があります。
グループを分ける方法は、例えばデータソースの違いや特徴量エンジニアリング方法の違いがあります。

-----------------
#### feature_groupの例
データが5個の特徴量（カラム）を持っていて、以下のように`feature_group`を設定したいとします。
   
| feature index(data col index) | feature group |   
|:------------:|:------------:|   
| 0 | 0 |
| 1 | 0 |
| 2 | 0 |
| 3 | 1 |
| 4 | 1 |
   
この場合、以下のようにlistを定義します。   
   
```python
feature_groups = [0, 0, 0, 1, 1]
```

サーチの結果として, `feature_group`毎に選択したかどうかを表すbooleanが得られます。

```python
feature_groups0: True   
feature_groups1: False
```

この結果は、グループ0の特徴量を選択し、グループ1の特徴量を選択しないという意味になります。

-----------------

なお

* `feature_group`を設定しない場合、全ての特徴量が使用されます。
* `feature_group`が`-1`であれば、そのグループの特徴量は必ず選択されます。

### 1.3. サーチ実行
scikit-learnのCross-validationクラスと同じように実行できます。

```python
estimator = LogisticRegression()
cv = StratifiedKFold(n_splits=3, shuffle=True, random_state=0)

opt = SimpleoptCV(estimator, param_distributions, 
                 scoring="roc_auc",  # サーチの目的関数
                 cv=cv,              # Cross-validationの設定
                 max_iter=32,        # サーチ回数
                 n_jobs=3,           # Cross-validationの並列実行数
                 backend="bayesopt", # サーチで使用するアルゴリズム
                 )

opt.fit(Xtrain, ytrain, feature_groups=feature_groups)
```

サーチ結果も、scikit-learnのCross-validationクラスと同じように、以下で表示できます。

```python
pd.DataFrame(opt.cv_results_)
```

#### 1.3.1 アルゴリズムの変更
アルゴリズムは`backend`の指定を変えるだけで変更可能です（サーチ範囲の設定などはそのまま流用可能）。
以下の`backend`をサポートしています。

* `"bayesopt"` : ベイズ最適化 (GpyOpt)
* `"gaopt"` :    遺伝的アルゴリズム
* `"hyperopt"` : Sequential Model Based Global Optimization (Hyperopt)
* `"randomopt"` :ランダムサーチ

なお[backendを直接操作するCross-validationクラス](https://genfifth.github.io/cvopt/modules/cvopt.model_selection.html)も用意しています。

#### 1.3.2 ログの保存と可視化
Cross-validationクラスの定義時にオプションを指定することで、ログの保存と可視化が可能です。
またfit時に`validation_data`を指定することで、そのデータセットに対するスコアも確認可能です。

```python
opt = SimpleoptCV(estimator, param_distributions, 
                 scoring="roc_auc", 
                 cv=cv, 
                 max_iter=32, 
                 n_jobs=3, 
                 backend="bayesopt", 
                 logdir="./search_usage", # ログの保存ディレクトリ。これが指定されるとログを保存する。
                 model_id="search_usage", # ログ管理で使用するid。
                 save_estimator=2,        # estimatorの保存設定
                                          #    0: 保存しない
                                          #    1: cvの各foldでfitしたestimatorを保存
                                          #    2: 1に加え、train_data全体でfitしたestimatorを保存
                 verbose=2,               # サーチログの表示設定。
                                          #    0: 非表示
                                          #    1: 最高スコアとサーチ完了時間（推定）をテキストで表示。
                                          #    2: サーチログを可視化して表示
                 )

opt.fit(Xtrain, ytrain, feature_groups=feature_groups, 
        validation_data=(Xtest, ytest))   # validation_data
```

`verbose`=`2`だと以下のように、notebook上にパラメータのヒストグラムやサーチ完了時間（推定）が表示されます。ヒストグラムを見ているとアルゴリズムの気持ちがわかるかもしれません...。

![02.png](https://qiita-image-store.s3.amazonaws.com/0/208177/f679e8d2-ece5-c173-0822-1f526f56b0a9.png)


またログは`logdir`に以下の構造で保存されます。これらは1つのパラメータがサーチされるたびに更新されるため、不測の事態でサーチが中断されても、そこまでの結果は利用可能です。
なおグラフ（html）は保存時に体裁を整えつつ、サーチ結果をパラメータとして背景を自動生成するようになっています。記事冒頭の画像がそれです。

```
logdir
|-cv_results
| |-{model_id}.csv                                     # サーチ結果（opt.cv_results_)
| ...
|-cv_results_graph
| |-{model_id}.html                                    # サーチ結果(グラフ)
| ...
|-estimators_{model_id}
    |-{model_id}_index{search count}_split{fold count} # cvの各foldでfitしたestimator
    ...
    |-{model_id}_index{search count}_test              # train_data全体でfitしたestimator
```

## 2. サーチ in Keras
cvoptはscikit-learnライクなestimatorに対応しているため、[keras.wrappers.scikit_learn](https://keras.io/ja/scikit-learn-api/)などを使用すれば、Kerasに対するサーチも実行可能です。ここでは例として、`KerasClassifier`（画像データに対する分類）のサーチを行います。

```python
n_classes = 10
def mk_nw(activation, lr, out_dim):
    model = Sequential()
    model.add(Conv2D(20, kernel_size=5, strides=1, 
                     activation=activation, input_shape=Xtrain.shape[1:]))
    model.add(MaxPool2D(2, strides=2))

    model.add(Conv2D(50, kernel_size=5, strides=1, activation=activation))
    model.add(MaxPool2D(2, strides=2))

    model.add(Flatten())
    model.add(Dense(out_dim, activation=activation))
    model.add(Dense(n_classes, activation="softmax"))

    model.compile(loss="categorical_crossentropy", optimizer=optimizers.SGD(lr=lr))
    return model
estimator = KerasClassifier(mk_nw, activation="linear", lr=0.01, out_dim=256, epochs=16, verbose=0)
```

cvoptをKerasで使用するためには、__scorer__と__saver__の定義が必要です。

### 2.1 scorerの定義
上記の`KerasClassifier`はfit時にytrueが1hot形式（ (n_samples, n_classes)のマトリクス ）であることを前提としますが、`predict`の返り値`ypred`はラベル形式（ (n_samples, )のベクトル ）となります。一方、通常のスコア関数（例えば`accuracy_score(ytrue, ypred)`）は`ytrue`と`ypred`で同じ形式を前提とするため、`KerasClassifier`に対し使用するとエラーとなります。

よってこの差を吸収するようなスコア関数`scorer`を定義する必要があります。この定義には[sklearn.metrics.make_scorer](http://scikit-learn.org/stable/modules/generated/sklearn.metrics.make_scorer.html#sklearn.metrics.make_scorer)を使用します。

```python
from sklearn.metrics import make_scorer, accuracy_score

def acc(ytrue, ypred):
    return accuracy_score(np.argmax(ytrue, axis=1), ypred)
scorer = make_scorer(acc,  greater_is_better=True)
```

### 2.2 saverの定義
cvoptではestimatorを保存する際に、デフォルトで`sklearn.externals.joblib.dump`を使用します。この保存方法はKerasの推奨ではないため、保存関数`saver`をKeras用に定義します。なおestimatorを保存しない場合、これは不要です。

```python
from keras.models import save_model

def saver(estimator, path):
    save_model(estimator.model, path)
# KerasClassifierはKerasClassifier.modelにモデルを保持するので、estimator.modelという指定になります。
```


### 2.3 サーチ実行
scorerとsaverを指定する以外は、前述と同じようにサーチが実行可能です。

```python
param_distributions = {
    "activation": search_category(["linear", "relu"]),
    "lr":  search_numeric(0.0001, 0.1, "float"),  
    "out_dim" : search_numeric(124, 512, "int"),  
    }
cv = StratifiedKFold(n_splits=3, shuffle=True, random_state=0)
opt = SimpleoptCV(estimator, param_distributions, 
                  scoring=scorer, 
                  cv=cv, 
                  max_iter=8, 
                  n_jobs=1,
                  verbose=2, 
                  logdir="./cifar10", 
                  model_id="search_usage", 
                  save_estimator=2, 
                  saver=saver, 
                  backend="hyperopt", 
                  )
opt.fit(Xtrain, ytrain, validation_data=(Xtest, ytest))
```

### 2.4 その他注意事項
* 並列処理
cvoptの並列処理はpythonの`multiprocessing`で実現しています。一方、Kerasの`multiprocessing`による並列化においては環境依存の問題が存在します[（例えばこのissue）](https://github.com/keras-team/keras/issues/8396)。並列化で問題が起きた際には、`n_jobs`を`1`にしてみてください。

* 特徴量選択
基本的にKerasは`input_shape`を固定してmodelを定義します。一方、特徴量選択を実行する場合、inputのshapeはその選択により変化します。よってKerasにおいて特徴量選択を使用する場合、`input_shape`が入力によって可変になるような実装をする必要があります。

* Resource exhausted error
gpu環境でサーチを実行するとResource exhaustedエラーが発生することがあります。 このエラーの1つの対策は、特定のタイミングでセッションをクリアすることです。`keras.wrappers.scikit_learn`を使用する場合、以下のような対策があります。ただしこれを行うとsaverによるモデルの保存ができなくなるので、保存が必要な場合はKerasの[callback](https://keras.io/ja/callbacks/)などを使用する必要があります。
   * [【5行くらいで書ける】keras.wrappers.scikit_learnにおけるResourceExhaustedError対策](https://qiita.com/gen_fifth/items/a58359681a0878c0e711)


## 3. ログ利用
### 3.1 サーチ結果の抽出
cvoptではヘルパー関数を使って、ログからサーチ結果のハイパーパラメータ・特徴量選択結果をささっと抽出できます。

```python
from cvopt.utils import extract_params
estimator_params, feature_params, feature_select_flag  = extract_params(
   logdir="./search_usage",        # ログの保存ディレクトリ
   model_id="search_usage",        # 抽出対象のid
   target_index=0,                 # 抽出対象のindex（N回目のサーチ結果のindexがN-1になる）
   feature_groups=feature_groups,  # feature_groups（サーチ時と同じもの）
   )

estimator.set_params(**estimator_params)         # パラメータのセット
Xtrain_selected = Xtrain[:, feature_select_flag] # 特徴量選択結果の反映
```

### 3.2 stackingのためのメタ特徴量生成
estimatorを保存できる仕様にしたので「どうせならKaggleで使用されている[stacking](https://mlwave.com/kaggle-ensembling-guide/)用の特徴量を生成できるといいのでは？」と思い、作ってみました。
これをする場合estimatorの保存が必要なので、サーチ実行時に`save_estimator`を`2`に指定してください。

```python
from cvopt.utils import mk_metafeature 
Xtrain_meta, Xtest_meta = mk_metafeature(Xtrain, ytrain, 
   logdir="./search_usage", 
   model_id="search_usage", 
   target_index=0, 
   cv=cv,                           # Cross-validationの設定（サーチ時と同じもの）
   validation_data=(Xtest, ytest),  # fitしていないデータに対するメタ特徴量を生成する場合に指定する。
                                    # (train全体でfitしたestimatorによるメタ特徴量が生成される。)
   feature_groups=feature_groups,   # feature_groups（サーチ時と同じもの）
   estimator_method="predict_proba" # メタ特徴量を生成する際のメソッド
   )

```
## 3. 参考
* [GitHub](https://github.com/genfifth/cvopt)
   * [サンプルnotebook (LogisticRegression)](https://github.com/genfifth/cvopt/blob/master/notebooks/basic_usage_jp.ipynb)
   * [サンプルnotebook (Keras)] (https://github.com/genfifth/cvopt/blob/master/notebooks/keras_sample.ipynb)
   * [API Reference](https://genfifth.github.io/cvopt/index.html)

# __おわりに__
__5行で書く__というのはちょっと無理がありましたが、ハイパーパラメータサーチとその周辺をシンプルに書けるようにしてみました。
こーゆーものを通して、データサイエンスをどんどんシンプルにしていきたいです。

# __参考にさせて頂いた記事__
* [ベイズ最適化入門](https://qiita.com/masasora/items/cc2f10cb79f8c0a6bbaa)
* [Pythonでベイズ最適化を行うパッケージ GPyOpt](https://qiita.com/marshi/items/51b82a7b990d51bd98cd)
* [【初心者向け】Re:ゼロから始める遺伝的アルゴリズム【人工知能】](https://qiita.com/Azunyan1111/items/975c67129d99de33dc21)
* [hyperoptって何してんの？](https://qiita.com/kenchin110100/items/ac3edb480d789481f134)
* [ハイパーパラメータ自動調整いろいろ](https://qiita.com/cvusk/items/1f3b178f34c39beb29ff)