📚 背景知識(読んでから問題へ)
今月やってきたこと。 S5E5(消費カロリー予測、RMSLE)を写した縮小シミュレータで、Kaggle のコンペを1本通すときの判断を12個決めてきた(決定1〜12)。どの決定にも「採らなかった案」と「前提が変わったら」が付いているが、証拠の強さはばらばらだ。5世界で1.6SE しかないもの、記憶だけで公式ルールを確かめていないもの、仮説のまま次へ進んだものが混ざっている。
設計書を1枚にする意味。 決定を日ごとのファイルに散らしておくと、(1) 決定どうしの矛盾に気づかない、(2) 弱い証拠の上に次の決定を積んでいることに気づかない。1枚に並べると両方が見える。今日はまずそれをやり、いちばん弱い所を1つ選んで測り直す。
今日測り直すもの: CV の偏り(Day170)。 Day170 で、CV(OOF)と真の点数のずれが候補ごとに違った(leaf63 は default より CV が +5.73 悪く見え、leaf15 は −4.51 良く見えた、×10⁻⁴)。そのせいで真の最良(16世界中14世界で leaf63)を CV はほとんど選べなかった。仮説は「OOF は1モデルの予測、提出は5つの fold モデルの平均。複雑な設定ほど平均で得をするのに、CV はその得を数えていない」。
平均の得はラベル無しで測れる(今日の鍵)。 二乗誤差では、次の式が行ごとに必ず成り立つ(アンサンブルの「あいまいさ分解」。Krogh & Vedelsby 1995, NIPS 7 として知られる。今日は原典を開いていない=記憶による)。
5モデルの平均の二乗誤差 = 1モデルずつの二乗誤差の平均 − 5モデルの予測のばらつき(分散)
右の「ばらつき」には正解ラベルが要らない。test の行(ラベル無し)で fold モデルどうしの予測のばらつきを測れば、平均でどれだけ得するかが分かる。 RMSLE は log1p 空間の RMSE なので、log1p で学習した予測にそのまま使える。
用語整理:
| 用語 | 意味 |
|---|---|
| C | CV の点数(OOF の RMSLE。各行を学習に使っていない1モデルが予測) |
| T1 | 真の点数(truth 6万行)を、fold モデル1本ずつで測って平均したもの |
| T5 | 真の点数を、5モデル平均(提出の形)で測ったもの |
| A_test | test 2万行で、5つの fold モデルの予測の分散を行ごとに取り、平均したもの(ラベル不要) |
| 補正CV | sqrt(C² − A_test)。CV を「5モデル平均で提出したときの点数」に直したもの |
| 損(regT) | 選んだ候補の T5 − 候補中の T5 の最良(×10⁻⁴、小さいほど良い。Day170 と同じ) |
| 証拠の印 | ◎=対の差が 3SE 超、2つ以上の条件で同じ向き / ○=測ったが 2〜3SE か条件1つ / △=2SE 未満・比較していない / ✕=記憶・仮説だけ |
縮小シミュレータは Day170 と同じ(train 3万・元15k・test 2万・truth 6万、乱数 3000+ワールド、16ワールド)。数値は仮定の上の実測。
データ・保存物の構造(スキーマ)
| 名前 | 形・型 | 意味・作り方 | 使う場所 |
|---|---|---|---|
| Sex … Body_Temp | int / float(7列) | 元データ 1.5万行から合成(Day163〜170 と同じ、乱数 3000+ワールド) | 全候補 |
| Dur_x_HR, Keytel_kcal | float | 決定6 で採用した2列 | HistGB(noDurHR・noKeytel・raw7 は抜く) |
| oof | (12, 30000) float64 | 分割seed 0 の5分割の OOF(1モデルの予測)→ C | CV・補正CV |
| evk | (12, 5, 80000) float32 | 平均しない fold 別予測。先頭2万行=test、残り6万行=truth | T1・T5・A_test |
| A_test | (候補,) | test 行で fold 間の予測の分散を取り平均(ラベル不要) | 補正CV = √(C² − A_test) |
| Calories(目的変数) | float | log1p 空間で学習、RMSLE で評価 | — |
評価指標と今日の恒等式
❓ 問題
タスクA(設計書)— コードを書く前に
- 決定1〜12 を1枚の表にし、各決定に証拠の印(◎○△✕)を付ける。印の根拠(何SE・何ワールド)も1行で書く
- 決定どうしで矛盾しているものを探す(ヒント: 公開LBの使い方)
- 印が最も弱く、かつ他の決定がその上に乗っているものを1つ選ぶ
タスクB(予想)
- CV の偏り(候補間で SD 3.3×10⁻⁴)のうち、何割が「1モデル対5モデル平均」(T1 − T5)で説明できるか
- ラベル無しの補正
sqrt(C² − A_test)で CV最良を選ぶと、損は Day170 の CV最良(2.80)・公開LB最良(2.11)と比べてどうなるか - 候補数 K を 3→13 に増やすと、公開LB最良の損はどう増えるか
タスクC(コーディング)
- Day170 と同じ16ワールドを作り直し、5つの fold モデルの予測を平均せずに保存する(
day171.py)。Day170 の5モデル平均と一致するかを確かめる - 候補ごとに C − T5 を (C − T1) + (T1 − T5) に分け、A_test で補正した CV で方針を比べ直す。K を 3/5/8/13 に変えた損も出す(
analyze171.py)
タスクD(決定)
設計書を更新する(決定12 の改訂、決定13 の追加、決定5 の扱い)。S5E5 実データで利用者が行う作業を手順として切り出す。
💡 ヒント
ヒント1(方向性)
- 決定5(Day163)と決定12(Day170)を並べて読む。公開LBを「壊れ確認だけ」にするのか「2本目を選ぶ」のに使うのか
- 偏りの原因を確かめるには、同じ候補の「1モデルの真の点数」が要る。Day170 は5モデル平均しか保存していなかった
ヒント2(アプローチ)
- 恒等式:
mean_k (f_k − y)² = (f̄ − y)² + mean_k (f_k − f̄)²。右の第2項が fold 間分散。行ごとに成り立つので、平均しても成り立つ - 作り直したデータが Day170 と一致するかを、候補ごとに最大差で見る。一致しない候補があれば、どちらかの作り方が違う
ヒント3(コード骨格)
evk = np.zeros((n_cand, 5, N_TEST + N_TRUTH), np.float32) # (候補, fold, 行) evk[j, k] = model.predict(evalset[cols]) # 平均せずに入れる A_te = evk[:, :, :N_TEST].var(axis=1).mean(1) # test 行の fold 間分散(ラベル無し) C_adj = np.sqrt(C_mse - A_te) # 提出の形に直した CV T1_mse = np.mean([sle(y, evk[j, k, N_TEST:]).mean() for k in range(5)]) # 1モデルの真の点数
✅ 模範解答
タスクA(設計書の模範)
S5E5 用 CV設計書(2026-10-04 版)。 「印」は上の用語整理の基準。「乗っている決定」は、その決定が崩れると見直しが要る決定。
| # | 決定 | 採った案 | 印 | 印の根拠 | 乗っている決定 |
|---|---|---|---|---|---|
| 1 | 分割 | ランダム KFold 5分割(shuffle・seed固定) | △ | 層別 KFold・GroupKFold と比べていない。敵対的 AUC 0.5004 だけ測った(Day163) | 全部 |
| 2 | 目的変数 | log1p で学習、expm1 で戻す | ◎ | private −0.0049、5seed で 5/5 同じ向き(Day163) | 13(log 空間の恒等式) |
| 3 | 元データ | 学習 fold にだけ足す | △ | private −0.00024±0.00015(1.6SE、Day163) | 6〜8 |
| 4 | 予測のクリップ | 下限1 | — | 論理(NaN 防止)。測る対象ではない | — |
| 5 | 公開LBの使い方 | 判断は CV、公開LBは壊れ確認だけ | ✕(撤回) | 決定12 と矛盾(下) | — |
| 6 | 特徴量の採否 | 1つずつ足し、R2 で −2SE | ◎ | 28件中 R2 の判定が真と一致26、偽採用1・見逃し1(Day164) | 7・8・11 |
| 7 | ハイパーパラメータ | 既定値を trial 0、Optuna 60回、新分割 R2 で採否 | ○ | 2ワールドで採否6/6が真と一致。ただし SE の個数差と分割の新しさは未分離(Day165) | 12 |
| 8 | 混ぜる | HistGB に knn を 0.10 固定 | ○ | 4ワールド中、真に採用に足りたのは2(Day166) | 12・13 |
| 9 | 再現性 | 起動時に OOF の sha256 を比べる。seed は対にする | ○ | max_features=1.0 で 6/6 完全一致(Day167)。ただし今日、sha256 では捕まらない作り間違いが見つかった(下) | 全部 |
| 10 | SE の使い分け | 採否は naive −2SE、報告は NB 補正 | ○ | 24ワールドで naive の被覆 0.82(Day168)。NB 原典は未読 | 6・7・11 |
| 11 | −2SE の読み方 | 「効く列か、効果±1.7以内の列」 | ○ | 108件中 11件採用(10.2%、Wilson [5.8,17.3]、Day169) | 6 |
| 12 | 最終2本 | CV最良+公開LB最良 | △ → 改訂 | CV最良の損は Day170 で 2.80。原因が仮説のまま、他の決定の結論(提出の選び方)がその上に乗る | — |
矛盾(タスクA-2): 決定5(Day163)は「公開LBは壊れ確認だけ」、決定12(Day170)は「2本目は公開LB最良」。Day170 の測定(公開20%で公開LB最良が単独で 2.11、CV最良 2.80)を根拠に12が5を上書きしたが、5を撤回した記録がどこにも無かった。今日、5を撤回と明記する。
選んだ弱点(タスクA-3): 決定12 の「CV の偏り」。印が△で、原因は仮説、しかも提出の選び方という最後の決定がその上に乗る。原因が分かれば、補正の方法まで決まる可能性がある(決定12 を ✕ から外せる)。
タスクB(予想の模範と、実測との照合)
| # | 予想の模範 | 実測 |
|---|---|---|
| 1 | 大半(7〜8割)が T1 − T5 で説明できる | おおむね当たり。 leaf63 の偏り +5.73 は T1−T5 で +5.48、leaf15 −4.51 は −4.01、it150 −3.84 は −4.03(default との差、×10⁻⁴)。候補間 SD は 3.31 → 補正後 2.25。ただし残り 2.2 のうち、特徴量を抜いた候補(noKeytel −2.35±1.11、raw7 −1.81±1.06)には平均の得では説明できないずれが残った |
| 2 | 補正CV最良は CV最良より良く、公開LB最良と同程度 | 予想より良い。 補正CV最良の損 0.92 は、CV最良 2.34 より −1.42±0.33(4.3SE)、公開20%の公開LB最良 2.10 より −1.18±0.33(3.6SE)。真の最良に当たった世界は 1/16 → 8/16 |
| 3 | K に比例して増える | 増えたが比例ではない。 K=3/5/8/13 で 0.92/1.27/1.67/2.15。一様ランダムに対する比は 0.35→0.45 とゆっくり悪化。補正CV最良は 0.70/0.93/1.04/0.92 でほぼ平ら |
途中で見つかったこと(予想外): 作り直したデータを Day170 と比べると、12本は最大差 3.8×10⁻⁷(float32 の丸め)で一致したのに、混合候補 lr05_it600+knn.1 だけ最大 0.087 ずれた。Day170 のコードは混合を2本続けて足すとき oof[-1](最後の行)を knn のつもりで使ったが、1本目を足した後の最後の行は default+knn.1 だった。Day170 の lr05_it600+knn.1 は、実際には 0.9×lr05 + 0.09×default + 0.01×knn だった(この式で作り直すと最大差 4.7×10⁻⁷ で一致、16ワールドとも)。
タスクC(完全なコード)
day171.py(Day170 の day170.py から3か所だけ変えた。変更は fold 別に保存することと、混合を作らないこと):
"""Day171: Day170 と同じワールド・同じ候補12本を作り直し、5つの fold モデルの予測を平均せずに1本ずつ保存する。 使い方: OMP_NUM_THREADS=1 python day171.py <world> -> raw171_w<world>.npz """ import sys, time import numpy as np, pandas as pd from sklearn.ensemble import HistGradientBoostingRegressor from sklearn.neighbors import KNeighborsRegressor from sklearn.preprocessing import StandardScaler from sklearn.model_selection import KFold T0 = time.time() W = int(sys.argv[1]) N_ORIG, N_TRAIN, N_TEST, N_TRUTH = 15000, 30000, 20000, 60000 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-170 と同じ 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-170 と同じ 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): 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 d["log_Dur"] = np.log(d.Duration) return d RAW = ["Sex", "Age", "Height", "Weight", "Duration", "Heart_Rate", "Body_Temp"] BASE = RAW + ["Dur_x_HR", "Keytel_kcal"] # 決定6 D = dict(learning_rate=0.1, max_iter=300, max_leaf_nodes=31, min_samples_leaf=20) # 決定7の既定値 CANDS = [("default", BASE, {}), ("leaf15", BASE, dict(max_leaf_nodes=15)), ("leaf63", BASE, dict(max_leaf_nodes=63)), ("lr05_it600", BASE, dict(learning_rate=0.05, max_iter=600)), ("msl50", BASE, dict(min_samples_leaf=50)), ("msl5", BASE, dict(min_samples_leaf=5)), ("l2_1", BASE, dict(l2_regularization=1.0)), ("it150", BASE, dict(max_iter=150)), ("noKeytel", [c for c in BASE if c != "Keytel_kcal"], {}), ("noDurHR", [c for c in BASE if c != "Dur_x_HR"], {}), ("raw7", RAW, {})] KNN = ["log_Dur", "Heart_Rate", "Weight", "Age", "Sex"] rng = np.random.default_rng(3000 + W) orig = make_original(N_ORIG, rng) pool = synth_from(orig, N_TRAIN + N_TEST + N_TRUTH, rng) orig, pool = add_feats(orig), add_feats(pool) train = pool.iloc[:N_TRAIN].reset_index(drop=True) evalset = pool.iloc[N_TRAIN:].reset_index(drop=True) # 先頭 N_TEST = test、残り = truth names = [c[0] for c in CANDS] + ["knn"] oof = np.zeros((len(names), N_TRAIN)); evk = np.zeros((len(names), 5, N_TEST + N_TRUTH), np.float32) # (候補, fold, 行) 平均しない for k, (tr, va) in enumerate(KFold(5, shuffle=True, random_state=0).split(train)): tr_df = pd.concat([train.iloc[tr], orig], ignore_index=True) y = np.log1p(tr_df.Calories.values) for j, (nm, cols, over) in enumerate(CANDS): m = HistGradientBoostingRegressor(early_stopping=False, random_state=0, **{**D, **over}).fit(tr_df[cols], y) oof[j, va] = m.predict(train.iloc[va][cols]); evk[j, k] = m.predict(evalset[cols]) sc = StandardScaler().fit(tr_df[KNN]); m = KNeighborsRegressor(n_neighbors=15, algorithm="kd_tree").fit(sc.transform(tr_df[KNN]), y) oof[-1, va] = m.predict(sc.transform(train.iloc[va][KNN])); evk[-1, k] = m.predict(sc.transform(evalset[KNN])) print(f"world {W} fold {k} {time.time()-T0:.0f}s", flush=True) # 混合は後で(fold ごとに 0.9*base + 0.1*knn)。ここでは12本の fold 別予測だけ保存 np.savez_compressed(f"raw171_w{W}.npz", oof=oof, evk=evk, ytr=train.Calories.values, yev=evalset.Calories.values, names=np.array(names)) print(f"world {W} wall {time.time()-T0:.0f}s")
analyze171.py(分解・補正・方針の比べ直し・候補数 K):
"""Day171 集計: raw171_w*.npz(fold 別予測)から (B) CV の偏りが「1モデル対5モデル平均」で説明できるか(分解: C − T5 = (C − T1) + (T1 − T5)) (B') ラベル無しで測れる補正 A_test(test 行での fold 間の予測の分散)で CV を直す: C_adj = sqrt(C_mse − A_test) (C) 補正した CV で最終提出の方針を比べ直す(公開 5/20/50%、再分割 500 回、Day170 と同じ乱数) (D) 候補数 K を 3/5/8/13 に変えたときの方針の損 使い方: python analyze171.py """ import glob import numpy as np, pandas as pd from scipy.stats import spearmanr pd.set_option("display.width", 250) N_TEST, R = 20000, 500 files = sorted(glob.glob("raw171_w*.npz"), key=lambda f: int(f.split("_w")[1].split(".")[0])) OLD = "../d170/raw170_w{}.npz" # Day170 の出力(再現確認用) def sle(y, lp): return (np.log1p(np.clip(np.expm1(lp), 1, None)) - np.log1p(y)) ** 2 dec, pol_rows, k_rows, repro = [], [], [], [] for f in files: w = int(f.split("_w")[1].split(".")[0]) z = np.load(f); oof, evk, ytr, yev, names = z["oof"], z["evk"].astype(np.float64), z["ytr"], z["yev"], list(z["names"]) # 混合(決定8の形)を fold ごとに作る。knn の行番号を先に固定しておく(Day170 の作り間違いの再発防止) kn = names.index("knn") for base in ("default", "lr05_it600"): b = names.index(base) oof = np.vstack([oof, 0.9 * oof[b] + 0.1 * oof[kn]]); evk = np.concatenate([evk, (0.9 * evk[b] + 0.1 * evk[kn])[None]]) names.append(f"{base}+knn.1") ev5 = evk.mean(1) # 提出の形(5モデル平均) old = np.load(OLD.format(w)); repro.append(np.abs(ev5 - old["ev"].astype(np.float64)).max()) ids = [i for i, n in enumerate(names) if n != "knn"] yte, ytru = yev[:N_TEST], yev[N_TEST:] C_mse = np.array([sle(ytr, oof[j]).mean() for j in range(len(names))]) T5_mse = np.array([sle(ytru, ev5[j, N_TEST:]).mean() for j in range(len(names))]) T1_mse = np.array([np.mean([sle(ytru, evk[j, k, N_TEST:]).mean() for k in range(5)]) for j in range(len(names))]) A_tru = evk[:, :, N_TEST:].var(axis=1).mean(1) # truth 行の fold 間分散(恒等式の項) A_te = evk[:, :, :N_TEST].var(axis=1).mean(1) # test 行の fold 間分散(実戦で測れる) C_adj_mse = C_mse - A_te C, T5, T1, Ca = np.sqrt(C_mse), np.sqrt(T5_mse), np.sqrt(T1_mse), np.sqrt(C_adj_mse) for j in ids: dec.append(dict(world=w, cand=names[j], C_T5=(C[j] - T5[j]) * 1e4, C_T1=(C[j] - T1[j]) * 1e4, T1_T5=(T1[j] - T5[j]) * 1e4, Cadj_T5=(Ca[j] - T5[j]) * 1e4, A_tru=A_tru[j] * 1e4, gap_mse=(T1_mse[j] - T5_mse[j]) * 1e4, A_te=A_te[j] * 1e4, T5=T5[j], C=C[j], Ca=Ca[j])) E_te = np.stack([sle(yte, ev5[j, :N_TEST]) for j in range(len(names))]) fixed = names.index("default+knn.1") rng = np.random.default_rng(w) # Day170 と同じ乱数(広い13本の再分割) idsA = np.array(ids) for frac in (0.05, 0.20, 0.50): n_pub = int(N_TEST * frac) M = np.zeros((N_TEST, R)) for r in range(R): M[rng.choice(N_TEST, n_pub, replace=False), r] = 1 S_pub = E_te[idsA] @ M; U = np.sqrt(S_pub / n_pub) P = np.sqrt((E_te[idsA].sum(1, keepdims=True) - S_pub) / (N_TEST - n_pub)) Tk, Ck, Cak = T5[idsA], C[idsA], Ca[idsA]; best = Tk.min() fx = int(np.where(idsA == fixed)[0][0]); pub = U.argmin(0); cv = int(Ck.argmin()); cva = int(Cak.argmin()) def two(a): t = np.stack([np.full(R, a) if np.isscalar(a) else a, pub]); pp = np.stack([P[t[0], np.arange(R)], P[t[1], np.arange(R)]]) return t[pp.argmin(0), np.arange(R)] pol = {"CV最良": np.full(R, cv), "補正CV最良": np.full(R, cva), "公開LB最良": pub, "事前固定(決定8)": np.full(R, fx), "2枠: CV最良+公開最良": two(cv), "2枠: 補正CV最良+公開最良": two(cva)} for nm, ch in pol.items(): pol_rows.append(dict(world=w, frac=frac, policy=nm, regT=float(((Tk[ch] - best) * 1e4).mean()))) if frac == 0.20: # (D) 候補数 K krng = np.random.default_rng(10_000 + w) for K in (3, 5, 8, 13): for s in range(300): sub = np.sort(krng.choice(len(idsA), K, replace=False)) if K < len(idsA) else np.arange(len(idsA)) r = int(krng.integers(R)); b = Tk[sub].min() p_ = sub[U[sub, r].argmin()]; c_ = sub[Ck[sub].argmin()]; a_ = sub[Cak[sub].argmin()] def pick2(x): return x if P[x, r] <= P[p_, r] else p_ k_rows.append(dict(world=w, K=K, 公開LB最良=(Tk[p_] - b) * 1e4, CV最良=(Tk[c_] - b) * 1e4, 補正CV最良=(Tk[a_] - b) * 1e4, **{"2枠(CV+公開)": (Tk[pick2(c_)] - b) * 1e4, "2枠(補正CV+公開)": (Tk[pick2(a_)] - b) * 1e4}, 一様ランダム=(Tk[sub].mean() - b) * 1e4)) print(f"worlds={len(files)} Day170 との再現(5モデル平均の最大差): max {max(repro):.2e}") D = pd.DataFrame(dec) print("\n=== 表1 分解(×1e-4、16ワールド平均) ===") print(D.groupby("cand").mean(numeric_only=True).drop(columns=["world", "T5", "C", "Ca"]).round(2).to_string()) print("\n=== 表2 default との差(世界ペア、平均±SE、×1e-4) ===") rows = [] for col in ("C_T5", "C_T1", "T1_T5", "Cadj_T5"): X = D.pivot(index="world", columns="cand", values=col); X = X.sub(X["default"], axis=0) for c in X.columns: rows.append(dict(cand=c, col=col, m=X[c].mean(), se=X[c].std(ddof=1) / np.sqrt(len(X)))) print(pd.DataFrame(rows).pivot(index="cand", columns="col", values=["m", "se"]).round(2).to_string()) for col in ("C_T5", "C_T1", "Cadj_T5"): print(f"候補間SD of {col}: {D.groupby('world')[col].std(ddof=1).mean():.2f}") sp = [] for w, g in D.groupby("world"): sp.append(dict(world=w, rho_C=spearmanr(g.C, g.T5)[0], rho_Ca=spearmanr(g.Ca, g.T5)[0], hit_C=g.cand.iloc[g.C.argmin()] == g.cand.iloc[g.T5.argmin()], hit_Ca=g.cand.iloc[g.Ca.argmin()] == g.cand.iloc[g.T5.argmin()], best=g.cand.iloc[g.T5.argmin()], cabest=g.cand.iloc[g.Ca.argmin()])) SP = pd.DataFrame(sp) print("\n=== 表2b CV と真の順位相関(世界平均)・真の最良に当たった世界数 ===") print(f"rho(C,T5) {SP.rho_C.mean():.3f} rho(Cadj,T5) {SP.rho_Ca.mean():.3f} hit C {SP.hit_C.sum()}/{len(SP)} hit Cadj {SP.hit_Ca.sum()}/{len(SP)}") print("Cadj最良に選ばれた候補:", SP.cabest.value_counts().to_dict(), " 真の最良:", SP.best.value_counts().to_dict()) print("\n=== 表3 方針の損(regT, ×1e-4, 16世界平均)と主要な対の paired 差 ===") PR = pd.DataFrame(pol_rows) print(PR.groupby(["frac", "policy"]).regT.agg(["mean", lambda s: s.std(ddof=1) / np.sqrt(len(s))]).round(3).to_string()) for fr in (0.05, 0.2, 0.5): a = PR[PR.frac == fr].pivot(index="world", columns="policy", values="regT") for x, y in [("補正CV最良", "CV最良"), ("補正CV最良", "公開LB最良"), ("補正CV最良", "事前固定(決定8)"), ("2枠: 補正CV最良+公開最良", "2枠: CV最良+公開最良"), ("2枠: 補正CV最良+公開最良", "補正CV最良"), ("公開LB最良", "CV最良")]: d = a[x] - a[y]; print(f"公開{int(fr*100):>2}% {x} − {y}: {d.mean():+.2f} ± {d.std(ddof=1)/np.sqrt(len(d)):.2f}") print("\n=== 表4 候補数 K(公開20%、各世界300回の無作為部分集合、×1e-4) ===") KR = pd.DataFrame(k_rows) print(KR.groupby("K").mean(numeric_only=True).drop(columns="world").round(2).to_string()) print((KR.groupby(["K", "world"]).mean(numeric_only=True).groupby("K").std(ddof=1) / np.sqrt(len(files))).round(2).to_string())
実行結果(実測)
# ============ 実行環境: macOS (M1, メモリ8GB)・Python 3.13.12・scikit-learn 1.9.1・numpy 2.5.3・pandas 3.0.6・scipy 1.18.1(Day170 と同じ uv venv)
# 16ワールド(4並行、OMP_NUM_THREADS=1)。壁時計 508秒。1ワールド 109〜129秒。fit 数 = 12モデル × 5 fold × 16 = 960
# 再現確認: 基本12本(HistGB 11本 + knn)は Day170 の5モデル平均と最大差 3.8e-07(float32 の丸め)、OOF は完全一致(差 0.0)
# 混合 lr05_it600+knn.1 だけ最大差 8.7e-02 → Day170 の値は 0.9*lr05 + 0.1*(default+knn.1) と一致(最大差 4.7e-07、16ワールド)
# このファイルに載せたコードを抜き出して回し直し、下の数字と一致することを確かめた。表2〜4 は出力を読みやすく並べ替えてある(数字は出力のまま)
=== 表1 分解(×1e-4、16ワールド平均): A_tru=truth行の fold 間分散、gap_mse=T1_mse−T5_mse、A_te=test行の分散(3つが一致すれば恒等式どおり) ===
C_T5 C_T1 T1_T5 Cadj_T5 A_tru gap_mse A_te
default 11.08 -1.65 12.73 -1.67 3.99 3.99 3.99
default+knn.1 9.28 -1.44 10.72 -1.46 3.36 3.36 3.36
it150 7.24 -1.46 8.70 -1.47 2.73 2.73 2.73
l2_1 11.48 -0.99 12.48 -0.97 3.91 3.91 3.90
leaf15 6.56 -2.15 8.71 -2.16 2.74 2.74 2.74
leaf63 16.80 -1.40 18.20 -1.34 5.71 5.71 5.68
lr05_it600 9.39 -1.68 11.07 -1.72 3.47 3.47 3.48
lr05_it600+knn.1 7.85 -1.53 9.37 -1.56 2.93 2.93 2.94
msl5 12.42 -2.11 14.53 -2.16 4.56 4.56 4.57
msl50 8.98 -2.10 11.08 -2.14 3.48 3.48 3.49
noDurHR 10.10 -2.39 12.49 -2.41 3.91 3.91 3.91
noKeytel 10.56 -4.00 14.55 -4.06 4.58 4.57 4.58
raw7 10.61 -3.46 14.06 -3.53 4.43 4.42 4.44
=== 表2 default との差(世界ペア、平均±SE、×1e-4) ===
C_T5 (Day170の偏り) C_T1 (1モデル同士) T1_T5 (平均の得) Cadj_T5 (補正後)
default+knn.1 -1.80 ± 0.23 0.21 ± 0.22 -2.01 ± 0.02 0.21 ± 0.22
it150 -3.84 ± 0.54 0.19 ± 0.53 -4.03 ± 0.04 0.20 ± 0.53
l2_1 0.41 ± 0.53 0.66 ± 0.52 -0.25 ± 0.05 0.70 ± 0.55
leaf15 -4.51 ± 0.73 -0.50 ± 0.73 -4.01 ± 0.07 -0.49 ± 0.74
leaf63 5.73 ± 0.98 0.25 ± 0.91 5.48 ± 0.09 0.33 ± 0.94
lr05_it600 -1.69 ± 0.49 -0.03 ± 0.48 -1.66 ± 0.04 -0.05 ± 0.49
lr05_it600+knn.1 -3.23 ± 0.55 0.12 ± 0.53 -3.36 ± 0.05 0.11 ± 0.54
msl5 1.34 ± 0.68 -0.46 ± 0.67 1.81 ± 0.10 -0.49 ± 0.68
msl50 -2.09 ± 0.62 -0.45 ± 0.61 -1.64 ± 0.06 -0.47 ± 0.62
noDurHR -0.98 ± 0.46 -0.74 ± 0.45 -0.24 ± 0.06 -0.74 ± 0.45
noKeytel -0.52 ± 1.15 -2.35 ± 1.11 1.83 ± 0.09 -2.39 ± 1.11
raw7 -0.47 ± 1.10 -1.81 ± 1.06 1.34 ± 0.10 -1.86 ± 1.07
候補間SD of C_T5: 3.31 C_T1: 2.21 Cadj_T5: 2.25
=== 表2b CV と真の順位相関(世界平均)・真の最良に当たった世界数 ===
rho(C,T5) 0.428 rho(Cadj,T5) 0.701 hit C 1/16 hit Cadj 8/16
Cadj最良に選ばれた候補: leaf63 7, msl5 7, noDurHR 1, default+knn.1 1 真の最良: leaf63 14, msl5 1, noDurHR 1
=== 表3 方針の損(regT, ×1e-4, 16世界平均 ± SE)。lr05_it600+knn.1 は正しい混合に直した ===
公開5% 公開20% 公開50%
補正CV最良 0.92 ± 0.29 0.92 ± 0.29 0.92 ± 0.29
2枠: 補正CV最良+公開最良 0.92 ± 0.25 0.88 ± 0.24 0.86 ± 0.25
2枠: CV最良+公開最良 1.82 ± 0.24 1.58 ± 0.24 1.44 ± 0.28
公開LB最良 3.26 ± 0.21 2.10 ± 0.19 1.50 ± 0.24
CV最良 2.34 ± 0.29 2.34 ± 0.29 2.34 ± 0.29
事前固定(決定8) 3.00 ± 0.29 3.00 ± 0.29 3.00 ± 0.29
paired(世界ペア, n=16):
公開 5% 補正CV最良 − CV最良: -1.42 ± 0.33 補正CV最良 − 公開LB最良: -2.33 ± 0.35 補正CV最良 − 事前固定: -2.08 ± 0.38
公開20% 補正CV最良 − CV最良: -1.42 ± 0.33 補正CV最良 − 公開LB最良: -1.18 ± 0.33 2枠(補正) − 2枠(CV): -0.69 ± 0.23
公開50% 補正CV最良 − CV最良: -1.42 ± 0.33 補正CV最良 − 公開LB最良: -0.58 ± 0.35 2枠(補正) − 2枠(CV): -0.58 ± 0.22
2枠(補正) − 補正CV最良 単独: 公開5% -0.00 ± 0.09 / 20% -0.04 ± 0.14 / 50% -0.06 ± 0.20
公開LB最良 − CV最良(混合を直した後): 公開5% +0.92 ± 0.27 / 20% -0.24 ± 0.28 / 50% -0.84 ± 0.33
(Day170 の作り間違いのまま: +0.50 ± 0.42 / -0.69 ± 0.45 / -1.30 ± 0.50)
=== 表4 候補数 K(公開20%、各世界300回の無作為部分集合、×1e-4、平均・世界間SE) ===
公開LB最良 CV最良 補正CV最良 2枠(CV+公開) 2枠(補正CV+公開) 一様ランダム
K
3 0.92±0.07 1.32±0.20 0.70±0.16 0.55±0.06 0.37±0.05 2.61±0.13
5 1.27±0.10 1.58±0.23 0.93±0.20 0.80±0.11 0.52±0.07 3.32±0.18
8 1.67±0.12 1.93±0.23 1.04±0.23 1.16±0.15 0.67±0.13 4.00±0.23
13 2.15±0.20 2.34±0.29 0.92±0.29 1.60±0.24 0.89±0.24 4.75±0.30
一様ランダムに対する比: 公開LB最良 0.35 / 0.38 / 0.42 / 0.45、補正CV最良 0.27 / 0.28 / 0.26 / 0.19
読み方: 表1 の A_tru・gap_mse・A_te が小数2桁まで一致している。1つ目と2つ目の一致は恒等式(クリップの影響は無視できる大きさ)、2つ目と3つ目の一致は「ラベル無しの test 行で測った平均の得が、ラベル付きの truth 行の得と同じ」ことを示す。表2 は、Day170 の偏り(C_T5)が「平均の得」(T1_T5、SE 0.02〜0.10 と非常に精密)でほぼ打ち消されることを示す。
タスクD(決定)
(1) 決定13(新規): CV は「提出の形」に直してから比べる。
| # | 決定 | 採った案 | 採らなかった案と理由 | 前提が変わったら |
|---|---|---|---|---|
| 13 | CV の点数の直し方 | 候補ごとに test 行で5つの fold モデルの予測の分散(log1p 空間)を測り、補正CV = sqrt(CV² − A_test) で比べる。報告にも補正後の値を使う | (a) 生の CV(真の最良に 1/16 世界しか当たらない、損 2.34)。(b) 提出を1モデルにして CV と形をそろえる(提出が平均の得 4〜6×10⁻⁴ を捨てる)。(c) 入れ子 CV で5モデル平均の OOF を作る(fit 数が5倍、今日は比べていない)。(d) truth 相当のラベル付きホールドアウトを切る(train が減る) | 指標が二乗誤差系でない(AUC・MAE など)→ 恒等式が成り立たないので使えない。提出が fold 平均でない(全データ再学習1本)→ 補正は不要、ただし全データ1本の得は別に測る。test の分布が train と違う(敵対的 AUC 0.55 超)→ A_test と A_truth がずれるはずなので測り直す |
(2) 決定12(改訂): 最終2本 = 補正CV最良 + 公開LB最良。 損は 0.88(公開20%)で、改訂前(1.58)より −0.69±0.23 小さい。ただし 2本目(公開LB最良)を足した得は −0.04±0.14 で測れないほど小さい。2本目は「補正 CV の前提(決定13 の右欄)が崩れたときの保険」として残す。同じ候補になったら公開LBの2位を出す(Day170 と同じ)。
(3) 決定5: 撤回。 決定12 が公開LBを2本目の選択に使う以上、「壊れ確認だけ」とは両立しない。撤回の理由に、Day170 の比較(公開20%で公開LB最良 − CV最良 −0.69±0.45)は作り間違いの候補を含んでおり、直すと −0.24±0.28(差は測れない)だったことも書く。公開LBが CV(生)に勝つのは公開50%のとき(−0.84±0.33)だけで、公開5%では負ける(+0.92±0.27)。
(4) 決定9(追記): sha256 の再現テストは「同じ間違いを毎回同じに作る」ことしか保証しない。 候補の名前と作り方を対応させる表明(例: assert np.allclose(mix, 0.9*base + 0.1*knn))を、混合を作った直後に置く。
(5) 利用者の作業(S5E5 実データ)。 Kaggle のログインとルール同意が要るので、ここでは手順だけ書く(私は実行していない)。
| 手順 | 何を確かめるか |
|---|---|
| 1. S5E5 のルールページで、最終提出の本数と「良い方が採られるか」を読む | 決定12 の前提(記憶による) |
| 2. データページで test 行数と公開の割合を読む | 決定12・13 の公開割合(今日は 5/20/50% の仮定) |
| 3. train と test で敵対的 AUC を測る | 決定1・13 の「前提が変わったら」(0.55 超なら見直し) |
| 4. 候補を作るとき、fold モデルの test 予測を平均前に保存する | 決定13 の A_test を測るため |
5. 提出前に 補正CV の順位と公開LB の順位を並べて記録する | 実データでの補正の当たり外れ(今日の数字は縮小シミュレータ) |
📊 結果の可視化
上: CV の偏りは fold 間分散(平均の得)を引くとほぼ消える(noKeytel・raw7 を除く)。中: 補正CV最良は公開割合によらず損 0.92 で、生の CV最良(2.34)・公開LB最良(公開20%で 2.10)より小さい。下: 候補数が増えると公開LB最良の損は増えるが、補正CV最良はほぼ平ら。
🪜 Step-by-Step 解説
Step 1: 設計書を1枚にしたら、矛盾が1つ見つかった
決定5(公開LBは壊れ確認だけ)と決定12(2本目は公開LB最良)。日ごとのファイルではどちらも「その日の正解」で、並べるまで気づかない。印を付けると、決定12 は△の上に乗っていた(CV の偏りの原因が仮説)。
Step 2: 弱点を測るために、平均する前の予測を保存する
Day170 は5モデル平均しか保存していなかったので、1モデルの真の点数(T1)が出せなかった。同じ乱数で作り直し、fold 別に保存した(1ワールド約120秒、4並行で計508秒)。
Step 3: 作り直したら、Day170 と1本だけ合わなかった
12本は 3.8×10⁻⁷(float32 の丸め)で一致、OOF は完全一致。混合 lr05_it600+knn.1 だけ 0.087 ずれた。Day170 のコードでは、1本目の混合を vstack で足した後に oof[-1] を knn のつもりで使っていた。「最後の行」は、行を足すたびに別のものを指す。 名前ではなく行番号で参照したことが原因。Day170 の sha256 の再現テスト(決定9)では捕まらない(毎回同じ間違いを再現する)。
Step 4: CV の偏りを2つに分ける(表2)
C − T5 = (C − T1) + (T1 − T5)。前半は「1モデル同士の CV と真のずれ」、後半は「平均の得」。leaf63 の +5.73 は、前半 +0.25±0.91、後半 +5.48±0.09。偏りのほぼ全部が平均の得だった。 複雑な設定(葉が多い・葉の最小行数が小さい)ほど fold モデルどうしの予測がばらつき、平均で大きく得をする。
Step 5: 平均の得をラベル無しで測る(表1)
恒等式により、平均の得(MSE 単位)は fold 間分散そのもの。test 行(ラベル無し)で測った A_te と truth 行の A_tru は全候補で小数2桁まで一致(leaf63 5.68 対 5.71)。train と test が同じ分布なら、実戦で必ず測れる量になる。
Step 6: 補正した CV で方針を比べ直す(表3)
補正CV最良の損 0.92。生の CV最良 2.34 より −1.42±0.33、公開20%の公開LB最良 2.10 より −1.18±0.33。真の最良に当たった世界 1/16 → 8/16。外した8世界の多くは msl5(7世界で選ばれ、真の最良だったのは1世界)。
Step 7: 残ったずれ(表2 の右)
補正後も候補間 SD は 2.25 残る。大半は世界ごとの雑音(SE 0.5〜0.9 の幅)だが、特徴量を抜いた候補(noKeytel −2.39±1.11、raw7 −1.86±1.07)は補正後も CV が良く見える側にずれた(約2SE)。原因は未調査(元データ1.5万行を足して学習することとの関係を疑っているが、測っていない)。
Step 8: 候補数 K(表4)
公開LB最良の損は K=3 で 0.92、13 で 2.15。一様ランダムも 2.61→4.75 と増えるので、比で見ると 0.35→0.45 とゆっくり悪化。補正CV最良は K によらずほぼ平ら(0.70〜1.04)。K は 13 までしか測れない(候補が13本しか無い)ので、数百本の世界は外挿になる。
📐 数学・統計の補足(文系向け)
平均すると得をする量は、ばらつきそのもの。 5人に同じ的を狙って矢を射させる。1人ずつの外れ方の平均(2乗)は、「5人の矢の真ん中」の外れ方(2乗)に、「5人の矢がどれだけ散らばっているか(分散)」を足したものに必ず等しい。真ん中で提出するなら、散らばりの分だけ得をする。そして散らばりは、的(正解)を見なくても矢どうしを見るだけで測れる。今日の補正は、この「矢どうしの散らばり」を test の行で測っただけ。
なぜ RMSLE でも使えるか。 RMSLE は「log(1+予測) と log(1+正解) の差の2乗平均の平方根」。log1p で学習したモデルの予測は最初から log 空間にあるので、その空間での2乗誤差に恒等式がそのまま使える。ただし下限1のクリップ(決定4)が入るとわずかに崩れる。今日は gap_mse と A_tru の差が最大 0.01×10⁻⁴ で無視できた。
「√(CV² − A)」の意味。 CV²(平均2乗誤差)から散らばりを引くと「5人の真ん中で射たときの平均2乗誤差」の推定になる。最後に平方根で RMSLE に戻す。
🏆 Kaggleでの実践的な使い方
- fold モデルで test を平均して提出するなら、CV の比較は平均の得を引いてから。 葉の数・学習率・木の本数など「ばらつきを変える設定」の比較で、生の CV は単純な設定をひいきする(今日
leaf15が −4.51 良く見えた)。必要なのは、各 fold の test 予測を平均する前に保存することだけ - AUC・MAE のコンペでは使えない。 恒等式は二乗誤差のもの。分類の logloss・AUC では別の議論が要る(今日は扱っていない)
- 「公開LBを信じるか CV を信じるか」の前に、CV を提出の形に直す。 直した CV は、公開5〜50%のどの割合でも公開LB最良より損が小さかった(−2.33/−1.18/−0.58)
- 再現できることと、正しいことは別。 同じ乱数で作り直して照合したから、作り間違いが見つかった。照合を「乱数の確認」だけにせず、候補の名前と式を表明(assert)で結ぶ
- 設計書は定期的に1枚に並べる。 日ごとに決めると、前の決定を黙って上書きする(今日の決定5)
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| CV が良い設定は提出でも良い | CV と提出が同じものを測っていると思う | OOF は1モデル、提出は5モデル平均。差(平均の得)は候補ごとに 2.7〜5.7×10⁻⁴(MSE 単位)と違い、leaf63 を CV は15/16世界で外した |
| 平均の得を測るには正解ラベルが要る | 「得」は正解との比較だと思う | 二乗誤差なら、予測どうしの分散そのもの。test 行で測れ、truth 行の値と小数2桁まで一致 |
| 補正すれば CV のずれは消える | 偏りの原因を1つだと思う | 平均の得で説明できる部分は消えたが、特徴量を抜いた候補には約2SE のずれが残った |
| sha256 が一致したので結果は正しい | 再現性を正しさと混同する | 毎回同じ間違いを作れば sha256 も一致する。Day170 の混合1本は作り方が名前と違っていた |
arr[-1] で最後に足した行を指す | 1回目は正しく動く | 行を足すたびに「最後」が変わる。名前から行番号を引く(names.index("knn")) |
| 公開LB最良は CV最良より当たる(Day170) | 1回の測定の結論を固定する | 作り間違いを直すと公開20%で −0.24±0.28 で差は測れない。公開5%では CV が勝つ(+0.92±0.27) |
🧭 扱わなかった範囲と理由
- シミュレータ1つ、モデル族1つ: HistGB(乱数なし)と knn だけ。bagging・列抽出のある LightGBM、NN では fold 間分散に model seed の揺れが混ざる(決定9)。今日の補正がそのまま効くかは未測定
- 残ったずれの原因:
noKeytel・raw7の約2SE のずれは原因を調べていない。16ワールドで約2SE なので、偶然の可能性も残る - 候補数 K: 13本までしか作っていない。数百本(実際のコンペ)は外挿。部分集合は無作為で、人が「良さそうなもの」を選んで足す偏りは入っていない
- 補正の SE: A_test 自体の揺れ(2万行での推定誤差)は測っていない。test 行が少ない(数千行)コンペでは揺れが大きくなるはず
- Day170 の他の結論の見直し: 混合候補を直した後の数字は表3 の6方針だけ出し直した。Day170 のハイブリッド方針・狭い10本・hitT・regP は出し直していない
- 原典: Krogh & Vedelsby 1995 の恒等式は記憶による(式は今日の表1 で数値的に確かめた)。Kaggle のルール(最終2本・良い方採用)・S5E5 の公開割合は今日も確認していない(利用者の作業、タスクD(5))
- 入れ子 CV との比較: 「5モデル平均の OOF を直接作る」方法(fit 数5倍)と補正CVの比較はしていない
- 持ち越し(今月中には扱わない): Day165 の SE 個数差と分割の新しさの分離、Day162 の選択バイアス仮説、真の効果ちょうど0の候補での偽採用率、Nadeau & Bengio 原典の確認
🚀 次のステップ
- 発展: 列抽出(
max_features=0.5)を入れた HistGB で、fold 間分散に model seed の揺れが混ざっても補正CV が効くかを測る(決定9 と決定13 の接点) - 発展:
noKeytel・raw7の残ったずれが、元データを学習 fold に足すこと(決定3)と関係するかを、元データ無しで同じ分解をして確かめる - 次回予告: Day172 — Playground Series 実戦投入⑦(月の残りは 10/25 まで。今日の決定13 を前提に、LightGBM の bagging ありで補正CV を測り直す。その後、月替わりテーマ3「画像(CV)系コンペへの応用」は 10/25 から)
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: