競馬AIの精度がどうしても上がらないとき、まず疑うべきはモデルではなくデータの結合です。筆者の場合、各馬に別の馬のオッズを渡していた誤結合が原因で、1年以上「モデルが弱い」と誤解していました。
この記事では、シリーズ第2回として、誤結合を見つけた方法と、それ以来続けているデータリーク・誤結合の検査手順をまとめます。
この記事の結論
- 10分前オッズと確定オッズの順位相関を見ると、馬番の誤結合を検出できる(誤結合時 -0.004 → 修正後 0.987)。
- 結合キーは
race_id × horse_num。pandasのmerge(..., validate="one_to_one")で重複を例外にする。 - 「10分前」という列名だけでは時点を証明できない。取得時刻を確認する処理と、過去の評価で実施された処理を区別する。
- 特徴量は列名ではなく「発走10分前に分かるか」で分類し、結果列が混ざったら学習を止める。
- train / validation / test は時系列で分け、testの結果を見て条件を変えない。
症状:特徴量を足しても予測が当てずっぽうに見える
特徴量を追加しても、パラメータを変えても精度が上がらない状態が続きました。外から見えるのは「モデルの精度が低い」という症状だけで、データが壊れているとは考えていませんでした。
原因:出走頭数の違うレースで馬番とオッズの対応が崩れていた
初期の中間データでは、出走頭数の違うレースを処理する途中で、10分前オッズと馬番の対応がずれていました。つまり各馬に別の馬のオッズが付いていたのです。
見つけ方:10分前オッズと確定オッズの順位相関
10分前と確定時で、人気の順番が大きく入れ替わることはほとんどありません。そこで両者の順位相関を計算したところ、ほぼゼロでした。
| 状態 | 10分前と確定の単勝オッズの順位相関 |
|---|---|
| 誤結合していたデータ | -0.004 |
race_id × horse_num で結合し直したあと | 0.987 |
なお、これは確定オッズを予測に使うという意味ではありません。結果情報を判断には使わず、馬番の対応が正しいかを確かめる健全性チェックとしてだけ使っています。
対処1:結合前にキーの一意性を検査する
以下は今後の再現実験に使う防御的な実装例です。過去の評価スクリプトには validate 指定がありません。pandasの merge に validate="one_to_one" を付けると、キーの重複時に例外で止まります。発走前のオッズであることもあわせて確認します。
KEY = ["race_id", "horse_num"]
assert not odds.duplicated(KEY).any(), "オッズが馬番単位で一意ではない"
merged = entries.merge(odds, on=KEY, how="left", validate="one_to_one")
assert (merged["odds_timestamp"] < merged["start_time"]).all()
リーク対策は「禁止列」に目が行きがちですが、実際に検証を壊したのはキーと行数でした。毎回のレポートに次の値を出し、急変したらモデルより先にデータを疑います。
- 10分前と確定オッズの順位相関
- 勝ち馬のオッズの中央値
- 結合前後の行数
対処2:発走前オッズの時点を取得時刻で確認する
ファイル名や列名に「10分前」とあっても、実際にその時刻のデータとは限りません。時点付きのデータでは、レコードに保存した取得時刻と発走時刻の差を計算します。以下の8〜12分は許容窓の設計例です。今回照合した過去の評価スクリプトは10分前用CSVを再結合していますが、各行の取得時刻をこの範囲に制限する処理は確認できませんでした。過去のROIを「8〜12分の検査済み」とは扱いません。ファイルの更新日時はコピーや移動で変わるため使いません。
minutes_before = (start_time - odds_timestamp).total_seconds() / 60
if not 8.0 <= minutes_before <= 12.0:
return skip("odds_snapshot_out_of_window", minutes_before=minutes_before)
範囲外のレースは黙って捨てず、見送り理由として残します。候補数が減ったとき、モデルの問題かオッズ取得の遅れかを区別するためです。
対処3:特徴量を「発走10分前に分かるか」で分類する
確定オッズや確定人気は、名前だけ見ると「オッズ」「人気」なので誤って使いがちです。列名ではなく、情報が判明する時点で分類しました。
| グループ | 代表的な列 | 10分前に使えるか | 注意点 |
|---|---|---|---|
| レースカード | 枠番、性齢、斤量、馬体重、距離、天候、馬場 | 使える | 発表時刻も保存する |
| 過去成績の集計 | 馬・騎手の出走数、平均順位率 | 使える | 当該レース・未来のレースを含めない |
| 時点付きオッズ | TAN_10、FUK_10_low/high | 使える | 取得時刻の確認が前提。過去評価では未確認 |
| 結果・精算 | goal_num、払戻、確定人気、確定オッズ | 使えない | ラベルと精算専用 |
学習直前に結果列が混ざっていたら例外で止めます。人の注意力に頼らない仕組みにするのが確実です。
FORBIDDEN = {"rank", "goal_num", "fukusho1", "tasho", "time_s"}
assert not (set(feature_columns) & FORBIDDEN)
過去成績の特徴量は「当日より前」だけで作る
過去成績は必ず race_date < current_race_date の範囲で集計します。同じ日の後のレースが1行混ざっただけでも、ランダム分割の検証では見つけにくいためです。補助確認として horse_ave_rank_rate と当該レースの結果から作った指標の相関を見たところ 0.007 と記録されています。ただし、低い相関だけでは未来情報の混入がないことは証明できません。集計元の日時と対象行を追跡する検査が別途必要です。
別件として、過去5走を「直近順」ではなく「古い順」に並べていた不具合も見つかりました。未来情報ではないのでリークではありませんが、特徴量の意味がゆがむため品質課題として記録しています。
対処4:train / validation / test を時系列で分割する
同じレースが複数の区分にまたがらないよう、期間で分けました。
| 区分 | 期間 | 用途 |
|---|---|---|
| train | 2013年1月〜2020年12月 | 学習 |
| validation | 2021年 | 条件の選定 |
| test | 2022〜2023年 | 最終評価 |
testの結果を見て条件を変えると、そのtestは実質validationになります。次に必要なのは別の未来期間であり、同じtestで何度も試すことではありません。
運用中に毎回出しているデータ品質レポート
- 取得したレース数
- オッズの結合率
- 時点の範囲(8〜12分)に入った割合
- キーの重複数
- 特徴量の欠損率
- 見送り理由ごとの件数
- 原本ファイルのハッシュ
異常のある日は収益計算から黙って外さず、理由と件数を残します。
まとめ
- 「精度が低い」という症状だけで、すぐにモデルを疑わない。
- オッズの順位相関・結合前後の行数で、誤結合を早期に検出する。
- 時点(取得時刻)と情報の判明時点で、使える特徴量を機械的に制限する。
- testは最後に一度だけ使う。
次回は、整えた入力から確率を出すLightGBMモデルと、確率を「買うかどうか」に変える戦略を分けて評価する方法です。
注意:結果・払戻・確定オッズは、精算と監査以外には使っていません。本記事は研究・技術解説で、馬券の購入を勧めるものではありません。
出典と今回確認できた範囲
数値の出典は保存済みの honest_strategy_comparison.md、実装の照合先は evaluate_current_strategy_honest.py の load_split と get_prerace_odds です。2026年10月15日の確認では、馬番とレースIDでの再結合、禁止列検査、オッズ欠測の除外を確認しました。相関値そのものの再計算や、原取得時刻の監査は行っていません。
訂正:過去の集計処理と推奨実装例を区別し、相関値だけでリークがないと断定した説明を修正しました。列名・相関・キー検査は別々の確認であり、一つを通過してもデータ全体の正しさは保証できません。