CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: Optuna で機械学習モデルのハイパーパラメータを選ぶ

今回は、ハイパーパラメータを最適化するフレームワークの一つである Optuna を使ってみる。 このフレームワークは国内企業の Preferred Networks が開発の主体となっていて、ほんの数日前にオープンソースになったばかり。

ハイパーパラメータ自動最適化ツール「Optuna」公開 | Preferred Research

先に使ってみた印象について話してしまうと、基本は Hyperopt にかなり近いと感じた。 実際のところ、使っているアルゴリズムの基本は変わらないし、定義できるパラメータの種類もほとんど同じになっている。 おそらく Hyperopt を使ったことがある人なら、すぐにでも Optuna に切り替えることができると思う。

その上で Hyperopt との違いについて感じたのは二点。 まず、Define-by-run という特性によって複雑なパラメータを構成しやすくなっていること。 そして、もうひとつが最適化を分散処理 (Distributed Optimization) するときの敷居がとても低いことだった。 これらの点に関して詳しくは後述する。 あと、これは使い勝手とは違うけどフレームワークのソースコードが Python のお手本のようにキレイ。

今回使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.14.1
BuildVersion:   18B75
$ python -V              
Python 3.6.7

下準備

Optuna は PyPI に登録されているので pip を使ってインストールできる。

$ pip install optuna

単純な例

機械学習モデルのハイパーパラメータを扱う前に、まずはもっと単純な例から始めてみよう。 手始めに、次のような一つの変数 x を取る関数の最大値を探す問題について考えてみる。 グラフを見ると、最大値は 2 付近にあるようだ。

f:id:momijiame:20180818132951p:plain

以下のサンプルコードでは、上記の関数の最大値を Optuna で探している。 ただし、Optuna は今のところ (v0.4.0) は最小化にのみ対応しているため、目的関数は符号を反転している。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import math

import optuna


def objective(trial):
    """最小化する目的関数"""
    # パラメータが取りうる範囲
    x = trial.suggest_uniform('x', -5, +15)
    # デフォルトで最小化かつ現在は最小化のみのサポートなので符号を反転する
    return - math.exp(-(x - 2) ** 2) + math.exp(-(x - 6) ** 2 / 10) + 1 / (x ** 2 + 1)


def main():
    # 最適化のセッションを作る
    study = optuna.create_study()
    # 100 回試行する
    study.optimize(objective, n_trials=100)
    # 最適化したパラメータを出力する
    print('params:', study.best_params)


if __name__ == '__main__':
    main()

コメントもあるけど、念のためソースコードについて補足する。 まず、Optuna では一つの最適化したい問題を Study というオブジェクト (または概念) で取り扱う。 このオブジェクトの optimize() というメソッドに最適化したい目的関数を渡して試行を繰り返す。 サンプルコードにおいて、目的関数は objective() という名前で定義している。 また、各試行は Trial というオブジェクトで、目的関数の引数として渡される。 この Trial は過去に探索した内容の履歴を扱っていて、それを元に次に試行するパラメータの値が決まる。 なお、パラメータの探索にはデフォルトで TPE (Tree-structured Parzen Estimator) というベイズ最適化の一種が用いられる。

あんまり説明ばかりが長くなっても仕方ないので、先ほどのサンプルコードを実際に実行してみる。 すると、各試行ごとにログが出力されて探索の様子が分かるようになっている。

$ python helloptuna.py 
[I 2018-12-05 21:34:11,776] Finished a trial resulted in value: 0.5459903986050455. Current best value is 0.5459903986050455 with parameters: {'x': -0.9268011342183158}.
[I 2018-12-05 21:34:11,777] Finished a trial resulted in value: -0.11535207904386202. Current best value is -0.11535207904386202 with parameters: {'x': 1.2859333865518803}.
[I 2018-12-05 21:34:11,778] Finished a trial resulted in value: 0.014796275560628312. Current best value is -0.11535207904386202 with parameters: {'x': 1.2859333865518803}.
...(snip)...
[I 2018-12-05 21:34:12,484] Finished a trial resulted in value: -0.46165044813597855. Current best value is -0.597104581903869 with parameters: {'x': 2.029056526434356}.
[I 2018-12-05 21:34:12,495] Finished a trial resulted in value: 0.020372692700788855. Current best value is -0.597104581903869 with parameters: {'x': 2.029056526434356}.
[I 2018-12-05 21:34:12,508] Finished a trial resulted in value: -0.24643573203869212. Current best value is -0.597104581903869 with parameters: {'x': 2.029056526434356}.
params: {'x': 2.029056526434356}

結果として、たしかに 2 前後にある最大値が見つかった。

機械学習のモデルに適用する

続いては、実際に機械学習のモデルを使ってハイパーパラメータを探してみよう。 その準備として scikit-learn をインストールしておく。

$ pip install scikit-learn

まずは、乳がんデータセットと SVM (Support Vector Machine) の組み合わせを試してみる。 これは、二値分類という問題が分かりやすいのと SVM がハイパーパラメータに対して割と敏感な印象があるため。 最小化する目的関数の返り値は、モデルを 5-Fold CV で Accuracy について評価したもの。 また、カーネル関数のようなカテゴリ変数や、応答の特性が対数に比例する類のパラメータもちゃんと扱えることが分かる。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from functools import partial

import optuna
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import cross_validate
from sklearn.svm import SVC
from sklearn import datasets


def objective(X, y, trial):
    """最小化する目的関数"""
    params = {
        'kernel': trial.suggest_categorical('kernel', ['rbf', 'sigmoid']),
        'C': trial.suggest_loguniform('C', 1e+0, 1e+2),
        'gamma': trial.suggest_loguniform('gamma', 1e-2, 1e+1),
    }

    # モデルを作る
    model = SVC(**params)

    # 5-Fold CV / Accuracy でモデルを評価する
    kf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
    scores = cross_validate(model, X=X, y=y, cv=kf)
    # 最小化なので 1.0 からスコアを引く
    return 1.0 - scores['test_score'].mean()


def main():
    # 乳がんデータセットを読み込む
    dataset = datasets.load_breast_cancer()
    X, y = dataset.data, dataset.target
    # 目的関数にデータを適用する
    f = partial(objective, X, y)
    # 最適化のセッションを作る
    study = optuna.create_study()
    # 100 回試行する
    study.optimize(f, n_trials=100)
    # 最適化したパラメータを出力する
    print('params:', study.best_params)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python svmopt.py
[I 2018-12-05 21:36:11,195] Finished a trial resulted in value: 0.37257406694882644. Current best value is 0.37257406694882644 with parameters: {'kernel': 'rbf', 'C': 2.0190784147815957, 'gamma': 0.8877063047664268}.
[I 2018-12-05 21:36:11,332] Finished a trial resulted in value: 0.37257406694882644. Current best value is 0.37257406694882644 with parameters: {'kernel': 'rbf', 'C': 2.0190784147815957, 'gamma': 0.8877063047664268}.
[I 2018-12-05 21:36:11,413] Finished a trial resulted in value: 0.37257406694882644. Current best value is 0.37257406694882644 with parameters: {'kernel': 'rbf', 'C': 2.0190784147815957, 'gamma': 0.8877063047664268}.
...(snip)...
[I 2018-12-05 21:36:22,924] Finished a trial resulted in value: 0.37077337437475955. Current best value is 0.37077337437475955 with parameters: {'kernel': 'rbf', 'C': 86.65350935649266, 'gamma': 0.01187563934327901}.
[I 2018-12-05 21:36:23,093] Finished a trial resulted in value: 0.37257406694882644. Current best value is 0.37077337437475955 with parameters: {'kernel': 'rbf', 'C': 86.65350935649266, 'gamma': 0.01187563934327901}.
[I 2018-12-05 21:36:23,254] Finished a trial resulted in value: 0.37077337437475955. Current best value is 0.37077337437475955 with parameters: {'kernel': 'rbf', 'C': 86.65350935649266, 'gamma': 0.01187563934327901}.
params: {'kernel': 'rbf', 'C': 86.65350935649266, 'gamma': 0.01187563934327901}

スコアの改善自体は 0.2% 程度のものだけど、ちゃんと最初よりも性能の良いパラメータが見つかっていることが分かる。

もう少し複雑な例を扱ってみる

さきほどの例では、パラメータの組み合わせが単純だったので、正直 Optuna の真価を示すことができていなかった。 そこで、次はもう少しだけ複雑な例を扱ってみることにする。

次のサンプルコードでは、先ほどと比較して使う分類器が RandomForest と SVM の二種類に増えている。 当然ながら RandomForest と SVM ではインスタンス化するときの引数からして異なる。 こういったパターンでも、Optuna なら Trial オブジェクトが返した値を元に処理を分岐することで目的関数が簡単に表現できる。 Optuna では、このように実行しながら試行するパターンを決定していく特定を指して Define-by-run と呼んでいる。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from functools import partial

import optuna
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import cross_validate
from sklearn.svm import SVC
from sklearn import datasets


def objective(X, y, trial):
    """最小化する目的関数"""

    # 使う分類器は SVM or RF
    classifier = trial.suggest_categorical('classifier', ['SVC', 'RandomForestClassifier'])

    # 選ばれた分類器で分岐する
    if classifier == 'SVC':
        # SVC のとき
        params = {
            'kernel': trial.suggest_categorical('kernel', ['rbf', 'sigmoid']),
            'C': trial.suggest_loguniform('C', 1e+0, 1e+2),
            'gamma': trial.suggest_loguniform('gamma', 1e-2, 1e+1),
        }
        model = SVC(**params)
    else:
        # RF のとき
        params = {
            'n_estimators': int(trial.suggest_loguniform('n_estimators', 1e+2, 1e+3)),
            'max_depth': int(trial.suggest_loguniform('max_depth', 2, 32)),
        }
        model = RandomForestClassifier(**params)

    kf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
    scores = cross_validate(model, X=X, y=y, cv=kf, n_jobs=-1)
    return 1.0 - scores['test_score'].mean()


def main():
    dataset = datasets.load_breast_cancer()
    X, y = dataset.data, dataset.target
    f = partial(objective, X, y)
    study = optuna.create_study()
    study.optimize(f, n_trials=100)
    print('params:', study.best_params)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python clfopt.py
[I 2018-12-05 21:38:48,588] Finished a trial resulted in value: 0.04390919584455555. Current best value is 0.04390919584455555 with parameters: {'classifier': 'RandomForestClassifier', 'n_estimators': 733.1380680046733, 'max_depth': 2.3201133434932117}.
[I 2018-12-05 21:38:49,972] Finished a trial resulted in value: 0.045648326279338236. Current best value is 0.04390919584455555 with parameters: {'classifier': 'RandomForestClassifier', 'n_estimators': 733.1380680046733, 'max_depth': 2.3201133434932117}.
[I 2018-12-05 21:38:50,084] Finished a trial resulted in value: 0.37257406694882644. Current best value is 0.04390919584455555 with parameters: {'classifier': 'RandomForestClassifier', 'n_estimators': 733.1380680046733, 'max_depth': 2.3201133434932117}.
...(snip)...
[I 2018-12-05 21:42:30,883] Finished a trial resulted in value: 0.033382070026933386. Current best value is 0.028133897652943496 with parameters: {'classifier': 'RandomForestClassifier', 'n_estimators': 453.56949779973104, 'max_depth': 30.86562241046072}.
[I 2018-12-05 21:42:31,710] Finished a trial resulted in value: 0.03866102347056566. Current best value is 0.028133897652943496 with parameters: {'classifier': 'RandomForestClassifier', 'n_estimators': 453.56949779973104, 'max_depth': 30.86562241046072}.
[I 2018-12-05 21:42:31,826] Finished a trial resulted in value: 0.37077337437475955. Current best value is 0.028133897652943496 with parameters: {'classifier': 'RandomForestClassifier', 'n_estimators': 453.56949779973104, 'max_depth': 30.86562241046072}.
params: {'classifier': 'RandomForestClassifier', 'n_estimators': 453.56949779973104, 'max_depth': 30.86562241046072}

先ほどよりも実行に時間はかかかるものの、着実にスコアが上がる様子が見て取れる。

Storage と最適化の分散処理 (Distributed Optimization)

続いては Optuna の重要なオブジェクトとして Storage について扱う。 これは Study や各 Trial といった探索に関する情報を記録しておくための場所を示している。

Storage は普段オンメモリにあるため、実行ごとに揮発してしまう。 しかし、指定すれば実装を RDB (Relational Database) などに切り替えて使うことができる。 こうすれば探索した履歴をディスクに永続化できるので、途中までの計算結果が何かのはずみにパーという事態も減らせる。 Optuna では SQLAlchemy を使って RDB を抽象化して扱っているため SQLite3 や MySQL などを切り替えながら利用できる。 この点はバックエンドが基本的に MongoDB しかない Hyperopt に対して優位な点だと思う。

説明が長くなってきたので、そろそろ実際に使ってみる。 最初は Storage の実装として SQLite3 を使うパターンで試してみよう。 まずは optuna コマンドを使ってデータベースを初期化する。 このとき Study に対して分かりやすい名前をつけておく。

$ optuna create-study --study 'distributed-helloworld' --storage 'sqlite:///example.db'
[I 2018-12-05 22:04:07,038] A new study created with name: distributed-helloworld
distributed-helloworld

これで必要な SQLite3 のデータベースが作られる。

$ file example.db 
example.db: SQLite 3.x database, last written using SQLite version 3025003
$ sqlite3 example.db '.tables'
studies                  trial_params             trial_values           
study_system_attributes  trial_system_attributes  trials                 
study_user_attributes    trial_user_attributes    version_info

指定した名前で Study に関する情報も記録されている。

$ sqlite3 example.db 'SELECT * FROM studies'
1|distributed-helloworld|MINIMIZE

この時点では、まだ全くパラメータを探索していないので Trial に関する情報はない。

$ sqlite3 example.db 'SELECT COUNT(1) AS count FROM trials'
0

上記の Storage を使って探索するサンプルコードが次の通り。 Study のオブジェクトを作るときに、名前とバックエンドに関する情報を指定してやる。 試行回数は意図的に抑えてある。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import math

import optuna


def objective(trial):
    """最小化する目的関数"""
    x = trial.suggest_uniform('x', -5, +15)
    return - math.exp(-(x - 2) ** 2) + math.exp(-(x - 6) ** 2 / 10) + 1 / (x ** 2 + 1)


def main():
    # NOTE: 実行前のデータベースを作ること
    # $ optuna create-study --study 'distributed-helloworld' --storage 'sqlite:///example.db'

    # study_name, storage は作成したデータベースの内容と合わせる
    study = optuna.Study(study_name='distributed-helloworld', storage='sqlite:///example.db')

    study.optimize(objective, n_trials=5)
    print('params:', study.best_params)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python dbstudy.py 
[I 2018-12-05 22:05:20,037] Finished a trial resulted in value: 0.6938982089558796. Current best value is 0.6938982089558796 with parameters: {'x': 7.969822697335578}.
[I 2018-12-05 22:05:20,075] Finished a trial resulted in value: 0.9714561387259608. Current best value is 0.6938982089558796 with parameters: {'x': 7.969822697335578}.
[I 2018-12-05 22:05:20,113] Finished a trial resulted in value: -0.515546413663528. Current best value is -0.515546413663528 with parameters: {'x': 1.7262219554205434}.
[I 2018-12-05 22:05:20,151] Finished a trial resulted in value: 0.5172823829449016. Current best value is -0.515546413663528 with parameters: {'x': 1.7262219554205434}.
[I 2018-12-05 22:05:20,191] Finished a trial resulted in value: 0.9779376537980013. Current best value is -0.515546413663528 with parameters: {'x': 1.7262219554205434}.
params: {'x': 1.7262219554205434}

5 回だけ試行が実行されて、ベストスコアは -0.515546413663528 となった。

データベースを確認すると Trial の回数が更新されていることが分かる。 つまり、探索した内容を永続化しつつ処理が進められている。

$ sqlite3 example.db 'SELECT COUNT(1) AS count FROM trials'
5

そして、サンプルコードをもう一度実行してみよう。 すると、初回の探索時に示されているベストスコアが前回から引き継がれていることがわかる。 つまり、過去にデータベースに永続化した内容を使いながら探索が進められている。 これはソースコードレベルでも確認していて、過去に試行した結果を全て引っ張ってきて、その内容を元に次の試行内容を決定していた。

$ python dbstudy.py 
[I 2018-12-05 22:05:29,048] Finished a trial resulted in value: 0.9358553655678095. Current best value is -0.515546413663528 with parameters: {'x': 1.7262219554205434}.
[I 2018-12-05 22:05:29,079] Finished a trial resulted in value: 0.04114448607195525. Current best value is -0.515546413663528 with parameters: {'x': 1.7262219554205434}.
[I 2018-12-05 22:05:29,118] Finished a trial resulted in value: 0.7127626383671348. Current best value is -0.515546413663528 with parameters: {'x': 1.7262219554205434}.
[I 2018-12-05 22:05:29,157] Finished a trial resulted in value: -0.5533154442856236. Current best value is -0.5533154442856236 with parameters: {'x': 2.2005954763853053}.
[I 2018-12-05 22:05:29,197] Finished a trial resulted in value: 0.4829461021707021. Current best value is -0.5533154442856236 with parameters: {'x': 2.2005954763853053}.
params: {'x': 2.2005954763853053}

確認すると、ちゃんと試行回数が増えている。

$ sqlite3 example.db 'SELECT COUNT(1) AS count FROM trials'
10

ちなみに、この仕組みは最適化の分散処理とも深く関わりがある。 というのも、このデータベースを複数のマシンから参照できる場所に置いた場合、どうなるだろうか。 各マシンは、データベースの内容を参照しつつ分散処理でパラメータの探索が可能になる。

また、複数のマシンを使わなくても同じマシンから複数のプロセスで探索するコードを実行すればマルチプロセスで処理が可能になる。 まあ、ただこの用途についてはあまり現実的ではない。 というのも Study 自体が n_jobs という並列度を制御するオプションを持っているのと、一般的にモデルの学習・推論部分が並列化されているので。

バックエンドを MySQL にしてみる

最適化の分散処理という観点でいえば本番環境で SQLite3 を使うことは、おそらくないはず。 そこで、次は Storage のバックエンドに MySQL を試してみる。

まずは MySQL をインストールする。

$ brew install mysql
$ brew services start mysql

Optuna 用のデータベースを作る。

$ mysql -u root -e "CREATE DATABASE IF NOT EXISTS optuna"

SQLAlchemy が接続に使うデータベースドライバのパッケージをインストールする。

$ pip install mysqlclient

あとは Storage の指定を MySQL が使われるように変えるだけ。

$ optuna create-study --study 'distributed-mysql' --storage 'mysql://root@localhost/optuna'
[I 2018-12-05 23:44:09,129] A new study created with name: distributed-mysql
distributed-mysql

ちゃんと MySQL にテーブルができていることを確認する。

$ mysql -u root -D optuna -e "SHOW TABLES"
+-------------------------+
| Tables_in_optuna        |
+-------------------------+
| studies                 |
| study_system_attributes |
| study_user_attributes   |
| trial_params            |
| trial_system_attributes |
| trial_user_attributes   |
| trial_values            |
| trials                  |
| version_info            |
+-------------------------+

次のサンプルコードもバックエンドに MySQL が使われるように指定している。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import math

import optuna


def objective(trial):
    """最小化する目的関数"""
    x = trial.suggest_uniform('x', -5, +15)
    return - math.exp(-(x - 2) ** 2) + math.exp(-(x - 6) ** 2 / 10) + 1 / (x ** 2 + 1)


def main():
    # NOTE: 実行前のデータベースを作ること
    # $ optuna create-study --study 'distributed-mysql' --storage 'mysql://root@localhost/optuna'

    # study_name, storage は作成したデータベースの内容と合わせる
    study = optuna.Study(study_name='distributed-mysql', storage='mysql://root@localhost/optuna')

    study.optimize(objective, n_trials=100)
    print('params:', study.best_params)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python mysqlstudy.py 
[I 2018-12-05 23:45:07,051] Finished a trial resulted in value: 0.021628217984214517. Current best value is 0.0216282 with parameters: {'x': 12.4684}.
[I 2018-12-05 23:45:07,093] Finished a trial resulted in value: 0.9874823396086119. Current best value is 0.0216282 with parameters: {'x': 12.4684}.
[I 2018-12-05 23:45:07,140] Finished a trial resulted in value: 0.8407030713413823. Current best value is 0.0216282 with parameters: {'x': 12.4684}.
...(snip)...
[I 2018-12-05 23:45:12,811] Finished a trial resulted in value: 0.7561641667979796. Current best value is -0.507712 with parameters: {'x': 2.28833}.
[I 2018-12-05 23:45:12,925] Finished a trial resulted in value: 1.0257911319634438. Current best value is -0.507712 with parameters: {'x': 2.28833}.
[I 2018-12-05 23:45:13,003] Finished a trial resulted in value: 0.989037566945567. Current best value is -0.507712 with parameters: {'x': 2.28833}.
params: {'x': 2.28833}

データベースに記録されている試行回数が増えている。

$ mysql -u root -D optuna -e "SELECT COUNT(1) AS count FROM trials"
+-------+
| count |
+-------+
|   100 |
+-------+

ばっちり。