Pythonでめっちゃ簡単にできるベイズ最適化

2020.09.22 この記事の続きになる記事を書きました。
[scikit-optimizeのEarlyStopperで最適化を中断する](https://qiita.com/saiaron/items/8bf6cdf411b48a5ea59e)

# 前置き
仕事でパラメータの最適化をすることがあるのと、職場で最適化問題の相談を受けることが多いので、めっちゃ簡単にベイズ最適化ができる`scikit-optimize`の`gp_minimize`について、まとめておこうと思います。
> [scikit-optimize.gp_minimize](https://scikit-optimize.github.io/stable/modules/generated/skopt.gp_minimize.html)

# インストール
pipで簡単インストール

```
pip install scikit-optimize
```

# ソースコード全文
今回のソースコード全文。
ここでは要所のみ、実際には描画用のコードがあったりする。

```python
import numpy as np
from skopt import gp_minimize

def func(param=None):
    ret = np.cos(param[0] + 2.34) + np.cos(param[1] - 0.78)
    return -ret

if __name__ == '__main__':
    x1 = (-np.pi, np.pi)
    x2 = (-np.pi, np.pi)
    x = (x1, x2)
    result = gp_minimize(func, x, 
                          n_calls=30,
                          noise=0.0,
                          model_queue_size=1,
                          verbose=True)
```

## インポート周り
```python
import numpy as np
from skopt import gp_minimize
```
numpyとgp_minimizeだけ。
転用される場合は必要に応じて変更。

## 最適化関数
```python
def func(param=None):
    ret = np.cos(param[0] + 0.34) + np.cos(param[1] - 0.78)
    return -ret
```
今回は二つの入力を受け取り、それぞれのコサイン関数値の合計が最大化する入力値を求める問題とした。
分かりやすいから。
ただ、さすがに芸がなさすぎるので、入力値にオフセット(0.34, -0.78)を加えた。
最終的にこのオフセットを相殺する(-0.34, 0.78)が求められれば、最適化成功と言えるものである。

なお、`gp_minimize`という名前の通り、**最小化しかできない**ので、戻り値は-をつけて負値にすることで、最小化を最大化としている。

## 探索空間の設定
```python
    x1 = (-np.pi, np.pi)
    x2 = (-np.pi, np.pi)
    x = (x1, x2)
```
今回はコサイン関数と知っているので、二つの入力変数の空間は同じで、(-π ～ π)が取りうる範囲。
変数ごとに取りうる最小と最大をListかTupleで指定し、最後にひとつのListかTupleにまとめて渡す必要があるので、最後にひとつにまとめている。

## 最適化
```python
    result = gp_minimize(func, x, 
                          n_calls=30,
                          noise=0.0,
                          model_queue_size=1,
                          verbose=True)
```
これだけで、サンプリングが回りだす。
とりあえず、今回は使いそうなパラメータだけ指定している。

最初の`func`と`x`は最適化関数と探索空間を指定。
`n_calls`はサンプリング回数。
`noise`は指定しないとガウス分布のノイズを載せて探索するので、必要に応じて変更。今回は0.0でノイズなしで評価する。
`model_queue_size`は`gp_minimize`は`scikit-learn`の`GaussianProcessRegressor`を使って、空間の評価値の予測と次のサンプリング点を探しているので、都度都度の`GaussianProcessRegressor`のインスタンスを保持する数。
指定しないと全部残していくので、メモリをどんどん食いつぶしていく。今回は1を指定して、常に最新のみ保持するようにした。
`verbose`はサンプリング中の標準出力をありにしているだけ。Falseを指定すると何も出ない。

この最適化の結果が`result`に返される。

## resultの中身について
dir関数で中身を参照してみると下記のメンバーが存在している。

```python
In [1]:dir(result)
Out[1]: 
['fun',
 'func_vals',
 'models',
 'random_state',
 'space',
 'specs',
 'x',
 'x_iters']
```
概ね、下記の解釈でよいと思われる。

|メンバー|値|解釈|
|---|---|---|
|fun|-1.9999999997437237|最適化中のもっともよかった評価値|
|func_vals|array([ 0.72436992, -0.2934671 , ・・・・・ -1.99988708, -1.99654543])|最適化中の都度都度の関数の戻り値（評価値）|
|models|list(GaussianProcessRegressor)|先の説明の通り、保持した`GaussianProcessRegressor`のインスタンスのList|
|random_state|RandomState(MT19937) at 0x22023222268|乱数のシード|
|space|Space([<br>Real(low=-3.141592653589793, high=3.141592653589793, prior='uniform', transform='normalize'),<br>Real(low=-3.141592653589793, high=3.141592653589793, prior='uniform', transform='normalize')])|探索空間のオブジェクト|
|specs|（多いので省略）<br>Dictionaryが入っている|どうも最適化の仕様がまとめて入っているっぽい|
|x|[-2.3399919472084387, 0.7798573940377893]|最適化された入力変数の値|
|x_iters|(多いので省略)<br>List|都度都度、どんな値をサンプリングしたかが入っている|

# 結果のまとめ
最後に`result`に入っていた値を用いて、プロットしたやつを張り付けておく。
ちょぉっと見にくいが、左図は実際のヒートマップ、縦軸が`x1`で横軸が`x2`なので、縦軸には下の方、横軸には少し右のほうに最小値（正負反転したので本当は最大値）がある。

右図は最適化の結果ですが、ヒートマップの色自体も`GaussianProcessRegressor`の予測値で作ったもの。
３０回のサンプリングで実空間とほぼ同じものを作りだすことができ、最適値も求めることができた。
○のマーカーは都度都度のサンプリング点、わかりにくいが、端っこをサンプリングし、空間の予測がある程度できてからは最小値付近を重点的にサンプリングしたように見える。
☆のマーカーが最終的に見つかった最適値。
![Figure_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316366/e171e993-60df-c58f-75b2-b079db5362a4.png)

# Callbackの使い方
scikit-optimizeの使い方の記事は少ないながらもあるのに、今回これを書いたのは、実はCallbackの使い方を説明している日本語記事が全く見つからなかったから。
というわけで、Callbackの使い方は別記事にして、ここにリンクを貼ります。

2020.09.22 書きました。
[scikit-optimizeのEarlyStopperで最適化を中断する](https://qiita.com/saiaron/items/8bf6cdf411b48a5ea59e)