📚 背景知識(読んでから問題へ)
Day171 のおさらい。 fold モデル5本の平均で提出するとき、二乗誤差では次の式が行ごとに必ず成り立つ。
5モデルの平均の二乗誤差 = 1モデルずつの二乗誤差の平均 − 5モデルの予測のばらつき(分散)
右の「ばらつき」は正解ラベル無しで test 行から測れる(A_test)。だから CV(OOF、1モデルの予測)を 補正CV = √(CV² − A_test) に直せば「提出の形」で候補を比べられる。Day171 は HistGB(乱数なし)だけで、真の最良に当たった世界が 1/16 → 8/16、損 2.34 → 0.92 に下がった。
今日の疑問。 LightGBM で bagging_fraction(行の抜き出し)や feature_fraction(列の抜き出し)を使うと、同じデータでも乱数(model seed)を変えるだけで予測が変わる。すると fold 間の分散には2種類が混ざる。
| 混ざるもの | 意味 | 例え |
|---|---|---|
| データの揺れ | fold ごとに学習データが違うことによるばらつき | 5人の射手が別々の練習場で練習した差 |
| seed の揺れ | 同じデータでも乱数で変わるばらつき | 同じ射手でも毎回の手ぶれ |
恒等式そのものは「5本の予測が何でも」成り立つので、補正の式は壊れないはず。ただ、(1) OOF は1モデルなので seed の揺れを全部かぶり、提出は5本平均なので 1/5 しかかぶらない、(2) seed を変えるたびに補正CV の値が動き、選ぶ候補が入れ替わるかもしれない。今日はこの2つを測る。
用語整理(Day171 と同じものは省略):
| 用語 | 意味 |
|---|---|
| model seed 組 s0/s1/s2 | 候補ごとに random_state = 1000×s + fold番号 で学習した3組。乱数なしの候補は3組とも同じ値 |
| avg3 | 3組の seed を fold ごとに平均した提出(15モデル)。OOF も3 seed 平均 |
| seed の割合 | fold 間分散 A_test のうち seed の揺れが占める割合(seed 間分散 σ²_seed × 0.8 ÷ A_test。0.8 は5本の母分散の係数 4/5) |
| 世界ペア差 | 同じワールドで基準候補 lgb_det(乱数なし LightGBM)との差を取り、16ワールドで平均と SE を出したもの |
| 損(regT) | 選んだ候補の T5 − 候補中の T5 最良(×10⁻⁴、Day170・171 と同じ) |
縮小シミュレータは Day163〜171 と同じ(train 3万・元1.5万・test 2万・truth 6万、乱数 3000+ワールド、16ワールド)。数値は仮定の上の実測。
データ・保存物の構造(スキーマ)
| 名前 | 形・型 | 意味・作り方 | 使う場所 |
|---|---|---|---|
| Sex … Body_Temp, Dur_x_HR, Keytel_kcal | int / float(9列) | Day163〜171 と同じ合成(乱数 3000+ワールド)。決定6 の9列 | 全候補 |
| oof | (11, 3, 30000) float64 | (候補, model seed, 行)。分割seed 0 の5分割の OOF(1モデルの予測)→ C | CV・補正CV |
| evk | (11, 3, 5, 80000) float32 | (候補, seed, fold, 行)。平均しない予測。先頭2万行=test、残り6万行=truth | T1・T5・A_test・σ²_seed |
| A_test | (候補,) | test 行で fold 間の予測の分散(ラベル不要) | 補正CV = √(C² − A_test) |
| σ²_seed | (候補,) | test 行で fold ごとに seed 間の分散(ddof=1)を取り平均(ラベル不要) | seed の割合・3 seed 平均の得 2σ²/15 |
| Calories(目的変数) | float | log1p 空間で学習、RMSLE で評価 | — |
評価指標(RMSLE)と、fold 間分散の中身
RMSLE は log1p 空間の RMSE。二乗誤差なので「5本平均の誤差² = 1本ずつの誤差²の平均 − fold 間分散」が成り立つ。今日はその fold 間分散が、乱数ありの LightGBM でどれだけ seed の揺れで占められるかを測った。
❓ 問題
タスクA(理論)— コードを書く前に
- 恒等式
mean_k (f_k − y)² = (f̄ − y)² + mean_k (f_k − f̄)²は、f_k に seed の揺れが入っても成り立つか。成り立つなら、補正CV の「平均の偏り」は消えるはずか - 3 seed 平均(avg3)にしたときの真の MSE の得を、ラベル無しで予測する式を書け(seed 間分散 σ²_seed を使う)
- seed の揺れがあると、補正CV の「選ぶ力」に何が起きると予想するか
タスクB(予想)
- fold 間分散のうち seed の揺れの割合は、bagging 0.8 でどれくらい、
extra_treesでどれくらいか - 生の CV の偏り(
lgb_detとの差)は最大どれくらいで、補正後はどこまで消えるか - 補正CV最良と生の CV最良・公開LB最良(公開20%)の損の差は、Day171(−1.42・−1.18)と比べてどうなるか
タスクC(コーディング)
- Day171 と同じ16ワールドで、HistGB 既定値(Day171 との一致確認用)・
lgb_det・乱数ありの LightGBM 9本を、model seed 3通り × 5 fold で作り、fold 別・seed 別の予測を平均せずに保存する(day172.py) - 恒等式の確認、seed の割合、補正後の偏り、seed 組を変えたときの揺れ、方針の損、3 seed 平均の得を出す(
analyze172.py)
タスクD(決定)
決定13 を「乱数ありのモデル」に広げてよいかを決め、必要なら新しい決定を足す。
💡 ヒント
ヒント1(方向性)
- 恒等式は f_k が「何であっても」成り立つ代数の式。seed の揺れは f_k の中身を変えるだけ
- ただし「平均の偏りが消える」ことと「1回ごとの値がぶれない」ことは別の話
ヒント2(アプローチ)
- 5 fold 平均の中の seed の揺れは σ²_seed/5、3 seed × 5 fold 平均なら σ²_seed/15。差が得になる
- 選ぶ力を測るには、seed 組だけを変えて同じワールドで比べる(世界内 SD)
- 「乱数なしの基準を常に出す」も方針の1つとして並べる。 選ぶこと自体が損を生む場合がある
ヒント3(コード骨格)
evk = np.zeros((n_cand, 3, 5, N_TEST + N_TRUTH), np.float32) # (候補, seed, fold, 行) evk[j, si, k] = lgb.LGBMRegressor(**params, random_state=1000*s + k).fit(X, y).predict(XE) sig_s = evk[:, :, :, :N_TEST].var(axis=1, ddof=1).mean(axis=(1, 2)) # seed 間分散(ラベル無し) A_te = evk[:, s, :, :N_TEST].var(axis=1).mean(1) # fold 間分散(seed 組 s) seed_share = 0.8 * sig_s / A_te gain_avg3_pred = 2 * sig_s / 15 # 1/5 − 1/15
✅ 模範解答
タスクA(理論の模範)
- 成り立つ。 恒等式は5つの数とその平均についての代数の式で、数がどう作られたかを問わない。だから
T1² − T5² = A(truth 行)は seed ありでも正確に成り立ち、test 行で測った A_test が truth の A と同じ分布なら、補正CV の平均の偏りは消える - 得 = 2σ²_seed / 15。 5 fold 平均の中に残る seed の分散は σ²_seed/5、3 seed × 5 fold では σ²_seed/15。差は σ²_seed × (1/5 − 1/15) = 2σ²_seed/15(MSE 単位)。σ²_seed は test 行で seed を変えた予測の分散なのでラベル不要
- 選ぶ力は落ちる。 OOF(1モデル)は seed の揺れをまるごとかぶり、補正はその平均を引くだけで「今回の seed の当たり外れ」は消せない。だから補正CV は偏りは無くても、1回ごとの値がぶれ、候補の差が小さいと入れ替わる
タスクB(予想の模範と、実測との照合)
| # | 予想の模範 | 実測 |
|---|---|---|
| 1 | bagging 0.8 で2〜3割、extra_trees で5割 | 予想より大きい。 bagging 0.8 で 0.46、列抽出 0.5 で 0.53、bagging 0.3+列0.5 で 0.73、extra_trees は 0.95(fold 間分散のほぼ全部が seed の揺れ) |
| 2 | 生の偏りは最大 +10 前後、補正で ±1 以内に消える | 当たり。 生の CV の偏り(lgb_det との差)は最大 bag03_ff05 +12.30±0.95、補正後 +0.09±0.93。補正後の最大は bag05 +1.27±0.77(1.6SE)。11候補とも 2SE 未満 |
| 3 | Day171 と同じく補正CV が大きく勝つ | 外れ。 補正CV最良 − 生の CV最良は s0 −0.18±0.25、s1 +0.49±0.86、s2 +0.07±0.28(差なし)。公開LB最良には公開20%で −1.35±0.29(s0)と勝つ。ただし乱数なしの lgb_det を常に出す方針(0.24〜0.58)がどの選び方よりも損が小さかった |
3が外れた理由(今日いちばん大事なこと): 今日の候補では、乱数ありの9本はどれも真の点数で lgb_det より悪かった(T5 の差 +1.11〜+15.25、avg3 にしても bag08 が −0.08 で並ぶだけ)。Day171 は複雑な設定(leaf63)が本当に良く、CV がそれを過小評価していたので、補正が「選ぶ力」に直結した。今日は本当に良い候補が無いので、偏りを消しても選べる上積みが無く、残るのは選ぶときのノイズだけだった。
タスクC(完全なコード)
day172.py(Day171 の day171.py から、候補を LightGBM に替え、model seed の次元を足した。HistGB 既定値は Day171 との一致確認のために残した):
"""Day172: Day171 と同じワールドで、LightGBM の bagging・列抽出(model seed の揺れがある候補)を model seed 3通り × 5 fold で作り、fold 別の予測を平均せずに保存する。 使い方: OMP_NUM_THREADS=1 python day172.py <world> -> raw172_w<world>.npz """ import sys, time, hashlib import numpy as np, pandas as pd import lightgbm as lgb from sklearn.ensemble import HistGradientBoostingRegressor 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 SEEDS = (0, 1, 2) 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-171 と同じ 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-171 と同じ 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 LG = dict(objective="regression", learning_rate=0.1, n_estimators=300, num_leaves=31, min_child_samples=20, n_jobs=1, verbose=-1) # 決定7の既定値を LightGBM の名前に写したもの B7 = dict(subsample=0.7, subsample_freq=1, colsample_bytree=0.7) CANDS = [("lgb_det", {}), # 乱数なし(bagging も列抽出もなし) ("bag08", dict(subsample=0.8, subsample_freq=1)), ("bag05", dict(subsample=0.5, subsample_freq=1)), ("ff05", dict(colsample_bytree=0.5)), ("bag05_ff05", dict(subsample=0.5, subsample_freq=1, colsample_bytree=0.5)), ("bag03_ff05", dict(subsample=0.3, subsample_freq=1, colsample_bytree=0.5)), ("leaf63_b7", dict(num_leaves=63, **B7)), ("leaf15_b7", dict(num_leaves=15, **B7)), ("lr05_it600_b7", dict(learning_rate=0.05, n_estimators=600, **B7)), ("et", dict(extra_trees=True))] names = ["hgb_default"] + [c[0] for c in CANDS] 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 XE = evalset[BASE].values oof = np.zeros((len(names), len(SEEDS), N_TRAIN)) evk = np.zeros((len(names), len(SEEDS), 5, N_TEST + N_TRUTH), np.float32) # (候補, model seed, 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) X, y, XV = tr_df[BASE].values, np.log1p(tr_df.Calories.values), train.iloc[va][BASE].values m = HistGradientBoostingRegressor(early_stopping=False, random_state=0, learning_rate=0.1, max_iter=300, max_leaf_nodes=31, min_samples_leaf=20).fit(tr_df[BASE], y) p_va, p_ev = m.predict(train.iloc[va][BASE]), m.predict(evalset[BASE]) for si in range(len(SEEDS)): oof[0, si, va] = p_va; evk[0, si, k] = p_ev # 乱数なし: 3 seed とも同じ値 for j, (nm, over) in enumerate(CANDS, start=1): for si, s in enumerate(SEEDS): m = lgb.LGBMRegressor(**{**LG, **over}, random_state=1000 * s + k).fit(X, y) oof[j, si, va] = m.predict(XV); evk[j, si, k] = m.predict(XE) print(f"world {W} fold {k} {time.time()-T0:.0f}s", flush=True) assert names[0] == "hgb_default" and names.index("lgb_det") == 1 # 決定9 追記: 名前と行の対応を表明 h = hashlib.sha256(oof[names.index("lgb_det"), 0].tobytes()).hexdigest()[:16] np.savez_compressed(f"raw172_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 sha_lgbdet_s0 {h}")
analyze172.py(恒等式・seed の割合・偏り・揺れ・方針の損・選んだ候補):
"""Day172 集計: raw172_w*.npz(候補11 × model seed 3 × fold 5 の予測)から (1) 恒等式の確認(A_test・A_truth・T1−T5)を model seed ありの候補で (2) fold 間分散のうち model seed の揺れが占める割合 (3) 補正CV(決定13)の偏りが seed ありでも消えるか(lgb_det との差、世界ペア) (4) 方針の損(公開 5%/20%、再分割500回)を seed 組ごと・3 seed 平均で (5) seed 組を変えたとき、補正CV の選ぶ候補と真の最良がどれだけ入れ替わるか 使い方: python analyze172.py """ import glob import numpy as np, pandas as pd pd.set_option("display.width", 250) N_TEST, R = 20000, 500 files = sorted(glob.glob("raw172_w*.npz"), key=lambda f: int(f.split("_w")[1].split(".")[0])) def sle(y, lp): return (np.log1p(np.clip(np.expm1(lp), 1, None)) - np.log1p(y)) ** 2 dec, pol, picks = [], [], [] 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"]) assert names[1] == "lgb_det" yte, ytru = yev[:N_TEST], yev[N_TEST:] sig_s = EVK[:, :, :, :N_TEST].var(axis=1, ddof=1).mean(axis=(1, 2)) # seed 間分散(fold・test 行で平均) settings = {f"s{s}": (OOF[:, s], EVK[:, s]) for s in range(3)} settings["avg3"] = (OOF.mean(1), EVK.mean(1)) # 3 seed 平均(提出は 15 モデル) for st, (oof, evk) in settings.items(): ev5 = evk.mean(1) 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_te = evk[:, :, :N_TEST].var(axis=1).mean(1); A_tru = evk[:, :, N_TEST:].var(axis=1).mean(1) C, T5, T1, Ca = np.sqrt(C_mse), np.sqrt(T5_mse), np.sqrt(T1_mse), np.sqrt(C_mse - A_te) for j, n in enumerate(names): dec.append(dict(world=w, st=st, cand=n, C=C[j], T5=T5[j], Ca=Ca[j], C_T5=(C[j] - T5[j]) * 1e4, T1_T5=(T1[j] - T5[j]) * 1e4, Cadj_T5=(Ca[j] - T5[j]) * 1e4, A_te=A_te[j] * 1e4, A_tru=A_tru[j] * 1e4, gap_mse=(T1_mse[j] - T5_mse[j]) * 1e4, seed_share=0.8 * sig_s[j] / A_te[j] if st != "avg3" else 0.8 * sig_s[j] / 3 / A_te[j])) best = T5.min(); picks.append(dict(world=w, st=st, true=names[T5.argmin()], cv=names[C.argmin()], cadj=names[Ca.argmin()])) E_te = np.stack([sle(yte, ev5[j, :N_TEST]) for j in range(len(names))]) rng = np.random.default_rng(w) for frac in (0.05, 0.20): 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 @ M; U = np.sqrt(S_pub / n_pub) P = np.sqrt((E_te.sum(1, keepdims=True) - S_pub) / (N_TEST - n_pub)) pub = U.argmin(0); cv = int(C.argmin()); cva = int(Ca.argmin()) def two(a): t = np.stack([np.full(R, 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)] for nm, ch in {"CV最良": np.full(R, cv), "補正CV最良": np.full(R, cva), "公開LB最良": pub, "2枠: 補正CV最良+公開最良": two(cva), "一様ランダム": None}.items(): reg = (T5.mean() - best) * 1e4 if ch is None else float(((T5[ch] - best) * 1e4).mean()) pol.append(dict(world=w, st=st, frac=frac, policy=nm, regT=reg)) D = pd.DataFrame(dec); PR = pd.DataFrame(pol); PK = pd.DataFrame(picks) print(f"worlds={len(files)}") print("\n=== 表1 恒等式と seed の割合(s0、16世界平均、×1e-4。seed_share は fold 間分散のうち model seed の割合) ===") print(D[D.st == "s0"].groupby("cand", sort=False)[["C_T5", "T1_T5", "Cadj_T5", "A_tru", "gap_mse", "A_te", "seed_share"]].mean().round(2).to_string()) print("\n=== 表2 lgb_det との差(世界ペア n=16、seed 組 s0/s1/s2 を世界内で平均してから、平均±SE、×1e-4) ===") rows = [] for col in ("C_T5", "T1_T5", "Cadj_T5"): X = D[D.st != "avg3"].groupby(["world", "cand"])[col].mean().unstack(); X = X.sub(X["lgb_det"], axis=0) for c in X.columns: rows.append(dict(cand=c, col=col, v=f"{X[c].mean():+.2f}±{X[c].std(ddof=1)/np.sqrt(len(X)):.2f}")) print(pd.DataFrame(rows).pivot(index="cand", columns="col", values="v").to_string()) for st in ("s0", "avg3"): print(f"候補間SD({st}): C_T5 {D[D.st==st].groupby('world').C_T5.std(ddof=1).mean():.2f} Cadj_T5 {D[D.st==st].groupby('world').Cadj_T5.std(ddof=1).mean():.2f}") # seed 組だけを変えたときの Cadj_T5 の揺れ(世界内 SD)と、候補間の真の差の大きさ sd_seed = D[D.st.isin(["s0", "s1", "s2"])].groupby(["world", "cand"]).Cadj_T5.std(ddof=1).groupby("cand").mean() sd_T5 = D[D.st.isin(["s0", "s1", "s2"])].groupby(["world", "cand"]).T5.std(ddof=1).groupby("cand").mean() * 1e4 print("\n=== 表3 seed 組を変えたときの揺れ(世界内 SD の世界平均、×1e-4) ===") print(pd.DataFrame(dict(Cadj_T5_seedSD=sd_seed, T5_seedSD=sd_T5)).round(2).to_string()) print("\n=== 表4 方針の損(regT, ×1e-4, 16世界平均±SE) ===") g = PR.groupby(["frac", "st", "policy"]).regT.agg(lambda s: f"{s.mean():.2f}±{s.std(ddof=1)/np.sqrt(len(s)):.2f}").unstack("st") print(g.to_string()) for fr in (0.05, 0.2): for st in ("s0", "s1", "s2", "avg3"): a = PR[(PR.frac == fr) & (PR.st == st)].pivot(index="world", columns="policy", values="regT") out = [] for x, y in [("補正CV最良", "CV最良"), ("補正CV最良", "公開LB最良"), ("2枠: 補正CV最良+公開最良", "補正CV最良")]: d = a[x] - a[y]; out.append(f"{x}−{y} {d.mean():+.2f}±{d.std(ddof=1)/np.sqrt(len(d)):.2f}") print(f"公開{int(fr*100):>2}% {st}: " + " | ".join(out)) print("\n=== 表5 選ばれた候補と真の最良(16世界) ===") for st in ("s0", "s1", "s2", "avg3"): p = PK[PK.st == st] print(f"{st}: hit CV {int((p.cv==p.true).sum())}/16 hit 補正CV {int((p.cadj==p.true).sum())}/16 真の最良 {p.true.value_counts().to_dict()} 補正CV最良 {p.cadj.value_counts().to_dict()}") q = PK[PK.st.isin(["s0", "s1", "s2"])].groupby("world").agg(n_true=("true", "nunique"), n_cadj=("cadj", "nunique")) print("seed 組 3つで、真の最良が変わった世界:", int((q.n_true > 1).sum()), "/16 補正CV最良が変わった世界:", int((q.n_cadj > 1).sum()), "/16") # seed 平均の得(同じ候補で s0 単独 → avg3) g5 = D.pivot_table(index=["world", "cand"], columns="st", values="T5") d = (g5["avg3"] - g5[["s0", "s1", "s2"]].mean(1)) * 1e4 print("\n=== 表6 3 seed 平均にした真の得(T5_avg3 − T5_単独 の平均、×1e-4、世界平均±SE) ===") print(d.groupby("cand").agg(lambda s: f"{s.mean():+.2f}±{s.std(ddof=1)/np.sqrt(len(s)):.2f}").to_string()) D.to_pickle("dec172.pkl"); PR.to_pickle("pol172.pkl"); PK.to_pickle("pk172.pkl")
追加の集計(3 seed 平均の得の予測と、採用に余裕を求める「ガード」)は analyze172.py が保存した dec172.pkl と raw172_w*.npz から作った:
"""Day172 追加集計: (1) 3 seed 平均の得をラベル無しで予測(2σ²_seed/15)して実測と比べる (2) 補正CV最良を採るのに lgb_det より m×1e-4 以上の余裕を求める「ガード」の損 使い方: python extra172.py(analyze172.py の後) """ import glob import numpy as np, pandas as pd N = 20000 def sle(y, lp): return (np.log1p(np.clip(np.expm1(lp), 1, None)) - np.log1p(y)) ** 2 rows = [] for f in sorted(glob.glob("raw172_w*.npz")): z = np.load(f); E = z["evk"].astype(np.float64); names = list(z["names"]); ytru = z["yev"][N:] for j, n in enumerate(names): if n in ("hgb_default", "lgb_det"): continue sig = E[j, :, :, :N].var(axis=0, ddof=1).mean() # test 行の seed 間分散(ラベル無し) t1 = np.mean([sle(ytru, E[j, s].mean(0)[N:]).mean() for s in range(3)]) t3 = sle(ytru, E[j].mean((0, 1))[N:]).mean() rows.append(dict(cand=n, pred=2 * sig / 15 * 1e4, actual=(t1 - t3) * 1e4)) print(pd.DataFrame(rows).groupby("cand").mean().round(3).to_string()) D = pd.read_pickle("dec172.pkl"); out = [] for st in ["s0", "s1", "s2", "avg3"]: for m in [0, 0.5, 1, 2, 3, "常にlgb_det"]: r = [] for w, h in D[D.st == st].groupby("world"): h = h.set_index("cand"); b = h.Ca.idxmin() pick = "lgb_det" if m == "常にlgb_det" else (b if h.Ca[b] < h.Ca["lgb_det"] - m * 1e-4 else "lgb_det") r.append((h.T5[pick] - h.T5.min()) * 1e4) out.append(dict(st=st, m=m, v=f"{np.mean(r):.2f}±{np.std(r, ddof=1) / 4:.2f}")) print(pd.DataFrame(out).pivot(index="m", columns="st", values="v").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・lightgbm 4.7.0(今日の新しい uv venv)
# LightGBM の macOS 用 wheel は libomp を同梱していないため、scikit-learn 同梱の libomp を DYLD_LIBRARY_PATH で読ませた(brew は使っていない)
# fit 数 = 16ワールド × 5 fold × (HistGB 1 + LightGBM 10本 × seed 3) = 2,480。OMP_NUM_THREADS=1・n_jobs=1
# 壁時計: ワールド0 単独 318秒、ワールド1〜15 を4並行で 1,275秒(1ワールド 282〜355秒)
# 再現確認: hgb_default は Day171 の default と 16ワールドとも完全一致(fold 別予測・OOF とも最大差 0.0)
# lgb_det(乱数なし)は seed 0 と seed 2 で完全一致(最大差 0.0)。乱数ありの9本は seed で最大 0.22〜0.49 変わる(log1p 空間、ワールド0)
worlds=16
=== 表1 恒等式と seed の割合(s0、16世界平均、×1e-4。seed_share は fold 間分散のうち model seed の割合) ===
C_T5 T1_T5 Cadj_T5 A_tru gap_mse A_te seed_share
cand
hgb_default 11.08 12.73 -1.67 3.99 3.99 3.99 0.00
lgb_det 10.83 12.61 -1.80 3.95 3.95 3.96 0.00
bag08 12.76 14.23 -1.61 4.47 4.47 4.51 0.46
bag05 19.18 18.68 0.47 5.89 5.89 5.90 0.57
ff05 10.95 12.97 -2.06 4.08 4.08 4.09 0.53
bag05_ff05 17.17 19.70 -2.49 6.23 6.23 6.21 0.64
bag03_ff05 23.63 25.23 -1.59 8.02 8.02 8.01 0.73
leaf63_b7 21.97 22.73 -0.78 7.17 7.17 7.17 0.52
leaf15_b7 10.60 11.72 -1.18 3.70 3.70 3.71 0.59
lr05_it600_b7 10.11 11.40 -1.24 3.58 3.58 3.56 0.38
et 19.80 21.51 -1.73 6.81 6.81 6.81 0.95
=== 表2 lgb_det との差(世界ペア n=16、seed 組 s0/s1/s2 を世界内で平均してから、平均±SE、×1e-4) ===
col C_T5 Cadj_T5 T1_T5
cand
bag03_ff05 +12.30±0.95 +0.09±0.93 +12.16±0.08
bag05 +7.29±0.76 +1.27±0.77 +6.03±0.07
bag05_ff05 +7.20±0.76 +0.51±0.73 +6.71±0.07
bag08 +1.93±0.80 +0.25±0.78 +1.65±0.06
et +9.42±1.58 +0.35±1.48 +9.06±0.18
ff05 +0.09±0.69 +0.04±0.67 +0.05±0.08
hgb_default +0.25±0.79 +0.14±0.75 +0.12±0.07
leaf15_b7 -0.89±0.85 +0.45±0.82 -1.38±0.07
leaf63_b7 +10.72±1.05 +0.99±1.02 +9.73±0.09
lgb_det +0.00±0.00 +0.00±0.00 +0.00±0.00
lr05_it600_b7 -1.36±0.80 +0.04±0.79 -1.36±0.06
候補間SD(s0): C_T5 5.88 Cadj_T5 2.76
候補間SD(avg3): C_T5 3.58 Cadj_T5 2.43
=== 表3 seed 組を変えたときの揺れ(世界内 SD の世界平均、×1e-4) ===
Cadj_T5_seedSD T5_seedSD
cand
bag03_ff05 2.28 0.84
bag05 2.31 0.82
bag05_ff05 2.06 0.98
bag08 1.32 0.46
et 1.98 1.11
ff05 1.67 0.63
hgb_default 0.00 0.00
leaf15_b7 1.36 0.49
leaf63_b7 2.11 0.66
lgb_det 0.00 0.00
lr05_it600_b7 1.47 0.53
=== 表4 方針の損(regT, ×1e-4, 16世界平均±SE) ===
st avg3 s0 s1 s2
frac policy
0.05 2枠: 補正CV最良+公開最良 1.27±0.32 0.82±0.21 1.02±0.29 0.68±0.22
CV最良 2.65±0.87 0.93±0.32 1.00±0.38 0.52±0.21
一様ランダム 5.32±0.17 6.56±0.18 6.74±0.22 6.27±0.22
公開LB最良 3.55±0.21 4.47±0.22 4.49±0.23 4.17±0.23
補正CV最良 1.95±0.88 0.75±0.22 1.49±0.74 0.58±0.21
0.20 2枠: 補正CV最良+公開最良 0.86±0.19 0.64±0.18 0.73±0.19 0.63±0.24
CV最良 2.65±0.87 0.93±0.32 1.00±0.38 0.52±0.21
一様ランダム 5.32±0.17 6.56±0.18 6.74±0.22 6.27±0.22
公開LB最良 1.99±0.24 2.10±0.26 2.01±0.28 2.03±0.28
補正CV最良 1.95±0.88 0.75±0.22 1.49±0.74 0.58±0.21
公開 5% s0: 補正CV最良−CV最良 -0.18±0.25 | 補正CV最良−公開LB最良 -3.72±0.22 | 2枠: 補正CV最良+公開最良−補正CV最良 +0.07±0.17
公開 5% s1: 補正CV最良−CV最良 +0.49±0.86 | 補正CV最良−公開LB最良 -3.00±0.78 | 2枠: 補正CV最良+公開最良−補正CV最良 -0.47±0.53
公開 5% s2: 補正CV最良−CV最良 +0.07±0.28 | 補正CV最良−公開LB最良 -3.58±0.21 | 2枠: 補正CV最良+公開最良−補正CV最良 +0.10±0.13
公開 5% avg3: 補正CV最良−CV最良 -0.70±0.37 | 補正CV最良−公開LB最良 -1.60±0.87 | 2枠: 補正CV最良+公開最良−補正CV最良 -0.68±0.61
公開20% s0: 補正CV最良−CV最良 -0.18±0.25 | 補正CV最良−公開LB最良 -1.35±0.29 | 2枠: 補正CV最良+公開最良−補正CV最良 -0.11±0.16
公開20% s1: 補正CV最良−CV最良 +0.49±0.86 | 補正CV最良−公開LB最良 -0.52±0.83 | 2枠: 補正CV最良+公開最良−補正CV最良 -0.76±0.71
公開20% s2: 補正CV最良−CV最良 +0.07±0.28 | 補正CV最良−公開LB最良 -1.45±0.27 | 2枠: 補正CV最良+公開最良−補正CV最良 +0.05±0.20
公開20% avg3: 補正CV最良−CV最良 -0.70±0.37 | 補正CV最良−公開LB最良 -0.04±0.95 | 2枠: 補正CV最良+公開最良−補正CV最良 -1.09±0.80
=== 表5 選ばれた候補と真の最良(16世界) ===
s0: hit CV 5/16 hit 補正CV 5/16 真の最良 {np.str_('lgb_det'): 8, np.str_('hgb_default'): 6, np.str_('bag08'): 2} 補正CV最良 {np.str_('lgb_det'): 8, np.str_('bag08'): 5, np.str_('hgb_default'): 3}
s1: hit CV 6/16 hit 補正CV 3/16 真の最良 {np.str_('lgb_det'): 7, np.str_('hgb_default'): 6, np.str_('bag08'): 3} 補正CV最良 {np.str_('lgb_det'): 8, np.str_('bag08'): 4, np.str_('hgb_default'): 3, np.str_('et'): 1}
s2: hit CV 7/16 hit 補正CV 6/16 真の最良 {np.str_('hgb_default'): 7, np.str_('lgb_det'): 6, np.str_('bag08'): 3} 補正CV最良 {np.str_('lgb_det'): 7, np.str_('bag08'): 5, np.str_('hgb_default'): 4}
avg3: hit CV 4/16 hit 補正CV 5/16 真の最良 {np.str_('bag08'): 7, np.str_('hgb_default'): 5, np.str_('lgb_det'): 4} 補正CV最良 {np.str_('lgb_det'): 6, np.str_('bag08'): 5, np.str_('hgb_default'): 3, np.str_('et'): 2}
seed 組 3つで、真の最良が変わった世界: 5 /16 補正CV最良が変わった世界: 4 /16
=== 表6 3 seed 平均にした真の得(T5_avg3 − T5_単独 の平均、×1e-4、世界平均±SE) ===
cand
bag03_ff05 -3.02±0.02
bag05 -1.76±0.01
bag05_ff05 -2.08±0.01
bag08 -1.10±0.01
et -3.40±0.04
ff05 -1.12±0.01
hgb_default +0.00±0.00
leaf15_b7 -1.13±0.01
leaf63_b7 -1.96±0.01
lgb_det +0.00±0.00
lr05_it600_b7 -0.71±0.00
=== 追加1 3 seed 平均の MSE の得(×1e-4): 予測 2σ²_seed/15(test 行、ラベル無し) vs 実測(truth 行) ===
pred actual
cand
bag03_ff05 0.969 0.950
bag05 0.556 0.550
bag05_ff05 0.661 0.654
bag08 0.342 0.343
et 1.072 1.069
ff05 0.364 0.350
leaf15_b7 0.365 0.356
leaf63_b7 0.621 0.614
lr05_it600_b7 0.227 0.222
=== 追加2 ガード: 補正CV最良が lgb_det より m×1e-4 以上良いときだけ採用、そうでなければ lgb_det(16世界平均±SE, ×1e-4) ===
st avg3 s0 s1 s2
m
0 1.95±0.88 0.75±0.22 1.49±0.74 0.58±0.21
0.5 1.95±0.88 0.69±0.22 1.49±0.74 0.59±0.21
1 1.83±0.89 0.69±0.22 0.66±0.15 0.40±0.14
2 1.85±0.89 0.57±0.22 0.45±0.13 0.42±0.14
3 0.64±0.18 0.57±0.22 0.37±0.12 0.29±0.10
常にlgb_det 0.58±0.15 0.33±0.14 0.32±0.10 0.24±0.09
読み方:
- 表1 の
A_tru・gap_mse・A_teが全候補で小数2桁まで一致(et6.81/6.81/6.81)。seed の揺れが fold 間分散の 38〜95% を占めても、恒等式もラベル無しの推定もそのまま使える - 表2 の右列(T1_T5=平均の得)は SE 0.06〜0.18 と非常に精密で、左列(生の偏り)をほぼ打ち消す。補正後(中央列)は11候補とも ±1.27 以内・2SE 未満
- 表3: seed 組を変えるだけで補正CV − 真のずれは世界内 SD 1.3〜2.3 動く。真の点数(5本平均)の seed による揺れ 0.5〜1.1 の約2倍。OOF は1モデルなので seed の揺れを 1/5 に薄められない
- 追加1: 3 seed 平均の得はラベル無しの予測と実測が 4% 以内で一致(
et1.072 対 1.069)。seed を増やすべきかは学習前の test 予測だけで判断できる - 追加2: 補正CV最良を採るのに「
lgb_detより 3×10⁻⁴ 以上良いこと」を求めると、avg3 の損が 1.95 → 0.64、s1 が 1.49 → 0.37 に下がる。それでも「常にlgb_det」(0.24〜0.58)には届かない
外れ値2件の正体: avg3 で補正CV が et を選んだ2世界(損 11.76・9.72)と、s1 で et を選んだ1世界(12.45)が、補正CV の損の平均をほぼ決めている(et を候補から外すと avg3 は 0.62、s1 は 0.71)。ワールド4 の et は補正CV − 真が 4つの seed 設定すべてで −11.0〜−11.5(lgb_det は +1.7)。seed を変えても動かないので、seed の揺れではない。 lgb_det との相対的なずれの世界間 SD は、他の候補 3.0〜4.4 に対し et 6.08。分割の閾値をランダムに引く extra_trees は、ワールド(データ)ごとの CV の当たり外れが他と違う向きに出る。
タスクD(決定)
(1) 決定13 の範囲を広げる: 乱数ありのモデルでも補正CV で比べる(偏りについては◎)。 16ワールドで11候補とも補正後の偏りが 2SE 未満、生の偏り最大 +12.30 を +0.09 にした。恒等式(表1)も seed ありで一致。
(2) 決定14(新規): 補正CV で基準を乗り換えるには余裕を求める。
| # | 決定 | 採った案 | 採らなかった案と理由 | 前提が変わったら |
|---|---|---|---|---|
| 14 | 基準からの乗り換え | 乱数なしの基準(今は lgb_det か HistGB 既定値)より補正CV が 3×10⁻⁴ 以上良い候補だけを1本目に採る。届かなければ基準を1本目に出し、補正CV最良は2本目の候補にする。乱数ありの候補は seed を2組以上回し、seed による補正CV の世界内 SD(今日 1.3〜2.3)を報告に添える | (a) 補正CV最良をそのまま採る(今日は外れ値3件で損 0.58〜1.95、et に引っ張られる)。(b) 常に基準を出す(今日は最小の損 0.24〜0.58 だが、本当に良い候補が来たとき拾えない。Day171 では事前固定の損 3.00 対 補正CV 0.92 で逆の結果)。(c) 余裕を世界ごとの SE から決める(ワールドは実戦で1つしかなく、その SE は測れない)。(d) seed を増やして OOF の揺れを減らす(avg3 でも et の外れは消えなかった。seed ではなくデータ側のずれだったため) | 本当に良い乱数ありの候補(基準より 3 以上良い)が多い問題 → 余裕 3 は良い候補を見逃す。今日はその場合を作っていないので、余裕の大きさは仮置き(印△)。基準と別の系統(extra_trees・NN など)が入る → 系統ごとのずれの SD を先に測る |
(3) 3 seed 平均は「得の予測」が当たる。 2σ²_seed/15 で学習前に分かるので、seed 平均を足すかはこの予測と計算時間で決める(今日 bag08 で 0.34×10⁻⁴、3倍の時間)。
(4) 設計書の印の更新: 決定13 は○→◎(seed ありでも偏りが消えることを確認、2つ目の条件)。決定12(補正CV最良+公開LB最良)は、決定14 で1本目が基準になる場合があるので「1本目=決定14 の結果、2本目=公開LB最良(同じなら補正CV最良)」に読み替える。2本目の得(今日 公開20%で +0.05〜−1.09、どれも 2SE 未満)は測れていない。
📊 結果の可視化
上: seed の揺れがあっても、生の CV の偏り(最大 +12.30)は補正でほぼ消える。中: それでも選ぶ方針はどれも「常に lgb_det」(0.24〜0.58)に負けた。今日の候補に本当に良いものが無く、選ぶノイズだけが残ったため。下: 3 seed 平均の得はラベル無しで予測でき、実測と一致する。
🪜 Step-by-Step 解説
Step 1: 理論で「壊れないもの」と「壊れうるもの」を分ける
恒等式は代数なので壊れない。壊れうるのは「1回ごとの値のぶれ」。前者は表1で、後者は表3で測る、と先に決めておく。
Step 2: Day171 と同じワールドを作り、つながりを確かめる
HistGB 既定値を候補に残し、Day171 の fold 別予測と 16ワールドとも完全一致(0.0)を確認した。これで今日の LightGBM の数字を Day171 と同じ土俵で比べられる。lgb_det は seed を変えても完全一致し、「乱数なし」の対照として使える。
Step 3: seed の割合を測る(表1)
seed を3通り回すと、fold の中で seed だけを変えた分散 σ²_seed が出る。bag08 で fold 間分散の 46%、extra_trees で 95%。LightGBM の bagging を入れると、fold 間分散の半分前後は seed の揺れになる。
Step 4: 偏りが消えるかを見る(表2)
生の CV は乱数が多い候補ほど悪く見える(bag03_ff05 +12.30、et +9.42)。補正後は +0.09・+0.35。平均としては、決定13 は seed ありでも正しい。
Step 5: 1回ごとのぶれを見る(表3)
seed 組を変えるだけで補正CV − 真は 1.3〜2.3 動き、真の点数の揺れ(0.5〜1.1)の約2倍。理由は、OOF が1モデルで seed の揺れを薄められないから。
Step 6: 方針で比べる(表4・表5)
補正CV最良と生の CV最良は差なし。公開LB最良には公開20%で勝つ(s0 −1.35±0.29)。しかし lgb_det を常に出すと損 0.24〜0.58 で、どの選び方より小さい。真の最良は lgb_det・HistGB・bag08 の3つしか出てこない(表5)。
Step 7: 外れ値を調べる
補正CV の損を押し上げたのは et を選んだ3件。seed を変えても同じずれ(−11)が出るので、seed ではなくデータ側(ワールドごとの CV の当たり外れ)の問題。系統が違う候補は、ずれの世界間 SD が倍近い(6.08 対 3.0〜4.4)。
Step 8: ガードを試す(追加2)
「基準より 3 以上良いときだけ乗り換える」で外れ値3件が消え、損は 0.29〜0.64。余裕の大きさ 3 は今日のずれの SD(約3)に合わせた仮置き。本当に良い候補がある世界では損になりうるので、決定14 の印は△にした。
📐 数学・統計の補足(文系向け)
手ぶれは「平均の人数」で割れる。 射手の手ぶれ(seed の揺れ)の分散を σ² とすると、5人の矢の真ん中の手ぶれは σ²/5、15人なら σ²/15。手ぶれは的(正解)を見なくても、同じ射手に何度か射させて矢どうしの散らばりを見れば測れる。今日の「3 seed 平均の得の予測 2σ²/15」はこの引き算で、実測と 4% 以内で合った。
偏りが無い ≠ 当てになる。 体重計が平均では正しくても、乗るたびに ±2kg ぶれるなら、0.5kg の差は見分けられない。補正CV は「平均では正しい体重計」になったが、seed の手ぶれ(±1.3〜2.3)と、系統ごとのくせ(et で ±6)が残る。候補どうしの本当の差(今日は多くが 1 未満)より大きいと、比べるほど外れを引く。
だから余裕を求める。 「3 以上軽くなったら本当に痩せた」と決めておく考え方。余裕を大きくすると誤った乗り換えは減るが、本当の改善も見逃す。この交換条件は、本当に良い候補がどれくらいの頻度で来るかで決まる(今日は測っていない)。
🏆 Kaggleでの実践的な使い方
- LightGBM の bagging・列抽出を比べるときも、生の OOF は乱数の多い設定を不当に悪く見せる。 今日
bag03_ff05は +12.30×10⁻⁴ 悪く見えた。fold 別の test 予測を平均前に保存し、補正CV で比べる - seed 平均を足すか迷ったら、学習前に得を予測する。 seed を2つ回して test 予測の分散 σ² を取り、
2σ²/15(3 seed の場合)を CV の改善幅と比べる。上位解法の「seed averaging」を、計算時間に見合うかで判断できる - 系統の違うモデル(extra_trees、NN など)の CV は、同じ系統どうしより当てにならない。 1本目を乗り換えるのは余裕があるときだけにし、迷う候補は2本目か混ぜる側に回す
- 「何も選ばない」方針も比べる。 候補を増やすほど、本当の差が無い所で CV のノイズに選ばされる。今日は既定値を出すのが最良だった
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| 乱数ありのモデルでは fold 間分散の補正が使えない | seed の揺れは「データの差」ではないと思う | 恒等式は5本の予測が何でも成り立つ。今日も小数2桁で一致し、補正後の偏りは11候補とも 2SE 未満 |
| 偏りが消えたので補正CV最良を採れば良い | 偏り(平均のずれ)と、1回ごとのぶれを混同する | seed で 1.3〜2.3、系統によって ±6 ぶれる。本当の差が小さいと外れを引く(今日は既定値を常に出す方が良かった) |
| seed を増やせば CV の外れは消える | 外れの原因を seed だと決めつける | et の外れは seed を変えても同じ −11 だった。データ側(ワールドごと)のずれは seed では減らない |
| seed 平均の得はやってみないと分からない | 得は正解との比較だと思う | 2σ²_seed/15 で test 行から予測でき、実測と 4% 以内で一致 |
LightGBM で random_state を固定すれば seed の問題は無い | 再現性と乱数の影響を混同する | 固定すれば毎回同じ値になるだけで、「その seed の当たり外れ」は残る。比べるなら seed を2組以上回して揺れを測る |
🧭 扱わなかった範囲と理由
- 本当に良い乱数ありの候補: 今日の9本はどれも真に
lgb_detより悪かった(avg3 のbag08が −0.08 で並ぶだけ)。そのため「補正CV が乗り換えで得をする」場面を作れておらず、決定14 の余裕 3 は良い候補を見逃す側のコストを測っていない。次に測るべき最重要項目 - ワールドの作りが LightGBM 向きでない可能性: シミュレータは Keytel の式+小さな雑音で、木の深さや bagging の正則化が効きにくい。実データ(S5E5)で乱数ありの設定が勝つかは分からない
- 余裕の決め方: 3×10⁻⁴ は今日のずれの世界間 SD(約3)に合わせた仮置き。0/0.5/1/2/3 しか試しておらず、交差検証で決めたわけではない
- 公開50%・候補数 K: 公開は 5%・20% だけ。K は11本固定(Day171 の K 依存は出し直していない)
etのずれの原因: 「分割閾値をランダムに引く系統はワールドごとのずれが大きい」は観察で、なぜかは調べていない- seed による OOF の揺れの分離: OOF を3 seed 平均にした avg3 でも外れは残ったが、「OOF の seed の揺れ」と「test 側の seed の揺れ」を別々に測ってはいない
- 持ち越し(Day171 から):
noKeytel・raw7の補正後のずれ、補正CV と入れ子CV の比較、A_test の推定誤差、Day170 のハイブリッド方針・狭い10本の出し直し、SE 個数差と分割の新しさ(Day165)、Day162 の選択バイアス、真の効果ちょうど0の偽採用率、NB・Krogh & Vedelsby 原典(今日も記憶による。式は表1と追加1で数値的に確かめた)
🚀 次のステップ
- 発展: 真に基準より良い乱数ありの候補(例: 雑音を増やしたワールドで
bag05が効くように作る)を混ぜ、決定14 の余裕 0〜5 で「見逃しの損」と「外れの損」の釣り合う点を測る - 発展:
etのような別系統について、ワールドごとのずれの SD を系統別に測り、余裕を系統ごとに変えるべきかを調べる - 次回予告: Day173 — Playground Series 実戦投入⑧(決定14 の余裕を、本当に良い候補が含まれる候補群で測り直す)。月替わりテーマ3「画像(CV)系コンペへの応用」は 10/25 から
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: