HELLO CYBERNETICS

深層学習、機械学習、強化学習、信号処理、制御工学、量子計算などをテーマに扱っていきます

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

 

 

follow us in feedly

f:id:s0sem0y:20181222152223p:plain

はじめに: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)

f:id:s0sem0y:20181222151106p:plain

最小値は左側の谷になっています。これを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"])

f:id:s0sem0y:20181222152223p:plain

ちゃんと左側の谷を捉えていますが、若干最小値からはずれているような…w(30回じゃ足らなかったようです)。

ニューラルネットワークのハイパーパラメータチューニング

さて、いよいよ本番です。ニューラルネットワークのハイパーパラメータチューニングに使う方法を示します。

問題設定

ニューラルネットワークの学習自体が損失関数の最小化問題です。決してこの最小化にOptunaを使おうというわけではありません(パラメータが膨大すぎる!)。ニューラルネットワークには逆誤差伝播法という優れた学習手法があるので、この部分はこいつに任せましょう。Optunaが最適化するのは、ニューラルネットワークの学習を行う際に、人手で決めなければならない幾つかのハイパーパラメータの設定です。これに関しては下記記事で一度簡単に説明しているのでここでは割愛します。

www.hellocybernetics.tech

端的に述べれば、「ハイパーパラメータを引数に取り、ニューラルネットワークの学習結果を出力する関数」をOptunaで最適化します。

f:id:s0sem0y:20180210083032p:plain

実装

OptunaはDefine by Runというパラダイムを有しているため、実際には「ハイパーパラメータを引数に取り、ニューラルネットワークの学習結果を出力する関数」を直接ラッピングしなくても良いです。もっと小分けに、「ハイパーパラメータを引数に取り、ニューラルネットワークを構成する関数」を作り、「ハイパーパラメータを引数に取り、ニューラルネットワークの学習を行う関数」を作り、それぞれにOptunaが管理する変数を仕込む事ができます。

ハイパーパラメータを引数に取り、ニューラルネットワークを構成する関数

まずはニューラルネットワークの構成をする関数を作ります。 例の通り trial 引数を取る関数にします。trialにはニューラルネットワークの層の数n_layersを$\{1, 2, 3\}$の中からサンプリングしてもらうことにしましょう。更に、ドロップアウト率dropout_rateを$[0, 0.5]$の一様分布から決めてもらうことまで兼任してもらうことにします。

あとは n_layersdropout_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_trainy_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_layersdropout_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回分)のパラメータの設定と結果が載っているので、見たい人はこれも確認すると良いでしょう。

コードは下記

github.com