競馬MLのデータリーク実例集 — 学習では強いのに本番で動かない特徴量
競馬MLのデータリーク実例集 — 学習では強いのに本番で動かない特徴量
データリークとは、本番の予測時点では知り得ない情報が学習データに混ざってしまうことです。リークした特徴量は学習・バックテストでは異様に強く見えるのに、本番では存在しない (NULL や 0 になる) ので、モデルが現場で突然使い物にならなくなります。競馬データは「レース前に確定する情報」と「レース後にしか確定しない情報」が同じテーブルに同居しているため、リークが特に起きやすい領域です。この記事では JV-Link 取り込みデータ (mykeibadb 系スキーマ) で実際に踏みやすい 4 類型を紹介します。
類型1: 当該レースの結果系列の混入
最も古典的なリークです。umagoto_race_joho (馬毎レース情報) には、出走前に確定している列と、レースが終わらないと埋まらない列が混在しています。
| 出走前に確定 | レース後にしか確定しない |
|---|---|
| umaban, wakuban, barei | kakutei_chakujun, nyusen_juni |
| futan_juryo, bataiju, zogen_sa | soha_time, time_sa |
| kishu_code, chokyoshi_code | corner1_juni 〜 corner4_juni |
| seibetsu_code, blinker_shiyo_kubun | kohan_3f, kohan_4f, chakusa_code1 |
危ないのは SELECT u.* で全列を取ってしまうパターンです。
-- 危険: u.* には「レース後にしか確定しない列」が大量に含まれる
SELECT u.*, r.kyori, r.track_code
FROM umagoto_race_joho u
JOIN race_shosai r USING (race_code)
WHERE r.kaisai_nen >= '2020';
この DataFrame をそのまま学習に回すと、当該レースのコーナー通過順や上がり 3F が「特徴量」として混入します。当該レースの corner4_juni はほぼ着順そのものなので、学習スコアは劇的に良くなります。しかし本番の予測時点ではこれらの列は空です。学習時は値あり・本番は NULL/0 という train/serve skew が起き、モデルは「一番頼っていた特徴が消えた」状態で予測することになります。
対策は、特徴量に使ってよい列をホワイトリストで明示することです。PRE_RACE_COLS = ['umaban', 'futan_juryo', 'bataiju', ...] のような許可リストを 1 箇所で定義し、学習・推論とも X = df[PRE_RACE_COLS] で必ずそこを通します。「禁止列リスト (ブラックリスト)」より「許可列リスト (ホワイトリスト)」の方が安全です。スキーマに列が追加されたとき、ブラックリストは黙ってリークします。
類型2: 過去走集計に自レースが混入
「直近 5 走の平均着順」のようなローリング集計で、境界の 1 行を間違えて自レースを含めてしまうパターンです。
import pandas as pd
df = df.sort_values(['horse_id', 'race_date']).reset_index(drop=True)
# 間違い: rolling(5) は自レースを含む 5 行を平均してしまう
df['avg_finish_last5_BAD'] = (
df.groupby('horse_id')['finish_position']
.rolling(5, min_periods=1).mean()
.reset_index(level=0, drop=True)
)
# 正しい: shift(1) で 1 行ずらしてから rolling する
df['avg_finish_last5'] = (
df.groupby('horse_id')['finish_position']
.shift(1)
.rolling(5, min_periods=1).mean()
.reset_index(level=0, drop=True)
)
SQL のウィンドウ関数で書く場合も同じで、ROWS BETWEEN 5 PRECEDING AND 1 PRECEDING の 1 PRECEDING が境界です。これをうっかり CURRENT ROW と書くと、自レースの着順が「過去成績」に混ざります。
このリークは類型 1 より発見しにくいのが厄介です。混入するのは「5 走平均のうちの 1/5」なので、特徴量が露骨に着順と一致するわけではなく、学習スコアが少しだけ不自然に良くなる形で現れます。
類型3: 時間を跨ぐ統計量
「騎手の年間勝率」をその年全体で計算してしまうパターンです。
# 間違い: 2023年4月のレースの特徴量に、2023年12月までの成績が含まれる
jockey_stats = (
df.groupby(['kishu_code', 'year'])['won'].mean()
.rename('jockey_win_rate_year')
)
df = df.merge(jockey_stats, on=['kishu_code', 'year'])
# 正しい: レース日時点での累積勝率 (自レース除外つき)
df = df.sort_values(['kishu_code', 'race_date'])
df['jockey_win_rate_to_date'] = (
df.groupby('kishu_code')['won']
.apply(lambda s: s.shift(1).expanding().mean())
.reset_index(level=0, drop=True)
)
間違い側は、4 月時点の予測に 12 月までの成績が混ざるので未来情報のリークです。「集計キーが年単位」のように粒度の粗い統計量は、結合した瞬間に時間の前後関係が消えるため見落としやすい類型です。正しい側は「その時点までの成績」を expanding (累積) で計算しています。
騎手だけでなく、調教師成績・種牡馬成績・コース別の平均タイムなど、「母集団全体で 1 回計算して結合する」タイプの特徴量はすべて同じ罠を持っています。「この値はいつ時点で計算可能か」を列ごとに言えるかが判定基準です。
類型4: NN / embedding の全期間学習
ツリー系の特徴量だけでなく、ニューラルネットで馬や騎手の embedding を作って GBDT に渡す構成でも同じ問題が起きます。
- 2015〜2024 年の全データで embedding を事前学習する
- その embedding を特徴量にして、2023 年までで学習・2024 年で検証する
この場合、embedding 自体が検証期間 (2024 年) の結果を見て学習されているので、検証スコアは過大評価になります。GBDT 本体の split をどれだけ丁寧に切っても、上流の表現学習が全期間を見ていたら台無しです。
対策は、embedding の学習データも検証期間より前で打ち切ること。検証期間を動かすたびに embedding を学習し直す必要があるので手間ですが、ここを省略すると「検証では強いのに本番で平凡」という典型症状になります。
リークの検出法
仕込んでしまったリークを後から見つける実践的な方法を 3 つ挙げます。
1. ランダム split → 時系列 split に変えて精度を比べる
ランダム split では AUC 0.85、時系列 split (過去で学習・未来で検証) にしたら 0.72 に急落——という場合、その差分のかなりの部分はリークか時間依存の何かです。最初から時系列 split で検証するのが原則ですが、「両方やって差を見る」のは診断としても使えます。
2. feature importance の上位に「効きすぎる」特徴がいたら由来を疑う
LightGBM なら booster.feature_importance(importance_type='gain') で gain ベースの重要度が取れます。上位に「他を突き放して 1 つだけ重要度が高い特徴」がいたら、その列が SELECT 文のどこから来たかを遡って確認します。経験上、競馬の出走前情報で単独の特徴がそこまで支配的になることは稀で、支配的な特徴の正体が結果系列だった、というのがよくある結末です。
3. 「本番初日」のログで特徴の充足率を確認する
学習時とまったく同じ特徴量生成コードをレース前の時点で走らせ、各特徴量の非 NULL 率を学習時と比較します。
fill_rate_train = X_train.notna().mean()
fill_rate_serve = X_serve.notna().mean()
diff = (fill_rate_train - fill_rate_serve).sort_values(ascending=False)
print(diff.head(20)) # 学習時は埋まるのに本番で空の特徴 = リーク候補
学習時は 99% 埋まるのに本番では 0% という特徴があれば、それは類型 1 のリークがほぼ確定です。本番投入の前に必ず一度やる価値があります。
まとめ
競馬データのリークは「結果系列の直接混入」「ローリング境界のミス」「時間を跨ぐ統計量」「表現学習の全期間学習」の 4 類型でほぼ説明できます。共通する予防策は、特徴量ホワイトリストと**「この値はレース発走前に計算できるか」を列ごとに問うこと**の 2 つです。
JV-Link データはレース前情報と結果情報が同じテーブルに同居している分、SELECT 文を書いた瞬間にリークの種が撒かれます。ETL の段階で「出走前ビュー」と「結果ビュー」を分けておくと、後段のモデル開発が一気に安全になります。
この記事をシェア