Optunaで枝刈りも使ってKerasのパラメータチューニング

少し間が空きましたが、再びOptunaの記事です。
今度はKerasのモデルのパラメーターをチューニングしてみます。
その際、枝刈りも使いました。

Optunaに、Keras用のコールバックが用意されていて、
これを使うと、エポック毎に判定が走り、学習が十分進んでいないと学習を打ち切ってくれるので、
効率的に探索ができます。
classoptuna.integration.KerasPruningCallback

githubのサンプルコードを(少しだけ修正しましたが)ほぼ写経しながら、
コードを書いてみました。


import keras
from keras.datasets import mnist
from keras.utils import to_categorical
from keras.layers import Dense
from keras.layers import Dropout
from keras.models import Sequential
import optuna
from optuna.integration import KerasPruningCallback

# 訓練データとテストデータの件数を指定
N_TRAIN_EXAMPLES = 3840
N_TEST_EXAMPLES = 1280
BATCHSIZE = 128
CLASSES = 10
EPOCHS = 20

# MNISTのデータを準備
(x_train, y_train), (x_test, y_test) = mnist.load_data()
# 特徴量を0~1に正規化する
x_train = x_train.reshape(60000, 784)[:N_TRAIN_EXAMPLES]/255
x_test = x_test.reshape(10000, 784)[:N_TEST_EXAMPLES]/255

# ラベルを1 hot 表現に変換
y_train = to_categorical(y_train[:N_TRAIN_EXAMPLES], CLASSES)
y_test = to_categorical(y_test[:N_TEST_EXAMPLES], CLASSES)


def create_model(trial):
    """MLPモデルの構築
    """

    # 層の数を選択する
    n_layers = trial.suggest_int("n_layers", 1, 3)
    model = Sequential()
    for i in range(n_layers):
        num_hidden = int(
                            trial.suggest_loguniform(
                                    f"n_units_l{i}",
                                    4,
                                    128
                                )
                            )
        model.add(Dense(num_hidden, activation="relu"))
        dropout = trial.suggest_uniform(f"dropout_l{i}", 0.2, 0.5)
        model.add(Dropout(rate=dropout))
    model.add(Dense(CLASSES, activation="softmax"))

    # 学習率も最適化する。
    lr = trial.suggest_loguniform("lr", 1e-5, 1e-1)
    model.compile(loss="categorical_crossentropy",
                  optimizer=keras.optimizers.RMSprop(lr=lr),
                  metrics=["acc"])

    return model


def objective(trial):
    # 前のセッションをクリアする
    keras.backend.clear_session()
    # モデル作成
    model = create_model(trial)

    # モデルの学習
    # KerasPruningCallbackでepoch毎に枝刈りの判定。
    model.fit(
        x_train,
        y_train,
        batch_size=BATCHSIZE,
        callbacks=[KerasPruningCallback(trial, "val_acc")],
        epochs=EPOCHS,
        validation_data=(x_test, y_test),
        verbose=2
    )

    # モデルの評価
    score = model.evaluate(x_test, y_test, verbose=0)
    return score[1]


study = optuna.create_study(
                direction="maximize",
                pruner=optuna.pruners.MedianPruner()
            )
study.optimize(objective, n_trials=100)
pruned_trials = [t for t in study.trials if t.state == optuna.structs.TrialState.PRUNED]
complete_trials = [t for t in study.trials if t.state == optuna.structs.TrialState.COMPLETE]
print("Study statistics: ")
print("  Number of finished trials: ", len(study.trials))
print("  Number of pruned trials: ", len(pruned_trials))
print("  Number of complete trials: ", len(complete_trials))

# 結果の表示
print("Best trial:")
trial = study.best_trial
print("  Value: ", trial.value)
print("  Params: ")
for key, value in trial.params.items():
    print(f"    {key}: {value}")

"""
探索・学習中に出てくる大量の出力は省略。

Study statistics: 
  Number of finished trials:  100
  Number of pruned trials:  81
  Number of complete trials:  19
Best trial:
  Value:  0.92734375
  Params: 
    n_layers: 1
    n_units_l0: 114.75796459517468
    dropout_l0: 0.3088006045981605
    lr: 0.0035503337631505026

"""

100回のトライアルのうち、最後まで走ったのは19回だけのようです。
もっと色々なパターンを試さないと、これがどれほど効率的なのかと、
最終的な結果がどれだけ優れてるのかってのは判断しかねるのですが、
データが少ない中で90%程度の正解率も出していますし、
これまでMNISTを触ってきた経験からあまり違和感のないパラメータが選ばれている感じはします。

create_model の中で、最初に選ばれるlayerの数によって、
ユニットの数やdropoutの割合のなどのパラメーター数が変わるのですが、
Optunaならそのあたりもかなり柔軟にかけることを実感できました。

参考ですが、n_layersが3だったtraialでは、次のようにパラメータが多くサンプリングされています。


print(study.trials[3].params)
"""
{
    'n_layers': 3,
    'n_units_l0': 8.18713988230328,
    'dropout_l0': 0.3510040004516323,
    'n_units_l1': 99.80407964220358,
    'dropout_l1': 0.22630235294167378,
    'n_units_l2': 6.585776717612663,
    'dropout_l2': 0.3731719751601379,
    'lr': 0.000710144846966038
}
"""

なお、n_layersは1の方が良いようで、かなり早い段階で、1の場合ばかり探索されるようになっています。
時短のためデータを減らしているのも影響していそうですね。


print([study.trials[i].params["n_layers"] for i in range(100)])
"""
[3, 1, 1, 3, 2, 2, 2, 1, 1, 3, 1, 1, 1, 1, 2, 1, 2, 1, 1, 2,
 1, 1, 1, 1, 1, 1, 2, 1, 1, 2, 1, 1, 1, 1, 3, 1, 1, 2, 1, 1,
 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
 2, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
"""

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です