Day 165 — Playground Series 実戦投入③ — Optuna でハイパーパラメータを60回探す。探索に使った CV は改善を最大1.7倍に見せ、探索回数を増やすと本当は悪い設定を選んだワールドもあった。探索に使っていない分割で測り直すと、採否6件がすべて真の効果と一致した

2026-09-28 金 / Phase 6(月替わりテーマ2: Playground Series 実戦投入) 分析→戦略 Optuna TPE 60試行 × 2ワールド、N=1〜60 のベストを測り直し

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

探索の過学習(selection bias / winner's curse)。 ハイパーパラメータ探索は「同じ CV の点数を何十回も見て、一番良かったものを選ぶ」作業。CV の点数には「設定の本当の良さ」と「その分割でたまたま当たった分」が混ざっていて、60個から最小値を選ぶとたまたま当たった分が大きい設定ほど選ばれやすい。Cawley & Talbot (2010, JMLR 11:2079–2107) が扱った問題で、Kaggle の「公開 LB に合わせて提出を繰り返すと private で落ちる」と同じ仕組み。

🔁
Day164 との違い: Day164 は候補7つを1つずつ判定した(比較7回)。今日は60設定から最良を1つ選ぶ(比較60回)。選ぶ回数が増えるほど、最良値に乗る「たまたま」は大きくなる。

今日の問い: (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⁻⁴)。正なら見かけの方が大きい
TPEOptuna の既定の探索法。最初の10回はランダム、その後は良かった設定の近くを多めに試す
🧪
シミュレータは Day163/164 と同じ(Keytel et al. 2005, J Sports Sci 23(3):289–297 の式 × 運動時間)。ワールド0・1 は Day164 のワールド0・1 と同じ乱数のデータ。数値は仮定の上の実測。

🗂️ データ・探索空間

項目値理由
特徴量(9列)基準7列 + Dur_x_HR + Keytel_kcalDay164 決定6
orig / train / test15,000 / 30,000 / 60,000 行 × 2ワールド元データは学習foldにだけ(決定3)。test は答え合わせ専用
learning_rate0.02〜0.3(log)既定 0.1
max_iter100〜600(50刻み)early stopping を切ったので木の数を探す
max_leaf_nodes7〜63(log)既定 31。127 まで広げると1 fit が約6秒(準備の計測)で8GB 機では回らない
min_samples_leaf5〜200(log)既定 20
l2_regularization1e-4〜10(log)既定 0 は log 空間に置けないので下限で代用
early_stopping / random_stateFalse / 0モデル側の乱数を0にして、揺れを分割だけにする

📏 評価の仕組み — 最良を選ぶと甘くなる

指標は RMSLE(何倍外したか)。今日評価するのは「探索の最良値をどれだけ信じてよいか」。下の図は、実力が全員同じ N 個の設定から最良を選んだとき、運だけでどれだけ良く見えるか(標準正規乱数の最大値の期待値)。N=60 では標準偏差の2.3倍。

実力が全員同じでも、N 個から最良を選ぶと「運」でこれだけ良く見える(標準偏差の何倍か) 0.00N=11.16N=51.54N=101.87N=202.25N=402.32N=60
⚠️
対策の考え方: 選ぶデータと測るデータを分ける。 探索は分割seed 0 だけで行い、選んだ設定を分割seed 1〜3 で測り直す。選んだときの「運」は分割seed 0 の切り方に乗っているので、別の切り方には持ち越されない。

❓ 問題

タスクA(予想)— コードを書く前に紙に書く

  1. 60回探した最良設定は既定値より本当に良いか。良いならどのくらい(Dur_x_HR の −0.00051 と比べて)
  2. 探索用CV で見た改善は、真の改善の何倍に見えるか。N=10 と N=60 でどう変わるか
  3. 「探索回数を増やせば、選ばれる設定の本当の良さは単調に良くなる」は正しいか
  4. 探索用CV の上位と test の上位はどのくらい一致するか(60試行の順位相関)

タスクB(コーディング)

  1. Day164 の make_original・synth_from を再利用し、9列で学習する run(params, split_seed) を書く。early_stopping=False を明示
  2. Optuna の目的関数は分割seed 0 の CV だけを返す。探索空間は5つ。既定値を enqueue_trial で trial 0 に入れる
  3. N = 1, 5, 10, 20, 40, 60 のベストを取り出し、既定値との差を (a) 探索用CV (b) 新分割R2 (c) test で出す
  4. 表1(N ごとの差と SE)、表2(全60試行の順位相関)、表3(−2SE ルールの採否と真の効果)、表4(楽観)
  5. assert 3つ: (a) N=60 では新分割R2 の方が探索用CV より真の効果に近い (b) ワールド1 で N=60 の楽観は N=10 より大きい (c) 新分割R2 の採否は6件すべて真の効果と一致

タスクC(分析・決定)

表1〜4から S5E5 用の探索手順を決め、CV 設計書に「決定7: ハイパーパラメータの探索と採否」を書き足す。「採った案/採らなかった案と理由/前提が変わったら」を付ける。

💡 ヒント

ヒント1方向性
「最良を選ぶ」のに使ったデータで「最良の良さ」を測ると、必ず甘くなる。選ぶデータと測るデータを分ける。探索のたびに test を見てはいけない(実コンペでは見られない)。
ヒント2アプローチ
study.enqueue_trial(dict(...)) で既定値を最初に試させると、N=1 のベスト = 既定値になり「探索しない」との比較がそのまま取れる。探索中に test 予測も保存しておくと、後で全試行の順位相関が出せる(探索には使わない)。測り直しでは既定値も同じ分割seed 1〜3 で回して対の差にする。
ヒント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 と同じ桁
21.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位
🔎
もう1つの観察: W0 では最初の20試行(ランダム10回 + TPE 10回)に既定値を探索用CV で上回った設定が1つも無かった。60試行全体で既定値を上回ったのは W0 29・W1 43。scikit-learn の既定値は、広い空間からランダムに引いた設定よりかなり強い。

タスク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")

実行結果(実測)

🖥️
macOS (M1, 8GB)・Python 3.13.12・scikit-learn 1.9.1・optuna 5.0.0・numpy 2.5.3・pandas 3.0.6(新規 uv venv)。ワールド0・1 を別プロセスで並行(各2スレッド)、壁時計 635秒・785秒。fit 数 W0 345回・W1 375回。
=== 表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つの物差しで

既定値からの改善(×10⁻⁴、下に長いほど改善)探索用CV新分割R2test(真) 0-2-4-6-8-10 ワールド0N=40N=60ワールド1N=10N=20N=40N=60 W1 N=60: 探索用CV −10.7真(test)−6.2 → 1.7倍に見えた

W0 の N=5〜20、W1 の N=5 はベストが既定値のまま(差0)なので省略。青(探索用CV)だけが N とともに伸び続け、紫(新分割R2)と黄(test)はそれより短い。

図2 楽観(真の改善 − 見えた改善)

楽観 = 真の改善 − 見えた改善(×10⁻⁴、右ほど「見かけが甘い」) 探索用CV 新分割R2 -0.92-0.77W0 N=40+1.90+1.07W0 N=60+1.20-0.70W1 N=10+3.85+2.80W1 N=20+2.22-0.26W1 N=40+4.51-0.09W1 N=60

図3 −2SE ルールの採否(既定値以外が選ばれた6件)

ワールド・Nz 探索用CVz 新分割R2z 真探索用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 の fold 差は5個、新分割R2 は15個から SE を取っている。採否の一致(図3)は「分割が新しい」効果と「個数が3倍」効果の両方で、今日は分けていない。楽観が消えたこと(図2)は分割を変えた効果。

🧭 決定 — 探索手順と 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

1
モデル側の乱数を消す。 HistGB は1万行を超えると early stopping が自動で有効になり、判定用10%を乱数で選ぶ(Day164 の持ち越し)。early_stopping=False・random_state=0 にすると同じ設定・同じ分割なら予測は毎回同じ。揺れは分割だけになる。代わりに木の数を探索に入れた。
2
既定値を trial 0 に入れる。 N=1 のベスト = 既定値になり、「探索しない」との差がそのまま取れる。
3
探索は探索用CV だけを見る。 test 予測は保存するが Optuna には渡さない。後から表2(順位相関)が出せる。
4
N ごとのベストを測り直す。 ベストは W0 で3設定(既定値・trial 34・52)、W1 で5設定。既定値と一緒に分割seed 1〜3 で回す(1設定15 fit)。
5
楽観は N とともに増え、新しい分割で消える。 W1 の探索用CV の楽観は N=10 1.20・20 3.85・40 2.22・60 4.51。新分割R2 では −0.70・2.80・−0.26・−0.09。W0 は N=40→60 で探索用CV が 0.00009 良化、真は 0.00019 悪化。
6
採否を判定する。 探索用CV の fold 差ルールは見逃し2、新分割R2 は全件一致。W1 N=10 はどれも「採用しない」。
7
設計書に足す。 手順は「特徴量(決定6)→ 既定値を含めて探す → 最良1つを新しい分割で測り直して採否(決定7)」。

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

  • 最良を選ぶと甘くなる理由: 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 のベスト値がそのモデルの CVbest_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 の学習マップ

1-23
Phase6主要テーマ・総復習(Day126-149、完了)
月替わり1
NLP系コンペへの応用①〜⑬(Day150-162、完了)
月替わり2
Playground Series 実戦投入①〜③(Day163〜、〜2026-10-25)

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

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