ハイパーパラメータ探索の基本的な手法#
本項では、機械学習におけるハイパーパラメータ探索の基本的な手法であるgrid searchとrandom searchについて解説します。機械学習におけるハイパーパラメータの定義から始め、また機械学習ライブラリscikit-learnにおけるハイパーパラメータ関連の実装についても簡単に触れます。
import warnings
warnings.filterwarnings("ignore")
import numpy as np
np.random.seed(42)
機械学習におけるハイパーパラメータ#
機械学習の文脈において、ハイパーパラメータ (hyperparameter) とは特定のアルゴリズムで特定のモデルの重み(パラメータ)の学習を開始する前に定めておく必要のあるパラメータのことを指します。個々の学習プロセスにおけるハイパーパラメータは学習に利用するモデル、アルゴリズム、あるいは解析全体のパイプラインに応じて異なりますが、典型的には以下のようなパラメータがハイパーパラメータとして扱われます。
モデルの構造に関するパラメータ#
モデルの構造に関するパラメータは、利用するアルゴリズムを通じて学習されない場合はハイパーパラメータとして扱われます。例えば、単変量の多項式回帰モデル
を通常の最小二乗法 (OLS) で学習する場合、モデルの最大次数 \(d\) はハイパーパラメータとなります。また、三層パーセプトロン (three-layer perceptron, TLP) を用いた単変量の回帰モデル
(ただし \(W^{(0)} = (w_{i, k}^{(0)})_{i, k}\) および \(W^{(1)} = (w_k^{(1)})_k\) とします)を確率的勾配降下法 (SGD) で学習する場合、中間層のユニット数 \(K\) や中間層の活性化関数 \(\sigma\) はハイパーパラメータとなります。
さらに、利用するモデルの種類を予め決めていない場合には、これら多項式回帰や三層パーセプトロン回帰といったモデルの種類そのものをハイパーパラメータとして考えることもできます。即ち、上記の例では関数 \(f_m\) のラベル \(m \in \{ \text{poly}, \text{TLP} \}\) 自体をハイパーパラメータと見なします。この場合、多項式回帰の最大次数 \(d\) は \(m = \text{poly}\) の場合のみ、三層パーセプトロンの中間層のユニット数 \(K\) や活性化関数 \(\sigma\) は \(m = \text{TLP}\) の場合のみそれぞれ存在する(あるいは、意味を持つ)ハイパーパラメータとなります。
ハイパーパラメータ間の階層性は一般的な性質で、モデルの種類の選択のみに関わる話ではありません。例えばサポートベクター回帰 (SVR) などのカーネル法では候補となるカーネル関数ごとに固有のパラメータ(例えば多項式カーネルの最大次数など)が存在しますし、一般の多層パーセプトロンでは中間層の層数というハイパーパラメータに応じて各中間層のユニット数というハイパーパラメータの個数が異なります。
正則化パラメータ#
線形回帰やニューラルネットワークなどのモデルで重みの正則化を行う場合、正則化項の乗数は(データから適応的に決定されるのでなければ)ハイパーパラメータとして扱われます。例えば重み \(w\) に対する \(L_2\)-正則化
における乗数 \(\lambda_2\) や、 \(L_1\)-正則化
における乗数 \(\lambda_1\) がそれにあたります。ただし \(L\) は適当な損失関数とします。
学習アルゴリズムのパラメータ#
機械学習で用いられる学習アルゴリズム(多くの場合は反復法による最適化アルゴリズム)にパラメータがある場合、それらも一般にハイパーパラメータとして扱われます。例えばモメンタムつきのミニバッチ勾配効果法
において、学習率 \(\eta > 0\) やモメンタムの係数 \(\gamma > 0\) 、またミニバッチサイズ \(B \equiv | \mathcal{B}^{(n)} |\) は一般にハイパーパラメータとして扱われます。なお、形式的には重みの初期値 \(w^{(0)}\) や個々のミニバッチ \(\mathcal{B}^{(n)}\) の具体的な値(実用上はそれぞれ対応するランダムシード)をハイパーパラメータと思うことも可能ですが、ランダム性の喪失による過学習の懸念などの理由もあり、通常は積極的に探索されることがありません。
データの前処理に関するパラメータ#
入力データに対して施す各種の前処理についても、場合によっては、予め指定するパラメータがある場合はそれを(解析全体のパイプラインにおける)ハイパーパラメータとして捉えた方がよいかもしれません。例えば前処理の中で主成分分析 (PCA) を用いて
(ただし \(V \in \mathbf{R}^{n \times p}\) とします)のように \(n\) 次元のデータ \(x \in \mathbf{R}^{1 \times n}\) を \(p\) 次元に圧縮している場合、この \(p\) を直接指定しているのであればハイパーパラメータとして捉えるのが適当です。
いま、入力 \(x \in \mathbf{R}^{1 \times n}\) を前処理 \(g ( \cdot ; v )\) で \(z = g ( x ; v ) \in \mathbf{R}^{1 \times p}\) に変換し、この \(z\) に対して関数 \(f ( \cdot ; w )\) を用いた回帰モデル \(y = f ( z ; w ) + \epsilon\) を当てはめるようなパイプラインを考えます( \(\epsilon\) は適当な雑音)。このとき \(y = f ( g ( x ; v ) ; w ) + \epsilon\) と書けるので、このパイプライン全体は関数 \(h ( \cdot ; v, w ) = f ( g ( \cdot ; v ) ; w )\) を用いた回帰モデルと思うことができます。即ち、前処理 \(g\) はこの回帰モデルの一部を取り出したものと思うことができ、 \(g\) のパラメータ \(v\) を学習することは \(h\) のパラメータ \((v, w)\) の一部 \(v\) を入力データから(残りの \(w\) とは別の方法で)学習しているものと思うことができるのです。
Scikit-learnでの実装例#
機械学習ライブラリのscikit-learnにおいて、これらハイパーパラメータは当該のモデルや前処理のクラスを初期化するときに与える引数として実装されています。例えば \(L_2\)-正則化つきの線形回帰(a.k.a. Ridge回帰)のクラス Ridge
では
from sklearn.linear_model import Ridge
_model = Ridge(
alpha=1.0, # float or array-like: 正則化パラメータ(ハイパーパラメータ)。
fit_intercept=True, # bool: 回帰モデルの切片項の有無。モデルの構造に関するパラメータ(ハイパーパラメータ)。
normalize=False, # bool: 入力のL2-正規化の有無。前処理に関するパラメータ(ハイパーパラメータ)。
copy_X=True, # bool: 入力を複製してクラス内部で保持するか否か。
max_iter=None, # int: 反復法による学習での最大反復回数。学習アルゴリズムのパラメータ(ハイパーパラメータ)。
tol=0.001, # float: 反復法による学習での停止条件。学習アルゴリズムのパラメータ(ハイパーパラメータ)。
solver="auto", # str: 学習アルゴリズムを指定するラベル。学習アルゴリズムのパラメータ(ハイパーパラメータ)。
random_state=None, # int, RandomState, or None: 学習時のランダムシード。
)
_model
Ridge(alpha=1.0, copy_X=True, fit_intercept=True, max_iter=None,
normalize=False, random_state=None, solver='auto', tol=0.001)
のように、このモデルの構造と学習に関する各種のハイパーパラメータを与えてクラスを初期化します(なお、scikit-learnの場合はすべてのハイパーパラメータにデフォルト値が設定されています)。ただし、引数 normalize
は fit_intercept=True
の場合のみ、 max_iter
や tol
は solver
で特定のアルゴリズムを指定した場合のみそれぞれ意味を持ちます。
前処理についても同様で、例えば主成分分析のクラス PCA
では
from sklearn.decomposition import PCA
_prep = PCA(
n_components=None, # int, float, None, or str: 圧縮後の成分数またはその導出方法(ハイパーパラメータ)。
copy=True, # bool: 入力を複製してクラス内で保持するか否か。
whiten=False, # bool: 事前の白色化の有無(ハイパーパラメータ)。
svd_solver="auto", # str: 特異値分解 (SVD) のソルバの指定(ハイパーパラメータ)。
tol=0.0, # float: 反復法によるSVDでの停止条件(ハイパーパラメータ)。
iterated_power="auto", # int or str: 冪乗法によるSVDでの反復回数(ハイパーパラメータ)。
random_state=None, # int, RandomState, or None: 学習時のランダムシード。
)
_prep
PCA(copy=True, iterated_power='auto', n_components=None, random_state=None,
svd_solver='auto', tol=0.0, whiten=False)
のように、やはりこの前処理のパラメータを学習するのに必要なハイパーパラメータを与えてクラスを初期化します。ただし、引数 tol
と iterated_power
は solver
で特定のソルバを指定した場合のみ意味を持ちます。
Scikit-learnでは上記の関数 \(h\) のように、入力をPCAで圧縮して(関数 \(g\) )からRidge回帰のモデル(関数 \(f\) )を学習するというパイプラインを単一のオブジェクトにまとめることもできます。これには Pipeline
クラスを利用して
from sklearn.pipeline import Pipeline
pca_ridge = Pipeline(
steps=[
("pca", PCA(n_components=3)), # 'pca' という名前でPCAによる変換を登録する
("ridge", Ridge(alpha=0.01)), # 'ridge' という名前でRidge回帰を登録する
], # Transformerを含むタプルを0個以上と、最後にEstimatorを含むタプルを1個だけ並べたリスト
)
pca_ridge
Pipeline(memory=None,
steps=[('pca',
PCA(copy=True, iterated_power='auto', n_components=3,
random_state=None, svd_solver='auto', tol=0.0,
whiten=False)),
('ridge',
Ridge(alpha=0.01, copy_X=True, fit_intercept=True,
max_iter=None, normalize=False, random_state=None,
solver='auto', tol=0.001))],
verbose=False)
のように記述します。パイプライン中の各ステップのオブジェクトに渡された引数は、パイプラインに登録した対応する名前によるmanglingが行われて
pca_ridge.get_params()
{'memory': None,
'steps': [('pca',
PCA(copy=True, iterated_power='auto', n_components=3, random_state=None,
svd_solver='auto', tol=0.0, whiten=False)),
('ridge', Ridge(alpha=0.01, copy_X=True, fit_intercept=True, max_iter=None,
normalize=False, random_state=None, solver='auto', tol=0.001))],
'verbose': False,
'pca': PCA(copy=True, iterated_power='auto', n_components=3, random_state=None,
svd_solver='auto', tol=0.0, whiten=False),
'ridge': Ridge(alpha=0.01, copy_X=True, fit_intercept=True, max_iter=None,
normalize=False, random_state=None, solver='auto', tol=0.001),
'pca__copy': True,
'pca__iterated_power': 'auto',
'pca__n_components': 3,
'pca__random_state': None,
'pca__svd_solver': 'auto',
'pca__tol': 0.0,
'pca__whiten': False,
'ridge__alpha': 0.01,
'ridge__copy_X': True,
'ridge__fit_intercept': True,
'ridge__max_iter': None,
'ridge__normalize': False,
'ridge__random_state': None,
'ridge__solver': 'auto',
'ridge__tol': 0.001}
のように、例えば 'pca'
の 'tol'
なら 'pca__tol'
として、 'ridge'
の 'tol'
なら 'ridge__tol'
として、パイプライン中で一意に識別されます。パイプラインは上述の関数 \(h\) のように全体で1つの識別器ないし回帰器として機能し、
from sklearn.datasets import load_diabetes
x, y = load_diabetes(return_X_y=True) # 糖尿病データセットを利用
x_train, y_train = x[:300], y[:300] # 訓練データ
x_test, y_test = x[300:], y[300:] # 試験データ
pca_ridge.fit(x_train, y_train)
print(f"Test R2 score: {pca_ridge.score(x_test, y_test)}.")
Test R2 score: 0.38736989961757406.
のように、通常の識別器や回帰器であるかのように利用できます。
ハイパーパラメータの探索#
これらのハイパーパラメータは学習の目的に応じて適切に選択される必要があります。機械学習の文脈では主にモデルの未知データに対する予測性能が重視されるため、訓練データで交差検証 (cross validation, CV) を行って適当な損失を評価し、複数の候補の中で平均の損失が最も小さいハイパーパラメータを選択することが一般的です。
交差検証の対象とするハイパーパラメータ(の組)の探索には多様な戦略が存在します。前述の通り、本稿ではその最も基本的なものとして主にgrid searchおよびrandom searchという2つのアルゴリズムを解説します。
Manual Search#
最適なハイパーパラメータを解析者が手動で (manually) 探索することを、俗にmanual searchなどと呼ぶことがあります。属人化されたいわゆる「ノウハウ」のような解析者の趣味嗜好を反映でき、後述する random search や他の先進的な探索手法の実行前に(コードスニペットの試験を兼ねて)試験的に、あるいはそれらの探索手法を実行するための時間・計算資源・実装能力などが不足している場合に力尽くでしばしば行われます。
Scikit-learnでmanual searchを行う場合、上述の Pipeline
を clone()
関数や set_params()
メソッドと組み合わせるのが相対的にはスマートです。実装の一例は以下の通り:
from sklearn.base import clone
from sklearn.model_selection import cross_validate
base_pipeline = Pipeline(
steps=[
("pca", PCA()), # ここではパラメータを何も設定しない
("ridge", Ridge()),
]
)
manual_search_candidates = [] # 探索したパラメータを格納するためのリスト
manual_search_results = [] # 探索した結果を格納するためのリスト
def execute_manual_search(**candidate_params):
cloned_estimator = clone(base_pipeline) # estimator(ここではpipeline)を複製する
cloned_estimator.set_params(**candidate_params)
scores = cross_validate(
estimator=cloned_estimator,
X=x_train,
y=y_train,
cv=3, # 3-fold CVで評価
scoring="neg_mean_squared_error", # CVのスコアに負のMSEを指定する
return_estimator=True, # CVの際にfitしたestimatorたちを返却する
)
manual_search_candidates.append(candidate_params)
manual_search_results.append(scores)
この execute_manual_search()
関数を(引数を変えながら)何度も繰り返し実行することで、気の済むまでmanual searchを行うことができます(以下では試しに2回ほど実行してみます):
execute_manual_search(pca__n_components=5, ridge__alpha=0.01)
execute_manual_search(pca__n_components=7, ridge__alpha=0.1)
manual_search_candidates
[{'pca__n_components': 5, 'ridge__alpha': 0.01},
{'pca__n_components': 7, 'ridge__alpha': 0.1}]
manual_search_results
[{'fit_time': array([0.00148106, 0.00134587, 0.00102687]),
'score_time': array([0.00140572, 0.00041318, 0.00041318]),
'estimator': (Pipeline(memory=None,
steps=[('pca',
PCA(copy=True, iterated_power='auto', n_components=5,
random_state=None, svd_solver='auto', tol=0.0,
whiten=False)),
('ridge',
Ridge(alpha=0.01, copy_X=True, fit_intercept=True,
max_iter=None, normalize=False, random_state=None,
solver='auto', tol=0.001))],
verbose=False), Pipeline(memory=None,
steps=[('pca',
PCA(copy=True, iterated_power='auto', n_components=5,
random_state=None, svd_solver='auto', tol=0.0,
whiten=False)),
('ridge',
Ridge(alpha=0.01, copy_X=True, fit_intercept=True,
max_iter=None, normalize=False, random_state=None,
solver='auto', tol=0.001))],
verbose=False), Pipeline(memory=None,
steps=[('pca',
PCA(copy=True, iterated_power='auto', n_components=5,
random_state=None, svd_solver='auto', tol=0.0,
whiten=False)),
('ridge',
Ridge(alpha=0.01, copy_X=True, fit_intercept=True,
max_iter=None, normalize=False, random_state=None,
solver='auto', tol=0.001))],
verbose=False)),
'test_score': array([-2873.12718642, -2906.20042626, -3452.08019009])},
{'fit_time': array([0.0009737 , 0.00104594, 0.0009141 ]),
'score_time': array([0.0004251 , 0.00039721, 0.00040221]),
'estimator': (Pipeline(memory=None,
steps=[('pca',
PCA(copy=True, iterated_power='auto', n_components=7,
random_state=None, svd_solver='auto', tol=0.0,
whiten=False)),
('ridge',
Ridge(alpha=0.1, copy_X=True, fit_intercept=True,
max_iter=None, normalize=False, random_state=None,
solver='auto', tol=0.001))],
verbose=False), Pipeline(memory=None,
steps=[('pca',
PCA(copy=True, iterated_power='auto', n_components=7,
random_state=None, svd_solver='auto', tol=0.0,
whiten=False)),
('ridge',
Ridge(alpha=0.1, copy_X=True, fit_intercept=True,
max_iter=None, normalize=False, random_state=None,
solver='auto', tol=0.001))],
verbose=False), Pipeline(memory=None,
steps=[('pca',
PCA(copy=True, iterated_power='auto', n_components=7,
random_state=None, svd_solver='auto', tol=0.0,
whiten=False)),
('ridge',
Ridge(alpha=0.1, copy_X=True, fit_intercept=True,
max_iter=None, normalize=False, random_state=None,
solver='auto', tol=0.001))],
verbose=False)),
'test_score': array([-2909.79218127, -2995.17625416, -3470.24066289])}]
cross_validate()
関数の帰り値は辞書型で、交差検証の各foldのスコアは 'test_score'
キーに格納されています。したがって、各ハイパーパラメータに対応するfold平均の負のMSEは
cv_scores = list(map(lambda d: np.mean(d["test_score"]), manual_search_results))
cv_scores
[-3077.1359342573646, -3125.069699436964]
と計算できます。今回(手動で)探索した範囲では、最初の候補が最もよいハイパーパラメータだったようです。
探索の後に最適なハイパーパラメータを用いて(広義の)訓練データ全体を用いて学習する際も、 clone()
関数や set_params()
メソッドを利用するのが便利です。実装の一例は以下の通りです:
mscv_pipeline = clone(base_pipeline)
mscv_pipeline.set_params(**manual_search_candidates[0])
mscv_pipeline.fit(x_train, y_train)
print(f"Test R2 score: {mscv_pipeline.score(x_test, y_test)}.")
Test R2 score: 0.4891460259649496.
Grid Search#
予め候補となるハイパーパラメータの値をすべて列挙し、それらの候補を順にすべて探索するという戦略は少し計算機の使い方を知っている人なら誰でも思いつくものです。機械学習の分野では、一般にこうした力任せの (brute-force)、あるいはしらみつぶしの (exhaustive) 方法をgrid searchと呼びます。まず個々のハイパーパラメータについて値の候補を列挙し、ハイパーパラメータの空間上でそれらの組み合わせがなす格子 (grid) の各点をすべて探索するというイメージからこうした名前がついています。
Scikit-learnでgrid searchを行う場合、上述の Pipeline
を GridSearchCV
クラスと組み合わせて使うのが効率的です。この GridSearchCV
クラスはこれ自身がestimatorとしてふるまい、交差検証により決定した最適なハイパーパラメータで(広義の、交差検証で分割する前の)訓練データ全体を用いて学習を実行してくれます。実装の一例は以下の通りです:
from sklearn.model_selection import GridSearchCV
# base_pipeline = Pipeline(
# steps=[
# ('pca', PCA()), # ここではパラメータを何も設定しない
# ('ridge', Ridge()),
# ]
# )
param_grid = [
{
"pca__n_components": [3, 5, 7],
"pca__whiten": [False],
"ridge__alpha": [0.1, 0.01],
},
{
"pca__n_components": [2, 4, 6],
"pca__whiten": [True],
"ridge__alpha": [1, 0.1],
},
] # PCAの 'whiten' の値に応じて異なるgridを設定する
gscv_pipeline = GridSearchCV(
estimator=base_pipeline,
param_grid=param_grid,
cv=3, # 3-fold CVで評価
scoring="neg_mean_squared_error", # CVのスコアに負のMSEを指定する
)
gscv_pipeline.fit(X=x_train, y=y_train)
gscv_pipeline
GridSearchCV(cv=3, error_score=nan,
estimator=Pipeline(memory=None,
steps=[('pca',
PCA(copy=True, iterated_power='auto',
n_components=None,
random_state=None,
svd_solver='auto', tol=0.0,
whiten=False)),
('ridge',
Ridge(alpha=1.0, copy_X=True,
fit_intercept=True, max_iter=None,
normalize=False,
random_state=None, solver='auto',
tol=0.001))],
verbose=False),
iid='deprecated', n_jobs=None,
param_grid=[{'pca__n_components': [3, 5, 7],
'pca__whiten': [False], 'ridge__alpha': [0.1, 0.01]},
{'pca__n_components': [2, 4, 6], 'pca__whiten': [True],
'ridge__alpha': [1, 0.1]}],
pre_dispatch='2*n_jobs', refit=True, return_train_score=False,
scoring='neg_mean_squared_error', verbose=0)
gscv_pipeline.cv_results_ # CVの結果を表示
{'mean_fit_time': array([0.00181619, 0.00122118, 0.00095828, 0.00091028, 0.00108735,
0.00116928, 0.00108798, 0.00125329, 0.00112669, 0.00102655,
0.00112232, 0.00094469]),
'std_fit_time': array([6.15454410e-04, 1.44303850e-04, 1.00132271e-04, 2.31654866e-05,
1.05855224e-04, 1.59919972e-04, 1.09204096e-04, 1.31135179e-04,
7.33540092e-05, 5.18183766e-05, 8.29870960e-05, 6.83218889e-05]),
'mean_score_time': array([0.00050497, 0.00042089, 0.00038711, 0.0003887 , 0.00052396,
0.00047429, 0.00044203, 0.00047135, 0.00050743, 0.00041803,
0.00046269, 0.00040102]),
'std_score_time': array([6.80511392e-05, 2.52786165e-05, 7.01165040e-06, 2.59232623e-06,
1.62628309e-04, 6.19430658e-05, 4.55427334e-05, 4.20068007e-05,
1.27756225e-04, 4.34128452e-06, 2.37162247e-05, 9.28506966e-06]),
'param_pca__n_components': masked_array(data=[3, 3, 5, 5, 7, 7, 2, 2, 4, 4, 6, 6],
mask=[False, False, False, False, False, False, False, False,
False, False, False, False],
fill_value='?',
dtype=object),
'param_pca__whiten': masked_array(data=[False, False, False, False, False, False, True, True,
True, True, True, True],
mask=[False, False, False, False, False, False, False, False,
False, False, False, False],
fill_value='?',
dtype=object),
'param_ridge__alpha': masked_array(data=[0.1, 0.01, 0.1, 0.01, 0.1, 0.01, 1, 0.1, 1, 0.1, 1,
0.1],
mask=[False, False, False, False, False, False, False, False,
False, False, False, False],
fill_value='?',
dtype=object),
'params': [{'pca__n_components': 3,
'pca__whiten': False,
'ridge__alpha': 0.1},
{'pca__n_components': 3, 'pca__whiten': False, 'ridge__alpha': 0.01},
{'pca__n_components': 5, 'pca__whiten': False, 'ridge__alpha': 0.1},
{'pca__n_components': 5, 'pca__whiten': False, 'ridge__alpha': 0.01},
{'pca__n_components': 7, 'pca__whiten': False, 'ridge__alpha': 0.1},
{'pca__n_components': 7, 'pca__whiten': False, 'ridge__alpha': 0.01},
{'pca__n_components': 2, 'pca__whiten': True, 'ridge__alpha': 1},
{'pca__n_components': 2, 'pca__whiten': True, 'ridge__alpha': 0.1},
{'pca__n_components': 4, 'pca__whiten': True, 'ridge__alpha': 1},
{'pca__n_components': 4, 'pca__whiten': True, 'ridge__alpha': 0.1},
{'pca__n_components': 6, 'pca__whiten': True, 'ridge__alpha': 1},
{'pca__n_components': 6, 'pca__whiten': True, 'ridge__alpha': 0.1}],
'split0_test_score': array([-3914.12668749, -3979.22912682, -2914.44469595, -2873.12718642,
-2909.79218127, -2865.91386155, -4033.83444931, -4039.12307587,
-2841.93533539, -2843.54594757, -2864.34445161, -2866.13151372]),
'split1_test_score': array([-4301.62826473, -4291.12170112, -2974.32866375, -2906.20042626,
-2995.17625416, -2932.09104144, -4414.50948447, -4415.95441967,
-2880.40062295, -2881.10825737, -2911.40025698, -2912.03730424]),
'split2_test_score': array([-4361.60958027, -4359.33259845, -3474.18703931, -3452.08019009,
-3470.24066289, -3448.01803987, -4444.92823666, -4439.92408077,
-3574.68880672, -3572.13857598, -3461.94018055, -3460.25989039]),
'mean_test_score': array([-4192.45484416, -4209.89447546, -3120.98679967, -3077.13593426,
-3125.06969944, -3082.00764762, -4297.75739015, -4298.33385877,
-3099.00825502, -3098.93092697, -3079.22829638, -3079.47623612]),
'std_test_score': array([198.32525751, 165.46512004, 250.94399025, 265.46921483,
246.54932635, 260.21473211, 187.0344249 , 183.55073529,
336.72331375, 334.95954093, 271.29916073, 269.9061305 ]),
'rank_test_score': array([ 9, 10, 7, 1, 8, 4, 11, 12, 6, 5, 2, 3], dtype=int32)}
GridSearchCV.score()
メソッドで返されるスコアは元々の識別器や回帰器に設定されているものでなく、引数 scoring
で指定したものになります。識別器 or 回帰器に設定されたスコアを利用したい場合は、 GridSearchCV.best_estimator_
に格納されている訓練データ全体で学習したestimatorの score()
メソッドを叩くことになります:
print(f"Test R2 score: {gscv_pipeline.best_estimator_.score(x_test, y_test)}.")
Test R2 score: 0.4891460259649496.
Random Search#
ところで、冷静に考えてみるとmanual searchにせよ、grid searchにせよ探索すべきハイパーパラメータの値(の組)を人間がすべて指定しているという点では変わりありません。「後は適当によろしく」と仔細を計算機に丸投げする方法はないでしょうか? とりあえず最初に思いつくのは、探索するハイパーパラメータの値をその都度、計算機によってランダムに決めてもらう方法です。機械学習の分野では、(特に、すでに探索したハイパーパラメータの値のスコアの情報を利用しない場合)こうした手法をrandom searchと呼びます。
Scikit-learnでrandom searchを行う場合、やはり上述の Pipeline
を RandomizedSearchCV
クラスと組み合わせて使うのが効率的です。名前が示す通り、この RandomizedSearchCV
クラスのふるまいは GridSearchCV
とほぼ同じです。ただし、個々のハイパーパラメータをサンプリングしてくる確率分布の設定は、引数 param_distributions
に渡す辞書の中で
リストとして指定する
各ステップでこのリストの要素から等確率でサンプリングして探索する
o.rvs(random_state=rng)
の形でrvs()
メソッドを呼べるオブジェクトとして指定する各ステップでこのメソッドの返り値を用いて探索する
scipy.stats.distributions
の確率分布からサンプリングするのと同じ呼び方
の2通りが可能になっています。つまり、有限個の要素からランダムに選ぶのでよければリストを指定し、出来合いの有名な確率分布からサンプリングするのでよければ scipy.stats.distributions
の確率分布を指定し、自分で考えた素晴らしい確率分布からサンプリングしたいのであれば数十行のコードを実装して適当にラップしたものを指定することになります:
from scipy.stats.distributions import binom, lognorm
from sklearn.model_selection import RandomizedSearchCV
# base_pipeline = Pipeline(
# steps=[
# ('pca', PCA()), # ここではパラメータを何も設定しない
# ('ridge', Ridge()),
# ]
# )
param_distributions = [
{
"pca__n_components": binom(
n=9, p=0.5, loc=1
), # 台をずらした二項分布からサンプリング
"pca__whiten": [False],
"ridge__alpha": lognorm(s=1.0), # 対数正規分布からサプリング
},
{
"pca__n_components": binom(n=10, p=0.4),
"pca__whiten": [True],
"ridge__alpha": lognorm(s=10.0),
},
] # PCAの 'whiten' の値に応じて異なる分布を設定する
rscv_pipeline = RandomizedSearchCV(
estimator=base_pipeline,
param_distributions=param_distributions,
n_iter=10, # 10個のサンプルを探索
cv=3, # 3-fold CVで評価
scoring="neg_mean_squared_error", # CVのスコアに負のMSEを指定する
)
rscv_pipeline.fit(X=x_train, y=y_train)
rscv_pipeline
RandomizedSearchCV(cv=3, error_score=nan,
estimator=Pipeline(memory=None,
steps=[('pca',
PCA(copy=True,
iterated_power='auto',
n_components=None,
random_state=None,
svd_solver='auto', tol=0.0,
whiten=False)),
('ridge',
Ridge(alpha=1.0, copy_X=True,
fit_intercept=True,
max_iter=None,
normalize=False,
random_state=None,
solver='auto',
tol=0.001))],
verbose=False),
iid='d...
'ridge__alpha': <scipy.stats._distn_infrastructure.rv_frozen object at 0x12d1ad190>},
{'pca__n_components': <scipy.stats._distn_infrastructure.rv_frozen object at 0x12d1ad210>,
'pca__whiten': [True],
'ridge__alpha': <scipy.stats._distn_infrastructure.rv_frozen object at 0x12d1ad250>}],
pre_dispatch='2*n_jobs', random_state=None, refit=True,
return_train_score=False, scoring='neg_mean_squared_error',
verbose=0)
rscv_pipeline.cv_results_ # CVの結果を表示
{'mean_fit_time': array([0.00107797, 0.00102433, 0.00085425, 0.00104698, 0.0010287 ,
0.00085227, 0.00096019, 0.00093738, 0.00095344, 0.00082898]),
'std_fit_time': array([1.72078581e-04, 1.37926104e-04, 2.63859469e-05, 2.19813856e-04,
1.35267243e-04, 3.87781953e-05, 7.89753121e-05, 1.00156678e-04,
8.98008149e-06, 2.12286275e-05]),
'mean_score_time': array([0.00050632, 0.00039522, 0.0003744 , 0.00040094, 0.00038544,
0.00044545, 0.0004158 , 0.00045594, 0.00041954, 0.00036375]),
'std_score_time': array([1.61733381e-04, 1.82991329e-05, 1.85306443e-05, 2.36000943e-05,
3.17095714e-06, 7.94713497e-05, 2.69592276e-05, 9.70872800e-05,
3.84366113e-05, 7.41103060e-06]),
'param_pca__n_components': masked_array(data=[7, 4, 4, 5, 2, 3, 6, 4, 7, 3],
mask=[False, False, False, False, False, False, False, False,
False, False],
fill_value='?',
dtype=object),
'param_pca__whiten': masked_array(data=[False, False, False, True, True, True, False, False,
True, True],
mask=[False, False, False, False, False, False, False, False,
False, False],
fill_value='?',
dtype=object),
'param_ridge__alpha': masked_array(data=[1.7224421879716991, 0.540408594210446,
0.13385972044238456, 0.007240722754232799,
10.287734810693072, 3.254198873109517,
1.022470570498426, 0.6519464100532789,
0.08123251500595977, 0.19423798111165178],
mask=[False, False, False, False, False, False, False, False,
False, False],
fill_value='?',
dtype=object),
'params': [{'pca__n_components': 7,
'pca__whiten': False,
'ridge__alpha': 1.7224421879716991},
{'pca__n_components': 4,
'pca__whiten': False,
'ridge__alpha': 0.540408594210446},
{'pca__n_components': 4,
'pca__whiten': False,
'ridge__alpha': 0.13385972044238456},
{'pca__n_components': 5,
'pca__whiten': True,
'ridge__alpha': 0.007240722754232799},
{'pca__n_components': 2,
'pca__whiten': True,
'ridge__alpha': 10.287734810693072},
{'pca__n_components': 3,
'pca__whiten': True,
'ridge__alpha': 3.254198873109517},
{'pca__n_components': 6,
'pca__whiten': False,
'ridge__alpha': 1.022470570498426},
{'pca__n_components': 4,
'pca__whiten': False,
'ridge__alpha': 0.6519464100532789},
{'pca__n_components': 7,
'pca__whiten': True,
'ridge__alpha': 0.08123251500595977},
{'pca__n_components': 3,
'pca__whiten': True,
'ridge__alpha': 0.19423798111165178}],
'split0_test_score': array([-3729.58474745, -3196.07260277, -2915.91493697, -2870.97294222,
-3987.12904445, -3966.9025722 , -3459.28635819, -3262.62320597,
-2863.17182345, -3987.23000511]),
'split1_test_score': array([-4328.3635136 , -3518.50655607, -3030.86424179, -2904.47414837,
-4405.03739385, -4287.85723233, -3917.50144274, -3626.89365223,
-2931.14725318, -4290.21780104]),
'split2_test_score': array([-4891.61449025, -4052.75991536, -3620.93218173, -3457.23769134,
-4497.17339605, -4375.48612061, -4427.02097882, -4165.68361321,
-3453.35701473, -4362.89611151]),
'mean_test_score': array([-4316.5209171 , -3589.11302474, -3189.23712016, -3077.56159397,
-4296.44661145, -4210.08197505, -3934.60292658, -3685.0668238 ,
-3082.55869712, -4213.44797255]),
'std_test_score': array([474.47055835, 353.28670411, 308.84063345, 268.81968787,
221.93133873, 175.63574426, 395.2610273 , 370.96056682,
263.65850264, 162.68878429]),
'rank_test_score': array([10, 4, 3, 1, 9, 7, 6, 5, 2, 8], dtype=int32)}
print(f"Test R2 score: {rscv_pipeline.best_estimator_.score(x_test, y_test)}.")
Test R2 score: 0.4874803776721546.
小括#
本稿では、機械学習におけるハイパーパラメータの定義とその基本的な探索手法、およびそれらのscikit-learnにおける実装について簡単に解説しました。
それで、結局ハイパーパラメータの探索はどうすればよいのでしょうか? 参考までに、 [Bergstra and Bengio, 2012] で挙げられている各手法の利点には次のようなものがあります:
Manual search:
解析者がハイパーパラメータと汎化性能の関係についての洞察を得られる
実行する上で技術的なオーバーヘッドや障壁が存在しない
Grid search:
実装が容易で並列化も自明
同じ時間をかけるならmanual searchより良い候補が見つかりやすい
ハイパーパラメータの数が少ないときは信頼できる
Random search:
どの時点で探索を中止しても完全な結果が得られる
追加の計算資源が得られた場合、既存の計画を変更することなく新しい試行を追加できる
個々の候補に対する試行を非同期に実行できる
個々の候補に対する試行で失敗しても、探索全体を危険に晒すことなく放棄ないし再実行できる
この内容を素直に受け取るのであれば、必要に応じてまず数回〜十数回程度のmanual searchで全体の雰囲気を掴んだ上で、探索するハイパーパラメータの数が(利用できる計算資源・時間と比較して相対的に)少なくて密なgridが組める場合はgrid searchを、そうでなければrandom searchを行って最適なハイパーパラメータを選ぶのが良さそうに思えます。
とはいえgrid searchも、random searchも今までの探索結果を利用せずに次の候補を決めているし、一度評価すると決めた点は最後まできっちり評価するという点では大差ありません。最初のmanual searchで意識せずともそうしていたように、何となく良さそうなハイパーパラメータを選んで学習するとか、学習や交差検証の途中でも結果が余りにどうしようもないときはそのハイパーパラメータでの学習を打ち切って次の候補に移るとか、そういうもっと柔軟で知的な探索はできないのでしょうか?(実際これは一部の人々が十分な時間も、計算資源もあるのにmanual searchを偏愛する理由になっています)
実は、ハイパーパラメータ探索の問題はハイパーパラメータ(の組)を与えると対応するスコアを返す(確率的な)関数の最適化問題と捉えることができ、その意味でハイパーパラメータ最適化 (hyperparameter optimization, HPO) の問題と呼ばれることもあります。近年ではこの観点からベイズ最適化やバンディット最適化の知見を取り入れた探索手法が研究され、そうした効率のよい探索を実行するものとして広く利用されています。
参考文献#
Scikit-learn API Reference
Bergstra, J. and Bengio, Y. (2012). Random search for hyper-parameter optimization. JMLR 13, 281-305.