Day 097 — Optuna高度な使い方 — Pruning・並列化・可視化

2026-07-18 青 / Phase 4 コーディング Pruning / MedianPruner / n_jobs / fANOVA

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

🔵
Day 096 で Optuna の基本(Study・Trial・objective関数・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の中央値より明らかに悪ければ残りの学習をスキップする。

複数trialを同時に回す

🧵 並列化

n_jobs / 共有ストレージ

n_jobsで1台の中のスレッド並列、複数マシンでの本格分散にはRDBストレージが必要。

探索の中身を振り返る

📊 可視化

history / param_importances

スコアの推移とパラメータ重要度を可視化し、次の探索範囲を絞る材料にする。

🎯 問題

Day 095/096 と同じ CatBoost × 家賃データ(district 30種類・n=800)を使います。今日は Day 096 の objective(trial) 関数を拡張し、Pruning・可視化まで組み込みます。

使用データDay095/096と同一(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
【理論】Pruningとは何かを「途中経過を見て見込みのない試行を諦める」観点で説明する。MedianPrunerがどう判断を下すかも直感的に説明する
2
【実装】Day096のobjective(trial)を書き換え、5-fold CVの各fold終了ごとに累積平均RMSEをtrial.report(value, step)で報告し、should_prune()Trueならoptuna.TrialPruned()を送出する
3
【実装】MedianPruner(n_startup_trials=5, n_warmup_steps=2)付きStudyでn_trials=50を実行し、完了trial数・打ち切りtrial数を集計する
4
【分析】plot_optimization_historyplot_param_importancesで、スコアが頭打ちになった時期とパラメータ重要度を読み取る
5
【戦略】n_jobs=-1の並列化の仕組みと、複数マシンで本格的に分散する場合に追加で必要なものを説明する

✂️ MedianPruner はどう「打ち切り」を判断するか

5-fold CVの各fold終了時点で「ここまでの累積平均RMSE」を報告する。同じstepの過去trialの中央値より明らかに悪いtrialは、残りのfoldを回さずに打ち切られる。

fold(step)が進むごとの累積平均RMSE step0 step1 step2 step3 step4 悪い 良い 過去trialの中央値 良いtrial A 良いtrial B 中央値より明らかに悪い → TrialPruned(打ち切り)
💡
step0・step1(n_warmup_steps=2)はまだ判定対象外。step2の時点で「中央値ライン」より明らかに悪いことが確定した赤いtrialは、残りstep3・step4の学習をせずにそこで打ち切られる。

📊 Pruningの効果(出力例・50 trial)

見込みのないtrialの残りfoldをスキップすることで、同じ50 trialでも実際に最後まで学習する回数が減り、その分だけ多くの候補を試せるようになる。

Trial数の内訳(全50 trial)

完了(COMPLETE)
34 trial
最後まで5-fold学習
打ち切り(PRUNED)
16 trial
途中で計算を節約

💡 ヒント

ヒント1方向性

GBDTの学習はCVのfoldごとに時間がかかる。全fold回してから「やっぱり悪かった」とわかるのは無駄。foldの合間に「ここまでの成績」をOptunaに申告し、過去の他trialと比べて明らかに見込みがなければその場で諦める——これがPruning。今日のゴールは trial.report / trial.should_prune / optuna.TrialPruned の3点セットに慣れることと、可視化で「どのパラメータが効くか」を定量的に把握すること。

ヒント2アプローチ
  • 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 になる
ヒント3コード骨格
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()
🔑
なぜこう書くか: Optunaは「打ち切るべきだ」という判断材料は提供するが、実際に打ち切る(ループを中断する)のはあなたのコードの責任should_prune()は内部で「今の値を、同じstepの過去trialの中央値(MedianPrunerの場合)と比較して明らかに劣っているか」を判定している。Trueならoptuna.TrialPruned()raiseすることで、そのtrialはPRUNEDとして記録され、残りfoldはスキップされる。

3n_startup_trialsn_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_historyplot_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を直感で: 部活のタイムトライアルを想像してほしい。5周走るコースで、これまで走った選手の「1周目のラップタイム」を記録しておく。新しい選手が1周目を終えた時点で、そのタイムがこれまでの選手の中央値より明らかに遅ければ、残り4周を律儀に走らせなくても大体の順位は予想がつく。その選手には「今回は棄権していい」と伝える——これがMedianPrunerの考え方。選手が少ないうち(n_startup_trials)や走り始めてすぐ(n_warmup_steps)は判断材料が足りないので打ち切りは行わない。
📏
fANOVA(パラメータ重要度)を直感で: 「テストの点数のバラつきは、勉強時間の差でどれだけ説明できるか、睡眠時間の差でどれだけ説明できるか」を分解する分散分析(ANOVA)と同じ発想。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統計量・差分・比率特徴量。パラメータチューニングから一旦離れ、モデルに渡す「材料」そのものを強くする特徴量エンジニアリングの本丸に戻ります

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

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