競馬AI開発メモ:距離適性を特徴量化すると予測精度は上がるのか?

地方競馬

はじめに

今回は馬の「距離適性」を過去走情報から数値化し、LightGBMの1着予測精度への貢献を検証しました。

「この馬は長距離が得意」「短距離専門」といった直感的な情報が、実際にモデルの予測精度改善に効くのかをデータで確認します。

今回確認すること

  • 過去走の距離情報から距離適性を数値化できるか
  • 距離適性特徴量を追加するとLightGBMの1着予測AUCが改善するか
  • どの距離適性特徴量(8種類)が最も効果的か
  • 距離変更(延長・短縮・同距離)の有無で予測精度に差があるか
  • 人気ありモデルでも距離適性特徴量の効果が残るか(人気に既に織り込まれていないか)

距離適性とは何か

競馬では「距離適性」という概念があります。短距離が得意な馬は長距離では成績が落ちやすく、長距離を得意とする馬は短距離では追走できないと言われます。

今回は「過去走の距離情報から、今走の距離適性スコアを作れないか」を検証しました。使用するのは今走の着順・タイム・上がり・着差などの結果情報ではなく、今走前までの過去走履歴のみです。

使用データ

  • テーブルresult_table
  • 学習期間: 2017-01-01 ~ 2022-12-31 (787,381 件、1着率 0.1000)
  • 検証期間: 2023-01-01 ~ 2023-12-31 (136,354 件、1着率 0.0999)
  • 評価期間: 2024-01-01 ~ 2024-12-31 (138,372 件、1着率 0.0989)
  • 目的変数: 1着予測(1着)、3着内予測(3着内
  • 除外: 帯広(ばんえい)を除外(124,418 件)

距離帯の定義

以下の4区分で距離帯を定義しました。地方競馬の距離分布に合わせています。

距離帯範囲全期間レース数
短距離0~1200m203,687
マイル前後1201~1600m782,185
中距離1601~2000m72,322
長距離2001~99999m3,913

距離の範囲は 800m 〜 2600m、平均 1391.2m、中央値 1400.0m でした。

作成した距離適性特徴量

距離適性を以下の8種類に分けて特徴量化し、それぞれ単独でbaselineに追加して比較しました。

A: baseline

距離適性なしのベース特徴量のみ(比較の基準)

B: 過去平均距離差

過去5走の平均・中央値距離と今回距離との差

C: 距離変更

前走からの距離変更(延長・短縮・変更量・変更率)

D: 同距離帯成績

同距離帯(短・マイル・中・長)での過去成績

E: 近距離成績

今回距離±200m以内の過去走成績

F: 好走距離差

過去3着内レースの距離平均と今回距離との差

G: 距離レンジ

過去走の距離レンジ(最短・最長・レンジ幅・未経験フラグ)

H: 競馬場×距離帯

競馬場×距離帯の組み合わせでの過去成績

ALL: 全距離適性特徴量

B〜Hのすべての特徴量を同時に追加した組み合わせです。

各特徴量の詳しい定義・入力列・リーク対策は feature_definitions.md に整理しています。

比較方法

  • モデル: LightGBM(二値分類、objective=binary
  • 分割: 時系列分割(ランダム分割は使用しない)
  • 評価指標: AUC / logloss / Brier score
  • 過去走特徴量は必ず shift(1) 後に rolling して今走情報のリークを防止
  • 人気なしモデルと人気ありモデルの2系統で比較(人気情報に織り込まれた効果と分離するため)
  • 各特徴量セットを A_baseline に単独追加し、AUC差分で効果を測定

結果

人気なしモデル:1着予測

人気情報を除外したモデルで、距離適性特徴量そのものの効果を確認します。

特徴量セットAUCbaseline差分loglossBrier
A: baseline0.73410.28950.0823
B: 過去平均距離差0.7394+0.00530.28810.0820
C: 距離変更0.7365+0.00240.28890.0822
D: 同距離帯成績0.7502+0.01610.28450.0812
E: 近距離成績0.7474+0.01330.28540.0814
F: 好走距離差0.7364+0.00230.28890.0821
G: 距離レンジ0.7393+0.00520.28810.0820
H: 競馬場×距離帯0.7536+0.01950.28360.0811
ALL: 全距離適性特徴量0.7585+0.02440.28200.0807

最良は ALL: 全距離適性特徴量(AUC 0.7585、baseline比 +0.0244)でした。

人気ありモデル:1着予測

人気情報を含めたモデルで、市場人気に既に織り込まれた効果を差し引いた後の追加改善を確認します。

特徴量セットAUCbaseline差分loglossBrier
A: baseline0.84610.24370.0714
B: 過去平均距離差0.8462+0.00010.24370.0714
C: 距離変更0.8457-0.00040.24390.0714
D: 同距離帯成績0.8464+0.00030.24340.0713
E: 近距離成績0.8465+0.00040.24350.0713
F: 好走距離差0.8458-0.00030.24390.0714
G: 距離レンジ0.8462+0.00010.24370.0714
H: 競馬場×距離帯0.8464+0.00030.24350.0713
ALL: 全距離適性特徴量0.8464+0.00030.24340.0713

最良は E: 近距離成績(AUC 0.8465、baseline比 +0.0004)でした。

補助検証:3着内予測

1着予測だけでなく、3着内予測でも同じ傾向が出るかを確認します(人気なしモデル)。

特徴量セットAUCbaseline差分loglossBrier
A: baseline0.71960.54220.1819
B: 過去平均距離差0.7238+0.00420.53960.1809
C: 距離変更0.7219+0.00230.54070.1814
D: 同距離帯成績0.7304+0.01080.53490.1792
E: 近距離成績0.7296+0.01000.53550.1794
F: 好走距離差0.7217+0.00210.54090.1814
G: 距離レンジ0.7234+0.00380.53990.1810
H: 競馬場×距離帯0.7334+0.01380.53310.1785
ALL: 全距離適性特徴量0.7378+0.01820.53000.1773

3着内予測での最良は ALL: 全距離適性特徴量(AUC 0.7378、baseline比 +0.0182)でした。

特徴量重要度

ALL_distance_features(人気なし・1着予測)モデルの LightGBM feature importance(gain)上位10件です。

順位特徴量importance (gain)全体比
1past3_avg_rank229,25137.8%
2same_place_same_band_avg_rank68,87811.4%
3past3_top3_rate56,2409.3%
4near_distance_avg_rank36,4236.0%
5同走馬の数27,3774.5%
6馬体重26,2334.3%
7same_place_same_band_top3_rate25,6254.2%
8same_place_same_band_runs17,9773.0%
9near_distance_runs15,0942.5%
10same_band_win_rate14,9992.5%

条件別分析

ALL_distance_features(人気なし・1着予測)モデルの評価期間予測を、条件別にAUCで比較しました。

条件サンプル数AUClogloss
距離延長31,1300.75380.2686
距離延長なし101,9400.76270.2770
距離短縮29,4830.76040.2821
距離短縮なし103,5870.76080.2730
同距離72,4570.76360.2749
距離変更あり60,6130.75730.2751
同距離帯経験あり127,6490.76200.2719
同距離帯経験なし10,7230.67330.4014
過去最長より長い距離7,2370.75130.3270
過去最短より短い距離4,2870.73520.3671
過去経験レンジ内121,5460.76060.2687
短距離30,8230.75990.2767
マイル前後96,6120.76020.2832
中距離10,4170.73790.2852
長距離5200.70860.2965

考察

距離適性特徴量は効いたか

人気なしモデルでは最良で AUC +0.0244 の改善が見られました。距離適性特徴量には一定の予測貢献があると考えられます。

人気ありモデルとの差

人気ありモデルでの追加改善は AUC +0.0004 と小さく、距離適性の多くは既に市場人気に織り込まれている可能性があります。

人気なしモデルの改善幅(+0.0244)と比較すると、人気情報の追加によって距離適性の追加効果は縮小したと言えます。

どの距離適性特徴量が効いたか

人気なしモデルで最も効いたのは ALL: 全距離適性特徴量 でした。

条件別に見て効きやすかったところ

評価期間の条件別AUCでは 同距離(AUC 0.7636)が最も高く、同距離帯経験なし(AUC 0.6733)が最も低い結果でした。

条件によってモデルの予測しやすさに差があり、距離変更や未経験距離帯では予測が難しくなる可能性があります。

注意点

今回の結果を解釈する上で注意すべき点をまとめます。

  • 帯広(ばんえい競馬)は分析対象から除外しています(124,418 件)。
  • 距離帯(短距離/マイル/中距離/長距離)の区切りは任意です。区切りを変えると同距離帯成績・競馬場×距離帯成績の値も変わり、結果が変動する可能性があります。
  • 同距離帯成績や競馬場×距離帯成績は、過去走数が少ない馬(新馬・若齢馬)では不安定です。
  • 人気ありモデルでは、距離適性情報の多くが既に市場人気(オッズ)に織り込まれている可能性があります。
  • 今回の評価期間は 2024-01-01 ~ 2024-12-31 の1年間です。他期間・他場では傾向が異なる可能性があります。
  • 本記事はデータ分析・AI開発のメモであり、馬券購入を推奨するものではありません。

次にやること

  • 馬IDが利用可能になった場合の再検証(同名馬混入リスクの解消)
  • 距離帯区分の複数バリエーションでの感度分析
  • 距離適性特徴量と脚質・展開圧力特徴量の組み合わせ効果の検証
  • 条件別に効いた特徴量セットの絞り込み(例: 距離変更時のみ有効な特徴量)
  • 予測確率のキャリブレーション(temperature scaling 等)と、ROI観点での再評価

まとめ

距離適性特徴量を8種類作成し、LightGBMベースラインへの追加効果を検証しました。人気なしモデルでは最良で AUC 0.7341 → 0.7585(+0.0244)の改善が得られました。

距離適性の効果は特徴量の種類・条件によって差がありました。今回のデータの範囲では、ALL: 全距離適性特徴量 が最も有望な追加特徴量でした。

以下、参考。

実装コード

"""
距離適性特徴量の検証実験
"""

import json
import sys
from pathlib import Path

# Add repo scripts/ to sys.path for shared utilities
sys.path.insert(0, str(Path(__file__).resolve().parents[5] / "scripts"))

from article_utils import make_article_dir
from render_article_html import copy_scripts_to_article, render_article_html, save_article_md

repo_root = Path(__file__).resolve().parents[5]
RESULTS_PATH = repo_root / "outputs" / "distance_aptitude" / "results.json"


# ---------------------------------------------------------------------------
# 特徴量セットのラベル / 説明
# ---------------------------------------------------------------------------

FS_KEYS = [
    "A_baseline",
    "B_distance_history",
    "C_distance_change",
    "D_same_band_performance",
    "E_near_distance_performance",
    "F_good_distance",
    "G_distance_range",
    "H_place_distance",
    "ALL_distance_features",
]

FS_LABELS = {
    "A_baseline": "A: baseline",
    "B_distance_history": "B: 過去平均距離差",
    "C_distance_change": "C: 距離変更",
    "D_same_band_performance": "D: 同距離帯成績",
    "E_near_distance_performance": "E: 近距離成績",
    "F_good_distance": "F: 好走距離差",
    "G_distance_range": "G: 距離レンジ",
    "H_place_distance": "H: 競馬場×距離帯",
    "ALL_distance_features": "ALL: 全距離適性特徴量",
}

FS_SHORT_DESCRIPTIONS = {
    "A_baseline": "距離適性なしのベース特徴量のみ(比較の基準)",
    "B_distance_history": "過去5走の平均・中央値距離と今回距離との差",
    "C_distance_change": "前走からの距離変更(延長・短縮・変更量・変更率)",
    "D_same_band_performance": "同距離帯(短・マイル・中・長)での過去成績",
    "E_near_distance_performance": "今回距離±200m以内の過去走成績",
    "F_good_distance": "過去3着内レースの距離平均と今回距離との差",
    "G_distance_range": "過去走の距離レンジ(最短・最長・レンジ幅・未経験フラグ)",
    "H_place_distance": "競馬場×距離帯の組み合わせでの過去成績",
    "ALL_distance_features": "B〜Hをすべて追加",
}

BAND_LABELS = {"short": "短距離", "mile": "マイル前後", "middle": "中距離", "long": "長距離"}


# ---------------------------------------------------------------------------
# ヘルパー
# ---------------------------------------------------------------------------

def load_results():
    """実験結果のJSONを読み込む。ファイル不在ならエラーで終了する。"""
    if not RESULTS_PATH.exists():
        print(f"[ERROR] Results file not found: {RESULTS_PATH}", file=sys.stderr)
        print("[ERROR] 先に scripts/experiments/distance_aptitude_features.py を実行してください", file=sys.stderr)
        sys.exit(1)
    with open(RESULTS_PATH, encoding="utf-8") as f:
        return json.load(f)


def fmt(val, decimals=4):
    """数値をフォーマットする"""
    if val is None:
        return "N/A"
    if isinstance(val, float):
        return f"{val:.{decimals}f}"
    return str(val)


def fmt_diff(base, new):
    """base からの差分を +0.0059 / -0.0021 形式で返す。base が None なら 'N/A'。"""
    if base is None or new is None:
        return "N/A"
    d = new - base
    sign = "+" if d >= 0 else ""
    return f"{sign}{d:.4f}"


def get_test(results_dict, key, metric):
    return results_dict.get(key, {}).get("test", {}).get(metric)


def get_valid(results_dict, key, metric):
    return results_dict.get(key, {}).get("valid", {}).get(metric)


def find_best_fs(results_dict, target_prefix):
    """target_prefix (例: 'win') のうち baseline より良い最良の特徴量セットを返す。"""
    base = get_test(results_dict, f"{target_prefix}/A_baseline", "auc")
    if base is None:
        return None, None
    best_key = None
    best_auc = base
    for fs_key in FS_KEYS:
        if fs_key == "A_baseline":
            continue
        auc = get_test(results_dict, f"{target_prefix}/{fs_key}", "auc")
        if auc is not None and auc > best_auc:
            best_auc = auc
            best_key = fs_key
    return best_key, best_auc


# ---------------------------------------------------------------------------
# 結果テーブルビルダー
# ---------------------------------------------------------------------------

def build_result_table(results_dict, target_prefix):
    """
    | 特徴量セット | AUC | baseline差分 | logloss | Brier |
    形式の Markdown テーブルを返す。
    """
    base_auc = get_test(results_dict, f"{target_prefix}/A_baseline", "auc")

    lines = []
    lines.append("| 特徴量セット | AUC | baseline差分 | logloss | Brier |")
    lines.append("|---|---:|---:|---:|---:|")
    for fs_key in FS_KEYS:
        label = FS_LABELS[fs_key]
        key = f"{target_prefix}/{fs_key}"
        auc = get_test(results_dict, key, "auc")
        ll = get_test(results_dict, key, "logloss")
        bs = get_test(results_dict, key, "brier")
        if fs_key == "A_baseline":
            diff = "—"
        else:
            diff = fmt_diff(base_auc, auc)
        lines.append(f"| {label} | {fmt(auc)} | {diff} | {fmt(ll)} | {fmt(bs)} |")
    return "\n".join(lines)


def build_importance_table(results_dict, target_prefix, top_n=10):
    """
    ALL_distance_features モデルの feature importance 上位を Markdown table で返す。
    """
    entry = results_dict.get(f"{target_prefix}/ALL_distance_features", {})
    imp = entry.get("top_feature_importance", {})
    if not imp:
        return "*重要度データが取得できませんでした。*"

    items = sorted(imp.items(), key=lambda x: x[1], reverse=True)[:top_n]
    total = sum(imp.values()) or 1.0

    lines = []
    lines.append("| 順位 | 特徴量 | importance (gain) | 全体比 |")
    lines.append("|---:|---|---:|---:|")
    for i, (feat, val) in enumerate(items, 1):
        pct = val / total * 100
        lines.append(f"| {i} | `{feat}` | {val:,.0f} | {pct:.1f}% |")
    return "\n".join(lines)





距離適性特徴量 — 定義集

特徴量セット概要

セット説明
A: baselineベース特徴量のみ(距離適性なし)
B: 過去平均距離差過去5走の平均距離・中央値と今回距離との差を追加
C: 距離変更前走からの距離変更(延長・短縮・変更幅・変更率)を追加
D: 同距離帯成績同距離帯での過去成績(出走数・1着率・3着内率・平均着順・最良着順)を追加
E: 近距離成績今回距離±200m以内の過去走成績(出走数・3着内率・平均着順)を追加
F: 好走距離差過去3着内のレース距離平均と今回距離との差を追加
G: 距離レンジ過去走の距離レンジ(最短・最長・レンジ幅)と未経験距離フラグを追加
H: 競馬場×距離帯競馬場×距離帯の組み合わせでの過去成績を追加
ALL: 全距離適性特徴量すべての距離適性特徴量(B〜H)を追加

距離帯区分

距離帯範囲
短距離0~1200m
マイル前後1201~1600m
中距離1601~2000m
長距離2001~99999m

共通設計方針(リーク対策)

  • 近距離閾値: ±200m(E: 近距離成績で使用)
  • 今走の着順・タイム・上がり・着差・賞金は特徴量に含めない(未来情報リーク防止)
  • 過去走特徴量はすべて shift(1) 後に rolling して今走情報のリークを防止
  • 時系列分割を使用(学習 < 検証 < 評価の期間順)
  • 馬名をキーに過去走を紐づけ(馬IDが無いため)

個別特徴量リスト

B: 過去平均距離差

  • past_avg_distance
  • past_median_distance
  • distance_diff_from_avg
  • distance_diff_from_median
  • abs_distance_diff_from_avg
  • abs_distance_diff_from_median

C: 距離変更

  • prev_distance
  • distance_change_from_prev
  • abs_distance_change_from_prev
  • is_distance_up
  • is_distance_down
  • is_same_distance
  • distance_change_rate

D: 同距離帯成績

  • same_band_runs
  • same_band_win_rate
  • same_band_top3_rate
  • same_band_avg_rank
  • same_band_best_rank
  • same_band_has_history

E: 近距離成績(±200m)

  • near_distance_runs
  • near_distance_top3_rate
  • near_distance_avg_rank
  • near_distance_has_history

F: 好走距離差

  • good_run_avg_distance
  • distance_diff_from_good_avg
  • abs_distance_diff_from_good_avg
  • has_good_run_distance_history

G: 距離レンジ

  • past_min_distance
  • past_max_distance
  • past_distance_range
  • is_longer_than_past_max
  • is_shorter_than_past_min
  • is_within_past_distance_range

H: 競馬場×距離帯

  • same_place_same_band_runs
  • same_place_same_band_top3_rate
  • same_place_same_band_avg_rank
  • same_place_same_band_has_history

ベース特徴量

人気なしモデルのベース特徴量

  • 距離
  • 馬番
  • 枠番
  • 斤量
  • 馬体重
  • 馬体重増減
  • 年齢
  • 同走馬の数
  • 曜日
  • past3_avg_rank
  • past3_top3_rate
タイトルとURLをコピーしました