📚 背景知識(読んでから問題へ)
昨日の宿題。 Day163 で特徴量 Dur_x_HR・log_Dur を足すと、CV では +5.5SE で区別できたのに private(16,000行)では 0.9SE で区別できなかった。今日はこれを「特徴量をどう採否判定するか」という運用の問題として扱う。
⚠️
昨日の SE は実コンペでは使えない。 Day163 の「5seed」は seed ごとにデータそのもの(元データ・train・test)を作り直していた。実コンペでは train は1つしかない。手元で揺れを測る方法は次の2つだけ。
| 揺れの源 | やり方 | 弱点(今日測る点) |
|---|---|---|
| fold 間の差 | 1回の5分割で fold ごとに「候補あり − なし」を出し、5個の平均と SE | 5個しかないので SE そのものが揺れる。大きめに出やすい |
| 繰り返し KFold | 分割 seed を変えて5分割を3回(計15 fit × 2設定) | 15個の fold 差は同じ行を共有していて独立ではない。そのまま SE を取ると小さく出る |
今日の問い: 「CV の対の差が −2SE を下回ったら採用」というルールは、SE の取り方で採用・見逃し・偽採用がどれだけ変わるか。正解(真の効果)は train とは別に作った test 6万行で測る。
🧪
シミュレータは Day163 と同じ(目的変数は Keytel et al. 2005, J Sports Sci 23(3):289–297 の心拍数からの消費カロリー式 × 運動時間)。変えたのは行数だけ: train 6万 → 3万(繰り返し KFold で fit 数が3倍になるため、開発機メモリ8GB)、test 2万 → 6万(真の効果の SE を小さくするため)。数値は仮定の上の実測。
🗂️ データ・候補スキーマ
| 列/データ | 定義・値 | 役割・作った理由 |
|---|---|---|
| Sex / Age / Height / Weight / Duration / Heart_Rate / Body_Temp | 基準の7列 | 基準モデル(候補なし) |
| Dur_x_HR | 運動時間 × 心拍数 | Day163 で採用。Keytel 式で最も効く心拍数と時間の掛け算 |
| log_Dur | log(運動時間) | Day163 で採用。log の世界で掛け算を足し算にする |
| BMI | 体重 / 身長² | ありがちな健康系特徴量 |
| HR_x_W | 心拍数 × 体重 | Keytel 式の2つの項の掛け算(式には無い形) |
| Keytel_kcal | Keytel 式そのもの × 運動時間 | 生成式を渡す(実コンペでは「ドメイン知識の式」) |
| noise | 標準正規乱数 | 陰性対照1 |
| HR_shuffled | 心拍数を行の間でシャッフル | 陰性対照2(分布は本物、中身は無関係) |
| orig / train / test | 15,000 / 30,000 / 60,000 行 × 4ワールド | 元データは学習foldにだけ足す(Day163 決定3)。test は真の効果の測定専用 |
📏 評価の仕組み — 対の差と「−2SE」の判定線
指標は Day163 と同じ RMSLE(何倍外したか)。今日評価するのは採否ルールの方で、判定は「対の差の平均が −2SE を下回るか」。下の図は同じ候補(真の改善 −0.0005 前後)を、SE が違う2つのルールで判定したときの模式図。SE が大きいと、同じ改善でも判定線をまたげず見逃す。
点の位置 −4.8 は Dur_x_HR の R2 平均差、SE は表2の中央値(×10⁻⁴)。
❓ 問題
タスクA(予想)— コードを書く前に紙に書く
- 候補7つそれぞれについて「真に効く/効かない/悪化する」を予想する
log_Dur = log(Duration)は GBDT に対してどんな効果を持つか。理由とともに- 陰性対照(
noise・HR_shuffled)を足すと、真の効果は 0 になるか、悪化するか - R1(1回の5分割・fold差SE)と R2(3回繰り返し・15fold差の素朴なSE)では、どちらが見逃しが多く、どちらが偽採用が多いか
タスクB(コーディング)
- Day163 の
make_original・synth_fromを再利用し、add_candidates(df, rng)で候補7列を足す run(train, test, orig, cols, split_seed)を CV 設計書どおりに実装(ランダム5分割・log1p・元データは学習foldにだけ・下限1クリップ)。1回の fit で検証fold と test を両方予測する(Day163 は2回 fit していた)- 4ワールド × 3分割seed × (基準 + 候補7) を回し、R1・R2・R3 の対の差と SE、真の効果とその SE を出す
- 表1(候補ごとの平均と採用回数)、表2(SE の大きさ)、表3(ルールごとの正しい採用・偽採用・見逃し)、表4(CV差と真の効果の順位相関)
- assert 2つ: (a)
Keytel_kcalは4ワールド中3以上で真に効く (b) 15fold差の SE は1回の5分割の SE より小さい(28件の70%超)
タスクC(分析・決定)
表1〜4から (1) 採否ルールを1つ決め、(2) 今日の候補の採否を決めて CV 設計書に「決定6: 特徴量の採否」を書き足す。「採った案/採らなかった案と理由/前提が変わったら」を付ける。
💡 ヒント
ヒント1方向性
決定木は「ある値より大きいか小さいか」で分けるだけ。値の順番を変えない変換(log・平方根・定数倍)をした列は、元の列と同じ分け方しかできない。乱数の列でも、木はそれで分けて「たまたま当たった」分割を学習してしまう。
ヒント2アプローチ
fold 差の SE は
d.std(ddof=1) / sqrt(len(d))。R2 は15個を並べて同じ式(重なりは無視)、R3 は各繰り返しの全体 CV の差3個に同じ式。真の効果の SE は行ごとの誤差の差から出し、RMSLE が平方根なので「平均の差の SE ÷ (2 × RMSLE)」で近似する(デルタ法)。ヒント3コード骨格
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(max_iter=300, learning_rate=0.1, random_state=split_seed) m.fit(tr_df[cols], np.log1p(tr_df.Calories)) pv, pt = [np.clip(np.expm1(m.predict(X)), 1, None) for X in (train.iloc[va][cols], test[cols])] ... fd = fold_scores_cand - fold_scores_base # 対の差(5個) adopt = fd.mean() < -2 * fd.std(ddof=1) / np.sqrt(5)
✅ 模範解答
タスクA(予想の模範と実測の照合)
| # | 予想の模範 | 実測 |
|---|---|---|
| 1 | Dur_x_HR・Keytel_kcal は効く。BMI・HR_x_W はほぼ効かない | 当たり。Dur_x_HR 4/4、Keytel_kcal 3/4 で真に効く。BMI は 1/4 で効き 2/4 で悪化、HR_x_W は 1/4 で悪化 |
| 2 | 効果はちょうど0。 Duration は1〜30の整数。HistGradientBoosting は値を順番で最大255個のビンに分けてから木を作るので、log を取っても同じビンになる | 当たり。4ワールドすべてで CV 差・真の効果とも 0.00000 |
| 3 | 0 ではなく悪化する。乱数で分けて過学習する分だけ損をする | 当たり。noise +0.00054、HR_shuffled +0.00048(どちらも 4/4 で真に悪化)。最も効いた Dur_x_HR の改善 −0.00051 とほぼ同じ大きさの損 |
| 4 | R1 は見逃しが多く、R2 は偽採用が多い | 半分当たり。R1 は見逃し 4/8。R2 の偽採用は1件で、陰性対照の偽採用は0件(陰性対照は CV でも毎回はっきり悪化して見えた) |
タスクB(完全なコード)
"""Day164: Playground S5E5 縮小シミュレータで「特徴量を1つずつ足し、CVの対の差で採否を決める」運用を測る。 実コンペでは train は1つしかない(Day163 の「seed=データを作り直す」は使えない)。 使える揺れの源は (1) fold 間の差 (2) 分割 seed を変えた繰り返し KFold の2つ。 採否ルール3種の「偽採用(効かない特徴量を採る)」「見逃し」を、大きな test(真の効果)と突き合わせる。 """ import time import numpy as np import pandas as pd from sklearn.ensemble import HistGradientBoostingRegressor from sklearn.model_selection import KFold T0 = time.time() def keytel_rate(sex, hr, w, age): """Keytel et al. 2005 (J Sports Sci 23(3):289-297) の kcal/分。sex: 1=男 0=女""" 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 と同じ 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 と同じ 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 BASE = ["Sex", "Age", "Height", "Weight", "Duration", "Heart_Rate", "Body_Temp"] def add_candidates(d, rng): d = d.copy() d["Dur_x_HR"] = d.Duration * d.Heart_Rate d["log_Dur"] = np.log(d.Duration) d["BMI"] = d.Weight / (d.Height / 100) ** 2 d["HR_x_W"] = d.Heart_Rate * d.Weight 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["noise"] = rng.normal(0, 1, len(d)) # 陰性対照1: 目的変数と無関係 d["HR_shuffled"] = rng.permutation(d.Heart_Rate.values) # 陰性対照2: 分布は本物、中身は無関係 return d CANDS = ["Dur_x_HR", "log_Dur", "BMI", "HR_x_W", "Keytel_kcal", "noise", "HR_shuffled"] NEG = {"noise", "HR_shuffled"} def rmsle_rows(y, p): # 行ごとの二乗対数誤差(対の差の SE を行で出すため) return (np.log1p(p) - np.log1p(y)) ** 2 def fit_pred(Xtr, ytr, Xs, seed): m = HistGradientBoostingRegressor(max_iter=300, learning_rate=0.1, random_state=seed) m.fit(Xtr, np.log1p(ytr)) return [np.clip(np.expm1(m.predict(X)), 1, None) for X in Xs] def run(train, test, orig, cols, split_seed): """CV 設計書(Day163)どおり: ランダム5分割・log1p・元データは学習foldにだけ・下限1クリップ。 返り値: fold ごとの RMSLE(5個), 全体 CV, test 予測(5モデル平均)""" kf = KFold(5, shuffle=True, random_state=split_seed) oof, tp, fold_scores = np.zeros(len(train)), np.zeros(len(test)), [] for tr, va in kf.split(train): tr_df = pd.concat([train.iloc[tr], orig], ignore_index=True) pv, pt = fit_pred(tr_df[cols], tr_df.Calories, [train.iloc[va][cols], test[cols]], split_seed) oof[va] = pv tp += pt / 5 fold_scores.append(np.sqrt(rmsle_rows(train.Calories.values[va], pv).mean())) return np.array(fold_scores), np.sqrt(rmsle_rows(train.Calories.values, oof).mean()), tp N_ORIG, N_TRAIN, N_TEST = 15000, 30000, 60000 WORLDS, REPEATS = range(4), range(3) rec = [] for w in WORLDS: rng = np.random.default_rng(2000 + w) orig = make_original(N_ORIG, rng) pool = synth_from(orig, N_TRAIN + N_TEST, rng) orig, pool = add_candidates(orig, rng), add_candidates(pool, rng) train, test = pool.iloc[:N_TRAIN].reset_index(drop=True), pool.iloc[N_TRAIN:].reset_index(drop=True) res = {} for name in ["base"] + CANDS: cols = BASE if name == "base" else BASE + [name] res[name] = [run(train, test, orig, cols, r) for r in REPEATS] yt = test.Calories.values for c in CANDS: # (1) 1回の5分割: fold ごとの対の差 fd = res[c][0][0] - res["base"][0][0] # (2) 3回繰り返しの15個の fold 差(重なりを無視した素朴な SE) fd15 = np.concatenate([res[c][r][0] - res["base"][r][0] for r in REPEATS]) # (3) 繰り返しごとの全体 CV 差(3個) cvd = np.array([res[c][r][1] - res["base"][r][1] for r in REPEATS]) # 真の効果: test 6万行で、3繰り返し×5モデルの平均予測どうしの差(行で対にした SE) pc = np.mean([res[c][r][2] for r in REPEATS], axis=0) pb = np.mean([res["base"][r][2] for r in REPEATS], axis=0) ec, eb = rmsle_rows(yt, pc), rmsle_rows(yt, pb) true_d = np.sqrt(ec.mean()) - np.sqrt(eb.mean()) # 行ごとの差の SE を、平方根の前の平均の差から近似(デルタ法) true_se = (ec - eb).std(ddof=1) / np.sqrt(len(yt)) / (2 * np.sqrt(eb.mean())) rec.append(dict(world=w, cand=c, d1=fd.mean(), se1=fd.std(ddof=1) / np.sqrt(5), d15=fd15.mean(), se15=fd15.std(ddof=1) / np.sqrt(15), d3=cvd.mean(), se3=cvd.std(ddof=1) / np.sqrt(3), true_d=true_d, true_se=true_se)) print(f"world {w} done {time.time()-T0:.0f}s", flush=True) df = pd.DataFrame(rec) df["truth_help"] = df.true_d < -2 * df.true_se # 真に効く df["truth_hurt"] = df.true_d > 2 * df.true_se # 真に悪化 for k in ["1", "15", "3"]: df[f"adopt{k}"] = df[f"d{k}"] < -2 * df[f"se{k}"] pd.set_option("display.width", 200) print("\n=== 表1 候補ごとの 対の差(4ワールド平均)と 採用回数/4 ===") g = df.groupby("cand", sort=False) t1 = pd.DataFrame({ "true_d": g.true_d.mean(), "true_se(平均)": g.true_se.mean(), "真に効く": g.truth_help.sum(), "d1": g.d1.mean(), "採用R1": g.adopt1.sum(), "d15": g.d15.mean(), "採用R2": g.adopt15.sum(), "d3": g.d3.mean(), "採用R3": g.adopt3.sum()}) print(t1.round(5).to_string()) print("\n=== 表2 SE の大きさ(4ワールド×7候補の中央値)===") print(df[["se1", "se15", "se3", "true_se"]].median().round(6).to_string()) print("\n=== 表3 ルールごとの判定 対 真の効果(28 = 4ワールド×7候補)===") for k, nm in [("1", "R1 1回5分割・fold差SE"), ("15", "R2 3回繰返し・15fold差SE(素朴)"), ("3", "R3 3回繰返し・CV差SE(n=3)")]: a = df[f"adopt{k}"] print(f"{nm:32s} 採用 {a.sum():2d} | 正しい採用 {(a & df.truth_help).sum():2d} | " f"偽採用(真に効かない) {(a & ~df.truth_help).sum():2d} | うち陰性対照 {(a & df.cand.isin(NEG)).sum():2d} | " f"見逃し {(~a & df.truth_help).sum():2d}") print("\n=== 表4 CV差と真の効果の順位相関(候補×ワールド 28点、Spearman)===") for k in ["1", "15", "3"]: print(f"d{k:3s} vs true_d: {df[f'd{k}'].rank().corr(df.true_d.rank()):.3f}") print(f"\nwall {time.time()-T0:.0f}s") df.to_csv("day164_records.csv", index=False) # assert(数値の向きを固定する) assert df[df.cand == "Keytel_kcal"].truth_help.sum() >= 3, "式そのものを渡す特徴量は真に効く" assert (df.se15 < df.se1).mean() > 0.7, "15個の fold 差を独立扱いすると SE は小さく見える" print("all asserts passed")
実行結果(実測)
# ============ 実行環境: macOS (M1, メモリ8GB)・Python 3.13.12・scikit-learn 1.9.1(新規 uv venv)
# 壁時計 1,191秒(ワールド0 はスワップ中で 546秒、1〜3 は各 180〜270秒)============
=== 表1 候補ごとの 対の差(4ワールド平均)と 採用回数/4 ===
true_d true_se(平均) 真に効く d1 採用R1 d15 採用R2 d3 採用R3
cand
Dur_x_HR -0.00051 0.00010 4 -0.00063 2 -0.00048 4 -0.00048 4
log_Dur 0.00000 0.00000 0 0.00000 0 0.00000 0 0.00000 0
BMI 0.00014 0.00005 1 0.00014 0 0.00015 0 0.00015 0
HR_x_W 0.00006 0.00006 0 0.00016 0 0.00025 0 0.00025 0
Keytel_kcal -0.00044 0.00017 3 -0.00110 2 -0.00100 4 -0.00099 4
noise 0.00054 0.00005 0 0.00060 0 0.00055 0 0.00055 0
HR_shuffled 0.00048 0.00005 0 0.00050 0 0.00051 0 0.00051 0
=== 表2 SE の大きさ(4ワールド×7候補の中央値)===
se1 0.000200
se15 0.000127
se3 0.000083
true_se 0.000057
=== 表3 ルールごとの判定 対 真の効果(28 = 4ワールド×7候補)===
R1 1回5分割・fold差SE 採用 4 | 正しい採用 4 | 偽採用(真に効かない) 0 | うち陰性対照 0 | 見逃し 4
R2 3回繰返し・15fold差SE(素朴) 採用 8 | 正しい採用 7 | 偽採用(真に効かない) 1 | うち陰性対照 0 | 見逃し 1
R3 3回繰返し・CV差SE(n=3) 採用 8 | 正しい採用 7 | 偽採用(真に効かない) 1 | うち陰性対照 0 | 見逃し 1
=== 表4 CV差と真の効果の順位相関(候補×ワールド 28点、Spearman)===
d1 vs true_d: 0.941
d15 vs true_d: 0.940
d3 vs true_d: 0.939
wall 1191s
all asserts passed
28件の内訳(真の効果で分類): 真に効く 8件(Dur_x_HR 4・Keytel_kcal 3・BMI 1)、真に悪化 11件(noise 4・HR_shuffled 4・BMI 2・HR_x_W 1)、どちらとも言えない 9件(log_Dur 4 を含む)。
| R2 の誤判定 | CV の対の差(R2) | 真の効果 | 読み |
|---|---|---|---|
| 偽採用: Keytel_kcal・ワールド3 | −0.00083 ± 0.00026(3.2SE) | −0.00010 ± 0.00017(0.6SE) | 向きは合うが真の改善は CV の約1/8。悪化はしていない |
| 見逃し: BMI・ワールド0 | −0.00013 ± 0.00014(0.9SE) | −0.00018 ± 0.00005(3.6SE) | 改善が小さすぎて train 3万行の CV では測れない。他の3ワールドでは悪化か差なし |
📊 結果の可視化
図1 候補ごとの CV の対の差(R2)と真の効果(4ワールド平均、×10⁻⁴)
図2 ルールごとの判定(28件のうち真に効く8件 + 誤判定)
図3 SE の大きさ(28件の中央値、×10⁻⁴)
🧭 決定 — 採否ルールと CV 設計書「決定6」
(1) 採否ルール: R2 を使い、R3 を照合に使う
| 案 | 判定 | 理由 |
|---|---|---|
| R1 1回の5分割・fold差SE | 採らない | 見逃し 4/8。SE 中央値 0.00020 は R2 の約1.6倍で、Dur_x_HR を2ワールドで見逃した。偽採用0は慎重すぎるだけ |
| R2 3回繰り返し・15fold差SE | 採る | 正しい採用 7/8、偽採用1、陰性対照の偽採用0。SE は楽観的だが今日の28件では偽採用1件に収まった |
| R3 3回繰り返し・CV差SE(n=3) | 照合用 | 判定は R2 と28件すべて同じ。ただし3個から取る SE は R2 比 0.20〜1.58 倍(中央値 0.59)と揺れる |
| Day163 の「データを作り直す」SE | 使えない | 実コンペでは train が1つしかない |
| CV 差の符号だけで決める | 採らない | 順位相関は 0.94 と高いが、HR_x_W・ワールド0(R1 差 −0.00019、真の効果 −0.00003 で差なし)のような揺れの範囲の候補まで拾う |
(2) CV 設計書 決定6: 特徴量の採否(S5E5 用)
| 採った案 | 採らなかった案と理由 | 前提が変わったら |
|---|---|---|
| 1つずつ足し、R2 で −2SE を下回ったものだけ採る。採用: Dur_x_HR・Keytel_kcal | log_Dur: GBDT では効果ちょうど0(Day163 の採用を取り消す)。BMI・HR_x_W: 真に効いたのは 1/4 以下で悪化の方が多い。noise 系: 足すだけで −0.0005 前後の損 | NN や線形モデルを混ぜる回では単調変換にも意味が出るので、モデルごとに測り直す。候補が数十個に増えたら偽採用が積み上がるので、しきい値を −3SE に上げるか、最後に全部入りで R2 を1回回して確かめる |
⚠️
Keytel_kcal の注意書き: CV の改善(R2 平均 −0.00100)は真の効果(−0.00044)の約2.3倍。ワールド0・2・3 では約3〜8倍の過大評価。「採ってよいか」には答えられるが、「どれだけ良くなるか」を CV で見積もってはいけない。原因は未測定(候補: 真の効果は15モデルの平均予測で測っており、平均でばらつきが減るぶん特徴量で減らせる誤差が小さい)。
🪜 Step-by-Step 解説
1
1回の fit で検証と test を両方予測する。
fit_pred がリストで複数の表を受け取る。今日の fit 数は 4ワールド × 8設定 × 3繰り返し × 5fold = 480回。Day163 のように2回学習すると960回で、壁時計は約2倍になっていた。2
陰性対照を必ず入れる。 偽採用率の目盛りのつもりで入れたが、実際にはそれ自体が損をする特徴量だった(真の効果 +0.0005、4ワールドとも)。CV も毎回 +0.0004〜+0.0008 と悪化を映し、どのルールも採用しなかった。「効かない列は害が無いから入れておけ」は誤り。
3
単調変換は木には効かない。 HistGradientBoosting は学習前に各列を値の順番で最大255個のビンに分ける。Duration は30通りしかなく、log_Dur も同じ30通りに同じ順番で並ぶ。木から見ると同じ列が1本増えただけで、4ワールド × 3繰り返しのすべてで予測が完全に一致した。Day163 の「log で掛け算を足し算にする」は目的変数を log にした時点で済んでいた話だった。
4
SE の取り方で採否が変わる。 SE 中央値は R1 0.00020 > R2 0.00013 > R3 0.00008 > 真 0.00006。R1 は Dur_x_HR(真の効果は4ワールドとも −0.00034〜−0.00066 で有意)を2ワールドで見逃した。R2 は15個を独立とみなして SE を小さく見積もるが、偽採用は1件だった。R3 は SE が揺れるので単独では使わない。
5
「採るか」と「どれだけ良くなるか」を分ける。 順位相関は3ルールとも 0.94 で、良し悪しの順番はよく当たる。一方で Keytel_kcal の CV 差は真の効果の約2.3倍。
6
決定を設計書に足す。 Day163 の設定C は「7列 + Dur_x_HR + Keytel_kcal」に置き換わる。log_Dur を外すのは、効果0と分かった列を残すと、次に誰かが「効いている」と思い込むから。
🧮 数学・統計の補足(文系向け)
- 5個の差の SE が揺れる理由: SE は「差のばらつき ÷ √個数」。5個だとばらつき自体がたまたま大きくも小さくも出る。5人に聞いたアンケートのばらつきを信じにくいのと同じ。今日の R1 の SE は 0〜0.00061 まで散らばった
- 15個を独立とみなすと楽観的になる理由: 3回の5分割は同じ3万行を並べ替えただけで、同じ行が検証側に3回出る。同じ人に3回聞いた答えを「3人分」と数えるようなもの。補正方法(Nadeau & Bengio 2003 の補正付き t 検定)はあるが今日は実装していない
- 単調変換: 大小の順番を変えない変換。1, 2, 3 の log は 0, 0.69, 1.10 で順番は同じ。木は「上か下か」しか見ないので同じ木になる。線形モデルや NN には間隔も効くので log に意味がある
- デルタ法: RMSLE は平均の平方根。平方根のグラフの傾き 1 ÷ (2×√値) を掛けて、平均の差の SE を RMSLE の差の SE に直す。小さな変化なら直線で近似してよい、という微分の考え方
🏆 Kaggleでの実践的な使い方
- 特徴量の採否は「3回繰り返し5分割の fold 差 −2SE」で判定し、判定表を残す。 1回の5分割は本当に効く特徴量の半分を見逃した。GBDT 1本が数分で済む Playground 級なら3倍の時間を払う価値がある
- 陰性対照(乱数列)を1本、同じ手順に通す。 乱数を採用したらルールか CV が壊れている
- GBDT に単調変換した列を足さない。 効くのは線形モデル・NN を混ぜるときと、目的変数側の変換(Day163 の log1p で −0.0049)
- ドメインの式は採用候補の最上位に置くが、CV の改善幅は割り引く。 今日は真の効果の約2.3倍に出た
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| log を取った特徴量で GBDT も掛け算を学びやすくなる | 線形モデルの知識を持ち込む | 木は順番しか見ない。log_Dur は差ちょうど0。効いたのは2列を組み合わせた Dur_x_HR |
| 効かない列を入れても害は無い | 重要度が低い列は無視されると考える | 乱数1列で +0.0005 悪化(4/4)。最も効いた特徴量の改善と同じ大きさ |
| 1回の5分割で CV が良くなったら採用 | 手元の CV 差をそのまま信じる | SE(中央値 0.0002)と比べないと、真に効く8件のうち4件を見逃すか、揺れで採用する |
| 繰り返し KFold の15個は独立な測定 | 分割を変えれば別の測定だと思う | 同じ行を並べ替えただけなので SE は小さく出る。候補が増えれば偽採用も増える |
| CV 差 −0.0010 なら本番でも −0.0010 | 差の大きさをそのまま当てはめる | Keytel_kcal は真の効果 −0.00044。順番は当たる(0.94)が大きさは外れる |
🚧 扱わなかった範囲と理由
- 実データ(S5E5): Kaggle のログインとルール同意が必要なため未使用。Keytel_kcal がよく効くのはシミュレータの目的変数を Keytel 式で作ったからでもある。実物の目的変数の作り方は未確認
- モデルの seed 平均: HistGradientBoosting は1万行を超えると early stopping が自動で有効になり、判定用の10%を random_state で選ぶ。今日は random_state を分割 seed と同じにしたので、分割の揺れとモデルの揺れを分けていない
- Nadeau & Bengio の補正付き t 検定: R2 の楽観を補正する標準的な方法。今日は素朴な比較にとどめた
- 特徴量の組み合わせ(2つ同時・前向き選択): 1つずつだけ。Dur_x_HR と Keytel_kcal の効果が足し算になるかは未測定
- ワールド数: 4ワールド(28件)。偽採用率を「1/20」の精度で言うには少ない。言えるのは件数そのもの(偽採用 R1 0・R2 1・R3 1)まで
- Day162 の選択バイアス仮説: 疑似ラベルを使う回に再検証(Day163 から持ち越し)
🚀 次のステップ
- 発展: R2 に Nadeau & Bengio の補正を入れ、偽採用と見逃しがどう動くかを同じ28件で測る。陰性対照を「害の無い列」に替えて偽採用率そのものを測る
- 次回予告: Day165 — Playground Series 実戦投入③。Dur_x_HR・Keytel_kcal を入れた状態で GBDT のハイパーパラメータを Optuna で探し、探索回数を増やすほど CV が楽観する「探索の過学習」を R2 と test 6万行で測る
Phase 6 の学習マップ
1-23Phase6主要テーマ・総復習(Day126-149、完了)
月替わり1NLP系コンペへの応用①〜⑬(Day150-162、完了)
月替わり2Playground Series 実戦投入①②(Day163〜、〜2026-10-25)
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: