📚 背景知識(読んでから問題へ)
探索の過学習(selection bias / winner's curse)。 ハイパーパラメータ探索は「同じ CV の点数を何十回も見て、一番良かったものを選ぶ」作業。CV の点数には「設定の本当の良さ」と「その分割でたまたま当たった分」が混ざっていて、60個から最小値を選ぶとたまたま当たった分が大きい設定ほど選ばれやすい。Cawley & Talbot (2010, JMLR 11:2079–2107) が扱った問題で、Kaggle の「公開 LB に合わせて提出を繰り返すと private で落ちる」と同じ仕組み。
今日の問い: (1) 探索回数 N を増やすと「探索用CV で見た改善」と「test 6万行での本当の改善」の差(楽観)は大きくなるか (2) 探索に使っていない分割で測り直すと楽観は消え、採否は当たるか (3) チューニングの本当の改善は、Day164 の特徴量(Dur_x_HR −0.00051)と比べて大きいか
| 用語 | 意味 |
|---|---|
| 探索用CV | 分割seed 0 の5分割での RMSLE。Optuna はこれだけを見る |
| 新分割R2 | 分割seed 1〜3 の5分割×3回で、同じ設定と既定値の fold 差15個から平均と SE(Day164 の R2)。探索は一度も見ていない |
| 真の効果 | test 6万行で、3繰り返し×5モデルの平均予測どうしの RMSLE の差(行で対にしてデルタ法で SE) |
| 既定値 | lr 0.1・木300・葉31・葉の最小行数20・L2 1e-4。trial 0 として最初に入れる → N=1 のベスト = 既定値 |
| 楽観 | 真の改善 − 見えた改善(×10⁻⁴)。正なら見かけの方が大きい |
| TPE | Optuna の既定の探索法。最初の10回はランダム、その後は良かった設定の近くを多めに試す |
🗂️ データ・探索空間
| 項目 | 値 | 理由 |
|---|---|---|
| 特徴量(9列) | 基準7列 + Dur_x_HR + Keytel_kcal | Day164 決定6 |
| orig / train / test | 15,000 / 30,000 / 60,000 行 × 2ワールド | 元データは学習foldにだけ(決定3)。test は答え合わせ専用 |
| learning_rate | 0.02〜0.3(log) | 既定 0.1 |
| max_iter | 100〜600(50刻み) | early stopping を切ったので木の数を探す |
| max_leaf_nodes | 7〜63(log) | 既定 31。127 まで広げると1 fit が約6秒(準備の計測)で8GB 機では回らない |
| min_samples_leaf | 5〜200(log) | 既定 20 |
| l2_regularization | 1e-4〜10(log) | 既定 0 は log 空間に置けないので下限で代用 |
| early_stopping / random_state | False / 0 | モデル側の乱数を0にして、揺れを分割だけにする |
📏 評価の仕組み — 最良を選ぶと甘くなる
指標は RMSLE(何倍外したか)。今日評価するのは「探索の最良値をどれだけ信じてよいか」。下の図は、実力が全員同じ N 個の設定から最良を選んだとき、運だけでどれだけ良く見えるか(標準正規乱数の最大値の期待値)。N=60 では標準偏差の2.3倍。
❓ 問題
タスクA(予想)— コードを書く前に紙に書く
- 60回探した最良設定は既定値より本当に良いか。良いならどのくらい(Dur_x_HR の −0.00051 と比べて)
- 探索用CV で見た改善は、真の改善の何倍に見えるか。N=10 と N=60 でどう変わるか
- 「探索回数を増やせば、選ばれる設定の本当の良さは単調に良くなる」は正しいか
- 探索用CV の上位と test の上位はどのくらい一致するか(60試行の順位相関)
タスクB(コーディング)
- Day164 の
make_original・synth_fromを再利用し、9列で学習するrun(params, split_seed)を書く。early_stopping=Falseを明示 - Optuna の目的関数は分割seed 0 の CV だけを返す。探索空間は5つ。既定値を
enqueue_trialで trial 0 に入れる - N = 1, 5, 10, 20, 40, 60 のベストを取り出し、既定値との差を (a) 探索用CV (b) 新分割R2 (c) test で出す
- 表1(N ごとの差と SE)、表2(全60試行の順位相関)、表3(−2SE ルールの採否と真の効果)、表4(楽観)
- assert 3つ: (a) N=60 では新分割R2 の方が探索用CV より真の効果に近い (b) ワールド1 で N=60 の楽観は N=10 より大きい (c) 新分割R2 の採否は6件すべて真の効果と一致
タスクC(分析・決定)
表1〜4から S5E5 用の探索手順を決め、CV 設計書に「決定7: ハイパーパラメータの探索と採否」を書き足す。「採った案/採らなかった案と理由/前提が変わったら」を付ける。
💡 ヒント
study.enqueue_trial(dict(...)) で既定値を最初に試させると、N=1 のベスト = 既定値になり「探索しない」との比較がそのまま取れる。探索中に test 予測も保存しておくと、後で全試行の順位相関が出せる(探索には使わない)。測り直しでは既定値も同じ分割seed 1〜3 で回して対の差にする。study = optuna.create_study(direction="minimize", sampler=optuna.samplers.TPESampler(seed=W)) study.enqueue_trial(DEFAULT) # trial 0 = 既定値 study.optimize(lambda t: run(suggest(t), split_seed=0)[1], n_trials=60) best_at = {n: trials.cv_search.iloc[:n].idxmin() for n in [1, 5, 10, 20, 40, 60]} fresh = {i: [run(params(i), s) for s in (1, 2, 3)] for i in set(best_at.values())}
✅ 模範解答
タスクA(予想の模範と実測の照合)
| # | 予想の模範 | 実測 |
|---|---|---|
| 1 | 本当に良くなるが、特徴量1本と同じくらいの小ささ | 当たり。N=60 の真の改善は W0 −0.00022(3.8SE)、W1 −0.00062(6.8SE)。Dur_x_HR と同じ桁 |
| 2 | 1.2〜2倍に見え、N が増えるほど倍率が上がる | W1 は当たり: N=10 で2.0倍、N=60 で1.7倍。楽観の絶対値は 1.20 → 4.51(×10⁻⁴)。W0 は N=40 で逆に悲観、N=60 で1.9倍 |
| 3 | 正しい(探すほど良くなる) | 外れ。W0 では N=40 のベスト(真 −0.00041)より N=60 のベスト(真 −0.00022)の方が本当は悪い。探索用CV では N=60 が 0.00009 良く見えていた |
| 4 | 上位はほぼ一致する | 外れ。順位相関は W0 0.38・W1 0.71。探索用CV で1位の設定は test で16位・20位 |
タスクB(完全なコード)
day165.py(1ワールドを回す。OMP_NUM_THREADS=2 python day165.py 0)
"""Day165: Playground S5E5 縮小シミュレータで「Optuna の探索回数を増やすほど CV が楽観する(探索の過学習)」を測る。 探索は分割seed 0 の5分割(探索用CV)だけを見て行う。選ばれた設定を (a) 探索に使っていない分割seed 1〜3 の R2(Day164 の採否ルール)と (b) test 6万行(真の効果) で測り直し、既定値との差が探索用CVでどれだけ大きく見えていたかを数える。 使い方: OMP_NUM_THREADS=2 python day165.py <world> """ import sys, time, json import numpy as np, pandas as pd, optuna from sklearn.ensemble import HistGradientBoostingRegressor from sklearn.model_selection import KFold T0 = time.time() W = int(sys.argv[1]) N_ORIG, N_TRAIN, N_TEST, N_TRIALS = 15000, 30000, 60000, 60 CHECK = [1, 5, 10, 20, 40, 60] def keytel_rate(sex, hr, w, age): m = (-55.0969 + 0.6309 * hr + 0.1988 * w + 0.2017 * age) / 4.184 f = (-20.4022 + 0.4472 * hr - 0.1263 * w + 0.074 * age) / 4.184 return np.where(sex == 1, m, f) def make_original(n, rng): # Day163/164 と同じ sex = rng.integers(0, 2, n); age = rng.integers(20, 80, n) h = np.where(sex == 1, rng.normal(175, 7, n), rng.normal(162, 6.5, n)).round() w = (rng.normal(24.5, 2.5, n) * (h / 100) ** 2).round() dur = rng.integers(1, 31, n).astype(float) hr = np.clip(80 + 1.2 * dur + rng.normal(0, 5, n), 67, 128).round() temp = (37 + 0.35 * dur ** 0.5 + rng.normal(0, 0.2, n)).round(1) rate = np.clip(keytel_rate(sex, hr, w, age), 0.3, None) cal = np.clip((rate * dur * np.exp(rng.normal(0, 0.05, n))).round(), 1, None) return pd.DataFrame(dict(Sex=sex, Age=age, Height=h, Weight=w, Duration=dur, Heart_Rate=hr, Body_Temp=temp, Calories=cal)) def synth_from(orig, n, rng): # Day163/164 と同じ s = orig.sample(n, replace=True, random_state=int(rng.integers(1 << 30))).reset_index(drop=True) s["Age"] = np.clip((s.Age + rng.normal(0, 1.5, n)).round(), 20, 79) s["Height"] = (s.Height + rng.normal(0, 2, n)).round() s["Weight"] = (s.Weight + rng.normal(0, 2, n)).round() s["Duration"] = np.clip((s.Duration + rng.normal(0, 1.0, n)).round(), 1, 30) s["Heart_Rate"] = (s.Heart_Rate + rng.normal(0, 2, n)).round() s["Body_Temp"] = (s.Body_Temp + rng.normal(0, 0.1, n)).round(1) s["Calories"] = np.clip((s.Calories * np.exp(rng.normal(0, 0.03, n))).round(), 1, None) return s def add_feats(d): # 決定6(Day164)で採用した2列だけ d = d.copy() d["Dur_x_HR"] = d.Duration * d.Heart_Rate d["Keytel_kcal"] = np.clip(keytel_rate(d.Sex.values, d.Heart_Rate.values, d.Weight.values, d.Age.values), 0.3, None) * d.Duration return d COLS = ["Sex", "Age", "Height", "Weight", "Duration", "Heart_Rate", "Body_Temp", "Dur_x_HR", "Keytel_kcal"] DEFAULT = dict(learning_rate=0.1, max_iter=300, max_leaf_nodes=31, min_samples_leaf=20, l2_regularization=0.0) def sle(y, p): return (np.log1p(p) - np.log1p(y)) ** 2 def run(params, split_seed): """CV 設計書どおり。early_stopping=False にして、モデル側の乱数を無くす(同じ設定・同じ分割なら同じ予測)""" oof, tp, fs = np.zeros(len(train)), np.zeros(len(test)), [] for tr, va in KFold(5, shuffle=True, random_state=split_seed).split(train): tr_df = pd.concat([train.iloc[tr], orig], ignore_index=True) m = HistGradientBoostingRegressor(early_stopping=False, random_state=0, **params) m.fit(tr_df[COLS], np.log1p(tr_df.Calories)) pv = np.clip(np.expm1(m.predict(train.iloc[va][COLS])), 1, None) tp += np.clip(np.expm1(m.predict(test[COLS])), 1, None) / 5 oof[va] = pv fs.append(np.sqrt(sle(ytr[va], pv).mean())) return np.array(fs), np.sqrt(sle(ytr, oof).mean()), tp rng = np.random.default_rng(2000 + W) # Day164 のワールド W と同じデータ orig = make_original(N_ORIG, rng) pool = synth_from(orig, N_TRAIN + N_TEST, rng) orig, pool = add_feats(orig), add_feats(pool) train, test = pool.iloc[:N_TRAIN].reset_index(drop=True), pool.iloc[N_TRAIN:].reset_index(drop=True) ytr, yt = train.Calories.values, test.Calories.values trials, preds = [], [] def objective(t): p = dict(learning_rate=t.suggest_float("learning_rate", 0.02, 0.3, log=True), max_iter=t.suggest_int("max_iter", 100, 600, step=50), max_leaf_nodes=t.suggest_int("max_leaf_nodes", 7, 63, log=True), min_samples_leaf=t.suggest_int("min_samples_leaf", 5, 200, log=True), l2_regularization=t.suggest_float("l2_regularization", 1e-4, 10, log=True)) fs, cv, tp = run(p, 0) # 探索が見るのは分割seed 0 の CV だけ trials.append(dict(trial=t.number, cv_search=cv, test=np.sqrt(sle(yt, tp).mean()), **p, folds=json.dumps(list(fs)))) preds.append(tp) return cv optuna.logging.set_verbosity(optuna.logging.WARNING) study = optuna.create_study(direction="minimize", sampler=optuna.samplers.TPESampler(seed=W)) d0 = dict(DEFAULT); d0["l2_regularization"] = 1e-4 # 探索空間の下限(0 は log 空間に置けない) study.enqueue_trial(d0) # trial 0 = 既定値(探索0回の状態) study.optimize(objective, n_trials=N_TRIALS) tr_df = pd.DataFrame(trials) print(f"world {W} search done {time.time()-T0:.0f}s", flush=True) # 探索回数 N ごとの「その時点のベスト」 best_at = {n: int(tr_df.cv_search.iloc[:n].idxmin()) for n in CHECK} fresh = {} for i in sorted(set(best_at.values())): p = {k: tr_df.loc[i, k] for k in DEFAULT} p["max_iter"], p["max_leaf_nodes"], p["min_samples_leaf"] = int(p["max_iter"]), int(p["max_leaf_nodes"]), int(p["min_samples_leaf"]) fresh[i] = [run(p, s) for s in (1, 2, 3)] # 探索に使っていない分割 seed print(f"world {W} re-eval done {time.time()-T0:.0f}s", flush=True) rows = [] f0 = fresh[0] for n in CHECK: i = best_at[n] fs0 = np.array(json.loads(tr_df.folds[0])); fsi = np.array(json.loads(tr_df.folds[i])) dS = tr_df.cv_search[i] - tr_df.cv_search[0] seS = (fsi - fs0).std(ddof=1) / np.sqrt(5) if i else 0.0 d15 = np.concatenate([fresh[i][r][0] - f0[r][0] for r in range(3)]) dF, seF = d15.mean(), (d15.std(ddof=1) / np.sqrt(15) if i else 0.0) pi = np.mean([fresh[i][r][2] for r in range(3)], axis=0) # 真の効果: 3繰返し×5モデル平均(Day164 と同じ) pb = np.mean([f0[r][2] for r in range(3)], axis=0) ei, eb = sle(yt, pi), sle(yt, pb) dT = np.sqrt(ei.mean()) - np.sqrt(eb.mean()) seT = (ei - eb).std(ddof=1) / np.sqrt(len(yt)) / (2 * np.sqrt(eb.mean())) if i else 0.0 rows.append(dict(world=W, N=n, best_trial=i, dS=dS, seS=seS, dF=dF, seF=seF, dT=dT, seT=seT, **{k: tr_df.loc[i, k] for k in DEFAULT})) out = pd.DataFrame(rows) tr_df.to_csv(f"trials_w{W}.csv", index=False) out.to_csv(f"check_w{W}.csv", index=False) pd.set_option("display.width", 250) print(out.round(5).to_string()) print(f"world {W} wall {time.time()-T0:.0f}s")
analyze.py(2ワールドの CSV を集計して表1〜4と assert)
import glob, numpy as np, pandas as pd pd.set_option("display.width", 250) ck = pd.concat([pd.read_csv(f) for f in sorted(glob.glob("check_w*.csv"))]) tr = {int(f[-5]): pd.read_csv(f) for f in sorted(glob.glob("trials_w*.csv"))} ck["opt_S_T"] = ck.dS - ck.dT # 探索用CVが見せた改善 − 真の改善(負 = 楽観の逆) ck["opt_S_F"] = ck.dS - ck.dF print("=== 表1 探索回数Nごとのベスト設定: 既定値との差(×1e-4)===") t = ck.copy() for c in ["dS", "seS", "dF", "seF", "dT", "seT", "opt_S_T", "opt_S_F"]: t[c] = (t[c] * 1e4).round(2) print(t[["world", "N", "best_trial", "dS", "seS", "dF", "seF", "dT", "seT", "opt_S_T", "opt_S_F", "learning_rate", "max_iter", "max_leaf_nodes", "min_samples_leaf", "l2_regularization"]].to_string(index=False)) print("\n=== 表2 全60試行: 探索用CV と test の関係 ===") for w, d in tr.items(): rho = d.cv_search.rank().corr(d.test.rank()) top = d.nsmallest(10, "cv_search") rho10 = top.cv_search.rank().corr(top.test.rank()) best_cv, best_test = d.cv_search.idxmin(), d.test.idxmin() print(f"world {w}: Spearman 全60 {rho:.3f} | 上位10 {rho10:.3f} | CV最良 trial {best_cv} (test順位 {int(d.test.rank()[best_cv])}/60) | " f"test最良 trial {best_test} (CV順位 {int(d.cv_search.rank()[best_test])}/60) | " f"CV幅 {1e4*(d.cv_search.max()-d.cv_search.min()):.1f}e-4 test幅 {1e4*(d.test.max()-d.test.min()):.1f}e-4 | " f"CV最良から+0.0002以内 {(d.cv_search <= d.cv_search.min()+2e-4).sum()}試行") print("\n=== 表3 採否判定(既定値以外が選ばれた点のみ): −2SE ルール 対 真の効果 ===") nd = ck[ck.best_trial != 0].copy() nd["truth"] = nd.dT < -2 * nd.seT nd["adopt_search"] = nd.dS < -2 * nd.seS nd["adopt_fresh"] = nd.dF < -2 * nd.seF nd["z_S"], nd["z_F"], nd["z_T"] = nd.dS / nd.seS, nd.dF / nd.seF, nd.dT / nd.seT print(nd[["world", "N", "z_S", "z_F", "z_T", "adopt_search", "adopt_fresh", "truth"]].round(2).to_string(index=False)) for k in ["adopt_search", "adopt_fresh"]: print(f"{k}: 一致 {(nd[k] == nd.truth).sum()}/{len(nd)} | 見逃し {(~nd[k] & nd.truth).sum()} | 偽採用 {(nd[k] & ~nd.truth).sum()}") print("\n=== 表4 楽観(真の改善 − 見えた改善、×1e-4。正 = 見かけの方が大きい)===") nd["楽観_探索CV"] = ((nd.dT - nd.dS) * 1e4).round(2) nd["楽観_新分割R2"] = ((nd.dT - nd.dF) * 1e4).round(2) print(nd[["world", "N", "楽観_探索CV", "楽観_新分割R2"]].to_string(index=False)) a = nd[nd.N == 60] assert ((a.dT - a.dF).abs() < (a.dT - a.dS).abs()).all(), "N=60 では新しい分割の R2 の方が真の効果に近い" w1 = nd[nd.world == 1].set_index("N") assert w1.loc[60, "dT"] - w1.loc[60, "dS"] > w1.loc[10, "dT"] - w1.loc[10, "dS"], "探索回数が増えると楽観が増える(world 1)" assert (nd.adopt_fresh == nd.truth).all(), "新しい分割の R2 で判定すると6件すべて真の効果と一致" print("all asserts passed")
実行結果(実測)
=== 表1 探索回数Nごとのベスト設定: 既定値との差(×1e-4)===
world N best_trial dS seS dF seF dT seT opt_S_T opt_S_F learning_rate max_iter max_leaf_nodes min_samples_leaf l2_regularization
0 1 0 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.100000 300 31 20 0.000100
0 5 0 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.100000 300 31 20 0.000100
0 10 0 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.100000 300 31 20 0.000100
0 20 0 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.100000 300 31 20 0.000100
0 40 34 -3.17 1.68 -3.32 1.05 -4.09 0.57 0.92 0.15 0.055856 600 39 14 0.003905
0 60 52 -4.07 2.07 -3.24 1.46 -2.17 0.57 -1.90 -0.83 0.047259 600 39 12 0.002038
1 1 0 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.100000 300 31 20 0.000100
1 5 0 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.100000 300 31 20 0.000100
1 10 8 -2.41 1.35 -0.51 1.11 -1.21 0.83 -1.20 -1.90 0.047001 450 44 5 0.563276
1 20 13 -6.07 1.20 -5.02 1.73 -2.23 0.53 -3.85 -1.05 0.060682 550 39 18 4.313545
1 40 20 -7.64 1.44 -5.16 1.70 -5.42 0.90 -2.22 -2.48 0.059452 550 55 7 7.501334
1 60 53 -10.69 2.64 -6.09 2.06 -6.18 0.90 -4.51 -4.60 0.054147 500 63 7 4.391959
=== 表2 全60試行: 探索用CV と test の関係 ===
world 0: Spearman 全60 0.375 | 上位10 0.552 | CV最良 trial 52 (test順位 16/60) | test最良 trial 47 (CV順位 34/60) | CV幅 222.7e-4 test幅 239.2e-4 | CV最良から+0.0002以内 10試行
world 1: Spearman 全60 0.709 | 上位10 0.382 | CV最良 trial 53 (test順位 20/60) | test最良 trial 48 (CV順位 4/60) | CV幅 45.3e-4 test幅 44.4e-4 | CV最良から+0.0002以内 4試行
=== 表3 採否判定(既定値以外が選ばれた点のみ): −2SE ルール 対 真の効果 ===
world N z_S z_F z_T adopt_search adopt_fresh truth
0 40 -1.89 -3.17 -7.18 False True True
0 60 -1.96 -2.22 -3.83 False True True
1 10 -1.78 -0.46 -1.46 False False False
1 20 -5.06 -2.90 -4.19 True True True
1 40 -5.30 -3.03 -6.05 True True True
1 60 -4.05 -2.96 -6.84 True True True
adopt_search: 一致 4/6 | 見逃し 2 | 偽採用 0
adopt_fresh: 一致 6/6 | 見逃し 0 | 偽採用 0
=== 表4 楽観(真の改善 − 見えた改善、×1e-4。正 = 見かけの方が大きい)===
world N 楽観_探索CV 楽観_新分割R2
0 40 -0.92 -0.77
0 60 1.90 1.07
1 10 1.20 -0.70
1 20 3.85 2.80
1 40 2.22 -0.26
1 60 4.51 -0.09
all asserts passed
dS = 探索用CV、dF = 新分割R2、dT = test で見た「ベスト設定 − 既定値」(×10⁻⁴、負なら改善)。z_* はそれぞれの差 ÷ SE。
📊 結果の可視化
図1 探索回数 N ごとのベスト設定: 既定値からの改善を3つの物差しで
W0 の N=5〜20、W1 の N=5 はベストが既定値のまま(差0)なので省略。青(探索用CV)だけが N とともに伸び続け、紫(新分割R2)と黄(test)はそれより短い。
図2 楽観(真の改善 − 見えた改善)
図3 −2SE ルールの採否(既定値以外が選ばれた6件)
| ワールド・N | z 探索用CV | z 新分割R2 | z 真 | 探索用CVで採否 | 新分割R2で採否 | 真に効く |
|---|---|---|---|---|---|---|
| W0 N=40 | −1.89 | −3.17 | −7.18 | 不採用(見逃し) | 採用 | ○ |
| W0 N=60 | −1.96 | −2.22 | −3.83 | 不採用(見逃し) | 採用 | ○ |
| W1 N=10 | −1.78 | −0.46 | −1.46 | 不採用 | 不採用 | × |
| W1 N=20 | −5.06 | −2.90 | −4.19 | 採用 | 採用 | ○ |
| W1 N=40 | −5.30 | −3.03 | −6.05 | 採用 | 採用 | ○ |
| W1 N=60 | −4.05 | −2.96 | −6.84 | 採用 | 採用 | ○ |
🧭 決定 — 探索手順と CV 設計書「決定7」
(1) 手順: 探索は1回の5分割、測り直しは新しい分割の R2
| 案 | 判定 | 理由(今日の数字) |
|---|---|---|
| 探索用CV の最良値をそのまま改善幅として報告 | 採らない | W1 N=60 で見かけ −0.00107、真 −0.00062(1.7倍)。W0 では N=60 の方が良く見えたが本当は悪かった |
| 探索用CV の fold 差(5個)で採否 | 採らない | 6件中見逃し2(W0 N=40・60、z −1.89・−1.96) |
| 新分割R2(seed 1〜3、15個の fold 差)で採否 | 採る | 6件すべて一致(採用5・不採用1)。楽観は W1 N=60 で 4.51 → −0.09 |
| 探索自体を R2 で回す | 採らない | fit 3倍で探索だけ約30分×ワールド。「選ぶ」は1回の5分割で足り、「測る」だけを別分割にすれば楽観は消えた |
| 入れ子 CV | 今回は採らない | 評価として最も正しいが fit 数5倍。8GB 機で Playground 1本に払う費用に見合わない |
(2) CV 設計書に書き足す「決定7」
| # | 決定 | 採った案 | 採らなかった案と理由 | 前提が変わったら |
|---|---|---|---|---|
| 7 | ハイパーパラメータの探索と採否 | 既定値を trial 0 に入れて Optuna TPE で60回、分割seed 0 の5分割で探す。最良1設定だけを分割seed 1〜3 の R2 で既定値と比べ、−2SE を下回れば採用。報告する改善幅も R2 の値。early_stopping=False・random_state=0 固定 | 探索用CV の最良値をそのまま使う(1.7〜1.9倍の過大評価)。上位設定を複数比べる(比べるたびに楽観が乗る)。入れ子 CV(fit 5倍) | 探索を数百回・空間を広げる → 楽観も増えるので測り直しの seed を増やすか入れ子 CV に。公開 LB を探索に使い始めたら公開 LB も「探索用CV」扱い。LightGBM など bagging のあるモデルではモデル seed を分割 seed と分けて測り直す |
🪜 Step-by-Step
early_stopping=False・random_state=0 にすると同じ設定・同じ分割なら予測は毎回同じ。揺れは分割だけになる。代わりに木の数を探索に入れた。🧮 数学・統計の補足(文系向け)
- 最良を選ぶと甘くなる理由: 60人に同じテストを受けさせて最高点の人を選ぶと、その人は実力に加えて「当日の運」も良かった可能性が高い。もう一度受けると少し下がる(平均への回帰)。「探索用CV で1位 → test で16位・20位」はこれ
- 正規乱数の最大値: 標準正規乱数 N 個の最大値の期待値は N=10 で約1.54、N=60 で約2.32。実力が全員同じでも、60回探すと最良値は標準偏差の2.3倍ぶん良く見える
- 新しい分割で楽観が消える理由: 選んだときの運は分割seed 0 の切り方に乗っていた。別の切り方には持ち越されない。ただし行(3万行)は同じなので、「train そのものに合わせた」楽観は残りうる。今日それが小さかった、というだけ
- 順位相関(Spearman): 2つの並び順の一致度(−1〜1)。W0 の 0.38 は、探索用CV でかなり上位でも test では中位の設定が多いという意味
🏆 Kaggleでの実践的な使い方
- 既定値を trial 0 に入れる。 W0 では最初の20回で既定値を1つも上回らなかった。入れていないと「探索で良くなった」のか「悪い設定から戻っただけ」なのか分からない
study.best_valueを CV スコアとして記録しない。 記録するのは別分割で測り直した値。今日の真の改善は見かけの 0.53〜1.29 倍とばらつき、N=60 では 0.53・0.58 倍- チューニングの取り分は小さい。 既定値からの改善は −0.0002〜−0.0006。順番は「目的変数の変換(Day163 log1p −0.0049)≫ 特徴量 ≒ チューニング」
- 公開 LB での探索も同じ。 提出を繰り返して公開 LB の最良を選ぶのは、公開 LB(Day163 の実物換算で揺れ約0.0009)を探索用CV にしているのと同じ。今日の改善幅はその揺れより小さい
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| Optuna のベスト値がそのモデルの CV | best_value をそのまま記録 | 選ぶのに使った値なので甘い。W1 で改善を1.7倍に見せた |
| 探索回数を増やすほど良い設定が見つかる | 最良値が単調に下がるのを見る | 下がるのは探索用CV だけ。W0 では N=40→60 で本当は悪化 |
| 既定値は弱いので探索すれば勝てる | 探索結果しか見ない | 最初の20回で既定値を上回れなかったワールドがある |
| 探索用CV の上位は test でも上位 | CV を信じる | 順位相関 0.38・0.71。CV の1位は test で16位・20位 |
| early stopping は常に入れておけば安全 | 過学習防止の定石 | HistGB では判定用10%を乱数で選ぶので、設定比較にモデルの揺れが混ざる。比べる回は切って木の数を探す |
🚧 扱わなかった範囲と理由
- ワールド数: 2ワールド・判定6件。1ワールド約11〜13分(並行2プロセス、8GB 機)。「N=60 で何倍」と一般化できる数ではない
- SE の個数と分割の新しさの混ざり: 5個 vs 15個。分けるには新しい分割の1回5分割(seed 1 だけ)でも判定して R2 と比べる(次回候補)
- 入れ子 CV・Nadeau & Bengio 補正: fit 5倍のため未実施。補正は Day164 から持ち越し
- 探索法・探索空間の比較: TPE・空間1つだけ
- LightGBM・XGBoost: HistGB だけ。bagging・列抽出のあるモデルはモデル側の乱数が加わる
- CPU スレッド数: 準備の計測(8コア・負荷平均11、各1回)で300本の fit が 8スレッド 4.1秒・4スレッド 0.87秒・1スレッド 1.3秒。本番は各プロセス2スレッド。一般化しない
- 実データ(S5E5): Kaggle のログインとルール同意が必要なため未使用
- Day162 の選択バイアス仮説: 疑似ラベルを使う回に再検証(持ち越し)
🚀 次のステップ
- 発展: 探索用CV の上位5設定を新分割R2 で比べて1つ選ぶと、選ぶ段階が1つ増えて楽観が戻るかを測る。探索空間を2倍・試行を200回にしたときの楽観を比べる
- 次回予告: Day166 — Playground Series 実戦投入④。HistGB に性質の違うモデル(Ridge + log 特徴量、k近傍)を混ぜる。log_Dur が線形モデルでは効くかを測り、OOF でブレンド重みを決めたときの楽観を今日と同じ方法で数える
Phase 6 の学習マップ
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: