Optunaでハイパパラメータチューニング
今回はハイパパラメータチューニングを自動化するOptunaを触りながら紹介していきます。
Optuna
勾配ブースティング木やニューラルネットワークなどのハイパパラメータチューニングは職人芸みたいなところがあって、一発で最適に近い値をマークするなどほぼ不可能です。かと言って、グリッドサーチなどで探索しようにもパラメータ数が多いと計算時間が膨大になります。
こうした実質ブラックボックスのパラメータチューニングを、ベイズ最適化で行ってくれるPythonライブラリが Optuna です。
これら↓の記事が大変参考になります。
www.slideshare.net
BayesianOptimizationのかゆいところ
同じくベイズ最適化を行ってくれるPythonライブラリとして、以前紹介1した BayesianOptimization がありますが、以下の点で使い勝手の悪さを感じていました。
- 並列化がサポートされておらず、自前で実装しないといけない
- WebAPIなどでラップして途中の結果などを共有する必要がある
- https://github.com/fmfn/BayesianOptimization/blob/master/examples/async_optimization.py
- チューニングが必要なパラメータが異なるモデルを複数組み合わせて同時に最適化できない
他方Optunaはこれらの点で優位性があるかと思います。
- DB (SQLite含む) で結果を共有するため、並列化が簡単にできる
- カテゴリ変数など柔軟にパラメータを設定できる
- LightGBMについては探索空間を狭めるStep-wiseアルゴリズムが実装されている
Optunaを用いた実装
最初にpipでインストールしておきます。
$ pip install -U optuna
Python 3.7環境下でoptuna 1.5にて実装しています。
import sys print(sys.version) # 3.7.7 (default, Mar 26 2020, 10:32:53) # [Clang 4.0.1 (tags/RELEASE_401/final)] import optuna print(optuna.__version__) # 1.5.0
1変数関数の最適化
簡単な例として、まずは以下の1変数関数の最適化をOptunaで行います。2つの極大値を持つ関数となってます。
Optunaを用いたチューニングの実装は以下になります。大きく3ステップです。
- ステップ1. 目的関数 (f) をラップするobjective関数を定義
- 引数 (trial) はTrial型の値
- 探索 (トライアル) の途中状態を持つ
- 次に試すxを選ぶ (parameter suggestion) のインタフェースを持つ
- 引数 (trial) はTrial型の値
- ステップ2. Study型の変数 (study) を生成
- storage、sampler、prunerのインスタンスを持ち、新しいトライアルを実行するクラス
- storage: これまでのトライアルの結果を入出力する
- sampler: 次のトライアルのパラメータを選択する
- pruner: トライアルを途中で打ち切るかジャッジする
- create_study関数で初期化する
- storage、sampler、prunerのインスタンスを持ち、新しいトライアルを実行するクラス
- ステップ3. optimizeメソッドで最適化
- 最終的な結果はStudyのアトリビュート (best_value, best_params) に格納
- 履歴はget_trialsメソッド
20回の試行でx=-3.348のときに最大となっています。
import optuna def f(x): return -(x**4 - 20 * x**2 + 10 * x) + 300 def objective(trial): x = trial.suggest_uniform("x", -5, 5) ret = f(x) return ret study = optuna.create_study(direction="maximize") study.optimize(objective, n_trials=20) # 探索後の最良値 print(study.best_value) # 432.0175613887767 print(study.best_params) # {'x': -3.3480816839313774} # 探索の履歴 for trial in study.get_trials(): print(f"{trial.number}: {trial.value:.3f} ({trial.params['x']})") # 0: 352.773 (-4.423048512320604) # 1: 407.505 (-2.4294965219926326) # 2: 305.123 (0.8356711564205996) # 3: 393.128 (-2.1597035592672) # 4: 376.969 (-4.25413619864094) # 5: 379.965 (-1.9307217923302078) # 6: 398.657 (-4.059011681641815) # 7: 347.563 (2.0948937615830747) # 8: 401.788 (-4.025156085303183) # 9: 366.608 (3.2808351995664804) # 10: 342.989 (-1.281990491946241) # 11: 425.971 (-2.8826956711964233) # 12: 412.053 (-2.5236953455516193) # 13: 307.889 (-0.42721642437729157) # 14: 428.742 (-2.988498516819845) # 15: 430.659 (-3.087648006717103) # 16: 300.537 (-0.04887834795409862) # 17: 246.420 (-4.923547931892692) # 18: 432.018 (-3.3480816839313774) # 19: 331.979 (-1.0635461426020039)
分散最適化
storageにDBを指定することで、トライアル履歴をプロセス間で共有できるようになりますので、分散処理の実装も容易です。
storageにSQLiteを使い、joblibで2プロセスの並列処理を実装する例を示します。ポイントは2点です。
- ポイント1. create_studyでstorageとstudy_nameを指定
- storageにはDBのURLを指定する (SQLiteの場合は指定のパスにファイルが作成される)
- ポイント2. 各プロセスではload_study関数でトライアル履歴を取得してトライアルを実行する
- create_studyと同じstudy_nameを指定
import optuna import time import random import os from joblib import Parallel, delayed def f(x): # トライアルごとに5〜10秒かかるようにsleep time.sleep(random.uniform(5, 10)) return -(x**4 - 20 * x**2 + 10 * x) + 300 def objective(trial): x = trial.suggest_uniform("x", -5, 5) ret = f(x) return ret study = optuna.create_study( study_name="f_study", direction="maximize", storage="sqlite:///./f_study.db" ) def run(): study = optuna.load_study( study_name="f_study", storage="sqlite:///./f_study.db", ) study.optimize(objective, n_trials=20) return os.getpid() # joblibでプロセス並列化 process_ids = Parallel(n_jobs=2)([delayed(run)() for _ in range(2)]) print(process_ids) # [14937, 14938] # 結果はcreate_studyで作られたインスタンスからアクセス可能 print(study.best_value) print(study.best_params)
寄り道になりますが、DBのテーブル構成をサクッと見ておきます。
import pandas as pd import sqlite3 with sqlite3.connect("./f_study.db") as conn: print("tables") df = pd.read_sql("SELECT name FROM sqlite_master WHERE type ='table' AND name NOT LIKE 'sqlite_%';", conn) display(df) print("studies") df = pd.read_sql("SELECT * FROM studies;", conn) display(df) print("trials") df = pd.read_sql("SELECT * FROM trials;", conn) display(df)
study_nameとstudy_idがキーとなって、studiesとtrialsが紐づくようになっています。そのためstudy_nameさえ重複しなければ、DBを共用して、別の最適化を同時に走らせることも可能になります。4
scikit-learnの最適化
scikit-learnのSVMとRandomForestを同時に最適化して、最良の組み合わせを探索する例を、Irisデータセットで示します。
- Optunaで最適化の再現性を担保するためには、seedを固定したsamplerをcreate_studyでセットします 5
- カテゴリ変数はsuggest_categoricalメソッド、ログスケール変数はsuggest_loguniformメソッドを使います
- カテゴリ変数で分離して、それぞれでパラメータをセットできます
import optuna from sklearn import datasets, svm, ensemble, model_selection, metrics # irisデータセットのロードとスプリット iris = datasets.load_iris() data = iris.data target = iris.target tr_x, va_x, tr_y, va_y = model_selection.train_test_split(data, target, random_state=0) def objective(trial): classifier_category = trial.suggest_categorical("classifier", ["SVC", "RandomForest"]) if classifier_category == "SVC": svc_c = trial.suggest_loguniform("SVC_C", 0.01, 100) svc_gamma = trial.suggest_loguniform("SVC_gamma", 0.01, 100) classifier = svm.SVC( C=svc_c, gamma=svc_gamma, random_state=0 ) elif classifier_category == "RandomForest": randomforest_n_estimators = trial.suggest_int("RandomForest_n_estimators", 1, 3) randomforest_max_depth = trial.suggest_int("RandomForest_max_depth", 1, 3) classifier = ensemble.RandomForestClassifier( n_estimators=randomforest_n_estimators, max_depth=randomforest_max_depth, random_state=0 ) classifier.fit(tr_x, tr_y) va_pred = classifier.predict(va_x).astype("uint8") acc = metrics.accuracy_score(va_y, va_pred) return acc sampler = optuna.samplers.TPESampler(seed=0) study = optuna.create_study(sampler=sampler, direction="maximize") study.optimize(objective, n_trials=30)
ちなみにget_trialsメソッドでパラメータ (params) は辞書形式となっており、自由に値が設定できることがわかります。
[ FrozenTrial(number=0, ..., params={'classifier': 'SVC', 'SVC_C': 2.351681338577186, 'SVC_gamma': 23.82665049363666}, ...), FrozenTrial(number=1, ..., params={'classifier': 'RandomForest', 'RandomForest_n_estimators': 2, 'RandomForest_max_depth': 2}, ...), ... ]
まとめ
今回はOptunaを使ったハイパパラメータチューニングについて紹介しました。
他のライブラリと比較しても高機能で使い勝手が良く、1.0以降もガンガンバージョンアップしていますので "まずはOptunaでチューニング" というのが主流になっていくのかなと思います。
-
TPE以外にもグリッドサーチ、ランダムサンプリング、CMA-ESなどが提供されています https://optuna.readthedocs.io/en/latest/reference/samplers.html↩
-
その他はこちら https://optuna.readthedocs.io/en/latest/reference/pruners.html↩
-
ただしSQLiteのファイルをNFS等で共有することは非推奨 https://www.sqlite.org/faq.html#q5↩
-
https://optuna.readthedocs.io/en/stable/faq.html#how-can-i-obtain-reproducible-optimization-results↩