19.2. ハイパーパラメータ最適化 API

方法論に入る前に、まずはさまざまな HPO アルゴリズムを効率よく実装できる基本的なコード構造について説明する。一般に、ここで扱うすべての HPO アルゴリズムは、探索スケジューリング という 2 つの意思決定プリミティブを実装する必要がある。まず、新しいハイパーパラメータ構成をサンプリングする必要がある。これは多くの場合、構成空間に対する何らかの探索を伴いる。次に、各構成について、その評価をいつ行うかをスケジュールし、どれだけのリソースを割り当てるかを決める必要がある。いったん構成の評価を始めたら、それを trial と呼ぶ。これらの決定を HPOSearcherHPOScheduler の 2 つのクラスに対応付ける。さらに、最適化プロセスを実行する HPOTuner クラスも提供する。

この scheduler と searcher の概念は、Syne Tune (Salinas et al., 2022)、Ray Tune (Liaw et al., 2018)、Optuna (Akiba et al., 2019) などの一般的な HPO ライブラリにも実装されている。

import time
from d2l import torch as d2l
from scipy import stats
config_space = {
    "learning_rate": stats.loguniform(1e-2, 1),
    "batch_size": stats.randint(32, 256),
}
initial_config = {
    "learning_rate": 0.1,
    "batch_size": 128,
}
config_space = {
    "learning_rate": stats.loguniform(1e-2, 1),
    "batch_size": stats.randint(32, 256),
}
initial_config = {
    "learning_rate": 0.1,
    "batch_size": 128,
}
config_space = {
    "learning_rate": stats.loguniform(1e-2, 1),
    "batch_size": stats.randint(32, 256),
}
initial_config = {
    "learning_rate": 0.1,
    "batch_size": 128,
}

19.2.1. Searcher

以下では searcher の基底クラスを定義する。これは sample_configuration 関数を通じて新しい候補構成を提供する。この関数を実装する簡単な方法は、 19.1 章 でランダムサーチを行ったときのように、構成を一様ランダムにサンプリングすることである。ベイズ最適化のようなより高度なアルゴリズムでは、過去の trial の性能に基づいてこれらの決定を行う。その結果、時間とともにより有望な候補をサンプリングできるようになる。過去の trial の履歴を更新するために update 関数を追加し、これをサンプリング分布の改善に利用できるようにする。

class HPOSearcher(d2l.HyperParameters):  #@save
    def sample_configuration() -> dict:
        raise NotImplementedError

    def update(self, config: dict, error: float, additional_info=None):
        pass
searcher = RandomSearcher(config_space, initial_config=initial_config)
scheduler = BasicScheduler(searcher=searcher)
tuner = HPOTuner(scheduler=scheduler, objective=hpo_objective_lenet)
tuner.run(number_of_trials=5)
searcher = RandomSearcher(config_space, initial_config=initial_config)
scheduler = BasicScheduler(searcher=searcher)
tuner = HPOTuner(scheduler=scheduler, objective=hpo_objective_lenet)
tuner.run(number_of_trials=5)
searcher = RandomSearcher(config_space, initial_config=initial_config)
scheduler = BasicScheduler(searcher=searcher)
tuner = HPOTuner(scheduler=scheduler, objective=hpo_objective_lenet)
tuner.run(number_of_trials=5)

次のコードは、前節でのランダムサーチ最適化器をこの API で実装する方法を示している。少し拡張して、最初に評価する構成を initial_config で指定できるようにし、それ以降はランダムにサンプリングする。

class RandomSearcher(HPOSearcher):  #@save
    def __init__(self, config_space: dict, initial_config=None):
        self.save_hyperparameters()

    def sample_configuration(self) -> dict:
        if self.initial_config is not None:
            result = self.initial_config
            self.initial_config = None
        else:
            result = {
                name: domain.rvs()
                for name, domain in self.config_space.items()
            }
        return result
board = d2l.ProgressBoard(xlabel="time", ylabel="error")
for time_stamp, error in zip(
    tuner.cumulative_runtime, tuner.incumbent_trajectory
):
    board.draw(time_stamp, error, "random search", every_n=1)
board = d2l.ProgressBoard(xlabel="time", ylabel="error")
for time_stamp, error in zip(
    tuner.cumulative_runtime, tuner.incumbent_trajectory
):
    board.draw(time_stamp, error, "random search", every_n=1)
board = d2l.ProgressBoard(xlabel="time", ylabel="error")
for time_stamp, error in zip(
    tuner.cumulative_runtime, tuner.incumbent_trajectory
):
    board.draw(time_stamp, error, "random search", every_n=1)

19.2.2. Scheduler

新しい trial のための構成をサンプリングするだけでなく、trial をいつ、どれだけ長く実行するかも決める必要がある。実際には、これらすべての決定は HPOScheduler によって行われ、HPOSearcher に新しい構成の選択を委ねる。suggest メソッドは、学習のための何らかのリソースが利用可能になるたびに呼び出される。searcher の sample_configuration を呼ぶだけでなく、max_epochs(つまり、モデルをどれだけ長く学習させるか)などのパラメータも決めることがある。update メソッドは、trial が新しい観測値を返すたびに呼び出される。

class HPOScheduler(d2l.HyperParameters):  #@save
    def suggest(self) -> dict:
        raise NotImplementedError

    def update(self, config: dict, error: float, info=None):
        raise NotImplementedError

ランダムサーチを実装する場合も、他の HPO アルゴリズムを実装する場合も、新しいリソースが利用可能になるたびに新しい構成をスケジュールする基本的な scheduler だけで十分である。

class BasicScheduler(HPOScheduler):  #@save
    def __init__(self, searcher: HPOSearcher):
        self.save_hyperparameters()

    def suggest(self) -> dict:
        return self.searcher.sample_configuration()

    def update(self, config: dict, error: float, info=None):
        self.searcher.update(config, error, additional_info=info)

19.2.3. Tuner

最後に、scheduler/searcher を実行し、結果をいくつか管理するコンポーネントが必要である。以下のコードは、HPO trial を逐次的に実行し、1 つの学習ジョブを順番に評価するもので、基本例として使える。後ほど、よりスケーラブルな分散 HPO のケースでは Syne Tune を使う。

class HPOTuner(d2l.HyperParameters):  #@save
    def __init__(self, scheduler: HPOScheduler, objective: callable):
        self.save_hyperparameters()
        # Bookeeping results for plotting
        self.incumbent = None
        self.incumbent_error = None
        self.incumbent_trajectory = []
        self.cumulative_runtime = []
        self.current_runtime = 0
        self.records = []

    def run(self, number_of_trials):
        for i in range(number_of_trials):
            start_time = time.time()
            config = self.scheduler.suggest()
            print(f"Trial {i}: config = {config}")
            error = self.objective(**config)
            error = float(d2l.numpy(error.cpu()))
            self.scheduler.update(config, error)
            runtime = time.time() - start_time
            self.bookkeeping(config, error, runtime)
            print(f"    error = {error}, runtime = {runtime}")

19.2.4. HPO アルゴリズムの性能管理

どの HPO アルゴリズムでも、私たちが主に関心を持つのは、与えられた壁時計時間における最良性能の構成(incumbent と呼ぶ)と、その検証誤差である。そのため、各反復ごとの runtime を追跡する。これには、評価を実行する時間(objective の呼び出し)と、意思決定を行う時間(scheduler.suggest の呼び出し)の両方が含まれる。以下では、cumulative_runtimeincumbent_trajectory をプロットして、scheduler(および searcher)で定義される HPO アルゴリズムの any-time performance を可視化する。これにより、最適化器が見つけた構成がどれだけ良いかだけでなく、それをどれだけ速く見つけられるかも定量化できる。

@d2l.add_to_class(HPOTuner)  #@save
def bookkeeping(self, config: dict, error: float, runtime: float):
    self.records.append({"config": config, "error": error, "runtime": runtime})
    # Check if the last hyperparameter configuration performs better
    # than the incumbent
    if self.incumbent is None or self.incumbent_error > error:
        self.incumbent = config
        self.incumbent_error = error
    # Add current best observed performance to the optimization trajectory
    self.incumbent_trajectory.append(self.incumbent_error)
    # Update runtime
    self.current_runtime += runtime
    self.cumulative_runtime.append(self.current_runtime)

19.2.5. 例: 畳み込みニューラルネットワークのハイパーパラメータ最適化

ここでは、新しく実装したランダムサーチを使って、 7.6 章LeNet 畳み込みニューラルネットワークの バッチサイズ学習率 を最適化する。まず、目的関数を定義する。ここでも検証誤差を用いる。

def hpo_objective_lenet(learning_rate, batch_size, max_epochs=10):  #@save
    model = d2l.LeNet(lr=learning_rate, num_classes=10)
    trainer = d2l.HPOTrainer(max_epochs=max_epochs, num_gpus=1)
    data = d2l.FashionMNIST(batch_size=batch_size)
    model.apply_init([next(iter(data.get_dataloader(True)))[0]], d2l.init_cnn)
    trainer.fit(model=model, data=data)
    validation_error = trainer.validation_error()
    return validation_error

構成空間も定義する必要がある。さらに、最初に評価する構成は 7.6 章 で使ったデフォルト設定とする。

config_space = {
    "learning_rate": stats.loguniform(1e-2, 1),
    "batch_size": stats.randint(32, 256),
}
initial_config = {
    "learning_rate": 0.1,
    "batch_size": 128,
}

これでランダムサーチを開始できる。

searcher = RandomSearcher(config_space, initial_config=initial_config)
scheduler = BasicScheduler(searcher=searcher)
tuner = HPOTuner(scheduler=scheduler, objective=hpo_objective_lenet)
tuner.run(number_of_trials=5)
    error = 0.42863988876342773, runtime = 69.36778783798218
../_images/output_hyperopt-api_529020_58_1.svg
../_images/output_hyperopt-api_529020_58_2.svg
../_images/output_hyperopt-api_529020_58_3.svg
../_images/output_hyperopt-api_529020_58_4.svg
../_images/output_hyperopt-api_529020_58_5.svg

以下では、ランダムサーチの any-time performance を得るために、incumbent の最適化軌跡をプロットする。

board = d2l.ProgressBoard(xlabel="time", ylabel="error")
for time_stamp, error in zip(
    tuner.cumulative_runtime, tuner.incumbent_trajectory
):
    board.draw(time_stamp, error, "random search", every_n=1)
../_images/output_hyperopt-api_529020_60_0.svg

19.2.6. HPO アルゴリズムの比較

学習アルゴリズムやモデルアーキテクチャと同様に、異なる HPO アルゴリズムをどのように比較するのが最善かを理解することは重要である。各 HPO 実行は、2 つの主要なランダム性の源に依存する。1 つは、ランダムな重み初期化やミニバッチの順序付けなど、学習過程に由来するランダムな効果である。もう 1 つは、ランダムサーチにおけるランダムサンプリングのような、HPO アルゴリズム自体に内在するランダム性である。したがって、異なるアルゴリズムを比較する際には、各実験を複数回実行し、乱数生成器の異なるシードに基づく複数回の反復全体で、平均や中央値などの統計量を報告することが重要である。

これを示すために、フィードフォワードニューラルネットワークのハイパーパラメータ調整において、ランダムサーチ(19.1.2 章 を参照)とベイズ最適化 (Snoek et al., 2012) を比較する。各アルゴリズムは、異なる乱数シードで \(50\) 回評価された。実線はこの \(50\) 回の反復にわたる incumbent の平均性能を示し、破線は標準偏差を示す。ランダムサーチとベイズ最適化はおよそ 1000 秒まではほぼ同程度の性能であるが、ベイズ最適化は過去の観測を利用してより良い構成を特定できるため、その後はすぐにランダムサーチを上回ることがわかる。

../_images/example_anytime_performance.svg

図 19.2.1 2 つのアルゴリズム A と B を比較するための any-time performance プロットの例。

19.2.7. まとめ

この節では、この章で扱うさまざまな HPO アルゴリズムを実装するための、シンプルでありながら柔軟なインターフェースを示した。類似のインターフェースは、一般的なオープンソースの HPO フレームワークにも見られる。また、HPO アルゴリズムをどのように比較できるか、そして注意すべき潜在的な落とし穴についても見た。

19.2.8. 演習

  1. この演習の目的は、少し難しめの HPO 問題の目的関数を実装し、より現実的な実験を行うことである。 5.6 章 で実装した 2 層隠れ層 MLP DropoutMLP を使う。

    1. 目的関数をコード化せよ。これはモデルのすべてのハイパーパラメータと batch_size に依存する必要がある。max_epochs=50 を使用せよ。ここでは GPU は役に立たないため、num_gpus=0 とする。ヒント: hpo_objective_lenet を修正せよ。

    2. 妥当な探索空間を選べ。num_hiddens_1num_hiddens_2\([8, 1024]\) の整数、dropout 値は \([0, 0.95]\)batch_size\([16, 384]\) とする。scipy.stats の適切な分布を使って config_space のコードを示せ。

    3. この例で number_of_trials=20 としてランダムサーチを実行し、結果をプロットせよ。まず 5.6 章 のデフォルト構成、すなわち initial_config = {'num_hiddens_1': 256, 'num_hiddens_2': 256, 'dropout_1': 0.5, 'dropout_2': 0.5, 'lr': 0.1, 'batch_size': 256} を最初に評価することを忘れないこと。

  2. この演習では、過去のデータに基づいて意思決定を行う新しい searcher(HPOSearcher のサブクラス)を実装する。これは probab_localnum_init_random というパラメータに依存する。sample_configuration メソッドは次のように動作する。最初の num_init_random 回の呼び出しでは、RandomSearcher.sample_configuration と同じことを行う。それ以外では、確率 1 - probab_localRandomSearcher.sample_configuration と同じことを行う。それ以外では、これまでで最小の検証誤差を達成した構成を選び、そのハイパーパラメータの 1 つをランダムに選んで、その値を RandomSearcher.sample_configuration と同様にランダムにサンプリングするが、他の値はそのままにする。この 1 つのハイパーパラメータだけが異なり、それ以外はこれまでの最良構成と同一である構成を返せ。

    1. この新しい LocalSearcher をコード化せよ。ヒント: searcher の構築時には引数として config_space が必要である。RandomSearcher 型のメンバーを使っても構わない。また、update メソッドも実装する必要がある。

    2. 前の演習の実験を、RandomSearcher の代わりにこの新しい searcher を使って再実行せよ。probab_localnum_init_random のさまざまな値を試せ。ただし、異なる HPO 手法を適切に比較するには、実験を複数回繰り返し、できれば複数のベンチマークタスクを考慮する必要があることに注意せよ。