LoginSignup
78
108

More than 1 year has passed since last update.

Pythonガチの初学者が「LightGBM・TensorFlow」を使って競馬予測のモデルを作ったら単勝回収率100%こえた

Last updated at Posted at 2021-11-08

目次

1, はじめに
2, 今回の目標
3, 使用するデータ
4, 前処理
5, モデル作成
6, スコア結果
7, レース結果
8, 反省点・改善点
9, さいごに

はじめに

ご覧いただきありがとうございます。

今回は、競馬予測の機械学習モデルを作ってみました。
作成しようと思った理由としては元々競馬が好きで予想とかをしていましたが、自分で予想するのは難しくなってきたから機械にやってもらおうというのが事の発端です。

まだまだ勉強不足や荒い知識で作成しているようななところもありますが、改善案などをコメントでいただけると幸いです。

GitHubにコードをあげています!
GitHub-競馬予想モデル

今回の目標

複勝or単勝で回収率100%を超えるようなモデルの作成、データの前処理を行うこと。

使用するデータ

今回使用するデータはこちらです。平地レースのみのデータを使用しています。
(障害レースは数が少ないので対象外としました。)
useCsvData.png
※画像が小さくて申し訳ございません。

各カラムに関してはこちらで確認していただけると幸いです。
競馬予測で使用する際のCSVデータ

今回使用するライブラリーはこちらです。
事前にCSVデータを読み込ませておきます。

import time
import warnings

import numpy as np
import optuna.integration.lightgbm as lgb
import pandas as pd
import tensorflow as tf

from datetime import datetime
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
from sklearn.preprocessing import StandardScaler
from tensorflow import feature_column
from tensorflow.keras import layers

# 事前にCSVファイルを読み込ませる。
df = pd.read_csv('horsedata.csv', encoding='shift-jis')

前処理

前処理といってもかなりありますが、多少のドメイン知識を利用して作成を行いました。

・前走成績
・騎手のコースごとの連対率
・父親のコース、距離、馬場の連対率と脚質
・父親のタイプのコース、距離、馬場の連対率と脚質
・各コース連対時の3角・4角の平均順位、3ハロンタイム、人気、オッズ
・各距離連対時の3角・4角の平均順位、3ハロンタイム、人気、オッズ
・特徴量の生成

前走成績

df = df.dropna(how='any')

df['days'] = pd.to_datetime(df['days'])
name_days_df = df[["horsename", "days", "pop",
                   "odds", "rank3", "rank4", "3ftime", "result"]].sort_values(['horsename', 'days'])

name_list = name_days_df['horsename'].unique()
df_list = []

for name in name_list:
    name_df = name_days_df[name_days_df['horsename'] == name]
    shift_name_df = name_df[["pop", "odds", "rank3", "rank4", "3ftime", "result"]].shift(1)
    shift_name_df['horsename'] = name
    df_list.append(shift_name_df)

df_before = pd.concat(df_list)
df_before['days'] = name_days_df['days']

df_before = df_before.rename(columns={'pop': 'pre_pop', 'odds': 'pre_odds', 'rank3': 'pre_rank3',
                                      'rank4': 'pre_rank4', '3ftime': 'pre_3ftime', 'result': 'pre_result'})

df = pd.merge(df, df_before, on=['horsename', 'days'], how='inner')

人気、オッズ、3角順位、4角順位、3ハロンタイム、結果を前走のデータに加えて各馬ごとにshiftでひとつずらしています。
ここのfor分の所で処理の時間が無茶苦茶かかってしまっているので変えたいところですが、いい方法が思いつきませんでした。

騎手のコースごとの連対率

df.loc[df['result'] >= 3, 'result'] = 0
df.loc[df['result'] == 2, 'result'] = 1

table_jockey = pd.pivot_table(df, index='jocky', columns='place', values='result', aggfunc='mean', dropna=False)
table_jockey = table_jockey.fillna(0)

table_jockey = pd.DataFrame(table_jockey)
table_jockey = table_jockey.round(4)
table_jockey = table_jockey.add_prefix('jockey_')

父親のコース、距離、馬場の連対率と脚質

df.loc[df['result'] >= 3, 'result'] = 0
df.loc[df['result'] == 2, 'result'] = 1

index = 'father'

table_father_place = pd.pivot_table(df, index=index, columns='place', values='result', aggfunc='mean',
                                    dropna=False)
table_father_distance = pd.pivot_table(df, index=index, columns='distance', values='result', aggfunc='mean',
                                       dropna=False)
table_father_turf = pd.pivot_table(df, index=index, columns='turf', values='result', aggfunc='mean',
                                   dropna=False)
table_father_condition = pd.pivot_table(df, index=index, columns='condition', values='result', aggfunc='mean',
                                        dropna=False)

table_father = pd.merge(table_father_place, table_father_distance, on=index, how='left')
table_father = pd.merge(table_father, table_father_turf, on=index, how='left')
table_father = pd.merge(table_father, table_father_condition, on=index, how='left')

table_father1 = table_father.fillna(0)

df['legtype'] = df['legtype'].map({'逃げ': 0, '先行': 1, '差し': 2, '追込': 3, '自在': 4})
legtypes = df.groupby(index).legtype.apply(lambda x: x.mode()).reset_index()

legtype = pd.DataFrame(legtypes)
legtype['legtype'] = legtype['legtype'].map({0: '逃げ', 1: '先行', 2: '差し', 3: '追込', 4: '自在'})

legtype = legtype.drop('level_1', axis=1)

time_3f = df.groupby(index).mean()['3ftime']
time3f = pd.DataFrame(time_3f)

father = pd.merge(table_father1, legtype, on=index, how='left')
father = pd.merge(father, time3f, on=index, how='left')

father = father.round(3)
father = father.add_prefix('{}_'.format(index))

騎手のコース連対率と父親のコース、距離、馬場の連対率と脚質は同様の処理を行っています。
基本的にはピポットテーブルを作成して、マージをして作成を行っています。脚質に関しては最頻値を利用して最も採用されている脚質でデータを作成しています。

「父親のタイプのコース、距離、馬場の連対率と脚質」や「各コース連対時の3角・4角の平均順位、3ハロンタイム、人気、オッズ」や「各距離連対時の3角・4角の平均順位、3ハロンタイム、人気、オッズ」はほとんど同様のやり方なので割愛させていただきます。詳細はGithubのコードで確認していただけると幸いです。

特徴量の生成

d_ranking = lambda x: 1 if x in [1, 2] else 0
df['flag'] = df['result'].map(d_ranking)

drop_list = ['result', 'rank3', 'rank4', '3ftime', 'time']
df = df.drop(drop_list, axis=1)

df['odds_hi'] = (df['odds'] / df['pop'])
df['re_odds_hi'] = (df['pre_odds'] / df['pre_pop'])
df['odds_hi*2'] = df['odds_hi'] ** 2
df['re_odds_hi*2'] = df['re_odds_hi'] ** 2
df['re_3_to_4time'] = (df['pre_rank4'] - df['pre_rank3'])
df['re_3_to_4time_hi*2'] = (df['pre_rank4'] / df['pre_rank3']) ** 2
df['father_3f_to_my'] = (df['father_3ftime'] - df['pre_3ftime'])
df['fathertype_3f_to_my'] = (df['fathertype_3ftime'] - df['pre_3ftime'])
df['re_pop_now_pop'] = (df['pre_pop'] - df['pop'])
df['re_odds_now_odds'] = (df['pre_odds'] - df['odds'])
df['re_result_to_pop'] = (df['pre_result'] - df['pre_pop'])

feature_list = ['odds_hi', 're_odds_hi', 'odds_hi*2', 're_odds_hi*2', 're_3_to_4time', 're_3_to_4time_hi*2',
                        'father_3f_to_my', 'fathertype_3f_to_my', 're_pop_now_pop', 're_odds_now_odds',
                        're_result_to_pop']
for feature in feature_list:
    df[feature] = df[feature].replace([np.inf, -np.inf], np.nan)
    df[feature] = df[feature].fillna(0)

今回は1着2着の馬にフラグを立てて作成を行います。
また、結果と3角順位と4角順位と3ハロンタイムと走破タイムは予想する際使えないデータなので削除します。特徴量の生成は適当なドメイン知識で作成していますので、ここは要改善ですね。

これらのデータをすべてマージして1つにまとめた状態でモデルに読み込ませます。

モデル作成

今回使用したモデル
・LightGBM
・TensorFlow

この2つを持ってきました。持ってきた理由は特になく調べたらこの2つが出てきたので2つとも実装しちゃえと思い使用しました。
ドキュメントに書いてあるチュートリアルのコードをうまくデータに合うように引っ張って来ているので所々おかしいところがあるかもしれませんがその点はご了承ください。

LightGBMでの実装

cat_cols = ['place', 'class', 'turf', 'distance', 'weather', 'condition', 'sex', 'father', 'mother',
                    'fathertype', 'fathermon', 'legtype', 'jocky', 'trainer', 'father_legtype']
for c in cat_cols:
    le = LabelEncoder()
    le.fit(df[c])
    df[c] = le.transform(df[c])

使用する文字列の変数をカテゴリ変数化しています。

df['days'] = pd.to_datetime(df['days'])
df = df.dropna(how='any')

df_pred = df[df['days'] >= datetime(2021, 11, 7)]
df_pred_droped = df_pred.drop(['flag', 'days', 'horsename', 'raceid', 'odds', 'pop'], axis=1)

df = df[df['days'] < datetime(2021, 11, 7)]

train_x = df.drop(['flag', 'days', 'horsename', 'raceid', 'odds', 'pop'], axis=1)
train_y = df['flag']

X_train, X_test, y_train, y_test = train_test_split(train_x, train_y,
                                                    stratify=train_y,
                                                    random_state=0, test_size=0.3, shuffle=True)

cat_cols = ['place', 'class', 'turf', 'distance', 'weather', 'condition', 'sex', 'father', 'mother',
            'fathertype', 'fathermon', 'legtype', 'jocky', 'trainer', 'father_legtype']

lgb_train = lgb.Dataset(X_train, y_train, categorical_feature=cat_cols)
lgb_eval = lgb.Dataset(X_test, y_test, reference=lgb_train, categorical_feature=cat_cols)

params = {
    'task': 'predict',
    'objective': 'binary',
    'verbosity': -1,
}

model = lgb.train(
    params,
    lgb_train,
    categorical_feature=cat_cols,
    valid_sets=lgb_eval,
    num_boost_round=100,
    early_stopping_rounds=20,
)
best_params = model.params

model = lgb.train(
    best_params,
    lgb_train,
    categorical_feature=cat_cols,
    valid_sets=lgb_eval,
    num_boost_round=100,  # 100
    early_stopping_rounds=20,  # 20
)

predict_proba = model.predict(df_pred_droped, num_iteration=model.best_iteration)

gbm_predict = pd.DataFrame({"raceid": df_pred['raceid'],
                        "gbm_pred": predict_proba})

予想するデータと訓練するデータを日にちで分けてからモデルに入れます。
今回はoptunaを利用してパラメーターのチューニングを自動で行っています。

TensorFlowでの実装

scaler = StandardScaler()
sc = scaler.fit(df[num_data])

scalered_df = pd.DataFrame(sc.transform(df[num_data]), columns=num_data, index=df.index)
df.update(scalered_df)

# ここからTensorFlow
feature_columns = []

num_data = datalist.num_datas

for header in num_data:
    feature_columns.append(feature_column.numeric_column(header))

horsenum = feature_column.numeric_column('horsenum')
horsenum_buckets = feature_column.bucketized_column(horsenum, [2, 4, 6, 8, 10, 12, 14, 16, 18])
feature_columns.append(horsenum_buckets)

cat_data = ['place', 'class', 'turf', 'weather', 'condition', 'sex', 'father', 'mother', 'fathermon',
            'fathertype', 'legtype', 'jocky', 'trainer']

for cat in cat_data:
    category = feature_column.categorical_column_with_vocabulary_list(cat, list(df[cat].unique()))
    feature_columns.append(feature_column.embedding_column(category, dimension=8))

feature_layer = tf.keras.layers.DenseFeatures(feature_columns)

TensorFlowを利用する際は標準化した方がいいとどこかの記事でみたので利用してみました。不必要ならば削除しようと思います。
num_dataは数値化しているデータのカラムを格納しています。
Tensorflowの部分はドキュメントを見よう見まねで作成しています。

TensorFlow 構造化されたデータの分類

def df_to_dataset(dataframe, shuffle=True, batch_size=32):
    dataframe = dataframe.copy()
    labels = dataframe.pop('flag')
    ds = tf.data.Dataset.from_tensor_slices((dict(dataframe), labels))
    if shuffle:
        ds = ds.shuffle(buffer_size=len(dataframe))
    ds = ds.batch(batch_size)
    return ds

df['days'] = pd.to_datetime(df['days'])
df = df.dropna(how='any')

df_pred = df[df['days'] >= datetime(2021, 11, 7)]
df_pred_droped = df_pred.drop(['flag', 'days', 'horsename', 'raceid', 'odds', 'pop'], axis=1)

df = df[df['days'] < datetime(2021, 11, 7)]
df = df.drop(['days', 'horsename', 'raceid', 'odds', 'pop'], axis=1)

train, test = train_test_split(df, test_size=0.2)
train, val = train_test_split(train, test_size=0.2)

batch_size = 32
train_ds = self.df_to_dataset(train, batch_size=batch_size)
val_ds = self.df_to_dataset(val, shuffle=False, batch_size=batch_size)
test_ds = self.df_to_dataset(test, shuffle=False, batch_size=batch_size)

pred_ds = tf.data.Dataset.from_tensor_slices(dict(df_pred_droped))
pred_ds = pred_ds.batch(batch_size=batch_size)

model = tf.keras.Sequential([
    feature_layer,
    layers.Dense(128, activation='relu'),
    layers.Dense(128, activation='relu'),
    layers.Dense(128, activation='relu'),
    layers.Dense(64, activation='relu'),
    layers.Dense(1, activation='sigmoid')
])

model.compile(optimizer='adam',
              loss='binary_crossentropy',
              metrics=['accuracy'])

model.fit(train_ds,
          validation_data=val_ds,
          epochs=5)

# loss, accuracy = model.evaluate(test_ds)

predictions = model.predict(pred_ds)
predict = [i for i in predictions]

d = {
    "raceid": df_pred['raceid'],
    "tf_pred": predict
}

tf_predict = pd.DataFrame(data=d)

ここら辺すべてドキュメントを参照して作成を行いました。

平均をとりフラグの作成

df_pred = main_df[main_df['days'] >= datetime(2021, 11, 7)]

df = pd.merge(gbm_predict, tf_predict, on='raceid', how='left')

# gbm_pred, tf_pred
df['new_mark_flag'] = '×'
df['new_flag'] = 0

# # 0.5が1個以上のフラグ作成。〇
df['new_mark_flag'].mask((df['gbm_pred'] >= 0.5) | (df['tf_pred'] >= 0.5), '〇', inplace=True)

# 0.5が2個以上のフラグ作成。◎
df['new_mark_flag'].mask((df['gbm_pred'] >= 0.5) & (df['tf_pred'] >= 0.5), '◎', inplace=True)

# 0.5以上をフラグ追加
df['new_flag'].mask(((df['gbm_pred'] * 0.5) + (df['tf_pred'] * 0.5)) >= 0.5, 1, inplace=True)

df = pd.merge(df_pred, df, on='raceid', how='left')

今回は〇と◎のフラグを追加と、各モデル1対1の割合にして0-1フラグを立てて予想をしてもらいました。

スコア結果

# LightGBM
binary_logloss: 0.313166

# TensorFlow
loss: 0.2861 - accuracy: 0.8771
val_loss: 0.3332 - val_accuracy: 0.8605

正直、高いか低いかの基準があいまいなので正確な判断はできませんでしたが、他のを見ているとわりと高い方な気がします。
しかし、競馬の特性を考えたらloglossは高くなるのは必然的なのかもしれません。

レース結果

2021年11月7日(日)の実際のレースで予想してみました。
※新馬戦、障害レース、その他確率が極端に低いレースは除外しています。

賭け方は各レース確率が良かった上位4頭を選んで、単勝4頭400円複勝4頭400円の計800円を投票しました。
計25レース合計20,000円分投票をおこないました。

結果は

レース場 単勝 複勝
福島 3,750円 3,530円
東京 2,750円 2,850円
阪神 3,610円 2,510円
合計金額 10,110円 8,890円
投資金額 10,000円 10,000円
回収金額 110円 -1,110円
回収率 101% 89%

ぎりぎりですが、単勝が回収100%を超えました。複勝が超えなかった原因は馬券を的中させたのに損をしたケースが多かったです(要するにガミです)

レース場 投票回数 単勝的中数 単勝的中率 複勝的中数 複勝的中率
福島 8  6 75% 8 100%
東京 8  8 100% 8 100%
阪神 9 8 89% 8 100%

的中数はこんな感じでした。複勝は4頭もかけているので流石に100%ですが、単勝は平均して88%の的中率でした。
4頭かけているとはいえいい感じの的中率じゃないでしょうか

ちなみに【単勝1640円・複勝500円】が最高配当でした。わりと穴馬も狙えているので良かったと思います。

今回は単勝複勝だけでしたが、今後は馬連、馬単や3連複、3連単なんかにも挑戦していきたいです。

反省点・改善点

・前処理がまだまだ荒い
作成している段階ではあまり気にならなかったのですが、実際に書いてみるとこれ本当に必要なのかみたいなのが多かったなぁと思いました。ちゃんとグラフ化して特徴量を精査する必要があると感じました。

また、他に特徴量を追加するとしたら馬の強さの数値化(レーティング)みたいにしても面白いかなと思いました。しかし、あくまでも予想ですがレーティングが高いほど人気になる傾向が強いのではないかと考えられます。ただ単純に1着2着でレーティングを作るのはちょっと厳しい気がするので、3ハロンタイムだったり走破タイム、3角・4角の順位などといったほかの指標も考慮するべきなのではないかとも思います。ジョッキーや調教師などもレーティング化しても面白いなと思いました。

・圧倒的知識不足
競馬のドメイン知識もそうですが、統計学や機械学習やライブラリーの使い方、アルゴリズムの理解不足など足りてない部分が多いと改めて気づかされました。今後はQiitaで同じことをやっている人の記事をみたり、ドキュメントを再確認などをして知識をしっかりと増やしていきたいと思いました。

さいごに

今回はLightGBMとTensorFlowを用いて予想を行いましたが、単勝は回収率100%超えましたが複勝は超えることができませんでした。買い目を絞るなしてガミる対策を取る必要がある気がします。また、前処理がきちんとできてないといい精度のモデルができないことがしっかりと理解できたのである意味よかったなと思いました。

これからは競馬の予想モデルの改良したり、作ったモデルをDjangoやReactを用いてWebアプリケーションにしていきたいと思っています。

その際はQiitaにちきんと残していきたいと思いますのでまたみていただけると嬉しいです。

長くなってしまいましたが最後までご覧いただきありがとうございました。

78
108
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
78
108