Help us understand the problem. What is going on with this article?

Pythonでベイズ最適化を使ってハイパーパラメータを探索するライブラリ実装のメモ

More than 3 years have passed since last update.

はじめに

ベイズ最適化(参考:ベイズ最適化入門, 機械学習のためのベイズ最適化入門)を使うと、機械学習の時の各種Try&Errorで決めざるを得ないようなハイパーパラメータの探索を効率よく実施できる可能性があります。

考え方などは最近色々解説記事が増えてきたおかげで理解はできるのですが、GridSearchのライブラリみたいな形ではWeb上で見つけられなかったので、今回作りました。きっと車輪の再発明なのだと思うのですが、まあ再発明は勉強にはなるので良しとします。

今回使っている各種Version

  • Python3.5
  • numpy==1.11.1
  • scikit-learn==0.17.1

コード

bayesian_optimizer.py
from itertools import product
from sklearn.gaussian_process import GaussianProcess

# The MIT License (C) 2016 mokemokechicken


class BayesianOptimizer:
    x_list = None
    y_list = None
    yielding_index = None
    k_band = 5
    verbose = False

    def __init__(self, params):
        self.params = params
        self.keys = []
        self.values = []
        for k, v in sorted(self.params.items()):
            self.keys.append(k)
            self.values.append(v)

    @property
    def n_pattern(self):
        return len(list(product(*self.values)))

    def output(self, *args, **kwargs):
        if self.verbose:
            print(*args, **kwargs)

    def supply_next_param(self, max_iter=None):
        self.x_list = []
        self.y_list = []
        all_parameters = list(product(*self.values))  # [(0.01, [0, 0], 0, [10, 10]), (0.01, [0, 0], 0, [15, 15]), ...
        index_space = [list(range(len(v))) for v in self.values]  # [[0], [0, 1, 2], [0], [0, 1, 2]]
        all_index_list = list(product(*index_space))  # [(0, 0, 0, 0), (0, 0, 0, 1), ...

        # examine 2 random points initially
        idx = list(range(len(all_index_list)))
        np.random.shuffle(idx)
        searched_index_list = []
        for index in idx[:2]:
            param = self.to_param(all_parameters[index])
            self.yielding_index = all_index_list[index]
            searched_index_list.append(index)
            yield param

        # Bayesian Optimization
        max_iter = int(min(max_iter or max(np.sqrt(self.n_pattern)*4, 20), self.n_pattern))  # 最大探索回数を適当に計算。
        for iteration in range(max_iter):
            k = 1 + np.exp(-iteration / max_iter * 3) * self.k_band  # kの値を徐々に減らして 探索重視 → 活用重視 にしてみる
            gp = self.create_gp_and_fit(np.array(self.x_list), np.array(self.y_list))

            mean_array, mse_array = gp.predict(all_index_list, eval_MSE=True)
            next_index, acq_array = self.acquisition(mean_array, mse_array, k, excludes=searched_index_list)

            self.output("--- Most Expected Predictions")
            for acq, ps in sorted(zip(acq_array, all_parameters), reverse=True)[:3]:
                self.output("%.2f: %s" % (acq, list(zip(self.keys, ps))))
            self.output("--- Past Best Results")
            for acq, vs, ps in self.best_results(3):
                self.output("%.2f: %s" % (acq, list(zip(self.keys, vs))))

            if next_index in searched_index_list:
                break
            searched_index_list.append(next_index)
            self.yielding_index = all_index_list[next_index]
            yield self.to_param(all_parameters[next_index])

    @staticmethod
    def create_gp_and_fit(x, y, max_try=100):
        # この辺怪しい
        theta0 = 0.1
        for i in range(max_try+1):
            try:
                gp = GaussianProcess(theta0=theta0)
                gp.fit(x, y)
                return gp
            except Exception as e:
                theta0 *= 10
                if i == max_try:
                    print(theta0)
                    raise e

    def to_param(self, row):
        return dict(zip(self.keys, row))

    def report(self, score):
        self.x_list.append(self.yielding_index)
        self.y_list.append(score)

    def best_results(self, n=5):
        index_list = []
        param_list = []
        for xs in self.x_list:
            values = [self.values[i][x] for i, x in enumerate(xs)]
            index_list.append(values)
            param_list.append(self.to_param(values))
        return sorted(zip(self.y_list, index_list, param_list), reverse=True)[:n]

    @staticmethod
    def acquisition(mean_array, mse_array, k, excludes=None):
        excludes = excludes or []
        values = mean_array + np.sqrt(mse_array) * k
        for_argmax = np.copy(values)
        for ex in excludes:
            for_argmax[ex] = -np.Inf
        return np.argmax(for_argmax), values

使い方

基本的な使い方は以下のようになります。

params = {
    "parameter1": [10, 20, 25, 50],
    "parameter2": ['p1', 'p2'],
    ...
}

bo = BayesianOptimizer(params)  # パラメータの組み合わせを辞書で渡す

for param in bo.supply_next_param():  # 次調べたほうが良いparamをdictで渡される
    y = unknown_score_function(param)              # なんらかの方法でその param を評価する
    bo.report(y)                                    # 評価値を教えてあげる

print(bo.best_results())                            # 一応一番良いScoreの値やparamは覚えてくれている

2次元空間でのデモ

ちゃんと動くか Jupyter notebook上で試したものを以下に置いてます。
https://gist.github.com/mokemokechicken/7f3cf33c71d33bf0ec242c37cb3f2a75

探索空間

以下の様な 50 x 50 平面に sin, cos で起伏を複数作った後、ノイズを乗っけてちょっと凸凹した2次元空間を用意します。

i1.png

赤い点がある (row=3, col=29) の部分が最も値の大きい地点です。

探索

こんな感じで進んでいきます。
探索回数は200回です。

序盤

まずは全体的に空間を調べています。少し囲碁っぽいw。
s1.png

序盤2

運良く、真ん中上にある本命の近くを何度か調べたようです。
a2.png

中盤

本命の地点は結構調査済みになりました。
s3.png

中盤2

もう一つの良さそうな左下の山も丹念にチェック。真ん中上の本命周辺も結構網羅。

s4.png

終盤

他に良さそうなところがないか空白地点をチェック。

s5.png

最終状態

他に特に良さそうなところも無く終了。まあ、人間がやってもこんな感じになりそうです。
s6.png

所感

  • 200回の探索で50x50のパラメータ空間から良いポイントを見つけられました。必ずしも最善の地点を見つけられはしませんが、センスの良い探索をしてくれています。
  • 今回のように探索空間が凸凹していると(?) sklearnのGaussianProcessの fit()Exception が発生することがしばしばあります。 「theta0thetaU を 大きくしたらどうだ」と言われるので、今のところそれで対応しています(それで良いのかよくわかってないです)。実際に機械学習のハイパーパラメータの評価のケースでは、結構ノイズも大きいのでこの問題はしばしば起こります。
  • 今回の実装では、パラメータの「値」ではなく「配列上の位置」を使って共分散を計算しているので、文字列パラメータにも対応していますが、順序が無い値(例えば、 ['dog', 'cat', 'bird'] とか)だと少し変な感じです。

さいごに

作ってみると、結構色々なシーンで使えそうな感じです。
人生もこんな感じだよなぁ。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away