Lab

回収率200%が実戦で50%になる理由 ― 時系列CVを誤ると競馬AIは死ぬ

この記事の結論

競馬AIで最も多い失敗は、モデル設計ではなく 検証設計 の誤りです。派手なバックテスト結果に騙されないために、時系列クロスバリデーションは必須技術です。

私が初めて競馬AIを作った時、バックテストで単勝回収率 200% という派手な数字が出ました。

しかし実戦運用すると、3ヶ月で 50%まで落ち込みました

原因はモデルそのものではなく、「検証方法」の根本的な誤りでした。

この記事では、競馬AIにおいて時系列CV (クロスバリデーション) がなぜ必須なのか、どう実装すべきか、私の運用モデルでの実例を交えて解説します。

この記事の対象読者

  • Python + LightGBM等で競馬AIを自作している中級者
  • これから自作したいが、何から始めれば良いか分からない人
  • 派手なバックテスト結果が実戦で再現しない経験がある人

通常のクロスバリデーション (KFold) の問題点

KFoldの基本おさらい

通常使われる「KFold交差検証」は、データを5分割して、4セットで学習・1セットで検証することを5回繰り返す方法です。

from sklearn.model_selection import KFold

kf = KFold(n_splits=5, shuffle=True, random_state=42)
for train_idx, test_idx in kf.split(X):
    X_train, X_test = X.iloc[train_idx], X.iloc[test_idx]
    # 学習と評価

シンプルで使いやすく、Kaggleの入門記事でもよく見ます。

競馬データで起こる「時系列リーケージ」

しかし競馬データで KFold を使うと、致命的な問題が起こります。

「2024年のレースで学習して、2023年のレースを予測する」事態が頻発するのです。

なぜダメか? 実戦で考えてみてください。2023年のレースを予測する時、2024年のデータは存在しません。

KFold は「ランダム分割」なので、時系列を完全に無視します。これが 「時系列リーケージ」 と呼ばれる現象です。

時系列リーケージが起こると、検証時には「未来情報を使って過去を予測」できるため、不当に高い精度が出ます。これがバックテスト200%の正体です。

実戦では未来情報は使えないので、当然精度は落ちます。私の場合は 3ヶ月で半分以下 になりました。

時系列CVの基本

TimeSeriesSplit の仕組み

scikit-learn には TimeSeriesSplit という時系列向けのCVが用意されています。

from sklearn.model_selection import TimeSeriesSplit

tscv = TimeSeriesSplit(n_splits=5)
for train_idx, test_idx in tscv.split(X):
    X_train, X_test = X.iloc[train_idx], X.iloc[test_idx]
    # 学習と評価

ココがポイント: 学習データは常に「テストデータより前のレース」になります。これにより 「未来で過去を予測する」事故が起こりません

Walk-Forward と Expanding Window の違い

時系列CVには2つのスタイルがあります。

  • Expanding Window: 学習データを累積していく (初期 → 全期間)。TimeSeriesSplit のデフォルト
  • Sliding Window: 学習データの長さを固定して、ウィンドウを移動させる

競馬では Expanding Window が一般的 です。古いデータも含めて学習することで、レアな現象 (重い馬場、大荒れレース) を含む頑健なモデルになります。

競馬で推奨する LOYO (Leave-One-Year-Out) 設計

なぜ年単位で分けるべきか

私が推奨するのは、競馬特有の事情を考慮した 「LOYO (Leave-One-Year-Out)」設計 です。理由は3つあります。

理由1: 競馬の季節性

春G1 (4-6月)、秋G1 (10-11月)、有馬記念 (12月)、と1年で1サイクルです。月単位で分けると同じ年内で「春→秋を予測」となり、季節バイアスを正しく評価できません。

理由2: トレンドシフト

馬場改修、騎手騎乗依頼の変化、人気馬の世代交代など、年をまたぐと予測の難易度が変わります。年単位で分けることで「全く新しい1年」をどう予測できるかを評価できます。

理由3: 異常年の影響を分離

特定の年にだけ起こった珍事 (雨続き、特定種牡馬のブレイクなど) が他年に漏れません。

LOYO の実装

import lightgbm as lgb
from scipy.stats import spearmanr

def leave_one_year_out_cv(df, target_col, year_col='year', min_train_years=3):
    """
    競馬データ向け年単位クロスバリデーション(LOYO)

    Args:
        df: 全データ(年カラム必須)
        target_col: 目的変数のカラム名
        year_col: 年のカラム名
        min_train_years: 学習データの最低年数

    Returns:
        results: 各年の検証結果リスト
    """
    years = sorted(df[year_col].unique())
    results = []

    # 最初の3年は学習データとして確保
    for test_year in years[min_train_years:]:
        train_years = [y for y in years if y < test_year]
        train = df[df[year_col].isin(train_years)]
        test = df[df[year_col] == test_year]

        X_train = train.drop([target_col, year_col], axis=1)
        y_train = train[target_col]
        X_test = test.drop([target_col, year_col], axis=1)
        y_test = test[target_col]

        model = lgb.LGBMRegressor(random_state=42)
        model.fit(X_train, y_train)
        pred = model.predict(X_test)

        spearman, _ = spearmanr(y_test, pred)
        results.append({
            'test_year': test_year,
            'train_size': len(train),
            'test_size': len(test),
            'spearman': spearman,
        })

    return results

これだけで「未来で過去を予測する」事故は防げます。

私の血統評価AIでの実装事例

14年分の LOYO 検証結果

私が運用している「血統評価AI」(全JRA 122,327頭 を血統情報のみで予測) では、14年分のLOYO検証 を採用しています。

主軸指標「log_honsho_man (対数本賞金)」の Spearman 相関は、0.372 ± 0.021(14年平均±標準偏差)です。

地味な数字に見えるかもしれません。しかし「最弱年」の精度を重視する設計にしているため、この 標準偏差0.021というブレの小ささ は私にとって最も大事な指標です。

なぜ「最弱年」を重視するのか

平均値だけ見ていると、たまたま当たり年があった時に過大評価しがちです。

「最弱年でも使い物になるか」 を基準にする方が、実戦で裏切られにくい。

例えば、2つのモデルを比べてみます。

  • モデルX: 平均 0.4 / 標準偏差 0.10 → 最弱年は 0.30 程度
  • モデルY: 平均 0.37 / 標準偏差 0.02 → 最弱年でも 0.35 程度

どちらが実戦で安心か? 私は 後者 だと考えています。

失敗から学んだ落とし穴

時系列CVを採用しても、別の形でリーケージが起こることがあります。私が過去にロールバックした実験を3つ紹介します。

失敗1: 累計統計のカットオフ漏れ

「種牡馬の累計産駒勝ち上がり数」を特徴量に入れた時、target year のデータも累計に含めてしまい、Spearmanが不当に上がりました (0.372 → 0.41)。実戦運用したらすぐに精度が下がるはずです。

修正コード:

# NG例(リーケージあり)
sire_stats = df.groupby('sire_name')['honsho'].sum()

# OK例(target_year以前のみ)
def cutoff_stats(df, target_year):
    past = df[df['year'] < target_year]
    return past.groupby('sire_name')['honsho'].sum()

失敗2: 自分自身の戦績混入

「target horse の前走着順」を特徴量に入れたら、明らかにリーク。デビュー前予測モデルなのに、走った後のデータが入る矛盾。これも実装上で見落としやすいポイントです。→ デビュー前確定情報のみを使うルールを徹底しました。

失敗3: cross_quality の自己参照

血統のクロス品質 (インブリード度合いなど) を計算する時、target horse自身の戦績を含めてしまうケース。これは集計関数のループに自分自身を含めないよう、self除外の実装が必要です。

実務的なチェックリスト

時系列CVを設計するときの確認事項です。これだけ守れば、バックテスト200%の幻に騙されることは無くなります。

  • 検証期間より前のデータだけで特徴量を計算しているか
  • 累計統計が target_year - 1 でカットオフされているか
  • target horse 自身の戦績が特徴量に混入していないか
  • cross系特徴量で自己除外を実装しているか
  • 季節性・トレンドシフトを意識した分割になっているか
  • 平均値だけでなく、最弱年の精度・標準偏差も見ているか
  • 「不利な数字」を隠さず記録しているか

さらに大事なこと: 最後の項目「不利な数字を隠さず記録する」が、研究を続ける上で最も大事だと思っています。

まとめ

時系列クロスバリデーションは、競馬AIにおける最低限の 基礎工事 です。派手なバックテスト結果より、「最弱年でも安定する精度」を信じる

私の血統評価AIも、Spearman 0.372 という地味な数字ですが、14年間ブレの小さい数字を信じて運用しています。派手な数字に惑わされない検証設計こそが、長く続けられる研究者の条件だと思っています。

参考: 私の運用モデル

本記事で言及した数字は、すべて私が運用している以下のモデルから引用しています。

保有モデル一覧

血統評価AI
全JRA 122,327頭を血統情報のみで予測 / 14年LOYO検証 / Spearman 0.372 ± 0.021

単勝予測AI
1000レース×4回バックテスト検証値 ROI 135-193% / 的中率 11% / 現在実運用中

モデルの月次精度レポートを無料で公開しています。よろしければ下記からフォローをお願いします。

X (@waidarTennosuke) をフォロー

関連記事

本記事の分析・数値は、機械学習モデルによる確率的な研究結果です。馬券の購入、一口馬主への出資、その他金銭が伴う判断は、すべて閲覧者自身の責任で行ってください。本記事の情報により発生した損失について、当方は一切の責任を負いません。本記事には広告 (アフィリエイトリンク) を含む場合があります。詳細は 免責事項 をご確認ください。

-Lab