Day 167 — 2026-09-29 | Playground Series 実戦投入(9/29 のバックフィル分・独立1本)— 「同じコードを回し直したら同じ数字が出るか」と「乱数の揺れは特徴量1本の効果より大きいか」。HistGB はスレッド数を変えても OOF が完全一致した。列抽出を入れると seed の揺れ(SD 0.00014)が効果(0.00014)と同じ大きさになり、seed を対にしても、seed を増やしても、データの揺れは減らなかった

2026-09-29(バックフィル) 金 / Phase 6(月替わりテーマ2: Playground Series 実戦投入) 戦略→コーディング HistGB · 1ワールド · 8 seed · 160 fit

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

再現性は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列
Sex0/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 をかけるyy

評価指標 RMSLE(二乗対数平均平方根誤差)。 予測と正解を log1p(1を足して対数を取る)してから、ふつうの RMSE を取る。「正解 100 に対して予測 110」と「正解 10 に対して予測 11」はほぼ同じ誤りとして扱われる(比率で見る)。式は √平均[(log(1+予測) − log(1+正解))²]。小さいほど良い。今日扱う差は 0.0001 台で、CV の点数 0.1596 の約0.1%。

今日の問い:

  1. 乱数のない設定(max_features=1.0)で、スレッド数 1/4/8 と再実行で OOF は完全に一致するか
  2. 列抽出(max_features=0.5)を入れると、seed を変えるだけで CV はどれだけ動くか。分割seed を変えたときと比べてどうか。特徴量1本の効果(Dur_x_HR)と比べてどうか
  3. 候補と基準で同じ seed を使う(対にする)と、判定は安定するか
  4. seed を増やして平均すれば、採否は安定するか

用語整理:

用語意味
OOFOut-Of-Fold 予測。各行を「その行を学習に使っていないモデル」で予測したもの。CV の点数はこれから出す
モデル seedHistGradientBoostingRegressor(random_state=…)。max_features<1 のときは列の抽出を決める
分割seedKFold(shuffle=True, random_state=…)。どの行をどの fold に入れるか
対にする候補と基準に同じ seed を使い、同じ seed どうしの差を取る
sha256OOF 配列のバイト列から作る指紋。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(予想)— コードを書く前に紙に書く

  1. max_features=1.0・early_stopping=False の HistGB は、スレッド数 1/4/8 と再実行で OOF が完全に一致するか(浮動小数点の足し算の順序が変わるので、末尾の桁がずれると予想するか)
  2. max_features=0.5 で seed だけ変えたとき、CV の SD は分割seed を変えたときの SD の何割か。特徴量1本の効果(Day164 では −0.0005 だった)と比べてどうか
  3. 同じ seed で対にすると、候補−基準の差の SD は何割減るか。1回の比較で効果の符号を誤る確率は
  4. 8 seed 平均の OOF で候補と基準を比べると、差は seed 1個のときと同じ大きさか

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

  1. Day165 の make_original・synth_from を再利用し、run(cols, split_seed, model_seed, max_features) を書く(CV 設計書どおり: 5分割・log1p・元データは学習foldだけ・下限1クリップ)
  2. 表1: max_features=1.0 で OOF を作り、スレッド数 {1,4,8} × 2回の sha256 を並べる(別プロセスで OMP_NUM_THREADS を変えて回す)
  3. 表2・3: max_features=0.5。(a) モデル seed 8通り(分割seed 0 固定)(b) 分割seed 8通り(モデル seed 0 固定)で、基準・候補の CV、対の差、対にしない差(i≠j の56通り)、test 6万行での真の効果(8モデル平均)を出す
  4. 8 seed 平均 OOF どうしの CV 差と、行で対にしたデルタ法の SE(Day164 と同じ考え方)を出す
  5. 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 — 最終提出の選び方」は変更しない)

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

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