📚 背景知識(読んでから問題へ)
再現性は2段ある。 (1) 同じコード・同じ seed・同じデータで回し直して同じ数字が出るか(再現)。(2) seed を変えたら結論が変わらないか(頑健)。Kaggle で「昨日の CV 0.1596 が今日 0.1598 になった」とき、原因が (1) の崩れ(スレッド数・ライブラリ更新・乱数の固定漏れ)なのか、(2) の揺れ(seed)なのかで、打つ手が違う。(1) が崩れていると、Day164〜166 のように「差 0.0001 を2SE で判定する」ことは意味を失う。
モデル側の乱数。 Day165 では early_stopping=False と random_state=0 で「行も列も抽出しない」設定にして、モデル側の乱数を0にした。LightGBM や XGBoost で colsample_bytree<1・bagging_fraction<1 を使うと、同じデータでも seed で木が変わる。今日は sklearn の max_features=0.5(各分割で列の半分だけを見る。scikit-learn 1.4 で追加)で同じ状況を作る。
データの列(縮小シミュレータ。Playground S5E5 の構造を写したもの):
| 列 | 型 | 意味 | 基準7列 | 候補8列 |
|---|---|---|---|---|
| Sex | 0/1 | 性別 | ○ | ○ |
| Age | 整数 | 年齢(20〜79) | ○ | ○ |
| Height / Weight | 整数 | 身長 cm・体重 kg | ○ | ○ |
| Duration | 整数 | 運動時間 分(1〜30) | ○ | ○ |
| Heart_Rate | 整数 | 心拍数(67〜128) | ○ | ○ |
| Body_Temp | 小数 | 体温 | ○ | ○ |
| Dur_x_HR | 数値 | Duration × Heart_Rate(Day164 で効いた候補) | なし | ○ |
| Calories | 整数 | 目的変数(消費カロリー)。学習では log1p をかける | y | y |
評価指標 RMSLE(二乗対数平均平方根誤差)。 予測と正解を log1p(1を足して対数を取る)してから、ふつうの RMSE を取る。「正解 100 に対して予測 110」と「正解 10 に対して予測 11」はほぼ同じ誤りとして扱われる(比率で見る)。式は √平均[(log(1+予測) − log(1+正解))²]。小さいほど良い。今日扱う差は 0.0001 台で、CV の点数 0.1596 の約0.1%。
今日の問い:
- 乱数のない設定(
max_features=1.0)で、スレッド数 1/4/8 と再実行で OOF は完全に一致するか - 列抽出(
max_features=0.5)を入れると、seed を変えるだけで CV はどれだけ動くか。分割seed を変えたときと比べてどうか。特徴量1本の効果(Dur_x_HR)と比べてどうか - 候補と基準で同じ seed を使う(対にする)と、判定は安定するか
- seed を増やして平均すれば、採否は安定するか
用語整理:
| 用語 | 意味 |
|---|---|
| OOF | Out-Of-Fold 予測。各行を「その行を学習に使っていないモデル」で予測したもの。CV の点数はこれから出す |
| モデル seed | HistGradientBoostingRegressor(random_state=…)。max_features<1 のときは列の抽出を決める |
| 分割seed | KFold(shuffle=True, random_state=…)。どの行をどの fold に入れるか |
| 対にする | 候補と基準に同じ seed を使い、同じ seed どうしの差を取る |
| sha256 | OOF 配列のバイト列から作る指紋。1ビットでも違えば別の値になる(完全一致の判定に使う) |
縮小シミュレータは Day163〜166 と同じ(Day164 のワールド0 と同じ乱数・同じ make_original・synth_from。目的変数は Keytel et al. 2005, J Sports Sci 23(3):289–297 の式 × 運動時間)。基準7列 vs 候補8列(+Dur_x_HR)。数値は仮定の上の実測。
❓ 問題
タスクA(予想)— コードを書く前に紙に書く
max_features=1.0・early_stopping=Falseの HistGB は、スレッド数 1/4/8 と再実行で OOF が完全に一致するか(浮動小数点の足し算の順序が変わるので、末尾の桁がずれると予想するか)max_features=0.5で seed だけ変えたとき、CV の SD は分割seed を変えたときの SD の何割か。特徴量1本の効果(Day164 では −0.0005 だった)と比べてどうか- 同じ seed で対にすると、候補−基準の差の SD は何割減るか。1回の比較で効果の符号を誤る確率は
- 8 seed 平均の OOF で候補と基準を比べると、差は seed 1個のときと同じ大きさか
タスクB(コーディング)
- Day165 の
make_original・synth_fromを再利用し、run(cols, split_seed, model_seed, max_features)を書く(CV 設計書どおり: 5分割・log1p・元データは学習foldだけ・下限1クリップ) - 表1:
max_features=1.0で OOF を作り、スレッド数 {1,4,8} × 2回の sha256 を並べる(別プロセスでOMP_NUM_THREADSを変えて回す) - 表2・3:
max_features=0.5。(a) モデル seed 8通り(分割seed 0 固定)(b) 分割seed 8通り(モデル seed 0 固定)で、基準・候補の CV、対の差、対にしない差(i≠j の56通り)、test 6万行での真の効果(8モデル平均)を出す - 8 seed 平均 OOF どうしの CV 差と、行で対にしたデルタ法の SE(Day164 と同じ考え方)を出す
assert: (a) 表1の6つの OOF がビット単位で一致(checkモード) (b) 対にした差の符号誤りは対にしない差の符号誤り以下
タスクC(決定・戦略)
表1〜3から、S5E5 のパイプラインに入れる「再現性の検査」と「乱数のあるモデルでの採否の手順」を決め、CV 設計書に「決定9」を足す。「採った案/採らなかった案と理由/前提が変わったら」を付ける。
💡 ヒント
ヒント1(方向性)
- 「再現できない」と「揺れる」は別の問題。まず乱数を全部消して再現を確かめ、次に乱数を1つずつ入れて揺れを測る
- 揺れの原因は2つある(モデル seed と分割seed)。片方だけ動かして測る
ヒント2(アプローチ)
- 完全一致は
hashlib.sha256(oof.tobytes())で判定する。スレッド数は環境変数OMP_NUM_THREADSで変え、プロセスごとに分ける(同じプロセスの中では変えられない) - 揺れの比較は「SD」と、効果(−0.0001 台)の大きさを並べる。SE は「何の揺れを測った SE か」を書く(seed の揺れか、行の揺れか)
ヒント3(コード骨格)
oof, _ = run(BASE, split_seed=0, model_seed=0) # max_features=1.0 print(hashlib.sha256(oof.tobytes()).hexdigest()[:16]) # スレッド数を変えて比べる for s in range(8): # 同じ s で基準と候補を作る = 対にする ob, _ = run(BASE, 0, s, 0.5); oc, _ = run(CAND, 0, s, 0.5) d.append(rmsle(y, oc) - rmsle(y, ob))
✅ 模範解答
タスクA(予想の模範と、実測との照合)
| # | 予想の模範 | 実測 |
|---|---|---|
| 1 | 浮動小数点の足し算は順序で結果が変わるので、スレッド数を変えると末尾の桁がずれると予想するのが自然。ただし HistGB のヒストグラム集計は整数のビン単位で、木の分割点が同じなら予測も同じになりうる | 完全一致。6回(スレッド1/4/8 × 2回)すべて sha256 = cd4f8e3eb64ab96d、CV 0.15959793 |
| 2 | モデル seed の SD は分割seed の SD より小さいが、効果と同じ桁になる | モデル seed の SD 1.44(×10⁻⁴)、分割seed の SD 3.48。モデル seed の揺れは分割の0.41倍で、真の効果 1.42 とほぼ同じ大きさ。範囲は 4.75(効果の3.3倍) |
| 3 | 対にすると SD は2〜3割減り、符号誤りはほぼ消える | 差の SD は対にしない 1.90 → 対 1.50(21%減)。1回の比較で効果の符号を誤る回数は 対 0/8、対にしない 6/56(11%)。ただし対でも1回の差の範囲は −4.10〜−0.05(真の効果の 0.04〜2.9倍) |
| 4 | 平均すれば揺れが消えて、真の効果に近づく | 外れた。 8 seed 平均の OOF どうしの差は +0.63(符号が逆)、SE 1.50。真の効果は −1.42。1つの分割で見ると、seed を平均した提出の形では効果が見えなかった |
タスクB(完全なコード)
day167.py(oof・check・seeds の3モード):
コードを開く(129 行)
"""Day167: 縮小シミュレータ(S5E5 型)で「同じコードを回し直したら同じ数字が出るか」と 「モデル側の乱数(seed)の揺れは、特徴量1本の効果に対してどれだけ大きいか」を測る。 使い方: OMP_NUM_THREADS=<n> python day167.py oof <tag> # 表1用: 1回分の OOF を npy に保存して sha256 を出す python day167.py check # 表1の6つの OOF が完全一致するか assert OMP_NUM_THREADS=1 python day167.py seeds # 表2〜3用 """ import sys, time, hashlib import numpy as np, pandas as pd from sklearn.ensemble import HistGradientBoostingRegressor from sklearn.model_selection import KFold MODE = sys.argv[1] N_ORIG, N_TRAIN, N_TEST = 15000, 30000, 60000 BASE = ["Sex", "Age", "Height", "Weight", "Duration", "Heart_Rate", "Body_Temp"] CAND = BASE + ["Dur_x_HR"] # Day164: 真に効く候補(真の効果 約 -0.0005) PARAMS = dict(learning_rate=0.1, max_iter=200, max_leaf_nodes=31, min_samples_leaf=20, l2_regularization=1e-4) 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〜165 と同じ 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〜165 と同じ 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(d): d = d.copy(); d["Dur_x_HR"] = d.Duration * d.Heart_Rate; return d rng = np.random.default_rng(2000) # Day164 のワールド0 と同じデータ orig = make_original(N_ORIG, rng) pool = synth_from(orig, N_TRAIN + N_TEST, rng) orig, pool = add(orig), add(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 rmsle = lambda y, p: float(np.sqrt(((np.log1p(p) - np.log1p(y)) ** 2).mean())) def run(cols, split_seed, model_seed, max_features=1.0, want_test=False): """CV設計書どおり(5分割・log1p・元データは学習foldだけ・下限1クリップ)。戻り値: OOF, 5 fold 平均の test 予測""" oof, tp = np.zeros(N_TRAIN), np.zeros(N_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=model_seed, max_features=max_features, **PARAMS) m.fit(tr_df[cols], np.log1p(tr_df.Calories)) oof[va] = np.clip(np.expm1(m.predict(train.iloc[va][cols])), 1, None) if want_test: tp += np.clip(np.expm1(m.predict(test[cols])), 1, None) / 5 return oof, tp T0 = time.time() if MODE == "check": import glob fs = sorted(glob.glob("oof_t*.npy")); arr = [np.load(f) for f in fs] assert len(fs) == 6 and all(np.array_equal(arr[0], a) for a in arr), "OOF が一致しない" print("check OK:", len(fs), "個の OOF がビット単位で一致") if MODE == "oof": tag = sys.argv[2] oof, _ = run(BASE, split_seed=0, model_seed=0) # max_features=1.0(モデル側の乱数なし) np.save(f"oof_{tag}.npy", oof) print(f"{tag} sha256={hashlib.sha256(oof.tobytes()).hexdigest()[:16]} cv={rmsle(ytr, oof):.8f} {time.time()-T0:.1f}s") if MODE == "seeds": MF = 0.5 # 列抽出あり: モデル側の乱数が結果に効く MS, SS = range(8), range(8) rows = [] for s in MS: # (a) モデル seed だけ動かす(分割seed 0 固定) ob, tb = run(BASE, 0, s, MF, True); oc, tc = run(CAND, 0, s, MF, True) rows.append(dict(kind="model_seed", seed=s, cv_b=rmsle(ytr, ob), cv_c=rmsle(ytr, oc), te_b=rmsle(yt, tb), te_c=rmsle(yt, tc), tp_b=tb, tp_c=tc, oof_b=ob, oof_c=oc)) print("model_seed", s, f"{time.time()-T0:.0f}s", flush=True) for s in SS: # (b) 分割 seed だけ動かす(モデル seed 0 固定) ob, _ = run(BASE, 100 + s, 0, MF); oc, _ = run(CAND, 100 + s, 0, MF) rows.append(dict(kind="split_seed", seed=s, cv_b=rmsle(ytr, ob), cv_c=rmsle(ytr, oc))) print("split_seed", s, f"{time.time()-T0:.0f}s", flush=True) df = pd.DataFrame(rows) M = df[df.kind == "model_seed"]; S = df[df.kind == "split_seed"] # 真の効果: 8 seed の test 予測を平均した後の RMSLE の差(1モデルでなく平均で測る) tpb = np.mean(list(M.tp_b), axis=0); tpc = np.mean(list(M.tp_c), axis=0) true_eff = rmsle(yt, tpc) - rmsle(yt, tpb) avg_b = np.mean(list(M.oof_b), axis=0); avg_c = np.mean(list(M.oof_c), axis=0) cv_avg_diff = rmsle(ytr, avg_c) - rmsle(ytr, avg_b) # 提出する形(8 seed 平均)と同じ形の OOF で測った差 e = lambda x: f"{x*1e4:8.2f}" print("\n=== 表2 CV 点数のばらつき(×1e-4)===") print("model_seed 動かす: base SD", e(M.cv_b.std()), " cand SD", e(M.cv_c.std()), " base 範囲", e(M.cv_b.max()-M.cv_b.min())) print("split_seed 動かす: base SD", e(S.cv_b.std()), " cand SD", e(S.cv_c.std()), " base 範囲", e(S.cv_b.max()-S.cv_b.min())) dM = (M.cv_c - M.cv_b).values; dS = (S.cv_c - S.cv_b).values print("\n=== 表3 候補の効果 cand-base(×1e-4)===") print("真の効果(test, 8モデル平均):", e(true_eff), " 1モデル test 差の平均:", e((M.te_c - M.te_b).mean()), "SD", e((M.te_c - M.te_b).std())) print("同じ model_seed で対にした CV 差: 平均", e(dM.mean()), "SD", e(dM.std(ddof=1)), "範囲", e(dM.min()), "~", e(dM.max())) ub = np.array([M.cv_c.values[i] - M.cv_b.values[j] for i in range(8) for j in range(8) if i != j]) print("model_seed を別々にした CV 差(i!=j): 平均", e(ub.mean()), "SD", e(ub.std(ddof=1)), "範囲", e(ub.min()), "~", e(ub.max())) print("split_seed 動かした CV 差(対): 平均", e(dS.mean()), "SD", e(dS.std(ddof=1))) print("1回の差が符号を誤る回数: 対 model", int((dM > 0).sum()), "/8, 別々 model", int((ub > 0).sum()), "/56, split", int((dS > 0).sum()), "/8") print("8 seed 平均した OOF どうしの CV 差:", e(cv_avg_diff), " (真の効果", e(true_eff), ")") def se_diff(pb, pc): # 行で対にしたデルタ法の SE(Day164 と同じ考え方。fold 間の相関は無視) d = (np.log1p(pc) - np.log1p(ytr)) ** 2 - (np.log1p(pb) - np.log1p(ytr)) ** 2 return d.std(ddof=1) / np.sqrt(len(d)) / (2 * rmsle(ytr, pb)) print("SE(行対, デルタ法): 8 seed 平均OOF", e(se_diff(avg_b, avg_c)), " seed0 の1モデルOOF", e(se_diff(M.oof_b.iloc[0], M.oof_c.iloc[0]))) assert (dM > 0).sum() <= (ub > 0).sum() # 対にした方が符号誤りは多くない print("asserts passed") print(f"壁時計 {time.time()-T0:.0f}s")
実行結果(実測)
# ============ 実行環境: macOS (M1, メモリ8GB)・Python 3.13.12・scikit-learn 1.9.1・numpy 2.5.3・pandas 3.0.6(新規 uv venv) # 表1(max_features=1.0, 分割seed 0, モデル seed 0。OMP_NUM_THREADS を変えて別プロセスで実行) t1a sha256=cd4f8e3eb64ab96d cv=0.15959793 1.9s t1b sha256=cd4f8e3eb64ab96d cv=0.15959793 1.9s t4a sha256=cd4f8e3eb64ab96d cv=0.15959793 1.3s t4b sha256=cd4f8e3eb64ab96d cv=0.15959793 2.4s t8a sha256=cd4f8e3eb64ab96d cv=0.15959793 7.9s t8b sha256=cd4f8e3eb64ab96d cv=0.15959793 5.7s check OK: 6 個の OOF がビット単位で一致 # seeds モード(OMP_NUM_THREADS=1、壁時計 95秒) === 表2 CV 点数のばらつき(×1e-4)=== model_seed 動かす: base SD 1.44 cand SD 1.33 base 範囲 4.75 split_seed 動かす: base SD 3.48 cand SD 1.75 base 範囲 11.84 === 表3 候補の効果 cand-base(×1e-4)=== 真の効果(test, 8モデル平均): -1.42 1モデル test 差の平均: -2.78 SD 2.23 同じ model_seed で対にした CV 差: 平均 -2.13 SD 1.50 範囲 -4.10 ~ -0.05 model_seed を別々にした CV 差(i!=j): 平均 -2.13 SD 1.90 範囲 -7.69 ~ 1.38 split_seed 動かした CV 差(対): 平均 -3.34 SD 2.75 1回の差が符号を誤る回数: 対 model 0 /8, 別々 model 6 /56, split 1 /8 8 seed 平均した OOF どうしの CV 差: 0.63 (真の効果 -1.42 ) SE(行対, デルタ法): 8 seed 平均OOF 1.50 seed0 の1モデルOOF 2.98 asserts passed
読み方: 差は「候補 − 基準」の RMSLE(負なら候補が良い)。「真の効果」は test 6万行で 8 seed × 5 fold = 40 モデルの平均予測どうしの差。SE(行対) は Day164 と同じ、行ごとの二乗誤差の差から出すデルタ法で、fold 間の相関は無視している。
今日の数字は、実行ごとに同じになる(表1が証明)。表2・3 は95〜99秒の実行を3回行って同じ数字が出たことを確認した(途中で出力の行を足したため、出力の行数は回ごとに異なる)。
タスクC(決定)
(1) 手順: パイプラインの先頭に再現テストを1つ置く。乱数のあるモデルでは、seed を揺れの量として測っておくが、採否の SE には使わない。
| 案 | 判定 | 理由(今日の数字) |
|---|---|---|
| 再現テスト: 同じ設定を2回回し OOF の sha256 が一致することを assert する | 採る | HistGB(max_features=1.0)はスレッド 1/4/8 でも完全一致(6/6)。1回の実行が1〜6秒なので費用が小さく、一致しなくなったらライブラリ更新・並列度・固定漏れのどれかが起きたと分かる |
| 基準と候補に同じ model_seed を使う(対にする) | 採る | 符号誤り 0/8(対にしない 6/56)。対にしない比較は範囲が −7.69〜+1.38 で、符号が反転する回が11% |
| seed の揺れ(対の差 SD 1.50 → 8個の平均の SE 0.53、z ≈ −4.0)を採否の SE にする | 採らない | これは「同じデータで seed を変えたときの揺れ」だけを測った SE。行の揺れを含む行対デルタ法の SE は seed0 の1モデルで 2.98 → z ≈ −0.7。分割seed を変えた対の差の SD 2.75 とも近い(導出が別の2つの数が一致)。seed をいくら増やしても、データの揺れは減らない |
| 採否は Day164 の R2(分割seed を変えて繰り返す)を使い、各分割の中の seed は固定して対にする | 採る | 上の行の裏返し。採否の SE が拾うべき揺れはデータ側にある |
| 提出と同じ形(複数 seed 平均)で CV を測る | 採る(ただし今日は結論が出ない) | 提出の形で測った差は +0.63±1.50(SE の1/2 以下で判定不能)。1つの分割・1ワールドでは足りない。R2 の15個の fold 差に拡張するのが次の一手(未測定) |
| seed を平均して揺れを消してから比べる(8 seed × 基準と候補 = 16 モデル) | 今回は採らない | 費用が8倍。平均すると差が +0.63 になり判定不能になった。平均が正しい比較かの検証は R2 で |
(2) CV 設計書に書き足す「決定9: 再現性と乱数のあるモデルの採否」(S5E5 用)
| # | 決定 | 採った案 | 採らなかった案と理由 | 前提が変わったら |
|---|---|---|---|---|
| 9 | 再現性と乱数のあるモデルの採否 | (a) 起動時に max_features=1.0 の HistGB で OOF の sha256 を前回と比べる。(b) 乱数のあるモデルでは基準・候補に同じ model_seed を使う。(c) 採否の SE はデータ側の揺れ(分割seed を変える R2)から取る。(d) seed の揺れは「ここまでは seed の違いで動く」の目安として記録する(今日は CV で SD 0.00014) | seed を平均して SE を出す(データ側の揺れが残るので SE が過小になる)。seed を対にしない(符号誤り11%)。再現テストを省く(差が 0.0001 台の判定を壊す) | 別のマシン・別の sklearn バージョン・LightGBM に替える → 表1を測り直す(今日は M1・sklearn 1.9.1 だけ)。列抽出・bagging を強める → seed の SD が増えるので、R2 の各分割で複数 seed を使うか検討する |
🪜 Step-by-Step 解説
Step 1: 再現を先に確かめる(表1)
乱数を消した設定(max_features=1.0・early_stopping=False・random_state=0)で、OOF の sha256 を6回比べた。スレッド 1/4/8 と再実行を混ぜて全部一致した。random_state は scikit-learn のドキュメントでは「ビン分けの部分抽出と、early stopping の検証分割を制御する」と書かれており(インストールした 1.9.1 の docstring で確認)、今回の4.5万行・early stopping なしの設定では乱数を使う場所がない、という読みと一致する。ただし「一致した」ことは、この M1・この sklearn・この4.5万行での実測である。別のマシンや別のバージョンでも一致するとは言えない。
副産物として所要時間も測れた。同じ fit が 1スレッド 1.9秒、4スレッド 1.3〜2.4秒、8スレッド 5.7〜7.9秒(8コア・他のプロセスが動いている状態で、各2回の計測)。小さいデータでは並列度を上げても速くならず、8スレッドは遅くなった。本番の fit では1スレッドで足りた。一般化はしない。
Step 2: 乱数を1つ入れる(max_features=0.5)
各分割で列の半分だけを見る設定にすると、random_state が列の選び方を決めるので、seed で結果が変わる。モデル seed を8通り動かすと、基準の CV が 4.75×10⁻⁴ の範囲で動いた。真の効果(1.42)の3.3倍の幅である。
Step 3: 対にする(表3)
候補と基準に同じ seed を使うと、差の SD が 1.90 → 1.50 になった。減り方は21%で、基準と候補の揺れが強く連動しているわけではない(連動が完全なら SD は0になる)。それでも符号誤りは 0/8 になった(対にしないと 6/56)。
Step 4: SE は何を測ったか
8個の対の差から SE は 1.50/√8 = 0.53(z ≈ −4.0)と出る。これは「seed を変えたときの揺れ」だけを測った数である。行ごとの二乗誤差から出す行対デルタ法の SE は 2.98(z ≈ −0.7)。分割seed を変えた対の差の SD も 2.75 で、行対の SE とほぼ同じ大きさ。データ側の揺れ(2.75〜2.98)が seed の揺れ(SD 1.50)より大きいので、seed だけで SE を作ると過小になる。
Step 5: 提出の形で測る
8 seed 平均の OOF は、提出する形(複数モデルの平均)に近い。この形での差は +0.63(SE 1.50)で、符号が逆で判定不能だった。真の効果は −1.42。1モデルの test 差は −2.78 なので、平均すると効果が約半分(−1.42/−2.78 = 0.51)に縮む。Day166 の縮み 1.96〜2.18 倍と同じ向きの数字(今日は 1/0.51 = 1.96倍)だが、今日は seed の平均で、Day166 は混ぜる前後。原因は同じとは言えない。
Step 6: 決定を設計書に足す
タスクCの表。
🧮 数学・統計の補足(文系向け)
2種類の揺れ。 同じ問題集(分割)で、別の勉強法(seed)を試すと点が揺れる。逆に、同じ勉強法で問題集を変えても点が揺れる。今日はこの2つを分けて測り、問題集の揺れ(SD 3.48)の方が勉強法の揺れ(SD 1.44)より大きかった。
対にする(paired)ことの意味。 基準と候補を同じ seed で比べるのは、「同じ日の同じ天気で2つの靴を比べる」こと。天気(seed)の影響が両方に同じようにかかっていれば、差を取ると消える。消える割合は、基準と候補の揺れが連動する度合いで決まる。今日は連動が弱く、SD は21%しか減らなかった。それでも符号の誤りは消えた(0/8)。これは SD が 1.90 → 1.50 でも、平均 −2.13 が SD 1.50 の約1.4倍の位置にあるため、1回の比較が負の側に収まりやすいから。
SE が何の揺れを見ているか。 「8個の差の平均の SE = SD/√8」は、8個が独立に同じデータの揺れを引いている前提。seed を変えても行は同じ3万行なので、行の揺れは8個に共通で、平均しても減らない。だから z ≈ −4.0 は「seed を変えても候補が常に良い」ことは言えるが、「別のデータでも良い」ことは言えない。
🏆 Kaggleでの実践的な使い方
- 提出前に sha256 を比べる。 コードと環境を固定した状態で、昨日の OOF と今日の OOF を比べる。一致しないなら、スコアの変化を特徴量の効果と読まない。Kaggle のノートブックは環境(CPU・ライブラリ)が手元と違うので、手元との一致までは言えない(今日は未検証)
- seed 固定で「効いた」を判定しない。 同じ seed で対にしても、効果の見積もりの範囲は −4.10〜−0.05(真の効果の0.04〜2.9倍)と広い。seed 1つの差を「改善幅」として記録しない
- 採否は Day164 の R2 に任せる。 seed は対にして固定、揺れは分割seed を変える側で拾う。「seed を増やせば SE が減る」は、モデル側の揺れにしか効かない
- 提出する形で測る。 複数 seed・複数 fold を平均して出す提出なら、評価も同じ形にする。今日の1つの分割では判定不能だったので、R2 の分割で測り直す
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| seed を固定すれば再現できる | random_state=0 と書いた安心感 | 乱数を使う場所が全部 seed で押さえられている必要がある。今日の設定は一致したが、別の環境は未検証。sha256 で確かめる |
| スレッド数を変えると結果がずれる | 並列の足し算は順序で変わる、という一般論 | 今日の HistGB(4.5万行・max_features=1.0)は完全一致。ただし他のライブラリ・設定で一致する保証はない(LightGBM は未検証) |
| seed を8個平均すれば SE が1/√8 になる | 独立な標本の平均の公式 | 行の揺れは seed で変わらないので平均しても減らない。今日の SE は 0.53(seed のみ)と 2.98(行を含む)で5.6倍違う |
| 対にすれば揺れは消える | 対にして符号誤りが 0/8 になった | SD は21%しか減らず、1回の差の範囲は −4.10〜−0.05。符号は安定しても、大きさは安定しない |
| 提出の形(seed 平均)で測れば効果が確かめられる | 提出と同じ形が一番正しいはず | 今日の1分割では +0.63±1.50 で判定不能。形を揃えるだけでは足りず、分割を変えて繰り返す必要がある |
🚧 扱わなかった範囲と理由
- ワールド数・候補数・世界の実物との比較: 1ワールド・候補1つ(
Dur_x_HR)・8 seed。「seed の SD は効果と同じ大きさ」は今日の1ワールドの数であり、一般化できない。Day164 では同じ候補の真の効果が −0.00051 だったが、今日はmax_features=0.5・200木で −0.00014 と小さい。差の原因(列抽出か、木の数か)は分けていない - LightGBM・XGBoost・CatBoost: 使っていない。Kaggle の実物(Playground の上位解法の多く)が使うのは LightGBM 系だが、今日の再現テストの結果は HistGB だけのもの。LightGBM の
deterministicオプションやスレッド数による非決定性は未検証(ドキュメントも今日は読んでいない) - 別のマシン・別のバージョン・GPU: M1・sklearn 1.9.1・CPU のみ。Kaggle の実行環境との一致は未検証
- R2 での再測定: 提出の形(seed 平均)の差が R2 の15個の fold 差でどうなるかは未測定。次の一手
- seed の数: 8個。SD の推定は粗い(8個の SD の標準誤差は約25%)
- 実データ(S5E5): Kaggle のログインとルール同意が必要なため使っていない(Day163 から同じ)
- Day166 の流れとの関係: この回は 9/29 のバックフィルで、Day166(10/2 出題済み)の「最終提出の選び方」には触れていない。そちらは state の次回予告のとおり別の回で扱う
🚀 次のステップ
- 発展: 提出の形(8 seed 平均 × 5 fold)の OOF を、R2 の分割seed 1〜3 で作り、候補と基準の差を行対 SE で判定する。1モデルの差(今日 −2.13)と平均の差(今日 +0.63)のどちらが提出の効果を当てるかを、複数ワールドで数える
- 次回予告: (この回は独立したバックフィル。state の次回予告「Day 167 — 最終提出の選び方」は変更しない)
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: