まずは蝋の翼から。

学んだことを書きながら確認・整理するためのメモブログ。こういうことなのかな?といったことをふわっと書いたりしていますが、理解が浅いゆえに的はずれなことも多々あると思うのでツッコミ歓迎

Optunaを使ってみる

Optunaとは

ざっくり書くと、 良い感じのハイパーパラメーターを見つけてくれる ライブラリ。

ちゃんと書くと、

Optuna はハイパーパラメータの最適化を自動化するためのソフトウェアフレームワークです。ハイパーパラメータの値に関する試行錯誤を自動的に行いながら、優れた性能を発揮するハイパーパラメータの値を自動的に発見します。現在は Python で利用できます。

Optuna は次の試行で試すべきハイパーパラメータの値を決めるために、完了している試行の履歴を用いています。そこまでで完了している試行の履歴に基づき、有望そうな領域を推定し、その領域の値を実際に試すということを繰り返します。そして、新たに得られた結果に基づき、更に有望そうな領域を推定します。具体的には、Tree-structured Parzen Estimator というベイズ最適化アルゴリズムの一種を用いています。

とのこと。

ただ、実際に触ってみると『「ある目的関数に対して最適化をする」に対してハイパーパラメータチューニングで使いやすいようなフレームワークになってる』の方が正確な印象を受けた。つまり、ハイパーパラメータチューニング以外の最適化もできる。

tech.preferred.jp

optuna.readthedocs.io

www.slideshare.net

実装1: 簡単な例

まずはハイパーパラメータの最適化ではなく、普通の最適化について例示することで全体でやってることの雰囲気を掴む。

以下のような、  y = 3x^{4} - 2x^{3} - 4x^{2} + 2で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()

f:id:chito_ng:20210612170613p:plain

評価関数

まずは、評価関数を定義する。今回はそのまま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になる。

optuna.readthedocs.io

最適化

次に先程の目的関数の最適化をおこなう。

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)といったことを指定することができる。

optuna.readthedocs.io

optuna.readthedocs.io

optuna.readthedocs.io

そして、optimize関数で目的関数と、トライアル数(何回探索するか)n_trialsを指定して最適化をおこなう。

f:id:chito_ng:20210612172857p:plain

それぞれ実行結果は上キャプチャのように各トライアル毎のxと評価関数の結果、その段階の一番良い結果(評価関数の最小結果)を出力する。

この結果は、studyインスタンスに保存されている。

study.get_trials()で各トライアルの情報がlistで取り出すことができる。また、最小値(目的変数)とそのときのxをそれぞれbest_valuebest_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']})")

f:id:chito_ng:20210612173348p:plain

実装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)の最大となるハイパーパラメータを探索してくれます。

f:id:chito_ng:20210614113007p:plain

# 探索後の最良値
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

nykergoto.hatenablog.jp

なお、lightGBMに限ると、optuna側でlightGBMで簡単に利用するためのLightGBM Tunerが用意されている。

これは、通常のlightGBMを使う際に、import lightbgmfrom optuna.integration import lightgbmに差し替えるだけでそのまま既存のコードで動かすことができる。

ただし、全ハイパーパラメータを調整してくれるわけではなく、以下のみ調整してくれる。

  • lambda_l1
  • lambda_l2
  • num_leaves
  • feature_fraction
  • bagging_fraction
  • bagging_freq
  • min_child_samples

そのため、他も調整したい場合はLightGBM Tunerを使わず前述のobjectiveを使う方法でやる必要がある。

optuna.readthedocs.io

# ベースとなるパラメータ
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で評価しようとしてエラー吐くっぽいがバグかやり方が間違ってるかわからん・・・。とりあえず上記だと動かないので注意。

チューニング結果などを取り出したいときは、通常同様にparamsbest_iterationbest_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}

tech.preferred.jp

qiita.com

実装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でおこなっている。

blog.amedama.jp

その他

sample

書き方(このハイパーパラメータのsuggestionは何がいいか?とか)は公式のsampleで使いたいロジックのものを見て真似たら良さげ

github.com

複数アルゴリズムの使用

以下のように、複数アルゴリズム(例では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)

参考

qiita.com

qiita.com

ohke.hateblo.jp

www.nogawanogawa.com