Optuna における最適化の流れを追ってみた

# 概要
Optuna の使い方，またその最適化について実装上の流れを確認していく．
ただし，TPE のアルゴリズムの実装について触れるわけではないので注意！

また，ここでは scikt-learn に対して用いたコードを基に見ていく．
# Example
以下にscikit-learm を用いた例（[引用元](https://github.com/pfnet/optuna/blob/master/examples/sklearn_simple.py)）を示す．

```python

import sklearn.datasets
import sklearn.ensemble
import sklearn.model_selection
import sklearn.svm


# FYI: Objective functions can take additional arguments
# (https://optuna.readthedocs.io/en/stable/faq.html#objective-func-additional-args).
def objective(trial):
    iris = sklearn.datasets.load_iris()
    x, y = iris.data, iris.target

    classifier_name = trial.suggest_categorical('classifier', ['SVC', 'RandomForest'])
    if classifier_name == 'SVC':
        svc_c = trial.suggest_loguniform('svc_c', 1e-10, 1e10)
        classifier_obj = sklearn.svm.SVC(C=svc_c)
    else:
        rf_max_depth = int(trial.suggest_loguniform('rf_max_depth', 2, 32))
        classifier_obj = sklearn.ensemble.RandomForestClassifier(max_depth=rf_max_depth)

    score = sklearn.model_selection.cross_val_score(classifier_obj, x, y, n_jobs=-1, cv=3)
    accuracy = score.mean()
    return 1.0 - accuracy


if __name__ == '__main__':
    import optuna
    study = optuna.create_study()
    study.optimize(objective, n_trials=100)
    print(study.best_trial)
```

# `main` 部について
とりあえず `main` をざっと見てみる．
まず，

```python
optuna.create_study()
```

で最適化の大枠を担う [Study](https://github.com/pfnet/optuna/blob/master/optuna/study.py)クラスのインスタンスを作る．


見ての通り，その次の工程として

```python
study.optimize(objective, n_trials=100)
```

で最適化が完了する．とても簡単でシンプルである．

# `objective` 関数
次に，目的関数の設定方法について見ていく．
ここで `objective` 関数が守るべき性質として， 必ず `trial` を引数にとり，最適化対象の値を返す．ただし， `trial` は [Traial](https://github.com/pfnet/optuna/blob/master/optuna/trial.py) クラスのインスタンスである．

また，`Traial` クラスは `suggest_*` 系の関数を持っていて，この関数を通して最適なハイパラの推定をしている．
このことからわかるように，`objective` 関数は所謂目的関数の役割だけでなく，問題設定および推定フェーズも含む物となっている．

## ハイパラの設定方法
次に，最適化対象のハイパラの設定方法について細かく見ていく．
例えば，対数一様分布を用いて， `svc` の `C` を最適化する場合は，以下のように，`trial` を用いることで，

```python
svc_c = trial.suggest_loguniform('svc_c', 1e-10, 1e10)
```
となる．
次に，`suggest_loguniform` は `float` を返しているため`svc_c` という変数は単純に以下のように SVC に渡せる．

```python
sklearn.svm.SVC(C=svc_c) 
```

また，以下を例に各引数について見てみると，

```python
trial.suggest_loguniform(name, low, high)
```

- `name`: 最適化対象の変数管理のためのタグ
- `low`: 最適化対象の分布の下界
- `high`: 最適化対象の分布の上界
となっている．

**NOTE**
ただし，`suggest_loguniform` は設定した下界と上界の範囲内で設定した分布に従いサンプリングしているのではなく，この範囲内で分布に従いつつ，かつ*最適であると推定した値*を返していることに注意する．（suggest という名前の所以はそこなんだろう．）

## 最適化のターゲット設定方法
`objective` 関数内でメトリック等を用いて計算した得た値等を `return` するだけ！

# 最適化の流れ
もう少し詳しく実装面での最適化の流れについて確認する．
理論については，以下を参照．
- [解説ページ](https://qiita.com/nabenabe0928/items/708d221dbccebf31f01c)
- [TPE の元論文](https://www.lri.fr/~kegl/research/PDFs/BeBaBeKe11.pdf)


以降でも以下の関数を参考に見ていく．

```python
trial.suggest_loguniform(name, low, high)
```
ちなみに [解説ページ#smbo](https://qiita.com/nabenabe0928/items/708d221dbccebf31f01c#smbo) を参考にすると，この関数は

```math
 x^{*} \leftarrow \textrm{argmin}_{x}S(x, M_{t-1})
```

の部分を担っていることがわかる．

## 関数の一つ深いところへ...
この関数の中身は docs 部分を省くと以下のようになっている．

```python
    def suggest_loguniform(self, name, low, high):
        # type: (str, float, float) -> float
        ...
        return self._suggest(name, distributions.LogUniformDistribution(low=low, high=high))
```
ここで，

```python
distributions.LogUniformDistribution(low=low, high=high)
```

とあるが，これはどの分布であるかを管理するだけで，本体は実質ただの `NamedTuple` である．
また，これは `BaseDistribution` クラスも継承しているが，その docs の中に，

```python
"""Base class for distributions.

Note that distribution classes are not supposed to be called by library users.
They are used by :class:`~optuna.trial.Trial` and :class:`~optuna.samplers` internally.
"""
```
とあるので，分布をまさに分布として使うものではないっぽい．（このあたりは chainer が最近分布の実装がされてきているので，いつか統合されるかも？）

## もう一つ深く...
さっきの関数ではなにも分からなかったので，もう少し深く進み `_suggest` 関数を見てみる．

```python
def _suggest(self, name, distribution):
    # type: (str, distributions.BaseDistribution) -> Any

    param_value_in_internal_repr = self.study.sampler.sample(self.storage, self.study_id, name,
    distribution)
    ...
    return param_value
```

ここで初めて `sample` というワードが出てくることに注目する．
どうやら，`study` インスタンスは `sampler` と呼ばれる分布からサンプルするためのインスタンス変数を持っている事がわかる．
この `sampler` はデフォルトで [TPESampler](https://github.com/pfnet/optuna/blob/master/optuna/samplers/tpe/sampler.py) というものを使っており，名前からも予想がつくが TPE のアルゴリズムを実行するものである．
このタイミング（もとを辿れば `objective` 関数のなかでのパラメータの suggest 部分）で推定可能にしているキモの内容としては，関数の引数に対象とする分布だけでなく，`strage` とされるこれまでの推定ログが渡されていることにある．
これにより，これまでの結果に基づき，与えられた分布から最適だと思われるパラメータをサンプリングすることが可能になっている．

# まとめ
Optuna の最適化におけるコアの部分は [TPESampler](https://github.com/pfnet/optuna/blob/master/optuna/samplers/tpe/sampler.py) が担っており，これが `study` を介して `strage` にアクセスすることで推定を可能にしていた．
`objective` 関数の役割は， Define-and-Run であると言われるだけあって，まさに雛形作りをしているというのが改めてわかった．