ニューラルネットワークやLightgbmでモデルを作成する際に最も時間のかかるのはハイパーパラメータの調整ではないでしょうか?
最適なものを得ることができればそれだけでモデルの性能をぐんと上げることができる一方で、その最適な値を探索するのはすごく苦労する作業です。。。
今回はそんな面倒だけどとても重要なハイパーパラメータの調整を自動で簡単に行ってくれるPythonライブラリのOptunaをご紹介します!
前半ではOptunaがどんなライブラリでどうやって使うのかを簡単な例で説明します!そのあと具体例としてPytordhで作成したニューラルネットワークモデルの層数とノード数を最適化しています!
後半ではPostgressDBをバックエンドに使用して最適化のプロセスを並列化して高速化する方法もご紹介しています!
Optunaとは?
ハイパーパラメータの調整を自動で簡単に行ってくれるPythonライブラリであり、Preferred Networksが開発しました!Optunaはこれまでの探索履歴をもとにして有望なハイパーパラメータを推定しそれを実際に試してみるという過程を繰り返すことで最適なハイパーパラメータを探します!詳しくはPreferred Networksのこちらのページをご参照ください!
Pythonで直感的にもわかりやすいAPIを用意してくれていて、初めて扱うときにもとても使いやすいです!結果の可視化用の関数も用意してくれていて最適化からその結果の可視化までこれだけで完結させられます!
また、個人的にOptunaの一番の強みはPostgressDBをバックエンドに使用してめちゃくちゃ簡単に並列分散処理が行える点です!つまり、使用できるCPUのコア数分だけハイパーパラメータの最適化を高速化できるということです!
基本的にめちゃくちゃ時間のかかる作業であるハイパーパラメータの調整は複数の条件を別々のプロセスで同時に実行させて高速化するのが定番です!しかしそれを自分で実装しようとなるととても難しくコードは煩雑になってしまいます。。。でもOptunaならとても簡単に実装できます!
Optunaの使い方
まずはOptunaですごく簡単な最適化を行ってみて、そのコードを見ながら使い方を説明してきます!
今回はy=(x-2)^2を最小化するxの値を探索するコードを書いてみます!
以下のようなx=2で最小値を取る二次関数の最小化を行います!
必要なライブラリをインポートします!
import optuna
Optunaではまず、最適化する対象となる目的関数を設定します!以下のような関数です!
def objective(trial):
# 探索するxの範囲の指定
x = trial.suggest_uniform('x', -10, 10)
# xが与えられたときの目的関数の値(スコア)を計算
score = (x - 2) ** 2
# スコアを返す
return score
trialオブジェクトを引数として与えて、目的関数の返り値(スコア)を返す関数を作成してください!
trialオブジェクトはこの段階ではどんなものなのかわからないかもしれませんが、こういうものなんだと一旦おいておいてください!あとで説明します!
この返り値のスコアを最小化したり最大化したりしてもらいます!今回は目的関数がy=(x-2)^2なのでこれをスコアとしています!
x = trial.suggest_uniform(‘x’, -10, 10) この部分はxの値を-10から10までの一様分布で試してみてねと指定しています!trial.suggest_uniformの他にも整数を指定するtrial.suggest_intやリストの中から1つを選んでもらうtrial.suggest_categoricalなどを用意してくれています!
さて!最適化の対象となる関数が設定できたらあとはOptunaに最適化してもらいましょう!
study_name = 'example1'
study = optuna.create_study(study_name=study_name)
study.optimize(objective, n_trials=100)
まずoptuna.create_study()関数でstudyオブジェクトを作成します!このstudyオブジェクトに目的関数(objective)と何回探索を行うかのn_trialsを指定してoptimize()メソッドを使うことで最適化を行います!
studyオブジェクトには探索の履歴や探索の設定が保存されています!ですのでこのstudyオブジェクトのattributeを確認することで探索の結果を見ることができます!またOptunaが用意してくれている可視化関数にこのstudyオブジェクトを渡すことでも結果を確認できます!
次の節でその結果の確認と可視化を行います!
探索結果の可視化
まずはstudyのbest_trialを確認してみましょう!
best_trial = study.best_trial
print(f"""Number of finished trials: {len(study.trials)}
Best trial:
Value: {best_trial.value}
Params: """)
for k, v in best_trial.params.items():
print(f' {k}: {v}')
# Number of finished trials: 100
# Best trial:
# Value: 9.84197826198519e-05
# Params:
# x: 1.990079325495721
study.best_trialで最も目的関数のスコアが良かったTrialを取得します!best_trialはFrozenTrialというクラスとして保存されていてスコアの値や探索したハイパーパラメータ値が保存されています!
そしてそのbest_trialからbest_trial.valueによって目的関数のスコアの値を、best_trial.paramsで探索したパラメータの値を取得してきました!
100回の試行によってxの値が1.990で最小値になっているようです!理想値のx=2にかなり近い値を見つけることができました!
History Plot
次に探索の履歴をHistory Plotという形で可視化してみましょう!
fig = optuna.visualization.plot_optimization_history(study)
fig.show()
これは横軸に各探索の試行、縦軸に目的関数のスコアを表示しています!青い点一つ一つが一回の探索を示していて、赤いラインプロットがその時点で最もよい目的関数のスコアを表しています!
このサイトでは画像として表示していますが、本来はPlotlyオブジェクトというインターラクティブに操作できるオブジェクトとして出力されるのでこの青い点がどんな値なのかなどもカーソルを合わせることで調べることもできます!
これを見ていると4回目くらいの時点でもうほぼ0に近い値を見つけることができているようですね!
Slice Plot
さて!次はハイパーパラメータ調整で最も興味のあるであろうハイパーパラメータと目的関数のスコアの関係性を可視化してみましょう!
これはoptuna.visualization.plot_sliceという関数で行います!
fig = optuna.visualization.plot_slice(study, params=["x"])
fig.show()
今回の検討はObjectiveが二次関数だったのでこのプロットもその形になっていますね!
ちなみに青色が濃いほうが探索が進んだ段階になっています!
実際、x=2の付近に濃い青色の点が集中していることから、Optunaはこのあたりにベストな値があるのだろうと推測して探索を行ってくれていることがわかります!
ちなみにこのプロットもPlotlyオブジェクトなのでインタラクティブに操作できます!(Optunaの可視化関数はすべてPlotlyオブジェクトです!)
ニューラルネットワークモデルのハイパーパラメータ最適化
さて次はもう少し実践的な、回帰タスクのニューラルネットワークのハイパーパラメータ調整をやってみましょう!
今回はニューラルネットワークの層数、各層のノード数、学習率、Optimizer(SGDとAdamの2つだけ)の4つのハイパーパラメータを対象として最適化を行ってみます!
必要なライブラリをインポートします!
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
import os
from multiprocessing import Process
import numpy as np
import random
import optuna
タスク設定と目的
今回モデルのインプットは5次元の乱数を使用します!そして回帰する対象は私が設定した以下の3次関数とします!
# 正解ラベル生成用関数
def f(x):
return x[0] + x[0]**2 + 2*x[1]**3 + 3*x[2] + 4*x[3] + 5*x[4]
つまり、3次関数をニューラルネットワークで再現するのに最適なハイパーパラメータを探そうというタスクです!
少し不自然な設定ではありますが、Optunaの実験なので良しとします!
層数、各層のノード数を変更可能なニューラルネットワークの実装
今回最も重要になるのが調整するハイパーパラメータを自在に変更することのできるニューラルネットワークを実装することです!
以下のように実装してみました!インスタンス化する際にn_layers, n_nodesを設定することで自由に層数とノード数を変更できます!
# 引数でレイヤー数、ノード数を変更可能なモデルクラス
class VariableModel(nn.Module):
def __init__(self, n_layers, n_nodes):
super(VariableModel, self).__init__()
self.input_layer = nn.Linear(5,n_nodes)
layer = [nn.Linear(n_nodes,n_nodes) for _ in range(n_layers)]
self.layer = nn.ModuleList(layer)
self.output_layer = nn.Linear(n_nodes,1)
def forward(self, x):
x = self.input_layer(x)
x = F.relu(x)
for i in range(len(self.layer)):
x = self.layer[i](x)
x = F.relu(x)
out = self.output_layer(x)
return out
目的関数の実装
def objective(trial):
# チューニングするパラメータの範囲の設定
n_layers = trial.suggest_int('n_layers', 1, 4)
n_nodes = trial.suggest_int('n_nodes', 2, 64)
lr = trial.suggest_loguniform('lr', 1e-5, 1e-1)
optimizer_name = trial.suggest_categorical('optimizer_name', ['SGD', 'Adam'])
# その他の定数
n_epochs = 1000
n_sample = 10000
seed = 42
# インプットと正解ラベルの生成
torch.manual_seed(seed)
X = torch.randn(n_sample, 5)
y = torch.FloatTensor([f(x_i) for x_i in X]).reshape(n_sample, 1)
# Trainセット Testセットの分割
X_train, y_train = X[:int(n_sample * 0.8)], y[:int(n_sample * 0.8)]
X_test, y_test = X[int(n_sample * 0.8):], y[int(n_sample * 0.8):]
# モデルのインスタンス化
model = VariableModel(n_layers=n_layers, n_nodes=n_nodes)
# 最適化手法のパラメータ設定
if optimizer_name == 'SGD':
optimizer = optim.SGD(model.parameters(), lr=lr, momentum=0.9)
elif optimizer_name == 'Adam':
optimizer = optim.Adam(model.parameters(), lr=lr)
else:
raise ValueError('Invalid optimizer_name')
# loss関数の定義
criterion = nn.MSELoss()
# 学習ループ
model.train()
for epoch in range(n_epochs):
optimizer.zero_grad()
preds = model(X_train)
loss = criterion(preds, y_train)
loss.backward()
optimizer.step()
if (epoch + 1) % 100 == 0:
print(f'Epoch [{epoch + 1}/{n_epochs}], Loss: {loss.item():.2f}')
# テストセットで評価
preds_test = model(X_test)
test_loss = criterion(preds_test, y_test)
# テストセットのLossを返す
return test_loss.item()
今回の目的関数は内部でインプットと正解ラベルを生成し、TrainデータセットとTestデータセットに分割、Trainデータセットでモデルを学習させてTestデータセットのMSE(平均2乗誤差)をスコアとして返しています!
詳細はコードの中のコメントを参照してみてください!
探索するハイパーパラメータの範囲は以下です!
各層のノード数:2 ~ 64
学習率:1e-5 ~ 1e-1
Optimizer:SGD, Adam
最適化の実行
study_name = 'exampleNN'
study = optuna.create_study(study_name=study_name)
study.optimize(objective, n_trials=100)
ここまでくればあとは一緒で、studyオブジェクトを作成してoptimizeします!
結果の可視化
まずは最も良かったパラメータとそのスコアを確認してみましょう!
best_trial = study.best_trial
print(f"""Number of finished trials: {len(study.trials)}
Best trial:
Value: {best_trial.value}
Params: """)
for k, v in best_trial.params.items():
print(f' {k}: {v}')
# Number of finished trials: 86
# Best trial:
# Value: 0.015964144840836525
# Params:
# lr: 0.0831362619593964
# n_layers: 1
# n_nodes: 58
# optimizer_name: Adam
今回のスコアは予測値と真値(3次関数の出力)の平均2乗誤差(MSE)でした!Best Valueが0.0159なのでほとんど誤差なくニューラルネットワークで3次関数を再現できているみたいですね!
次にHistory Plotを確認してみます!
かなり初期の段階からMSEはかなり低い値を示していますね!この様子ならもう少しTrial数を少なくしても大丈夫かもしれません!
Slice Plotを確認してみます!
学習率は0.01〜0.1に後半のTrialが集中しているようです!Best Parameterも0.0831だったのでこれについては0.1以上の領域も探索範囲に含めたほうがいいかもしれないですね!
n_nodesについてもも60前後に後半のTrialが集中していることから64以上の領域も探索範囲に含めるのが良さそうです!
最後にHyperparameter Importanceを確認してみます!これはfANOVAというハイパーパラメータの重要性を評価するアルゴリズムを使って各ハイパーパラメータの重要性を計算してくれています!
これを見てみるとoptimizerが最も重要みたいですね!Best ParamもAdamですし、Slice PlotのOptimizerも後半のTrialがAdamに集中しています!これならOptimizerはAdamに固定してしまっても良さそうです!
まとめと次回予告(並列分散処理について)
並列分散処理についても紹介しようと思ったのですが、記事が長くなりすぎたので次回の記事でご紹介しようと思います!
今回は簡単な例を使ったOptunaの紹介と少し実践的なニューラルネットワークのハイパーパラメータ最適化を行ってみました!
結果の可視化もかなり簡単に行える上に見た目もクオリティ高くていたれりつくせりのパッケージだと思います!
ぜひOptunaを使って機械学習モデルなどのハイパーパラメータの調整をやってみてください!
ここまで読んでいただいてありがとうございました!次回の並列分散処理も楽しみにしていてください!