ハイパーパラメータチューニングの理論とOptuna実装

ハイパーパラメータチューニングは、機械学習モデルの性能を最大化するための重要なプロセスです。学習アルゴリズムで自動的に決まらないパラメータを、データに基づいて最適化します。

本記事では、グリッドサーチからベイズ最適化まで、各手法の理論と実装を解説します。

本記事の内容

  • ハイパーパラメータとは何か
  • グリッドサーチの理論と限界
  • ランダムサーチの有効性
  • ベイズ最適化の理論
  • 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. 評価コストが高い: 1回の評価にモデル訓練が必要
  2. 勾配が得られない: $\mathcal{L}_{\text{val}}$ は $\bm{\lambda}$ に対して微分不可能
  3. 非凸性: 局所最適解が多数存在
  4. ノイズ: 交差検証の分散による評価ノイズ

グリッドサーチ

理論

探索空間を格子状に離散化し、全ての組み合わせを評価します。

$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})$ のサロゲートモデル(代理モデル)を構築し、獲得関数を用いて次の評価点を選択します。

アルゴリズムの流れ

  1. 初期点でいくつか評価を実行
  2. 観測データに基づきサロゲートモデルを更新
  3. 獲得関数を最大化して次の評価点を選択
  4. その点を評価し、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: カテゴリカル変数や条件付きパラメータに強い

実務では、まずランダムサーチで大まかな範囲を探索し、その後ベイズ最適化で精密化するアプローチが効果的です。

次のステップとして、以下の記事も参考にしてください。