どこから見てもメンダコ

軟体動物門頭足綱八腕類メンダコ科

optunaでsklearnモデルやXGBにハイパーパラメータチューニング機能をつける

RidgeCVは便利だよね

sklearn.linear_modelRidgeCVfitメソッドにより内部で交差検証を行ってハイパーパラメータのチューニングまで自動で実行してくれる便利なクラスです。 探索してくれるのは正則化の強度パラメータであるalphaだけで、かつその範囲もデフォルトでは0.1, 1.0, 10.0だけと心許ない感じではありますが、それでも様々なモデルの精度をざくっとみたいモデリングの初期には便利です。

しかしBruteforce探索の限界なのか、sklearnでbuilt-in CVが付属しているのはRidgeやLassoなどのパラメータが少なく計算も軽い線形モデルだけです。

そこで、Preferred Networks, Inc.開発のブラックボックス関数最適化のためのpythonパッケージであるoptunaを使用してsklearnのRIdgeCV風にSVRCVやXGBRegressorCVを作ってみたいと思います。

※本記事のコードは公式githubのexampleを参考に作成しました

github.com


データセット

みんな大好きいつものボストン住宅データセット

import numpy as np
import pandas as pd

from sklearn.preprocessing import StandardScaler
from sklearn.datasets import load_boston
from sklearn.model_selection import train_test_split


X = pd.DataFrame(load_boston().data, columns=load_boston().feature_names)
y = pd.DataFrame(load_boston().target, columns=['Price'])

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3)
scaler = StandardScaler()
scaler.fit(X_train)

X_sc_train = scaler.transform(X_train)
X_sc_test = scaler.transform(X_test)


1. まずはRidgeCVから

まずはsklearn.linear_model.RIdgeCVの再現から始めます。

from sklearn.linear_model import Ridge
from sklearn.model_selection import KFold


class RidgeCV():
    model_cls = Ridge

    def __init__(self, n_trials=100):
        self.n_trials = n_trials

    def fit(self, X, y):
        if isinstance(X, np.ndarray):
            X = pd.DataFrame(X)
            y = pd.DataFrame(y)
        elif isinstance(X, pd.DataFrame):
            X = X.reset_index(drop=True)
            y = y.reset_index(drop=True)

        self.X = X
        self.y = y

        study = optuna.create_study(direction='maximize')
        study.optimize(self, n_trials=self.n_trials)
        self.best_trial = study.best_trial

        print()
        print("Best score:", round(self.best_trial.value, 2))
        print("Best params:", self.best_trial.params)
        print()

        self.best_model = self.model_cls(**self.best_trial.params)
        self.best_model.fit(self.X, self.y)

    def predict(self, X):
        if isinstance(X, pd.Series):
            X = pd.DataFrame(X.values.reshape(1, -1))
        elif isinstance(X, np.ndarray):
            X = pd.DataFrame(X)

        return self.best_model.predict(X)

    def score(self, X, y):
        if isinstance(X, np.ndarray):
            X = pd.DataFrame(X)
            y = pd.DataFrame(y)

        return self.best_model.score(X, y)

    def kfold_cv(self, model, splits=5):
        scores = []

        kf = KFold(n_splits=splits, shuffle=True)
        for train_index, test_index in kf.split(self.X):
            X_train, X_test = self.X.iloc[train_index], self.X.iloc[test_index]
            y_train, y_test = self.y.iloc[train_index], self.y.iloc[test_index]
            model.fit(X_train, y_train)
            scores.append(model.score(X_test, y_test))

        score = np.array(scores).mean()
        return score

    def __call__(self, trial):
        alpha = trial.suggest_loguniform('alpha', 1e0, 1e2)
        model = self.model_cls(alpha=alpha)
        score = self.kfold_cv(model)
        return score

※KFoldがインデックスが揃ってないデータフレームに対してよくわからない挙動をするのでpd.DataFrameに変換したりreset_index()をしたりとやや冗長なコードになってしまいました。


RidgeCV.fit(X, y)が呼ばれるとoptunaが起動し、study.optimize(self, n_trials=self.n_trials)self.__call__()によってハイパーパラメータの探索試行が行われます。試行ではあるハイパーパラメータの組み合わせにおけるmodelの精度を5分割KFoldでスコアリングします。試行はn_trials回行い、最も結果がよかったパラメータの組み合わせでpredict()score()で使用されるモデルが訓練されます。


パラメータ最適化する場合

>>> model = RidgeCV(n_trials=30)
>>> model.fit(X_sc_train, y_train)
[I 2019-09-02 22:21:45,824] Finished trial#0 resulted in value: 0.697599103770344. Current best value is 0.697599103770344 with parameters: {'alpha': 62.76453278130266}.
[I 2019-09-02 22:21:45,909] Finished trial#1 resulted in value: 0.6982891322971174. Current best value is 0.6982891322971174 with parameters: {'alpha': 2.850086378345985}.
~中略~
[I 2019-09-02 22:21:48,383] Finished trial#29 resulted in value: 0.7283317363854922. Current best value is 0.7290569859072776 with parameters: {'alpha': 9.503946089070118}.
Best score: 0.73
Best params: {'alpha': 9.503946089070118}
>>> print(model.score(X_sc_test, y_test))
0.6985819125601394

デフォルトパラメータで訓練する場合

>>> model = Ridge()
>>> model.fit(X_sc_train, y_train)
>>> print(model.score(X_sc_test, y_test))
0.6988474458318901

Ridgeでボストンデータセットだとパラメータ探索してもしなくても0.69程度のスコアでした。


2. GridSearchだとけっこうしんどいSVR

SVRは汎用性が高く個人的には回帰問題のファーストピックですが、パラメータサーチをきちんと行わないと精度がでないのが面倒なところです。

from sklearn.svm import SVR

class SVRCV(RidgeCV):
    model_cls = SVR

    def __call__(self, trial):
        kernel = trial.suggest_categorical('kernel', ['rbf', 'linear'])
        C = trial.suggest_loguniform('C', 1e0, 1e2)
        epsilon = trial.suggest_loguniform('epsilon', 1e-1, 1e1)
        model = self.model_cls(kernel=kernel, C=C,
                               epsilon=epsilon, gamma='auto')

        score = self.kfold_cv(model)
        return score

RidgeCVと構造はほぼ同じなので継承によってシンプルに書けます。


パラメータ最適化する場合

>>> model = SVRCV(n_trials=100)
>>> model.fit(X_sc_train, y_train)
~略~
[I 2019-09-02 22:34:51,280] Finished trial#99 resulted in value: 0.8020224328731113. Current best value is 0.8599302680077867 with parameters: {'kernel': 'rbf', 'C': 95.38592381835898, 'epsilon': 1.6773144109569142}.
Best score: 0.86
Best params: {'kernel': 'rbf', 'C': 95.38592381835898, 'epsilon': 1.6773144109569142}
>>> print(model.score(X_sc_test, y_test))
0.8794262116660849

デフォルトパラメータで訓練する場合

>>> model = SVR()
>>> model.fit(X_sc_train, y_train)
>>> print(model.score(X_sc_test, y_test))
0.6667099218666681

おお、SVRの場合はパラメータサーチする/しないで大きく精度が変わりました。 デフォルトパラメータではscoreは0.66とRidge回帰以下の精度ですが、パラメータ探索によって0.87まで向上しています。 ちなみにn_trials=100で実行時間は15秒ほど。


3. 本命のXGBRegressor

とにかくパラメータが多いXGBもお気楽チューニング。

import xgboost as xgb

class XGBRegressorCV(RidgeCV):
    model_cls = xgb.XGBRegressor

    def __call__(self, trial):
        booster = trial.suggest_categorical('booster', ['gbtree', 'dart'])
        alpha = trial.suggest_loguniform('alpha', 1e-8, 1.0)

        max_depth = trial.suggest_int('max_depth', 1, 9)
        eta = trial.suggest_loguniform('eta', 1e-8, 1.0)
        gamma = trial.suggest_loguniform('gamma', 1e-8, 1.0)
        grow_policy = trial.suggest_categorical(
            'grow_policy', ['depthwise', 'lossguide'])

        if booster == 'gbtree':
            model = self.model_cls(silent=1, booster=booster,
                                   alpha=alpha, max_depth=max_depth, eta=eta,
                                   gamma=gamma, grow_policy=grow_policy)
        elif booster == 'dart':
            sample_type = trial.suggest_categorical('sample_type',
                                                    ['uniform', 'weighted'])
            normalize_type = trial.suggest_categorical('normalize_type',
                                                       ['tree', 'forest'])
            rate_drop = trial.suggest_loguniform('rate_drop', 1e-8, 1.0)
            skip_drop = trial.suggest_loguniform('skip_drop', 1e-8, 1.0)
            model = self.model_cls(silent=1, booster=booster,
                                   alpha=alpha, max_depth=max_depth, eta=eta,
                                   gamma=gamma, grow_policy=grow_policy,
                                   sample_type=sample_type,
                                   normalize_type=normalize_type,
                                   rate_drop=rate_drop, skip_drop=skip_drop)

        score = self.kfold_cv(model)
        return score

選ばれたboosterに応じてif文で使用するパラメータを変更しています。こういう処理を楽に記述できるのがgoodですね。


パラメータ最適化する場合

>>> model = XGBRegressorCV(n_trials=100)
>>> model.fit(X_sc_train, y_train)
~略~
[I 2019-09-02 22:51:05,374] Finished trial#99 resulted in value: 0.8502358779185025. Current best value is 0.8829819998433361 with parameters: {'booster': 'gbtree', 'alpha': 7.807503234311212e-06, 'max_depth': 5, 'eta': 0.09454508257094363, 'gamma': 1.1698753557081089e-07, 'grow_policy': 'depthwise'}.
Best score: 0.88
Best params: {'booster': 'gbtree', 'alpha': 7.807503234311212e-06, 'max_depth': 5, 'eta': 0.09454508257094363, 'gamma': 1.1698753557081089e-07, 'grow_policy': 'depthwise'}
>>> print(model.score(X_sc_test, y_test))
0.90519430053149

デフォルトパラメータで訓練する場合

>>> model = xgb.XGBRegressor()
>>> model.fit(X_sc_train, y_train)
>>> print(model.score(X_sc_test, y_test))
0.8988995910099546

パラメータチューニングする/しないに関わらずスコアは0.9程度でした。まあボストンデータセットの難易度が低いためカリカリにチューニングせずとも高精度が出るのでしょう。 試行100回で実行時間は1分程度でした。

まとめ

optunaは使いやすくなった Hyperopt という印象でとても便利!! 今回は一回の試行が軽いので使用していませんがDLモデルのチューニングのために筋の悪い試行を途中で打ち切るpruning機能もあるようです。興味のある方はgithubのexamplesを見てみましょう。

github.com

便利パッケージを公開してくれるPFNet先生に圧倒的感謝。。。