ニューラルネットワークにおけるOptunaを用いたハイパーパラメータチューニング

###
Optunaを使ってハイパーパラメータチューニングを体験しよう！
============

自己紹介
------------
こんにちは。豊田工業大学3年のひのです。

お恥ずかしながら、プログラミングをちゃんと勉強し始めてまだ1年ですので、学習記録として残します。今回はNNモデルのハイパーパラメータチューニングをOptunaを用いて実装します。

他にも豊田工業大学Kaggleサークルの皆さんがQiitaの記事を書いているので、是非見てくださいね！下に同大学の友人と先輩の記事貼っておきますね。

[CNNを使って0から9の数字を分類する](https://qiita.com/umeboshia_0w/items/20eea40ec0dd9faf9bd8):@umeboshia_0w
[計算力がなければPythonを使えばいいじゃない](https://qiita.com/umeboshia_0w/items/20eea40ec0dd9faf9bd8):@Kofuku

プログラムのコードに関してはこちらの方のものを参考に一部改変させていただいています。
[Optuna で PyTorch のハイパーパラメータチューニングを試みる（テーブルデータ回帰）](http://maruo51.com/2020/08/07/optuna_pytorch/)

実行環境
---------------------------
 * Windowsの使用
    * エディション    Windows 10 Pro
    * バージョン      21H1
    * OSビルド    19043.1415    

 * Python 3.7.12
    * pandas 1.1.5
    * torch 1.10.0+cu111

データセット
-------------------
データセットはKaggleのデータセットを使用しました。
[House Prices - Advanced Regression Techniques
Predict sales prices and practice feature engineering, RFs, and gradient boosting](https://www.kaggle.com/c/house-prices-advanced-regression-techniques/overview)

このコンペの内容はボストンの住宅価格を予測するもので、データとしては部屋数や犯罪率などが含まれています。私自身このコンペを素通りしていたのでいい機会だと思い、このデータセットを使用しております。

データの読み込み
-----------  
まずは、訓練データとテストデータを確認します。

```python
import pandas as pd

train = pd.read_csv("./House Prices/train.csv")
test = pd.read_csv("./House Prices/test.csv")
train.head()
```


| Id | MSSubClass | MSZoning | LotFrontage | LotArea | Street | ... | 	SalePrice |
|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
| 1  | 60  |  RL | 65.0  | 8450	  | Pave	  |  ... |  208500 |
|  2 |  20 | RL  |  80.0 |  9600 |  Pave	 |  ... |  181500 |
| 3  |  60 |  RL | 68.0	  |  11250 |   Pave	|   ...| 223500  |
| 4  | 70  |  RL | 60.0  |  9550 |  Pave	 |   ...|  140000 |
|  5 |  60 |  RL | 84.0  |  14260 | Pave	  |   ...| 250000  |

前処理
----------

次に、欠損値補間をしたいのでこの二つのデータにおいて、それぞれの説明変数でデータを連結させます。

```python
all_X = pd.concat((train.loc[:, 'MSSubClass':'SaleCondition'],
                      test.loc[:, 'MSSubClass':'SaleCondition']))
```
連結したそれぞれのカラムにおけるデータの型を確認します。

```python
print(all_X.dtypes)
------------------------
MSSubClass         int64
MSZoning          object
LotFrontage      float64
LotArea            int64
Street            object
                  ...   
MiscVal            int64
MoSold             int64
YrSold             int64
SaleType          object
SaleCondition     object
Length: 79, dtype: object
```
**int型**と**object型**のデータが含まれていることが確認できました。object型は単に**str型ではない**ので注意してください。詳細に関しては以下を参照してください。
[Pythonのオブジェクトとは](https://atmarkit.itmedia.co.jp/ait/articles/1907/12/news019.html)

それぞれのデータに対して、学習できる形にデータを成形します。

```python
numnic_feats = all_X.dtypes[all_X.dtypes!='object'].index
all_X[numnic_feats] = all_X[numnic_feats].apply(lambda x: (x-x.mean())/x.std())
all_X = pd.get_dummies(all_X, dummy_na=True)
```
まず、object型ではないデータに対しては、正規化を行います。次に、pd.get_dummiesにより、Nanも1つのカテゴリーとしてダミー変数化します。

```python
all_X.isnull().sum()
----------------
MSSubClass                 0
LotFrontage              486
LotArea                    0
OverallQual                0
OverallCond                0
                        ... 
SaleCondition_Alloca       0
SaleCondition_Family       0
SaleCondition_Normal       0
SaleCondition_Partial      0
SaleCondition_nan          0
Length: 331, dtype: int64
```
欠損値を確認し、欠損値を平均値で補完します。

```python
all_X = all_X.fillna(all_X.mean())
```
以上で簡単ではありますが前処理を終えたので、データを訓練データとテストデータに戻しておきましょう。

```python
num_train = train.shape[0]
X_train = all_X[:num_train]
X_test = all_X[num_train:]
y_train = train.SalePrice
```

ニューラルネットワーククラスの定義
------------------------

```python
class Net(nn.Module):
 
  def __init__(self, input_dim, hidden_dim, n_layers):
    super().__init__()
    self.input_dim = input_dim
    self.hidden_dim = hidden_dim
    self.n_layers = n_layers
 
    self.input_layer = nn.Sequential(
        nn.BatchNorm1d(input_dim),
        nn.Linear(input_dim, hidden_dim),
        nn.ReLU(inplace=True)
    )
 
    middle = []
    for _ in range(n_layers):
      middle.append(nn.BatchNorm1d(hidden_dim),)
      middle.append(nn.Linear(hidden_dim, hidden_dim))
      middle.append(nn.ReLU(inplace=True))
 
    self.middle_layers = nn.Sequential(*middle)
 
    self.output_layer = nn.Linear(hidden_dim, 1)
 
 
  def forward(self, x):
    x = self.input_layer(x)
    x = self.middle_layers(x)
    x = self.output_layer(x)
    return x
```
今回NNモデルのハイパーパラメータチューニングの対象としては以下の4つです。

 * **中間層の数**
 * **中間層のノード数**
 * **最適化手法**
 * **学習率**

**input_dim**は、**入力層へのノード数**です。データの説明変数の次元を変更した際に、それに合わせて変更する必要があります。
**hidden_dim**は、**中間層のノード数**です。なお、中間層の活性化関数は**ReLU関数**を使用しています。
**n_layers**は、**中間層の数**です。何層の中間層にするかを決定します。
このように、Optunaでのハイパーパラメータチューニングの際に、中間層の数とノード数を最適化したいため、このように定義しています。なお、**活性化関数の最適化**も可能です。

目的関数の定義
-----------------------------

```python
max_epoch = 30
criterion = nn.MSELoss()
 
def objective(trial):
  device = "cuda" if torch.cuda.is_available() else "cpu"
 
  n_layers = trial.suggest_int("n_layers", 1, 5)
  hidden_dim = trial.suggest_int("hidden_dim", 8, 256, 8)
  optimizer = trial.suggest_categorical("optimizer", ["Adam", "RMSprop", "SGD"])
  learning_rate = trial.suggest_discrete_uniform("learning_rate", 1e-5, 1e-1, q=1e-5)
 
  net = Net(input_dim=331, hidden_dim=hidden_dim, n_layers=n_layers).to(device)
  optimizer = getattr(torch.optim, optimizer)(net.parameters(), lr=learning_rate)

  # train 
  net.train()
  for epoch in range(max_epoch):
    for batch in train_dataloader:
      x, t = batch
      x.to(device)
      t.to(device)
 
      optimizer.zero_grad()
      y = net(x)
      loss = criterion(y, t)
      loss.backward()
      optimizer.step()
    
  # validation
  net.eval()
  validation_loss = 0.0
  with torch.no_grad():
    for batch in test_dataloader:
      x, t = batch
      x.to(device)
      t.to(device)
      y = net(x)
      loss = criterion(y, t)
      validation_loss += loss.item() * x.size(0)
 
    validation_loss = validation_loss / len(test_dataloader.dataset)
 
  return validation_loss
```

**objective()**という関数で最小化する目的関数を設定します。trialオブジェクトは、Optunaに実装されているクラスのインスタンスで、最適化したい対象のハイパーパラメータの定義に用います。
ハイパーパラメータチューニングのパラメータの設定を行います。

 * **中間層の数**：      1~5
 * **中間層のノード数**:  8~256 (8間隔)
 * **最適化手法**: "Adam", "RMSprop", "SGD"
 * **学習率**: 1e-5~1e-1 (1e-5間隔)

studyオブジェクトの定義
------------------

```python
import optuna
 
study = optuna.create_study()
study.optimize(objective, n_trials=50)
```

ハイパーパラメータチューニングを行う**studyオブジェクト**を**optuna.create_study()**で作成します。なお、今回はスコアを最小化していますが、最大化したい場合は、**optuna.create_study(direction='maximize')**で最大化を行います。
**study.optimize**により目的関数の最小化を行い、ハイパーパラメータチューニングを行います。なお、試行回数を指定していますが、**timeout=60**のように時間で指定することも可能です。

```python
study.best_trial
---------------------------
FrozenTrial(number=1, values=[6909027840.0], datetime_start=datetime.datetime(2021, 12, 22, 16, 26, 32, 457532), datetime_complete=datetime.datetime(2021, 12, 22, 16, 26, 33, 355795), params={'n_layers': 4, 'hidden_dim': 40, 'optimizer': 'RMSprop', 'learning_rate': 0.08705}, distributions={'n_layers': IntUniformDistribution(high=5, low=1, step=1), 'hidden_dim': IntUniformDistribution(high=256, low=8, step=8), 'optimizer': CategoricalDistribution(choices=('Adam', 'RMSprop', 'SGD')), 'learning_rate': DiscreteUniformDistribution(high=0.1, low=1e-05, q=1e-05)}, user_attrs={}, system_attrs={}, intermediate_values={}, trial_id=1, state=TrialState.COMPLETE, value=None)
```
ハイパーパラメータだけを見てみましょう。

```python
#学習結果
params={'n_layers': 4, 'hidden_dim': 40, 'optimizer': 'RMSprop', 'learning_rate': 0.08705}
```
このように、中間層の数、中間層のノード数、最適化手法、学習率の4つに対してハイパーパラメータチューニングを行うことができました。

まとめ
---------------
ここまで読んで頂き、誠にありがとうございます。

Qiitaの記事を書くことは初めてでしたが、間違ったことを書かないことを意識して、それぞれについて再び調べることで、私自身非常に勉強になりました。まだまだ未熟者ですので温かい目で見守っていただければ幸いです。

次回は、ハイパーパラメータチューニングをしてモデルの性能を向上させてみたいと思います。

参考
--------------

 * http://maruo51.com/2020/08/07/optuna_pytorch/
 * https://qiita.com/Yushi1958/items/cd22ade638f7e292e520