Kerasで書いたDLモデルをOptunaでハイパーパラメータ最適化(1)
概要
- ダーツスキル評価用のDLモデルのハイパーパラメータを最適化する。
- 最適化には、Preferred Networks製Optunaを用いる。同社はChainerのメンテナーだけど、Optunaは別にchainer以外にも使える。今回はOptunaとKerasを合わせて使います。
ハイパーパラメータ最適化について
概念的には、ここのページがすごくまとまっている。
- 日本語の説明ページもネットには転がってるけど、だいたい説明がテキトーだから、こういうちゃんとした英語のページのほうを見たほうが良い気がします。
- 気が向いたら全訳載せます。 towardsdatascience.com
最近、ハイパーパラメータ最適化のライブラリは、Hyperopt, Optuna、Hyperopt、SMAC、MOE, Spearmintとか色々ある。
上のページだとHyperopt推しだけど、2018/12/03にOptunaが公開されていて、そこではHyperoptとと同じくTPE(Tree-structured Parzen Estimator)というので計算できて、かつ「学習曲線を用いた試行の枝刈り」「並列分散最適化」といった点でより効率よく計算できるようになっている。詳しくは下記記事参照。
なお、TPEを使う場合は、「損失の上位グループと下位グループを分割する閾値y*」を設定する必要があるが、これはHyperoptと同じ設定にしていると書かれている。参考論文からすると「a quantile cutoff point of previous values」なので、単に過去の目的関数の評価結果値群の中央値をy*として選択しているのかなと思う。なのでユーザ側では設定不要。参考論文は下記。
- 該当ソースコード読んだわけではないので、違ってたらごめんなさい。そして良ければ正しい情報教えてください。
https://papers.nips.cc/paper/4443-algorithms-for-hyper-parameter-optimization.pdf
さっそくハイパーパラメータ最適化をやってみる
- Optunaでハイパーパラメータチューニングしたい!どこから手をつけるべきか?というところだが、githubにサンプルソースがあるので参考にする。
OptunaはPreferred Networks製なので、同社のライブラリであるchainerでサンプルが書かれている。
基本的なハイパーパラメータ最適化コードの構成
- だいたいどのハイパーパラメータ最適化も、基本構成はかわらない。
- ハイパーパラメータチューニングは、結局のところ、目的関数の最小化だから、結局最適化関数と、その関数に入力する目的関数が定義されていればOK。
- ざっくり、optunaライクに書けば例えば以下のような感じ。
def objective(trial): trial変数からのハイパーパラメータ候補の生成 目的関数の定義 return 目的関数の評価値 if __name__ == '__main__': study = optuna.create_study() # 最適化インスタンス作成 study.optimize(objective, n_trials=トライアル回数) ハイパーパラメータ最適化結果の出力
サンプルソース
- chainer_simple.pyが参考になる。
https://github.com/pfnet/optuna/blob/master/examples/chainer_simple.py
from __future__ import print_function import chainer import chainer.functions as F import chainer.links as L import numpy as np import pkg_resources if pkg_resources.parse_version(chainer.__version__) < pkg_resources.parse_version('4.0.0'): raise RuntimeError('Chainer>=4.0.0 is required for this example.') N_TRAIN_EXAMPLES = 3000 N_TEST_EXAMPLES = 1000 BATCHSIZE = 128 EPOCH = 10 def create_model(trial): # We optimize the numbers of layers and their units. n_layers = trial.suggest_int('n_layers', 1, 3) layers = [] for i in range(n_layers): n_units = int(trial.suggest_loguniform('n_units_l{}'.format(i), 4, 128)) layers.append(L.Linear(None, n_units)) layers.append(F.relu) layers.append(L.Linear(None, 10)) return chainer.Sequential(*layers) def create_optimizer(trial, model): # We optimize the choice of optimizers as well as their parameters. optimizer_name = trial.suggest_categorical('optimizer', ['Adam', 'MomentumSGD']) if optimizer_name == 'Adam': adam_alpha = trial.suggest_loguniform('adam_alpha', 1e-5, 1e-1) optimizer = chainer.optimizers.Adam(alpha=adam_alpha) else: momentum_sgd_lr = trial.suggest_loguniform('momentum_sgd_lr', 1e-5, 1e-1) optimizer = chainer.optimizers.MomentumSGD(lr=momentum_sgd_lr) weight_decay = trial.suggest_loguniform('weight_decay', 1e-10, 1e-3) optimizer.setup(model) optimizer.add_hook(chainer.optimizer.WeightDecay(weight_decay)) return optimizer # FYI: Objective functions can take additional arguments # (https://optuna.readthedocs.io/en/stable/faq.html#objective-func-additional-args). def objective(trial): # Model and optimizer model = L.Classifier(create_model(trial)) optimizer = create_optimizer(trial, model) # Dataset rng = np.random.RandomState(0) train, test = chainer.datasets.get_mnist() train = chainer.datasets.SubDataset( train, 0, N_TRAIN_EXAMPLES, order=rng.permutation(len(train))) test = chainer.datasets.SubDataset( test, 0, N_TEST_EXAMPLES, order=rng.permutation(len(test))) train_iter = chainer.iterators.SerialIterator(train, BATCHSIZE) test_iter = chainer.iterators.SerialIterator(test, BATCHSIZE, repeat=False, shuffle=False) # Trainer updater = chainer.training.StandardUpdater(train_iter, optimizer) trainer = chainer.training.Trainer(updater, (EPOCH, 'epoch')) trainer.extend(chainer.training.extensions.Evaluator(test_iter, model)) log_report_extension = chainer.training.extensions.LogReport(log_name=None) trainer.extend(chainer.training.extensions.PrintReport( ['epoch', 'main/loss', 'validation/main/loss', 'main/accuracy', 'validation/main/accuracy'])) trainer.extend(log_report_extension) # Run! trainer.run() # Set the user attributes such as loss and accuracy for train and validation sets log_last = log_report_extension.log[-1] for key, value in log_last.items(): trial.set_user_attr(key, value) # Return the validation error val_err = 1.0 - log_report_extension.log[-1]['validation/main/accuracy'] return val_err if __name__ == '__main__': import optuna study = optuna.create_study() study.optimize(objective, n_trials=100) print('Number of finished trials: ', len(study.trials)) print('Best trial:') trial = study.best_trial print(' Value: ', trial.value) print(' Params: ') for key, value in trial.params.items(): print(' {}: {}'.format(key, value)) print(' User attrs:') for key, value in trial.user_attrs.items(): print(' {}: {}'.format(key, value))
目的関数をobjective関数で定義。
- モデルの定義をcreate_modelで、最適化手法の定義をcreate_optimizerで定義。
- ハイパーパラメータの探索範囲は、trial.suggest_〜みたいな関数で値を振れるように書く。値が整数なのか、離散値なのか、浮動小数点なのか、広範囲の小数点なのか等で、suggestを変えてやる必要がある。 optuna.readthedocs.io
で、最後にstudy.optimizeで最適化。最適化した結果は、study.best_trialに格納される。
ダーツ評価モデルの場合
- 僕の場合はKeras+Tensorflowでやっていたので、目的関数をkerasで書く。
- OptimizerはSGD, Adam, Nadamを選べるようにした。
- 層数、ノード数、ドロップアウト率、減衰率、学習率、等を変えてやった。
- 目的関数は、validation lossとした。
import optuna from keras.layers import Input, concatenate from keras.layers.core import Activation, Flatten, Reshape, Dense, Dropout from keras.layers.normalization import BatchNormalization from keras.models import Model import pandas as pd import numpy as np import glob import os import csv import keras count = 0 def create_model(trial): N_LAYERS_FINGER = trial.suggest_int('n_layers_finger', 1,10) N_LAYERS_BODY = trial.suggest_int('n_layers_body', 1,10) N_LAYERS_INTEGRATED = trial.suggest_int('n_layers_integrated', 1,10) finger_input_shape = (60, 6) body_input_shape = (60, 30) fingers_input = Input(shape=finger_input_shape) body_input = Input(shape=body_input_shape) x1 = Flatten()(fingers_input) for i in range(N_LAYERS_FINGER): n_units = int(trial.suggest_loguniform('n_units_finger_l{}'.format(i), 10, 400)) drop_out_rate = trial.suggest_uniform('dropout_rate_finger_l{}'.format(i), 0.0, 1.0) x1 = Dense(n_units, name='finger_fc{}'.format(i))(x1) x1 = BatchNormalization()(x1) x1 = Activation('relu')(x1) x1 = Dropout(drop_out_rate)(x1) x2 = Flatten()(body_input) for i in range(N_LAYERS_BODY): n_units = int(trial.suggest_loguniform('n_units_body_l{}'.format(i), 10, 400)) drop_out_rate = trial.suggest_uniform('dropout_rate_body_l{}'.format(i), 0.0, 1.0) x2 = Dense(n_units, name='body_fc{}'.format(i))(x2) x2 = BatchNormalization()(x2) x2 = Activation('relu')(x2) x2 = Dropout(drop_out_rate)(x2) x = concatenate([x1, x2]) for i in range(N_LAYERS_INTEGRATED): n_units = int(trial.suggest_loguniform('n_units_integrated_l{}'.format(i), 10, 400)) drop_out_rate = trial.suggest_uniform('dropout_rate_integrated_l{}'.format(i), 0.0, 1.0) x = Dense(n_units, name='integrated_fc{}'.format(i))(x) x = BatchNormalization()(x) x = Activation('relu')(x) x = Dropout(drop_out_rate)(x) n_units = int(trial.suggest_loguniform('n_units_integrated_l{}'.format(i), 5, 100)) x = Dense(n_units, activation='relu', name='fc_final')(x) x = Dense(1, activation='sigmoid', name='fc_final_sigmoid')(x) model = Model(inputs=[fingers_input, body_input], outputs=x) return model def create_optimizer(trial): optimizer_name = trial.suggest_categorical('optimizer', ['SGD', 'ADAM', 'NADAM']) if optimizer_name == 'SGD': sgd_lr = trial.suggest_loguniform('sgd_lr', 1e-5, 1e-2) opt = keras.optimizers.SGD(lr=sgd_lr, nesterov=True) elif optimizer_name == 'ADAM': adam_lr = trial.suggest_loguniform('adam_lr', 1e-6, 1e-2) weight_decay = trial.suggest_loguniform('weight_decay', 1e-10, 1e-3) opt = keras.optimizers.Adam(lr=adam_lr, decay=weight_decay) else: nadam_lr = trial.suggest_loguniform('nadam_lr', 1e-6, 1e-2) schedule_decay = trial.suggest_loguniform('schedule_decay', 1e-10, 1e-3) opt = keras.optimizers.Nadam(lr=nadam_lr, schedule_decay=schedule_decay) return opt def objective(trial): global count count = count + 1 print("progress : {}".format(count)) # # ========================================================================== # # # # Set Model # # # # ========================================================================== model = create_model(trial) # # ========================================================================== # # # # Set Optimizer # # # # ========================================================================== opt = create_optimizer(trial) # # ========================================================================== # # # # Set Data Config # # # # ========================================================================== # center location vector for normalization center_location_norm_body = [1.8447725036231883, -0.17879784788302278, 0.3172548949275362] # scale value for normalization # assume person only moves 2.0 [m] at most scale_norm_body = 2.0 scale_norm_finger_motion = 10000.0 scale_norm_finger_pressure = 50000.0 data_dir = "./*_log.csv" # ========================================================================== # # Load Data # # ========================================================================== X_fingers = np.empty((0, 60, 6), dtype='float32') X_body = np.empty((0, 60, 30), dtype='float32') y = np.empty((0, 1), dtype='float32') for files in glob.glob(data_dir): basename = os.path.basename(files) dir_name = os.path.dirname(files) # output_filename1 = dir_name + '/' + basename[:-4] + '_finger_normalized.csv' # output_filename2 = dir_name + '/' + basename[:-4] + '_body_normalized.csv' loss_filename = dir_name + '/' + basename[:-8] + '_loss.csv' # ------------------------------------------------------------------------------- # load loss value with open(loss_filename, 'r') as f: reader = csv.reader(f) # header = next(reader) # ヘッダーを読み飛ばしたい時 for row in reader: # arr.append(row[0]) # print(float(row[0])) y = np.append(y, [[float(row[0])]], axis=0) # print(row[0]) break # ------------------------------------------------------------------------------- # load finger motion and pressure df = pd.read_csv(files) df_finger_motion = df[['finger0', 'finger2', 'finger4', 'finger6', 'finger8', ]] / scale_norm_finger_motion df_finger_pressure = df[['finger1']] / scale_norm_finger_pressure df_fingers = pd.concat([df_finger_motion, df_finger_pressure], axis=1) # df_fingers.to_csv(output_filename1) X_fingers = np.append(X_fingers, [df_fingers.values], axis=0) # ------------------------------------------------------------------------------- # load body motion df_body_head = (df[['head_x', 'head_y', 'head_z', ]] - center_location_norm_body) / scale_norm_body df_body_neck = (df[['neck_x', 'neck_y', 'neck_z', ]] - center_location_norm_body) / scale_norm_body df_body_torso = (df[['torso_x', 'torso_y', 'torso_z', ]] - center_location_norm_body) / scale_norm_body df_body_waist = (df[['waist_x', 'waist_y', 'waist_z', ]] - center_location_norm_body) / scale_norm_body df_body_left_shoulder = (df[['left_shoulder_x', 'left_shoulder_y', 'left_shoulder_z', ]] - center_location_norm_body) / scale_norm_body df_body_left_elbow = (df[['left_elbow_x', 'left_elbow_y', 'left_elbow_z', ]] - center_location_norm_body) / scale_norm_body df_body_left_hand = (df[['left_hand_x', 'left_hand_y', 'left_hand_z', ]] - center_location_norm_body) / scale_norm_body df_body_right_shoulder = (df[['right_shoulder_x', 'right_shoulder_y', 'right_shoulder_z', ]] - center_location_norm_body) / scale_norm_body df_body_right_elbow = (df[['right_elbow_x', 'right_elbow_y', 'right_elbow_z', ]] - center_location_norm_body) / scale_norm_body df_body_right_hand = (df[['right_hand_x', 'right_hand_y', 'right_hand_z']] - center_location_norm_body) / scale_norm_body df_body_normalized = pd.concat( [df_body_head, df_body_neck, df_body_torso, df_body_waist, df_body_left_shoulder, df_body_left_elbow, df_body_left_hand, df_body_right_shoulder, df_body_right_elbow, df_body_right_hand], axis=1) # df_body_normalized.to_csv(output_filename2) X_body = np.append(X_body, [df_body_normalized.values], axis=0) model.compile(loss='mean_squared_error', optimizer=opt) hist_model = model.fit(x=[X_fingers, X_body], y=y, epochs=100, validation_split=0.2, batch_size=32) val = hist_model.history['val_loss'][-1] trial.set_user_attr('loss', hist_model.history['loss']) trial.set_user_attr('val_loss', hist_model.history['val_loss']) trial.set_user_attr('loss_final', hist_model.history['loss'][-1]) trial.set_user_attr('val_loss_final', hist_model.history['val_loss'][-1]) return val if __name__ == '__main__': study = optuna.create_study() study.optimize(objective, n_trials=500) print('Number of finished trials : ', len(study.trials)) print('Best trial:') trial = study.best_trial print(' Value: ', trial.value) print(' Params: ') for key, value in trial.params.items(): print(' {} : {}'.format(key,value)) print(' User attrs: ') for key, value in trial.user_attrs.items(): print(' {} : {}'.format(key,value)) path_w1 = 'result_params.txt' with open(path_w1, mode='w') as f: for key, value in trial.params.items(): f.write(' {} : {}'.format(key, value)) path_w2 = 'result_user_attrs.txt' with open(path_w2, mode='w') as f: for key, value in trial.user_attrs.items(): f.write(' {} : {}'.format(key, value))
計算結果
1st トライ
- とりあえず、目的関数の1回の学習計算を100エポックで行い、ハイパーパラメータ評価を500トライアル行うようにしてみた。
- 大体僕のPCで8時間かければ終わると思ったので、就寝して朝確認してみた。
- 結果・・・メモリが死ぬほど消費されて、計算が激遅になっていて、全然最後まで計算できてなかった・・・。16GBじゃ小さかったか。もしくはトライアル回数が多すぎたか。
- とりあえず途中で切ったが、そのときの一番最後のトライアル時点で、以下のようであった。
- Current best valueがつまり、最適と判断したハイパーパラメータでの目的関数の値である。 - 手で設計した値で0.0374・・・くらいだったので、微妙によくなった程度。もっと良くなってほしいなぁ・・・。
Current best value is 0.03668692404261002 with parameters: { 'n_units_body_l2': 289.4605170198315, 'dropout_rate_body_l0': 0.8364339589557341, 'dropout_rate_finger_l0': 0.8859765071323414, 'dropout_rate_body_l1': 0.15985137870934507, 'weight_decay': 2.488304298517825e-05, 'dropout_rate_finger_l1': 0.341986720233033, 'dropout_rate_integrated_l0': 0.5575471944470913, 'n_units_finger_l0': 70.38427639988954, 'dropout_rate_body_l3': 0.07259930169805667, 'n_units_body_l3': 53.536974517157255, 'n_layers_body': 4, 'dropout_rate_body_l2': 0.2272064995599263, 'n_layers_integrated': 1, 'n_units_body_l1': 46.97294594609835, 'n_units_integrated_l0': 34.01545234457688, n_units_body_l0': 399.85159118050177, 'optimizer': 'ADAM', 'adam_lr': 0.00014800027376498905, 'n_units_finger_l1': 29.76813380928397, 'n_layers_finger': 2}.
2ndトライ
- メモリ食い過ぎたのが、目的関数の試行回数が多すぎて、目的関数評価履歴データが溜まりすぎたのかな?と思い、目的関数の1回の学習計算を1000エポックで行い、ハイパーパラメータ評価を50トライアル行うようにしてみた。
- また、前回の計算では、最後のほうはほぼNadamかAdamしか選ばれていなかったので、SGDは探索から除外した。
- 2時間半くらいで終わり、以下の結果が得られた。
- 最終的な値としては、0.036185・・・になったので、手で設計したときのモデルに比べると0.0015くらいval_lossが改善されている。しかも、圧倒的にパラメータ数が減っている。
Best trial: Value: 0.03618520897669861 Params: n_units_body_l2 : 235.38956630848222 dropout_rate_integrated_l1 : 0.5737162875000977 n_layers_integrated : 2 dropout_rate_body_l2 : 0.9891436475997171 dropout_rate_body_l1 : 0.1555186733167152 dropout_rate_finger_l2 : 0.3464849954214746 n_units_finger_l2 : 49.48920178671224 dropout_rate_body_l3 : 0.3426272345026113 n_units_finger_l1 : 103.31434565170868 n_units_body_l0 : 34.571351359102025 dropout_rate_finger_l0 : 0.7604449168769942 optimizer : NADAM schedule_decay : 9.964837485839651e-06 nadam_lr : 1.3501789024608659e-06 dropout_rate_finger_l1 : 0.3228038832180243 n_units_body_l3 : 185.55310387909378 n_units_body_l1 : 14.346523075661459 n_units_finger_l0 : 25.094171936721253 n_layers_finger : 3 n_units_integrated_l0 : 151.11179196621413 dropout_rate_integrated_l0 : 0.38696485442569295 n_units_integrated_l1 : 101.67919179045141 n_layers_body : 4 dropout_rate_body_l0 : 0.1023105039740452
最終結果のモデル図
所感
- Optunaでハイパーパラメータ最適化をトライしてみた。手で設計するよりも、まぁまぁ良いパフォーマンスのモデルができたと思う。
- ただ、ハイパーパラメータ最適化で劇的に良くならなかったのは、学習データのせいもあると思う。そもそもデータ数が少ないし、(自分でデータ化しておいてなんだが)データの質も高くないしね。今後もっとマシなデータで試してみたいな。
- クラスごとのデータ量にかなり偏りのあるデータなので、最近話題のFocal Lossを導入してみると、効果がでるかもなと思っている。
- 識別が簡単な大多数に対する損失の重みを軽くし、識別が困難な少数のクラスに対して損失の重みを大きくする。データに含まれる各クラスのデータ量のばらつきが大きい時に特に効果を発揮するらしい。KITTI Benchmarkの3D Object DetectionのSOTAになっているPointRCNNとかでも使われている。
今後
- Focal Lossを試す、LSTMとGRUの導入。もしかしたらCNNもちょっといれる。
- ぶっちゃけLSTMとか簡単に試すだけならやったことあるけど、どう使うのが効果的なのか考えあぐねている・・・まぁその悩んだ結果もそのうち書ければと。論文読まないとなー。
- ちゃんと時系列処理した奴に対してOptunaかけて、もう一回記事をポストしたいですね。