Optunaを使ってみる
Optunaとは
ざっくり書くと、 良い感じのハイパーパラメーターを見つけてくれる ライブラリ。
ちゃんと書くと、
Optuna はハイパーパラメータの最適化を自動化するためのソフトウェアフレームワークです。ハイパーパラメータの値に関する試行錯誤を自動的に行いながら、優れた性能を発揮するハイパーパラメータの値を自動的に発見します。現在は Python で利用できます。
Optuna は次の試行で試すべきハイパーパラメータの値を決めるために、完了している試行の履歴を用いています。そこまでで完了している試行の履歴に基づき、有望そうな領域を推定し、その領域の値を実際に試すということを繰り返します。そして、新たに得られた結果に基づき、更に有望そうな領域を推定します。具体的には、Tree-structured Parzen Estimator というベイズ最適化アルゴリズムの一種を用いています。
とのこと。
ただ、実際に触ってみると『「ある目的関数に対して最適化をする」に対してハイパーパラメータチューニングで使いやすいようなフレームワークになってる』の方が正確な印象を受けた。つまり、ハイパーパラメータチューニング以外の最適化もできる。
www.slideshare.net
実装1: 簡単な例
まずはハイパーパラメータの最適化ではなく、普通の最適化について例示することで全体でやってることの雰囲気を掴む。
以下のような、 でxについての最適化(yの最小化)を考える。
import numpy as np import matplotlib.pyplot as plt x = np.linspace(-2, 2, 100) plt.figure(0) plt.plot(x, (3*x**4 - 2*x**3 - 4*x**2 + 2)) plt.show()
評価関数
まずは、評価関数を定義する。今回はそのままyが評価関数となる。
# 目的関数 (f) def f(x): return (3*x**4 - 2*x**3 - 4*x**2 + 2)
目的関数
次に、先程の評価関数を用いた目的関数を定義する。
# f(x)をラップするobjective関数を定義 def objective(trial): # 引数 (trial) はTrial型の値 x = trial.suggest_uniform("x", -5, 5) # 試すxを指定範囲から選ぶ (parameter suggestion) ret = f(x) # 探索 (トライアル) の途中状態を持つ return ret
このとき、xの探索範囲も併せて内部で定義をする。上コードではtrial.suggest_uniform("x", -5, 5)
が探索範囲に相当する。このtrial.suggest_uniform
メソッド部分は探索する内容によってメソッドを変える必要がある。今回は連続値なのでtrial.suggest_uniform
だが例えば、対数的な取り方にしたい場合はtrial. suggest_loguniform
になるし、カテゴリカル変数であればsuggest_categorical
になる。
最適化
次に先程の目的関数の最適化をおこなう。
study = optuna.create_study(direction="minimize") # 最適化処理を管理するstudyオブジェクト study.optimize(objective, # 目的関数 n_trials=100 # トライアル数 )
まずはStudy型の変数 (study) を生成するためにcreate_study関数
でインスタンス初期化をする。
このとき、どのような最適化をするか予め指定をおこなう。
今回は最小化をしたいのでdirectio
はminimizeとする。
他にも、storage
(これまでのトライアルの結果をどこに保存んするか:デフォルトはInMemoryStorage)、sampler
(次のトライアルのパラメータをどう選択するか:デフォルトはTPE)、pruner
(トライアルを途中で打ち切るかジャッジ:デフォルトはMedianPruner)といったことを指定することができる。
そして、optimize
関数で目的関数と、トライアル数(何回探索するか)n_trials
を指定して最適化をおこなう。
それぞれ実行結果は上キャプチャのように各トライアル毎のxと評価関数の結果、その段階の一番良い結果(評価関数の最小結果)を出力する。
この結果は、study
インスタンスに保存されている。
study.get_trials()
で各トライアルの情報がlistで取り出すことができる。また、最小値(目的変数)とそのときのxをそれぞれbest_value
、best_params
で出力できる。
# 探索後の最良値 print(study.best_value) print(study.best_params) # 探索の履歴 for trial in study.get_trials(): print(f"{trial.number}: {trial.value:.3f} ({trial.params['x']})")
実装2: lightGBMでの例
lightGBMのハイパーパラメータチューニングについて、今回はboosting_type
num_leaves
learning_rate
の3つをOptunaで自動チューニングします。コードはPyData.Tokyo Meetup #21 講演資料を参照。
データは以下のように'iris'を使います。
import lightgbm as lgb, numpy as np, optuna, sklearn.datasets, sklearn.metrics from sklearn.model_selection import train_test_split iris = datasets.load_iris() data = iris.data target = iris.target train_x, test_x, train_y, test_y = train_test_split(data, target, random_state=0)
通常、lightGBMでは以下のようにハイパーパラメータ(param
)を決め打ちだったりGridSearchするなりで決めます。
def main(): param = { 'objective': 'multiclass', 'num_class': 3, 'boosting_type': 'gbdt', 'num_leavrs': 3, 'learning_rate': 0.1 } train_xy = lgb.Dataset(train_x, train_y) val_xy = lgb.Dataset(test_x, test_y, reference=train_xy) gbm = lgb.train(param, train_xy, valid_sets = val_xy) pred_proba = gbm.predict(test_x) pred = np.argmax(pred_proba, axis=1) return sklearn.metrics.accuracy_score(test_y, pred) print('Accuracy:', main())
このコードをもとに、boosting_type
num_leaves
learning_rate
の3つをOptunaで自動チューニングするコードに書き換えます。
def objective(trial): param = { 'objective': 'multiclass', 'num_class': 3, 'boosting_type': trial.suggest_categorical('hoge', ['gbdt', 'dart']), 'num_leavrs': trial.suggest_int('num_leaves', 10, 1000), 'learning_rate': trial.suggest_loguniform('learning_rate', 1e-8, 1.0) } train_xy = lgb.Dataset(train_x, train_y) val_xy = lgb.Dataset(test_x, test_y, reference=train_xy) gbm = lgb.train(param, train_xy,valid_sets = val_xy) pred_proba = gbm.predict(test_x) pred = np.argmax(pred_proba, axis=1) acc = sklearn.metrics.accuracy_score(test_y, pred) return acc study = optuna.create_study(direction='maximize') study.optimize(objective, n_trials=100)
ほぼ一緒ですが、探索対象であるboosting_type
num_leaves
learning_rate
それぞれがtrial.suggest_...
にして探索範囲を指定しています。また、main()
をobjective(trial)
のように、引数としてtrial
を渡しています(なお、main(trial)
のままでも動きはしますが色々ややこしいのでobjective(trial)
としています)。
その後、この目的関数に対して、create_study
およびoptimize
をすることで今回指定した探索対象/範囲でのreturn
部分(accuracy_score
)の最大となるハイパーパラメータを探索してくれます。
# 探索後の最良値 print(study.best_value) print(study.best_params) => 0.9736842105263158 {'hoge': 'dart', 'num_leaves': 687, 'learning_rate': 0.28862099397009183}
実際の探索範囲などは以下の1つ目のpdfのP31や、2つめの記事の「チューニング」見出しあたりが参考になる。
PyData Tokyo Meetup #21 LightGBM
なお、lightGBM
に限ると、optuna側でlightGBM
で簡単に利用するためのLightGBM Tuner
が用意されている。
これは、通常のlightGBM
を使う際に、import lightbgm
をfrom optuna.integration import lightgbm
に差し替えるだけでそのまま既存のコードで動かすことができる。
ただし、全ハイパーパラメータを調整してくれるわけではなく、以下のみ調整してくれる。
- lambda_l1
- lambda_l2
- num_leaves
- feature_fraction
- bagging_fraction
- bagging_freq
- min_child_samples
そのため、他も調整したい場合はLightGBM Tuner
を使わず前述のobjective
を使う方法でやる必要がある。
# ベースとなるパラメータ param = { 'objective': 'multiclass', 'num_class': 3, } train_xy = lgb.Dataset(train_x, train_y) val_xy = lgb.Dataset(test_x, test_y, reference=train_xy) gbm = lgb_opt.train(param, train_xy, valid_sets = val_xy )
※'objective': 'multiclass'だと、validationの評価のときにbinary_logloss
で評価しようとしてエラー吐くっぽいがバグかやり方が間違ってるかわからん・・・。とりあえず上記だと動かないので注意。
チューニング結果などを取り出したいときは、通常同様にparams
やbest_iteration
、best_score
でアクセスできる。
print(gbm.params) # => {'objective': 'regression', 'metric': 'rmse', 'feature_pre_filter': False, 'lambda_l1': 0.008437118066241178, 'lambda_l2': 6.201313740165131e-06, 'num_leaves': 31, 'feature_fraction': 0.5, 'bagging_fraction': 0.41674201711462905, 'bagging_freq': 2, 'min_child_samples': 20}
実装3:閾値の最適化
今まではハイパーパラメータを変えて目的関数の最適化だったが、同様のことを分類問題の確率閾値の評価関数最適化にも使ってみる。
まずは、LightGBM Tuner
経由で二値分類をおこなう(コードはこの記事参照)。
import optuna.integration.lightgbm as lgb from sklearn import datasets from sklearn.model_selection import train_test_split import numpy as np import pandas as pd from sklearn import metrics # Breast Cancer データセットを読み込む bc = datasets.load_breast_cancer() X, y = bc.data, bc.target # 訓練データとテストデータに分割する X_train, X_test, y_train, y_test = train_test_split(X, y) # データセットを生成する lgb_train = lgb.Dataset(X_train, y_train) lgb_eval = lgb.Dataset(X_test, y_test, reference=lgb_train) # LightGBM のハイパーパラメータ params = { # 二値分類問題 'objective': 'binary', # AUC の最大化を目指す 'metric': 'auc', # Fatal の場合出力 'verbosity': -1, } # 上記のパラメータでモデルを学習する model = lgb.train(params, lgb_train, valid_sets=lgb_eval, verbose_eval=50, # 50イテレーション毎に学習結果出力 num_boost_round=1000, # 最大イテレーション回数指定 early_stopping_rounds=100 ) y_prob = model.predict(X_test, num_iteration=model.best_iteration)
このとき、y_predは1となる確率として返ってきます。何も考えずに閾値を0.5として、0.5以上を1、0.5未満を0とするよりこの閾値も調整した方が評価結果がよくなることがあります。
以下の例では評価関数F1の最適化をしています。
# 閾値付きのF1 def f1(y_test, y_prob, threshold): y_pred = [1 if prob >= threshold else 0 for prob in y_prob] score = f1_score(y_test, y_pred) return score # 目的関数 def objective(trial): threshold = trial.suggest_float('threshold', 0.0, 1.0) # 0~1.0で探索 ret = f1(y_test, y_prob, threshold) return ret # 最適化 study = optuna.create_study(direction="maximize") study.optimize(objective, n_trials=100) # 探索後の最良値 print(study.best_value) print(study.best_params) # => 0.9710982658959537 {'threshold': 0.019298362201426927} # 探索の履歴 for trial in study.get_trials(): print(f"{trial.number}: {trial.value:.3f} ({trial.params['threshold']})") # => 0: 0.951 (0.9881809312984907) 1: 0.958 (0.8109994485475401) 2: 0.963 (0.9577886452457406) 3: 0.963 (0.9777420721270973) 4: 0.964 (0.37669967567838136) 5: 0.958 (0.8019522885395326) 6: 0.964 (0.7026656746225185) 7: 0.958 (0.8412677014903346) 8: 0.964 (0.6663596796212387) 9: 0.964 (0.5085085173697037) 10: 0.965 (0.048865011443941064) 11: 0.971 (0.019298362201426927) 12: 0.955 (0.0013199363878491632) 13: 0.966 (0.004203349012100257) 14: 0.970 (0.17029221135052455) 15: 0.970 (0.20333826422743217) 16: 0.970 (0.16478681177393573) ...
以下の記事は評価関数をQWKにして閾値調整をOptunaでおこなっている。
その他
sample
書き方(このハイパーパラメータのsuggestionは何がいいか?とか)は公式のsampleで使いたいロジックのものを見て真似たら良さげ
複数アルゴリズムの使用
以下のように、複数アルゴリズム(例ではSVMとRandomForest)で調べることも可能。
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) # log一様分布から試すxを選ぶ 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 ) # train classifier.fit(tr_x, tr_y) # pred 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型の変数 (study) を初期化して生成、sampler(次のトライアルのパラメータを選択)は上記、最大化 study = optuna.create_study(sampler=sampler, direction="maximize") study.optimize(objective, n_trials=30)