KerasTunerでのHyperBandハイパーパラメータ最適化

[KerasTuner](https://keras.io/keras_tuner/)を使ってHyperBandのハイパーパラメータチューニングをしたので、その記録です。概要レベルでしか調査・理解していません。以前使ったHyperasとAPIの呼び方自体はあまり変わりませんが、探索アルゴリズムが違いますし、Kerasに対してはとても使いやすいです。
※Hyperasに関しては[記事「Hyperasを使ったKerasハイパーパラメータチューニング」](https://qiita.com/FukuharaYohei/items/c8cc9d80452350b449eb)に書いています。
今回は、[言語処理100本ノック 2020 (Rev2)](https://nlp100.github.io/ja/)の[「第9章: RNN, CNN」](https://nlp100.github.io/ja/ch09.html)の[88本目「パラメータチューニング」](https://nlp100.github.io/ja/ch09.html#88-%E3%83%91%E3%83%A9%E3%83%A1%E3%83%BC%E3%82%BF%E3%83%81%E3%83%A5%E3%83%BC%E3%83%8B%E3%83%B3%E3%82%B0)を解くために調べました。実際に使った内容は以下の記事にあります。

https://qiita.com/FukuharaYohei/items/b71fe71f8dda8c9c08a0

# 参考リンク
当記事には全然書きませんが、リンクは載せておきます。

|リンク|備考|
|:--|:--|
|[機械学習におけるハイパーパラメータ最適化の理論と実践](https://logmi.jp/tech/articles/322678)|ハイパーパラメータ最適化の非常に素晴らしい記事|
|[Hyperband（Banditを基礎とした効率的なRandom Search）のアルゴリズム](https://qiita.com/nabenabe0928/items/7575abc084a981c03461)|HyperBand解説|
|[Successive Halvingの性能解析](https://cyberagent.ai/blog/research/1036/)|HyperBandのベースのアルゴリズム解説|
|[HyperBand and BOHB: Understanding State of the Art Hyperparameter Optimization Algorithms](https://neptune.ai/blog/hyperband-and-bohb-understanding-state-of-the-art-hyperparameter-optimization-algorithms)|解説の図がわかりやすい|
|[【公式ガイド】Getting started with KerasTuner](https://keras.io/guides/keras_tuner/getting_started/)||
|[【公式ガイド】Visualize the hyperparameter tuning process](https://keras.io/guides/keras_tuner/visualize_tuning/)||
|[【公式 API】Hyperband Tuner](https://keras.io/api/keras_tuner/tuners/hyperband/)||
|[【公式チュートリアル】Keras Tuner の基礎](https://www.tensorflow.org/tutorials/keras/keras_tuner?hl=ja)||

# 環境
Google Colaboratory使っています。[KerasTuner](https://keras.io/keras_tuner/)は、Google Colaboratoryにプリインストールされておらず、Google内部での立ち位置悪い？

|種類|バージョン|内容|
|:--|:--|:--|
|Python|3.7.12|Google Colaboratoryのバージョン|
|tensorflow|2.7.0|ディープラーニング|
|keras-tuner|1.1.0|今回のメイン|
|numpy|1.19.5|行列処理|

# ハイパーパラメータ最適化とHyperBand
[記事「機械学習におけるハイパーパラメータ最適化の理論と実践」](https://logmi.jp/tech/articles/322678)が最初に読むといいです。
ハイパーパラメータ最適化を分類すると下図のとおりです(引用元: [ Automated Machine Learning: State-of-The-Art and Open Challenges](https://arxiv.org/abs/1906.02287))。
※ "Successive Having" ではなく"Successive Halving"が正解
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134273/febfb70b-fa3b-2b55-f193-c652ce2a0e11.png)
上の"Black-Box Optimazation"では、機械学習モデルの中身はわからなくても結果だけを見てハイパーパラメータ最適化をします。
下の"Multi-Fidelty Optimization"は、機械学習の訓練途中経過を見ながらハイパーパラメータ最適化をします。

# 使ってみた
## プログラム
Jupyterのプログラム全体です。マジックコマンドも含んでいます。

```python
!pip install keras-tuner
%load_ext tensorboard

from tensorflow import keras
from tensorflow.keras import layers
import keras_tuner as kt
import numpy as np

def build_model(hp):
    model = keras.Sequential()
    model.add(layers.Flatten())
    model.add(layers.Dense(units=32, activation=hp.Choice("activation", ["relu", "tanh"])))
    model.add(layers.Dropout(rate=0.25))
    model.add(layers.Dense(10, activation="softmax"))
    learning_rate = hp.Float("lr", min_value=1e-4, max_value=1e-3, sampling="log")
    model.compile(
        optimizer=keras.optimizers.Adam(learning_rate=learning_rate),
        loss="categorical_crossentropy",
        metrics=["accuracy"],
    )
    return model

tuner = kt.Hyperband(
    hypermodel=build_model,
    objective='val_loss',
    max_epochs=10,
    overwrite=True,
    directory="./",
    project_name="helloworld",
)


(x, y), (x_test, y_test) = keras.datasets.mnist.load_data()

x_train = x[:-50000]  #末尾50000件を除外して10000件
x_val = x[-1000:]
y_train = y[:-50000]
y_val = y[-1000:]

x_train = np.expand_dims(x_train, -1).astype("float32") / 255.0
x_val = np.expand_dims(x_val, -1).astype("float32") / 255.0
x_test = np.expand_dims(x_test, -1).astype("float32") / 255.0

num_classes = 10
y_train = keras.utils.to_categorical(y_train, num_classes)
y_val = keras.utils.to_categorical(y_val, num_classes)
y_test = keras.utils.to_categorical(y_test, num_classes)

%tensorboard --logdir /tmp/tb_logs

tuner.search_space_summary()

tuner.search(x_train, y_train, 
             validation_data=(x_val, y_val),
             callbacks=[keras.callbacks.TensorBoard("/tmp/tb_logs")])

# Get the top 2 models.
models = tuner.get_best_models(num_models=2)
best_model = models[0]
# Build the model.
# Needed for `Sequential` without specified `input_shape`.
best_model.build(input_shape=(None, 28, 28))
best_model.summary()

tuner.results_summary()
```

## モデル
ハイパーパラメータを受け取り、Kerasモデルを返す関数`build_model`を定義します。

### シンプルなモデル
今回使ったシンプルなモデルです。

```python
def build_model(hp):
    model = keras.Sequential()
    model.add(layers.Flatten())
    model.add(layers.Dense(units=32, activation=hp.Choice("activation", ["relu", "tanh"])))
    model.add(layers.Dropout(rate=0.25))
    model.add(layers.Dense(10, activation="softmax"))
    learning_rate = hp.Float("lr", min_value=1e-4, max_value=1e-3, sampling="log")
    model.compile(
        optimizer=keras.optimizers.Adam(learning_rate=learning_rate),
        loss="categorical_crossentropy",
        metrics=["accuracy"],
    )
    return model
```

### いろいろ使った場合
いろいろな種類使うと以下のように書きます。`Int`, `Choice`, `Boolean`, `Float`で選択肢を定義できます(他にもいくつかあり[HyperParameters](https://keras.io/api/keras_tuner/hyperparameters/)参照)。最初は単純にしたかったので、以下のコードは非採用。

```python
def build_model(hp):
    model = keras.Sequential()
    model.add(layers.Flatten())
    # Tune the number of layers.
    for i in range(hp.Int("num_layers", 1, 2)):
        model.add(
            layers.Dense(
                # Tune number of units separately.
                units=hp.Int(f"units_{i}", min_value=32, max_value=64, step=32),
                activation=hp.Choice("activation", ["relu", "tanh"]),
            )
        )
    if hp.Boolean("dropout"):
        model.add(layers.Dropout(rate=0.25))
    model.add(layers.Dense(10, activation="softmax"))
    learning_rate = hp.Float("lr", min_value=1e-4, max_value=1e-3, sampling="log")
    model.compile(
        optimizer=keras.optimizers.Adam(learning_rate=learning_rate),
        loss="categorical_crossentropy",
        metrics=["accuracy"],
    )
    return model
```

### 依存関係あり
チューニング対象のハイパーパラメータ間に依存関係がある場合です。`parent_name`と`parent_values`を使います。試していないのですが、親→子→孫の3階層以上の依存関係も定義できるのでないでしょうか。

```python
def build_model(hp):
    model = keras.Sequential()
    model.add(layers.Flatten())
    model.add(layers.Dense(units=32, activation='relu'))
    if hp.Boolean("dropout"):
        dropout_rate = hp.Float("dropout_rate", 
                                 min_value=0.1, 
                                 max_value=0.4, 
                                 sampling="linear",
                                 parent_name="dropout", parent_values=True)
        model.add(layers.Dropout(rate=dropout_rate))

    model.add(layers.Dense(10, activation="softmax"))    
    model.compile(
        optimizer=keras.optimizers.Adam(),
        loss="categorical_crossentropy",
        metrics=["accuracy"],
    )
    return model
```

依存関係ありの別解として以下のような書き方もできます。[`conditional_scope`関数](https://keras.io/api/keras_tuner/hyperparameters/#conditionalscope-method)を使ってスコープをきります。こっちの方がスッキリする場合も多いかと思います。

```python
def build_model(hp):
    model = keras.Sequential()
    model.add(layers.Flatten())
    model.add(layers.Dense(units=32, activation='relu'))
    if hp.Boolean("dropout"):
        with hp.conditional_scope("dropout", True):
            dropout_rate = hp.Float("dropout_rate", 
                                 min_value=0.1, 
                                 max_value=0.4, 
                                 sampling="linear")
            model.add(layers.Dropout(rate=dropout_rate))

    model.add(layers.Dense(10, activation="softmax"))    
    model.compile(
        optimizer=keras.optimizers.Adam(),
        loss="categorical_crossentropy",
        metrics=["accuracy"],
    )
    return model
```


## ハイパーパラメータ探索tuner定義
ハイパーパラメータを探索する`tuner`定義します。

```python
tuner = kt.Hyperband(
    hypermodel=build_model,
    objective='val_loss',
    max_epochs=10,
    directory="./",
    project_name="helloworld",
)
```

以下がパラメータです。

- hypermodel: 先程定義したモデルを返す関数を渡します
- objective: 最適化のための指標
- max_epochs: 最大のエポック数。収束するエポックより多いエポック数の指定と、EalyStopping使用が推奨。[公式API](https://keras.io/api/keras_tuner/tuners/hyperband/)の記載 → *It is recommended to set this to a value slightly higher than the expected epochs to convergence for your largest Model, and to use early stopping during training (for example, via tf.keras.callbacks.EarlyStopping).*
- directory: 途中経過やログを保存するディレクトリ
- project_name: ディレクトリ内に保存されるサブディレクトリ名

今回はパラメータ指定していませんが、`factor`の数を大きくするほど、枝刈り回数が増えます。[具体例](https://qiita.com/nabenabe0928/items/7575abc084a981c03461#%E5%85%B7%E4%BD%93%E4%BE%8B)および[Hyperband Algorithm](https://homes.cs.washington.edu/~jamieson/hyperband.html)を見て、以下のコードをサンプルで動かしながら理解しました。コード中の`max_iter`がパラメータの`max_epochs`を意味し、`eta`が`factor`を意味します。

```python
import math

max_iter = 81  # maximum iterations/epochs per configuration
eta = 3 # defines downsampling rate (default=3)
logeta = lambda x: math.log(x)/math.log(eta)
s_max = int(logeta(max_iter))  # number of unique executions of Successive Halving (minus one)
B = (s_max+1)*max_iter  # total number of iterations (without reuse) per execution of Succesive Halving (n,r)

print(f'{s_max}, {B}')

#### Begin Finite Horizon Hyperband outlerloop. Repeat indefinetely.
for s in reversed(range(s_max+1)):
    print(f's: {s}')

    n = int(math.ceil(int(B/max_iter/(s+1))*eta**s)) # initial number of configurations
    r = max_iter*eta**(-s) # initial number of iterations to run configurations for
    print(f'n: {n}, r: {r}')

    #### Begin Finite Horizon Successive Halving with (n,r)
#    T = [ get_random_hyperparameter_configuration() for i in range(n) ] 
#    for i in range(s+1):
#        # Run each of the n_i configs for r_i iterations and keep best n_i/eta
#        n_i = n*eta**(-i)
#        r_i = r*eta**(i)
#        val_losses = [ run_then_return_val_loss(num_iters=r_i,hyperparameters=t) for t in T ]
#        T = [ T[i] for i in argsort(val_losses)[0:int( n_i/eta )] ]
    #### End Finite Horizon Successive Halving with (n,r)
```


実行するとディレクトリ内に以下のように途中経過やログが保存。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134273/49397e15-0d5c-260d-d72c-3834d7b91e96.png)


## 探索空間
`search_space_summary`で探索空間を見ることができます。

```python
> tuner.search_space_summary()

Search space summary
Default search space size: 2
activation (Choice)
{'default': 'relu', 'conditions': [], 'values': ['relu', 'tanh'], 'ordered': False}
lr (Float)
{'default': 0.0001, 'conditions': [], 'min_value': 0.0001, 'max_value': 0.001, 'step': None, 'sampling': 'log'}
```

## TensorBoard
TensorBoardを実行中に見るために探索開始前に起動しておきます。[Visualize the hyperparameter tuning process](https://keras.io/guides/keras_tuner/visualize_tuning/)を参考にしました。

```bash
%load_ext tensorboard
%tensorboard --logdir /tmp/tb_logs
```

### SCALARS
KerasTunerと直接関係ないですが、Epochごとに各ハイパーパラメータ選択時の指標がわかります。途中で打ち切られた組み合わせもわかります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134273/29f1285b-3259-cae3-b542-5f60740bcade.png)

### HPARAMS
メニューHPARAMSは共通して左側でハイパーパラメータでフィルタできます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134273/ff2bbe54-8892-876e-e684-c5b3272cf63c.png)


#### TABLE VIEW
一覧形式のView
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134273/7497106b-630a-d664-a482-d6e360182f78.png)

#### PARALLEL COORDINATES VIEW
ハイパーパラメータの選択肢と評価指標をグラフで見られるView
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134273/0cae65d3-b202-ed3b-5672-dfc8152215ef.png)

#### SCATTER PLOT MATRIX VIEW
散布図表示。散布図上の点を選択すると、下側に具体的な値をグラフを表示してくれる(グラフはこの図では省略)。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134273/aa536ae1-ccee-69ee-f8e7-7535f995c04b.png)


## 探索開始
`fit`関数と同じパラメータで探索開始します。ここでEarlyStoppingも本当はCallback関数に追加しておくべきなのでしょう。

```python
tuner.search(x_train, y_train, 
             validation_data=(x_val, y_val),
             callbacks=[keras.callbacks.TensorBoard("/tmp/tb_logs")])
```

実行中の画面です。
![9720841A-6412-4EBC-ABE5-CB3A4ADC7E7E.GIF](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134273/27656b8e-b8ad-b0ae-7ba5-e0bddb195e67.gif)

実行後に以下の情報を出力。

```
Trial 30 Complete [00h 00m 21s]
val_loss: 0.3008600175380707

Best val_loss So Far: 0.18738491833209991
Total elapsed time: 00h 04m 29s
INFO:tensorflow:Oracle triggered exit
```

## 最適モデル使用
`get_best_models`で最適なモデルを取得できるようです。あまり調べていないです。

```python
# Get the top 2 models.
models = tuner.get_best_models(num_models=2)
best_model = models[0]
# Build the model.
# Needed for `Sequential` without specified `input_shape`.
best_model.build(input_shape=(None, 28, 28))
best_model.summary()
```

```:summary関数結果
Model: "sequential"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 flatten (Flatten)           (None, 784)               0         
                                                                 
 dense (Dense)               (None, 32)                25120     
                                                                 
 dropout (Dropout)           (None, 32)                0         
                                                                 
 dense_1 (Dense)             (None, 10)                330       
                                                                 
=================================================================
Total params: 25,450
Trainable params: 25,450
Non-trainable params: 0
_________________________________________________________________
```

## 結果出力
`results_summary`関数で上位10の結果出力。1つ1つの行を分けてほしい。

```python
> tuner.results_summary()

Results summary
Results in ./helloworld
Showing 10 best trials
Objective(name='val_loss', direction='min')
Trial summary
Hyperparameters:
activation: relu
lr: 0.0009570214467764447
tuner/epochs: 10
tuner/initial_epoch: 0
tuner/bracket: 0
tuner/round: 0
Score: 0.19376720488071442
Trial summary
Hyperparameters:
activation: relu
lr: 0.0006136739662904565
tuner/epochs: 10
tuner/initial_epoch: 4
tuner/bracket: 2
tuner/round: 2
tuner/trial_id: b51f3f0bd0ace0b99f48ef03db9a93e6
Score: 0.21589896082878113
Trial summary
Hyperparameters:
activation: relu
lr: 0.0002824952294528987
tuner/epochs: 10
tuner/initial_epoch: 0
tuner/bracket: 0
tuner/round: 0
Score: 0.22479817271232605
Trial summary
Hyperparameters:
activation: tanh
lr: 0.0006141098124590815
tuner/epochs: 10
tuner/initial_epoch: 4
tuner/bracket: 1
tuner/round: 1
tuner/trial_id: c70f7a6051127a4d37ca100acb443b27
Score: 0.23398013412952423
Trial summary
Hyperparameters:
activation: tanh
lr: 0.0006736282432334351
tuner/epochs: 10
tuner/initial_epoch: 4
tuner/bracket: 2
tuner/round: 2
tuner/trial_id: 2ec22d95503871c8cad7111ee23a48c0
Score: 0.23797860741615295
Trial summary
Hyperparameters:
activation: tanh
lr: 0.00033550426727547506
tuner/epochs: 10
tuner/initial_epoch: 0
tuner/bracket: 0
tuner/round: 0
Score: 0.23959438502788544
Trial summary
Hyperparameters:
activation: relu
lr: 0.000424709121934671
tuner/epochs: 10
tuner/initial_epoch: 4
tuner/bracket: 1
tuner/round: 1
tuner/trial_id: 82ea7550018d9b5f23dfe7a5ee369b10
Score: 0.2398107945919037
Trial summary
Hyperparameters:
activation: relu
lr: 0.00020633386682052565
tuner/epochs: 10
tuner/initial_epoch: 0
tuner/bracket: 0
tuner/round: 0
Score: 0.24579833447933197
Trial summary
Hyperparameters:
activation: relu
lr: 0.000424709121934671
tuner/epochs: 4
tuner/initial_epoch: 0
tuner/bracket: 1
tuner/round: 0
Score: 0.26777052879333496
Trial summary
Hyperparameters:
activation: tanh
lr: 0.0006141098124590815
tuner/epochs: 4
tuner/initial_epoch: 0
tuner/bracket: 1
tuner/round: 0
Score: 0.26802679896354675
```