初めに
こちらはゆっくりと作る競馬予想ソフト開発のロードマップ2の内容になっています。
ここでは、簡易分析その5の分析結果のJupyter Notebookを公開しています。
Youtubeで競馬予想ソフトの開発の制作過程を投稿しています。再生リストはこちらを参照ください。
YouTube再生リスト:
チャンネル登録もしてもらえると嬉しいです。
競馬予想ソフト開発の概要
競馬予想ソフトの概要を知りたい場合は、以下のページを参照ください。
注意
簡易分析のコードには、一部有料で公開しているモジュールを使用しています。もし一緒に競馬予想ソフトを作ってみたい方は、以下のBookersリンクからソースを購入していただければと思います。
Bookersに登録&YouTubeチャンネル登録をしていただけると1000円オフで購入できます。
Bookersリンク:低アクセス回数で競馬データをスクレイピングする方法
データ分析内容:Jupyter notebook
0.ロードマップ2のパート1でやった分を実行¶
詳細は以下リンクを参照ください。
import warnings
import numpy as np
import pandas as pd
import sys
import re
import seaborn as sns
import matplotlib.pyplot as plt
import japanize_matplotlib
sys.path.append(".")
from src.core.db.controller import getDataFrame # noqa
warnings.filterwarnings("ignore")
dbpath = "./data/keibadata.db"
start_year = 2010
end_year = 2023
year_list = list(range(start_year, end_year+1))
# レース結果
dfrace = pd.concat(
[
getDataFrame(f"racedata{year}", dbpath)
for year in year_list
]
)
# レース情報
dfinfo = pd.concat(
[
getDataFrame(f"raceinfo{year}", dbpath)
for year in year_list
]
)
# 2つのDataFrameをマージ
df = pd.merge(dfinfo, dfrace, on="raceId")
df["raceDate"] = df["raceDate"].astype("datetime64[ns]")
df = df.sort_values(["raceDate", "raceId", "number"]).reset_index(drop=True)
df["race_span"] = df.groupby(
"horseId")["raceDate"].diff().apply(lambda d: d.days)
df = df[~df["field"].isin(["障"])].reset_index(drop=True)
df["label_mark"] = df["label"].apply(lambda d: 1 if re.search(r"\(降\)", d) else 0).astype("int8")
df["label"] = df["label"].apply(lambda d: d.replace("失", "19"))
raceId_list = df[df["label"].apply(
lambda d: True if re.search(r"\(降\)|失", d) else False)]["raceId"].unique()
df["label_flag"] = df["raceId"].apply(lambda d: 1 if d in raceId_list else 0)
df = df[df["label"].apply(lambda d: False if re.search(r"中|取|除", d) else True)].reset_index(drop=True)
df["label"] = df["label"].apply(lambda d: int(re.search(r"\d+", d).group()))
df["last3F"] = pd.to_numeric(df["last3F"], errors="coerce")
df["time"] = pd.to_numeric(df["time"], errors="coerce")
df = df[~df["last3F"].isna()]
df.reset_index(drop=True, inplace=True)
# 数値化対象のカラムを列挙
target_columns = [
'distance', 'number', 'boxNum',
'label', 'odds', 'favorite', 'age',
'jweight', 'time', 'weight', 'gl'
]
# 数値変換
for col in target_columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
dfhorse_fill = df.groupby("horseId")[
["weight", "gl"]].apply(
lambda x: x.ffill().bfill()).sort_index(level=1).reset_index(level=0)
dfna = df["weight"].isna()
dfhorse_fill = dfhorse_fill.sort_index(level=1).reset_index(level=0)
df["weight"] = dfhorse_fill["weight"]
df["gl"] = dfhorse_fill["gl"]
5.オッズの仕組み¶
5.1. オッズとは?¶
そもそもオッズって何?
ある馬Aの単勝オッズの決まり方 控除率を差し引いた払戻率に対して、馬Aの単勝馬券の総売り上げに対する割合で割ったもの
例:
出走馬が、A,B,Cといたとき、それぞれの馬券の売り上げが以下のようになっていた場合
馬 | 売上 | 売上の割合 |
---|---|---|
A | 1000 | 0.125 |
B | 2000 | 0.25 |
C | 5000 | 0.625 |
上記になっているとき、単勝馬券の払戻率が0.8だった場合の各馬の単勝オッズは以下
馬 | 計算 | オッズ |
---|---|---|
A | 0.8÷0.125 | 6.4 |
B | 0.8÷0.25 | 3.2 |
C | 0.8÷0.625 | 1.28 |
一般的かどうかは分からないが、各馬の単勝馬券の売上の割合というのは単勝支持率とも呼ばれる(以降支持率という)
逆に言えば、払戻率は公開されているため、オッズさえ分かれば単勝支持率を求めることができる。
horseNumMap = dfrace.groupby("raceId")["raceId"].count().to_dict()
df["horseNum"] = df["raceId"].map(horseNumMap)
# 単勝の払戻率は0.8と決まっているので、0.8を単勝オッズで割れば支持率が求まる
df["sup"] = 0.8/df["odds"]
df[["sup", "horseNum"]].describe()
sup | horseNum | |
---|---|---|
count | 659571.000000 | 659571.00000 |
mean | 0.070761 | 14.74699 |
std | 0.094641 | 2.37693 |
min | 0.000800 | 5.00000 |
25% | 0.009756 | 13.00000 |
50% | 0.032000 | 16.00000 |
75% | 0.094118 | 16.00000 |
max | 0.727273 | 18.00000 |
統計を見ると、半数近くは売上割合が3%程度しかない。
これは、出走馬数が中央値である16の場合、すべて等しいオッズとなった場合の支持率は0.0625となるため、
かなりオッズの偏りが出ている可能性が高い
5.2. 1着馬のオッズ(支持率)とそれ以外との関係¶
# もう少し深堀するために、1着になった馬の支持率の統計も併せて確認する
pd.concat([
df[df["label"]>1][["sup"]].rename(columns={"sup": "sup_all"}).describe(),
df[df["label"]==1][["sup"]].rename(columns={"sup": "sup_label1"}).describe()],
axis=1
)
sup_all | sup_label1 | |
---|---|---|
count | 612934.000000 | 46637.000000 |
mean | 0.061125 | 0.197404 |
std | 0.081835 | 0.146294 |
min | 0.000800 | 0.001405 |
25% | 0.008909 | 0.081633 |
50% | 0.027972 | 0.163265 |
75% | 0.080000 | 0.275862 |
max | 0.727273 | 0.727273 |
結果から、1着馬となった馬の支持率が明らかに全体の支持率と比較して、全体の統計で見て高いことが分かる
詳しく検定はしないが、すべての分位で明らかに差があるため、有意性がある可能性が高い
つまり、支持率の高さからして過去の情報を元に勝ち馬を予測することは可能であることが分かる
あくまで1着馬になる馬を当てられる可能性があるというだけで、回収率が100%を超えるとは言えないことに注意
当てやすい馬が明らかに分かる場合など、そういうレースで単勝を当てやすいのは間違いないが、
単勝で儲けるのは難しいということ
5.3. 1番人気と回収率の関係¶
# 実際に1番人気の勝率と単勝回収率を確認する
print("1番人気勝率\t:", (df[df["label"].isin([1])]["favorite"]==1).mean())
print("1番人気回収率\t:",
df[df["label"].isin([1]) & df["favorite"].isin([1])]["odds"].sum()/df["raceId"].nunique())
1番人気勝率 : 0.3230053391084332 1番人気回収率 : 0.7815128937367143
data_list = []
for fav in range(1, 19):
win_rate = (df[df["label"].isin([1]) & (df["horseNum"]>=fav)]["favorite"]==fav).mean()
return_rate = df[
df["label"].isin([1]) & df["favorite"].isin([fav])][
"odds"].sum()/df[df["horseNum"]>=fav]["raceId"].nunique()
data_list += [
{
"人気": fav,
"勝率": win_rate,
"回収率": return_rate
}
]
fig, axes = plt.subplots(figsize=(4, 2))
pd.DataFrame(data_list).set_index("人気")[["回収率"]].plot(ax=axes)
plt.xticks(list(range(1, 19)))
plt.grid()
plt.show()
5.4. 支持率と勝率の意外な関係¶
次に、支持率と勝率の関係にも着目してみる。
オッズが高いものはある程度丸める。
- オッズ4倍未満はそのまま
- オッズ4倍以上10倍未満は小数点第1位を四捨五入
- オッズ10倍以上150倍未満は、1の位を四捨五入
- オッズ150倍以上200倍未満は、10の位を四捨五入
- オッズ200倍以上はすべて200とする
fig, axes = plt.subplots(3, 2, figsize=(20,15))
for num, field in enumerate([["ダ", "芝"], ["芝"], ["ダ"]]):
idfodds = df[df["field"].isin(field)].copy()
idfodds["odds_round"] = idfodds["odds"].apply(lambda d: round(d, 1-sum([d>=4, d>=10, d>=150])) if d < 200 else 200)
idfcount: pd.DataFrame = pd.concat(
[
pd.DataFrame(idfodds["odds_round"].value_counts().sort_index()).rename(columns={"count": "all"}),
pd.DataFrame(idfodds[idfodds["label"]<=1]["odds_round"].value_counts().sort_index()).rename(columns={"count": "label1"})
],
axis=1
)
idfcount["勝率"] = idfcount["label1"]/idfcount["all"]
idfcount["支持率"] = 0.8/idfcount.index
# display(idfcount)
idfcount["勝率"] = idfcount["label1"]/idfcount["all"]
idfcount["支持率"] = 0.8/idfcount.index
for col in ["勝率", "支持率"]:
idfcount[col].loc[:10].plot(ax=axes[num, 0])
axes[num, 0].legend(loc="best")
axes[num, 0].grid(ls=":")
axes[num, 0].set_title(", ".join(field))
axes[num, 0].set_ylabel("勝率")
for col in ["勝率", "支持率"]:
idfcount[col].loc[10:].plot(ax=axes[num, 1])
axes[num, 1].legend(loc="best")
axes[num, 1].grid(ls=":")
axes[num, 1].set_xlabel("オッズ")
plt.show()
5.5. 結果の考察¶
結果から、支持率と勝率は大変似通っていることが分かる
大変興味深く、馬券購入者はかなりの精度で勝てる馬を見極められていることが分かる。
→ 競馬予想で回収率100%超えるのが難しい原因だと思われる。
「機械学習=人間がしていることをする」(出典梨;勝手な持論)なので、人間ができることは機械学習でどうにかしてできるようになる期待はあるため、どの馬が勝つかを予測できる可能性が高い。
しかし、機械学習が当てられるということは、人間はもっと当てやすいものだと考えられるため、最低限でも支持率(=勝率)を上回るパフォーマンスを出せないとダメそう。
6. 簡易分析の結論からモデルの目標の設定¶
以上の話から、以下の目的によって出された仮説を検証し評価する必要がある
目的: 支持率を超える競馬予想モデルを作成する
動機:
支持率が勝率に酷似することから、支持率(=オッズ)からその馬のある程度の勝率が分かる
上記を前提とすれば、その支持率が信頼できるかどうかの判定も可能だと考えられ、
支持率以上のパフォーマンスを出せる馬に賭ければ回収率100%を目指せるのではないかと考える
仮説1: 支持率に対して、その支持率が妥当かどうか予測するモデルが構築できる
仮説2: 支持率が勝率に追従することから、支持率と同等以上の勝率を出せる機械学習モデルを構築できる
コメント