Optunaでハイパパラメータチューニング

今回はハイパパラメータチューニングを自動化するOptunaを触りながら紹介していきます。

Optuna

勾配ブースティング木やニューラルネットワークなどのハイパパラメータチューニングは職人芸みたいなところがあって、一発で最適に近い値をマークするなどほぼ不可能です。かと言って、グリッドサーチなどで探索しようにもパラメータ数が多いと計算時間が膨大になります。

こうした実質ブラックボックスのパラメータチューニングを、ベイズ最適化で行ってくれるPythonライブラリが Optuna です。

preferred.jp

これら↓の記事が大変参考になります。

tech.preferred.jp

www.slideshare.net

BayesianOptimizationのかゆいところ

同じくベイズ最適化を行ってくれるPythonライブラリとして、以前紹介1した BayesianOptimization がありますが、以下の点で使い勝手の悪さを感じていました。

他方Optunaはこれらの点で優位性があるかと思います。

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つの極大値を持つ関数となってます。


f(x) = -(x^{4} - 20x^{2} + 10x) + 300

f:id:ohke:20200704112307p:plain

Optunaを用いたチューニングの実装は以下になります。大きく3ステップです。

  • ステップ1. 目的関数 (f) をラップするobjective関数を定義
    • 引数 (trial) はTrial型の値
      • 探索 (トライアル) の途中状態を持つ
      • 次に試すxを選ぶ (parameter suggestion) のインタフェースを持つ
  • ステップ2. Study型の変数 (study) を生成
    • storage、sampler、prunerのインスタンスを持ち、新しいトライアルを実行するクラス
      • storage: これまでのトライアルの結果を入出力する
      • sampler: 次のトライアルのパラメータを選択する
      • pruner: トライアルを途中で打ち切るかジャッジする
    • create_study関数で初期化する
      • direction="maximize"とすることで、最大値を求める (デフォルトは"minimize")
      • storage (デフォルトはInMemoryStorage)、sampler (デフォルトはTPE 2) 、pruner (デフォルトはMedianPruner 3) もこの関数の引数で指定
  • ステップ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

f:id:ohke:20200704154153p:plain

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でチューニング" というのが主流になっていくのかなと思います。