本記事の目的
本記事は、下記事項を目的としています。
前回モデルの回収率
本ページでは、過去に作成した単勝予測モデルの改善方法を解説します。
過去モデルの詳細は「【競馬予測】「Optuna」のLightGBM(多クラス分類)で単勝予測」で解説しているので、詳しく知りたい方は読んでみてください。
ちなみに回収率は80%位です。。。
前回モデルからの変更点
本記事では、「過去モデルを改善し、その結果回収率は向上するのか」という観点で解説を行っていきます。
前回モデルの問題点
前回モデルの問題点は、大きく2つありました。
①特徴量の偏りが大きい
前回モデルの特徴量の重要度を見ると、
の2つが飛びぬけており、その他の特徴量があまり機能していませんでした。

このままだと、単純にオッズが低い(≒平均タイムが速い)競走馬を1位と予測してしまい、大きな回収率を見込めません。
②未来のデータを使用していた
これは致命的なミスなのですが、特徴量「time_ave(競走馬の1000m当たりの平均タイム)」は、訓練データ全ての平均タイムの値を採用していました。
競馬データは時系列データであるため、この方法で計算してしまうと、一部のデータが未来の結果を含んだ値となってしまいます。
本来は未来のタイムはわからないはずなので、これでは問題ですね。
今回モデルの変更点
上記の問題を受けて、今回モデルでは特徴量に対して4つの変更を加えていきます。
作業概要
以降では、下記の手順で競馬予想を行います。
- ①前準備
- ②モデル作成
- ③予測
- ④モデル評価
- ⑤回収率計算

①前準備

""" 1. 前準備 """
# ライブラリ
import pandas as pd
import datetime
from tqdm import tqdm
from sklearn.preprocessing import LabelEncoder
# パラメータ
date_split_train = 20190401 # テストデータの開始点(年+月+日)
date_split_valid = 20210401 # 訓練データと検証データの分岐点(年+月+日)
date_split_test = 20210901 # 検証データとテストデータの分岐点(年+月+日)
file_directory = 'C:/Users/kouhe/OneDrive/01_Documents/02_機械学習/データ'
file_name = 'race_result.csv'
# データ読み込み ---------- (※1)
race_result_total = pd.read_csv(file_directory + '/race_result.csv')
horse_result = pd.read_csv(file_directory + '/horse_result.csv', index_col=0)
horse_ped = pd.read_csv(file_directory + '/horse_peds.csv', index_col=0)
return_table = pd.read_csv(file_directory + '/return_table.csv', index_col=0)
race_result = race_result_total[race_result_total['date'] >= date_split_train]
# データ加工(レース結果) ---------- (※2)
df = edit_data(race_result, horse_result, horse_ped)
# オッズを取得
odds_df = pd.DataFrame(data={'date': df['date'],
'win_odds': df['win_odds']})
# 不要な行を削除
drop_list = ['time', 'win_odds', 'population']
df = df.drop(drop_list, axis=1)
# 訓練データ・テストデータに分割 ---------- (※3)
train = df[df['date'] < date_split_valid]
valid = df[(df['date'] >= date_split_valid) & (df['date'] < date_split_test)]
test = df[df['date'] >= date_split_test]
odds_df_test = odds_df[odds_df['date'] >= date_split_test]
# 説明変数
drop_list = ['rank']
x_train = train.drop(drop_list, axis=1)
x_valid = valid.drop(drop_list, axis=1)
x_test = test.drop(drop_list, axis=1)
# 目的変数
y_train = pd.DataFrame(train['rank'])
y_valid = pd.DataFrame(valid['rank'])
y_test = pd.DataFrame(test['rank'])
y_test_org = pd.DataFrame(test['rank'])
# 目的変数を「0 : 上位(1~3位)」「1: 中位(4~10位)」「2 : 下位(11位-)」に変換
# 上位
y_train.loc[y_train['rank'] <= 3] = 0
y_valid.loc[y_valid['rank'] <= 3] = 0
y_test.loc[y_test['rank'] <= 3] = 0
# 中位
y_train.loc[(y_train['rank'] >= 4) & (y_train['rank'] <= 10)] = 1
y_valid.loc[(y_valid['rank'] >= 4) & (y_valid['rank'] <= 10)] = 1
y_test.loc[(y_test['rank'] >= 4) & (y_test['rank'] <= 10)] = 1
# 下位
y_train.loc[y_train['rank'] >= 11] = 2
y_valid.loc[y_valid['rank'] >= 11] = 2
y_test.loc[y_test['rank'] >= 11] = 2
以降では、上記コードについて解説していきます。
①-1 データ読み込み
ローカルフォルダに保存している
を読み込みます。
使用するメソッドは、pandasの
read_csv
で、フルパスを指定することで、CSVファイルを読み込んでいます。
# データ読み込み ---------- (※1)
race_result_total = pd.read_csv(file_directory + '/race_result.csv')
horse_result = pd.read_csv(file_directory + '/horse_result.csv')
horse_ped = pd.read_csv(file_directory + '/horse_peds.csv')
race_result = race_result_total[race_result_total['date'] >= date_split_train]
上記データの取得方法
このデータは「netkeiba.com」からスクレイピングで取得しており、取得方法は「競馬AI作成:③-3」で解説しています。
①-2 データ加工
本工程は、「edit_data」という自作メソッドを使用しています。
# データ加工(レース結果) ---------- (※2)
race_result_edit = edit_data(race_result)
""" メソッド : レース結果を編集 """
# ライブラリ
import pandas as pd
from tqdm import tqdm
import numpy as np
import datetime
import sys
from sklearn.preprocessing import LabelEncoder
def edit_data(race_df, horse_df, ped_df):
df = race_df
# 不要な行を削除する ----------------- (※1)
# 「タイム」が「x:xx.xx」以外の行
df = df.dropna(subset=['タイム'])
horse_df = horse_df.dropna(subset=['タイム'])
# 「着順」の値に"失"が含まれている行
df['着順'] = df['着順'].astype(str)
df = df[~df['着順'].str.contains('失')]
horse_df['着順'] = horse_df['着順'].astype(str)
horse_df = horse_df[~horse_df['着順'].str.contains('失')]
# 「着順」に「(」が含まれている行
df = df[~df['着順'].str.contains('\(')]
df['着順'] = df['着順'].astype(float)
horse_df = horse_df[~horse_df['着順'].str.contains('\(')]
horse_df['着順'] = horse_df['着順'].astype(float)
# 「jockey_ID」が数値以外の行
df = df[pd.to_numeric(df['jockey_ID'], errors='coerce').notnull()]
# インデックスを振りなおす
df = df.reset_index(drop=True)
# 複数の情報が含まれている項目を分割する ----------------- (※2)
# 「性齢」と「sex」「age」に分割
df['sex'] = [str(x)[0] for x in df['性齢']]
df['age'] = [str(x)[1] for x in df['性齢']]
# 「馬体重(増減)」を「weight」「weight_change」に分割
df['weight'] = [int(str(x).split('(')[0]) for x in df['馬体重(増減)']]
df['weight_change'] = [int(str(x).split('(')[1][:-1]) if '(' in x\
else 0 for x in df['馬体重(増減)']]
# 「タイム」の値を秒数に変換 ----------------- (※3)
change_second = lambda x: int(str(x).split(':')[0]) * 60 + float(str(x).split(':')[1])
df['time'] = df['タイム'].map(change_second)
# 「タイム」に「:」が2つ以上含まれている行を削除する
horse_df['time_check'] = ['OK' if x.count(':') == 1 else 'NG' for x in horse_df['タイム']]
horse_df = horse_df[horse_df['time_check'] == 'OK']
horse_df['time'] = horse_df['タイム'].map(change_second)
# レースIDを追加 ----------------- (※4)
race_id_list = df['date'].map(lambda x: str(x)[0:4])\
+ df['area_ID'].map(lambda x: str(x).zfill(2))\
+ df['race_month'].map(lambda x: str(x).zfill(2))\
+ df['race_day'].map(lambda x: str(x).zfill(2))\
+ df['race_num'].map(lambda x: str(x).zfill(2))
df['race_ID'] = race_id_list
# 出走馬数を追加 ----------------- (※5)
df['n_horse'] = df['race_ID'].map(df['race_ID'].value_counts())
# 血統情報を追加 ----------------- (※6)
ped_df['horse_ID'] = ped_df.index
df = pd.merge(df, ped_df, how='left', on='horse_ID')
# 過去レースの情報を取得 ----------------- (※7)
horse_id_list = df['horse_ID']
horse_df['date'] = horse_df['日付'].map(lambda x: str(x)[0:4] + str(x)[5:7] + str(x)[8:10])
horse_df['date'] = pd.to_numeric(horse_df['date'], errors='coerce')
horse_df = horse_df[horse_df['date'] != float('nan')].dropna(subset=['date'])
horse_df['date'] = horse_df['date'].astype(int)
horse_df['race_len'] = [int(str(x)[1:]) for x in horse_df['距離']]
horse_df['money'] = [0 if np.isnan(x) else int(x) for x in horse_df['賞金']]
x = list(horse_df['着順'])[0]
# 初期値
time_ave_list = []
rank_ave_list = []
money_sum_list = []
race_interval_list = []
race_date_list = df['date']
for i in tqdm(range(0, len(df))):
race_date_int = int(race_date_list[i])
race_date = datetime.date(int(str(race_date_int)[0:4]),
int(str(race_date_int)[4:6]),
int(str(race_date_int)[6:8]))
horse_id = horse_id_list[i]
target_df = horse_df[(horse_df['date'] < race_date_int)\
& (horse_df.index == horse_id)].sort_values('date', ascending=False)
if len(target_df) == 0:
time_ave_list.append(float('nan'))
rank_ave_list.append(float('nan'))
money_sum_list.append(float('nan'))
race_interval_list.append(float('nan'))
elif (len(target_df) > 0) & (len(target_df) <= 5):
time_ave_list.append(target_df['time'].sum() * 1000 / target_df['race_len'].sum())
rank_ave_list.append(target_df['着順'].mean())
money_sum_list.append(target_df['money'].sum())
recent_date = target_df['日付'].values[0]
recent_date = datetime.date(int(recent_date[0:4]),
int(recent_date[5:7]),
int(recent_date[8:10]))
race_interval_list.append((race_date - recent_date).days)
else:
time_ave_list.append(target_df['time'].head(5).sum() * 1000 / target_df['race_len'].head(5).sum())
rank_ave_list.append(target_df['money'].head(5).sum())
money_sum_list.append(target_df['money'].head(5).sum())
recent_date = target_df['日付'].values[0]
recent_date = datetime.date(int(recent_date[0:4]),
int(recent_date[5:7]),
int(recent_date[8:10]))
race_interval_list.append((race_date - recent_date).days)
# dfに追加
df['time_ave_5R'] = time_ave_list
df['rank_ave_5R'] = rank_ave_list
df['money_sum_5R'] = money_sum_list
df['race_interval'] = race_interval_list
# 列名を英語に変換
df = df.rename(columns={'着順':'rank', '枠':'frame',
'馬番':'horse_num', '斤量':'weight2',
'人気':'population', '単勝オッズ':'win_odds'})
# 型変換 ----------------- (※8)
# 文字型の値を数値化
df = df.replace({'未勝利':0, '1勝クラス':1, '2勝クラス':2,
'3勝クラス':3, 'オープン':4, '新馬':5,
'500万下': 6, '1000万下': 7, '1600万下': 8,
'牝':0, '牡':1, 'セ':2})
df['rank'] = df['rank'].astype(int)
df['race_class'] = df['race_class'].astype(int)
df['age'] = df['age'].astype(int)
df['jockey_ID'] = df['jockey_ID'].astype(int)
# カテゴリ変数に型変換を行う
target_list = ['ped_' + str(x) for x in range(0, 62)]
target_list += ['horse_ID', 'jockey_ID', 'race_ID']
for column in target_list:
df[column] = LabelEncoder().fit_transform(df[column])
df[column] = df[column].astype('category')
# 不要な列を削除する ----------------- (※9)
drop_list = ['馬名', '性齢', '騎手', 'タイム', '着差', 'コーナー通過順', '厩舎', '馬体重(増減)', '後3F']
df.drop(drop_list, axis=1, inplace=True)
# 「df」のインデックスを0始まりの連番に振りなおす
df.reset_index(drop=True, inplace=True)
return df
出走馬数を追加(※5)
今回モデルでは、新たにレースごとの出走馬数を追加します。
出走馬数が多いほど1位になる確率は低くなるので、モデルの精度向上に貢献してくれるかなと思っています。
本処理では、レース結果データフレームの中で、レースIDが一致する行の合算値を計算し、新たな列(「n_horse」)に追加しています。
# 出走馬数を追加 ----------------- (※5)
race_id_list = df['race_ID'].unique()
n_horse_list = []
for race_id in race_id_list:
n_horse = (df['race_ID'] == race_id).sum()
n_horse_list += [n_horse] * n_horse
df['n_horse'] = n_horse_list
血統情報を追加(※6)
競争馬毎に、↓のような血統情報を追加します。

予めこの表をスクレイピングで取得しておき、変数 “df” に結合します。
データフレーム同士を結合する際に、本処理ではpandasの
merge()
を使用しています。
引数に、
を指定することで、列名 “horse_ID” をキーにした外部結合を行っています。
# 血統情報を追加 ----------------- (※6)
ped_df['horse_ID'] = ped_df.index
df = pd.merge(df, ped_df, how='left', on='horse_ID')
過去レースの情報を取得(※7)
続いて、過去レースの情報を取得します。
具体的には
の4項目を新たに追加します。
# 過去レースの情報を取得 ----------------- (※7)
horse_id_list = df['horse_ID']
horse_df['date'] = horse_df['日付'].map(lambda x: str(x)[0:4] + str(x)[5:7] + str(x)[8:10])
horse_df['date'] = pd.to_numeric(horse_df['date'], errors='coerce')
horse_df = horse_df[horse_df['date'] != float('nan')].dropna(subset=['date'])
horse_df['date'] = horse_df['date'].astype(int)
horse_df['race_len'] = [int(str(x)[1:]) for x in horse_df['距離']]
horse_df['money'] = [0 if np.isnan(x) else int(x) for x in horse_df['賞金']]
x = list(horse_df['着順'])[0]
# 初期値
time_ave_list = []
rank_ave_list = []
money_sum_list = []
race_interval_list = []
race_date_list = df['date']
for i in tqdm(range(0, len(df))):
race_date_int = int(race_date_list[i])
race_date = datetime.date(int(str(race_date_int)[0:4]),
int(str(race_date_int)[4:6]),
int(str(race_date_int)[6:8]))
horse_id = horse_id_list[i]
target_df = horse_df[(horse_df['date'] < race_date_int)\
& (horse_df.index == horse_id)].sort_values('date', ascending=False)
if len(target_df) == 0:
time_ave_list.append(float('nan'))
rank_ave_list.append(float('nan'))
money_sum_list.append(float('nan'))
race_interval_list.append(float('nan'))
elif (len(target_df) > 0) & (len(target_df) <= 5):
time_ave_list.append(target_df['time'].sum() * 1000 / target_df['race_len'].sum())
rank_ave_list.append(target_df['着順'].mean())
money_sum_list.append(target_df['money'].sum())
recent_date = target_df['日付'].values[0]
recent_date = datetime.date(int(recent_date[0:4]),
int(recent_date[5:7]),
int(recent_date[8:10]))
race_interval_list.append((race_date - recent_date).days)
else:
time_ave_list.append(target_df['time'].head(5).sum() * 1000 / target_df['race_len'].head(5).sum())
rank_ave_list.append(target_df['money'].head(5).sum())
money_sum_list.append(target_df['money'].head(5).sum())
recent_date = target_df['日付'].values[0]
recent_date = datetime.date(int(recent_date[0:4]),
int(recent_date[5:7]),
int(recent_date[8:10]))
race_interval_list.append((race_date - recent_date).days)
# dfに追加
df['time_ave_5R'] = time_ave_list
df['rank_ave_5R'] = rank_ave_list
df['money_sum_5R'] = money_sum_list
df['race_interval'] = race_interval_list
型変換(※8)
「競走馬ID」「騎手ID」「レースID」の3項目を、カテゴリ変数に型変換します。
「~.astype(‘category’)」と記述するだけで、列全体をカテゴリ変数に型変換できます。
# カテゴリ変数に型変換を行う
target_list = ['ped_' + str(x) for x in range(0, 62)]
target_list += ['horse_ID', 'jockey_ID', 'race_ID']
for column in target_list:
df[column] = LabelEncoder().fit_transform(df[column])
df[column] = df[column].astype('category')
①-3 訓練データ・手検証データ・テストデータに分割
この部分に関しては前回モデル作成時から変更はないので、詳細な解説は割愛します。
# 訓練データ・テストデータに分割 ---------- (※3)
train = df[df['date'] < date_split_valid]
valid = df[(df['date'] >= date_split_valid) & (df['date'] < date_split_test)]
test = df[df['date'] >= date_split_test]
odds_df_test = odds_df[odds_df['date'] >= date_split_test]
# 説明変数
drop_list = ['rank']
x_train = train.drop(drop_list, axis=1)
x_valid = valid.drop(drop_list, axis=1)
x_test = test.drop(drop_list, axis=1)
# 目的変数
y_train = pd.DataFrame(train['rank'])
y_valid = pd.DataFrame(valid['rank'])
y_test = pd.DataFrame(test['rank'])
y_test_org = pd.DataFrame(test['rank'])
# 目的変数を「0 : 上位(1~3位)」「1: 中位(4~10位)」「2 : 下位(11位-)」に変換
# 上位
y_train.loc[y_train['rank'] <= 3] = 0
y_valid.loc[y_valid['rank'] <= 3] = 0
y_test.loc[y_test['rank'] <= 3] = 0
# 中位
y_train.loc[(y_train['rank'] >= 4) & (y_train['rank'] <= 10)] = 1
y_valid.loc[(y_valid['rank'] >= 4) & (y_valid['rank'] <= 10)] = 1
y_test.loc[(y_test['rank'] >= 4) & (y_test['rank'] <= 10)] = 1
# 下位
y_train.loc[y_train['rank'] >= 11] = 2
y_valid.loc[y_valid['rank'] >= 11] = 2
y_test.loc[y_test['rank'] >= 11] = 2
②AIモデル作成

訓練データ・検証データを用いて、Light GBMのモデルを作成します。
この工程に関しても、前回モデル作成時から変更はないので、解説は割愛します。
""" 2. モデル作成 """
# ライブラリ
import optuna.integration.lightgbm as lgb
import matplotlib.pyplot as plt
import os
import numpy as np
import pickle
import math
import scipy.stats
from sklearn import preprocessing
import statistics
# LightGBM用のデータセットに変換
lgb_train = lgb.Dataset(x_train, y_train)
lgb_valid = lgb.Dataset(x_valid, y_valid)
# ハイパーパラメータ
params = {'boosting_type': 'gbdt',
'objective': 'multiclass',
'metric': 'multi_logloss',
'num_class': 3,
'learning_rate': 0.01}
# モデル作成
start = datetime.datetime.now()
model = lgb.train(params,
lgb_train,
valid_sets=[lgb_train, lgb_valid],
#categorical_feature=['horse_ID', 'jockey_ID'],
early_stopping_rounds=100,
verbose_eval=100)
end = datetime.datetime.now()
proc_time = end - start
③予測

作成したAIモデルに、テストデータの特徴量をインプットして、各競走馬が上位に入る確率を予測します。
""" 3. 予測 """
# テストデータを予測(レースIDの中で最も確率が高いものを1、それ以外を0にする)
y_pred_prob = pd.DataFrame(model.predict(x_test))
# 予測確率を標準化 → 正規化 ------------ (※1)
race_id_list = x_test['race_ID']
df_pred = pd.DataFrame(data={'race_ID': race_id_list,
'y_pred_prob': y_pred_prob[0]})
prob = df_pred.groupby('race_ID').transform(lambda x: (x - np.mean(x)) / statistics.stdev(x))['y_pred_prob']
df_pred['y_pred_prob'] = (prob - prob.min()) / (prob.max() - prob.min())
# 特徴量の重要度をプロット ------------- (※2)
lgb.plot_importance(model)
importance = np.array(model.feature_importance(importance_type='gain'))
feature = x_test.columns
df_importance = pd.DataFrame(data={'feature': feature, 'importance': importance})\
.sort_values('importance', ascending=False)
予測確率を標準化 → 正規化(※1)
前回モデル作成時では、予測確率をそのまま使用していましたが、今回は標準化・正規化を行います。
このような処理を行うことで、他の競走馬を加味した相対的な予測確率に変換します。
# 予測確率を標準化 → 正規化 ------------- (※1)
race_id_list = x_test['race_ID']
df_pred = pd.DataFrame(data={'race_ID': race_id_list,
'y_pred_prob': y_pred_prob[0]})
prob = df_pred.groupby('race_ID').transform(lambda x: (x - np.mean(x)) / statistics.stdev(x))['y_pred_prob']
df_pred['y_pred_prob'] = (prob - prob.min()) / (prob.max() - prob.min())
特徴量の重要度(※2)
今回モデルの特徴量の重要度を確認してみたところ、上位15位の特徴の重要度はこのような結果になりました。

新たに追加した
が大きく影響していることが分かります。
# 特徴量の重要度 -------------- (※2)
importance = np.array(model.feature_importance(importance_type='gain'))
feature = x_test.columns
df_importance = pd.DataFrame(data={'feature': feature, 'importance': importance})\
.sort_values('importance', ascending=False)
④回収率を計算

""" 5. 回収率 """
# 回収率を計算 ----------------- (※1)
odds = odds_df.loc[odds_df['date'] >= date_split_test, 'win_odds']
step_list = [x * 0.05 for x in range(0, 21)]
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']) * 100
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) * 100
return_rate_list.append(return_amount / input_amount)
# 回収率を描画 ------------- (※2)
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)
回収率を計算(※1)
「予測確率がxx%を超えたら、その競争馬に単勝を賭ける」というロジックで、回収率を計算します
本処理では、上記のxxの値を0%~100%の間で5%刻みで計算しています。
# 回収率を計算 -------------- (※1)
odds = odds_df.loc[odds_df['date'] >= date_split_test, 'win_odds']
step_list = [x * 0.05 for x in range(0, 21)]
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']) * 100
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) * 100
return_rate_list.append(return_amount / input_amount)
回収率を描画(※2)
上記の方法で回収率を計算したところ、このような結果になりました。

回収率の結果は大体80%前後で、予測確率が70%以上の競走馬に賭けた際の回収率が最大で86%でした。
前回モデルから若干改善されているものの、目標の100%に達成することができませんでした。
まだまだ改善の余地がありそうですね。
コメント