競馬MLの正しいクロスバリデーション
競馬MLの正しいクロスバリデーション
sklearnのtrain_test_split(shuffle=True)を競馬データに使うと、テスト精度が実力以上に高く出ます。手元では好成績なのに、いざ未来のレースを予測すると全然当たらない。その原因の多くは検証方法そのものにあります。この記事では、競馬データで精度が盛れてしまう仕組みと、時系列を尊重した分割・四半期ローテーション・ハイパーパラメータ探索の分離まで、検証設計の型を解説します。
ランダム分割で精度が盛れる仕組み
競馬データの行は独立ではありません。同じ馬が何度も出走し、同じ開催日に同じ競馬場で何レースも行われます。ランダムにシャッフルして分割すると、
- 同じ馬の近接レースがtrainとtestに泣き別れる: ある馬の5月のレースがtrain、6月のレースがtestに入ると、モデルは「この馬の今の調子」を実質カンニングできます
- 同じ開催の他レースから馬場や傾向が漏れる: 同日同コースのレースがtrainにあれば、その日の馬場バイアスをモデルが間接的に学べます
- rolling特徴量経由でも漏れる: 過去N走の集約特徴量は近接レース同士で強く相関するため、時間的に近い行がtrainにあるだけで有利になります
本番の予想は常に「過去のデータだけを使って、まだ走っていないレースを予測する」状況です。ランダム分割はこの状況を再現していないため、出てきた精度は本番では再現しません。リークしている特徴量がなくても、分割の仕方だけで評価は盛れます。
基本: 時間で切る
最低限のルールは「学習データはすべて検証データより過去」です。日付列を作って切るだけです。
import pandas as pd
# kaisai_nen(年) + kaisai_gappi(月日) から日付列を作る
df['race_date'] = pd.to_datetime(
df['kaisai_nen'] + df['kaisai_gappi'], format='%Y%m%d'
)
cutoff = '2025-01-01'
train = df[df['race_date'] < cutoff]
test = df[df['race_date'] >= cutoff]
print(f'train: {train["race_date"].min().date()} - {train["race_date"].max().date()}')
print(f'test : {test["race_date"].min().date()} - {test["race_date"].max().date()}')
これで「未来を知らない状態で予測する」という本番の条件を再現できます。なお、rolling特徴量を作る際のshift(1)によるリーク防止は分割とは別の問題で、両方必要です。
単一holdoutの落とし穴: 季節の偏り
時間で切れば安心、と言いたいところですが、単一のholdout期間には季節の偏りという問題があります。
競馬の開催は季節でかなり性格が変わります。夏はローカル開催が中心で、出走メンバーの層やコース形態が違います。冬は中央場での開催が中心になり、馬場状態の傾向も変わります。2歳戦は夏から秋に始まり、春と秋には大きなレースが集中します。
つまり「2025年1〜3月だけで検証」すると、その季節に出やすい条件での成績しか測れません。たまたまその期間に強い設定が選ばれてしまい、評価が特定の季節に過適合するリスクがあります。
四半期ローテーションで季節をバランスさせる
季節の偏りを抑える工夫が、年を跨いだ四半期ローテーションです。複数年のデータに対して「各年のQ1をまとめてholdout」「各年のQ2をまとめてholdout」…と4通りの分割を作り、全季節を一度ずつ検証に回します。
df['quarter'] = df['race_date'].dt.quarter
folds = []
for q in [1, 2, 3, 4]:
holdout_mask = df['quarter'] == q # 全年のQqがholdout
train_mask = ~holdout_mask # 残りの四半期で学習
folds.append((train_mask, holdout_mask))
for i, (tr, ho) in enumerate(folds, 1):
print(f'fold{i}: train={tr.sum()}行, holdout={ho.sum()}行')
4つのfoldの平均を取れば、春夏秋冬すべての条件を含んだ評価になります。単一holdoutでは見えなかった「夏だけ極端に悪い」のような季節依存も、fold別のスコアを見れば検出できます。
この方式の代償: 同じ年の他の四半期から将来情報がにじむ
ただし、この分割は厳密な時間順ではありません。2023年Q2をholdoutにしたとき、trainには同じ2023年のQ3・Q4が残ります。馬は成長し、クラスを上げ下げし、厩舎の調子も変わります。Q3以降のデータには「Q2時点ではまだ知り得なかったその馬のその後」が含まれており、rolling特徴量や馬・騎手単位の集約を通じてholdoutの予測に有利に働きえます。
つまりここにはtrade-offがあります。
- 完全な時間順split: 将来情報の混入はゼロ。ただし検証期間の季節が偏る
- 四半期ローテーション: 季節はバランスする。ただし同年内の将来情報が多少にじむ
どちらが正解というものではなく、どちらを選んでも「何を妥協したか」を自覚しておくことが重要です。たとえば「モデル間の比較は四半期ローテーションで行い、最終的な性能の見積もりは完全時間順のholdoutで確認する」という併用も現実的な落とし所です。
ハイパーパラメータ探索は評価データに触れさせない
もう一つの定番の失敗が、Optunaなどでハイパーパラメータを探索するときに最終評価用のholdoutをそのまま使ってしまうことです。何百trialも回してholdoutのスコアが最大になる設定を選ぶと、その設定はholdoutに過適合しており、評価はもはや「未知データの性能」を意味しません。
原則は「探索はtrain期間の内部だけで完結させ、最終holdoutは最後に一度だけ使う」です。train内部の検証には、学習期間を伸ばしながら直後の期間で検証するexpanding windowが時系列と相性の良い形です。
import lightgbm as lgb
import numpy as np
import optuna
from sklearn.metrics import log_loss
# train期間(〜2024年)の内部だけで作ったexpanding window
cv_windows = [
('2020-01-01', '2022-12-31', '2023-01-01', '2023-06-30'),
('2020-01-01', '2023-06-30', '2023-07-01', '2023-12-31'),
('2020-01-01', '2023-12-31', '2024-01-01', '2024-06-30'),
]
def objective(trial):
params = {
'objective': 'binary',
'learning_rate': trial.suggest_float('learning_rate', 0.01, 0.2, log=True),
'num_leaves': trial.suggest_int('num_leaves', 15, 127),
'feature_fraction': trial.suggest_float('feature_fraction', 0.5, 1.0),
'verbose': -1,
}
scores = []
for tr_start, tr_end, va_start, va_end in cv_windows:
tr = df[df['race_date'].between(tr_start, tr_end)]
va = df[df['race_date'].between(va_start, va_end)]
model = lgb.train(params, lgb.Dataset(tr[feature_cols], tr['target']),
num_boost_round=300)
scores.append(log_loss(va['target'], model.predict(va[feature_cols])))
return np.mean(scores)
study = optuna.create_study(direction='minimize')
study.optimize(objective, n_trials=100)
# 最良paramsで2024年までの全trainを再学習し、2025年のholdoutで一度だけ評価する
各windowは「過去で学習して直後の未来で検証」を守っており、2025年のholdoutは探索中に一度も登場しません。探索(モデル選び)と評価(性能の見積もり)を分離することで、最後に出てくる数字が信用できるものになります。
まとめ
| ポイント | 内容 |
|---|---|
| ランダム分割はNG | 同じ馬・同じ開催の近接レースが泣き別れて実質リークになる |
| 基本は時間で切る | 学習はすべて検証より過去。本番の条件を再現する |
| 単一holdoutの罠 | 検証期間の季節が偏り、評価が特定条件に過適合する |
| 四半期ローテーション | 各年のQ1〜Q4を順にholdoutして季節をバランスさせる |
| trade-offを自覚する | 同年内の将来情報のにじみと季節バランスは両立しない |
| 探索と評価の分離 | HPOはtrain内のexpanding windowで行い、holdoutは最後に一度だけ |
検証設計はモデルや特徴量より地味ですが、ここが崩れていると以降のすべての数字が信用できなくなります。まず時間で切る、次に季節をバランスさせる、最後に探索と評価を分ける。この順で固めるのがおすすめです。
JvLink To Importer で長期間のデータを揃えておけば、年を跨いだ四半期ローテーションのような検証設計も自由に組めます。
この記事をシェア