ハイパーパラメータチューニングは、機械学習モデルの性能を最大化するための重要なプロセスです。学習アルゴリズムで自動的に決まらないパラメータを、データに基づいて最適化します。
本記事では、グリッドサーチからベイズ最適化まで、各手法の理論と実装を解説します。
本記事の内容
- ハイパーパラメータとは何か
- グリッドサーチの理論と限界
- ランダムサーチの有効性
- ベイズ最適化の理論
- Optuna/Hyperoptによる実装
前提知識
この記事を読む前に、以下の記事を読んでおくと理解が深まります。
ハイパーパラメータとは
定義
機械学習におけるパラメータとハイパーパラメータは以下のように区別されます。
| 種類 | 決定方法 | 例 |
|---|---|---|
| パラメータ | 訓練データから学習 | 重み $\bm{w}$、バイアス $b$ |
| ハイパーパラメータ | 人間が設定 or 探索 | 学習率 $\eta$、正則化係数 $\lambda$ |
最適化の定式化
ハイパーパラメータ $\bm{\lambda} \in \Lambda$ に対して、検証誤差を最小化する問題として定式化されます:
$$ \bm{\lambda}^* = \arg\min_{\bm{\lambda} \in \Lambda} \mathcal{L}_{\text{val}}(\bm{\theta}^*(\bm{\lambda}), \bm{\lambda}) $$
ここで、$\bm{\theta}^*(\bm{\lambda})$ はハイパーパラメータ $\bm{\lambda}$ のもとで訓練されたモデルのパラメータです。
課題
この最適化問題は以下の理由で困難です:
- 評価コストが高い: 1回の評価にモデル訓練が必要
- 勾配が得られない: $\mathcal{L}_{\text{val}}$ は $\bm{\lambda}$ に対して微分不可能
- 非凸性: 局所最適解が多数存在
- ノイズ: 交差検証の分散による評価ノイズ
グリッドサーチ
理論
探索空間を格子状に離散化し、全ての組み合わせを評価します。
$d$ 個のハイパーパラメータがあり、各パラメータで $k$ 個の値を試す場合:
$$ \text{総評価回数} = k^d $$
アルゴリズム
1. 各ハイパーパラメータの候補値を設定
2. 全ての組み合わせを列挙
3. 各組み合わせで交差検証を実行
4. 最良のスコアを持つ組み合わせを選択
限界
次元の呪い: パラメータ数が増えると組み合わせ数が指数的に増加
例:5つのパラメータ、各10値 → $10^5 = 100,000$ 回の評価
非効率性: 重要でないパラメータにも均等にリソースを配分
ランダムサーチ
理論
Bergstra & Bengio (2012) により、ランダムサーチがグリッドサーチより効率的であることが示されました。
探索空間を確率分布として定義:
$$ \bm{\lambda} \sim p(\bm{\lambda}) = \prod_{i=1}^{d} p_i(\lambda_i) $$
なぜ効率的か
重要なパラメータが少数の場合、ランダムサーチはその重要な次元をより密にサンプリングします。
例:2つのパラメータのうち1つだけが重要な場合 – グリッドサーチ(9点): 重要な次元で3つの値を試行 – ランダムサーチ(9点): 重要な次元で約9つの異なる値を試行
理論的保証
$n$ 回のランダムサンプリングで、上位 $\alpha$ パーセントの領域を少なくとも1回サンプリングする確率:
$$ P(\text{success}) = 1 – (1 – \alpha)^n $$
例:$n=60$, $\alpha=0.05$(上位5%)の場合:
$$ P(\text{success}) = 1 – 0.95^{60} \approx 0.95 $$
ベイズ最適化
理論
ベイズ最適化は、目的関数 $f(\bm{\lambda})$ のサロゲートモデル(代理モデル)を構築し、獲得関数を用いて次の評価点を選択します。
アルゴリズムの流れ:
- 初期点でいくつか評価を実行
- 観測データに基づきサロゲートモデルを更新
- 獲得関数を最大化して次の評価点を選択
- その点を評価し、2に戻る
サロゲートモデル:ガウス過程
観測データ $\mathcal{D} = \{(\bm{\lambda}_i, y_i)\}_{i=1}^n$ に対して、ガウス過程は予測分布を提供します:
$$ f(\bm{\lambda}) \mid \mathcal{D} \sim \mathcal{GP}(\mu(\bm{\lambda}), \sigma^2(\bm{\lambda})) $$
予測平均と分散:
$$ \mu(\bm{\lambda}) = \bm{k}(\bm{\lambda})^\top (\bm{K} + \sigma_n^2 \bm{I})^{-1} \bm{y} $$
$$ \sigma^2(\bm{\lambda}) = k(\bm{\lambda}, \bm{\lambda}) – \bm{k}(\bm{\lambda})^\top (\bm{K} + \sigma_n^2 \bm{I})^{-1} \bm{k}(\bm{\lambda}) $$
獲得関数
Expected Improvement (EI)
現在の最良値 $f^* = \min_i y_i$ からの改善の期待値:
$$ \text{EI}(\bm{\lambda}) = \mathbb{E}[\max(f^* – f(\bm{\lambda}), 0)] $$
閉形式の解:
$$ \text{EI}(\bm{\lambda}) = (f^* – \mu(\bm{\lambda})) \Phi(Z) + \sigma(\bm{\lambda}) \phi(Z) $$
ここで、$Z = (f^* – \mu(\bm{\lambda})) / \sigma(\bm{\lambda})$、$\Phi$ と $\phi$ は標準正規分布のCDFとPDFです。
Upper Confidence Bound (UCB)
$$ \text{UCB}(\bm{\lambda}) = \mu(\bm{\lambda}) – \kappa \sigma(\bm{\lambda}) $$
$\kappa$ は探索と活用のバランスを制御します。
Tree-structured Parzen Estimator (TPE)
Hyperopt や Optuna で使用されるアルゴリズムです。
目的関数の値を閾値 $y^*$ で分割:
$$ p(\bm{\lambda} \mid y < y^*) = \ell(\bm{\lambda}), \quad p(\bm{\lambda} \mid y \geq y^*) = g(\bm{\lambda}) $$
EI の代わりに以下を最大化:
$$ \frac{\ell(\bm{\lambda})}{g(\bm{\lambda})} $$
Pythonでの実装
グリッドサーチとランダムサーチ
import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import GridSearchCV, RandomizedSearchCV
from sklearn.svm import SVC
from sklearn.datasets import make_classification
from scipy.stats import uniform, loguniform
# データ生成
X, y = make_classification(n_samples=500, n_features=20,
n_informative=10, random_state=42)
# グリッドサーチ
param_grid = {
'C': [0.1, 1, 10, 100],
'gamma': [0.001, 0.01, 0.1, 1],
'kernel': ['rbf']
}
grid_search = GridSearchCV(SVC(), param_grid, cv=5, scoring='accuracy', n_jobs=-1)
grid_search.fit(X, y)
print("=== グリッドサーチ ===")
print(f"評価回数: {len(grid_search.cv_results_['params'])}")
print(f"最良パラメータ: {grid_search.best_params_}")
print(f"最良スコア: {grid_search.best_score_:.4f}")
# ランダムサーチ
param_distributions = {
'C': loguniform(0.1, 100), # 対数一様分布
'gamma': loguniform(0.001, 1), # 対数一様分布
'kernel': ['rbf']
}
random_search = RandomizedSearchCV(SVC(), param_distributions, n_iter=16,
cv=5, scoring='accuracy', n_jobs=-1,
random_state=42)
random_search.fit(X, y)
print("\n=== ランダムサーチ ===")
print(f"評価回数: {random_search.n_iter}")
print(f"最良パラメータ: {random_search.best_params_}")
print(f"最良スコア: {random_search.best_score_:.4f}")
ベイズ最適化(スクラッチ実装)
import numpy as np
from scipy.stats import norm
from scipy.optimize import minimize
import matplotlib.pyplot as plt
class BayesianOptimizer:
"""ベイズ最適化のスクラッチ実装"""
def __init__(self, bounds, kernel_scale=1.0, length_scale=0.1, noise=1e-6):
self.bounds = np.array(bounds)
self.kernel_scale = kernel_scale
self.length_scale = length_scale
self.noise = noise
self.X_observed = []
self.y_observed = []
def rbf_kernel(self, X1, X2):
"""RBFカーネル"""
X1 = np.atleast_2d(X1)
X2 = np.atleast_2d(X2)
dists = np.sum((X1[:, None, :] - X2[None, :, :]) ** 2, axis=-1)
return self.kernel_scale * np.exp(-0.5 * dists / self.length_scale ** 2)
def predict(self, X):
"""ガウス過程による予測"""
X = np.atleast_2d(X)
if len(self.X_observed) == 0:
return np.zeros(len(X)), np.ones(len(X)) * self.kernel_scale
X_obs = np.array(self.X_observed)
y_obs = np.array(self.y_observed)
K = self.rbf_kernel(X_obs, X_obs) + self.noise * np.eye(len(X_obs))
K_star = self.rbf_kernel(X, X_obs)
K_star_star = self.rbf_kernel(X, X)
K_inv = np.linalg.inv(K)
mu = K_star @ K_inv @ y_obs
sigma = np.sqrt(np.diag(K_star_star - K_star @ K_inv @ K_star.T))
return mu, sigma
def expected_improvement(self, X, xi=0.01):
"""Expected Improvement獲得関数"""
mu, sigma = self.predict(X)
if len(self.y_observed) == 0:
return sigma
y_best = np.min(self.y_observed)
Z = (y_best - mu - xi) / (sigma + 1e-9)
ei = (y_best - mu - xi) * norm.cdf(Z) + sigma * norm.pdf(Z)
ei[sigma < 1e-9] = 0
return ei
def suggest_next(self, n_restarts=10):
"""次の評価点を提案"""
dim = len(self.bounds)
best_x = None
best_ei = -np.inf
for _ in range(n_restarts):
x0 = np.random.uniform(self.bounds[:, 0], self.bounds[:, 1])
def neg_ei(x):
return -self.expected_improvement(x.reshape(1, -1))[0]
result = minimize(neg_ei, x0, bounds=self.bounds, method='L-BFGS-B')
if -result.fun > best_ei:
best_ei = -result.fun
best_x = result.x
return best_x
def observe(self, X, y):
"""観測を追加"""
self.X_observed.append(X)
self.y_observed.append(y)
# 目的関数(最小化したい関数)
def objective(x):
"""テスト用の目的関数(1次元)"""
return (x - 2) ** 2 * np.sin(5 * x) + 0.5 * x ** 2
# ベイズ最適化の実行
bounds = [(-2, 5)]
optimizer = BayesianOptimizer(bounds, kernel_scale=10.0, length_scale=0.5)
# 初期点
initial_x = np.random.uniform(-2, 5, 3)
for x in initial_x:
y = objective(x)
optimizer.observe(np.array([x]), y)
# 最適化ループ
n_iterations = 10
for i in range(n_iterations):
x_next = optimizer.suggest_next()
y_next = objective(x_next[0])
optimizer.observe(x_next, y_next)
print(f"Iteration {i+1}: x = {x_next[0]:.4f}, y = {y_next:.4f}")
# 結果の可視化
x_plot = np.linspace(-2, 5, 200)
y_true = objective(x_plot)
mu, sigma = optimizer.predict(x_plot.reshape(-1, 1))
fig, axes = plt.subplots(2, 1, figsize=(10, 8))
# 上段:目的関数とサロゲートモデル
ax = axes[0]
ax.plot(x_plot, y_true, 'b-', label='True Function', linewidth=2)
ax.plot(x_plot, mu, 'r--', label='GP Mean')
ax.fill_between(x_plot, mu - 2*sigma, mu + 2*sigma, alpha=0.2, color='red')
ax.scatter(optimizer.X_observed, optimizer.y_observed, c='black', s=100,
zorder=5, label='Observations')
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_title('Bayesian Optimization')
ax.legend()
ax.grid(True, alpha=0.3)
# 下段:獲得関数
ax = axes[1]
ei = optimizer.expected_improvement(x_plot.reshape(-1, 1))
ax.plot(x_plot, ei, 'g-', linewidth=2)
ax.set_xlabel('x')
ax.set_ylabel('Expected Improvement')
ax.set_title('Acquisition Function (EI)')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('bayesian_optimization.png', dpi=150, bbox_inches='tight')
plt.show()
print(f"\n最良の点: x = {optimizer.X_observed[np.argmin(optimizer.y_observed)]}")
print(f"最良の値: y = {np.min(optimizer.y_observed):.4f}")
Optunaによる実装
import optuna
from sklearn.model_selection import cross_val_score
from sklearn.ensemble import RandomForestClassifier
from sklearn.datasets import make_classification
import numpy as np
# データ生成
X, y = make_classification(n_samples=500, n_features=20,
n_informative=10, random_state=42)
def objective(trial):
"""Optunaの目的関数"""
# ハイパーパラメータのサンプリング
n_estimators = trial.suggest_int('n_estimators', 10, 200)
max_depth = trial.suggest_int('max_depth', 2, 32)
min_samples_split = trial.suggest_int('min_samples_split', 2, 20)
min_samples_leaf = trial.suggest_int('min_samples_leaf', 1, 10)
max_features = trial.suggest_categorical('max_features', ['sqrt', 'log2'])
# モデルの構築と評価
clf = RandomForestClassifier(
n_estimators=n_estimators,
max_depth=max_depth,
min_samples_split=min_samples_split,
min_samples_leaf=min_samples_leaf,
max_features=max_features,
random_state=42,
n_jobs=-1
)
# 交差検証
scores = cross_val_score(clf, X, y, cv=5, scoring='accuracy')
return scores.mean()
# 最適化の実行
study = optuna.create_study(direction='maximize')
study.optimize(objective, n_trials=50, show_progress_bar=True)
print("\n=== Optuna結果 ===")
print(f"最良スコア: {study.best_value:.4f}")
print(f"最良パラメータ: {study.best_params}")
# 最適化の履歴を可視化
fig = optuna.visualization.plot_optimization_history(study)
fig.show()
# パラメータの重要度
fig = optuna.visualization.plot_param_importances(study)
fig.show()
手法の比較実験
import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import cross_val_score, GridSearchCV, RandomizedSearchCV
from sklearn.svm import SVC
from sklearn.datasets import make_classification
from scipy.stats import loguniform
import time
# データ生成
X, y = make_classification(n_samples=500, n_features=20,
n_informative=10, random_state=42)
# 評価回数と最良スコアの推移を記録
def track_optimization(results):
"""最適化の推移を追跡"""
scores = results['mean_test_score']
best_scores = np.maximum.accumulate(scores)
return best_scores
# グリッドサーチ
param_grid = {
'C': np.logspace(-2, 2, 5),
'gamma': np.logspace(-3, 0, 5),
}
start = time.time()
grid_search = GridSearchCV(SVC(), param_grid, cv=5, scoring='accuracy', n_jobs=-1)
grid_search.fit(X, y)
grid_time = time.time() - start
grid_scores = track_optimization(grid_search.cv_results_)
# ランダムサーチ
param_distributions = {
'C': loguniform(0.01, 100),
'gamma': loguniform(0.001, 1),
}
start = time.time()
random_search = RandomizedSearchCV(SVC(), param_distributions, n_iter=25,
cv=5, scoring='accuracy', n_jobs=-1,
random_state=42)
random_search.fit(X, y)
random_time = time.time() - start
random_scores = track_optimization(random_search.cv_results_)
# 結果の可視化
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# 最良スコアの推移
ax = axes[0]
ax.plot(range(1, len(grid_scores) + 1), grid_scores, 'b-o',
label=f'Grid Search (final: {grid_scores[-1]:.4f})', markersize=4)
ax.plot(range(1, len(random_scores) + 1), random_scores, 'r-o',
label=f'Random Search (final: {random_scores[-1]:.4f})', markersize=4)
ax.set_xlabel('Number of Evaluations')
ax.set_ylabel('Best Score Found')
ax.set_title('Optimization Progress')
ax.legend()
ax.grid(True, alpha=0.3)
# 探索点の分布
ax = axes[1]
# グリッドサーチの点
C_grid = [p['C'] for p in grid_search.cv_results_['params']]
gamma_grid = [p['gamma'] for p in grid_search.cv_results_['params']]
ax.scatter(C_grid, gamma_grid, c='blue', alpha=0.6, label='Grid Search', s=50)
# ランダムサーチの点
C_random = [p['C'] for p in random_search.cv_results_['params']]
gamma_random = [p['gamma'] for p in random_search.cv_results_['params']]
ax.scatter(C_random, gamma_random, c='red', alpha=0.6, label='Random Search', s=50)
ax.set_xscale('log')
ax.set_yscale('log')
ax.set_xlabel('C')
ax.set_ylabel('gamma')
ax.set_title('Search Space Coverage')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('search_comparison.png', dpi=150, bbox_inches='tight')
plt.show()
print(f"\nグリッドサーチ: {len(grid_scores)}回, {grid_time:.2f}秒, スコア: {grid_scores[-1]:.4f}")
print(f"ランダムサーチ: {len(random_scores)}回, {random_time:.2f}秒, スコア: {random_scores[-1]:.4f}")
まとめ
本記事では、ハイパーパラメータチューニングについて解説しました。
- グリッドサーチ: 網羅的だが、高次元では計算コストが指数的に増加
- ランダムサーチ: 重要なパラメータが少数の場合に効率的
- ベイズ最適化: サロゲートモデルと獲得関数で効率的に探索
- TPE: カテゴリカル変数や条件付きパラメータに強い
実務では、まずランダムサーチで大まかな範囲を探索し、その後ベイズ最適化で精密化するアプローチが効果的です。
次のステップとして、以下の記事も参考にしてください。