概要
Optuna の使い方,またその最適化について実装上の流れを確認していく.
ただし,TPE のアルゴリズムの実装について触れるわけではないので注意!
また,ここでは scikt-learn に対して用いたコードを基に見ていく.
Example
以下にscikit-learm を用いた例(引用元)を示す.
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
をざっと見てみる.
まず,
optuna.create_study()
で最適化の大枠を担う Studyクラスのインスタンスを作る.
見ての通り,その次の工程として
study.optimize(objective, n_trials=100)
で最適化が完了する.とても簡単でシンプルである.
objective
関数
次に,目的関数の設定方法について見ていく.
ここで objective
関数が守るべき性質として, 必ず trial
を引数にとり,最適化対象の値を返す.ただし, trial
は Traial クラスのインスタンスである.
また,Traial
クラスは suggest_*
系の関数を持っていて,この関数を通して最適なハイパラの推定をしている.
このことからわかるように,objective
関数は所謂目的関数の役割だけでなく,問題設定および推定フェーズも含む物となっている.
ハイパラの設定方法
次に,最適化対象のハイパラの設定方法について細かく見ていく.
例えば,対数一様分布を用いて, svc
の C
を最適化する場合は,以下のように,trial
を用いることで,
svc_c = trial.suggest_loguniform('svc_c', 1e-10, 1e10)
となる.
次に,suggest_loguniform
は float
を返しているためsvc_c
という変数は単純に以下のように SVC に渡せる.
sklearn.svm.SVC(C=svc_c)
また,以下を例に各引数について見てみると,
trial.suggest_loguniform(name, low, high)
-
name
: 最適化対象の変数管理のためのタグ -
low
: 最適化対象の分布の下界 -
high
: 最適化対象の分布の上界
となっている.
NOTE
ただし,suggest_loguniform
は設定した下界と上界の範囲内で設定した分布に従いサンプリングしているのではなく,この範囲内で分布に従いつつ,かつ最適であると推定した値を返していることに注意する.(suggest という名前の所以はそこなんだろう.)
最適化のターゲット設定方法
objective
関数内でメトリック等を用いて計算した得た値等を return
するだけ!
最適化の流れ
もう少し詳しく実装面での最適化の流れについて確認する.
理論については,以下を参照.
以降でも以下の関数を参考に見ていく.
trial.suggest_loguniform(name, low, high)
ちなみに 解説ページ#smbo を参考にすると,この関数は
x^{*} \leftarrow \textrm{argmin}_{x}S(x, M_{t-1})
の部分を担っていることがわかる.
関数の一つ深いところへ...
この関数の中身は docs 部分を省くと以下のようになっている.
def suggest_loguniform(self, name, low, high):
# type: (str, float, float) -> float
...
return self._suggest(name, distributions.LogUniformDistribution(low=low, high=high))
ここで,
distributions.LogUniformDistribution(low=low, high=high)
とあるが,これはどの分布であるかを管理するだけで,本体は実質ただの NamedTuple
である.
また,これは BaseDistribution
クラスも継承しているが,その docs の中に,
"""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
関数を見てみる.
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 というものを使っており,名前からも予想がつくが TPE のアルゴリズムを実行するものである.
このタイミング(もとを辿れば objective
関数のなかでのパラメータの suggest 部分)で推定可能にしているキモの内容としては,関数の引数に対象とする分布だけでなく,strage
とされるこれまでの推定ログが渡されていることにある.
これにより,これまでの結果に基づき,与えられた分布から最適だと思われるパラメータをサンプリングすることが可能になっている.
まとめ
Optuna の最適化におけるコアの部分は TPESampler が担っており,これが study
を介して strage
にアクセスすることで推定を可能にしていた.
objective
関数の役割は, Define-and-Run であると言われるだけあって,まさに雛形作りをしているというのが改めてわかった.