【競馬AI】スピード指数を特徴量に追加してLightGBMモデルを作成

競馬AI

本記事の目的

本記事は、下記事項を目的としています。

目的
  • LightGBMで着順を予測し、100%を超える回収率を得る。

前回モデルの回収率

本ページでは、過去に作成したモデルを改良方法とその結果を紹介します。

過去モデルの詳細は「【競馬予測】LightGBM(多クラス分類)で単勝予測|血統情報等の特徴量を追加」で解説しているので、詳しく知りたい方は読んでみてください。

ちなみに過去データでシミュレーションした結果、回収率の最大値は86%位でした。。。

前回モデルからの変更点

本記事では、「過去モデルを改善し、その結果回収率は向上するのか」という観点で解説を行っていきます。

今回は下記2つの変更を加えました。

  • 特徴量に「スピード指数」を追加
  • LightGBMの目的関数を「多クラス分類」から「2値分類」に変更

①特徴量に「スピード指数」を追加

いくつか種類がありますが、今回は「西田式スピード指数」をベースにした方法でスピード指数を計算します。

西田式スピード指数
スピード指数 = (基準タイム – 走破タイム) × 距離指数 + 馬場指数 + (斤量 – 55) × 2 + 80

各項の解説は公式サイトにあるので、そちらをご覧ください。

②LightGBMの目的関数を「多クラス分類」から「2値分類」に変更

以前のモデルでは、

  • 1着
  • 2着
  • 3着
  • 4着以降

の4クラスを分類していました。

しかし、

  • 全競走馬の1着になる確率が分かれば、2着・3着になる確率を計算できること
  • 多クラス分類は処理時間長い

これらの理由から、「1着」「2着以降」で2値分類することにしました。

ソースコード解説

以降では、今回加えた変更点についてソースコードを交えて解説していきます。

①スピード指数

西田式スピード指数をベースにスピード指数を計算します。

西田式スピード指数
スピード指数 = (基準タイム – 走破タイム) × 距離指数 + 馬場指数 + (斤量 – 55) × 2 + 80

以降では、上記式の各項について解説します。

基準タイム

基準タイムは

  • 競馬場(東京、中山、阪神、・・・)
  • レース長(1000m、1600m、・・・)
  • レース種(芝、ダート)
  • 馬場状態(良、重、・・・)

の4項目の全組み合わせ毎に、上位3着の走破タイムの平均値で計算します。

    # 基準タイムを計算(競馬場・レース長・芝/ダート・馬場毎の上位3着の平均値)
    horse_df['date'] = horse_df['日付'].map(lambda x: x.replace('/', '')).astype(int)
    past_df = horse_df[(horse_df['date'] < df['date'].min())
                       & (horse_df['date'] >= (df['date'].min() - 50000))
                       & (horse_df['着順'] <= 3)]
    std_time = past_df[['time', 'area_ID', 'race_len', 'race_type', '馬場']]\
                .groupby(['area_ID', 'race_len', 'race_type', '馬場'])\
                .median()
    std_time['time'] = std_time['time'].map(lambda x: x * 10)
ソースコード解説
  • ①変数「horse_df」の日付カラムの「/」を削除し、一律数値型に変更
  • ②予測対象データの5年前~予測対象データのうち最も古い日時データ、かつ着順が3着以内のデータを抽出
  • ③「競馬場」「レース長」「レース種類」「馬場状態」の組み合わせ毎に、走破タイムの中央値を取得
  • ④③の値を10倍する

変数解説

horse_df
df

走破タイム

名前の通り、競走馬のゴールタイムです。

これは、「netkeiba.com」からスクレイピングしたデータを使用するので、解説は割愛します。

距離指数

レース長ごとに1秒の価値が異なるので、その差を減らすための指数です。

計算方法は単純で

1秒 ÷ 基準タイム × 1000

で計算するだけです。

馬場指数

馬場状態によってもレースの結果は異なるので、その差を減らすための指数です。

この計算方法の詳細は、「西田式スピード指数」で解説されていないため、正直なところどう計算すればよいかが分かりませんでした。

なので今回は、「基準タイム」に馬場状態も含めたパターンを計算し、馬場指数は無視することにしました。

全体ソースコード(スピード指数計算)

スピード指数の各項の解説が終わったので、全体ソースコードを紹介します。

    # 基準タイムを計算(競馬場・レース長・芝/ダート・馬場毎の上位3着の平均値)
    horse_df['date'] = horse_df['日付'].map(lambda x: x.replace('/', '')).astype(int)
    past_df = horse_df[(horse_df['date'] < df['date'].min())
                       & (horse_df['date'] >= (df['date'].min() - 50000))
                       & (horse_df['着順'] <= 3)]
    std_time = past_df[['time', 'area_ID', 'race_len', 'race_type', '馬場']]\
                .groupby(['area_ID', 'race_len', 'race_type', '馬場'])\
                .median()
    std_time['time'] = std_time['time'].map(lambda x: x * 10)
                    
    # スピード指数を計算
    print('【スピード指数を計算】')
    
    # 処理を早くするために不要な行を削除
    horse_df_aft = horse_df[['time', 'area_ID', 'race_len', 'race_type', '馬場']]
    speed_ind = []
    for i in tqdm(horse_df.index):
        
        area_ID = int(horse_df_aft.loc[i, 'area_ID'])
        race_len = int(horse_df_aft.loc[i, 'race_len'])
        race_type = int(horse_df_aft.loc[i, 'race_type'])
        baba = str(horse_df_aft.loc[i, '馬場'])
        
        if (area_ID, race_len, race_type, baba) in std_time.index:

            # 基準タイム
            std = std_time.loc[(area_ID, race_len, race_type, baba), 'time']
            # 走破タイム
            time = horse_df_aft.loc[i, 'time'] * 10
            # 距離指数
            len_ind = 1000 / std
            # 斤量
            weight = horse_df.loc[i, '斤量']
            # スピード指数
            speed_ind.append((std - time) * len_ind + (weight - 55) * 2 + 80)
            
        else:
            speed_ind.append(float('nan'))
    horse_df['speed_indicator'] = speed_ind
ソースコード解説
  • ①基準タイムを計算

    ————-以降の処理は競走馬IDでループ————-
  • ②①の基準タイムに、該当する「競馬場」「レース長」「レース種類」「馬場状態」のパターンが含まれている場合、スピード指数を計算
  • ③①の基準タイムに、該当する「競馬場」「レース長」「レース種類」「馬場状態」のパターンが含まれていない場合、スピード指数の値にnanを追加
    ————- ここまでループ ————-
  • 計算結果のリストを、「horse_df」の「speed_indicator」カラムに追加

レース毎に他競走馬のスピード指数を追加

過去5Rのスピード指数の中央値を競争馬毎に計算し、レースに参加する競争馬の平均値・最大値・標準偏差を特徴量に追加します。

    # レース毎に競走馬のスピード指数(過去5Rの中央値)の平均値・最大値・標準偏差を追加
    print('【他競走馬のスピード指数を追加】')
    speed, speed_ave, speed_std, speed_max = [], [], [], []
    for race_id in tqdm(df['race_ID'].unique()):
        date = int(df.loc[df['race_ID'] == race_id, 'date'].head(1))
        horse_list = list(df.loc[df['race_ID'] == race_id, 'horse_ID'])
        speed_ind = [horse_df.loc[(horse_df['horse_ID'] == x) & (horse_df['date'] < date)
                                  , 'speed_indicator'].median()\
                     if len(horse_df.loc[(horse_df['horse_ID'] == x) & (horse_df['date'] < date)
                                         , 'speed_indicator']) <= 5\
                     else horse_df.loc[(horse_df['horse_ID'] == x) & (horse_df['date'] < date)
                                               , 'speed_indicator'].head(5).median()\
                     for x in horse_list]
        speed += speed_ind
        speed_ind = [x for x in speed_ind if not np.isnan(x)]
        speed_ave += [np.mean(speed_ind)] * len(horse_list)
        speed_std += [np.std(speed_ind)] * len(horse_list)
        speed_max += [max(speed_ind)] * len(horse_list)
    df['speed'] = speed
    df['speed_ave'] = speed_ave
    df['speed_std'] = speed_std
    df['speed_max'] = speed_max

②目的関数

LightGBMの目的変数を二値分類に設定して、学習させます。

やり方は簡単で、パラメータのobjectiveの値を「binary」に設定するだけです。

また、metricには「AUC」を選択しています。

「metric」で機械学習する際の学習評価手段を指定します。

# パラメータ
params = {'task': 'train',
          'boosting_type': 'gbdt',
          'objective': 'binary', # この箇所で目的関数を指定

          'metric': 'auc',
          'learning_rate': 0.01}

# モデル作成
model = lgb.train(params,
                  lgb_train,
                  valid_sets=(lgb_train, lgb_valid),
                  early_stopping_rounds=100,
                  verbose_eval=100)

回収率

今回の変化によって回収率がどの程度になるか、確認します。

回収率の計算

「予測確率がxx%を超えたら、その競争馬に単勝を賭ける」というロジックで、回収率を計算します。

本処理では、上記xxの値を0%~500%の間で10%刻みで計算しています。

# 予測結果を整理
df_pred = pd.DataFrame(data={'race_ID': x_test['race_ID'],
                             'y_pred_prob': y_pred_prob,
                             'y_test': y_test[0],
                             'win_odds': odds_df_test['win_odds']})

# 回収率を計算
step_list = [x * 0.01 for x in range(0, 31)]
return_rate_list = []

for cnt in range(0, len(step_list)):
    step = step_list[cnt]
    df_pred['y_pred_class'] = [1 if x >= step else 0 for x in df_pred['y_pred_prob']] 
    input_amount = len(df_pred.loc[df_pred['y_pred_class'] == 1, 'y_pred_prob']) 
    hit_odds_list = list(df_pred.loc[(df_pred['y_pred_class'] == df_pred['y_test']) &\
                                     (df_pred['y_pred_class'] == 1),
                                     'win_odds'])
    return_amount = sum(hit_odds_list)
    if input_amount > 0:
        return_rate_list.append(return_amount / input_amount)
    elif input_amount == 0:
        return_rate_list.append(float('nan'))
ソースコード解説
  1. レースID、予測確率、実際の着順、オッズを1つのデータフレーム型変数に格納
  2. 0-0.3を0.05刻みするリストを作成

    ——————– 2のリストでループ ——————-
  3. 予測確率がxxを超えた場合「y_pred_class」カラムの値を1にし、下回った場合は0を挿入する
  4. 3の値が1の数をカウントする(投入金額)
  5. 「y_pred_class」と「y_test」の値が両方1となる行数をカウントする(回収金額)
  6. 4と5の値を割って、回収率を計算する

回収率を描画

計算した回収率をグラフ化します。

# 回収率を描画
x = step_list
y = return_rate_list
fig, ax = plt.subplots(1, 1, figsize=(10, 8))
ax.plot(x, y, linestyle='solid', color='k', marker='^', label='race_cnt')
plt.title('Return_Rate', fontsize=20)

# 軸ラベルを追加
ax.set_xlabel('y_pred_prob', fontsize=15)
ax.set_ylabel('return_rate', fontsize=15)

# y軸設定
ax.set_ylim(0.5, 1.5)

# 値ラベルを追加
for i, k in zip(x, y):
    if math.isnan(k):
        continue
    elif k < 0.5 :
        continue
    else:
        plt.text(i, k + 0.05, str(round(k * 100)) + '%', fontsize=10)  

回収率の結果は以下のようになりました。

最大で125%を達成していますが、これは予測確率が30%以上の競走馬に賭けた場合なので、該当するレースがとても限られます。

この結果だと、あまり現実的ではありませんね。

特徴量の重要度

次に、特徴量の重要度を確認していきます。

lgb.plot_importance(model, max_num_features=20)

今回は新たに、

  • speed(競走馬の過去5Rのスピード指数の中央値)
  • speed_ave(レースに参加する競走馬の “speed” の平均値)
  • speed_std(レースに参加する競走馬の “speed” の標準偏差)
  • speed_max(レースに参加する競走馬の “speed” の最大値)

の4項目を新たに追加しましたが、それぞれが上位に食い込んでいることが分かります。

今回の変更は回収率にはあまり影響がなかったものの、よりよいモデルを作成できました。

コメント

タイトルとURLをコピーしました