LightGBMで競馬の複勝確率を予測するときは、確率を出すモデルと買うかどうかを決める戦略を別の部品として作り、別々に評価するのがおすすめです。「いくら儲かったか」だけで判断すると、モデルが良かったのか、買い方の条件がたまたま噛み合っただけなのか区別できません。

この記事では、シリーズ第3回として、LightGBMの二値分類による複勝圏確率の予測、validationでの確率校正と条件選択、testでの凍結評価、オッズ欠損時の見送り実装をまとめます。入力は第2回で整えた発走10分前のデータです。

学習・校正・凍結評価の流れ

この記事の結論

  • モデルの役割は複勝圏(3着以内)に入る確率を出すことだけ。LightGBMの二値分類で学習する。
  • 着順・払戻・確定人気・確定オッズは入力に使わず、学習直前の検査で混入を止める。
  • 確率校正と戦略の条件(しきい値・オッズ帯)はvalidationだけで決め、testでは一切変えない。
  • testでROI 111.7%でも、validationで93.5%だった比較モデルは採用しなかった。
  • オッズが取れないときは補完せず、理由付きで見送る。

モデルの設計:LightGBMで複勝圏確率を予測

  • 目的変数:着順の列 goal_num から作った「3着以内に入ったか」(0/1)
  • 入力:出馬表、過去成績の集計、発走10分前の単勝・複勝オッズ
  • 使わない列:着順、払戻、確定人気、確定オッズ(goal_num もラベル作成専用)

結果由来の列が入力に残っていたら、学習前に例外で止めます。

FORBIDDEN = {"rank", "goal_num", "fukusho1", "fukusho2", "fukusho3",
             "tasho", "time_s", "odds", "popular"}
leaked = set(feature_columns) & FORBIDDEN
if leaked:
    raise ValueError(f"結果由来の列を検出: {sorted(leaked)}")

時系列でのデータ分割

区分期間行数役割
train2013年1月〜2020年12月301,416モデルの学習
validation2021年41,212確率の校正と条件の選択
test2022〜2023年65,337条件を固定した最終評価

確率校正と、3条件ANDのスコアを区別する

分類モデルは馬の順番を正しく並べられても、確率の値そのものが正しいとは限りません。0.42と出た馬が実際に約42%の割合で3着以内に来るかは、別途確認が必要です。戦略は確率とオッズを組み合わせて判断するため、値がずれると判断もずれます。

検証コードは2021年のvalidationをレース単位で半分に分けます。ソートしたレースIDから乱数シード37で50%を校正用に抽出し、残りを条件選択用にします。train・validation・testの年単位の分割は時系列ですが、validation内の二分は時間順ではありません。時間変化への強さを保証する分割ではない点に注意が必要です。

校正器はIsotonicRegressionです。EV戦略は校正後の確率 p_cal × FUK_10_low を使います。一方、3条件ANDは校正後確率ではなく、p_place_norm = p_raw / レース内のp_raw合計 × 3 を使います。後者は合計を3にそろえたスコアで、0.42を「実際の複勝率42%」と解釈することはできません。

# 3条件AND用。dfは1行1頭で、race_idとモデル出力p_rawを持つ
race_sum = df.groupby("race_id")["p_raw"].transform("sum")
df["p_place_norm"] = df["p_raw"] / race_sum * 3
df["model_rank"] = df.groupby("race_id")["p_place_norm"].rank(
    ascending=False, method="first"
)

上のコードは評価スクリプトのスコア計算部分を抜き出したものです。同点は入力順で順位を付けます。欠測によりレース内の頭数が変わるとスコアも変わるため、レースの入力集合も再現条件になります。

購入条件(戦略):3条件ANDと複勝オッズ帯

比較の中心にした戦略は with_10min × 3条件AND(複勝FUK 1.5–8.0) です。次の3つをすべて満たしたときだけ、100円固定で買う想定にしました。

  • レース内のモデル順位 model_rank が3位以内
  • 正規化スコア p_place_norm が0.42以上(validationの条件選択用部分で決めた値)
  • 複勝オッズが1.5〜8.0の範囲

オッズ帯は高配当狙いではなく、低すぎると控除率に負けやすく、高すぎると確率の推定誤差の影響が大きくなる、その両端を避けるための条件です。複利やマーチンゲールのように負けを取り返すため賭け金を増やす方法は使いません。

testの好成績を採用しなかった例(ROI 111.7%)

比較用に、オッズを入力に使わないLightGBMでも検証しました。

モデル・戦略validation ROItest ROItest購入数
no_odds × 3条件AND(単勝TAN 3–30)93.5%111.7%883点

条件を選んだ期間(validation)では負けていて、選んでいない期間(test)でだけ勝っています。これは「良い戦略」というより「testの期間と偶然相性が良かった」可能性が高いと判断し、採用の根拠にしませんでした。

実装:オッズ欠損時は補完せず見送る

実運用では、オッズが取れない、取得時刻が範囲外、結合に失敗した、といったことが普通に起こります。古い値や確定オッズで埋めると、バックテストだけが実運用より有利になります。欠けていたら見送り、理由を残します。

以下は品質確認と3条件ANDを分けた説明用の擬似コードです。過去集計に使ったコードそのものではありません。

if odds is None:
    return decision(buy=False, reason="missing_prerace_odds")
if model_rank > 3:
    return decision(buy=False, reason="outside_top3")
if p_place_norm < 0.42:
    return decision(buy=False, reason="below_probability_threshold")
if not 1.5 <= place_odds_low <= 8.0:
    return decision(buy=False, reason="outside_fuk_odds_band")
return decision(buy=True, reason="place_3cond_pass", stake=100)

理由が残っていれば、「最近候補が少ない」ときに、データ取得の障害かモデルの判断かをすぐ見分けられます。

モデル比較で固定する条件と並べる指標

固定する条件並べる指標
購入単位(100円)、券種、オッズを見る時点、校正の方法、validationの期間ROI、購入数、的中率、総投資、総払戻、最大ドローダウン、最大連敗

testの収益を見てから条件を変えた実験は、メモとして残しても採用の比較表には混ぜません。

まとめ

  • 確率が正しいかはモデルの問題、どの条件で買うかは戦略の問題として分ける。
  • 校正と条件選択はvalidationで行い、testは凍結して一度だけ評価する。
  • validationとtestで成績が大きく食い違う条件は採用しない。
  • 欠損は補完せず、理由付きで見送る。

次回は、ROIや年ごとのばらつきを公開し、プラスの試算でも採用を見送った理由を解説します。

注意:本記事は研究・技術解説です。利益や的中を保証するものではなく、馬券の購入を勧めるものでもありません。

確認した実装と訂正履歴

2026年10月15日、評価スクリプト evaluate_current_strategy_honest.py の score、mask_3cond、校正と条件選択の処理を照合しました。以前の説明から欠けていた順位3位以内の条件を補い、正規化スコアと校正済み確率を区別しました。品質条件は3条件ANDに数えず、入力の前提として扱います。

当時の数値は保存済み集計との照合であり、モデルを再学習した結果ではありません。第4回に集計CSVと検証上の限界を掲載しています。