📚 背景知識(読んでから問題へ)
suggest_*)を学びました。しかし n_trials=30 を回すと、明らかに見込みのないパラメータの組み合わせも最後まで律儀に5-fold CVを回してしまい、時間を無駄にしています。今日は探索を高速化・効率化する3つの技術(Pruning・並列化・可視化)を学びます。| 用語 | 直感的な意味 |
|---|---|
| Pruning(枝刈り) | 学習の途中経過を見て「もう良い結果にならなさそうだ」と判断したtrialを途中で打ち切る仕組み |
trial.report(value, step) | 学習途中の「中間スコア」をOptunaに報告する。記録するだけで打ち切り判定はしない |
trial.should_prune() | 過去trialの中間スコアと比較し「今のtrialを打ち切るべきか」をTrue/Falseで判定 |
optuna.TrialPruned() | should_prune()がTrueの時に自分でraiseする例外。Optunaがそのtrialを打ち切る |
| MedianPruner | 「同じstepの過去trialの中央値」より明らかに悪ければ打ち切りを勧めるPruner |
| n_jobs / 共有ストレージ | n_jobsは1プロセス内のスレッド並列。真の分散にはSQLite等の共有ストレージが必要 |
⚡ 探索を高速化する3つの技術
✂️ Pruning
途中経過で早期打ち切り
foldごとの中間スコアを報告し、過去trialの中央値より明らかに悪ければ残りの学習をスキップする。
🧵 並列化
n_jobs / 共有ストレージ
n_jobsで1台の中のスレッド並列、複数マシンでの本格分散にはRDBストレージが必要。
📊 可視化
history / param_importances
スコアの推移とパラメータ重要度を可視化し、次の探索範囲を絞る材料にする。
🎯 問題
Day 095/096 と同じ CatBoost × 家賃データ(district 30種類・n=800)を使います。今日は Day 096 の objective(trial) 関数を拡張し、Pruning・可視化まで組み込みます。
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 |
タスク
MedianPrunerがどう判断を下すかも直感的に説明するobjective(trial)を書き換え、5-fold CVの各fold終了ごとに累積平均RMSEをtrial.report(value, step)で報告し、should_prune()がTrueならoptuna.TrialPruned()を送出するMedianPruner(n_startup_trials=5, n_warmup_steps=2)付きStudyでn_trials=50を実行し、完了trial数・打ち切りtrial数を集計するplot_optimization_historyとplot_param_importancesで、スコアが頭打ちになった時期とパラメータ重要度を読み取るn_jobs=-1の並列化の仕組みと、複数マシンで本格的に分散する場合に追加で必要なものを説明する✂️ MedianPruner はどう「打ち切り」を判断するか
5-fold CVの各fold終了時点で「ここまでの累積平均RMSE」を報告する。同じstepの過去trialの中央値より明らかに悪いtrialは、残りのfoldを回さずに打ち切られる。
n_warmup_steps=2)はまだ判定対象外。step2の時点で「中央値ライン」より明らかに悪いことが確定した赤いtrialは、残りstep3・step4の学習をせずにそこで打ち切られる。📊 Pruningの効果(出力例・50 trial)
見込みのないtrialの残りfoldをスキップすることで、同じ50 trialでも実際に最後まで学習する回数が減り、その分だけ多くの候補を試せるようになる。
Trial数の内訳(全50 trial)
💡 ヒント
GBDTの学習はCVのfoldごとに時間がかかる。全fold回してから「やっぱり悪かった」とわかるのは無駄。foldの合間に「ここまでの成績」をOptunaに申告し、過去の他trialと比べて明らかに見込みがなければその場で諦める——これがPruning。今日のゴールは trial.report / trial.should_prune / optuna.TrialPruned の3点セットに慣れることと、可視化で「どのパラメータが効くか」を定量的に把握すること。
trial.report(value, step)は中間値を記録するだけで、打ち切り判定はしない- 判定は
trial.should_prune()。TrueでもOptunaは自動で止めないので、自分でraise optuna.TrialPruned()する MedianPrunerはデフォルトで「同じstepの過去trialの中央値」より明らかに悪い場合に打ち切りを勧めるn_startup_trials: 最初のn件のtrialはPruning対象外(判断材料が少なすぎるため)n_warmup_steps: 各trialの最初のnステップはPruning対象外(序盤は不安定なため)- 打ち切られたtrialは
trial.state == optuna.trial.TrialState.PRUNEDになる
def objective(trial): params = {...} kf = KFold(n_splits=5, shuffle=True, random_state=42) rmses = [] for fold_idx, (tr, va) in enumerate(kf.split(X_train)): m = CatBoostRegressor(**params) m.fit(X_train.iloc[tr], y_train.iloc[tr]) rmses.append(mean_squared_error(y_train.iloc[va], m.predict(X_train.iloc[va]), squared=False)) interim = np.mean(rmses) trial.report(___, step=___) # 何を・どのstepで報告するか if trial.___(): # 打ち切り判定 raise optuna.___() # 打ち切り実行 return np.mean(rmses) study = optuna.create_study( direction="minimize", sampler=optuna.samplers.TPESampler(seed=42), pruner=optuna.pruners.MedianPruner(n_startup_trials=___, n_warmup_steps=___), ) study.optimize(objective, n_trials=___) pruned = [t for t in study.trials if t.state == optuna.trial.TrialState.PRUNED] completed = [t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE] print(f"完了: {len(completed)} / 打ち切り: {len(pruned)}")
✅ 模範解答
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/096と同一) ====== 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: Pruning対応 objective関数 ====== 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", "cat_features": cat_cols, "random_state": 42, "verbose": 0, } kf = KFold(n_splits=5, shuffle=True, random_state=42) rmses = [] for fold_idx, (tr, va) in enumerate(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)) interim_value = np.mean(rmses) # ここまでの累積平均RMSE trial.report(interim_value, step=fold_idx) if trial.should_prune(): raise optuna.TrialPruned() # このtrialを打ち切る return np.mean(rmses) # ====== タスク3: Pruner付きStudyで最適化 ====== study = optuna.create_study( direction="minimize", sampler=optuna.samplers.TPESampler(seed=42), pruner=optuna.pruners.MedianPruner(n_startup_trials=5, n_warmup_steps=2), ) study.optimize(objective, n_trials=50) pruned = [t for t in study.trials if t.state == optuna.trial.TrialState.PRUNED] completed = [t for t in study.trials if t.state == optuna.trial.TrialState.COMPLETE] print("--- タスク3: Pruning集計 ---") print(f"完了trial数: {len(completed)} / 打ち切りtrial数: {len(pruned)} / 全trial数: {len(study.trials)}") print(f"best_value (CV RMSE): {study.best_value:.4f}") print(f"best_params: {study.best_params}") # ====== タスク4: 可視化 ====== import optuna.visualization.matplotlib as opt_mpl import matplotlib.pyplot as plt opt_mpl.plot_optimization_history(study) plt.tight_layout() plt.savefig("optuna_history.png") # Colab/Jupyterならplt.show()でも可 opt_mpl.plot_param_importances(study) plt.tight_layout() plt.savefig("optuna_importances.png") # ====== タスク5: 並列化(実行例) ====== # n_jobs=-1 は「in-processのマルチスレッド」で並列化する。 # study_parallel = optuna.create_study(direction="minimize") # study_parallel.optimize(objective, n_trials=50, n_jobs=-1) # # 複数プロセス/複数マシンで本当に分散したい場合は、studyをメモリ上ではなく # 共有ストレージ(SQLite/MySQL等)に置き、同じ study_name で各プロセスが # optuna.load_study(study_name=..., storage="sqlite:///study.db") してから # それぞれ study.optimize(...) を呼び出す構成にする。
🪜 Step-by-Step 解説
1trial.report()は「記録するだけ」、判定は別
interim_value = np.mean(rmses) trial.report(interim_value, step=fold_idx)
trial.report(value, step) は「stepの時点でスコアはvalueでした」という記録をOptunaに渡すだけの処理。これ単体では何も起きない。「打ち切るかどうか」は次の should_prune() が別途判断する。この2段構えは、reportのログをplot_intermediate_valuesなどの可視化にも使い回せるようにするため。2should_prune()がTrueなら自分でraiseする
if trial.should_prune(): raise optuna.TrialPruned()
should_prune()は内部で「今の値を、同じstepの過去trialの中央値(MedianPrunerの場合)と比較して明らかに劣っているか」を判定している。Trueならoptuna.TrialPruned()をraiseすることで、そのtrialはPRUNEDとして記録され、残りfoldはスキップされる。3n_startup_trialsとn_warmup_stepsが「早すぎる判断」を防ぐ
pruner=optuna.pruners.MedianPruner(n_startup_trials=5, n_warmup_steps=2)
n_startup_trials=5は「最初の5trialはPruning対象にしない」の意味。比較対象の過去trialが少なすぎる序盤に中央値で判断すると基準が歪む。n_warmup_steps=2は「各trialの最初の2fold(step=0,1)ではPruningを判定しない」の意味。1〜2foldだけでは「たまたま難しいfoldに当たった」可能性があり判断材料として不十分。4plot_optimization_historyとplot_param_importancesで探索を振り返る
opt_mpl.plot_optimization_history(study) opt_mpl.plot_param_importances(study)
plot_optimization_historyは「trialを重ねるごとにベストスコアがどう推移したか」を折れ線で示し、どのあたりで改善が頭打ちになったかを判断する材料になる。plot_param_importancesはfANOVAで「各パラメータをちょっと動かすとスコアの分散がどれだけ変わるか」を分解し、影響の大きいパラメータをランキング表示する。5並列化は「手軽なn_jobs」と「本格的な共有ストレージ」の2段階
study.optimize(objective, n_trials=50, n_jobs=-1)は同じプロセス内のマルチスレッドで並列化する。CatBoost/LightGBMのfit()はC++実装でGILを解放するためある程度は恩恵を受けられるが、これは1台のマシンの中の並列化にすぎない。複数プロセス・複数マシンで本当に分散したい場合はStudyの状態をプロセス間で共有する必要があり、in-memory Studyでは不可能。storage="sqlite:///study.db"のような共有ストレージにStudyを置き、各プロセスが同じstudy_nameで読み込んでoptimize()を呼ぶ構成にする。🧮 数学・統計の補足(文系向け)
MedianPrunerの考え方。選手が少ないうち(n_startup_trials)や走り始めてすぐ(n_warmup_steps)は判断材料が足りないので打ち切りは行わない。plot_param_importancesは「このパラメータの値を動かすとCVスコアの分散がどれだけ変わるか」を全パラメータについて計算し、影響が大きい順に並べる。今回の探索範囲・試行回数の中での相対的な重要度である点には注意。数式を見たら
V_total = Σ V_i + Σ V_ij + ...
「スコアの全体のバラつき(V_total)」を、「パラメータ i 単体の効果(V_i)」と「パラメータ i と j の組み合わせの効果(V_ij)」などに分解する、という意味。fANOVAは各パラメータ単体の効果 V_i の割合を「重要度」として表示する。
🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☑ 表形式データ(Tabular) / ☑ 自然言語処理(NLP) / ☑ 画像認識(CV) / ☑ 時系列(Time Series)
- Pruning: NN系のコンペ(画像・NLP)では「epoch単位」でreportし、序盤のlossが悪いtrialを早期に打ち切るのが定番。GBDT系でも今日のように「CV fold単位」や「boosting round単位」(
optuna.integrationの各種PruningCallback)で使え、大規模データ・多いtrial数のコンペほど時間短縮効果が大きい - 可視化からの再探索:
plot_param_importancesで「効かないパラメータ」を洗い出し固定値にして探索空間を削減、浮いたtrial予算を「効くパラメータ」の細かい探索に回すのが実務・コンペ双方でよく行われる二段構えの最適化 - 並列化と計算資源: Kaggleノートブックの実行時間制限内で
n_trialsを稼ぐために、ローカルマシンやクラウドインスタンスで共有ストレージを使ったマルチプロセス最適化を回し、結果だけをノートブックに取り込む運用も上位陣ではよく見られる
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
trial.report()を呼べば自動でtrialが打ち切られると思う | should_prune()を呼ぶ必要があることを知らない | report()は記録するだけ。判定はshould_prune()を呼び、Trueならoptuna.TrialPruned()を自分でraiseする必要がある |
n_startup_trialsを0にしてすぐPruningを有効にする | 早く効果を出したいと焦る | 序盤は比較対象となるtrialが少なすぎて中央値が不安定。有望なtrialを誤って打ち切るリスクが上がる |
n_jobsを増やせば増やすほど速くなると思う | CPU数だけ並列にすれば速くなると考える | GILの影響やメモリ帯域の制約で、コア数に比例して速くなるとは限らない。ライブラリ自身が内部でマルチスレッド化している場合は競合してかえって遅くなることもある |
plot_param_importancesの結果を鵜呑みにしてパラメータを固定する | 可視化の結果を絶対視する | fANOVAは「今回の探索範囲・trial数」に依存した相対的な重要度。範囲を変えたり試行数を増やすと順位が変わることがある |
並列実行(n_jobs>1)でも直列実行と全く同じ結果になると思い込む | seedを固定していれば再現性があると思う | 並列だとtrialの実行順序が非決定的になりTPEの提案順が変わるため、直列実行と完全に同じ結果にはならない場合がある |
🚀 次のステップ
- 発展:
optuna.integrationの各種Pruningコールバック(LightGBM用のLightGBMPruningCallbackなど)を使い、CV fold単位ではなくboosting round単位でのPruningを試してみる - 次回予告: Day 098「Aggregation特徴量」 — groupby統計量・差分・比率特徴量。パラメータチューニングから一旦離れ、モデルに渡す「材料」そのものを強くする特徴量エンジニアリングの本丸に戻ります
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: