Optunaを用いたPyTorchにおけるハイパーパラメータチューニング

初投稿となります．

#はじめに
DeepLearningの重要な要素の一つとして__ハイパーパラメータ__があります．
重みといった勾配法などのアルゴリズムによって最適化を行うものがパラメータであることに対し，__人の手によって最適化を行うもの__がハイパーパラメータとなります．ハイパーパラメータは，最適化手法やその学習率，ニューロンの数やその層数，活性化関数，ドロップアウト率など様々なものが挙げられ，これを手動で調整するとなると試行錯誤を繰り返さなければなりません．

そこで登場するのが「__Optuna__」です．

今回は，__PyTorchによるMNISTの画像認識__を通して，このOptunaの使い方の説明をしたいと思います．

また，Optunaについて調査した際，__PyToch上で畳み込み層数のチューニング__を行なったという記事が見当たらなかったため，せっかくなので「畳み込み層数」をはじめとするハイパーパラメータのチューニングを行いたいと思います．

#Optunaとは
[Optuna](https://optuna.org/)とは，Preferred Networks発の 機械学習におけるハイパーパラメータの自動最適化（チューニング）を行うフレームワークのことです．
目的関数に対して，適当なハイパーパラメータを使って評価を繰り返し，その目的関数が__最小__となる最適なハイパーパラメータを探し出します．

#Optunaの使い方
Optunaの使用方法です．
詳しく知りたい方は，[公式ドキュメント](https://optuna.readthedocs.io/en/stable/)をご覧ください．

###インストール
Optunaはpipコマンドによりインストールすることができます．

```
pip install optuna
```

###目的関数の定義
上記したように，まず最小化する目的関数を設定するのですが，これはobjective()という関数で定義します．引数として`trial`オブジェクトを与えます．

```Python
def objective(trial):
    
    return 
```

###trialオブジェクトの定義
`trial`オブジェクトは，Optunaに実装されているクラスのインスタンスであり，最適化したいハイパーパラメータの定義に用います．適当な範囲から値をサンプリングしてくるのですが，過去に探索したパラメータの情報を保持し，それを元に次に探索する値を決定します．

`trial`の定義ですが，以下のように行うことができます．

```Python
#カテゴリの試行を行うパラメータ
param1 = trial.suggest_categorical(name, choices)

#整数値の試行を行うパラメータ
param2 = trial.suggest_int(name, low, high)

#連続値の試行を行うパラメータ
param3 = trial.suggest_uniform(name, low, high)

#離散値の試行を行うパラメータ
param4 = trial.suggest_discrete_uniform(name, low, high, q)

#対数値の試行を行うパラメータ
param5 = trial.suggest_loguniform(name, low, high)
```
`name`はstr型でありパラメータの名前を指定し，`choices`はlist型であり複数のカテゴリ名の選択肢として提示する引数となります．`low`，`high`ではパラメータの最小値と最大値を提示し，`q`によってその値間を試行する間隔を設定します．

他にも様々なメソッドがありますが，今回は[実例](https://qiita.com/Yushi1958/items/cd22ade638f7e292e520#%E5%AE%9F%E4%BE%8B%EF%BC%BFmnist%E7%94%BB%E5%83%8F%E8%AA%8D%E8%AD%98%E3%81%AE%E3%83%8F%E3%82%A4%E3%83%91%E3%83%BC%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)で使用するもののみを紹介しました．

###studyオブジェクトの定義
ハイパーパラメータを求めるには`study`オブジェクトを作る必要があります．
この`study`オブジェクトに最適化の結果が保持されます．

```Python
study = optuna.creat_study()
```

そして，optimizeメソッドを用いることにより，最適なハイパーパラメータを求めることができます．

```Python
study.optimize(objective, n_trials = 100)
```
optimizeメソッドの第1引数は目的関数であるobjective関数となり，第2引数は試行回数となります．tudyオブジェクト内でtrialの処理が行われるため，optimizeメソッドを実行するだけで自動的に目的関数の最小値およびそのハイパーパラメータを探してくれます．

最適化の結果は以下で見ることができます．

```Python
#最適化したハイパーパラメータの結果
study.best_params

#最適化後の目的関数の値
study.best_value

#全試行過程
study.trials
```

簡単ではありましたが，使用方法の説明は以上です．

#実例＿MNIST画像認識のハイパーパラメータチューニング
それでは，実際にMNISTの画像認識を通してハイパーパラメータのチューニングを行いたいと思います．

###実行環境
- Python : 3.6.8
- PyTorch : 0.4.1
- Optuna : 0.12.0

###チューニングを行うハイパーパラメータ
- 畳み込み層の数（3 ~ 7）
- 各畳み込み層のフィルタ数（16, 32, 48, ..., 128）
- 全結合層のユニット数（100, 200, 300, 400, 500）
- 活性化関数（ReLU, ELU）
- 最適化手法（Adam, MomentumSGD, rmsprop）
- 学習率（adam_lr(1e-10 ~ 1e-3), momentum_sgd_lr(1e-5 ~ 1e-1)）
- weight_decay（1e-10 ~ 1e-3）

パラメータチューニングを行うにあたって，自分は「[公式リポジトリ](https://github.com/pfnet/optuna)」と「[PyTorch+OptunaでMNIST](https://qiita.com/eduidl/items/9c684a6467a63acd28f2)」の2つを参考にさせていただきました．こちらもぜひ見てみてください．

~~今回のコードは[Google Drive](https://drive.google.com/file/d/1t8K5H_7b2-9NR-pL8n9r4Ei5-YPPa6sy/view?usp=sharing)にアップロードしてあるので，実際に動かしたい方はそちらを参照してください．~~
ノートブックを[Git Hub](https://github.com/Yushi-Goto/optuna-with-pytorch.git)に上げ直しました．


###データセット準備

```Python
from torch.utils.data import DataLoader
from torchvision.datasets import MNIST
from torchvision import transforms
import numpy as np


BATCHSIZE = 128

transform = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,))])

train_set = MNIST(root='./data', train=True, download=True, transform=transform)
train_loader = DataLoader(train_set, batch_size=BATCHSIZE, shuffle=True, num_workers=2)

test_set = MNIST(root='./data', train=False, download=True, transform=transform)
test_loader = DataLoader(test_set, batch_size=BATCHSIZE, shuffle=False, num_workers=2)

classes = tuple(np.linspace(0, 9, 10, dtype=np.uint8))
```
###モデル定義
```Python
import torch
import torch.nn as nn
import torch.nn.functional as F

import optuna
optuna.logging.disable_default_handler()


#モデルの定義

#入力画像の高さと幅，畳み込み層のカーネルサイズ
in_height = 28
in_width = 28
kernel = 3
class Net(nn.Module):
  def __init__(self, trial, num_layer, mid_units, num_filters):
    super(Net, self).__init__()
    self.activation = get_activation(trial)
    #第1層
    self.convs = nn.ModuleList([nn.Conv2d(in_channels=1, out_channels=num_filters[0], kernel_size=3)])
    self.out_height = in_height - kernel +1
    self.out_width = in_width - kernel +1
    #第2層以降
    for i in range(1, num_layer):
      self.convs.append(nn.Conv2d(in_channels=num_filters[i-1], out_channels=num_filters[i], kernel_size=3))
      self.out_height = self.out_height - kernel + 1
      self.out_width = self.out_width - kernel +1
    #pooling層
    self.pool = nn.AvgPool2d(kernel_size=2, stride=2)
    self.out_height = int(self.out_height / 2)
    self.out_width = int(self.out_width / 2)
    #線形層
    self.out_feature = self.out_height * self.out_width * num_filters[num_layer - 1]
    self.fc1 = nn.Linear(in_features=self.out_feature, out_features=mid_units) 
    self.fc2 = nn.Linear(in_features=mid_units, out_features=10)
    
  def forward(self, x):
    for i, l in enumerate(self.convs):
      x = l(x)
      x = self.activation(x)
    x = self.pool(x)
    x = x.view(-1, self.out_feature)
    x = self.fc1(x)
    x = self.fc2(x)
    return F.log_softmax(x, dim=1)
```

畳み込み層はPyTorchの`nn.ModuleList()`を用いてリスト形式で定義します．

ここで，nn.Conv2d()は第1引数に「入力チャンネル数」を指定する必要があるのですが，第1層のみそれに「入力画像のチャンネル数」（今回はMNISTを用いたため1）を指定しなければならないため，あらかじめnn.ModuleList()を作る際に定義しておきます．
第2層以降は第1引数として「前層の出力チャンネル数」を指定すれば良いのでfor文より繰り返しリストに追加していきます．

また，各畳み込み層およびプーリング層を定義する際，それら出力の__高さ__`self.out_height`と__幅__`self.out_width`を計算させています．これは，全結合層nn.Linear()の定義時に「入力次元数」を指定しなければならないためです．全結合層の入力は1次元である必要があり（厳密にはバッチサイズも配慮するため2次元），直前の出力の高さself.out_feature，幅self.out_feature，チャンネル数num_filters[num_layer-1]をかけた値`self.out_feature`を求め，これをviewメソッドに与え特徴マップを整形します．

###Train & Test

```Python
def train(model, device, train_loader, optimizer):
  model.train()
  for batch_idx, (data, target) in enumerate(train_loader):
        data, target = data.to(device), target.to(device)
        optimizer.zero_grad()
        output = model(data)
        loss = F.nll_loss(output, target)
        loss.backward()
        optimizer.step()
        
def test(model, device, test_loader):
    model.eval()
    correct = 0
    with torch.no_grad():
        for data, target in test_loader:
            data, target = data.to(device), target.to(device)
            output = model(data)
            pred = output.max(1, keepdim=True)[1]
            correct += pred.eq(target.view_as(pred)).sum().item()
    return 1 - correct / len(test_loader.dataset)
```

###最適化手法の試行

```Python
import torch.optim as optim

def get_optimizer(trial, model):
  optimizer_names = ['Adam', 'MomentumSGD', 'rmsprop']
  optimizer_name = trial.suggest_categorical('optimizer', optimizer_names)
  weight_decay = trial.suggest_loguniform('weight_decay', 1e-10, 1e-3)
  if optimizer_name == optimizer_names[0]: 
    adam_lr = trial.suggest_loguniform('adam_lr', 1e-5, 1e-1)
    optimizer = optim.Adam(model.parameters(), lr=adam_lr, weight_decay=weight_decay)
  elif optimizer_name == optimizer_names[1]:
    momentum_sgd_lr = trial.suggest_loguniform('momentum_sgd_lr', 1e-5, 1e-1)
    optimizer = optim.SGD(model.parameters(), lr=momentum_sgd_lr, momentum=0.9, weight_decay=weight_decay)
  else:
    optimizer = optim.RMSprop(model.parameters())
  return optimizer
```

###活性化関数の試行

```Python
def get_activation(trial):
    activation_names = ['ReLU', 'ELU']
    activation_name = trial.suggest_categorical('activation', activation_names)
    if activation_name == activation_names[0]:
        activation = F.relu
    else:
        activation = F.elu
    return activation
```

###目的関数の定義

```Python
EPOCH = 10
def objective(trial):
  device = "cuda" if torch.cuda.is_available() else "cpu"
  
  #畳み込み層の数
  num_layer = trial.suggest_int('num_layer', 3, 7)
  
  #FC層のユニット数
  mid_units = int(trial.suggest_discrete_uniform("mid_units", 100, 500, 100))
  
  #各畳込み層のフィルタ数
  num_filters = [int(trial.suggest_discrete_uniform("num_filter_"+str(i), 16, 128, 16)) for i in range(num_layer)]
  
  model = Net(trial, num_layer, mid_units, num_filters).to(device)
  optimizer = get_optimizer(trial, model)
  
  for step in range(EPOCH):
    train(model, device, train_loader, optimizer)
    error_rate = test(model, device, test_loader)

  return error_rate
```

目的関数の定義ですが，Optunaは目的関数が小さくなるようにハイパーパラメータをチューニングするため，objective関数は認識率でなく__誤り率__`error_rate`を返すよう定義します．

###パラメータチューニングの実行

```Python
TRIAL_SIZE = 100
study = optuna.create_study()
study.optimize(objective, n_trials=TRIAL_SIZE)
```

###実行結果
最適化されたハイパーパラメータの結果を出力します．

```Python
study.best_params

{'activation': 'ELU',
 'adam_lr': 0.00046155949650211135,
 'mid_units': 100.0,
 'num_filter_0': 128.0,
 'num_filter_1': 16.0,
 'num_filter_2': 112.0,
 'num_filter_3': 16.0,
 'num_filter_4': 128.0,
 'num_filter_5': 96.0,
 'num_filter_6': 128.0,
 'num_layer': 7,
 'optimizer': 'Adam',
 'weight_decay': 1.0303418654258521e-10}
```
また，最適化後の目的関数の値（誤り率）は，

```Python
study.best_value

0.00880000000000003
```
となりました．

#チューニングを行わなかった場合との比較
今回，Optunaを使ってハイパーパラメータのチューニングを行いましたが，チューニングを行わなかった場合とどのような違いがあるのか，__認識精度__と__実行時間__の観点から比較を行います．

チューニングを行わなかった場合のハイパーパラメータの値は以下のように指定しました．

- 畳み込み層の数 : 3
- 各畳み込み層のフィルタ数 : 16, 32, 48
- 全結合層のユニット数 : 100
- 活性化関数 : ReLU
- 最適化手法 : Adam
- 学習率 : 0.001
- weight_decay : 0

###認識精度と実行時間の比較
Optunaを用いてチューニングを行なった場合と行わなかった場合の__認識精度__および__実行時間__の比較です．

|  | 認識精度 | 実行時間（秒） |
|:------------:|:----------:|:----------:|
| チューニングあり | 0.9912 | 2580.886 |
| チューニングなし | 0.9881 | 58.232 |

認識精度においては，チューニングを行わなかった場合が__0.9881__であることに対し，チューニングした場合は__0.9912__となっており，精度が向上していることがわかります．わずかな差ではありますが，これはMNIST画像認識という簡単なタスクであったこともあり，複雑な問題の場合はより精度の向上が期待できると思います．

実行時間においては，当然ではありますがチューニングを行なったことによってより多くを要してしまいました．

#まとめ
今回は，PyTorchによるMNISTの画像認識を通し，Optunaを用いてハイパーパラメータのチューニングを行なってみました．

特に，畳み込み層の数というネットワークの構造を大きく左右するパラメータをチューニングできたので個人的には満足です（PyTorchの場合，特徴マップの大きさを計算しなければならなかったのが若干面倒でしたが）．

Optunaは複雑な問題であるほど効果を発揮できると思うので，機会が訪れ次第試してみたいと思います．