0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「モデルを学習して終わり」にしない時系列ML実装 ― 競馬データでリーク対策・確率評価・モデル選択まで作ってみた

0
Posted at

はじめに

機械学習を学び、モデルを動かせるようになると、用意されたデータセットから一歩進んで、まだ結果の分からない未来を予測してみたくなりました。そこで題材に選んだのが競馬です。

今回予測するのは、各出走馬が3着以内に入る確率 P(top3) です。

競馬には個体ごとの過去成績があり、レースごとに対戦相手や距離、競馬場などの条件が変わります。レース前に予測を出し、終了後には実際の結果を確認できるため、時系列データを「未来の予測」まで持っていく題材として適していると考えました。

最初は、過去成績から特徴量を作り、XGBoostなどを学習すれば形になると思っていました。しかし実際に作り始めると、モデルそのものより、その周辺で考えるべきことが多くありました。

  • 過去成績は、どの時点まで使ってよいのか
  • 学習・検証・テストをどう時間で分けるのか
  • 少ない過去成績を、そのまま率にしてよいのか
  • 「強い馬」ではなく「今回の相手の中で強い馬」をどう表すのか
  • 確率予測をAccuracy以外でどう評価するのか
  • 新しいモデルが少しだけ良かったとき、本当に入れ替えるのか
  • 本番予測では、モデルと最新データのどちらを固定するのか
  • Webで取得したデータを、どこまで公開してよいのか

そこで、特徴量の時点管理から学習、確率評価、モデル選択、将来予測までを一つのパイプラインとして実装しました。

この記事で特に伝えたいのは次の3点です。

  1. 時系列MLでは、Train / Testの分割だけでなく、特徴量そのものの時点管理が重要
  2. 確率を出すなら、AccuracyではなくBrier Scoreなどで予測確率そのものを評価する
  3. モデルの学習だけでなく、「いつ選び、いつ固定し、未来でどう検証するか」まで設計する

この記事は、pandasやscikit-learnで一度モデルを学習したことがある方を想定しています。競馬の詳しい知識は必要ありません。

なお、実データを扱う研究用リポジトリと、公開用リポジトリは分けて管理しています。外部データの権利や再配布条件に配慮し、公開用には自作したMLコード、テスト、説明文書、合成データ生成器などを抽出しました。実際のレース履歴や学習済みの本番モデルは含めていません。

公開コードはこちらです。

keiba-place-ml-showcase

この記事では、実プロジェクトで採った時系列の設計と、公開コードで動かせる簡略デモを分けて説明します。

公開デモと本文に掲載するデモスコアは合成データによるもので、実際の競馬での予測性能を示す数値ではありません。

1. 予測対象は、各出走馬の3着内確率

本プロジェクトの予測対象は、各レースにおける出走馬ごとの3着内確率 P(top3) です。

データの1行は「あるレースに出走する1頭の馬」です。16頭立てなら、そのレースには16行あります。

以下は列の意味を示すための架空例です。実データの転載ではありません。assigned_weight_kg は馬が負担する斤量で、馬体重ではありません。

race_date horse_id age assigned_weight_kg career_starts career_top3 field_size top3_label
2021-05-01 A 4 56 10 4 16 1
2021-05-01 B 5 57 18 3 16 0

目的変数 y は次の通りです。

top3_label = 1  # 3着以内
top3_label = 0  # それ以外

説明変数 X は、予測を行う時点で利用可能な情報だけから作ります。例えば、年齢、斤量、枠の相対位置、前走からの日数、過去の3着内実績、同距離での実績、同競馬場での実績、直近の着順、相対タイム、今回の対戦相手との相対能力などです。

今回必要なのは「3着以内かどうか」の0/1ではなく、P(top3) という確率値です。モデルが0.55と出力したなら、その0.55という値そのものを予測として評価します。

そのため学習後は model.predict_proba() を使い、閾値で0/1に変換する前の確率を評価とモデル比較に渡します。1

既存サービスがある中で、自分で実装した理由

競馬データを使った予測は、すでにさまざまなサービスで提供されています。

例えば、JRA-VANでは「データマイニング予測」として、走破速度を予測するタイム型と、競走馬同士の勝敗から強さを評価する対戦型の予測が提供されています。

また、netkeibaには、利用者が条件を設定してオリジナルAI予想を作成できる「UMAI予想ビルダー」があります。

整理すると、今回の取り組みとの位置づけは次のようになります。

例 提供・公開しているもの
JRA-VANのデータマイニング タイム型・対戦型による競走馬の予測
netkeibaのUMAI予想ビルダー オリジナルAI予想の作成や、AIモデルによる出走馬の評価
今回の取り組み 各馬の3着内確率を予測する設計と、特徴量生成・学習・評価・モデル選択のコード。公開版は合成データで動作確認できる

こうしたサービスがすでにある中で自分でも実装したかったのは、予測値が出るまでの設計判断を、自分でコードにして確かめたかったからです。

例えば、

  • どの時点までの履歴を特徴量に使うのか
  • 何をValidationで選ぶのか
  • 微小な改善でモデルを入れ替えるのか
  • 本番モデルを固定したあと、最新の履歴をどう入力へ反映するのか
  • 結果が出る前の予測をどう残し、その後どう評価するのか

といった部分です。

この記事では、予測サービスそのものを作ることよりも、こうした判断をどう実装し、後から検証できる形にしたかを中心に扱います。

なお、既存サービスと同一条件での性能比較は行っていません。そのため、JRA-VANやUMAI予想ビルダーに対する予測精度の優劣は主張しません。

特に、指数やスコアとして提供される予測を、そのまま本記事の P(top3) と同じ確率として扱うことはできません。Brier Scoreなどで比較するなら、対象レース、予測時刻、予測対象、出力の意味をそろえた評価設計が別途必要です。

また、公開リポジトリでは権利や再配布条件に配慮し、実データや本番モデルは公開版から分離しています。公開しているのは、自作コードと合成データを使って処理の仕組みを確認できる範囲です。

オッズは非市場モデルに入れない

競馬には、もう一つ強い比較対象があります。オッズです。

オッズには、多数の参加者の情報や判断が集約されています。そのため、「過去成績からモデルを作れたこと」と「市場より追加的な情報を持っていること」は別の問題です。

そこで、本プロジェクトの非市場モデルではオッズや人気を説明変数から除外しました。市場情報を使う経路と非市場モデルを分けることで、「過去成績などから得られる情報だけで何が予測できるのか」を確認しやすくしています。

なお、公開版には市場モデルとの比較処理自体は含めていません。また、比較用に保存する市場snapshotは、非市場モデルの特徴量とは分離して管理しています。

これは競馬に限らず実務でも同様です。既存ルールや人間の判断など、すでに使われている強いベースラインがある場合、「何を超えるモデルを作っているのか」をあらかじめ決めておく必要があります。

2. 実プロジェクトでは、期間ごとにデータの役割を分けた

実データを扱う上で重要だったのは、データを単純にランダム分割しなかったことです。

初期のHistorical評価から本番モデル作成までを単純化すると、次のようになります。

期間 役割 そのデータで行うこと
2010–2015 Warm-up 後のレースに必要な過去実績を生成する
2016–2022 初期Train 初期historical評価で特徴量 X と結果 y の関係を学習する
2023–2024 Validation 時間順を守ってモデルや特徴量構成を比較する
2025 初期Historical Test 当時選択済みだったモデルを、さらに未来の期間で評価する
2016–2025 Production refit 確定した仕様で本番モデルを再学習する
2026年の対象日前まで 履歴更新 確定済み結果を最新の特徴量生成に使う
2026年の事前記録対象 Prospective evaluation 発走前に固定した予測を結果取得後に評価する

タイムラインのイメージは次の通りです。

2010────────2015│2016────────2022│2023────2024│2025│2026────→
                 │                │             │    │
   Warm-up       │ Initial Train  │ Validation  │Test│ Prospective

ここで注意したいのは、2025年は初期のhistorical評価ではheld-out期間として使ったものの、その結果をすでに見た現在では、新しいモデル改善に対する未見Testではないということです。

現在の改善では、モデルや特徴量構成の検討は2024年までのデータを用いた時間順の検証で行い、仕様を確定したあと、本番用モデルは2016–2025を使ってrefitします。

つまり、2010–2025をまとめてランダムにTrain / Testへ分けているわけではありません。同じデータでも、「履歴生成」「モデル学習」「モデル選択」「historicalな評価」「本番学習」で役割を分けています。

Warm-up:学習開始時点の過去実績を作る期間

例えば、2016年1月に出走する馬について career_starts(通算出走数)や career_top3(通算3着内数)を作ろうとすると、それ以前の競走歴が必要です。

もし2016年からのデータしか持っていなければ、2016年初頭の馬は全員「過去出走なし」に近い状態になります。

そこで2010–2015年を、履歴を温めるためのWarm-up期間として使いました。この期間は、初期モデルで正解ラベルを学習する対象期間とは分けています。

どの行でも、履歴として利用するのは対象日より前のデータだけです。

history_date < target_date

2016年1月の特徴量を作るときに、2016年12月の結果を使うような時系列リークは起こしません。

Train、Validation、Testの役割を分ける

学習期間では、各レースについて、

その日より前の履歴 → 特徴量 X
そのレースの結果   → 正解ラベル y

という組を作ります。

初期historical評価では2016–2022を主要な学習期間とし、2023–2024をモデルや特徴量構成などの比較に使いました。ここでも時間順を守り、2023年を予測するために2024年の結果を学習させることはしません。

初期パイプラインでは、2025年をモデル選択から分離したHistorical Testとして使いました。これは、Validationで選んだモデルが、さらに未来の期間でも機能するかを確認するためです。

一度見たTestは、もう「未見」ではない

実際に進める中で意識するようになった点です。

2025年のデータを一度評価に使ったあと、その結果を見ながら特徴量やモデルを改善した場合、2025年はもう次のモデルに対する未見のTestではありません。

そのため現在は、初期の2025年評価を当時の仕様に対する記録として残しています。

後からモデルを改良した際に、

新モデルも2025年という未知データで性能確認済み

と読み替えることはしません。

現在のモデルに対する本当の未見評価は、事前に予測を固定する2026年以降のProspective evaluationになります。

3. 本番モデルでは、仕様を決めたあと過去データを使い直す

モデル構造や特徴量構成などの検討を終え、2026年を実際に予測する段階では、2025年までの結果はすでに確定しています。

そこで、本番モデルの再学習では次の期間を使います。

  • Warm-up:2010–2015
  • Production fit:2016–2025

ポイントは処理の順序です。

特徴量定義・モデル構造・ハイパーパラメータ・選択ルールを決める
        ↓
仕様を確定
        ↓
2016–2025でProduction refit
        ↓
学習済みモデルを固定

2025年をProduction refitに使うことと、2025年を未見のTestとして評価することは別です。

一度再学習に含めた2025年を、新しいモデルの独立したTestとして扱うことはできません。

4. 「固定するモデル」と「更新する特徴量」を分ける

本番運用では、モデルの固定と最新データの反映を区別することが重要でした。

例えば、2026年10月4日のレースを予測するとします。

モデルの重みやパラメータは、2016–2025年で学習した状態で固定します。

しかし、予測の入力データまで2025年末で止める必要はありません。2026年10月3日までに終了しているレースは、10月4日の時点ではすでに確定した過去です。

モデルを固定することと、予測時点の最新情報を入力特徴量へ反映することは別です。

例えば、ある馬が2026年9月に好走していれば、career_top3 や直近成績などの特徴量にはその結果を反映します。

一方で、その2026年9月のデータを使ってXGBoost自体を再学習するわけではありません。

モデルの更新と、予測時点の特徴量更新を分けて管理しています。

この考え方は、需要予測や離反予測などでも同様に利用できます。

5. 将来評価では、結果を見る前に予測を固定する

バックテストで良い結果が出ても、未来を予測できるかは別問題です。

そのため実プロジェクトでは、2026年以降についてProspective evaluationを行う設計にしています。

重要なのは、レース結果が判明する前に予測を確定させて保存することです。

保存する情報には、例えば次のものがあります。

  • 各馬の P(top3)
  • モデルのバージョンと識別ハッシュ
  • 非市場モデルに入力したデータ
  • 比較用として別途保存した市場snapshot
  • 予測を固定した時刻

発走前に予測ファイルをGitHubへcommitし、GitHub側の記録も含め、結果判明後に作成・変更された予測と区別できるようにします。

レース終了
↓
公式結果取得
↓
top3_label を付与
↓
Brier Score / Log Loss を計算

これにより、結果を見た後の予測値の変更、いわゆる後知恵による修正を防ぎます。

また、単一レースの的中・不的中だけで評価せず、複数レースの予測を蓄積してから評価します。

6. 公開デモは、実プロジェクトの流れを簡略化している

公開リポジトリには実データを同梱していません。

そのため公開版では合成データを生成し、時間順に分割します。

dates = np.array(sorted(frame["race_date"].unique()))
cut = dates[max(1, int(len(dates) * train_fraction)) - 1]

train = frame.loc[frame["race_date"] <= cut].copy()
holdout = frame.loc[frame["race_date"] > cut].copy()

前半を学習、後半を評価に使います。

ただし、この後半データはモデル選択にも使用しているため、役割としてはValidationに近いものです。公開デモの後半スコアを独立したTestの性能として解釈することはできません。

実プロジェクトの、

Train
↓
Validation
↓
Historical Test
↓
Production refit
↓
Prospective evaluation

という一連の流れを完全に再現するものではなく、特徴量生成や学習、モデル選択の実装を手元で確認するためのデモです。

7. Train / Testを時間で分けても、特徴量が未来を見たら意味がない

時系列データで注意すべき問題がData Leakageです。

例えば、次の処理を全期間に対して実行するとします。

df.groupby("horse_id")["top3_label"].mean()

2021年のレースを予測する特徴量の中に、2022年以降の結果まで含まれる可能性があります。

行の分割を時間順にしても、特徴量の計算処理で未来のデータを参照すると、評価値を信用できなくなります。

そのため、履歴生成の計算では次の条件を明示します。

history_date < target_date

公開コードでは、予測対象より前の日付だけを履歴として扱っています。

prior = t[:, None] > t[None, :]
履歴候補の日付 利用可否
予測対象より前 ○
予測対象と同日 ×
予測対象より後 ×

同日の別レース結果も使いません。

レース順や結果確定時刻までの情報を扱っていないため、「同じ日だから利用可能だった」と仮定しないためです。

この条件はpytestにも組み込んでおり、将来や同日のラベルを書き換えても、それ以前の特徴量が変化しないことを確認しています。

実装:

src/keiba_place_ml/features.py

8. 履歴が少ない馬の「100%」をそのまま使わない

出走回数が少ない馬の極端な率をそのまま使うと、少数の結果を強く反映しすぎる可能性があります。

馬 過去3着内回数 / 出走数 単純な3着内率
馬A 1 / 1 100%
馬B 10 / 20 50%

1回中1回と20回中10回では、同じ「率」でも観測数が異なります。

そこで、Beta事前分布を使った二項比率の事後平均と同じ形の縮約を行いました。

alpha = prior_mean * prior_strength
beta = (1.0 - prior_mean) * prior_strength

shrunk_rate = (
    successes + alpha
) / (
    starts + alpha + beta
)

出走数が少なければ全体平均に近づき、出走数が増えるほどその馬自身の実績が反映されます。

実装では prior_mean は学習データの正例率から求め、prior_strength = 6.0 を固定しています。

説明用に prior_mean = 0.25、prior_strength = 6.0 と置くと、次のようになります。

馬 単純率 縮約後の率
馬A(1 / 1) 100% 35.7%
馬B(10 / 20) 50% 44.2%

少数履歴による100%が、そのままモデルへ入力されるのを抑えられます。

また、np.log1p(starts) も特徴量として渡します。成績の率と、その率が何回の観測に基づくかの両方をモデルへ渡すためです。

この処理は、通算成績だけでなく、芝・同距離・同競馬場などの実績にも適用しています。

9. 「今回の相手の中でどれくらい強いか」を特徴量にする

過去3着内率40%という数値だけでは、そのレースで優位かどうかは分かりません。

相手が20%前後の馬ばかりなら相対的に高く、50%前後の馬ばかりなら低くなります。

そこで、

自分の値 - 自分以外の出走馬の平均

という相対特徴量をleave-one-outで計算しました。

peer_sum = sums - values.fillna(0.0)
peer_count = counts - valid
peer_mean = peer_sum / peer_count.where(peer_count.gt(0))

relative_value = values - peer_mean

計算対象には、例えば次のような特徴量があります。

rel_career_top3_vs_others
rel_same_distance_top3_vs_others
rel_same_course_top3_vs_others
rel_recent3_finish_vs_others
rel_recent3_time_vs_others

着順やタイムのように値が小さい方が良い変数は、差の方向を反転させています。

相対特徴量における前提条件のチェック

相対特徴量は、出走馬が一部欠けると値が変わります。

そのため、コード内で assert_full_field_context() を呼び出し、レース内の行数と field_size が一致しているかを確認しています。

馬の重複や、同一レース内で field_size が一致しない場合もエラーにします。

不完全なデータからの誤計算を防ぐため、計算式だけでなく、特徴量を計算してよい前提条件もコード化しています。

10. ロジスティック回帰を基準に、XGBoostを比較する

公開版では、次の2つを実装しています。

  • L2正則化Logistic Regression
  • 相対特徴量を加えたXGBoost

比較対象となるシンプルなベースラインとして、ロジスティック回帰を置いています。

ロジスティック回帰とPipeline

前処理は次の構成です。

入力 前処理
数値特徴量 中央値補完、欠損indicator、標準化
カテゴリ特徴量 UNKNOWN 補完、One-Hot Encoding
LogisticRegression(
    C=1.0,
    penalty="l2",
    solver="lbfgs",
    max_iter=3000,
)

前処理の統計量を全データから計算すると、評価期間の情報が前処理に入る可能性があります。

そのため、ColumnTransformer と Pipeline を使い、Trainデータだけから .fit() します。

from keiba_place_ml.model import fit_logistic, predict_logistic

model, prior_mean = fit_logistic(train)

probability = predict_logistic(
    model,
    holdout,
    prior_mean=prior_mean,
)

縮約に使う prior_mean も、予測対象データから再計算せず、学習時の値を使用します。

XGBoost

比較対象としてXGBoostも使用しています。

XGBClassifier(
    objective="binary:logistic",
    eval_metric="logloss",
    tree_method="hist",
    n_estimators=500,
    learning_rate=0.03,
    max_depth=3,
    min_child_weight=20,
    reg_lambda=5.0,
    subsample=0.8,
    colsample_bytree=0.8,
)

公開デモでの比較は、「モデル+特徴量構成」の比較です。

ロジスティック回帰とXGBoostで使用している特徴量セットが完全に同じではないため、この結果だけから「XGBoostというアルゴリズムがロジスティック回帰より優れている」とは言えません。

アルゴリズムだけの差を検証する場合は、同じ特徴量を使った切り分け比較(ablation)が必要です。

実装:

src/keiba_place_ml/model.py

11. Accuracyではなく、確率そのものを評価する

今回は P(top3) を予測するため、主指標にはBrier Scoreを使いました。

\mathrm{Brier}
=
\frac{1}{N}
\sum_{i=1}^{N}
(p_i-y_i)^2

例えば、実際に3着以内だった馬(y_i = 1)に対して0.90と予測した場合、Brier lossは、

(0.90 - 1)^2 = 0.01

です。

一方、0.55と予測した場合は、

(0.55 - 1)^2 = 0.2025

となります。

0.5を閾値にした二値分類ではどちらも正解ですが、確率予測としては差があります。

Brier Scoreは、予測確率そのものの損失を評価するproper scoring ruleです。

また、強く確信した予測を外したときに大きな損失となるLog Lossも副次的に確認します。

なお、Brier Scoreが良好でも、

0.7と予測した馬が、同程度の予測を受けた集団の中で実際におよそ70%の割合で3着以内に入っているか

というcalibrationは別途確認する必要があります。

12. 1頭を1票ではなく、1レースを1票として評価する

出走頭数が異なるレースを出走馬単位で単純平均すると、16頭立てのレースは8頭立てのレースより2倍の重みを持ちます。

そこで、1レースを1単位として評価するrace-macro評価を使いました。

まずレース内で損失を平均し、その平均値をレース間で平均します。

race_scores = work.groupby("race_id").agg(
    brier=("_brier", "mean"),
    log_loss=("_log_loss", "mean"),
)

race_macro_brier = race_scores["brier"].mean()

runner単位のmicro評価も診断用には残します。

どの単位を等しく扱うかは評価設計の問題です。顧客、店舗、病院など、グループごとのデータ数が異なる分析でも同じ論点があります。

13. 微小な改善だけではモデルを入れ替えない

複数のモデルを評価すると、race-macro Brierが、

現行モデル : 0.1492
新モデル   : 0.1490

のような僅差になることがあります。

この程度の差で頻繁にモデルを入れ替えると、Validationデータの揺らぎに反応しているだけかもしれません。

そこで、one-standard-error(1-SE)の考え方を使った選択ルールを導入しました。

まず、race-macro Brierが最小のモデルを point-best とします。

次に、各レースについて、

\mathrm{loss}_{\mathrm{incumbent}}
-
\mathrm{loss}_{\mathrm{point-best}}

を計算します。

同じレースで両モデルを比較するため、paired comparisonになります。

さらに、同日に開催されるレースは馬場や天候などの条件を共有する可能性があります。そのため、損失差の標準誤差はrace dateでclusterします。

現行モデルを維持する条件は次の通りです。

\mathrm{mean}
(
\mathrm{loss}_{\mathrm{incumbent}}
-
\mathrm{loss}_{\mathrm{point-best}}
)
\leq
1 \times
\mathrm{SE}_{\mathrm{date\ cluster}}

つまり、現行モデルの悪化幅が、最良モデルとの差の1標準誤差以内なら現行モデルを維持します。

これは統計的有意差検定ではありません。

Validation上の微小差への過反応や、不要なモデル入れ替えを抑えるための選択ルールです。

実装:

src/keiba_place_ml/model_selection.py

14. 実験上の前提条件を、テストコードにする

本プロジェクトでは、ソフトウェアの動作確認だけでなく、機械学習の実験が成立するための前提条件もpytestでテストしています。

守りたい条件 公開版での確認内容
時系列リーク防止 将来ラベルを書き換えても過去特徴量が変化しないこと
非市場性の維持 入力カラムに win_odds などが含まれる場合にエラーを出すこと
前提条件の検証 不完全なレースデータからの相対特徴量生成を拒否すること
数値の健全性 出力確率の形状、NaN / Inf、0〜1の範囲を検査すること
選択ルールの維持 同等性能の場合に現行モデルを維持すること

例えば、市場データ混入を防ぐテストです。

def test_market_columns_are_rejected():
    with pytest.raises(ValueError):
        validate_market_free(["age", "win_odds"])

この検査は列名を見ています。そのため、オッズ由来の値を別名の列に入れた場合まで自動検出できるわけではありません。

コードによる検査とあわせて、特徴量の出所や生成経路も管理する必要があります。

GitHub Actionsでは、pushやPull Requestのたびに ruff と pytest を実行しています。

- run: ruff check src tests scripts
- run: pytest -q

CIの役割は予測性能を保証することではありません。

既知の実験上の不変条件を、コード変更によって壊していないことを継続的に確認するためのものです。

テスト:

tests

15. 合成データでパイプラインを動かす

公開リポジトリには実際のレースデータを同梱していません。

代わりに合成データ生成器を用意し、手元でパイプライン全体を動作確認できるようにしています。

Python 3.11以上の環境で実行できます。

git clone https://github.com/ryotamatsuki/keiba-place-ml-showcase.git
cd keiba-place-ml-showcase

python -m pip install -e ".[dev,model]"
python scripts/train_demo.py --races 600
pytest -q

これで、データ生成から時間分割、学習、予測、評価、モデル選択までの一連の処理が実行されます。

コンソール出力項目 意味
train_max_date / test_min_date 学習と評価の時間境界
race_macro_brier race-macro Brierの評価値
point_brier_best 点推定で最良のモデル
winner 選択ルール適用後の採用モデル
incumbent_retained 現行モデルを残したか

最低限のベースライン 3 / n との比較

出走頭数を n とすると、情報を使わない予測として、

P(top3) = 3 / n

を置くことができます。

from scripts.train_demo import chronological_split
from keiba_place_ml.synthetic import make_synthetic_panel
from keiba_place_ml.model_selection import candidate_score

panel = make_synthetic_panel(
    races=600,
    seed=20261003
)

_, holdout = chronological_split(panel)

p_uniform = 3.0 / holdout["field_size"].to_numpy()

score = candidate_score(
    holdout,
    p_uniform,
    name="uniform"
)

print(score)

seedを固定して生成した後半120レース、1,495行での実行結果例です。2

予測方法 race-macro Brier ↓ race-macro Log Loss ↓
一様予測 3 / n 0.184707 0.555289
Logistic Regression 0.124767 0.392059

この合成データでは、特徴量を使ったモデルの損失が小さくなっています。

ただし、生成器自体に特徴量と結果の関係を組み込んでいるため、この結果はパイプラインの動作確認用デモです。

実際の競馬における予測性能を示すものではありません。

16. 自分でデータを用意した場合、どう予測に使うのか

ここまでの公開デモでは合成データを使いました。

では、自分で競馬データを用意した場合、このリポジトリをどう使うのでしょうか。

公開版の役割を一言で言うと、

必要な列をそろえた学習用パネルを用意すれば、特徴量変換・モデル学習・確率予測・評価の部分を利用できる

というものです。

逆に、生の競走結果CSVを渡すだけで、データ取得から履歴集計、予測対象レースの入力作成までをすべて自動で行うツールではありません。

データ取得元ごとのスクレイピングやパース処理、実データそのものは公開リポジトリから分離しています。

学習データは「1行 = 1レース × 1頭」

例えば、自分で次のような学習用パネルを用意します。

race_id
race_date
horse_id
top3_label
field_size

draw_pct
age
assigned_weight_kg
assigned_weight_delta_from_prev_kg
days_since_prev
distance_change_from_prev_m
surface_changed_from_prev

career_starts
career_top3
turf_starts
turf_top3
same_distance_starts
same_distance_top3
same_course_starts
same_course_top3

recent3_finish_pct_mean
recent3_top3_count
recent3_open_plus_count
recent3_graded_count
recent3_relative_time_mean
recent4_early_pos_pct_mean

front_forward_share
distance_m
sex
racecourse
race_class

ここで重要なのは、career_top3 や recent3_* などが、そのレースより前に確定していた情報だけで計算されていることです。

例えば、2024年5月1日のレース行を作るなら、その行の履歴特徴量に2024年5月1日以降の結果を混ぜてはいけません。

学習データには、実際に3着以内だったかを示す top3_label も付けます。

イメージとしては、

過去の確定済み情報 → 特徴量 X
そのレースの結果   → top3_label

を、各レース・各出走馬について時系列順に作っていくことになります。

まず過去データでモデルを学習する

例えば、自分で my_training_panel.parquet を作ったとします。

XGBoost版であれば、次のように学習できます。

import pandas as pd

from keiba_place_ml.model import fit_xgb_relative

history = pd.read_parquet("my_training_panel.parquet")
history["race_date"] = pd.to_datetime(history["race_date"])

train = history.loc[
    history["race_date"] <= "2025-12-31"
].copy()

xgb_model, xgb_prior_mean = fit_xgb_relative(train)

fit_xgb_relative() では、履歴率の縮約、レース内相対特徴量の生成、前処理、XGBoostの学習までを行います。

ロジスティック回帰を使う場合は、同様に fit_logistic() を利用できます。

from keiba_place_ml.model import fit_logistic

logistic_model, logistic_prior_mean = fit_logistic(train)

ここで返される xgb_prior_mean や logistic_prior_mean は、履歴率の縮約に使う学習時の正例率です。

予測時にも、それぞれのモデルを学習した際に得た値を使うため、モデル本体と一緒に保持しておきます。

次に、予測したいレースのデータを作る

例えば、2026年10月4日のレースを予測するとします。

このときは、予測対象レースについても学習時と同じ形式で特徴量を作ります。

ただし、まだレース結果は分からないので、予測時の入力には top3_label は必要ありません。

例えば1頭について、

race_id = 20261004-KYOTO-11R
horse_id = H001
field_size = 16

career_starts = 12
career_top3 = 5

same_distance_starts = 4
same_distance_top3 = 2

recent3_finish_pct_mean = ...
recent3_relative_time_mean = ...
...

といった行を作り、それを出走馬全頭分そろえます。

このときの履歴特徴量には、予測日の前日までに確定している結果だけを反映します。

つまり、

モデル
2016–2025で学習した状態を固定

入力特徴量
2026年10月3日までの確定済み履歴を反映

という状態です。

各馬の P(top3) を出す

予測対象レースを target_race.csv として保存していれば、XGBoost版では次のように予測できます。

import pandas as pd

from keiba_place_ml.model import predict_xgb_relative

target = pd.read_csv("target_race.csv")
target["race_date"] = pd.to_datetime(target["race_date"])

target["p_top3"] = predict_xgb_relative(
    xgb_model,
    target,
    prior_mean=xgb_prior_mean,
)

result = target[
    ["horse_id", "p_top3"]
].sort_values(
    "p_top3",
    ascending=False,
)

print(result)

例えば出力が、

horse_id   p_top3
H005       0.462
H011       0.381
H002       0.337
H008       0.291
...

となった場合、

H005 : 3着以内確率 46.2%
H011 : 3着以内確率 38.1%
H002 : 3着以内確率 33.7%
H008 : 3着以内確率 29.1%

という予測になります。

ここで出しているのは順位予想そのものではなく、各馬について推定した P(top3) です。

XGBoost版では、全出走馬をまとめて渡す

XGBoost版にはもう一つ重要な条件があります。

今回のモデルでは、

自分の能力値
-
自分以外の出走馬の平均

というレース内相対特徴量を使っています。

そのため、1頭だけを抜き出して予測することはできません。

16頭立てなら16頭すべて、12頭立てなら12頭すべてを同じDataFrameに含める必要があります。

公開コードでは assert_full_field_context() により、

レース内の行数 == field_size

であることを確認しています。

さらに、

  • 同じ race_id × horse_id が重複していないか
  • 同一レース内で field_size が一致しているか
  • 実際に全出走馬がそろっているか

も確認します。

これは、相手が1頭欠けるだけでも「自分以外の平均」が変わってしまうためです。

全体の流れ

自分のデータで使う場合の流れをまとめると、次のようになります。

① 過去レースのデータを用意する
        ↓
② 各レースについて、その時点より前の履歴だけで特徴量を作る
        ↓
③ top3_label を付けた学習パネルを作る
        ↓
④ fit_xgb_relative() / fit_logistic() で学習
        ↓
⑤ 学習済みモデルを固定
        ↓
⑥ 予測対象レースについて、全出走馬分の最新特徴量を作る
        ↓
⑦ predict_xgb_relative() / predict_logistic()
        ↓
⑧ 各馬の P(top3) を得る
        ↓
⑨ 発走前に予測を保存
        ↓
⑩ レース終了後に top3_label を付けて評価する

レース終了後に初めて評価する

予測時点では top3_label は存在しません。

レース終了後、結果を取得して初めて正解ラベルを付けます。

例えば、予測済みDataFrameに結果を結合して、

from keiba_place_ml.model_selection import candidate_score

score = candidate_score(
    completed_race,
    completed_race["p_top3"].to_numpy(),
    name="my_model",
)

print(score)

のように評価できます。

これにより、

  • Brier Score
  • Log Loss
  • race-macro評価

を使って、

発走前に出していた確率が、実際の結果に対してどうだったか

を確認できます。

この、

学習時
過去に確定していた特徴量 + 結果ラベル

予測時
発走前に利用できる特徴量だけ

評価時
結果確定後に初めてラベルを追加

という境界を崩さないことが、このリポジトリの基本的な使い方です。

公開リポジトリが担当する範囲

整理すると、公開版が担当する範囲は次のようになります。

自分で用意する部分
-------------------------
データ取得
↓
履歴DBの構築
↓
各レース時点の学習用パネル作成
↓
予測対象レースの入力データ作成


公開repoで利用できる部分
-------------------------
履歴率の縮約
↓
レース内相対特徴量の生成
↓
Logistic Regression / XGBoost の学習
↓
P(top3) の予測
↓
Brier Score / Log Loss による評価
↓
モデル比較・選択

つまり、このリポジトリは「競馬データを全部取得してくれるツール」ではありません。

自分で整えた時系列データを、リークを避けながら学習・予測・評価するためのML部分を切り出したものです。

データ取得元に依存する処理を分離しているため、考え方自体は競馬以外の時系列分類問題にも持ち出せます。

17. 「取得できる」と「再配布できる」は別だった

この取り組みでは、機械学習のコード実装とは別に、取得したWebデータをどこまで公開・再配布できるかも考える必要がありました。

整理すると、

取得できる
≠
分析に利用できる
≠
再配布できる

です。

そこで、研究用環境と公開用リポジトリを分けました。

管理場所 内容
非公開研究環境 実データ、取得処理、履歴更新、本番モデル、予測固定、結果照合
公開リポジトリ 自作MLコード、テスト、ドキュメント、合成データ生成器

公開リポジトリには、実レースのrow-level履歴や学習済みモデルのbundleなどを含めていません。

これは再現性を諦めるというより、再現可能に公開できる範囲を明確にするためです。

公開版で再現できるのは、特徴量変換・学習・予測・評価・モデル選択というMLパイプラインの仕組みです。

公開方針:

DATA_POLICY.md

18. 作ってみて分かったこと

最初に想定していたメイン作業は、

model.fit(X, y)

でした。

しかし実際には、その前後にあるデータ管理や評価、運用の設計に多くの時間を使うことになりました。

データ取得
↓
時点管理
↓
履歴生成
↓
特徴量生成
↓
リーク検証
↓
Train / Validation / Test
↓
モデル学習
↓
確率評価
↓
モデル選択
↓
Production refit
↓
モデル固定
↓
最新履歴から特徴量更新
↓
未来の予測
↓
予測固定
↓
結果取得
↓
Prospective evaluation

特に大きかった学びは次の3点です。

特徴量の性能より、情報の時点管理を先に考える

どれほど効果的に見える特徴量でも、未来の情報が混ざれば評価値を信用できなくなります。

モデルを高度化する前に、

この値はいつ知ることができたのか

を定義することが重要でした。

Accuracyではなく、意思決定に使う出力を評価する

今回は確率として予測値を扱うため、単なる的中・不的中だけでなく、予測確率そのものの損失を評価する必要がありました。

そのためにBrier ScoreやLog Loss、calibrationといった考え方が必要になります。

「モデルを作る」と「MLシステムを作る」は違う

学習済みモデルを1つ作るだけでなく、データの時点、評価期間、モデル選択ルール、本番用refit、予測の保存、CI、公開範囲まで決めることで、未来のデータに対して後から検証できる仕組みになります。

今回、公開リポジトリに「自分のデータを入れるならどこからか」という視点を加えてみると、もう一つ整理できたことがあります。

モデルのコードだけ公開しても、それだけでは実際の予測には使えません。

必要なのは、

生データ
↓
その時点で利用可能な情報へ変換
↓
モデルに渡せる学習パネル
↓
学習
↓
未来時点の入力作成
↓
予測

という一連のデータフローです。

つまり、機械学習の実装では「どのモデルを使うか」だけでなく、モデルへ何を、いつ、どの形で渡すかまで含めて設計する必要があります。

おわりに

始めたときは予測そのものに興味がありましたが、実装を進めるうちに、未来のデータに対して検証可能な形で機械学習モデルをどう実装するかに関心が移っていきました。

CSVを分割してスコアを出すところから一歩進んで実際の未来を予測しようとすると、予測時点、その時点で利用できる情報、履歴集計、前処理、評価単位、モデル選択、本番用refit、将来評価まで考える必要があります。

今回、特に実務的だと感じたのは、モデルの仕様を決めたあと過去データで本番用にrefitし、その後はモデルを固定しながら最新の履歴から入力特徴量を更新するという流れでした。

そして、その予測を結果が分かる前に保存しておく。

そこまで行うことで、

このモデルは未知の未来でも機能するのか

を後から評価できます。

自分のテーマで時系列MLを試すなら、最初に決めるべきなのはアルゴリズムではなく、

「いつ予測するのか」と「その時点で何を知ることができるのか」

なのかもしれません。

今回の実装が一例として参考になれば幸いです。

GitHub - keiba-place-ml-showcase

執筆について

本記事は、筆者自身が行った実装・検証と公開リポジトリをもとに作成しています。

文章の構成整理・推敲には生成AI(ChatGPT)を利用し、技術内容と記載事項は公開前に筆者が確認しています。

参考資料

  1. 各馬の P(top3) は周辺確率です。独立した二値分類モデルの出力なので、レース内の確率合計が3になる制約は入っていません。着順全体の同時分布も推定していません。目的変数は順位としての3着以内で、馬券の払戻条件や収益性は別の評価対象です。 ↩

  2. 掲載結果の実行環境はNumPy 2.3.5、pandas 2.2.3、scikit-learn 1.8.0です。記事のコード確認対象はコミット 9563bf7 です。実行結果は依存ライブラリのバージョンによって差が出る場合があります。 ↩

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?