複数目的関数でのベイズ最適化: シミュレーション回数を抑える実践的アプローチ
はじめに
皆さん、シミュレーションや機械学習のハイパーパラメータを調整したことはありますか?
今回は、効率的にパラメータ探索を行うベイズ最適化の、こだわりのアーキテクチャをご紹介します!
ハイパーパラメータ調整の基本
ブラックボックス最適化手法の一つであるベイズ最適化は、ランダムサーチやグリッドサーチに比べて効率的な手法として知られています。
通常、複数のパラメータを調整する場合では同一の目的関数を使用します(アーキテクチャ1)。
でも、複数のパラメータごとに異なる目的関数を設定したくなることもありますよね。
その場合の解決策は何でしょうか?
アーキテクチャ1:同一目的関数で最適化
単純な解決策は、各パラメータに対して個別に最適化を行う方法です。
その場合、個別に最適化を行う分に応じてシミュレーションの実行回数が増えてしまうのが難点です。
そこで、シミュレーションの実行回数を増やさずに対応する方法が求められます。
提案するアーキテクチャ
今回提案するのは、複数のパラメータを個別の目的関数で最適化しつつ、共通のシミュレーションを行うアーキテクチャです(アーキテクチャ2)。
この方法を使えば、計算リソースを大幅に節約しながら最適化することが可能になります!
アーキテクチャ2:各目的関数で最適化(シミュレーションは共通実行)
実装・実験
アーキテクチャ2(各目的関数で最適化、シミュレーションは共通実行)に基づいてコードを実装します。
実装にあたっては、Preferred Networks社が提供するオープンソースの最適化フレームワークライブラリであるOptunaを利用します。
今回は、OptunaのAsk-and-Tell Interfaceを活用します。
このインターフェースでは、試行するパラメータを提案する ask()
と目的関数の値をOptunaに報告する tell()
に分けて実装します。そして、その間にシミュレーションを実行します。
Ask-and-Tell Interfaceの公式ドキュメントはこちらです。
実装の流れ
実装は次の流れで行います。
- 最適化タスク(
study
)の作成 -
study
から試行(trial
)を作成し、パラメータを提案させる - 共通のシミュレーションを実行し、目的関数の値を得る
-
study
に対して目的関数の結果を報告する
サンプルコード
先ほどの実装の流れに沿って、サンプルコードを実装します。
ここでは探索するパラメータは3種類とし、それぞれ1.0, 10.0, 100.0の値が真値であると設定しています。
目的関数は、各パラメータの真値からの差分絶対値にマイナスをかけたものとします。
import optuna
def run_simulation(param1, param2, param3):
# 例:パラメータが1.0, 10.0, 100.0に近いほど良い目的関数値を返すシミュレーション
result = {
"score1": -abs(param1 - 1.0),
"score2": -abs(param2 - 10.0),
"score3": -abs(param3 - 100.0),
}
return result
# 各studyの定義
study1 = optuna.create_study(direction="maximize")
study2 = optuna.create_study(direction="maximize")
study3 = optuna.create_study(direction="maximize")
# 試行回数
n_trials = 50
for step in range(n_trials):
# 各studyからtrialをaskする
trial1 = study1.ask()
trial2 = study2.ask()
trial3 = study3.ask()
# 各trialからパラメータを提案させる
param1 = trial1.suggest_float("param1", 0.0, 2.0)
param2 = trial2.suggest_float("param2", 0.0, 20.0)
param3 = trial3.suggest_float("param3", 0.0, 200.0)
# 共通のシミュレーションを実行
result = run_simulation(param1, param2, param3)
# 各studyに対して目的関数の結果を報告
study1.tell(trial1, result["score1"])
study2.tell(trial2, result["score2"])
study3.tell(trial3, result["score3"])
# 最適なパラメータを表示
print("Best param1:", study1.best_params)
print("Best param2:", study2.best_params)
print("Best param3:", study3.best_params)
実行結果
上記のコードを実行すると次のような結果が表示されます。
最適化により真値に近い値が得られています!
Best param1: {'param1': 1.0075076659347848}
Best param2: {'param2': 9.993986603171756}
Best param3: {'param3': 100.38235662907955}
このコードを100回実行してみても、100回すべてで真値に近い結果が得られました!
以下は各最適化実験のベストパラメータをプロットしたヒストグラムです。
比較実験
比較として、アーキテクチャ1(同一の目的関数で全パラメータを最適化するアーキテクチャ)でも実験してみます。
目的関数は、各パラメータの真値からの差分絶対値にマイナスをかけたものの合計とします。
コードは次の通りです。
import optuna
def run_simulation(param1, param2, param3):
# 例:パラメータが1.0, 10.0, 100.0に近いほど良い目的関数値を返すシミュレーション
result = {
"score": -abs(param1 - 1.0)-abs(param2 - 10.0)-abs(param3 - 100.0)
}
return result
def objective(trial):
# trialから各パラメータを提案させる
param1 = trial.suggest_float("param1", 0.0, 2.0)
param2 = trial.suggest_float("param2", 0.0, 20.0)
param3 = trial.suggest_float("param3", 0.0, 200.0)
# 共通のシミュレーションを実行
result = run_simulation(param1, param2, param3)
# 目的関数の結果を返す
return result["score"]
# studyの定義
study = optuna.create_study(direction="maximize")
# 最適化の実行
n_trials = 50
study.optimize(objective, n_trials=n_trials)
# 最適なパラメータを表示
print("Best Params:", study.best_params)
print("Best Value:", study.best_value)
このコードを実行すると次のような結果が表示されました。
特に param1
に関しては、アーキテクチャ2(各目的関数で最適化、シミュレーションは共通実行)の最適化結果の方が真値に近い値が得られることがわかります。
Best Params: {
'param1': 0.18527162449231888,
'param2': 8.409796885220032,
'param3': 101.65175461786694}
この実験を100回繰り返しても、アーキテクチャ2(各目的関数で最適化、シミュレーションは共通実行)の方が値が安定していることがわかります。
以下は各最適化実験のベストパラメータをプロットしたヒストグラムです。
まとめ
本記事では、計算負荷の大きいシミュレーション処理を共通処理としつつ、複数のパラメータを効率的に最適化するための方法を紹介しました。
ぜひこのアーキテクチャを試し、シミュレーションチューニングの効率を最大化してください!

NTT DATA公式アカウントです。 技術を愛するNTT DATAの技術者が、気軽に楽しく発信していきます。 当社のサービスなどについてのお問い合わせは、 お問い合わせフォーム nttdata.com/jp/ja/contact-us/ へお願いします。
Discussion