はじめに:Optunaとは
OptunaとはPFNが世に送り出した最適化枝刈りライブラリです。 Pythonのコードとして機械学習のコードのどこにでも入れることができ、非常に使いやすいAPIとなっています。大体、結構丁寧なサンプル・解説が公式ドキュメントに既にあるので、分かる方は此方を読むのが一番早いでしょう。
Welcome to Optuna’s documentation! — Optuna 0.5.0 documentation
今回は、兎にも角にも使い方の雛形を日本語で確認しておきたい人のための記事になります。
使い方
インストール
pip
で入るので楽ちんです。
!pip install optuna
最適化問題の例
まずはライブラリのインポートを行います。(jupyterでのコードを載せます)
import numpy as np import tensorflow as tf import optuna import matplotlib.pyplot as plt %matplotlib inline
問題設定
問題設定としては、まずは普通に下記の4次関数の最小化問題を考えます。
$$ f(x) = 2x^4 - 5x^2 + x $$
この関数がどんな形をしているのか予め見ておきましょう。
x = np.linspace(-2, 2, 1000) plt.plot(x, 2*x**4 - 5*x**2 + x)
最小値は左側の谷になっています。これをOptunaに見つけてもらいます。
最適化
最適化のコードを書いていきましょう。基本的な考え方は、適当な $x$ を使って該当する目的関数 $f(x) = 2x4 - 5x2 + x$ を評価することを何度か繰り返し、一番値が小さくなった $x$ を最小値とすることです。この過程を意識して下記のコードを書きます。
def objective(trial): x = trial.suggest_uniform('x', -2, 2) return 2*x**4 - 5*x**2 + x
これは、trial
というのは後々使われるOptunaに実装されているクラスのインスタンスです。こいつは、適当な範囲から値をサンプリングしてくる役割をになっています。今回は $[-2, 2]$ の範囲の一様分布から x
という変数名で、値をサンプリングする役割をになってもらいます。そしてサンプリングされた値を使って目的関数$f(x) = 2x^4 - 5x^2 + x$を評価します。
今回は上記の過程を30回繰り返しましょう。
study = optuna.create_study()
study.optimize(objective, n_trials=30)
study
インスタンスの中で先程出てきたtrial
の処理は行われるので、最適化するときには我々が細かいことを気にする必要はありません。たった上記のコードだけで「目的関数に値を30回ランダムに入れてみて、最小値を見つける」というプログラムの完成です。
最適化の結果
study
オブジェクトには最適化の結果が内部変数として保持されています。
print("minimum f(x) : ", study.best_value) print("argmin f(x) : ", study.best_params) # > minimum f(x) : -4.021255980503703 # > argmin f(x) : {'x': -1.0072075405886374}
study.best_value
は今回の実験で得られた目的関数の最小値です(サンプリングした中で一番小さかった値)。また、study.best_params
はその時の$x$ の値になっています。仮に多変数関数の最小化を行った場合には、幾つかの変数を同時に扱うことになるので、study_params
は各変数の名前と値を辞書で持ってくれています。便利ですね。
可視化もしてみます。
plt.plot(x, 2*x**4 - 5*x**2 + x, "b") plt.plot(study.best_params['x'], study.best_value, "ro") plt.legend(["objective function f(x)", "minimized solution"])
ちゃんと左側の谷を捉えていますが、若干最小値からはずれているような…w(30回じゃ足らなかったようです)。
ニューラルネットワークのハイパーパラメータチューニング
さて、いよいよ本番です。ニューラルネットワークのハイパーパラメータチューニングに使う方法を示します。
問題設定
ニューラルネットワークの学習自体が損失関数の最小化問題です。決してこの最小化にOptunaを使おうというわけではありません(パラメータが膨大すぎる!)。ニューラルネットワークには逆誤差伝播法という優れた学習手法があるので、この部分はこいつに任せましょう。Optunaが最適化するのは、ニューラルネットワークの学習を行う際に、人手で決めなければならない幾つかのハイパーパラメータの設定です。これに関しては下記記事で一度簡単に説明しているのでここでは割愛します。
端的に述べれば、「ハイパーパラメータを引数に取り、ニューラルネットワークの学習結果を出力する関数」をOptunaで最適化します。
実装
OptunaはDefine by Runというパラダイムを有しているため、実際には「ハイパーパラメータを引数に取り、ニューラルネットワークの学習結果を出力する関数」を直接ラッピングしなくても良いです。もっと小分けに、「ハイパーパラメータを引数に取り、ニューラルネットワークを構成する関数」を作り、「ハイパーパラメータを引数に取り、ニューラルネットワークの学習を行う関数」を作り、それぞれにOptunaが管理する変数を仕込む事ができます。
ハイパーパラメータを引数に取り、ニューラルネットワークを構成する関数
まずはニューラルネットワークの構成をする関数を作ります。
例の通り trial
引数を取る関数にします。trial
にはニューラルネットワークの層の数n_layers
を$\{1, 2, 3\}$の中からサンプリングしてもらうことにしましょう。更に、ドロップアウト率dropout_rate
を$[0, 0.5]$の一様分布から決めてもらうことまで兼任してもらうことにします。
あとは n_layers
と dropout_rate
があたかも普通の数の如く、モデルを組むコードを書くだけです。返り値は組んだモデルにします。
def create_model(trial): # num of hidden layer n_layers = trial.suggest_int('n_layers', 1, 3) # dropout_rate dropout_rate = trial.suggest_uniform('dropout_rate', 0.0, 0.5) layers = [] for i in range(n_layers): layers.append( tf.keras.layers.Conv2D(filters=(i+1)*16, kernel_size=3, padding="same", activation="relu") ) layers.append( tf.keras.layers.MaxPool2D(pool_size=2, padding="same") ) layers.append( tf.keras.layers.Dropout(rate=dropout_rate) ) layers.append(tf.keras.layers.Flatten()) layers.append(tf.keras.layers.Dense(128, activation="relu")) layers.append(tf.keras.layers.Dense(10, activation="softmax")) return tf.keras.Sequential(layers)
ハイパーパラメータを引数にとり、最適化手法を返す関数
次は学習率という重要なハイパーパラメータをOptunaに決めてもらえるようにしておきましょう。
学習率は$[ 0.00001, 0.01] $ の範囲から決めてもらうことにしますが、私達が学習率で興味あるのは小数点のところに $0$ が何個付くかです(つまり10のマイナス何乗なのかが重要です)。このことを反映したサンプリングをするために、suggest_loguniform
を使います。これは$ab$で表される場合の $b$ の方を一様分布から発生させて$0.0001 < ab < 0.01 $ になる値をサンプリングするイメージです。
ここでこの関数の返り値は設定された最適化手法になります。
def create_optimizer(trial): # Loguniform parameter learning_rate = trial.suggest_loguniform('learning_rate', 1e-5, 1e-2) optimizer = tf.train.AdamOptimizer(learning_rate=learning_rate) return optimizer
ハイパーパラメータを引数に取り、学習済のモデルを返す関数
さて、次は若干練習のためにトリッキーな書き方をしてみましょう。 今回はハイパーパラメータとしてバッチサイズを取ることにします(これも結構学習に効きます)。
引数として、ここでは trial
と学習データである x_train
と y_train
を取ることにしておきます。例の通り、batch_size
をOptunaにサンプリングしてもらうコードを書き、普通に学習のコードを書くだけです。返り値は学習済のモデルにします。これまで作ってきた関数は trial
(ハイパーパラメータのサンプラー)のみを引数にしていましたが、別にそうでなければいけないというわけではないのです。
def trainer(trial, x_train, y_train): batch_size=trial.suggest_categorical('batch_size', [256, 512, 1024]) # さっき作った、ハイパーパラメータを引数に取り、モデルを返す関数 model = create_model(trial) # ハイパーパラメータを引数に取り、最適化手法を返す関数 optimizer = create_optimizer(trial) model.compile(optimizer=optimizer, loss='categorical_crossentropy', metrics=["accuracy"]) model.fit(x=x_train, y=y_train, batch_size=batch_size, epochs=5, ) return model
さて、このコードを見て疑問に思ったでしょうか。trial
とは一体何なのでしょうか。なんでモデルを返す関数や最適化手法を返す関数を、今回の関数の中で同一のtrial
を使って記述できるのだろうか?と思わなかったでしょうか。ここが、ある意味Optunaの実装の肝になってまして(知らんけど)、実はこいつは自分自身がサンプリングした変数(今回の場合はハイパーパラメータ)を名前で全て覚えています。
ですので、流れとしては trial
はまずbatch_size
をサンプリングしてそれを覚え、create_model
の中に入ってからこの関数の中で n_layers
とdropout_rate
をサンプリングして、これらを覚え、create_optimizer
の中に入ってlearning_rate
をサンプリングしてこれも覚えているのです。つまり動的に、サンプラーtrial
を呼び出すことが出来、その都度サンプリングしたものを覚えていくので、学習の好きな場面で呼び出すことができ、条件分岐などがあっても全く問題ないのです。素晴らしいぞDefine by Run!(認識合ってるか知らんけど)。
真面目にコードを書く場合は、どこかで学習結果を保存するようなコードを書いておきましょうね。
ハイパーパラメータを引数に取り、最小化したい値を返す目的関数
いよいよ目的関数を書きます。 ここまで真面目にコードを読んでいれば、何も問題なく下記のコードを見られるはずです。
def objective(trial): (x_train, y_train),(x_test, y_test) = tf.keras.datasets.mnist.load_data() x_train, x_test = x_train / 255.0, x_test / 255.0 x_train = x_train.reshape(-1, 28, 28, 1) x_test = x_test.reshape(-1, 28, 28, 1) x_train_ = tf.convert_to_tensor(x_train, dtype=tf.float32) y_train_ = tf.reshape(tf.one_hot(y_train, 10), (-1, 10)) x_test_ = tf.convert_to_tensor(x_test, dtype=tf.float32) y_test_ = tf.reshape(tf.one_hot(y_test, 10), (-1, 10)) model = trainer(trial, x_train_, y_train_) evaluate = model.evaluate(x=x_test_, y=y_test_) return 1 - evaluate[1]
注意することと言えば、Optunaは最小化問題を解くので、Accuracyのように大きいほど良い値は適宜「小さいほど良い値」に書き換えてやらねばなりません。ここでは、1 - accuracy
(誤答率に相当)を目的関数の返り値にしときます。通常の問題の場合は単に負符号をつけたりするのかなと思います。
いざ最適化
あとは下記のコード。
study = optuna.create_study()
study.optimize(objective, n_trials=10)
こいつはすなわち、ニューラルネットワークの学習を10回やり直すってことなので、まあまあ時間かかります。終わってしまえば下記で結果確認。
print("best params: ", study.best_params) print("best test accuracy: ", 1 - study.best_value) # > best params: {'batch_size': 512, 'n_layers': 3, 'dropout_rate': 0.37064663092391686, 'learning_rate': 0.0015806562771231835} # > best test accuracy: 0.9858
ちなみに study.trials
に全試行(今回は10回分)のパラメータの設定と結果が載っているので、見たい人はこれも確認すると良いでしょう。
コードは下記