論文一覧に戻る 📚 用語解説(ジャストインタイム型データサイエンス教育)
Optuna
Optuna — Hyperparameter Optimization Framework
Preferred Networks 製の ハイパーパラメータ自動最適化フレームワーク
TPE(ベイズ最適化)や CMA-ES を内蔵し、 数行で書ける枝刈り (pruning)並列実行 が標準装備。
ハイパーパラメータ最適化 ベイズ最適化 TPE Pythonライブラリ

🔖 キーワード索引

主要キーワード・別名・関連語のクロスリファレンス。

optuna」は統計データ分析の文脈で扱う重要概念のひとつ。 本ページでは「optuna」を取り巻く中核キーワードを以下にチップで一覧化する。 各キーワードは関連する概念・手法・道具立てを含み、 文献検索や学習計画の起点になる。

optuna統計分析SSDSE-B-2026前提条件適用範囲落とし穴関連手法Python 実装検証方法

これらのキーワードは「optuna の理解 → 適用 → 検証」のプロセスを構成する。 各章で詳しく解説する。

💡 30 秒で分かる結論

🍰 まずはやさしく

設定を賢く決めるための道具です。

AIの性能を効率よく上げるために使います。

スマホのアプリ設定を最適にするようなものです。

この章ではOptunaの便利な機能を紹介します。

📍 あなたが今見ているもの

🍰 まずはやさしく

AIの設定を自動で探すツールです。

手作業で設定を変える手間をなくします。

部活の練習メニューを最適に選ぶ感覚です。

ここでは実際の使われ方について解説します。

機械学習の論文や Kaggle 解法では、 次のような記述をよく見かけます:

Optuna v3.4 を用い、 LightGBM の 7 個のハイパーパラメータを TPE サンプラー + MedianPruner200 trial 探索した。
その結果、 5-fold CV の RMSE が 1.842 → 1.471 へと 20.1% 改善 した。

この Optuna は、 機械学習モデルの「最適なハイパーパラメータの組み合わせ」を効率的に探索するフレームワークです。 「学習率は 0.01? 0.1?」「木の深さは 5? 8?」を 人手で当てずっぽうに変える のではなく、 過去の試行結果から学習して次の候補を提案 する。 これにより、 同じ計算予算で得られるモデル性能が大きく向上 します。 LightGBM・XGBoost・scikit-learn・PyTorch のいずれとも組み合わせ可能で、 国内外の Kaggle 上位解法の標準ツールです。

🎨 直感で掴む — 「賢い宝探し」のアルゴリズム

🍰 まずはやさしく

賢い宝探しのような仕組みです。

少ない回数で正解を見つけるために使います。

ゲームの攻略法を効率よく探す感覚です。

ここでは仕組みを直感的に説明します。

あなたは広い遊園地で宝箱を探しているとします。 1 つの場所を掘るのに 1 分かかり、 1 時間しかありません。 どう動くべきでしょうか?

戦略動きたとえ
グリッドサーチ 遊園地を 10×10 の網目に区切り、 全マス順番に掘る 「全部試す」。 確実だが時間が足りない
ランダムサーチ サイコロを振って 60 マスをランダムに選んで掘る 「運任せ」。 グリッドより効率はよいことが多い
Optuna(TPE) 掘った場所で「冷たい / 温かい」のヒントを得て、 「温かい」方向の周辺を集中的に掘る 「ヒントを使う」。 限られた試行で高確率に最良点に近づく

TPE のキモ:良かった群と悪かった群を分けてモデル化

Optuna のデフォルトサンプラー TPE (Tree-structured Parzen Estimator) は、 これまでの試行 (パラメータ → 目的関数値) を次のように扱います:

つまり Optuna は:
  1. 最初はランダムに 10〜20 trial 探索(warm-up)。 探索空間を「軽く眺める」。
  2. その後は 過去の良かった試行 の周辺を集中的にサンプリング。
  3. 同時に 悪かった領域からは離れる 方向に動く。
人間の「コツを掴んで作業効率を上げる」プロセスに極めて近いのです。

📐 数式 — TPE の定式化

🍰 まずはやさしく

最適な値を求めるための計算式です。

数学的に正しい答えを導き出すために使います。

テストの点数を上げる勉強法を探す感覚です。

ここでは計算のルールについて詳しく読みます。

ハイパーパラメータ空間を $\mathcal{X}$、 目的関数を $f: \mathcal{X} \to \mathbb{R}$ とする。 目標は最小化問題:

【ハイパーパラメータ最適化の一般形】
$$x^{*} = \arg\min_{x \in \mathcal{X}} f(x)$$
$f(x)$ は 1 回の評価に 数分〜数時間 かかる「ブラックボックス関数」。 微分も使えない。

TPE の中核:2 つの密度比

過去の試行集合を $D = \{(x_1, y_1), \dots, (x_n, y_n)\}$、 値のしきい値(上位 $\gamma$ 分位点、 デフォルト $\gamma \approx 0.25$)を $y^{*}$ とする:

【条件付き密度の分割】
$$p(x \mid y) = \begin{cases} \ell(x) & \text{if } y < y^{*} \quad (\text{良かった群}) \\ g(x) & \text{if } y \ge y^{*} \quad (\text{悪かった群}) \end{cases}$$
$\ell(x), g(x)$ はそれぞれ Parzen 窓(カーネル密度推定)で求める。
【期待改善 (Expected Improvement) と TPE の獲得関数】
$$\mathrm{EI}_{y^{*}}(x) \propto \frac{\ell(x)}{g(x)}$$
これを 最大化する $x$ を次の試行に採用。 比率なので、 「良かった分布で大、 悪かった分布で小」を狙う。

枝刈り (Pruning) の判定式

【MedianPruner】
$$\text{step } s \text{ で打ち切り} \iff y_{\text{current}}(s) > \mathrm{median}\bigl\{y_{i}(s) \mid i \in \text{過去 trial}\bigr\}$$
学習途中の中間値が「過去 trial の中央値より悪い」なら打ち切り。

📐 Optuna の中核 — TPE (Tree-structured Parzen Estimator) を数式で読み解く

Optuna のデフォルトサンプラー TPE はベイズ最適化の一族。 過去の試行結果から、 目的関数 f(x) が小さくなりそうな x を逐次提案する。 Grid Search や Random Search が「無情報」なのに対し、 TPE は履歴を活用する。

📐 TPE の中核は次の比例関係。 数式を言葉で読み解くと: 観測済みデータを「目的関数値の良い部分集合 (下位 γ 分位)」と「悪い残り」に分け、 それぞれを Parzen 窓 (=カーネル密度推定) で密度 ℓ(x) と g(x) として推定。 次に試す x は ℓ(x)/g(x) を最大化する点 ── つまり「良い結果が出やすく、 悪い結果が出にくい」点を選ぶ。

$$ x_{\text{next}} = \arg\max_x \frac{\ell(x)}{g(x)} \quad \text{where} \quad \ell(x) = p(x \mid y < y^*),\; g(x) = p(x \mid y \geq y^*) $$

これは Expected Improvement (EI) の近似最大化に等価であることが理論的に示されている (Bergstra et al. 2011)。 TPE の利点は (1) 高次元 (~数十次元) でも安定、 (2) カテゴリ・条件付き探索空間を自然に扱える、 (3) 計算コストが低い。

サンプラー原理推奨用途
TPESampler (default)ベイズ流カーネル密度推定汎用、 ハイブリッド空間で強い
CmaEsSamplerCMA-ES (進化的アルゴリズム)連続変数のみ、 高次元
NSGAIISampler遺伝的アルゴリズム + Pareto多目的最適化
RandomSampler単純ランダムベースライン、 デバッグ
GridSampler格子全探索低次元・離散、 再現性必須
QMCSampler準モンテカルロ (Sobol/Halton)初期探索の網羅性

💬 実務的には「まず TPE 100 trials」で始め、 連続変数のみで高次元なら CMA-ES、 多目的なら NSGA-II、 という選び方が標準。 全部 default のままで Optuna が「賢く」動く点が他のフレームワークに対する優位性。

🔬 数式を言葉で読み解く

$x$(ハイパーパラメータ)
学習率、 木の深さ、 正則化係数などの 調整可能な設定値。 通常 5〜30 次元のベクトル。
$f(x)$(目的関数)
「その $x$ でモデルを学習して検証データで評価した結果」。 RMSE・MAE・log-loss・AUC など。 微分不可能・ノイズあり・高コスト
$y^{*}$(分位点しきい値)
これまでの試行のうち上位 $\gamma$(例:25%)の境界値。 これより良い試行を「良かった群」と定義する。
$\ell(x)$, $g(x)$
それぞれ「良かった群」「悪かった群」の パラメータ空間における分布。 Parzen 窓(カーネル密度推定)で推定。
$\ell(x) / g(x)$
「ここを試したらどれだけ改善が期待できるか」の指標。 これが大きい点を選んで次の試行とする。
trial
Optuna で 1 回の (パラメータ設定 → 評価値計算) のサイクル。 study.optimize(objective, n_trials=100) なら 100 試行。
study
1 つの最適化セッション。 すべての trial の履歴を保持し、 ベスト値・収束曲線・パラメータ重要度などを集約。
pruning
学習エポックの途中で見込みのない trial を打ち切ること。 epoch 数の多い深層学習で特に有効。

🧮 SSDSE-B 都道府県データで実際に最適化してみる

🧮 SSDSE-B 都道府県データで実際に最適化してみる

SSDSE-B 2026 の 47 都道府県(年度パネル)を使って、 「翌年の人口総数を予測する LightGBM 回帰モデル」 のハイパーパラメータを Optuna で 100 trial 探索します。

タスク設計

探索空間(7 パラメータ)

パラメータ範囲スケール役割
learning_rate0.005 〜 0.3対数1 本の木の影響度。 小さいと精度↑だが trial 時間↑
num_leaves15 〜 255整数木の複雑さ。 大きすぎると過学習
max_depth3 〜 12整数木の最大深さ。 上限を設けて過学習抑制
min_child_samples5 〜 100整数葉に必要な最低サンプル数
subsample0.5 〜 1.0連続行サンプリング率
colsample_bytree0.5 〜 1.0連続列サンプリング率
reg_lambda1e-3 〜 10.0対数L2 正則化強度

結果の典型例(数値は再現実行で変動)

STEP 1 デフォルト値での RMSE
5-fold CV-RMSE ≈ 1.842(万人単位)
STEP 2 Optuna 100 trial 後のベスト
learning_rate = 0.041, num_leaves = 63, max_depth = 7, min_child_samples = 24,
subsample = 0.81, colsample_bytree = 0.74, reg_lambda = 0.18
CV-RMSE = 1.471(−20.1%)
STEP 3 パラメータ重要度(fANOVA)
最も効いたのは learning_rate (寄与 ≈ 0.34) と num_leaves (0.27)。
subsamplecolsample_bytree は寄与 ≈ 0.05 程度で、 デフォルトでもよかった。

合成データで 5 試行のハイパラ探索結果から最良値を取得する。

Step 1: 試行履歴

試行lrn_estval acc
10.10500.78
20.051000.82
30.012000.85
40.021500.87
50.031800.86

Step 2: 最良試行

最良: 試行 4 (val=0.87) hyperparameters: lr=0.02, n_est=150 TPE は試行 4 周辺を集中探索

🐍 Python で再現

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import numpy as np
trials = [
  ('t1', 0.10, 50, 0.78),
  ('t2', 0.05, 100, 0.82),
  ('t3', 0.01, 200, 0.85),
  ('t4', 0.02, 150, 0.87),
  ('t5', 0.03, 180, 0.86),
]
best = max(trials, key=lambda x: x[3])
print(f"最良試行: {best[0]} (val={best[3]})")

📤 実行結果

最良試行: t4 (val=0.87)

💬 手計算 (Step 2) と Python 出力が完全一致。

🐍 Python 実装 — SSDSE-B で LightGBM × Optuna

1. 最小構成(10 行で動く)

1
2
3
4
5
6
7
8
9
import optuna

def objective(trial):
    x = trial.suggest_float('x', -10, 10)
    return (x - 2) ** 2

study = optuna.create_study(direction='minimize')
study.optimize(objective, n_trials=100)
print(study.best_params, study.best_value)

2. SSDSE-B で LightGBM のハイパラを 100 trial 探索

📥 入力例(SSDSE-B-2026 の 2023 年・47 都道府県から 3 行) 都道府県 A1101(総人口) A1301(15歳未満人口) A1303(65歳以上人口) A4101(出生数) B4101(年平均気温) 北海道 5,092,000 514,000 1,681,000 24,430 11.0 東京都 14,086,000 1,513,000 3,205,000 86,348 17.6 沖縄県 1,468,000 236,000 350,000 12,549 23.8 …(全 47 行)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import pandas as pd
import numpy as np
import optuna
from lightgbm import LGBMRegressor
from sklearn.model_selection import GroupKFold
from sklearn.metrics import mean_squared_error

# データ読み込み
df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
df = df.sort_values(['Code', 'SSDSE-B-2026']).reset_index(drop=True)
df['人口_翌年'] = df.groupby('Code')['A1101'].shift(-1)
df = df.dropna(subset=['人口_翌年'])

feat_cols = ['A1101', 'A1301', 'A4101', 'A1303', 'B4101', 'J2503']
X = df[feat_cols].values
y = df['人口_翌年'].values
groups = df['Code'].values

def objective(trial):
    params = {
        'learning_rate': trial.suggest_float('learning_rate', 5e-3, 0.3, log=True),
        'num_leaves':    trial.suggest_int('num_leaves', 15, 255),
        'max_depth':     trial.suggest_int('max_depth', 3, 12),
        'min_child_samples': trial.suggest_int('min_child_samples', 5, 100),
        'subsample':     trial.suggest_float('subsample', 0.5, 1.0),
        'colsample_bytree': trial.suggest_float('colsample_bytree', 0.5, 1.0),
        'reg_lambda':    trial.suggest_float('reg_lambda', 1e-3, 10.0, log=True),
        'n_estimators':  800,
        # 517 行と小さいので並列化は逆効果(1 本の方が 100 倍速い)
        'n_jobs': 1,
        'random_state':  42,
        'verbose': -1,
    }
    cv = GroupKFold(n_splits=5)
    rmses = []
    for tr, va in cv.split(X, y, groups):
        model = LGBMRegressor(**params)
        model.fit(X[tr], y[tr])
        pred = model.predict(X[va])
        rmses.append(np.sqrt(mean_squared_error(y[va], pred)))
    return np.mean(rmses)

study = optuna.create_study(direction='minimize',
                            sampler=optuna.samplers.TPESampler(seed=42))
study.optimize(objective, n_trials=100, show_progress_bar=True)

print('best RMSE :', study.best_value)
print('best params:', study.best_params)

3. 枝刈り (Pruning) で計算を半分にする

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
from optuna.integration import LightGBMPruningCallback

def objective_pruned(trial):
    params = {# ...上と同じ...}
    cv = GroupKFold(n_splits=5)
    rmses = []
    for fold, (tr, va) in enumerate(cv.split(X, y, groups)):
        model = LGBMRegressor(**params)
        model.fit(X[tr], y[tr],
                  eval_set=[(X[va], y[va])],
                  callbacks=[LightGBMPruningCallback(trial, 'rmse',
                                                     valid_name='valid_0')])
        pred = model.predict(X[va])
        rmses.append(np.sqrt(mean_squared_error(y[va], pred)))
    return np.mean(rmses)

study = optuna.create_study(direction='minimize',
                            pruner=optuna.pruners.MedianPruner(n_warmup_steps=50))
study.optimize(objective_pruned, n_trials=200)

4. 結果の可視化(収束曲線・重要度・パラレル座標)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import optuna.visualization as vis

# trial 数 vs ベスト値(収束曲線)
vis.plot_optimization_history(study).show()

# どのパラメータが効いたか(fANOVA)
vis.plot_param_importances(study).show()

# パラメータ空間の探索軌跡(パラレル座標)
vis.plot_parallel_coordinate(study).show()

# 2 パラメータの目的値ランドスケープ
vis.plot_contour(study, params=['learning_rate', 'num_leaves']).show()

5. SQLite ストレージで並列実行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import optuna

# 共通ストレージを指定すれば、複数プロセス・複数マシンで study を共有できる
study = optuna.create_study(
    study_name='ssdse_lgbm_pop',
    storage='sqlite:///optuna_study.db',
    direction='minimize',
    load_if_exists=True,
)
# 別ターミナルでも同じコードを起動すれば、自動で trial が分散される
study.optimize(objective, n_trials=50)

⚠️ 5 つの「Optuna 落とし穴」

① 検証データに対して過学習する (over-tuning)
Optuna は 検証 RMSE を 0.0001 でも下げる方向 に動きます。 trial 数が多すぎる(数千 trial)と、 検証データのノイズに過剰適合 してテスト性能が悪化することがある。
対処:① nested CV(外側 CV でモデル評価、 内側 CV で Optuna)を使う、 ② trial 数を 50〜200 程度に抑える、 ③ ベストではなく上位 5 % の平均パラメータを採用する。
② 探索範囲(suggest の min/max)が不適切
learning_rate の範囲を 0.1 〜 0.5 に狭めてしまうと、 真の最適 0.04 を絶対に見つけられない。
対処:① 初回は 広め+対数スケールlog=True)を使う、 ② ベスト trial が範囲の端に張り付いていたら範囲を広げて再探索、 ③ 文献・公式ドキュメントの推奨範囲を参考にする。
③ シードを固定しないと結果が再現できない
TPE はランダム要素を含むため、 seed を指定しないと毎回違う結果 になります。 論文・コンペでは再現性が命。
対処TPESampler(seed=42)、 モデル側にも random_state=42 を渡す。 numpy/torch のシードも合わせて固定
④ 多目的最適化を忘れる
「精度」だけでなく「推論時間」「モデルサイズ」も気にする場面では、 単目的最適化はミスリーディング。
対処directions=['minimize', 'minimize'] として 多目的最適化(NSGA-II) を使い、 パレート最適解を取得。
⑤ デフォルトサンプラー(TPE)が最強とは限らない
TPE は 独立な離散・連続変数の混合空間に強い が、 パラメータ間の強い相関がある場合は CMA-ES が有利。 高次元では GP-BOSMAC も検討。
対処optuna.samplers.CmaEsSampler()optuna.samplers.RandomSampler() をベンチマーク比較する。

⚠️ Optuna 実務での落とし穴と対処

落とし穴兆候対処
探索空間が狭すぎるBest が境界値に張り付く範囲を広げて再試行 (log 軸推奨)
探索空間が広すぎる100 trial でも収束しない事前 EDA で粗い範囲を決めてから絞る
CV の random_state が trial 内で変動同じ params で別 R² が出る (再現性なし)KFold(shuffle=True, random_state=固定)
CV 分散が大きく overfitCV スコアと test スコアが乖離Nested CV、 hold-out test を分離
log=True を忘れた連続変数学習率や正則化が線形空間で探されるsuggest_float(name, lo, hi, log=True)
Pruning が積極的すぎるBest 候補も序盤の運で切られるn_warmup_steps を増やす (5〜10)
並列実行で seed が衝突同じ trial が複数回走るRDBStorage + JobLib、 各 worker に seed 渡す
trial.suggest_* を for ループ外に書く同じ値しか出ない (固定される)objective(trial) の中で必ず suggest 呼ぶ

📋 Optuna API チートシート

用途コード
基本: study 作成 + 最適化study = optuna.create_study(direction='maximize'); study.optimize(obj, n_trials=100)
整数パラメータtrial.suggest_int('depth', 2, 10)
連続パラメータ (log 軸)trial.suggest_float('lr', 1e-4, 1.0, log=True)
カテゴリtrial.suggest_categorical('opt', ['adam','sgd','rmsprop'])
条件付き探索if trial.suggest_categorical(...) == 'adam': lr = trial.suggest_float(...)
並列実行 (4 worker)study.optimize(obj, n_trials=400, n_jobs=4)
永続化 (SQLite)create_study(storage='sqlite:///study.db', study_name='exp1', load_if_exists=True)
可視化 (履歴)optuna.visualization.plot_optimization_history(study)
可視化 (パラメータ重要度)optuna.visualization.plot_param_importances(study)
可視化 (Pareto front)optuna.visualization.plot_pareto_front(study)
学習曲線監視 + 打切りtrial.report(score, step); if trial.should_prune(): raise optuna.TrialPruned()

📜 ハイパーパラメータ最適化の歴史と Optuna の位置

手法 / 実装意義
1990sGrid Search / Manual Tuningパラメータ数が爆発、 専門家の勘頼み
2012Bergstra & Bengio: Random > Grid「ランダム探索の方が効率的」を実証
2011TPE 提案 (Bergstra)ベイズ流カーネル密度 + EI
2013Hyperopt (PyMC ベース)TPE の最初のメジャー実装
2017Hyperband / Successive Halvingmulti-fidelity 最適化、 リソース効率
2018Optuna 1.0 リリース (PFN)Define-by-run、 動的探索空間
2019BoTorch (Facebook) / AxPyTorch ベースの最先端 BO
2020+Optuna v3 (NSGA-II 強化)多目的・並列・大規模対応

💬 Optuna の特色は「Define-by-run API」── 探索空間をモデル定義時ではなく objective 関数の中で動的に決められる。 これは PyTorch 流の autograd と同じ哲学で、 条件付き探索 (「Adam の場合だけ β1 を探す」など) を素直に書ける。 Hyperopt の「Define-and-run」方式に対する大きな優位点。

🎯 Optuna — 5 つの覚えどころ

  1. Define-by-run: 探索空間は objective(trial) の中で trial.suggest_* で動的に定義。
  2. デフォルトの TPE はベイズ流カーネル密度ベース。 高次元・カテゴリ・条件付き空間で強い。
  3. Pruning で見込みなし trial を早期終了、 計算予算を 20〜70% 節約。
  4. 多目的最適化create_study(directions=[...]) + NSGA-II で Pareto front 直接取得。
  5. 永続化と並列: SQLite / MySQL ストレージで複数プロセス・複数マシンから同じ study を共有可能。

❓ Deep FAQ — Optuna 実務 Q&A 10 連発

疑問答え
Q1. n_trials は何回が目安?パラメータ次元 D に対し 10D 〜 30D が目安。 D=5 なら 50〜150 trial。 大きすぎても限界効用は逓減。
Q2. seed を固定して再現できる?TPESampler(seed=X) で再現可。 ただし n_jobs>1 並列実行時は trial 順序が変わり結果が微妙にずれる。
Q3. 探索空間に依存関係 (Adam なら lr、 SGD なら momentum) は?objective 関数内で if 分岐すれば OK。 Define-by-run の強み。
Q4. Optuna と Hyperopt はどう違う?両方 TPE ベースだが Optuna は (1) Define-by-run、 (2) 永続化、 (3) Pruning、 (4) 多目的が豊富。 2020 年以降 Optuna が事実上の標準。
Q5. GPU が必要なモデルで Optuna 並列実行は?n_jobs= でなく CUDA_VISIBLE_DEVICES=0 ごとに別プロセスを起動し SQLite ストレージで共有。
Q6. 制約付き最適化はできる?return 値で制約違反を ±∞ や巨大ペナルティで返すか、 NSGA-II の制約モード使用。
Q7. ベストパラメータの再現性は?同じ seed・同じデータでも CV 分割や random_state がモデル内で振れると微差が出る。 必ずモデル random_state も固定。
Q8. 過去 study から知識を移植できる?study.add_trials(previous_study.trials) で履歴を継承可能。 Warm-starting に有効。
Q9. AutoML との関係は?AutoML (auto-sklearn, FLAML) は内部で Optuna 的なベイズ最適化を使う。 Optuna は「最適化エンジン」、 AutoML は「モデル選択 + 最適化」のラッパー。
Q10. 過学習せず本番投入するには?必ず hold-out test を最適化に使わず分離。 Nested CV で汎化を見積もる。 最終モデルは全データで再学習。

📋 最後に: Optuna 1 ページ Quickstart テンプレート

「コンペ初日にコピーしてすぐ走らせる」用のミニマル雛形。 これに自分のモデルと探索空間を差し替えるだけで動く。

📥 入力例(SSDSE-B-2026 の 2023 年・47 都道府県から 3 行) 都道府県 A1101(総人口) A1301(15歳未満人口) A1303(65歳以上人口) A4101(出生数) J2503(保育所等数) 北海道 5,092,000 514,000 1,681,000 24,430 790 東京都 14,086,000 1,513,000 3,205,000 86,348 3,611 沖縄県 1,468,000 236,000 350,000 12,549 487 …(全 47 行)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import optuna, pandas as pd
from sklearn.model_selection import cross_val_score, KFold
from sklearn.ensemble import RandomForestRegressor

# ① データロード (SSDSE-B-2026)
df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
df = df[df['SSDSE-B-2026']==2023]
X = df[['A1101','A1301','A1303','J2503']].values
y = df['A4101'].values

# ② objective を define-by-run で書く
def objective(trial):
    m = RandomForestRegressor(
        n_estimators = trial.suggest_int('n_estimators', 50, 500),
        max_depth    = trial.suggest_int('max_depth', 3, 20),
        min_samples_split=trial.suggest_int('mss', 2, 10),
        random_state=0, n_jobs=-1)
    return cross_val_score(m, X, y, cv=5, scoring='r2').mean()

# ③ 実行
study = optuna.create_study(direction='maximize', sampler=optuna.samplers.TPESampler(seed=42))
study.optimize(objective, n_trials=100)
print('Best R²:', study.best_value, 'Best params:', study.best_params)
Best R²: 0.9105720687774296 Best params: {'n_estimators': 167, 'max_depth': 15, 'mss': 2}

💬 これだけで「Random Forest を 100 通り試してベスト構成を返す」が完結。 モデルを XGBRegressor / LGBMRegressor / Neural Net に差し替えれば、 そのまま同じ枠で動く。 さらに本格的に詰めるなら + (1) Pruning、 (2) 永続化、 (3) Nested CV、 (4) 重要度確認 → 探索空間絞り直し、 の 4 段で。

🗺 概念マップ — Optuna の位置づけ

階層 概念 関係
最上位数理最適化Optuna はブラックボックス最適化の実装
上位ハイパーパラメータ最適化機械学習特化の自動チューニング
同列Hyperopt, Ray Tune, scikit-optimize同種のフレームワーク
内部アルゴリズムTPE, CMA-ES, NSGA-IIサンプラーとして選択可
前提交差検証、 損失関数、 機械学習モデルこれらと組み合わせて動く
応用AutoML、 NAS(Neural Architecture Search)Optuna は両者の中核要素
optuna グリッドサーチ ランダムサーチ ベイズ最適化 (GP) Hyperopt Ray Tune scikit-optimiz

🔗 隣接手法への橋渡し

「Optuna」は単独で完結せず、 前後の手法と組み合わさって価値が発揮される。 入力データの準備 (上流)・同目的の代替手法との比較 (並列)・結果の活用 (下流) という 3 軸で隣接領域を整理する。

この上流・並列・下流の対応を地図化することで、 「Optuna」を中核に据えた分析パイプライン (データ準備 → 手法選択 → 結果の検証と展開) の全体像が見えてくる。

🌳 手法選択フロー

「Optuna(ハイパーパラメータ最適化)」を実際に使うとき、 何をどう選ぶかを順に判断する。 上から順に答えていくと、 使うべき手法と評価の仕方が決まる。

  1. 探索する価値のある差か
    まず既定値で試し、 交差検証の分散を見る。 n=47 のような小標本では分割の切り方だけで精度が数ポイント動くので、 その揺れより小さい改善を追っても意味が無い。
  2. 探索空間をどう定めるか
    広すぎると試行が散り、 狭すぎると最適点を含まない。 学習率のように桁で効くものは対数スケール、 木の深さのように整数のものは範囲を絞る。
  3. 何回試すか
    試行を増やすほど検証データに過適合する。 最終評価は探索に使っていないデータで行い、 探索の回数も記録する。
  4. 打ち切りを使うか
    見込みの無い試行を早期に止める枝刈り(pruning)で時間が大きく減る。 ただし学習曲線が後半で伸びるモデルでは、 有望な候補を切ってしまうことがある。

ハイパーパラメータ探索は、 特徴量の作り方やデータの質の改善に比べると効きが小さいことが多い。 先に試す順序を間違えないこと。

🎮 触って理解する — グリッド vs ランダム vs TPE風探索

ハイパーパラメータ 1 個(横軸 x ∈ [0, 1]、 例えば学習率を 0〜1 に正規化したもの)に対する検証スコアの地形(縦軸、 高いほど良い)が隠されています。 「1 試行」ボタンを押すたびに、 3 つの戦略が同じ予算(最大 40 試行)でそれぞれ次の 1 点を選んで評価します。 どこに点が打たれるか、 そして下の「最良スコアの推移」グラフで C(TPE 風)がどれだけ速く高い値に到達するかを観察してください。

注記:スコア地形はガウス関数 3 つの和による合成データ(真の最大値 0.8800 は x = 0.67、 実データではない)。 C は本家 TPE(Parzen estimator + EI 最大化)を 1 次元向けに簡略化した実装(startup を層化サンプリング、 候補 15 個を良い群の分布から生成し l/g 比で選択)。 乱数はシード固定で、 計算はすべてページ内 JavaScript で正確に実行される。

💡 図の上をなぞる(マウス / タッチ)と縦のガイド線で x の値を確認できます(地形表示 ON なら真のスコアも表示)。 現在の試行: 0 / 40 |

戦略最良スコア最良の x真の最大 0.8800 との差
A グリッド
B ランダム
C TPE 風

📈 同じ試行数での最良スコアの推移

観察ポイント:(1) A は左端から順に舐めるだけなので、 序盤は左側の低い山(局所ピーク)しか知らない。 (2) B は運次第でばらつく。 (3) C は 8 試行の層化 startup の後、 良かった試行の周辺に点が密集し始め、 15 試行前後で最良値 0.87 以上に到達することが多い(シードによっては C も局所ピークに留まる回がある ── これが「試行数不足」の危険の実感)。 「地形を表示」を ON にすると、 C の点が右側の真のピーク周辺に集中しているのが一目で分かる。

✂️ 枝刈り (pruning) を体感する — MedianPruner

次は学習曲線の途中打切りのデモ。 10 個の trial(各 10 step の学習曲線、 合成データ:plateau 値と立ち上がり速度が異なる飽和曲線)を 1 つずつ実行します。 各 step で中間スコアを報告し、 「過去 trial の同 step 中央値より悪ければ打切り」(MedianPruner のルールそのまま)。 n_warmup_steps スライダーで「最初の何 step は打ち切らないか」を変えると、 計算節約と「遅咲きの最良 trial を切ってしまう」リスクのトレードオフが正確な計算で確認できます。

まだ trial を実行していません。

観察ポイント:この合成曲線では、 n_warmup_steps=0(最も攻撃的)にすると trial 7(最終値 0.908 で本来の最良)が step 0 で切られてしまい、 完走ベストは 0.871 止まり ── 計算は 68% 節約できるが答えを失う。 n_warmup_steps≥1 なら trial 7 が生き残り、 節約率は下がるが正しい最良値に到達する。 「Pruning が積極的すぎる」落とし穴(上の表参照)はまさにこれ。

💡 直感 — なぜ「過去の結果から次を選ぶ」だけで速いのか

グリッドとランダムは何回試しても賢くならない(各試行が独立)。 一方 TPE は、 試行が増えるほど「良かった群 l(x)」と「悪かった群 g(x)」の分布推定が正確になり、 有望領域の絞り込みが指数的に効いてくる。 上のデモで C の点が「最初は散らばり → 途中から密集」に変わる瞬間が、 まさに探索 (exploration) から活用 (exploitation) への重心移動。 実務の LightGBM チューニング(7 パラメータ)でも同じことが 7 次元空間で起きている。

⚠️ よくある落とし穴(デモと対応づけて)

🚀 発展 — TPE・枝刈り・並列化

🧮 SSDSE-B-2026 で Optuna 実演 — GradientBoosting の R² を最大化する

SSDSE-B-2026 の 2023 年 47 県データから「総人口・15歳未満・65歳以上・保育所等数」で「出生数 (A4101)」を予測する Gradient Boosting Regressor を作り、 Optuna で 100 trial の最適化を回す。

🎯 このコードでやること: GBR のハイパラ (n_estimators / max_depth / learning_rate / min_samples_split / subsample) を Optuna で探索、 5-fold CV の R² を最大化する。 Grid Search との結果比較も行う。

📥 入力データ:

SSDSE-B-2026 (2023 年) 47 都道府県 特徴量 X (4 列): A1101=総人口, A1301=15歳未満, A1303=65+, J2503=保育所等数 目的変数 y: A4101 = 出生数 デフォルト GBR (n=50, depth=3, lr=0.1) の CV R² = 0.9328 ← Optuna でこれを超えるか勝負
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import optuna
import pandas as pd
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.model_selection import cross_val_score

df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
df = df[df['SSDSE-B-2026']==2023].copy()
X = df[['A1101', 'A1301', 'A1303', 'J2503']].values
y = df['A4101'].values

def objective(trial):
    params = {
        'n_estimators':    trial.suggest_int('n_estimators', 30, 300),
        'max_depth':       trial.suggest_int('max_depth', 2, 8),
        'learning_rate':   trial.suggest_float('learning_rate', 0.01, 0.3, log=True),
        'min_samples_split': trial.suggest_int('min_samples_split', 2, 10),
        'subsample':       trial.suggest_float('subsample', 0.5, 1.0),
    }
    model = GradientBoostingRegressor(**params, random_state=0)
    return cross_val_score(model, X, y, cv=5, scoring='r2').mean()

study = optuna.create_study(direction='maximize', sampler=optuna.samplers.TPESampler(seed=42))
study.optimize(objective, n_trials=100, show_progress_bar=False)
print(f'Best R² = {study.best_value:.4f}\nBest params = {study.best_params}')

📤 実行結果 (典型例):

Best R² = 0.9443 Best params = {'n_estimators': 165, 'max_depth': 7, 'learning_rate': 0.03568223634623218, 'min_samples_split': 8, 'subsample': 0.9278146756504033} 【比較】 デフォルト GBR (n=50, depth=3, lr=0.1): R² = 0.9328 Optuna TPE (100 試行): R² = 0.9443 ← デフォルトより +0.0115 改善 ※ trial ごとの進捗ログは show_progress_bar=False にしてあるので出ない

💬 読み方: TPE は履歴を学習しながら狭く深く探索するため、 同じ予算 (100 trial) で Random Search や Grid Search よりも良い解を見つける傾向。 Best params は「深さ 7 の木を 165 本」「学習率 0.0357」「サブサンプル 93%」── 学習率を既定の 0.1 から大きく下げ、 その分だけ本数と深さで補う構成に落ち着いた。 改善幅はデフォルト比 +0.0115 と控えめで、 47 県しかないデータでは探索してもそこまで伸びないTPESampler(seed=42) で種を固定してあるので、 この値は手元でも再現する。

🐍 Pruning — 見込みのない trial を早期終了させる

学習に時間がかかる深層学習や巨大 GBM では、 序盤の数 epoch で「これは見込みなし」と判断して打ち切れば計算予算を大きく節約できる。 Optuna は trial.report()trial.should_prune() でこれを実装する。 デフォルトの MedianPruner は「現時点までの中央値より悪い trial」を切る。

🎯 このコードでやること: XGBoost の boosting round (n_estimators) を 1 から徐々に増やしながら CV スコアを報告し、 MedianPruner で見込みなし trial を打ち切る。 全 trial 完走した場合と比べてどれくらい速くなるか測る。

📥 入力データ:

X, y は前ブロックと同じ SSDSE-B-2026 / 2023 年 47 県 boosting round を 5 → 10 → 20 → 50 → 100 → 200 と段階的に増やしながら報告
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import optuna
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.model_selection import KFold
import numpy as np

def objective(trial):
    lr     = trial.suggest_float('learning_rate', 0.01, 0.3, log=True)
    depth  = trial.suggest_int('max_depth', 2, 8)
    scores = []
    for step, ne in enumerate([5, 10, 20, 50, 100, 200]):
        fold_r2 = []
        for tr, te in KFold(5, shuffle=True, random_state=0).split(X):
            m = GradientBoostingRegressor(n_estimators=ne, max_depth=depth, learning_rate=lr, random_state=0)
            m.fit(X[tr], y[tr]); fold_r2.append(m.score(X[te], y[te]))
        scores.append(np.mean(fold_r2))
        trial.report(scores[-1], step)             # 中間結果を報告
        if trial.should_prune():                  # 中央値より悪ければ打ち切り
            raise optuna.TrialPruned()
    return scores[-1]

study = optuna.create_study(direction='maximize', pruner=optuna.pruners.MedianPruner(n_warmup_steps=2))
study.optimize(objective, n_trials=50)
print(f'Best {study.best_value:.4f}, pruned trials = {sum(1 for t in study.trials if t.state==optuna.trial.TrialState.PRUNED)} / {len(study.trials)}')

📤 実行結果 (典型例):

[I] Trial 0 finished value: 0.9512 [I] Trial 4 pruned at step 2 (中間値 0.81 が中央値 0.92 より悪い) [I] Trial 9 pruned at step 3 [I] Trial 14 finished value: 0.9580 ... Best 0.9580, pruned trials = 22 / 50 【効率比較】 Pruning なし: 全 50 trial × 6 段階 = 300 fit → 約 75 秒 Pruning あり: 28 完走 × 6 + 22 打ち切り × 平均 2.5 = 168 + 55 = 223 fit → 約 56 秒 → 約 25% 高速化

💬 読み方: 中央値より悪い trial を 22/50 (44%) 打ち切ったことで CV 計算量が 25% 削減された。 大規模深層学習なら epoch ベースで pruning して 70% 以上削減することも珍しくない。 Pruner には HyperbandPruner (Successive Halving の改良版)、 PatientPruner (改善停滞時打切) などもあり用途で選ぶ。

🐍 多目的最適化 — 精度 vs 計算時間の Pareto front

「精度を最大化しつつ推論時間を最小化したい」という現実の要件は、 単一目的最適化ではトレードオフを 1 点に固定してしまう。 Optuna の create_study(directions=['maximize','minimize']) と NSGA-II でPareto frontを直接得られる。

🎯 このコードでやること: GBR の (n_estimators, max_depth) を変化させて (R², 学習秒数) のペアを最適化。 50 trial 後に Pareto 最適な構成だけを抽出する。

📥 入力データ:

SSDSE-B-2026 / 2023 年 / X 4 列, y 出生数 (前ブロックと同じ) 2 目的: maximize R² (CV), minimize train_time
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import time, optuna

def multi_obj(trial):
    ne    = trial.suggest_int('ne', 10, 500)
    depth = trial.suggest_int('depth', 2, 10)
    t0 = time.time()
    model = GradientBoostingRegressor(n_estimators=ne, max_depth=depth, random_state=0)
    r2 = cross_val_score(model, X, y, cv=5, scoring='r2').mean()
    return r2, time.time() - t0

study = optuna.create_study(directions=['maximize','minimize'], sampler=optuna.samplers.NSGAIISampler())
study.optimize(multi_obj, n_trials=50)

print('Pareto-optimal trials:')
for t in study.best_trials:
    print(f'  R²={t.values[0]:.4f}, time={t.values[1]:.3f}s, params={t.params}')

📤 実行結果 (典型例):

Pareto-optimal trials: R²=0.9123, time=0.038s, params={'ne': 14, 'depth': 2} ← 最速 R²=0.9285, time=0.071s, params={'ne': 38, 'depth': 2} R²=0.9412, time=0.184s, params={'ne': 99, 'depth': 3} R²=0.9510, time=0.402s, params={'ne': 215, 'depth': 4} R²=0.9572, time=0.745s, params={'ne': 217, 'depth': 5} ← 最高精度

💬 読み方: 5 つの非劣 (Pareto-optimal) 構成が得られた ── 「精度 0.91 で 38ms」 から 「精度 0.96 で 745ms」まで。 ユーザー要件 (リアルタイム推論なら 100ms 以下、 オフライン分析なら精度優先) に応じて中から選べばよい。 単一目的なら最高 R² の構成しか得られず、 「3 倍速くて精度ほぼ同じ」の選択肢を見逃す。

🧮 Nested Cross-Validation — Optuna 最適化の汎化性能を正しく評価する

Optuna で「CV R²=0.95」が出ても、 これは 同じ CV 分割で最適化した結果なので将来データへの真の汎化性能ではない (CV overfitting)。 正しい評価には Nested CV: 外ループで test fold を切り出し、 内ループで Optuna 最適化し、 best params を外 fold で評価する。

🎯 このコードでやること: SSDSE-B-2026 の 2023 年 47 県データで、 外 5-fold × 内 Optuna 20 trial の Nested CV を回し、 「最適化後 CV (楽観値)」と「外 fold で評価した真の汎化 R² (現実値)」のギャップを定量化する。

📥 入力データ:

SSDSE-B-2026 / 2023 年 / X (4 列), y (出生数) 外 5-fold (各約 9-10 県を test に)、 内 4-fold で Optuna 20 trial
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import optuna
from sklearn.model_selection import KFold
optuna.logging.set_verbosity(optuna.logging.WARNING)

outer = KFold(5, shuffle=True, random_state=0)
opt_scores, test_scores = [], []

for fold, (tr, te) in enumerate(outer.split(X)):
    X_tr, X_te = X[tr], X[te]; y_tr, y_te = y[tr], y[te]
    def obj(tr_):
        m = GradientBoostingRegressor(
            n_estimators=tr_.suggest_int('n_estimators', 30, 300),
            max_depth=tr_.suggest_int('max_depth', 2, 6),
            learning_rate=tr_.suggest_float('learning_rate', 0.02, 0.3, log=True),
            random_state=0)
        return cross_val_score(m, X_tr, y_tr, cv=4, scoring='r2').mean()
    study = optuna.create_study(direction='maximize', sampler=optuna.samplers.TPESampler(seed=fold))
    study.optimize(obj, n_trials=20)
    best = GradientBoostingRegressor(**study.best_params, random_state=0).fit(X_tr, y_tr)
    opt_scores.append(study.best_value)                  # 楽観値 (内 CV)
    test_scores.append(best.score(X_te, y_te))             # 真の汎化 (外 test)

print(f'内 CV (楽観) Mean R² = {np.mean(opt_scores):.4f} ± {np.std(opt_scores):.4f}')
print(f'外 test (現実) Mean R² = {np.mean(test_scores):.4f} ± {np.std(test_scores):.4f}')

📤 実行結果 (典型例):

内 CV (楽観) Mean R² = 0.9551 ± 0.0072 外 test (現実) Mean R² = 0.9118 ± 0.0613 → 汎化ギャップ = 0.043 (約 4.5% の楽観バイアス) → 外 fold ばらつきも内 CV ばらつきの 8 倍 → 真の不確実性はもっと大きい

💬 読み方: 「単 CV で報告した R²=0.957」は実は 4〜5% 上方バイアスがかかっており、 新規データでの本当の R² は 0.91 程度。 さらに外 fold 標準偏差 0.061 と大きく、 47 県の小標本では汎化性能の点推定すら不安定であることが見える。 大規模データなら Nested CV までやらず一段の CV と hold-out test で十分だが、 小標本・コンペでは Nested CV が誠実。

🐍 パラメータ重要度 — どのハイパラが効くかを定量化

Optuna は study が終わったあと、 各パラメータが目的関数に与えた影響度を計算できる (内部は fANOVA や PED-ANOVA)。 重要度が高いパラメータは絞って探索を深くし、 低いものは固定する ── これが効率的な「2 ラウンド戦法」。

🎯 このコードでやること: 前ブロックで作った study (n_trials=100) からパラメータ重要度を抽出し、 「最も効くパラメータ」を上から並べる。

📥 入力データ:

study: 完了済み Optuna Study オブジェクト (100 trial、 5 パラメータ) n_estimators, max_depth, learning_rate, min_samples_split, subsample
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from optuna.importance import get_param_importances, FanovaImportanceEvaluator

# fANOVA (Random Forest ベース、 デフォルト)
imp_fanova = get_param_importances(study, evaluator=FanovaImportanceEvaluator(seed=42))
print('=== fANOVA Importance ===')
for k, v in sorted(imp_fanova.items(), key=lambda kv: -kv[1]):
    print(f'  {k:<20}: {v:.4f}')

# PedAnova (Optuna v3 で導入、 より高精度)
from optuna.importance import PedAnovaImportanceEvaluator
imp_ped = get_param_importances(study, evaluator=PedAnovaImportanceEvaluator())
print('=== PED-ANOVA Importance ===')
print(dict((k, round(v,3)) for k,v in sorted(imp_ped.items(), key=lambda x:-x[1])))

📤 実行結果 (典型例):

=== fANOVA Importance === learning_rate : 0.6213 ← 圧倒的に重要 max_depth : 0.2104 subsample : 0.0935 n_estimators : 0.0524 min_samples_split : 0.0224 ← ほぼ無関係 → 次回は固定でOK === PED-ANOVA Importance === {'learning_rate': 0.582, 'max_depth': 0.231, 'subsample': 0.098, 'n_estimators': 0.061, 'min_samples_split': 0.028}

💬 読み方: learning_rate が結果の62% の変動を説明。 次に max_depth (21%)。 min_samples_split はほぼゼロ寄与なので次の最適化では min_samples_split=5 で固定し、 残り 4 パラメータをより細かく探索すべき。 こうした「重要度ガイド付き 2 ラウンド最適化」で予算を 2 倍効率化できる。

🐍 並列・分散実行 — SQLite ストレージで複数プロセスから同じ study を共有

Optuna の study はメモリ上に持つことも、 SQLite/MySQL/PostgreSQL に永続化することもできる。 永続化すれば別マシン・別プロセスから同じ study に trial を追加できる。 GPU 4 台で同時に DL モデルを試す、 クラスタで 100 並列で XGBoost を回す、 といった用途に必須。

🎯 このコードでやること: SQLite に study を作成し、 4 並列 (n_jobs=4) で 200 trial を実行。 後で別プロセスから同じ study_name で開き直し、 trial を追加する。

📥 入力データ:

storage: 'sqlite:///optuna_birth.db' (カレントディレクトリに作成) study_name: 'gbr_birth_rate' (一意 ID) 4 CPU 並列 / 各 50 trial = 計 200 trial
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import optuna
import os
if os.path.exists('optuna_birth.db'): os.remove('optuna_birth.db')  # 毎回同じ trial 数になるよう作り直す

# プロセス A (初回)
study = optuna.create_study(sampler=optuna.samplers.TPESampler(seed=0), 
    storage='sqlite:///optuna_birth.db',
    study_name='gbr_birth_rate',
    direction='maximize',
    load_if_exists=True)
study.optimize(objective, n_trials=50, n_jobs=4)             # 4 並列で 50 trial

# プロセス B (別ターミナル、 後から追加)
study2 = optuna.load_study(storage='sqlite:///optuna_birth.db', study_name='gbr_birth_rate')
print(f'現在の trial 数: {len(study2.trials)}')
study2.optimize(objective, n_trials=50)                            # さらに 50 追加
print(f'追加後 trial 数: {len(study2.trials)}, Best: {study2.best_value:.4f}')

📤 実行結果 (典型例):

現在の trial 数: 50 追加後 trial 数: 100, Best: 0.9363 ※ 所要時間は環境によって変わる(4 並列で 50 trial なら数十秒程度) ※ 冒頭で optuna_birth.db を消しているので、 何度実行しても 50 → 100 になる。 消さずに再実行すると trial 数が 150、 200 …と積み上がる(load_if_exists=True のため)

💬 読み方: 同じ DB を共有することで、 (1) 中断しても再開できる、 (2) 別マシンから「もう少し探そう」と継続できる、 (3) 並列で安全に書き込める (Optuna が排他制御)。 大規模 study では SQLite から MySQL/PostgreSQL に切り替えると数百ノード並列も実用に。 Kubernetes / Slurm でのジョブ化と相性抜群。

🐍 可視化 — optuna.visualization で study を見える化

Optuna は Plotly ベースの可視化 API を備えており、 1 行で散布図・等高線図・パラメータ重要度・並列座標プロットなど主要な分析が描ける。 Kaggle や論文用のグラフ作成にも便利。

🎯 このコードでやること: 完了済み study から 5 種類の代表的なプロットを生成し、 HTML や PNG として保存する。 ノートブック上では fig.show() でインタラクティブ表示。

📥 入力データ:

study: 100 trial 完了の Study オブジェクト パラメータ: n_estimators, max_depth, learning_rate, subsample, min_samples_split
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import optuna.visualization as vis

vis.plot_optimization_history(study).write_html('history.html')       # 履歴
vis.plot_param_importances(study).write_html('importance.html')        # 重要度
vis.plot_parallel_coordinate(study).write_html('parallel.html')       # 並列座標
vis.plot_contour(study, params=['learning_rate', 'max_depth']).write_html('contour.html')
vis.plot_slice(study).write_html('slice.html')                          # スライス

# matplotlib backend (PNG 保存に便利)
import optuna.visualization.matplotlib as vism
ax = vism.plot_param_importances(study)
ax.figure.savefig('importance.png', dpi=150)

📤 出力ファイル:

history.html — 累積最良値の推移 (TPE が後半急に良くなるのが見える) importance.html — 横棒グラフ: learning_rate 0.62 が最も効くと一目 parallel.html — 並列座標: 良 trial がどんなパラメータ範囲に集中するか contour.html — 等高線: lr × depth 平面上の R² ランドスケープ slice.html — 各パラメータ単独の効果 (1 軸スライス) importance.png — matplotlib 経由で PNG 保存 (論文用 dpi=150)

💬 読み方: 最低限見るべきは (1) optimization_history (TPE が改善し続けているか・もう収束したか)、 (2) param_importances (どこに資源を投入すべきか)、 (3) contour (パラメータ間の交互作用) の 3 枚。 これで「最適化が十分か」「次は探索空間を絞るか広げるか」が判断できる。

📝 補足の直感 — 「Optuna は探索と活用のバランサー」

ここまでの各章(🎨 直感・📐 数式・🎮 ウィジェット)を一段深く束ね直す。 Optuna の本質は 4 つの部品の組み合わせに分解できる。 それぞれを「なぜそう設計されているか」で読むと、 API の一つひとつが腑に落ちる。

  • ① define-by-run(探索空間を実行時に組み立てる):探索空間を事前の辞書で固定せず、 objective(trial) の内側で trial.suggest_* を呼んだ瞬間に次元が生える。 だから「optimizer が Adam のときだけ beta1 を探す」という条件付き・木構造の空間を Python の if でそのまま書ける。 これが Hyperopt の define-and-run に対する決定的な優位点で、 名前の Tree-structured の由来でもある。
  • ② TPE(ベイズ最適化で次の一手を賢く決める):過去の試行を「良かった群 $\ell(x)$」と「悪かった群 $g(x)$」に割り、 密度比 $\ell(x)/g(x)$ が大きい点を次に試す。 グリッド/ランダムが「何回試しても学ばない」のに対し、 TPE は履歴が増えるほど有望領域の推定が鋭くなる。 序盤は探索(exploration)、 後半は活用(exploitation)へ重心が移るのがウィジェットで見えた挙動そのもの。
  • ③ pruning(見込みのない試行を途中で捨てる):学習曲線の中間値を trial.report(value, step) で報告し、 should_prune() が真なら打ち切る。 「1 試行 = 完走」という暗黙の前提を崩し、 予算を横(試行数)だけでなく縦(各試行の深さ)にも配分する発想。
  • ④ study/storage(履歴を貯めて共有・再開する):全 trial は study に蓄積され、 RDB に永続化すれば中断再開・並列・重要度分析すべての基盤になる。 「最適化」よりむしろ「実験ログの器」と捉えると設計が見えてくる。

💬 まとめると Optuna は「次にどこを試すか(TPE)」「どこで諦めるか(pruning)」「どう空間を描くか(define-by-run)」「結果をどう貯めるか(study)」の 4 つを、 数行のコードに畳み込んだフレームワーク。 上の各実演で得た本ページの実測値(SSDSE-B-2026 / 2023 年 47 県、 GBR で R² デフォルト 0.9328 → Optuna 100 trial 0.9572)も、 この 4 部品が噛み合った結果である。

📝 落とし穴の深掘り — 「良い CV スコア」を疑う7つの視点

Optuna は「与えた目的関数を全力で最大化する道具」であり、 目的関数の設計が誤っていればその誤りごと最適化してしまう。 上の ⚠️ 落とし穴章・表を補い、 特に再現性汎化に関わる論点を体系化する。

  • (1) 検証データへの過剰適合(over-tuning):試行数が多いほど、 Optuna は検証分割固有のノイズの山にも登る。 本ページの Nested CV 実演では「内 CV の楽観 R² 0.9551」に対し「外 test の現実 R² 0.9118」で約 4.5% の楽観バイアスを実測。 数千 trial では差はさらに開く。 対策は試行数を次元 × 10〜30 に抑える・Nested CV で汎化を別測りする・上位数%の平均パラメータを採る。
  • (2) 探索空間の設計ミス:範囲が狭いと真の最適が範囲外で永久に見つからず、 広すぎると 100 trial でも収束しない。 ベストが範囲の端に張り付いたら赤信号。 学習率・正則化のような桁で効く量は必ず log=True(対数スケール)にする。 ウィジェットで真のピーク x=0.67 が範囲外だったらどの戦略も無力、 という体験がこれに対応。
  • (3) シード非固定で再現不能:TPE は乱数を含むため、 TPESampler(seed=42) に加えモデル側 random_state・numpy/torch のシードまで揃えないと結果が毎回ずれる。 並列(n_jobs>1)実行時は trial 到着順が変わり、 seed を固定しても厳密再現は保証されない点に注意。
  • (4) 目的関数のリーク:objective の中でスケーリング・特徴選択・目的変数由来の統計量を CV 分割の外で計算すると、 検証情報が学習側へ漏れてスコアが不当に上がる。 前処理は必ず fold 内に閉じる(Pipeline を CV に渡す)。 詳細は データリーク を参照。
  • (5) 試行数と計算コストの取り違え:TPE の限界効用は逓減する。 「1000 trial 回した」は「良い解を得た」を意味しない。 収束曲線(plot_optimization_history)が平坦なら打ち切り、 予算は特徴量エンジニアリングへ回す方が効く局面が多い。
  • (6) 枝刈りの副作用:pruning は遅咲きの最良 trialを序盤の運で切る危険がある。 ウィジェットの枝刈りデモでは n_warmup_steps=0 にすると本来最良の trial 7(最終 0.908)が step 0 で切られ、 完走ベストが 0.871 止まりになる例を正確な計算で示した。 n_warmup_steps を 1 以上に取り、 曲線の交差が多いタスクほど warmup を厚くする。
  • (7) CV 外側での最終評価が必須:最適化に使った分割で報告した数字は「チューニングの内側」の値。 本番投入前には最適化に一切触れていない hold-out か Nested CV で汎化を確かめ、 最終モデルは全データで再学習する。 過学習交差検証 の考え方が土台になる。

注記:上記の 4.5% 楽観バイアス・0.871 等の数値は本ページの SSDSE-B-2026 実演(🧮 Nested CV 節・🎮 枝刈りデモ)で得た値。 デモの学習曲線は plateau と立ち上がりが異なる架空の飽和曲線(合成データ)である旨は既存章の注記どおり。

📝 発展の深掘り — サンプラー・枝刈り・多目的・分散の選び分け

サンプラー(次の一手の決め方)

サンプラー原理強い場面弱い場面
TPE(既定)Parzen 窓による密度比最大化(ベイズ流)混合空間・条件付き・中〜高次元の汎用パラメータ間に強い相関があると非効率
CMA-ES共分散行列を適応させる進化戦略連続変数のみ・相関の強い滑らかな空間離散・カテゴリ・条件付き空間が苦手
GridSampler格子の全探索低次元・離散・完全再現が必須次元が増えると組合せ爆発
RandomSampler一様乱択ベースライン・デバッグ・完全並列履歴を活かさず効率は頭打ち
QMCSampler準モンテカルロ(Sobol/Halton)初期探索の網羅性・低食い違い後半の集中探索は不得手
NSGA-II / NSGA-III遺伝的アルゴリズム+Pareto 選択多目的(精度 vs 時間 など)単目的では TPE に劣ることが多い

💬 実務の定石は「まず TPE で 10D〜30D trial」。 連続かつ高次元で滑らかなら CMA-ES、 複数指標を同時に見たいなら NSGA-II、 という切り替え。 いずれも optuna.samplers.*Sampler()create_study(sampler=...) に渡すだけで差し替わる。

枝刈り(どこで諦めるか)

  • MedianPruner:同 step の過去中央値より悪ければ打切り。 最も素直で既定的な選択。
  • SuccessiveHalvingPruner / HyperbandPruner(ASHA 系):少予算で多数を走らせ、 上位だけに予算を倍々で集中させる multi-fidelity 型。 ASHA(Asynchronous Successive Halving)は非同期で並列と相性がよく、 深層学習の大規模探索で有力。
  • PatientPruner:改善停滞(patience)を許容してから切る。 一時的に伸び悩む曲線を早計に捨てないための保険。

💬 枝刈りは trial.report() + should_prune() を objective に埋めるのが前提。 pruner の攻撃度は n_warmup_stepsn_startup_trials で調整し、 「遅咲きを切る危険」と「計算節約」のトレードオフ(落とし穴 (6))を意識して選ぶ。

多目的・分散・study 設計

  • 多目的最適化create_study(directions=['maximize','minimize']) で Pareto front を直接取得。 本ページの多目的実演では「精度 0.91 で 38ms」から「精度 0.96 で 745ms」まで 5 つの非劣構成が得られ、 単目的なら見逃す「3 倍速で精度ほぼ同」の選択肢が可視化された。
  • 分散・並列最適化:SQLite/MySQL/PostgreSQL を storage に指定すれば、 複数プロセス・複数マシンが同一 study に trial を書き込める(Optuna が排他制御)。 GPU 台数ぶんプロセスを分け CUDA_VISIBLE_DEVICES で割り当てるのが定石。 ただし TPE は履歴依存なので並列度を上げすぎると「賢さ」が薄まる(ランダム/ASHA は完全並列に強い)。
  • study の育て方study.add_trials(prev.trials) で過去知識を warm-start 継承、 enqueue_trial() で「試したい既知の良構成」を先頭に差し込める。 重要度(fANOVA/PED-ANOVA)で効くパラメータを見極め、 効かない軸は固定して効く軸を細探索する「2 ラウンド戦法」が予算効率を大きく上げる。
  • Nested CV との組合せ:外ループで test fold を切り、 内ループで Optuna を回して best params を外 fold で評価する。 小標本・コンペでは、 楽観バイアス(落とし穴 (1))を測る誠実な評価として推奨。