Day 096 — Optuna基礎 — Study・Trial・パラメータ空間定義

2026-07-16 青 / Phase 4 コーディング Study / Trial / TPE / suggest_*

📚 背景知識(読んでから問題へ)

🔵
Day 091-095 で XGBoost・LightGBM・CatBoost の3大GBDTを学びました。どのモデルにも learning_ratedepthl2_leaf_reg など性能を左右するハイパーパラメータがたくさんあります。今日からはその最適値をコンピュータに自動で探させるツール、Optuna を学びます。
用語直感的な意味
Study(スタディ)Optunaにおける「一連の最適化セッション」全体。実験そのものの入れ物
Trial(トライアル)Studyの中の1回分の試行。「このパラメータでCV RMSEはいくつだった」という1レコード
objective関数trial を受け取り、そのパラメータで学習・評価して最小化/最大化したいスコアを1つ返す関数
suggest_* APItrial.suggest_float(名前, 下限, 上限) のように「この範囲から選んでほしい」と伝える書き方。これがパラメータ空間の定義そのもの
TPE(Tree-structured Parzen Estimator)Optunaのデフォルトの探索アルゴリズム。過去の試行結果を踏まえて次の有望な値を予測しながら探す(ベイズ最適化の一種)

🧭 3つの探索戦略

確実だが遅い

🔲 グリッドサーチ

全組み合わせをしらみ潰し

候補値を格子状に列挙し全部試す。次元が増えると組み合わせ爆発(次元の呪い)で現実的でなくなる。

シンプルで強い

🎲 ランダムサーチ

サイコロで候補を選ぶ

範囲からランダムに選んで試す。同じ試行回数ならグリッドより「意外と効くパラメータ」を発見しやすい。

賢く効率的

🧠 ベイズ最適化(TPE)

過去の結果から学習して探す

良かった試行・悪かった試行の分布を別々にモデル化し、次に試す有望な値を予測する。少ない試行で高精度に到達。

🎯 問題

Day 095 の CatBoost で使った高カーディナリティ家賃データ(district 30種類・n=800)を使います。今日は CatBoost のハイパーパラメータを Optuna で自動探索します。

使用データDay095と同一(district 30種 n=800)
import numpy as np
import pandas as pd

np.random.seed(42)
n = 800

area_m2 = np.random.normal(45, 15, n).clip(15, 100)
age_years = np.random.randint(0, 35, n)
station_dist_min = np.random.normal(8, 4, n).clip(1, 25)

layout = np.random.choice(["1K", "1DK", "1LDK", "2LDK", "3LDK"], n,
                          p=[0.30, 0.15, 0.25, 0.20, 0.10])
structure = np.random.choice(["木造", "鉄骨", "RC"], n, p=[0.45, 0.30, 0.25])

districts = [f"area_{i:02d}" for i in range(30)]
district = np.random.choice(districts, n)
district_base = {d: v for d, v in zip(districts, np.random.uniform(0, 5, 30))}

layout_effect = pd.Series(layout).map(
    {"1K": 0.0, "1DK": 1.0, "1LDK": 2.5, "2LDK": 4.0, "3LDK": 6.0}).values
structure_effect = pd.Series(structure).map(
    {"木造": 0.0, "鉄骨": 0.8, "RC": 1.8}).values
district_effect = pd.Series(district).map(district_base).values

rent_man_yen = (
    3.0 + area_m2 * 0.20 - age_years * 0.05 - station_dist_min * 0.15
    + layout_effect + structure_effect + district_effect + np.random.normal(0, 1.0, n)
).clip(3.0, None)

df = pd.DataFrame({
    "area_m2": area_m2, "age_years": age_years, "station_dist_min": station_dist_min,
    "layout": layout, "structure": structure, "district": district,
    "rent_man_yen": rent_man_yen,
})
カラム名意味
area_m2専有面積(平米)float
age_years築年数int
station_dist_min最寄り駅からの徒歩時間(分)float
layout / structure / districtカテゴリ変数(低〜高カーディナリティ)category
rent_man_yen家賃(万円・目的変数)float

タスク

1
【理論】グリッドサーチ・ランダムサーチ・ベイズ最適化(TPE)の違いを、「次に何を試すかをどう決めるか」という観点で説明する
2
【実装】objective(trial) 関数を実装。CatBoost のパラメータを trial.suggest_* で定義し、5-fold CVの平均RMSEを返す(learning_rate / depth / l2_leaf_reg / subsample / random_strength)
3
【実装】optuna.create_study(direction="minimize") でStudyを作り、study.optimize(objective, n_trials=30) で最適化を実行する
4
【分析】study.best_params / study.best_value を確認し、study.trials_dataframe() で上位5件のtrialを表示する
5
【比較】デフォルトパラメータ(Day095の lr=0.05, depth=6)と Optunaのbest_paramsで、held-out testのRMSEを比較する

🧠 TPE はどう「次の一手」を決めるか

グリッド/ランダムは過去の結果を見ずに次を選ぶが、TPEは「良かった試行」と「悪かった試行」の分布を別々に作り、両者の比率が高い値を次の候補にする。試せば試すほど探索が賢くなる。

これまでの Trial(●=良かった試行 / ○=悪かった試行) ● 良かった試行 p(x|y<y*) ○ 悪かった試行 p(x|y≥y*) 良かった試行の密度 悪かった試行の密度 ↑ 比率最大=次の候補
💡
「良かった時に典型的で、悪かった時にはあまり出なかった」値ほど次に優先される。試行が積み重なるほど山の形が精密になり、無駄足が減っていく。

📊 デフォルト vs Optunaチューニング(出力例)

CatBoostはデフォルトでも堅いモデルなので改善幅は小さめだが、「探索の仕組みを自分で組める」ことが今日の到達点。

Test RMSE(万円・小さいほど良い)

デフォルト (lr=0.05, depth=6)
約1.0x
決め打ち
Optunaベスト(30 trial)
約0.9x
TPEで自動探索

💡 ヒント

ヒント1方向性

Optuna の使い方はパターンが決まっている。「①パラメータの決め方を objective(trial) という関数に閉じ込める → ② trial.suggest_* で探索範囲を宣言する → ③ Study に試行回数(n_trials)を指定して丸投げする」の3ステップ。TPEの数式は理解しなくても、「良かった試行と悪かった試行を分けて、良かった方に似た値を優先的に提案する」という直感さえあれば十分。

ヒント2アプローチ
  • インストール: pip install optuna(Colabなら基本入っている)
  • 対数スケール: 「0.01と0.02の差」と「0.1と0.2の差」を同じ重要度で探したい時は log=True
  • subsample を CatBoost で使うには bootstrap_type="Bernoulli"(または"MVS")の指定が必要
  • objective関数は「1つの数値」を返す。CVのRMSEの平均を返す
  • study.trials_dataframe() は全trialのパラメータとスコアを含むDataFrame。.sort_values("value").head()
  • 再現性: sampler=optuna.samplers.TPESampler(seed=42)
ヒント3コード骨格
import optuna
from catboost import CatBoostRegressor
from sklearn.model_selection import KFold, train_test_split
from sklearn.metrics import mean_squared_error

cat_cols = ["layout", "structure", "district"]
num_cols = ["area_m2", "age_years", "station_dist_min"]
X = df[num_cols + cat_cols]
y = df["rent_man_yen"]

def objective(trial):
    params = {
        "iterations": 400,
        "learning_rate": trial.suggest_float("learning_rate", ___, ___, log=True),
        "depth": trial.suggest_int("depth", ___, ___),
        "l2_leaf_reg": trial.suggest_float("l2_leaf_reg", ___, ___, log=True),
        "subsample": trial.suggest_float("subsample", ___, ___),
        "random_strength": trial.suggest_float("random_strength", ___, ___, log=True),
        "bootstrap_type": "Bernoulli",
        "cat_features": cat_cols,
        "random_state": 42, "verbose": 0,
    }
    kf = KFold(n_splits=5, shuffle=True, random_state=42)
    rmses = []
    for tr, va in kf.split(X):
        m = CatBoostRegressor(**params)
        m.fit(X.iloc[tr], y.iloc[tr])
        rmses.append(mean_squared_error(y.iloc[va], m.predict(X.iloc[va]), squared=False))
    return np.mean(rmses)

study = optuna.create_study(direction=___, sampler=optuna.samplers.TPESampler(seed=42))
study.optimize(objective, n_trials=___)
print(study.best_params, study.best_value)

模範解答

import numpy as np
import pandas as pd
import optuna
from catboost import CatBoostRegressor
from sklearn.model_selection import KFold, train_test_split
from sklearn.metrics import mean_squared_error

optuna.logging.set_verbosity(optuna.logging.WARNING)  # ログを静かに

# ====== データ準備(Day095と同一) ======
np.random.seed(42)
n = 800
area_m2 = np.random.normal(45, 15, n).clip(15, 100)
age_years = np.random.randint(0, 35, n)
station_dist_min = np.random.normal(8, 4, n).clip(1, 25)
layout = np.random.choice(["1K", "1DK", "1LDK", "2LDK", "3LDK"], n,
                          p=[0.30, 0.15, 0.25, 0.20, 0.10])
structure = np.random.choice(["木造", "鉄骨", "RC"], n, p=[0.45, 0.30, 0.25])
districts = [f"area_{i:02d}" for i in range(30)]
district = np.random.choice(districts, n)
district_base = {d: v for d, v in zip(districts, np.random.uniform(0, 5, 30))}
layout_effect = pd.Series(layout).map(
    {"1K": 0.0, "1DK": 1.0, "1LDK": 2.5, "2LDK": 4.0, "3LDK": 6.0}).values
structure_effect = pd.Series(structure).map(
    {"木造": 0.0, "鉄骨": 0.8, "RC": 1.8}).values
district_effect = pd.Series(district).map(district_base).values
rent_man_yen = (
    3.0 + area_m2 * 0.20 - age_years * 0.05 - station_dist_min * 0.15
    + layout_effect + structure_effect + district_effect + np.random.normal(0, 1.0, n)
).clip(3.0, None)
df = pd.DataFrame({
    "area_m2": area_m2, "age_years": age_years, "station_dist_min": station_dist_min,
    "layout": layout, "structure": structure, "district": district,
    "rent_man_yen": rent_man_yen,
})

cat_cols = ["layout", "structure", "district"]
num_cols = ["area_m2", "age_years", "station_dist_min"]
X = df[num_cols + cat_cols]
y = df["rent_man_yen"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# ====== タスク2-3: objective関数 & Study ======
def objective(trial):
    params = {
        "iterations": 400,
        "learning_rate": trial.suggest_float("learning_rate", 0.01, 0.3, log=True),
        "depth": trial.suggest_int("depth", 3, 10),
        "l2_leaf_reg": trial.suggest_float("l2_leaf_reg", 0.01, 10.0, log=True),
        "subsample": trial.suggest_float("subsample", 0.5, 1.0),
        "random_strength": trial.suggest_float("random_strength", 1e-3, 10.0, log=True),
        "bootstrap_type": "Bernoulli",   # subsampleを使うために必須
        "cat_features": cat_cols,
        "random_state": 42, "verbose": 0,
    }
    kf = KFold(n_splits=5, shuffle=True, random_state=42)
    rmses = []
    for tr, va in kf.split(X_train):
        m = CatBoostRegressor(**params)
        m.fit(X_train.iloc[tr], y_train.iloc[tr])
        pred = m.predict(X_train.iloc[va])
        rmses.append(mean_squared_error(y_train.iloc[va], pred, squared=False))
    return np.mean(rmses)

study = optuna.create_study(direction="minimize", sampler=optuna.samplers.TPESampler(seed=42))
study.optimize(objective, n_trials=30)

print("--- タスク3: 最適化結果 ---")
print(f"best_value (CV RMSE): {study.best_value:.4f}")
print(f"best_params: {study.best_params}")

# ====== タスク4: trials_dataframe で上位5件 ======
trials_df = study.trials_dataframe()
top5 = trials_df.sort_values("value").head(5)[
    ["number", "value", "params_learning_rate", "params_depth",
     "params_l2_leaf_reg", "params_subsample", "params_random_strength"]
]
print("\n--- タスク4: 上位5trial ---")
print(top5)

# ====== タスク5: デフォルト vs Optunaチューニング後の test RMSE ======
default_model = CatBoostRegressor(
    iterations=400, learning_rate=0.05, depth=6,
    cat_features=cat_cols, random_state=42, verbose=0,
)
default_model.fit(X_train, y_train)
default_rmse = mean_squared_error(y_test, default_model.predict(X_test), squared=False)

tuned_model = CatBoostRegressor(
    iterations=400, **study.best_params,
    bootstrap_type="Bernoulli", cat_features=cat_cols,
    random_state=42, verbose=0,
)
tuned_model.fit(X_train, y_train)
tuned_rmse = mean_squared_error(y_test, tuned_model.predict(X_test), squared=False)

print("\n--- タスク5: デフォルト vs Optunaチューニング(test RMSE)---")
print(f"デフォルト (lr=0.05, depth=6)   : {default_rmse:.4f} 万円")
print(f"Optunaベスト(30試行)          : {tuned_rmse:.4f} 万円")
print(f"改善幅: {default_rmse - tuned_rmse:+.4f} 万円")

# 出力例(乱数・環境で多少前後):
# best_value (CV RMSE): 1.0x
# best_params: {'learning_rate': 0.0x, 'depth': 6, 'l2_leaf_reg': 0.x, 'subsample': 0.8x, ...}
# デフォルト (lr=0.05, depth=6)   : 1.0x 万円
# Optunaベスト(30試行)          : 0.9x 万円   ← わずかに改善

🪜 Step-by-Step 解説

1objective関数は「パラメータ→スコア」を返す1つの関数

def objective(trial):
    params = {...}   # trial.suggest_* でパラメータを決める
    return np.mean(rmses)   # 最小化したい値を1つ返す
🔑
なぜこう書くか: Optuna は「この関数を何度も呼び出し、毎回違う trial を渡して、返ってきたスコアが小さくなるように次のパラメータを考える」仕組み。あなたがやるのは「パラメータを受け取ってスコアを返す関数」を書くことだけで、探索アルゴリズム(TPE)そのものは Optuna 側が丸ごと引き受ける

2suggest_* がそのままパラメータ空間の定義になる

trial.suggest_float("learning_rate", 0.01, 0.3, log=True)
trial.suggest_int("depth", 3, 10)
🔑
なぜこう書くか: suggest_float(名前, 下限, 上限) で「この範囲の実数から選んでほしい」と伝える。suggest_int は整数版。log=True は対数スケール探索の指定で、learning_rate のように小さい値の変化が影響を与えやすいパラメータでは必須級。線形だと大きい値ばかり試され、0.01〜0.05の重要な領域が疎かになりがち。

3Studyが「実験全体」、Trialが「1回分」

study = optuna.create_study(direction="minimize", sampler=optuna.samplers.TPESampler(seed=42))
study.optimize(objective, n_trials=30)
🧪
なぜこう書くか: create_study で実験の入れ物を作る。direction="minimize" は「返り値が小さいほど良い」の意味(AccuracyやAUCなら "maximize")。study.optimizeobjective を指定回数呼び出し、TPEで次に試す値を決めながら探索する。seed=42 で再現性を確保。

4trials_dataframe() で探索の過程を丸ごと確認

trials_df = study.trials_dataframe()
trials_df.sort_values("value").head(5)
📋
なぜこう書くか: study.best_params は「一番良かった1回」しか見せないが、trials_dataframe()全trialの全パラメータとスコアを返す。上位を並べると「depthは5〜7に集中」のような傾向が読み取れ、次にもっと絞った範囲で再探索する材料になる。

5デフォルトと比較して初めて「チューニングの価値」がわかる

⚖️
なぜ比較するか: Optunaを使うこと自体が目的ではない。「手で決めた値より本当に良くなったか」をtest setで確認して初めて意味がある。CatBoostはデフォルトでも堅いモデルなので改善幅がごく小さいこともある。それでも「探索の仕組みを自分で組めるようになる」ことが今日の到達点。

🧮 数学・統計の補足(文系向け)

📐
TPE(ベイズ最適化)を直感で: 新しいラーメン屋を開拓するとして、グリッドサーチは「地図上のマス目を全部律儀に回る」、ランダムサーチは「サイコロで行き先を決める」やり方。TPE はもっと賢く、「これまで美味しかった店の共通点」と「イマイチだった店の共通点」を別々に覚えておき、「美味しかった店に似ていて、イマイチだった店には似ていない」条件の店を次に選ぶ。試すほど「良い店の特徴」の解像度が上がり、無駄足が減る。
📏
対数スケール(log=True)を直感で: 「年収300万と310万の差」より「年収300万と600万の差」の方が生活の変わり方が大きい。learning_rate も同じで、0.2→0.3の変化より0.01→0.02(2倍)の変化の方がモデルの挙動に効く。対数スケールは「同じ"比率"の変化を同じ重みで探索する」工夫。

数式を見たら

p(x | y < y*) と p(x | y ≥ y*)

良かった試行(スコア y が閾値 y* より小さい)のパラメータ分布」と「悪かった試行のパラメータ分布」を別々に作り、前者の確率密度 ÷ 後者の確率密度が最大になる x(パラメータ)を次の候補にする、というだけ。この比率が高い場所=「良かった時に典型的で、悪かった時には出にくかった」値。

🏆 Kaggleでの実践的な使い方

よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☑ 自然言語処理(NLP) / ☑ 画像認識(CV) / ☑ 時系列(Time Series)

Optuna はモデルの種類を問わず使える汎用ツールなので、Kaggleのほぼ全カテゴリのコンペで使われます。

  • Tabular(GBDT系): XGBoost/LightGBM/CatBoost のハイパラ探索は Optuna が事実上の標準。上位ノートブックの多くが optuna.create_study から始まる
  • 探索の設計が腕の見せ所: 範囲を広く取りすぎると無駄な試行が増え、狭すぎると良い値を逃す。まず粗い範囲で少ないtrial数(10〜20)を回し、trials_dataframe() で当たりを付けてから範囲を絞って本番探索(100+trial)をするのが定石
  • 計算資源との兼ね合い: 実行時間制限があるため n_trials を闇雲に増やすのではなく、次テーマの Pruning(見込みのない試行を早期に打ち切る)と組み合わせて効率化するのが実務での使い方

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
objective関数が複数の値を返そうとするRMSEとMAEを同時に最適化したいと思う単目的最適化では戻り値は1つだけ。複数指標を同時に扱うには directions=[...] のマルチ目的最適化が必要
learning_rate の範囲を線形スケール(log=False)で広く取る対数スケールの意味を知らない桁が変わるパラメータ(learning_rate・正則化係数)は log=True にしないと候補が大きい値に偏る
n_trials を増やすほど良いと思い込む「試行回数=精度」と誤解するある程度で改善が頭打ちになる(収穫逓減)。CVスコアの推移を見て改善が止まったら打ち切るのが効率的
subsample を指定してもCatBoostがエラーを出すパラメータ名だけ真似て渡すbootstrap_type="Bernoulli"(または"MVS")を明示しないと subsample が無効になる
objective関数の中でCVを使わず1回のtrain/testだけで評価する実装を簡単にしたい1回の分割ではノイズでスコアが揺れ、「たまたま良かっただけ」のパラメータに引っ張られる。必ずCVの平均を返す

🚀 次のステップ

  • 発展: optuna.visualization.matplotlib.plot_optimization_history(study)plot_param_importances(study) を使い、どのパラメータがスコアに一番効いているかを可視化してみる
  • 次回予告: Day 097「Optuna高度な使い方」 — Pruning(見込みのない試行の早期打ち切り)・並列化・可視化。今日のobjective関数をベースに無駄な計算を削って探索を高速化する

📝 自己評価(解いた後に記入)

自分の回答・気づき・メモ: