optunaの理論

はじめに

optunaといえば、予測モデルのハイパーパラメータチューニングで大変お世話になっている人がとても多くいると思います。グリッドサーチのようなハイパーパラメータの最適化手法はどのようなことが行われているかがとても、簡単で解釈している人は多いと思いますが、ことoptunaに関してはどうでしょうか?内部的にどのようなことが行われているかわかっていながら使用している人がどの程度いるでしょうか?個人的な感覚ですが、大体こんなことをやっているのは知っているが、詳しくは知らないといった人が大半なのではないでしょうか?ということで今回はoptunaの理論に関して記事を書いていきたいと思います。

従来のフレームワークにおける課題

従来のハイパーパラメータ最適化フレームワークには、主に三つの重要な課題が存在していました。
最初の課題は、パラメータ探索空間の静的な定義です。従来のフレームワークでは、最適化を開始する前にすべてのパラメータ空間を明示的に定義する必要がありました。これは特に、複数のモデルタイプや条件付きパラメータを扱う大規模な実験において大きな制約となっていました。例えば、ニューラルネットワークの層数に応じて各層のユニット数を探索したい場合、その依存関係を静的に記述することは非常に煩雑でした。
二つ目の課題は、効率的な探索戦略と枝刈り戦略の両立です。多くのフレームワークは、パラメータの探索戦略は持っているものの、計算リソースを効率的に活用するための枝刈り(プルーニング)機能が不十分でした。これにより、有望でないパラメータの組み合わせの評価に多くの時間を費やすことになっていました。
三つ目の課題は、システムアーキテクチャの柔軟性です。従来のフレームワークの多くは、特定の使用環境や計算規模を想定して設計されており、小規模な実験から大規模な分散計算まで、様々なスケールの最適化タスクに対して柔軟に対応することが困難でした。

Optunaの設計思想

これらの課題を解決するため、Optunaは三つの革新的な設計基準に基づいて開発されました。
まず、Define-by-run APIの導入です。これは深層学習フレームワークの世界で普及した概念を、ハイパーパラメータ最適化の文脈に適用したものです。このアプローチにより、パラメータ探索空間を動的に構築することが可能となり、複雑な依存関係を持つパラメータ空間でも直感的に記述できるようになりました。
次に、効率的なサンプリングアルゴリズムと枝刈りアルゴリズムの統合です。Optunaは、Tree-structured Parzen Estimator(TPE)やCovariance Matrix Adaptation Evolution Strategy(CMA-ES)などの高度なサンプリングアルゴリズムと、Asynchronous Successive Halving(ASHA)などの効率的な枝刈りアルゴリズムを組み合わせることで、限られた計算リソースで効果的な最適化を実現しています。

optunaの理論

今回参考にした論文は以下です。

arxiv.org

Define-by-run APIの理論と実装

Define-by-run APIは、Optunaの最も特徴的な革新の一つです。この概念の理解を深めるため、まず従来のdefine-and-run方式との比較から始めましょう。 従来のdefine-and-run方式では、最適化の対象となるパラメータ空間を以下のように静的に定義する必要がありました:

\mathcal{S} = \{(x_1, ..., x_n) | x_i \in D_i, i = 1, ..., n\}
ここで、D_iは各パラメータx_iの定義域を表します。この方式では、パラメータ間の依存関係が存在する場合、その表現が非常に複雑になってしまいます。

一方、Optunaのdefine-by-run方式では、パラメータ空間を実行時に動的に構築します。この方式は、以下のような確率的な生成プロセスとして形式化できます:

p(\theta) = \prod_{i=1}^n p(x_i|\mathcal{H}_i)
ここで、\mathcal{H}_ii番目のパラメータを生成する時点での試行履歴を表します。各パラメータは、それまでの試行結果に基づいて条件付き確率的に生成されます。

わかりやすく説明すると、これらの式は、パラメータ探索の「従来の固定的な方法」と「optunaの柔軟な方法」の本質的な違いを表現しています。従来の方式(最初の式)では、例えば「層数は1から10、各層のユニット数は10から100」というように、すべてのパラメータの取りうる値を前もって決めておく必要がありました。これは、食事の注文で例えると、「前菜、主菜、デザートをそれぞれこの中から一つ選んでください」という固定のコース料理のようなものです。
一方、optunaの方式(二番目の式)は、まるで「お客様の好みをお聞きしながら、次の料理を提案させていただきます」というようなア・ラ・カルト方式です。パラメータは一つ一つ順番に、それまでの結果を見ながら柔軟に決めていきます。「層数を3にしたから、各層のユニット数はこの程度が良さそうだ」といった具合に、パラメータ間の自然な依存関係を表現できます。この柔軟性により、より効率的で直感的なパラメータ探索が可能となっています。
  
この方式の実装は、以下のような直感的なPythonコードとして表現されます:

def objective(trial):
    # 層数を動的に決定
    n_layers = trial.suggest_int('n_layers', 1, 4)
    
    # 各層のユニット数を動的に決定
    layers = []
    for i in range(n_layers):
        n_units = trial.suggest_int(f'n_units_{i}', 4, 128)
        layers.append(n_units)

この実装の特徴は、パラメータの生成が実行時のコンテキストに応じて行われることです。これにより、以下のような複雑な依存関係も自然に表現することが可能となります:

・パラメータ間の階層的な依存関係
・条件付きパラメータの動的な生成
・可変長のパラメータ構造

サンプリングアルゴリズムの理論

Optunaにおけるサンプリングアルゴリズムは、探索と活用のトレードオフを効果的に制御するように設計されています。中でも特に重要なのが、Tree-structured Parzen Estimator(TPE)です。
TPEは、以下のような条件付き確率モデルに基づいています:

p(x|y) = \begin{cases} l(x) & \text{if } y < y^* \\ g(x) & \text{if } y \geq y^* \end{cases}
ここで、y^*は観測された目的関数値の\gamma分位点(通常\gamma = 0.25)を表し、l(x)は良好な結果を与えたパラメータの確率密度、g(x)は悪い結果を与えたパラメータの確率密度を表します。

もっとわかりやすく述べると、この式は、TPEがパラメータの「良い領域」と「悪い領域」を学習する方法を表現しています。具体的には、これまでの試行結果を上位25%(γ = 0.25)とそれ以外に分け、それぞれの領域でパラメータの出現分布を推定します。l(x)は「良い結果を出したパラメータはこんな値を取っていた」という分布、g(x)は「あまり良くない結果だったパラメータはこんな値だった」という分布を表しています。この二つの分布を比較することで、「良いパラメータっぽい領域」を特定し、そこを重点的に探索することができます。これは、人間が試行錯誤する際の「うまくいった設定の周辺をもっと詳しく調べてみよう」という直感的な戦略を、確率的にモデル化したものと考えることができます。
  
TPEにおける改善期待値(Expected Improvement, EI)は、以下の式で定式化されます:

\text{EI}(x) = \gamma y^* + \int_{-\infty}^{y^*} (y^* - y)p(y|x)dy
この式は、新しいパラメータxを試行した際に、現在の最良値y^*からどれだけの改善が期待できるかを表現しています。TPEは、この改善期待値を最大化するパラメータを選択することで、効率的な探索を実現します。

  

実際のTPEアルゴリズムでは、カーネル密度推定を用いてl(x)g(x)を近似します。各分布は以下のように推定されます:
l(x) = \frac{1}{|\mathcal{D}_l|} \sum_{i \in \mathcal{D}_l} K_h(x - x_i)
g(x) = \frac{1}{|\mathcal{D}_g|} \sum_{i \in \mathcal{D}_g} K_h(x - x_i)
ここで、K_hカーネル関数(通常はガウシアンカーネル)、hはバンド幅パラメータ、\mathcal{D}_l\mathcal{D}_gはそれぞれ良好な結果と悪い結果を与えたパラメータの集合を表します。

わかりやすく説明してみましょう。TPEの仕組みは、経験豊富なデータサイエンティストの思考プロセスを数学的にモデル化したものと考えることができます。最初の式は「良い結果が出たパラメータと悪い結果が出たパラメータを分類する」という基本的な考え方を表しています。これは、例えば機械学習コンペティションで「上位25%に入った提出と、そうでない提出で使ったパラメータを分けて考える」というような戦略に相当します。
二番目の式(EI)は「次にどのパラメータを試すべきか」を決める方法を表現しています。これは、ポーカーでいえば「このカードを引いたらどれくらい手が良くなるか」を計算するようなものです。現在の最良値y*からどれだけ改善が見込めるかを、確率的に計算しています。
最後の二つの式は、「良いパラメータの分布」と「悪いパラメータの分布」をデータから具体的に推定する方法を示しています。これは、点在するデータ点から滑らかな分布を推定する作業で、例えば「この辺りのパラメータはうまくいきやすい」という連続的な傾向を捉えようとしています。カーネル関数Khは、データ点の周りに山のような形を作り、それらを重ね合わせることで全体の分布を表現します。これは、例えば「このパラメータの近くも似たような結果が出るだろう」という自然な仮定を数学的に表現したものです。
  
一方で、パラメータ間の相関関係を考慮したい場合、CMA-ES(Covariance Matrix Adaptation Evolution Strategy)も重要な選択肢となります。CMA-ESは、以下の多変量正規分布に基づくサンプリングを行います:

x_{k+1} \sim \mathcal{N}(m_{k+1}, \sigma_{k+1}^2 C_{k+1})
ここで、m_{k+1}は分布の中心、\sigma_{k+1}はステップサイズ、C_{k+1}は共分散行列を表します。共分散行列の更新は以下の式で行われます:
C_{k+1} = (1-c_1-c_\mu)\,C_k + c_1\,p_c\,p_c^T + c_\mu\,\sum_{i=1}^{\mu} w_i\,y_i\,y_i^T
この式において、c_1c_\muは学習率、p_cは進化パス、w_iは重み係数、y_iは選択された個体の変位ベクトルを表します。CMA-ESは、この共分散行列の適応的な更新により、パラメータ空間の構造を学習しながら効率的な探索を行います。

わかりやすく説明すると、これらの式は、CMA-ESが「パラメータ間の相互関係を学習しながら探索する」方法を表現しています。直感的に理解するために、地図上で宝探しをする状況を想像してみましょう。最初の式は「次にどこを探すか」を決める方法を表しています。中心m(今までで最も有望そうな場所)を基準に、ある範囲σ(探索範囲の大きさ)で、C(地形の特徴)を考慮しながら次の探索位置を決めます。
二番目の式は「地形の特徴Cをどう更新するか」を表しています。例えば、「北に行くと同時に東に行くと良い結果が得られやすい」といった、パラメータ間の相関関係を学習していきます。過去の経験(Ck)を活かしつつ、新しい発見(y_i)を適度な重み(w_i)で取り入れ、さらに長期的な探索の方向性(p_c)も考慮します。これにより、「ただランダムに探索する」のではなく、パラメータ空間の構造を理解しながら効率的に探索を進めることができます。

効率的な枝刈り(Pruning)メカニズム

Optunaの重要な特徴の一つが、効率的な枝刈りメカニズムです。特に、Asynchronous Successive Halving(ASHA)アルゴリズムは、分散環境での効率的な最適化を可能にする革新的な手法です。
ASHAの基本的なアイデアは、以下の数式で表現される評価基準に基づいています:

\text{rung} = \max(0, \lfloor \log_\eta(\lfloor \text{step}/r \rfloor) \rfloor - s)
ここで、rは最小リソース量、\etaは削減係数、sは最小早期停止レートを表します。各トライアルは、この式によって決定されるrungレベルに基づいて評価されます。

  
ASHAの重要な特徴は、その非同期性にあります。従来の同期的な手法では、すべてのトライアルが特定のチェックポイントに到達するまで待つ必要がありましたが、ASHAではそのような同期化は不要です。これは以下の確率モデルとして形式化できます:

p(\text{continue}|\text{value}, \text{rung}) = \mathbb{1}\{\text{rank}(\text{value}) \leq \lfloor N/\eta^{\text{rung}} \rfloor\}
ここで、Nは並行して実行されているトライアルの総数、\text{rank}(\text{value})は現在の性能値のランクを表します。この式は、各rungレベルにおいて、上位1/\etaのトライアルのみが継続されることを示しています。

ここまでを分かりやすく説明してみましょう。ASHAの仕組みは、効率的な運動選手の育成プログラムに似ています。最初の式は「いつ、誰を、どのように評価するか」という評価スケジュールを決める方法を表しています。rungは選手の成長段階のようなもので、各段階で評価を行い、次のステップに進むかどうかを判断します。ここで、rは最初の評価までに必要な最小トレーニング量、ηは選手を絞り込む際の厳しさ、sは最初の評価をどれくらい早く始めるかを決めるパラメータです。
二番目の式は「誰を次の段階に進ませるか」を決める基準を表しています。従来の方式では、例えば「100m走で全員が走り終わるまで待って、その後で評価する」という同期的な評価でしたが、ASHAでは「誰かが走り終わるたびに、その時点での相対的な順位で判断する」という非同期な評価を行います。具体的には、各段階で上位1/η(例えば上位3分の1)の選手だけが次の段階に進めます。これは、有望でない試行を早めに発見して打ち切ることで、計算資源を有望な試行により多く配分する効率的な戦略です。
  
実際の実装では、以下のようなコードとなります:

def objective(trial):
    for step in range(100):
        # 現在の性能値を計算
        current_value = compute_intermediate_value()
        
        # 中間結果を報告
        trial.report(current_value, step)
        
        # 枝刈りの判定
        if trial.should_prune(step):
            raise TrialPruned()

この枝刈りメカニズムにより、計算リソースを効率的に活用することが可能となります。有望でないトライアルを早期に停止することで、より多くのリソースを有望なトライアルに振り分けることができます。
ASHAの理論的な収束性は、以下の不等式で保証されます:

P(\text{regret}_T \leq \epsilon) \geq 1 - \delta
ここで、\text{regret}_TT回のトライアル後の最良値と真の最適値との差、\epsilonは許容誤差、\deltaは失敗確率を表します。この理論的保証は、ASHAが効率的な探索を行いながらも、最適解への収束を保証することを示しています。

分散最適化の理論とアーキテクチャ

Optunaは分散環境での最適化を効率的に行うために、独自のアーキテクチャを採用しています。この分散システムは、以下の理論的なフレームワークに基づいています。
分散最適化における各ワーカーの振る舞いは、以下の確率過程としてモデル化できます:

p(\theta_{t+1}|\mathcal{H}_t) = \int p(\theta_{t+1}|\mathcal{H}_t, \mathcal{D}_t)p(\mathcal{D}_t|\mathcal{H}_t)d\mathcal{D}_t
ここで、\theta_{t+1}は次のトライアルのパラメータ、\mathcal{H}_tは時刻tまでの試行履歴、\mathcal{D}_tは他のワーカーから得られる情報を表します。この式は、各ワーカーが他のワーカーの情報を考慮しながら探索を進めることを表現しています。

  
実際のシステムアーキテクチャでは、この理論的なフレームワークを実現するために、以下の要素が実装されています:

ストレージバックエンド

ストレージシステムは、以下の一貫性モデルに基づいて設計されています:

P(\text{read}(k) = v) = \begin{cases} 1 & \text{if last\_write}(k) = v \\ 0 & \text{otherwise} \end{cases}
この式は、データベースの一貫性を保証するモデルを表現しています。\text{read}(k)はキーkの読み取り操作、\text{last\_write}(k)kに対する最後の書き込み値を表します。

Optunaは、この一貫性モデルを満たすように、複数のストレージバックエンドをサポートしています:

# RDBを使用する場合
storage = optuna.storages.RDBStorage(
    "postgresql://user:password@host/database"
)

# SQLiteを使用する場合
storage = optuna.storages.RDBStorage(
    "sqlite:///example.db"
)

# インメモリストレージを使用する場合
storage = optuna.storages.InMemoryStorage()

トライアルの同期と非同期実行

分散環境での最適化効率は、以下の理論的な上限で特徴付けられます:

E[\text{speedup}(n)] \leq \min(n, \frac{T}{\tau})
ここで、nはワーカー数、Tは全体の計算時間、\tauは1トライアルの平均実行時間を表します。この上限は、並列化による理論的な性能向上の限界を示しています。

実際のユースケースと性能評価

Optunaの実践的な性能を理解するため、代表的なベンチマークと実際のユースケースについて解説します。

ベンチマーク評価の理論的枠組み

性能評価は、以下の理論的な枠組みに基づいて行われます:

\text{Performance}(A) = E_{f \sim \mathcal{F}}[\min_{t \leq T} f(x_t^A)
この式において、Aは最適化アルゴリズム\mathcal{F}ベンチマーク関数の集合、x_t^Aは時刻tでのアルゴリズムAによる解を表します。この評価指標は、アルゴリズムの平均的な性能を測定します。

TPEとCMA-ESの組み合わせによる性能向上

OptunaではTPEとCMA-ESを組み合わせることで、より効率的な探索を実現しています。この組み合わせの理論的な根拠は、以下の期待改善度の式で説明できます:

E[I(x)] = \sigma(x)(\gamma\Phi(\gamma) + \phi(\gamma))
ここで、\sigma(x)は予測の標準偏差\gammaは標準化された改善量、\Phi\phiはそれぞれ標準正規分布の累積分布関数と確率密度関数を表します。この式は、探索と活用のバランスを定量的に表現しています。

わかりやすく解説すると、この式は、TPEとCMA-ESを組み合わせた時の「探索と活用のバランス取り」の仕組みを表現しています。宝探しに例えると、σ(x)は「この場所にどれくらい不確実性があるか」(まだよく調べていない場所ほど大きな値)、γは「今までの最高の発見に比べてどれくらい良い発見が期待できるか」を表します。Φ(γ)は「より良い結果が得られる確率」、φ(γ)は「どれくらい良い結果が得られそうか」を表現しています。
これらを掛け合わせることで、「まだ調べていない場所(探索)」と「良さそうな場所の周辺(活用)」のどちらを優先すべきかをバランス良く判断します。具体的には、不確実性が高い場所(大きなσ(x))でも改善の期待が低ければ探索を控え、逆に不確実性が低い場所でも大きな改善が期待できれば重点的に調べる、という賢い探索戦略を実現しています。

実世界での応用例

実際の応用例として、Deep Learningモデルの最適化における性能を見てみましょう。この場合、目的関数は以下のように定式化されます:

f(\theta) = \mathcal{L}(\mathcal{M}(\theta; \mathcal{D}_{\text{train}}), \mathcal{D}_{\text{val}})
ここで、\mathcal{M}はモデル、\thetaはハイパーパラメータ、\mathcal{L}は損失関数、\mathcal{D}_{\text{train}}\mathcal{D}_{\text{val}}はそれぞれ訓練データと検証データを表します。

実験結果は以下のような収束特性を示しました:

\text{Error}(t) \approx c_1 + \frac{c_2}{\sqrt{t}}
この式は、試行回数tに対する誤差の漸近的な振る舞いを表しています。c_1c_2は問題に依存する定数です。この結果は、Optunaが理論的に予測される最適な収束速度を達成していることを示しています。

システムの拡張性と実装の詳細

Optunaのシステムアーキテクチャは、高い拡張性を持つように設計されています。この拡張性は以下の理論的枠組みに基づいています。

カスタムサンプラーの実装

カスタムサンプラーを実装する際の理論的な基礎は、以下のベイズ最適化の枠組みに基づいています:

x_{t+1} = \arg\max_{x \in \mathcal{X}} \alpha(x|\mathcal{D}_{1:t})
この式で、\alphaは獲得関数(acquisition function)、\mathcal{D}_{1:t}は時刻tまでの観測データを表します。カスタムサンプラーは、この獲得関数を独自に定義することで実装できます。

実装は以下のような形で行われます:

class CustomSampler(BaseSampler):
    def __init__(self, seed=None):
        self._rng = np.random.RandomState(seed)
    
    def sample_independent(self, study, trial, param_name, param_distribution):
        return self._sample_from_distribution(param_distribution)

カスタム枝刈り手法の実装

カスタム枝刈り手法の理論的基礎は、以下の条件付き確率モデルで表現されます:

P(\text{prune}|\mathcal{H}_t) = g(\mathbb{E}[f(x)|\mathcal{H}_t, \text{Var}[f(x)|\mathcal{H}_t])]
ここで、gは枝刈りの判定関数、\mathcal{H}_tは時刻tまでの試行履歴、f(x)は目的関数の値を表します。この式は、現在までの履歴に基づいて枝刈りを判断する確率モデルを表現しています。

実装上の最適化とパフォーマンスチューニング

Optunaのシステム実装には、様々な最適化技術が組み込まれています。これらの最適化は、理論的な性能保証と実用的な効率性のバランスを取るように設計されています。

データベースアクセスの最適化

データベースアクセスのパフォーマンスは、以下のキャッシュヒット率モデルに基づいて最適化されています:

H = \frac{\sum_{i=1}^n h_i}{n} = \frac{\text{cache hits}}{\text{total accesses}}
この式で、h_iは各アクセスがキャッシュヒットした場合1、そうでない場合0となる指示関数です。システムは、このヒット率を最大化するようにキャッシュ戦略を調整します。

メモリ使用量の最適化

メモリ使用量の制御は、以下の制約付き最適化問題として定式化されます:

\min_{m \in \mathcal{M}} C(m) \text{ subject to } M(m) \leq M_{\text{max}}
ここで、C(m)はメモリ構成mのコスト関数、M(m)はメモリ使用量、M_{\text{max}}は最大許容メモリ量を表します。この最適化により、効率的なメモリ利用が実現されています。

並列処理の効率化

並列処理の効率化は、以下のアムダールの法則に基づいて設計されています:

S(N) = \frac{1}{(1-p) + \frac{p}{N}}
ここで、S(N)N個のプロセッサを使用した場合のスピードアップ率、pは並列化可能な部分の割合を表します。システムは、この理論的な限界に近い性能を実現するように最適化されています。

ハイパーパラメータ探索の最適化

探索空間の効率的なサンプリングは、以下のエントロピー最大化問題として定式化されます:

H(X) = -\sum_{x \in \mathcal{X}} p(x) \log p(x)
この式で、H(X)は探索空間\mathcal{X}上の確率分布のエントロピーを表します。システムは、このエントロピーを考慮しながら効率的な探索を行います。

実践的な使用方法とベストプラクティス

Optunaを実務で効果的に活用するためには、理論的な理解に基づいた適切な使用方法が重要です。ここでは、実践的な側面から詳細に解説していきます。

探索空間の設計

探索空間の設計は、以下のような理論的な考慮に基づいて行われるべきです。探索空間の表現力と探索効率のトレードオフは、以下の式で定量化されます:

\text{Efficiency}(\mathcal{S}) = \frac{\text{Volume}(\mathcal{S}_{\text{optimal}})}{\text{Volume}(\mathcal{S}_{\text{total}})}
ここで、\mathcal{S}_{\text{optimal}}は最適解を含む領域、\mathcal{S}_{\text{total}}は全探索空間を表します。この効率を高めるために、以下のような探索空間の設計が推奨されます。

  
パラメータのスケーリングについては、以下の対数変換が有効です:

x' = \log(x + \epsilon)
ここで、\epsilonは小さな正の定数です。この変換により、異なるスケールのパラメータを効率的に探索することが可能となります。

目的関数の設計

目的関数の設計は、以下の複合的な評価指標に基づいて行われます:

f(\theta) = w_1 f_1(\theta) + w_2 f_2(\theta) + ... + w_n f_n(\theta)
ここで、f_i(\theta)は各評価指標、w_iは重み係数を表します。この設計により、複数の評価基準を統合した最適化が可能となります。

計算リソースの最適配分

計算リソースの配分は、以下の最適化問題として定式化されます:

\max_{r_1, ..., r_n} \sum_{i=1}^n U_i(r_i) \text{ subject to } \sum_{i=1}^n r_i \leq R
ここで、U_i(r_i)はリソースr_iを使用した場合の効用、Rは利用可能な総リソース量を表します。この最適化により、限られたリソースを効率的に活用することができます。

実装例

import optuna
import lightgbm as lgb
import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split

# 再現性のため乱数シードを設定
np.random.seed(42)

# サンプルデータの準備
print("データの読み込みと前処理...")
data = load_breast_cancer()
X_train, X_test, y_train, y_test = train_test_split(
    data.data, 
    data.target, 
    test_size=0.2, 
    random_state=42
)

def objective(trial):
    # パラメータ探索空間の定義
    param = {
        # 基本設定
        'objective': 'binary',
        'metric': 'binary_logloss',
        'verbosity': -1,
        'boosting_type': 'gbdt',
        
        # 学習率(対数スケールで探索)
        'learning_rate': trial.suggest_float(
            'learning_rate', 
            1e-4, 
            1e-1, 
            log=True
        ),
        
        # 木の深さと葉の数
        'max_depth': trial.suggest_int('max_depth', 3, 12),
        'num_leaves': trial.suggest_int(
            'num_leaves', 
            2, 
            4096, 
            log=True
        ),
        
        # 特徴量とサンプルのサブサンプリング
        'feature_fraction': trial.suggest_float(
            'feature_fraction', 
            0.6, 
            1.0
        ),
        
        # 過学習制御パラメータ
        'min_child_samples': trial.suggest_int(
            'min_child_samples', 
            5, 
            100
        ),
        
        # 正則化パラメータ
        'lambda_l1': trial.suggest_float(
            'lambda_l1', 
            1e-8, 
            10.0, 
            log=True
        ),
        'lambda_l2': trial.suggest_float(
            'lambda_l2', 
            1e-8, 
            10.0, 
            log=True
        ),
    }
    
    # データセットの作成
    dtrain = lgb.Dataset(X_train, label=y_train)
    
    # 早期停止を含むコールバックの設定
    callbacks = [
        lgb.early_stopping(stopping_rounds=50),
        lgb.log_evaluation(period=0)  # ログ出力を抑制
    ]
    
    # クロスバリデーションの実行
    cv_results = lgb.cv(
        params=param,
        train_set=dtrain,
        num_boost_round=1000,
        nfold=5,
        stratified=True,
        callbacks=callbacks,
        seed=42
    )
    
    # cv_resultsのキーを確認して適切なメトリクスを取得
    metric_name = 'binary_logloss-mean'
    best_score = float('inf')
    
    if metric_name in cv_results:
        best_score = min(cv_results[metric_name])
        best_iteration = len(cv_results[metric_name])
        trial.set_user_attr('best_iteration', best_iteration)
    
    return best_score

# 最適化の実行
print("Optunaによる最適化を開始...")
study = optuna.create_study(
    direction='minimize',
    sampler=optuna.samplers.TPESampler(seed=42)
)

study.optimize(
    objective, 
    n_trials=20,
    timeout=600  # 10分でタイムアウト
)

# 結果の表示
print("\n=== 最適化結果 ===")
print(f"最良の試行:")
print(f"  Trial number: {study.best_trial.number}")
print(f"  Loss: {study.best_trial.value:.4f}")
if 'best_iteration' in study.best_trial.user_attrs:
    print(f"  Best iteration: {study.best_trial.user_attrs['best_iteration']}")

print("\n最適なパラメータ:")
for key, value in study.best_trial.params.items():
    print(f"  {key}: {value}")

高度な機能とカスタマイズ

Optunaの高度な機能とカスタマイズについて、理論的な背景とともに解説していきます。

マルチ目的最適化

マルチ目的最適化は、以下のパレート最適性の概念に基づいて実装されています:

\mathcal{P} = \{x \in \mathcal{X} | \nexists y \in \mathcal{X}: \forall i, f_i(y) \leq f_i(x) \text{ and } \exists j, f_j(y) < f_j(x)\}
この式において、\mathcal{P}パレート最適解の集合を表します。これらの解は、ある目的関数の値を改善するために他の目的関数の値を犠牲にする必要がある解の集合です。

  
実際のマルチ目的最適化では、以下のような支配関係に基づくランキングが使用されます:

\text{rank}(x) = 1 + |\{y \in \mathcal{X} | y \text{ dominates } x\}|
ここで、\text{rank}(x)は解xのランク、|\cdot|は集合の要素数を表します。この指標により、複数の目的関数に関する解の優劣を評価することができます。

条件付きパラメータ空間

条件付きパラメータ空間は、以下のような条件付き確率モデルとして表現されます:

p(x_i|x_{1:i-1}) = \begin{cases} 
q_i(x_i|x_{1:i-1}) & \text{if } c_i(x_{1:i-1}) = \text{True} \\
\delta_{\text{null}}(x_i) & \text{otherwise}
\end{cases}
ここで、q_iは条件が満たされた場合のパラメータの分布、c_iは条件関数、\delta_{\text{null}}はnull値を表すデルタ関数です。この枠組みにより、複雑な依存関係を持つパラメータ空間を効率的に探索することができます。

カスタム分布の実装

カスタム分布の実装は、以下の確率分布族に基づいて行われます:

p(x|\theta) = h(x)\exp(\theta^T T(x) - A(\theta))
この式で、h(x)は基底測度、T(x)は十分統計量、A(\theta)は対数分配関数を表します。この指数型分布族の枠組みにより、様々な確率分布を統一的に扱うことができます。

実装例

import optuna
import lightgbm as lgb
import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, f1_score, roc_auc_score
import time

# 再現性のため乱数シードを設定
np.random.seed(42)

# サンプルデータの準備
print("データの読み込みと前処理...")
data = load_breast_cancer()
X_train, X_test, y_train, y_test = train_test_split(
    data.data, 
    data.target, 
    test_size=0.2, 
    random_state=42
)

def multi_objective(trial):
    """
    マルチ目的最適化の目的関数
    目的1: AUC最大化(予測性能)
    目的2: モデルサイズ最小化(計算リソース効率)
    目的3: 推論時間最小化(実行効率)
    """
    # パラメータ探索空間の定義
    param = {
        # 基本設定
        'objective': 'binary',
        'metric': 'auc',
        'verbosity': -1,
        'boosting_type': 'gbdt',
        
        # 条件付きパラメータの実装
        # まず木の深さ制御方法を選択
        'depth_control': trial.suggest_categorical('depth_control', ['max_depth', 'num_leaves']),
    }
    
    # 条件付きパラメータの設定
    if param['depth_control'] == 'max_depth':
        # max_depthを使用する場合の設定
        param['max_depth'] = trial.suggest_int('max_depth', 3, 12)
        param['num_leaves'] = 2 ** trial.suggest_int('max_depth', 3, 12)  # max_depthに応じて設定
    else:
        # num_leavesを使用する場合の設定
        param['num_leaves'] = trial.suggest_int('num_leaves', 16, 4096, log=True)
        param['max_depth'] = -1  # num_leavesを優先
    
    # 共通パラメータの設定
    param.update({
        # 学習率(小さいほど細かい学習が可能)
        'learning_rate': trial.suggest_float('learning_rate', 1e-4, 1e-1, log=True),
        
        # 特徴量のサブサンプリング(過学習抑制とモデルサイズ削減に効果)
        'feature_fraction': trial.suggest_float('feature_fraction', 0.6, 1.0),
        
        # 正則化パラメータ(過学習抑制)
        'lambda_l1': trial.suggest_float('lambda_l1', 1e-8, 10.0, log=True),
        'lambda_l2': trial.suggest_float('lambda_l2', 1e-8, 10.0, log=True),
        
        # 最小データポイント数(過学習抑制)
        'min_child_samples': trial.suggest_int('min_child_samples', 5, 100),
    })
    
    # 早期停止のためのコールバック
    callbacks = [
        lgb.early_stopping(stopping_rounds=50),
        lgb.log_evaluation(period=0)
    ]
    
    # データセットの作成
    dtrain = lgb.Dataset(X_train, label=y_train)
    dvalid = lgb.Dataset(X_test, label=y_test, reference=dtrain)
    
    # モデルの学習
    model = lgb.train(
        param,
        dtrain,
        num_boost_round=1000,
        valid_sets=[dvalid],
        callbacks=callbacks
    )
    
    # テストデータでの予測と実行時間計測
    start_time = time.time()
    y_pred = model.predict(X_test)
    inference_time = (time.time() - start_time) * 1000  # ミリ秒単位
    
    # 二値分類の予測値を生成
    y_pred_binary = (y_pred > 0.5).astype(int)
    
    # 目的関数1: AUC(最大化)
    auc = roc_auc_score(y_test, y_pred)
    
    # 目的関数2: モデルサイズ(最小化、KBサイズ)
    model_size = len(model.model_to_string()) / 1024
    
    # 目的関数3: 推論時間(最小化、ミリ秒)
    
    # 学習過程の情報をロギング
    trial.set_user_attr('accuracy', accuracy_score(y_test, y_pred_binary))
    trial.set_user_attr('f1_score', f1_score(y_test, y_pred_binary))
    trial.set_user_attr('n_estimators', model.current_iteration())
    
    return auc, -model_size, -inference_time  # 2番目と3番目の目的は最小化したいので負の値を返す

# マルチ目的最適化の実行
print("マルチ目的最適化の開始...")
study_multi = optuna.create_study(
    study_name="lightgbm_multi_objective",
    directions=['maximize', 'maximize', 'maximize']  # すべて最大化問題として扱う
)

# 最適化の実行
study_multi.optimize(
    multi_objective, 
    n_trials=20,
    timeout=600  # 10分でタイムアウト
)

# 結果の表示
print("\n=== パレート最適解 ===")
trials = sorted(study_multi.best_trials, key=lambda t: t.values[0], reverse=True)
for i, trial in enumerate(trials):
    print(f"\n解 {i+1}:")
    print(f"  AUC: {trial.values[0]:.4f}")
    print(f"  モデルサイズ: {-trial.values[1]:.2f} KB")
    print(f"  推論時間: {-trial.values[2]:.2f} ms")
    print(f"  Accuracy: {trial.user_attrs['accuracy']:.4f}")
    print(f"  F1 Score: {trial.user_attrs['f1_score']:.4f}")
    print(f"  使用した木の数: {trial.user_attrs['n_estimators']}")
    print("  パラメータ:")
    for key, value in trial.params.items():
        print(f"    {key}: {value}")

# パレートフロントの可視化
import plotly
    
# 最適化履歴の可視化
fig = optuna.visualization.plot_pareto_front(
    study_multi,
    target_names=["AUC", "Model Size (KB)", "Inference Time (ms)"]
)
fig.show()

このような発展的な最適化も可能というわけです。

おまけ:optunaとベイズ最適化との関連性

optunaの中核を成すTPE(Tree-structured Parzen Estimator)は、ベイズ最適化の一種として捉えることができます。この章では、optunaがなぜベイズ最適化と呼ばれるのか、その理論的背景について解説します。

ベイズ最適化の基本的フレームワーク

ベイズ最適化は、以下の確率的なフレームワークに基づいています:

p(f|\mathcal{D}) = \frac{p(\mathcal{D}|f)p(f)}{p(\mathcal{D})}
ここで、fは最適化対象の目的関数、\mathcal{D}は観測データ、p(f)は目的関数の事前分布、p(f|\mathcal{D})は事後分布を表します。この枠組みにより、これまでの試行結果から目的関数の振る舞いを確率的にモデル化することが可能となります。

TPEによるベイズ最適化の実装

TPEは、従来のガウス過程に基づくベイズ最適化とは異なるアプローチを取ります。TPEでは、条件付き確率を以下のように直接モデル化します:

p(x|y) = \begin{cases} l(x) & \text{if } y < y^* \\ g(x) & \text{if } y \geq y^* \end{cases}
ここで注目すべきは、この式がベイズの定理を暗に利用していることです。l(x)g(x)は、それぞれ良い結果と悪い結果を与えるパラメータの確率密度を表し、これらは以下のベイズ的解釈が可能です:
l(x) \propto p(x|y < y^*) \propto p(y < y^*|x)p(x)
これは、良い結果を得られる確率が高いパラメータ領域を、ベイズ的に学習していることを示しています。

獲得関数の確率的解釈

TPEにおける期待改善量(EI)は、以下のベイズ的な期待値計算として解釈できます:

\text{EI}(x) = \int_{-\infty}^{y^*} (y^* - y)p(y|x)dy = y^*\gamma - \int_{-\infty}^{y^*} yp(y|x)dy
この式は、パラメータxを選択した際の期待される改善量を、確率的に評価しています。特に、p(y|x)の推定にKDEカーネル密度推定)を用いることで、ノンパラメトリックベイズ推論を実現しています。

要は、このEI(Expected Improvement)の式は、「新しいパラメータを試してみた時に、どれだけ良い結果が得られそうか」を数学的に表現したものです。具体的には、現在の最良値y*よりも良い値が得られる可能性を、確率的に重み付けして計算しています。式の中のp(y|x)は、あるパラメータxを選んだ時に得られる値yの確率分布を表していますが、これを事前に正確に知ることは不可能です。そこでTPEでは、これまでの試行結果からカーネル密度推定(KDE)という手法を使って、この確率分布を柔軟に推定します。これにより、パラメータ空間の形状に関する強い仮定を置くことなく、データから確率分布を学習することができます。

マルチフィデリティ最適化との関連

optunaの枝刈り機能は、ベイズ最適化の文脈では以下のような条件付き確率モデルとして解釈できます:

p(\text{continue}|y_t) = \int p(\text{better}|y_T)p(y_T|y_t)dy_T
ここで、y_tは時刻tでの中間評価値、y_Tは最終的な評価値を表します。この確率モデルにより、計算リソースの効率的な配分が可能となります。

要は、この式は、optunaの賢い「途中打ち切り」の仕組みを確率的に表現したものです。途中経過の値y_tから、最終的な評価値y_Tがどうなりそうかを予測し、継続するかどうかを判断します。具体的には、「現在の中間評価値y_tが与えられた時に、最終的な評価値y_Tが既存の解よりも良くなる確率」を計算しています。この確率が低い場合、つまり途中経過から見て良い結果が期待できない試行は早めに打ち切ることで、計算リソースを有望な試行により多く割り当てることができます。これは、限られた計算リソースを「勝ち目のある試行」に賢く集中投資する戦略といえます。

ハイパーパラメータの階層ベイズモデル

optunaのハイパーパラメータ最適化は、以下のような階層ベイズモデルとして表現することができます:

p(\theta, \eta|\mathcal{D}) \propto p(\mathcal{D}|\theta)p(\theta|\eta)p(\eta)
ここで、\thetaはモデルのハイパーパラメータ、\etaはTPEのメタパラメータを表します。この階層的な構造により、柔軟かつロバストな最適化が実現されています。

要は、この式は、optunaによる最適化の「二重の学習」の仕組みを表現しています。最適化の対象となるモデルのハイパーパラメータθと、それを最適化するためのTPE自体の設定パラメータηが、階層的な関係を持っています。簡単に言えば、「モデルの学習」と「学習の仕方の学習」が同時に行われているのです。データDから、モデルのハイパーパラメータθの良し悪しを学習しながら、同時にTPEのメタパラメータηも調整されていきます。この二段階の学習構造により、個々のモデルの特性や問題の難しさに応じて、最適化の方法自体も適応的に変化していくことが可能となります。

最後に

つらつらとoputunaの理論から関係なくはないがあまり関係ない話まで色々扱ってみましたがどうだったでしょうか?正直oputunaの理論に関してのみでいえば、前半部分のみさえ理解できれば十分だと思います。後半部分はおまけだと思ってください。概念的説明で分かりやすくしようとした結果、少しというか結構読みにくくなった気がしますが、そんな記事を最後までお読みいただきありがとうございました。今回の記事では、数式ごとに概念的な説明を付属するという私の記事では目新しい挑戦をしてみましたがどうだったでしょうか?
わたしもまだ初学者の域を出ないので、なにか間違いなどありましたらご指摘いただけると幸いです。概念的な説明でかぶってる部分、TPEとかは特にかぶっていると思いますが、記事を最初から一貫して書いてるというよりも、行ったり戻ったりして書いているため、こんな感じになてしまいました。読みにくかったら申し訳ないです。