競馬予想AIの特徴量一覧とLightGBM(ランキング学習)による着順予測の実装
競馬予想AIをLightGBMで作るとき、最終的な精度を決めるのはモデルの種類よりも「どの列を特徴量にするか」です。ところが「競馬 特徴量一覧」で調べても、実装コードは見つかるのにどんな特徴量を入れるかを具体的にまとめた資料は多くありません。この記事では、競馬予想で実際に使われる特徴量を馬の基本情報・血統・過去成績・レース条件・騎手・オッズのカテゴリ別に一覧化し、そのうえでLightGBMのランキング学習(LGBMRanker/lambdarank)で着順を予測する実装までを、動くコードでつなげて解説します。回帰・分類ではなくランキング学習を選ぶ理由、レース単位でのデータ分割、着順のrelevance変換、LightGBM 4系で書き方が変わったearly_stoppingまで、つまずきやすい点を具体的に押さえます。機械学習の基礎を一度おさえておくと読み進めやすい内容です。
目次
まとめ:競馬予想AIの特徴量とLightGBM実装の要点
- 特徴量は大きく5カテゴリ:馬の基本情報・血統/過去成績・スピード指数/レース条件・馬場・展開/騎手・調教師/オッズ・人気。まずこの中核を押さえる。
- 一覧を集めるだけでは精度は上がらない:競馬特有の「レース内での相対化」「時系列リークの防止」「確定オッズ・確定タイムの扱い」が精度を分ける。
- 予測は回帰でも分類でもなくランキング学習:レース内の順位を最適化するlambdarank(LGBMRanker)が本命予想と素直に一致する。groupに各レースの頭数を渡す。
- 着順はそのまま使わない:1着=3のように反転したrelevanceに変換して学習させる。
- LightGBM 4系はearly_stopping_rounds引数が廃止:
callbacks=[lgb.early_stopping(50)]を使う。行単位のtrain_test_splitは同一レースが割れてリークするため、レース単位+時系列で分割する。
競馬予想AIで使う特徴量一覧【カテゴリ別】
競馬の1行データは「あるレースに出走した1頭」です。ここに、その馬・そのレース・その騎手にまつわる情報を列(特徴量)として並べていきます。以下は競馬予想モデルで定番の特徴量を、入手・加工のしやすさ順に整理したものです。すべてを入れる必要はなく、まずは各カテゴリの中核を入れ、後述する特徴量重要度を見ながら取捨選択します。
馬の基本情報・血統の特徴量
出馬表からそのまま取れる基礎情報です。血統(父・母父)は種類が非常に多いカテゴリ変数なので、産駒の芝・ダート別や距離別の成績に置き換える集約特徴量にすると効きます。
| 特徴量 | 内容 | 型・作り方 |
|---|---|---|
| 馬番・枠番 | 出走位置。内枠/外枠の有利不利 | 数値/カテゴリ |
| 性別 | 牡・牝・セン | カテゴリ |
| 馬齢 | 満年齢(3歳・4歳…) | 数値 |
| 斤量 | 負担重量(kg) | 数値 |
| 馬体重・増減 | 当日体重と前走比 | 数値 |
| 父・母父(BMS) | 種牡馬・母の父 | カテゴリ/ターゲットエンコード |
| 種牡馬別産駒成績 | 父産駒の芝ダ・距離別勝率 | 数値(集約) |
| 生産者・馬主 | 高cardinalityカテゴリ | カテゴリ |
過去成績・スピード指数系の特徴量
もっとも予測に効くグループです。単発の前走成績だけでなく、直近数走を平均・集約した特徴量にすると安定します。スピード指数(走破タイムを基準タイムやレースレベルで補正した値)は、生タイムより馬同士を比較しやすいのが利点です。
| 特徴量 | 内容 | 型・作り方 |
|---|---|---|
| 前走着順・着差 | 直近レースの結果 | 数値 |
| 過去N走の平均着順・複勝率 | 直近3〜5走の集約 | 数値(集約) |
| 距離別/コース別/馬場別成績 | 条件別の勝率・複勝率 | 数値(集約) |
| スピード指数・タイム指数 | 走破タイムを基準化した指標 | 数値 |
| 上がり3ハロン | ラスト600mのタイム | 数値 |
| 通過順位・脚質 | 各コーナー位置取り、逃げ/先行/差し/追込 | 数値/カテゴリ |
| レース間隔 | 前走からの日数、休養明けフラグ | 数値/フラグ |
| 距離延長・短縮 | 前走との距離差 | 数値 |
レース条件・馬場・展開の特徴量
そのレースの舞台設定です。馬場状態やクラスは「良→不良」「未勝利→G1」のように順序があるので、順序を保ったまま数値化すると情報が落ちません。
| 特徴量 | 内容 | 型・作り方 |
|---|---|---|
| 距離 | レース距離(m) | 数値 |
| 芝・ダート | コース種別 | カテゴリ |
| 回り | 右・左・直線 | カテゴリ |
| 馬場状態 | 良・稍重・重・不良 | 順序カテゴリ |
| 天候 | 晴・曇・雨など | カテゴリ |
| 出走頭数 | フルゲート16など | 数値 |
| クラス・グレード | G1〜・OP・3勝クラスなど | 順序カテゴリ |
| 開催場・回・日次 | 東京・中山など | カテゴリ |
騎手・調教師・厩舎の特徴量
人の要素です。騎手名そのものより、その騎手の勝率・複勝率や、当該コース・距離での成績に変換した集約特徴量が効きます。前走から乗り替わったかどうか(乗り替わりフラグ)も定番です。
| 特徴量 | 内容 | 型・作り方 |
|---|---|---|
| 騎手勝率・複勝率 | 直近成績 | 数値(集約) |
| 騎手×コース/距離成績 | 得意条件での成績 | 数値(集約) |
| 乗り替わり | 前走から騎手変更 | フラグ |
| 調教師勝率・所属 | 美浦/栗東 | 数値/カテゴリ |
| 厩舎の当該条件成績 | 開催場・距離別 | 数値(集約) |
オッズ・人気の特徴量(と扱いの注意)
単勝オッズや人気は市場の総合評価そのもので、単体でも非常に強い特徴量です。ただし確定オッズは結果と強く相関する分、扱いを誤るとリークになります。学習では締切時点の想定オッズを使い、運用時も「予想する時点で分かっているオッズ」だけを入れます。オッズは分布が偏るため対数化してから使うのが一般的です。
| 特徴量 | 内容 | 型・作り方 |
|---|---|---|
| 単勝オッズ | 市場評価。対数化して使う | 数値 |
| 人気順位 | オッズ順の順位 | 数値 |
| 直前オッズ変動 | 締切直前の動き | 数値 |
競馬特有の特徴量エンジニアリング(精度を分ける設計)
ここが「特徴量一覧を集めただけ」と「実際に当たるモデル」の分かれ目です。勾配ブースティング決定木(GBDT)は生の値でもそれなりに学習しますが、競馬では次の3つを外すと検証精度と本番精度が大きくズレます。
特徴量のレース内相対化
競馬の特徴量で最も効くのは、値そのものより「同じレースの中で相対的にどうか」です。斤量56kgが重いか軽いかはメンバー次第で、スピード指数もレースのレベルによって意味が変わります。だから主要な連続値は、race_idでグループ化して偏差(平均との差)やレース内順位・パーセンタイルに変換してから入れると精度が上がりやすい。ランキング学習はレース内の相対順位を学ぶので、特徴量側もレース内で正規化しておくと相性が良く、生の値だけを並べるより優先すべき前処理です。
# レース内での相対化(偏差とレース内順位)
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) # レース内順位
過去走の集約とターゲットエンコーディングのリーク防止
騎手勝率や種牡馬成績のような「カテゴリ×成績」の特徴量は、そのカテゴリの平均勝率などに置き換えるターゲットエンコーディングで効かせられます。ただし全データで平均を取るとまだ起きていない未来のレース結果が混ざり、リークします。必ず「そのレース時点までの過去データだけ」で計算してください(時系列の展開平均、またはout-of-fold)。過去N走の集約も同じで、対象レースより後のレースを含めないこと。ここを外すと、検証では高精度なのに本番で崩れる典型パターンになります。
カテゴリ変数のLightGBMネイティブ処理(one-hot回避)
騎手・種牡馬・開催場などは種類が多く、one-hotエンコード(pd.get_dummies)だと列が数千に膨らみ、疎行列で学習効率も精度も落ちます。LightGBMはカテゴリ変数をネイティブに扱えるので、対象列をcategory型にしてcategorical_featureで渡すのが定石です。旧来の「高cardinality列も全部get_dummies」は競馬データでは避けます。カテゴリを前処理なしで直接扱いたいならCatBoostという選択肢もあります。
なぜランキング学習(lambdarank)を使うのか
競馬予想は「このレースで1着はどれか、上位はどれか」を当てる問題です。これは各馬の絶対的な数値を当てる問題ではなく、レース内で馬を並べ替える問題なので、順位付けを直接学習するランキング学習が素直に噛み合います。
回帰・分類・ランキング学習の比較
| 手法 | 予測対象 | 競馬での相性 |
|---|---|---|
| 回帰(Regressor) | 着順やタイムの数値 | レース間でスケールが違い、順位付けに弱い |
| 分類(Classifier) | 1着や複勝圏に入る確率 | レース内の相対比較を直接は学ばない |
| ランキング学習(Ranker) | レース内の並び順 | 出走馬を相対比較でき本命予想と一致 ◎ |
着順のrelevanceラベルへの変換
lambdarankは「値が大きいほど上位」というrelevance(重要度)を学びます。着順は小さいほど上位なので、そのまま入れると意味が逆になります。1着=3、2〜3着=2、4〜5着=1、それ以外=0のように、上位ほど大きい重要度へ反転して変換します。複勝圏を厚く評価するか、1着だけを強く評価するかは、単勝狙い・複勝狙いといった目的に合わせて設計を変えて構いません。
LightGBMで競馬予想モデルを実装する
ここまでの設計をコードにします。ポイントは、レース単位のgroupを作ること、着順をrelevanceへ変換すること、そしてデータをレース単位かつ時系列で分割することの3点です。
データ準備とレース単位のgroup作成
import pandas as pd
import numpy as np
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 rank == 1: return 3
if rank <= 3: return 2
if rank <= 5: return 1
return 0
df['relevance'] = df['chakujun'].apply(to_relevance)
# カテゴリ列はcategory型にしておく(LightGBMへネイティブに渡すため。文字列のままだと学習時にエラー)
cat_cols = ['jockey_id', 'trainer_id', 'course']
for c in cat_cols:
df[c] = df[c].astype('category')
# 予想時点で使える列だけを特徴量にする(確定タイム・確定着順などの結果列は除外)
drop_cols = ['race_id', 'chakujun', 'relevance', 'horse_name', 'goal_time']
feature_cols = [c for c in df.columns if c not in drop_cols]
X = df[feature_cols]
y = df['relevance']
レース単位・時系列でのtrain/test分割
行単位のtrain_test_splitは、同じレースの馬が学習側と検証側に割れてしまい、レース内の答えを部分的に見た状態で評価することになります(リーク)。レース(race_id)ごと、かつ古い→新しいの時系列で分割し、group(各レースの頭数)も同じ並び順で作ります。なお下のコードはrace_idが開催日順に採番されている前提です。そうでない場合は開催日カラムでソートしてから分割してください。
# レース単位+時系列で分割(古いレースで学習し、新しいレースで検証)
race_ids = df['race_id'].drop_duplicates().tolist()
split = int(len(race_ids) * 0.8)
train_races = set(race_ids[:split])
test_races = set(race_ids[split:])
train_mask = df['race_id'].isin(train_races)
X_train, y_train = X[train_mask], y[train_mask]
X_test, y_test = X[~train_mask], y[~train_mask]
# groupは「各レースの頭数」。X_train と同じ並び順で作る
group_train = df[train_mask].groupby('race_id', sort=False).size().to_numpy()
group_test = df[~train_mask].groupby('race_id', sort=False).size().to_numpy()
LGBMRankerの学習(LightGBM 4系のearly_stopping)
目的関数はlambdarank、評価指標はNDCG。LightGBM 4系ではearly_stopping_rounds引数が廃止されたため、早期終了はcallbacksのlgb.early_stopping()で指定します(3系の書き方のままだとエラーになります)。カテゴリ列はcategorical_featureで渡します(データ準備段でcategory型にしてあるものと対応させます)。なおeval_set・eval_groupはLightGBM 4.7.0以降で引数名が見直されつつあるため、利用中のバージョンのドキュメントで引数名を確認してください。
model = lgb.LGBMRanker(
objective='lambdarank',
metric='ndcg',
n_estimators=1000,
learning_rate=0.05,
num_leaves=31,
)
model.fit(
X_train, y_train,
group=group_train,
eval_set=[(X_test, y_test)],
eval_group=[group_test],
eval_at=[3],
categorical_feature=['jockey_id', 'trainer_id', 'course'],
callbacks=[lgb.early_stopping(50), lgb.log_evaluation(100)],
)
予測と特徴量重要度の確認
予測値は各馬のスコアです。レースごとにスコアの降順で並べれば、それが予測順位になります。どの特徴量が効いているかは、sklearn APIのfeature_importances_で確認します。
# レースごとにスコアを並べ替えて予測順位を作る
df_test = df[~train_mask].copy()
df_test['score'] = model.predict(X_test)
df_test['pred_rank'] = df_test.groupby('race_id')['score'].rank(ascending=False)
# 特徴量重要度(大きいほど寄与が大きい)
imp = pd.Series(model.feature_importances_, index=feature_cols)
print(imp.sort_values(ascending=False).head(15))
モデルの評価と精度改善(NDCGと回収率)
評価指標(NDCG・MAP)と回収率は別物
ランキング学習の標準指標はNDCG@k(上位k頭の並びの良さ)で、競馬では「1着や複勝圏を上位に置けているか」を見るためeval_at=[3]などを使います。ただしNDCGが良くても儲かるとは限りません。最終的な良し悪しは、予測1位を買い続けたときの単勝回収率・複勝回収率を、テスト期間で金額ベースに検証して判断します。複数の当たりを含む並びの精度を見たいときはMAP(Mean Average Precision)を併用することもありますが、いずれの指標が上がっても回収率が100%を割るなら、狙う券種やベット対象を見直します。
過学習・リークを防ぐ実務ポイント
検証は必ず時系列(過去で学習→未来で検証)で行います。ランダム分割は同一レース混入と未来リークで数字が甘くなります。また、パラメータ探索でGridSearchCV(scoring='ndcg')のような書き方はできません(ndcgはsklearn標準スコアラーに無い)。探索はLightGBMのcvやOptunaを使い、group・時系列を保った分割で行います。そして、確定オッズ・確定タイム・確定上がりなど「レースが終わって初めて分かる列」は特徴量に入れないこと。この3点で、検証と本番の乖離を大きく減らせます。
競馬の特徴量とLightGBMに関するよくある質問
競馬予想に必要な特徴量はいくつくらいですか?
数十〜数百まで幅がありますが、数より質です。まず前走成績・過去走の集約・スピード指数・騎手成績・レース条件という中核をレース内で相対化して入れ、特徴量重要度を見ながら足し引きします。むやみに列を増やすと過学習とノイズが増え、かえって精度が落ちます。
競馬の特徴量やデータはどこで手に入りますか?
JRA-VANやnetkeibaなどのデータサービス、あるいは公開されている過去レースデータを使うのが基本です。自前でスクレイピングする場合は、各サイトの利用規約とrobots.txtを必ず確認し、アクセス頻度に配慮してください。取得した生データは、本記事の一覧を参考に特徴量へ加工します。
LightGBMは回帰と分類のどちらを使うべきですか?
レース内の順位(本命・上位)を当てたいなら、回帰でも分類でもなくランキング学習(LGBMRanker/lambdarank)が素直です。1着に入る確率だけが欲しいなら二値分類も選択肢ですが、その場合もレース内での相対化を特徴量に入れておくと安定します。
lambdarankのラベルには何を入れますか?
着順そのものではなく、上位ほど大きい重要度(relevance)に変換した値を入れます。たとえば1着=3、2〜3着=2、4〜5着=1、それ以外=0です。着順は小さいほど上位なので、そのまま入れると意味が逆になる点に注意してください。
特徴量重要度が高い=儲かる、ということですか?
いいえ。特徴量重要度は「モデルの予測にどれだけ寄与したか」であって、回収率とは別物です。単勝オッズや人気が最重要になりがちですが、それらは市場評価そのものなので、そこに頼るだけでは控除率(約20〜30%)を超えられません。回収率で勝てるかは、市場が見落としている条件を特徴量で拾えているかで決まります。