BoatraceCSV

穴予想 v9_suji(スジ予想)— Step 0: スジ表の本番構成を決める

設計は docs/design/ana_prediction.md(§13 A案)。 本ディレクトリは、実装に入る前にスジ表の 学習窓 と 収縮強度 を決めるための検証記録。

結論

全履歴・収縮なし(python scripts/build_suji_table.py の既定)を本番構成に採用する。

項目 決定 根拠
学習窓 全履歴(2025-11-01〜) log-loss が窓長に対して単調改善し 4 ヶ月で飽和。valid / test で順序が完全一致
収縮 なし(k=0) k=50/200 は k=0 と小数 4 桁まで同一。k=1000 でも -0.0001 nat
場の次元 持たない(全場プール) 場別はどれだけ収縮してもプールに追いつかない。§場別スジ表
選定指標 P(2着,3着 \| 真の1着) の log-loss 回収率は分散が大きすぎて選定に使えない(下記)

本番構成の test 実測(A案・上位 5 点):

指標 値
回収率 80.6%(95%CI [68, 94])
的中率 10.25%
平均配当 3,931 円
点数 5.0
万舟的中 27 本
万舟 / 1 万円賭け 0.153

→ 設計書 §13.2 に載せていた 82.0% は「train 窓のみ・収縮なし」の暫定値だったので、 この 80.6% に差し替える。

移植ゲート(合格)

設計書 §13.2 の 82.0% は scratchpad の実装が出した値。本番コードパス (scripts/build_suji_table.py + notebooks/ana_prediction/)で同じ構成 (train 窓 2026-05-01〜06-24・収縮なし)を再現できることを先に確認した。

  scratchpad(設計書 §13.2) 本番コードパス 差
回収率 82.0% 82.5% +0.5pt
的中率 10.12% 10.16% +0.04pt
平均配当 4,052 円 4,056 円 +4 円
点数 5.0 5.0 —
万舟 29 本 29 本 —

差分の原因は既知で、previews/stt が取れないレースの扱い。scratchpad は そのレースを落としていたが、本番コードは直前バッチの既存規約に合わせて 枠なり(枠番 = コース)にフォールバックする。対象は 95 レース(0.7%)。 本番の挙動として正しいのはフォールバック側なので、この差は許容する。

なぜ回収率で選ばないか

窓長を変えたときの回収率:

窓 valid 回収率 test 回収率
1 ヶ月 77.9% 78.3%(最下位)
2 ヶ月 77.1% 83.6%
3 ヶ月 71.7% 84.1%
4 ヶ月 73.2% 80.0%
6 ヶ月 73.2% 82.9%
全履歴 71.3%(最下位) 80.6%

valid の最良(1 ヶ月)が test の最下位、valid の最下位(全履歴)が test では中位。 95%CI は ±12〜14pt あり、全構成が互いの CI に収まる。回収率で選ぶと valid のノイズに過学習する — 設計書 §13.3 で A案 vs B案 の判定に回収率を使えないと 結論したのと同じ理屈が、ここでも当てはまる。

選定指標: 条件付き log-loss

真の 1着コースを与えたときの P(2着, 3着 | 1着) の log-loss。 1着の選び方(強さpt)から独立に、スジ表そのものの質だけを測る。

構成 valid test
一様(20 通り) 2.9957 2.9957
1 ヶ月 2.8257 2.8386
2 ヶ月 2.8204 2.8311
3 ヶ月 2.8177 2.8287
4 ヶ月 2.8146 2.8275
6 ヶ月 2.8144 2.8275
全履歴 2.8144 2.8274
全履歴 + k=1000 2.8144 2.8273

飽和しているので、運用上は窓を切る理由が無い。パラメータを 1 つ減らせること、 build_course_rate.py も全履歴から再生成していることから、全履歴を採る。

場別スジ表は効かない

「イン天国と波乱場でスジは違うのでは」という仮説を検証した。 build_suji_table.py --by-stadium で 場別セルを 全場プール分布へ k で収縮した表 (course_win_rate.csv と同じ考え方)を作り、同じ条件付き log-loss で比較:

構成 valid test
全場プール 2.8144 2.8274
場別 k=0(収縮なし) 2.9981 3.0096
場別 k=20 2.8385 2.8490
場別 k=50 2.8299 2.8410
場別 k=150 2.8209 2.8330
場別 k=400 2.8162 2.8288
(参考) 一様分布 2.9957 2.9957

どの k でもプールに追いつかない。 k→∞ で場別はプールに収束するので、これは 「場の次元を足しても情報が増えず、ノイズだけ増える」ことを意味する。 収縮なしの場別は 一様分布より悪い(1 場 1,700 レース ÷ 6 コース ÷ 20 ペア ≒ 14 件/セルで過学習)。

理由は分解して見ると明快で、場差は「誰が 1着になるか」に出て、 「1着が決まった後の 2-3 着の並び」には出ない:

  戸田(波乱) 平和島(波乱) 福岡 徳山(イン天国) 大村(イン天国) 全場
1コースの1着率 42% 46% 59% 62% 62% —
P(2着=1c | 1着=3c) 29.8% 30.9% 33.2% 38.4% 45.0% 39.5%
 その n 245 178 193 185 151 4,382

1着率は 20pt も違うのに、条件付きの 2-3 着のばらつきは n=150〜245 の標本誤差 (二項の 2SE ≒ ±7pt)にほぼ収まる。3 コースがまくったらインが残りやすい、という ターンの物理は場によらない。

そして A案では 1着コースを 強さpt で選んでおり、強さpt の waku 成分は 場×季節×コース勝率、重みも場別に学習されている。つまり場の情報は すでにパイプラインの 1着選択側に入っていて、スジ表に重ねて持たせる必要がない。

コードは残す: build_suji_table.py --by-stadium と CSV の 場コード 列は 残してある(本番は全場プール = 場コード 00 のみ)。build_suji_picks.py は 場別行があればそちらを優先して読むので、データが増えて結論が変わったときは フラグを立てるだけで切り替わる。

再現方法

# 本番構成のテーブルを生成 (既定 = 全履歴・収縮なし)
python scripts/build_suji_table.py

# 構成を振って比較する (--from-date は以上、--to-date は未満)
python scripts/build_suji_table.py --from-date 2025-11-01 --to-date 2026-06-25 --k 0 \
  --suji-out /tmp/all_suji.csv --kimarite-out /tmp/all_kim.csv

# バックテスト (name=path を複数渡せる)
python notebooks/ana_prediction/suji_backtest.py --tables "全履歴=/tmp/all_suji.csv"
ファイル 内容
dataset.py results/realtime × previews/stt × results/payouts × estimate/v1_basic を突き合わせ、進入コース順のパネルを作る
suji_backtest.py A案の買い目のバックテストと条件付き log-loss
kimarite_backtest.py B案の γ / β / w の再選定(下記)

B案 v10_kimarite のハイパラ再選定(2026-08-13)

設計書 §4.3 の γ=1.0 / β=1.4 / w=0.8 は 28 クラス・実験窓(train 6,956 レース) で選んだ値。本番は 32 クラス・全履歴なので、同じ手続きで選び直した。 選定は valid (2026-06-25〜07-17, 3,696 レース) の 3連単 log-loss のみ。 test (2026-07-18〜, 3,614 レース) は報告にしか使っていない。

学習窓は train = 2025-11-01〜2026-06-24 の 34,222 レース(Stage1)/ 35,016 レース(Stage2)。強さpt は 2026-05-01 以降しか無いが、 Stage1 の学習は強さpt を要求しないので全履歴を使える(評価側だけ絞る)。

選定結果

  実験時(28 クラス) 本番(32 クラス)
k 1000 150
γ 1.0 0.5
β 1.4 2.4
w 0.8 0.7

valid log-loss の感度(他の 3 つを固定して 1 軸ずつ動かす):

γ  0:3.9193  0.25:3.9097  0.5:3.9065  0.75:3.9095  1:3.9184  1.5:3.9519
β  0.8:3.9518  1:3.9380  1.2:3.9271  1.4:3.9188  1.6:3.9129
   1.8:3.9091  2:3.9069  2.4:3.9065  2.8:3.9098
w  0:4.2394  0.3:3.9690  0.5:3.9205  0.6:3.9097  0.7:3.9065  0.8:3.9108  1:3.9477

いずれも内側に最小があり、周辺は平坦(γ 0.25〜0.75 / β 1.8〜2.8 / w 0.6〜0.8 で差 0.005 nat 以内)。動かすときは valid で選び直すこと。

k を 150 に据え置いた理由

120 通りの log-loss で k を選ぶと単調に大きい方が良くなる (50:3.9071 → 2000:3.9037)が、40 倍動かして 0.0034 nat しか変わらない。 これはブレンド全体の改善幅 (0.12 nat) の 3% で、実質フラット。 一方 Stage2 側の条件付き log-loss(P(2着,3着 | 真のセル))は Stage2 の質を直接測っており、そちらでは k=150 に明確な最小がある。 測っている対象が鋭い方の選定を採る。

β が 2.4 になる理由

PL 単体の最良は実験時と同じ β=1.4(valid 4.0258 / test 4.0382)。 ところがブレンド成分としては β=2.4 が最良で、単体では悪化する (β=2.4 単体は test 4.2758)。混合の相手として使うときは PL を鋭くした方が効く。 そのため kimarite_blend.py では BETA(2.4、ブレンド用)と PL_BASELINE_BETA(1.4、A/B の対照用)を別の定数にしてある。

test の実測(報告用)

予測 valid test
一様 (1/120) 4.7875 4.7875
Plackett-Luce (β=1.4) 4.0258 4.0382
決まり手モデル単体 3.9477 3.9644
ブレンド (w=0.7) 3.9065 3.9196

改善 (PL 単体 → ブレンド) = +0.1186 nat [+0.1003, +0.1376](n=3,614)。

戦略 点数 的中率 回収率 95%CI 平均配当 万舟/1万円
B案 1着≠1 top3 3.0 8.85% 83.8% [72, 96] 2,839 0.055
B案 1着≠1 top5 5.0 12.65% 87.1% [76, 98] 3,442 0.111
B案 1着≠1 top8 8.0 16.85% 80.9% [73, 90] 3,842 0.135
B案 1着≠1 top12 12.0 20.61% 78.0% [71, 86] 4,542 0.171
(参考) 制限なし top5 5.0 30.35% 77.3% [72, 83] 1,273 0.006
A案 v9_suji (同一レース) 5.0 10.27% 80.6% [69, 93] 3,927 0.149

点数を valid で選び直しても top5 は「勝たない」。 valid では top3 が 80.5%、top5 が 76.3% で、順位が test と逆になる(どちらも CI ±10pt 級)。 回収率では点数すら決められないので、top5 は A案と同じ 5 点・同じ 500 円でペア比較できることを理由に固定した。 これは事前に決めたルールであって、valid の最良ではない。

実装との一致

本番の合成は stdlib のみの scripts/boatrace/kimarite_blend.py、この検証は numpy 版。同じ入力(2026-08-12 の 180 レース)で 120 通りすべてを突き合わせ、 最大差 3.3e-16(倍精度の丸め)を確認済み。ここで報告した数値は 本番が実際に計算する分布のものと言える。

再現方法

python notebooks/ana_prediction/kimarite_backtest.py --k-grid 150