Day 164 — Playground Series 実戦投入② — 特徴量を1つずつ足して採否を決める。1回の5分割では本当に効く8件のうち4件を見逃し、3回繰り返すと見逃し1件・偽採用1件になった。昨日採用した log_Dur は差がちょうど0だった

2026-09-27 金 / Phase 6(月替わりテーマ2: Playground Series 実戦投入) コーディング→分析 候補7 × 4ワールド × 3回繰り返し5分割

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

昨日の宿題。 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個の平均と SE5個しかないので 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_Durlog(運動時間)Day163 で採用。log の世界で掛け算を足し算にする
BMI体重 / 身長²ありがちな健康系特徴量
HR_x_W心拍数 × 体重Keytel 式の2つの項の掛け算(式には無い形)
Keytel_kcalKeytel 式そのもの × 運動時間生成式を渡す(実コンペでは「ドメイン知識の式」)
noise標準正規乱数陰性対照1
HR_shuffled心拍数を行の間でシャッフル陰性対照2(分布は本物、中身は無関係)
orig / train / test15,000 / 30,000 / 60,000 行 × 4ワールド元データは学習foldにだけ足す(Day163 決定3)。test は真の効果の測定専用

📏 評価の仕組み — 対の差と「−2SE」の判定線

指標は Day163 と同じ RMSLE(何倍外したか)。今日評価するのは採否ルールの方で、判定は「対の差の平均が −2SE を下回るか」。下の図は同じ候補(真の改善 −0.0005 前後)を、SE が違う2つのルールで判定したときの模式図。SE が大きいと、同じ改善でも判定線をまたげず見逃す。

対の差(候補あり − なし、×10⁻⁴)。左ほど良い 0 −10 −5 +5 R1 SE 2.0 判定線 −4.0 ±2SE(ワールドにより見逃す) R2 SE 1.3 判定線 −2.6 ±2SE(判定線より左 → 採用)

点の位置 −4.8 は Dur_x_HR の R2 平均差、SE は表2の中央値(×10⁻⁴)。

❓ 問題

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

  1. 候補7つそれぞれについて「真に効く/効かない/悪化する」を予想する
  2. log_Dur = log(Duration) は GBDT に対してどんな効果を持つか。理由とともに
  3. 陰性対照(noise・HR_shuffled)を足すと、真の効果は 0 になるか、悪化するか
  4. R1(1回の5分割・fold差SE)と R2(3回繰り返し・15fold差の素朴なSE)では、どちらが見逃しが多く、どちらが偽採用が多いか

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

  1. Day163 の make_original・synth_from を再利用し、add_candidates(df, rng) で候補7列を足す
  2. run(train, test, orig, cols, split_seed) を CV 設計書どおりに実装(ランダム5分割・log1p・元データは学習foldにだけ・下限1クリップ)。1回の fit で検証fold と test を両方予測する(Day163 は2回 fit していた)
  3. 4ワールド × 3分割seed × (基準 + 候補7) を回し、R1・R2・R3 の対の差と SE、真の効果とその SE を出す
  4. 表1(候補ごとの平均と採用回数)、表2(SE の大きさ)、表3(ルールごとの正しい採用・偽採用・見逃し)、表4(CV差と真の効果の順位相関)
  5. 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(予想の模範と実測の照合)

#予想の模範実測
1Dur_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
30 ではなく悪化する。乱数で分けて過学習する分だけ損をする当たり。noise +0.00054、HR_shuffled +0.00048(どちらも 4/4 で真に悪化)。最も効いた Dur_x_HR の改善 −0.00051 とほぼ同じ大きさの損
4R1 は見逃しが多く、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⁻⁴)

CV差(R2) 真の効果 0 −10 −5 +5(悪化) Dur_x_HR log_Dur 0.00000(完全に同じ予測) BMI HR_x_W Keytel_kcal CV は真の約2.3倍に見える noise HR_shuffled

図2 ルールごとの判定(28件のうち真に効く8件 + 誤判定)

正しい採用 偽採用 見逃し R1 1回5分割 44 R2 3回・15fold 711 R3 3回・CV差 711 判定は R2 と28件すべて同じ

図3 SE の大きさ(28件の中央値、×10⁻⁴)

R1 fold差(5個) 2.00(大きく、見逃す) R2 fold差(15個) 1.27(独立扱いで楽観) R3 CV差(3個) 0.83(R2比 0.20〜1.58倍と揺れる) 真の効果(test 6万) 0.57

🧭 決定 — 採否ルールと 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_kcallog_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-23
Phase6主要テーマ・総復習(Day126-149、完了)
月替わり1
NLP系コンペへの応用①〜⑬(Day150-162、完了)
月替わり2
Playground Series 実戦投入①②(Day163〜、〜2026-10-25)

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

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