📚 背景知識(読んでから問題へ)
learning_rate・depth・l2_leaf_reg など性能を左右するハイパーパラメータがたくさんあります。今日からはその最適値をコンピュータに自動で探させるツール、Optuna を学びます。| 用語 | 直感的な意味 |
|---|---|
| Study(スタディ) | Optunaにおける「一連の最適化セッション」全体。実験そのものの入れ物 |
| Trial(トライアル) | Studyの中の1回分の試行。「このパラメータでCV RMSEはいくつだった」という1レコード |
| objective関数 | trial を受け取り、そのパラメータで学習・評価して最小化/最大化したいスコアを1つ返す関数 |
suggest_* API | trial.suggest_float(名前, 下限, 上限) のように「この範囲から選んでほしい」と伝える書き方。これがパラメータ空間の定義そのもの |
| TPE(Tree-structured Parzen Estimator) | Optunaのデフォルトの探索アルゴリズム。過去の試行結果を踏まえて次の有望な値を予測しながら探す(ベイズ最適化の一種) |
🧭 3つの探索戦略
🔲 グリッドサーチ
全組み合わせをしらみ潰し
候補値を格子状に列挙し全部試す。次元が増えると組み合わせ爆発(次元の呪い)で現実的でなくなる。
🎲 ランダムサーチ
サイコロで候補を選ぶ
範囲からランダムに選んで試す。同じ試行回数ならグリッドより「意外と効くパラメータ」を発見しやすい。
🧠 ベイズ最適化(TPE)
過去の結果から学習して探す
良かった試行・悪かった試行の分布を別々にモデル化し、次に試す有望な値を予測する。少ない試行で高精度に到達。
🎯 問題
Day 095 の CatBoost で使った高カーディナリティ家賃データ(district 30種類・n=800)を使います。今日は CatBoost のハイパーパラメータを Optuna で自動探索します。
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 |
タスク
objective(trial) 関数を実装。CatBoost のパラメータを trial.suggest_* で定義し、5-fold CVの平均RMSEを返す(learning_rate / depth / l2_leaf_reg / subsample / random_strength)optuna.create_study(direction="minimize") でStudyを作り、study.optimize(objective, n_trials=30) で最適化を実行するstudy.best_params / study.best_value を確認し、study.trials_dataframe() で上位5件のtrialを表示する🧠 TPE はどう「次の一手」を決めるか
グリッド/ランダムは過去の結果を見ずに次を選ぶが、TPEは「良かった試行」と「悪かった試行」の分布を別々に作り、両者の比率が高い値を次の候補にする。試せば試すほど探索が賢くなる。
📊 デフォルト vs Optunaチューニング(出力例)
CatBoostはデフォルトでも堅いモデルなので改善幅は小さめだが、「探索の仕組みを自分で組める」ことが今日の到達点。
Test RMSE(万円・小さいほど良い)
💡 ヒント
Optuna の使い方はパターンが決まっている。「①パラメータの決め方を objective(trial) という関数に閉じ込める → ② trial.suggest_* で探索範囲を宣言する → ③ Study に試行回数(n_trials)を指定して丸投げする」の3ステップ。TPEの数式は理解しなくても、「良かった試行と悪かった試行を分けて、良かった方に似た値を優先的に提案する」という直感さえあれば十分。
- インストール:
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)
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つ返す
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.optimize で objective を指定回数呼び出し、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デフォルトと比較して初めて「チューニングの価値」がわかる
🧮 数学・統計の補足(文系向け)
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関数をベースに無駄な計算を削って探索を高速化する
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: