競馬予測モデル

2023/10/19に公開

概要

地方競馬における3連服5頭ボックスに最適化した競馬予測モデルを作成し、予測しやすいレース傾向を可視化する。

データ

LightGBM

予測したいデータは上位3頭と少ないので、偏ったデータをサンプリングしやすいブースティングモデルを使用する。

検証方法

2010年から2019年のデータを5-Fold交差検証を行う。

評価指標

2020年から2022年のデータにおいて、下記の平均を算出する。

  • 回収率(ランキング予想上位5頭に対して3頭の組み合わせ全10通りを100円ずつ買ったと仮定して、それに対する払い戻し金額の割合)
  • Recall@5
  • RSME

特徴量選択

  • 性別
  • 年齢
  • 馬記号コード(外国産馬など)
  • 品種コード(サラブレッド系種など)
  • 毛色コード
  • 体重
  • 体重増減量(前走比較)
  • 負担重量
  • 騎手コード
  • 騎手見習コード(騎手の成績ラベル)
  • 調教師コード
  • 馬主コード

*負担重量とは、ジョッキーの体重や鞍などを含めた競走馬ごとに決められた競走馬が背負う重量。満たない場合は背負う必要がある。

ベンチマーク

回帰モデルでランキングを予測

テストデータ

  • Profit Rate: -36.16%
  • Recall@5: 63.335%
  • RMSE: 3.697

学習データ

  • Profit Rate: -1.22%
  • Recall@5: 72.853%
  • RMSE: 3.140

まとめ:

  • 大きく過学習をしている。

考察:

  • 負担重量は予測に影響していると考えられるので、特徴量の使い方を再考する。

競馬の世界では「斤量1 kg=1馬身(約0秒2)」の差が生まれるとも言われています。
参照

ランキング学習

アイテムのペアを比較して、どちらが上位にくるべきかを学ぶ手法。
LightGBMのobjectivelambdarankにして学習する。

特徴量の変更:

  • 負担割合(負担重量/体重)

テストデータ

  • Profit Rate: -35.62%(前回:-36.16%)
  • Recall@5: 61.743%(前回:63.335%)
  • RMSE: 3.776(前回:-3.697)

学習データ

  • Profit Rate: -30.46%
  • Recall@5: 65.728%
  • RMSE: 3.576

まとめ:

  • 負担割合の特徴量重要度が増えた。
  • 過学習が抑えられた。

モデル改善

  • 予測が全く外れると勾配にペナルティーを付ける
  • 入賞馬のデータをサンプリングしやすいようにする
def assign_labels(ranking_data):
    '''
    入賞かどうかを判断するカラム'label'を作成
    
    入賞馬(上位三頭)にラベル1を付与する。それ以外は0を付与するコード
    '''
    ranking_data['label'] = 0
    ranking_data.loc[ranking_data['kakutei_chakujun'] <= 3, 'label'] = 1

assign_labels(merged_df)
PENALTY_TERM = 1

def custom_loss(y_pred, train_data):
    '''
    勾配にペナルティーを付けるコード

    予測の上位5頭の内に実際の入賞馬がいないと勾配を大きくする
    勾配が大きいほどパラメータの更新量も大きくなるので
    '''
    y_true = train_data['label'].values
    grad = 2 * (y_pred - y_true)
    hess = 2 * np.ones_like(y_true)
    
    top5_indices = np.argsort(y_pred)[-5:]
    if np.sum(y_true[top5_indices] == 1) == 0:
        grad[top5_indices] += PENALTY_TERM
    return grad, hess
//storage.googleapis.com/zenn-user-upload/ccd9ce75e6ae-20231019.png)
'''
labelに応じて、sample_weightsを変更するコード

入賞馬のデータをそれ以外のもの2倍多くサンプリングしやすいように重みを変更する
'''
y_true = merged_df['label'].values
sample_weights = np.where(y_true == 1, 2.0, 1.0)

テストデータ

  • Profit Rate: -33.79%(前回:-35.62%)
  • Recall@5: 63.688%(前回:61.743%)
  • RMSE: 3.682(前回:3.776%)

学習データ

  • Profit Rate: -25.18%
  • Recall@5: 68.069%
  • RMSE: 3.439

まとめ:回収率は今までの中で一番良い結果となった。しかし、まだ過学習をしているので今後改善する。

予測しやすいレースの可視化

クラスタリグ手法:階層クラスタリング
カテゴリー変数が多いため、階層クラスタリング使用する。視覚的に理解しやすいメリットがある。

閾値70でクラスタに分けた。

次元圧縮:t-SNE、UMAP
カテゴリー変数が多いので、線形の手法であるPCAは向かない。分離能力が高いt-SNEとUMAPで次元圧縮を行う。

t-SNEは分離できているが、水色が他のクラスタと混ざる傾向にある。

水色の混ざりがUMAPの方が少ないので、こちらをベースにさらに解釈をしていく。

まとめ:

  • 開催月(kaisai_month),開催日(kaisai_day),そのレース開催が何日目か(kaisai_nichime),レース番号(race_bango)はクラスタ間で同じような分布をしている。
  • 競争種別コード(kyoso_shubetu_code)やトラックコード(track_code)、競馬場コード(keibajo_code)などレース会場に関する特徴量はきれいに分離している。
  • 天候コード(tenko_code)やダート馬場状態(babajotai_code_dirt)など当日の環境に関する特徴量は分離しきれていない。

各クラスタの個数:

  • 緑色: 1894
  • オレンジ色: 1261
  • 紫色: 804
  • 水色: 529
  • 茶色: 19
  • 赤色: 4

4と6はデータが少なすぎるので、一旦無視します。


まず水色について、競馬場コード(keibajo_code)と距離(kyori)ではっきりと分かれています。
こちらは帯広競馬場の200mのレースです。

次に紫にかんして、グレードコード(grade_code)で分かれています。0が一般競走であり、5が重賞レースになります。よって、紫は重賞レースです。

最後にレース番号(race_bango)をみると、オレンジは1に山があり、緑は5に山、紫は10に山があります。

レース番号が少ない数字のレースは新馬戦など、経験の浅い競走馬や騎手が乗ることが多く、数字が大きくなるにつれ経験がある競走馬や騎手が出走する傾向にあります。

まとめると:

  • 水色:帯広競馬場の200mのレース
  • オレンジ色:経験の浅い競走馬や騎手のレース
  • 緑色:経験が中くらいな競走馬や騎手のレース
  • 紫色:経験の豊富な競走馬や騎手のレース

最後に各クラスタにおける、的中率と回収率は

  • 水色: 的中率0.20 回収率-25.59%
  • オレンジ色: 的中率0.24 回収率-36.92
  • 緑色: 的中率0.26 回収率-25.90
  • 紫色: 的中率0.24 回収率-33.54

まとめ

  • 全体的に的中率は変わらないが、新馬戦や重賞は回収率が低い傾向にある。
  • 現在のモデルでは帯広競馬場の200mのレースでも回収率-25.59%なので、マイナスである。
  • 過学習の抑制や、カスタム目的関数のブラッシュアップで精度を高めていく。

Discussion