---
title: "競馬予想AIの特徴量一覧とLightGBMのランキング学習（LGBMRanker）による着順予測の実装"
url: "https://www.issoh.co.jp/tech/details/2321/"
published: 2024-05-29
updated: 2026-10-13
categories: ["AI"]
publisher: "株式会社一創"
---

# 競馬予想AIの特徴量一覧とLightGBMのランキング学習（LGBMRanker）による着順予測の実装

競馬予想AIの精度を決めるのは、モデルの種類より「どの列を特徴量にするか」と「その列が予想する時点で本当に手に入るか」です。特徴量の一般的な意味や重要度の測り方は[特徴量とは何かを実装目線で整理した記事](/tech/details/15842/)に譲り、この記事では競馬で定番の特徴量を**馬の基本情報・血統・過去成績・レース条件・騎手・調教・オッズ**のカテゴリ別に一覧にします。続けて、出馬表・枠順・馬体重がいつ確定するかからリークの線引きを決め、[LightGBM](/tech/details/3919/) 4.7.0のLGBMRanker（lambdarank）で着順を予測するコードまでつなげます。コードはすべてLightGBM 4.7.0で実行を確認し、レース内相対化やリークの影響は合成データで測った数字で示します。

## まとめ：競馬予想AIの特徴量とLightGBM実装の要点

- **特徴量は6カテゴリ**：馬の基本情報・血統／過去成績・スピード指数／レース条件・展開／騎手・調教師／調教・馬具／オッズ・人気。中核は過去成績の集約と騎手成績。
- **列ごとに「いつ確定するか」を決めてから入れる**：JRAの出馬表は木曜16時すぎ、枠順付きは前日10時すぎ、馬体重は発走の概ね65分前に計量（2026年9月のJRA発表）。オッズは締切まで動く。予想する時刻より後に決まる列は入れない。
- **今回の合成データではレース内相対化による精度差は小さい**：合成データでは、生のスピード指数＋騎手勝率で予測1位の勝率32.1%、相対化して32.3%。効いたのはgroupを誤ったときの落ち込み（生の値では32.1%→29.3%）を防ぐ面だった。
- **全期間で計算した馬・騎手の成績はリークになる**：合成データでは、テスト期間に勝率43.7%と出たモデルが、本番と同じ条件では39.7%で、過去走だけで作ったモデル（40.1%）を下回った。
- **予測はLGBMRanker（lambdarank）**：groupに各レースの頭数を渡し、着順は1着=3のようなrelevanceに反転する。ラベルの上限は既定で30。
- **LightGBM 4.7.0では`eval_set`が非推奨**：`eval_X`・`eval_y`を使い、早期終了は`callbacks=[lgb.early_stopping(50)]`で指定する。早期終了に使う検証期間と、回収率を測るテスト期間は分ける。
- **最後は回収率で判断する**：JRAの設定払戻率は単勝80%・3連単72.5%。NDCGが高くても、予測1位を買い続けた回収率が1番人気買いを上回らなければ実戦では使えない。

## 競馬予想AIで使う特徴量一覧【カテゴリ別】

競馬の学習データは「あるレースに出走した1頭」が1行です。そこに馬・レース・騎手・調教にまつわる情報を列として並べます。以下の表は定番の特徴量を、入手しやすい順にまとめたものです。全部を入れる必要はありません。各カテゴリの中核から入れ、特徴量重要度と回収率を見ながら足し引きします。

### 馬の基本情報・血統の特徴量

出馬表からそのまま取れる列です。父・母父のカテゴリ数は収録期間と対象馬によって変わります。カテゴリ表現に加え、予想時点までの「父の産駒の芝・ダート別勝率」と出走数を集約特徴量の候補にします。

| 特徴量       | 内容                | 型・作り方   |
| --------- | ----------------- | ------- |
| 馬番・枠番     | 出走位置。内外の有利不利      | 数値/カテゴリ |
| 性別        | 牡・牝・セン            | カテゴリ    |
| 馬齢        | 出生年を0歳とし毎年1月1日に加齢 | 数値      |
| 斤量        | 負担重量(kg)          | 数値      |
| 馬体重・増減    | 当日体重と前走比          | 数値      |
| 父・母父      | 種牡馬・母の父           | カテゴリ/集約 |
| 種牡馬別の産駒成績 | 芝ダ・距離別の勝率         | 数値（集約）  |
| 生産者・馬主    | 種類数の多いカテゴリ        | カテゴリ    |

### 過去成績・スピード指数系の特徴量

過去成績は、前走の着順と直近3〜5走の集約値を比較するところから始めます。平均化すると単走の変動を抑えられる一方、直近の状態変化を薄める可能性もあります。スピード指数は走破タイムを基準タイムで補正した値で、距離や馬場が違うレース同士を比べられる点が生タイムより優れています。

| 特徴量            | 内容           | 型・作り方   |
| -------------- | ------------ | ------- |
| 前走着順・着差        | 直近レースの結果     | 数値      |
| 過去N走の平均着順・複勝率  | 直近3〜5走の集約    | 数値（集約）  |
| 距離別・コース別・馬場別成績 | 条件別の勝率・複勝率   | 数値（集約）  |
| スピード指数         | 走破タイムを基準化    | 数値      |
| 上がり3ハロン        | 最後の600mのタイム  | 数値      |
| 通過順位・脚質        | 逃げ/先行/差し/追込  | 数値/カテゴリ |
| レース間隔          | 前走からの日数・休養明け | 数値/フラグ  |
| 距離延長・短縮        | 前走との距離差(m)   | 数値      |

### レース条件・馬場・展開の特徴量

そのレースの舞台設定です。馬場状態とクラスには「良→不良」「未勝利→G1」という順序があるので、順序を保った整数にすると情報が落ちません。展開は直接は観測できないため、出走馬の過去の脚質から「逃げ・先行馬が何頭いるか」を数えて想定ペースの代わりにします。

| 特徴量       | 内容        | 型・作り方  |
| --------- | --------- | ------ |
| 距離        | レース距離(m)  | 数値     |
| 芝・ダート     | コース種別     | カテゴリ   |
| 回り        | 右・左・直線    | カテゴリ   |
| 馬場状態      | 良・稍重・重・不良 | 順序カテゴリ |
| 天候        | 晴・曇・雨など   | カテゴリ   |
| 出走頭数      | 最大18頭     | 数値     |
| クラス・グレード  | 未勝利〜G1    | 順序カテゴリ |
| 開催場・回・日次  | 東京・中山など   | カテゴリ   |
| 逃げ・先行馬の頭数 | 想定ペースの代理  | 数値（集約） |

### 騎手・調教師・厩舎の特徴量

騎手IDのカテゴリ表現と、予想時点までの勝率・複勝率、コース・距離別成績を比較します。条件別成績には集計対象の騎乗数も添えます。前走から騎手が替わったか（乗り替わり）も定番のフラグです。

| 特徴量         | 内容       | 型・作り方   |
| ----------- | -------- | ------- |
| 騎手勝率・複勝率    | 直近の成績    | 数値（集約）  |
| 騎手×コース・距離成績 | 得意条件での成績 | 数値（集約）  |
| 乗り替わり       | 前走から騎手変更 | フラグ     |
| 調教師勝率・所属    | 美浦/栗東    | 数値/カテゴリ |
| 厩舎の当該条件成績   | 開催場・距離別  | 数値（集約）  |

### 調教・馬具・パドックなど数値化しにくい特徴量

ITmediaのインタビューで「ヤナシ社長」は、パドックでの歩様やイレ込み、ハミ・ブリンカー・シャドーロールといった馬具の変更を注目する特徴量に挙げています（[ITmedia NEWS 2025年6月19日のインタビュー](https://www.itmedia.co.jp/news/article/2506/16/1250616118/3)）。ただし、この種の情報は入手経路と粒度がそろわないと列にできません。現実的な順番は次のとおりです。

- **先に入れる**：調教タイム（坂路・ウッドチップの自動計測）と馬具の変更フラグ（前走は無し→今回ブリンカー着用、など。使うデータに馬具の項目があるかを先に確認する）。JRA-VANは[2021年10月のJV-Link更新でウッドチップ調教データをDataLab.でも提供すると告知](https://jra-van.jp/news/20211019a.html)しており、データとして取得できます。
- **後回しにする**：パドックの気配や返し馬の印象。人の評価を数値化した列は、評価者が変わると意味が変わり、過去データとそろいません。

| 特徴量       | 内容         | 型・作り方       |
| --------- | ---------- | ----------- |
| 最終追い切りタイム | 坂路・ウッドの計測値 | 数値（レース内相対化） |
| 追い切り本数    | 中間の調教量     | 数値（集約）      |
| 馬具の変更     | ブリンカー初着用など | フラグ         |
| パドック評価    | 人手の評価      | 順序カテゴリ（後回し） |

### オッズ・人気の特徴量（と扱いの注意）

単勝オッズと人気は市場の総合評価で、単体でも強い列です。ただしオッズは発売締切まで動き、確定オッズは結果と強く相関します。学習データでも「予想する時刻のオッズ」を使い、確定オッズは入れません。オッズの対数化は候補の一つですが、LightGBMで必須の前処理とはせず、生の値と比較して採否を決めます。

| 特徴量    | 内容           | 型・作り方   |
| ------ | ------------ | ------- |
| 単勝オッズ  | 市場評価         | 数値（対数化） |
| 人気順位   | オッズ順の順位      | 数値      |
| オッズの変動 | 前日から締切前までの動き | 数値      |

## 特徴量ごとの確定タイミングとリークの線引き

競馬のリークは「レースが終わって初めて分かる列を入れる」だけでは起きません。**予想する時刻より後に公表される列**を入れても、運用時にはその列が空になり、検証時より精度が落ちます。JRAの公表時刻を並べると、何時に予想するかで使える列が変わることが分かります。

| 情報           | 確定・公表の時点（JRA通常開催）                 | 使える列        |
| ------------ | --------------------------------- | ----------- |
| 出馬投票の締切      | 原則木曜：新規14時30分、変更・取消14時45分（GⅠ等は別定） | 出走申込みの内容    |
| 出馬表（馬番なし）    | 木曜16時すぎ                           | 過去成績・血統・調教師 |
| 出馬表（枠番・馬番付き） | レース前日10時すぎ                        | 枠番・馬番・展開の頭数 |
| 馬体重          | 概ね65分前に計量（JRA・2026年9月公表）          | 馬体重・増減      |
| 単勝オッズ        | 発売締切まで変動                          | 予想時刻のオッズ    |
| 着順・走破タイム     | レース後                              | 次走以降の過去成績だけ |

出典は[JRAの2026年度競馬番組「出馬投票申込場所および申込日時」](https://www.jra.go.jp/keiba/program/2026/pdf/touroku-touhyou01.pdf)、[JRAのFAQ「出馬表はいつごろ発表されるのですか？」](https://www.jra.go.jp/faq/pop02/2%5F2.html)、[JRAの馬体重の発表ページ](https://www.jra.go.jp/news/202609/092407.html)です。G1など前日発売を行うレースは発表時刻が異なると同じFAQに注記があります。馬体重の計量時刻は、同じ2026年の1月の発表では「概ね60分前」で、年内に改められています。運用では毎回の公表を正とします。また、JRAホームページへの表示には発表後約15分かかる場合があるため、取得経路で実際に利用可能になった時刻を記録します。

ここから決めるべきことは1つです。**前日の夜に予想を確定させる運用なら、馬体重とその増減は特徴量から外す**。学習データには全レースの馬体重が入っているため、入れたまま検証すると、運用では手に入らない列で精度を測ることになります。当日に馬体重が公表され、利用するデータ経路で取得できたことを確認してから予想を出す運用なら使えます。オッズも同じで、学習に使うオッズの時刻と運用で予想する時刻をそろえます。

## 競馬特有の特徴量エンジニアリング（精度を分ける設計）

[勾配ブースティング決定木（GBDT）](/tech/details/5871/)は生の値でもある程度は学習しますが、競馬では次の3点の扱いで検証と本番の精度がずれます。工程全体の考え方は[特徴量エンジニアリングの工程をまとめた記事](/tech/details/15843/)を参照してください。以下の数字は、合成データでLightGBM 4.7.0のLGBMRankerを学習して測ったものです。データは1回あたり6,000レース・約7万2千行（馬3,000頭・騎手120人。着順は馬の能力・騎手の腕・偶然で決まり、スピード指数にはレースのクラス差が乗るように生成）で、古い4,200レースで学習、続く600レースを早期終了の検証、最後の1,200レースをテストにしました。比べる2つのモデルは、違いを見たい点以外の列・学習回数の上限・早期終了の条件をそろえ、データの乱数種を3通り（numpyのdefault\_rngに0・1・2）変えた平均を載せています。NDCG@3はLightGBMの既定と同じ指数利得（2のrelevance乗−1）で計算したレース別の値の平均です。実際のレースでの精度ではなく、この合成データで設計を比較した数字です。

### 特徴量のレース内相対化

斤量56kgが重いか軽いかはメンバー次第で、スピード指数もクラスによって水準が違います。そこで予想時点に取得済みの連続値を、race\_idごとの**平均との差（偏差）とレース内順位**に変換します。次のコードのagari3fは過去走から作った値で、予測対象レースの上がりタイムではありません。

```
# レース内での相対化（偏差とレース内順位）
for col in ['speed_index', 'kinryo', 'agari3f']:
    g = df.groupby('race_id')[col]
    df[col + '_dev']  = df[col] - g.transform('mean')   # 平均との差
    df[col + '_rank'] = g.rank(ascending=False)          # レース内順位
```

合成データで測った結果です。

| 使った特徴量        | NDCG@3 | 予測1位の勝率 |
| ------------- | ------ | ------- |
| 生のスピード指数だけ    | 0.5331 | 28.3%   |
| 相対化したスピード指数だけ | 0.5376 | 28.2%   |
| 生のスピード指数＋騎手勝率 | 0.5723 | 32.1%   |
| 相対化＋騎手勝率      | 0.5777 | 32.3%   |

この合成データでは、相対化の効果は小さく出ました（ランダムに1頭選んだときの勝率は8.7%）。1列だけなら相対化してもレース内の並び順は変わらず、騎手勝率と組み合わせても32.1%から32.3%への上積みです。lambdarankはレース内の馬同士の比較で損失を計算するため、クラスの水準差を含む生の値でもかなりの部分を学習できます。**相対化は精度を大きく押し上げる前処理ではなく、生の値と並べて時系列の検証期間で採否を決め、最終テスト期間では固定して評価する候補**と考えるのが実測に合っています。ただし、後述するgroupの作り間違いに対しては、相対化した列のほうが崩れにくいという差がはっきり出ました。

### 過去走の集約とターゲットエンコーディングのリーク防止

騎手勝率や馬の平均着順のような「カテゴリ×成績」の列は、全データで平均を取ると**そのレース自身の結果と未来のレース結果が混ざります**。必ず「そのレースより前のデータだけ」で計算します。時系列データのリーク全般は[時系列データの前処理とリーク回避の記事](/tech/details/16862/)で扱っています。

```
# そのレースより前の成績だけで集約する（race_idは開催日順）
df = df.sort_values('race_id').reset_index(drop=True)
past_sum = df.groupby('horse_id')['relevance'].cumsum() - df['relevance']
past_cnt = df.groupby('horse_id').cumcount()
df['horse_rel_past'] = past_sum / past_cnt.replace(0, np.nan)   # 初出走はNaNのまま渡す
df['horse_runs'] = past_cnt
```

合成データで、馬ごとの平均成績を過去走だけで作った場合と全期間で作った場合を比べました。どちらのモデルにも、相対化したスピード指数・騎手勝率（過去のみ）・過去出走数を同じように入れ、違いは馬の成績の作り方だけです。

| 馬の成績の作り方 | 評価の条件          | NDCG@3 | 予測1位の勝率 |
| -------- | -------------- | ------ | ------- |
| 過去走だけ    | テスト期間          | 0.6482 | 40.1%   |
| 全期間（リーク） | テスト期間（リーク列のまま） | 0.6830 | 43.7%   |
| 全期間（リーク） | 本番と同じ条件        | 0.6468 | 39.7%   |

「本番と同じ条件」は、全期間版で学習したモデルに、予想する時点で実際に計算できる値（そのレースより前の成績だけの平均）を入れて測った値です。運用時には未来の結果が無いので、こうなります。**テストで最も良く見えたモデルが、本番では正しく作ったモデルを下回り、3.6ポイントの上積みは幻でした**。一方、騎手勝率を全期間で計算した版は31.9%（過去だけは32.3%）と差が出ませんでした。騎手1人あたり平均約600騎乗あり、1レースの結果が平均をほとんど動かさないためと考えられます。**この合成データでは騎手単位より馬単位の集約でリークの影響が大きかったので、出走数の少ないカテゴリの集約から優先して点検します**。

### カテゴリ変数のLightGBMネイティブ処理（one-hot回避）

騎手・種牡馬などは収録範囲によってカテゴリ数が増え、`pd.get_dummies`でone-hotにするとカテゴリ数に応じて列が増えます。LightGBMはカテゴリ変数をそのまま扱えるので、列をcategory型にして渡します。pandas 3.0では文字列列が新しい`str`型になり、そのまま渡すとLightGBM 4.7.0は`ValueError: pandas dtypes must be int, float or bool. Fields with bad pandas dtypes: course: str`で止まりました。`astype('category')`を付ければ通ります。カテゴリを前処理なしで扱う設計を重視するなら[CatBoost](/tech/details/3474/)も候補です。

## ランキング学習（lambdarank）の採用理由と適用条件

競馬予想は「このレースで1着はどれか、上位はどれか」を当てる問題です。各馬の数値を当てる問題ではなく、レース内で馬を並べ替える問題なので、並び順を直接学習するランキング学習が合います。

### 回帰・分類・ランキング学習の比較

| 手法                  | 予測対象        | 競馬での相性                |
| ------------------- | ----------- | --------------------- |
| 回帰（LGBMRegressor）   | 着順やタイムの数値   | 数値誤差を最小化。順位指標とは目的が異なる |
| 分類（LGBMClassifier）  | 1着・複勝圏に入る確率 | レース内の比較を直接は学ばない       |
| ランキング学習（LGBMRanker） | レース内の並び順    | 出走馬を相対比較できる           |

分類が向くのは、券種ごとに「1着になる確率」そのものが欲しく、オッズと掛け合わせて期待値を出したい場合です。ランキング学習の出力はスコアで、確率ではありません。期待値で買い目を選ぶ運用なら、分類モデルを併用します。

### 着順のrelevance変換とlabel\_gainの上限

lambdarankは「値が大きいほど上位」というrelevanceを学びます。着順は小さいほど上位なので、1着=3、2〜3着=2、4〜5着=1、それ以外=0のように反転します。LightGBMの[公式パラメータ一覧](https://lightgbm.readthedocs.io/en/stable/Parameters.html)では`label_gain`の既定値が`0,1,3,7,15,31,...`（2のラベル乗−1）で、ラベル3は利得7、ラベル2は利得3になります。1着を2〜3着の2倍以上重く扱う設計が、既定値の時点で入っているということです。

ラベルには上限があります。公式の記述は「all values in label must be smaller than number of elements in label\_gain」で、既定では0〜30まで使えます。賞金額のような31以上の値をラベルにすると、4.7.0では（最大33のラベルを渡した場合）`LightGBMError: Label 33 is not less than the number of label mappings (31)`で学習が止まりました。0から始まるラベルを32段階以上使うなら`label_gain`を自分で渡します。

## LightGBM 4.7.0によるLGBMRankerの実装

ここまでの設計をコードにします。押さえるのは、groupの作り方、relevanceへの変換、レース単位・時系列での分割の3点です。以下のコードはLightGBM 4.7.0・pandas 3.0.6・scikit-learn 1.9.1で、合成データを入れて最後まで実行できることを確認しています。

### データ準備とレース単位のgroup作成

```
import numpy as np
import pandas as pd
import lightgbm as lgb

# 1行=1頭。race_id（開催日順に採番したレース識別子）で並べておく
df = pd.read_csv('race_data.csv').sort_values('race_id').reset_index(drop=True)

# 着順(chakujun)をrelevanceへ変換：1着=3, 2〜3着=2, 4〜5着=1, それ以外=0
def to_relevance(rank):
    if pd.isna(rank) or rank != int(rank) or rank < 1:
        raise ValueError('chakujun must be a positive integer')
    if rank == 1: return 3
    if rank <= 3: return 2
    if rank <= 5: return 1
    return 0
df['relevance'] = df['chakujun'].apply(to_relevance)

# カテゴリ列はcategory型にする（pandas 3.0のstr型のままだとValueError）
cat_cols = ['jockey_id', 'trainer_id', 'course']
for c in cat_cols:
    df[c] = df[c].astype('category')

# 予想する時刻に分かっている列だけを許可リストで指定する
# speed_indexは前走までのスピード指数、tansho_oddsは予想時刻のオッズ（確定オッズではない）
feature_cols = ['speed_index', 'kinryo', 'tansho_odds', *cat_cols]
```

`to_relevance`は、取消・除外・競走中止・失格など着順が正の整数でない行で止まるようにしています。データ提供元の結果区分を見て、学習から外すか別のラベルを付けるかを先に決めます。特徴量を除外リストでなく許可リストで書くのは、CSVに当該レースの上がりや払戻金の列が後から増えても、自動で学習に混ざらないようにするためです。

### レース単位・時系列での学習・検証・テスト分割

行単位の`train_test_split`は、同じレースの馬が学習側と検証側に割れます。さらに厄介なのは、行をシャッフルしたあとに`groupby(...).size()`でgroupを作っても、**LightGBMはエラーを出さずに学習してしまう**ことです。groupの合計が行数と一致すれば通ります。合成データで学習側の行だけをシャッフルしてgroupをずらし、ほかの条件をそろえて比べると、生のスピード指数＋騎手勝率では予測1位の勝率が32.1%から29.3%へ下がりました。レース内相対化した列では32.3%と32.2%で、差が出ませんでした。相対化した値は別のレースの馬と比べても意味が崩れにくいためです。精度が落ちない場合もあるぶん、指標だけではずれに気付けません。groupは「同じレースの行が連続して並んでいる」ことをコードで確かめてから作ります。

```
# レース単位＋時系列で3分割（学習70%・早期終了用の検証10%・最終評価用のテスト20%）
race_ids = df['race_id'].drop_duplicates().tolist()
n = len(race_ids)
train_ids = race_ids[:int(n * 0.7)]
valid_ids = race_ids[int(n * 0.7):int(n * 0.8)]
test_ids  = race_ids[int(n * 0.8):]

def part(ids):
    d = df[df['race_id'].isin(ids)]
    group = d.groupby('race_id', sort=False).size().to_numpy()   # 各レースの頭数
    # 同じレースの行が連続していること（レースの切れ目の数＝groupの数）
    assert (d['race_id'] != d['race_id'].shift()).sum() == len(group)
    return d, d[feature_cols], d['relevance'], group

df_train, X_train, y_train, group_train = part(train_ids)
df_valid, X_valid, y_valid, group_valid = part(valid_ids)
df_test,  X_test,  y_test,  group_test  = part(test_ids)
```

検証期間は早期終了で最良の回数を選ぶのに使うので、そこで測った成績はモデル選択の影響を受けています。回収率は、学習にも早期終了にも使っていないテスト期間で、モデルと買い方を固定してから測ります。

### LGBMRankerの学習（eval\_X・eval\_yとearly\_stopping）

目的関数はlambdarank、評価指標はNDCGです。書き方は版で2回変わっています。`fit(early_stopping_rounds=50)`は4.0.0で削除され、4.7.0では`TypeError: LGBMRanker.fit() got an unexpected keyword argument 'early_stopping_rounds'`になり、callbacksで指定します。また4.7.0では`eval_set`を渡すと`LGBMDeprecationWarning: The argument 'eval_set' is deprecated, use 'eval_X' and 'eval_y' instead.`が出ます。4.7.0では`eval_X`・`eval_y`にタプルで渡します（4.6以前は`eval_X`が無いので`eval_set=[(X_valid, y_valid)]`のまま）。

```
model = lgb.LGBMRanker(
    objective='lambdarank',
    n_estimators=1000,
    learning_rate=0.05,
    num_leaves=31,
    verbose=-1,
)
model.fit(
    X_train, y_train,
    group=group_train,
    eval_X=(X_valid,),
    eval_y=(y_valid,),
    eval_group=[group_valid],
    eval_at=[3],
    categorical_feature=cat_cols,
    callbacks=[lgb.early_stopping(50), lgb.log_evaluation(100)],
)
print(model.best_iteration_)
```

### 予測と特徴量重要度の確認

予測値は各馬のスコアです。レースごとに降順で並べれば予測順位になります。どの列が効いているかは`feature_importances_`で確認します。

```
# テスト期間のレースごとにスコアを並べ替えて予測順位を作る
df_test = df_test.copy()
df_test['score'] = model.predict(X_test)
df_test['pred_rank'] = df_test.groupby('race_id')['score'].rank(ascending=False, method='first')

# 特徴量重要度（既定は分岐に使われた回数）
imp = pd.Series(model.feature_importances_, index=feature_cols)
print(imp.sort_values(ascending=False).head(15))
```

## モデルの評価：NDCGと回収率

### NDCG@kの読み方と探索の注意

ランキング学習の標準指標はNDCG@k（上位k頭の並びの良さ）で、`eval_at=[3]`なら予測上位3頭の利得を評価します。複勝的中率とは異なり、このラベル設計では実着順4〜5着にも利得があります。検証は必ず時系列（過去で学習し未来で検証）で行います。パラメータ探索で`GridSearchCV(scoring='ndcg')`とは書けません。scikit-learn 1.9.1の`get_scorer_names()`にndcgを含む名前は1つもありませんでした。探索はOptunaなどで、groupと時系列を保った分割を自分で組みます。打ち切りの設計は[ハイパーパラメータ探索の記事](/tech/details/16351/)を参照してください。

### 回収率の計算と払戻率という壁

NDCGが上がっても儲かるとは限りません。最終的には、テスト期間に予測1位を買い続けた回収率で判断します。精算には特徴量のオッズでなく、100円あたりの公式払戻金を別の列（`tansho_return_yen`。不的中は0円、出走取消などの返還は100円）として結合して使います。予想時刻のオッズは確定オッズと違い、同じ列で精算すると払戻金と一致しません。比較の基準は「予想時刻の単勝オッズが最も低い馬を買い続けた場合」です。以下のコードでは、同率の場合はデータ上で先に現れる1頭を選びます。モデルがこれを上回れないなら、そのテスト期間と買い方では1番人気買いに対する回収率の優位性を確認できていません。

```
def tansho_roi(bets):
    # 100円ずつ単勝を買ったときの回収率（tansho_return_yenは特徴量に入れない）
    stake = 100 * len(bets)
    if bets.empty or bets['tansho_return_yen'].isna().any():
        raise ValueError('bets must be non-empty and payouts must not be missing')
    payout = bets['tansho_return_yen'].sum()
    return payout / stake

df_test['ninki'] = df_test.groupby('race_id')['tansho_odds'].rank(method='first')
print(f"モデルの予測1位: {tansho_roi(df_test[df_test['pred_rank'] == 1]):.1%}")
print(f"1番人気:         {tansho_roi(df_test[df_test['ninki'] == 1]):.1%}")
```

購入額を回収する水準は券種にかかわらず回収率100%で、利益を出すにはこれを上回る必要があります。一方、設定払戻率は券種ごとに異なります。[JRAの勝馬投票券の解説](https://www.jra.go.jp/kouza/baken/index.html)に載っている設定払戻率は次のとおりです。

| 券種        | 設定払戻率  |
| --------- | ------ |
| 単勝・複勝     | 80.00% |
| 枠連・馬連・ワイド | 77.50% |
| 馬単・3連複    | 75.00% |
| 3連単       | 72.50% |
| WIN5      | 70.00% |

設定払戻率は売上げに対する払戻対象額の割合であり、各買い目を一様に選ぶ購入者の長期回収率を保証する値ではありません。100%と設定払戻率との差は単勝で20ポイント、3連単で27.5ポイントですが、これは必要な予測精度の向上幅ではありません。最初の検証は単勝・複勝で行い、払戻率の低い券種には単勝で優位性を確認してから進むのが順当です。

競馬に限らず、需要予測や順位付けのモデルを業務データで組む場合は、[機械学習モデル開発の支援](/service/ai/machine-learning/)で特徴量設計から検証の組み立てまで相談を受けています。

## 競馬の特徴量とLightGBMに関するよくある質問

### 競馬予想に必要な特徴量はいくつくらいですか？

数十〜数百まで幅がありますが、数より「予想する時刻に手に入るか」と質で決まります。まず過去走の集約・スピード指数・騎手成績・レース条件を入れます。レース内相対化は馬ごとに異なる連続値で比較し、距離や馬場状態など同一レースで共通の条件は元の値を残します。列を増やすほど過学習とリークの点検箇所も増えます。

### 競馬の特徴量やデータはどこで手に入りますか？

JRAの公式データを使うなら[JRA-VAN DataLab.](https://jra-van.jp/dlb/)が基本で、月額2,090円（税込）で1986年からのデータを取得できます。Webサイトから自前で集める場合は、利用規約とrobots.txtを確認し、アクセス頻度に配慮します。判断の詳細は[スクレイピングの実装と法務の判断をまとめた記事](/tech/details/13414/)を参照してください。

### LGBMRankerとLGBMClassifierはどちらを使うべきですか？

レース内の順位（本命・上位）を当てたいならLGBMRankerです。1着になる確率そのものが欲しく、オッズと掛け合わせて期待値で買い目を選ぶならLGBMClassifierが向きます。どちらの場合も、生の値とレース内相対値を時系列の検証期間で比較して特徴量を選び、採用後のモデルを独立した最終テスト期間で評価します。

### lambdarankのラベルには何を入れますか？

着順ではなく、上位ほど大きい整数（relevance）を入れます。例は1着=3、2〜3着=2、4〜5着=1、それ以外=0です。既定のlabel\_gainではラベルの上限が30で、31以上を入れると学習が止まります。

### 競馬AIの予測精度はどう比較すればよいですか？

同じテスト期間・同じ券種で、予測1位を買い続けたときの的中率と回収率を、1番人気を買い続けた場合と並べて比べます。NDCGはモデル同士の比較には使えますが、儲かるかどうかは回収率でしか分かりません。公開されている回収率は期間・券種・買い方がそろっていないことが多く、そのままでは比較できません。

## 関連記事

- [LightGBMとは？勾配ブースティングの仕組みと特徴・XGBoostとの違いを](/tech/details/3919/)
- [特徴量エンジニアリングとは？変換・選択・自動化の工程と実装判断【2026年版】](/tech/details/15843/)
- [GBDTとは？勾配ブースティング決定木の仕組みと主要4実装の実測比較【2026年](/tech/details/5871/)
- [時系列データの前処理｜欠損補間・リサンプリング・特徴量生成とリーク回避を実装手順](/tech/details/16862/)
- [CatBoostとは？カテゴリ変数の扱い・XGBoost/LightGBMとの違](/tech/details/3474/)

---

出典: [競馬予想AIの特徴量一覧とLightGBMのランキング学習（LGBMRanker）による着順予測の実装](<https://www.issoh.co.jp/tech/details/2321/>)（株式会社一創）
