10.荒れるレースは予測可能か?¶
10-0.動機と目的¶
「荒れるレースを予測したい=高い回収率が見込める」という動機から、荒れたレースを事前に知ることが出来れば穴馬へベットするのは大変有効な考え方となるだろうと考える。
よって、本分析の目的は、荒れた荒れてないを評価する指標の決定と、その指標を予測することで荒れたレースも予測できる競馬AIが出来るかを確認する
10-1.下準備¶
ソースの一部は有料のものを使ってます。
同じように分析したい方は、以下の記事から入手ください。
import pathlib
import warnings
import sys
sys.path.append(".")
sys.path.append("..")
from src.model_manager.lgbm_manager import LightGBMModelManager # noqa
from src.core.meta.bet_name_meta import BetName # noqa
from src.data_manager.preprocess_tools import DataPreProcessor # noqa
from src.data_manager.data_loader import DataLoader # noqa
warnings.filterwarnings("ignore")
root_dir = pathlib.Path(".").absolute().parent
dbpath = root_dir / "data" / "keibadata.db"
start_year = 2000 # DBが持つ最古の年を指定
split_year = 2014 # 学習対象期間の開始年を指定
target_year = 2019 # テスト対象期間の開始年を指定
end_year = 2023 # テスト対象期間の終了年を指定 (当然DBに対象年のデータがあること)
# 各種インスタンスの作成
data_loader = DataLoader(
start_year,
end_year,
dbpath=dbpath # dbpathは各種環境に合わせてパスを指定してください。絶対パス推奨
)
dataPreP = DataPreProcessor(
# 今回からキャッシュ機能の追加をした。使用する場合にTrueを指定。デフォルト:True
use_cache=True,
cache_dir=pathlib.Path("./data")
)
df = data_loader.load_racedata()
dfblood = data_loader.load_horseblood()
df = dataPreP.exec_pipeline(
df,
dfblood,
blood_set=["s", "b", "bs", "bbs", "ss", "sss", "ssss", "bbbs"],
lagN=5
)
10-2.荒れたレースとは?¶
毎度のことながらGPTに聞いてみると
競馬における「荒れたレース」の定義は以下の要素に基づく。
-
高配当馬券の発生
上位人気馬が敗れ、低人気馬が勝つことで馬券のオッズが大きく跳ね上がる。 -
人気馬の凡走
1番人気や2番人気の馬が期待外れの結果となり、レース全体が波乱となる。 -
不良馬場や天候の変化
雨や強風により馬場状態が悪化し、通常とは異なる展開が生じる。 -
展開の予測困難さ
ペースが極端に速くなったり、スローになったりすることで、予想外の結果が生じる。
これらの要素が組み合わさることで、レースが「荒れた」と見なされる。
とのことである。
要は、高配当馬券が出たり人気馬が全く振るわなかったりした場合を指すことが多い
しかし、こと分析の文脈で話す場合では何かしらの定量的な評価を以って堅いレース、荒れたレースと判断できるのが理想
つまり、どこまでを高配当とするかであったり、何番人気までは人気馬なのかだったりとそういった数値的な目安を決める必要がある
10-3.荒れたレースを決めるための指標¶
これまでの話で、オッズには以下のような不思議な性質があることを話した。
$$
(勝率) \simeq \frac{0.8}{(オッズ)}
$$
この性質を使って、荒れた具合の指標を、1番人気~3番人気のオッズに対する1着から3着のオッズ違いを見ると良いのではと考えた。
要するに
$$
(荒れ具合) = f(odds_{\mathrm{fav}1}, odds_{\mathrm{fav}2}, odds_{\mathrm{fav}3}, odds_{\mathrm{label}1}, odds_{\mathrm{label}2}, odds_{\mathrm{label}3})
$$
みたいな関係式となる関数 $f(…) $を考えたいということである
よって、これまでの話から上記の荒れ具合の指標($ VolatilityScore $とする)とは以下の式で表せるとする
$$
VolatilityScore = \sum_{i}\frac{0.8}{odds_{\mathrm{label}i}}\times\mathrm{log}\left(\frac{\frac{0.8}{odds_{\mathrm{label}i}}}{\frac{0.8}{odds_{\mathrm{fav}i}}}\right)
$$
ここでオッズとは、勝率へ変換可能であることから、
$$
VolatilityScore = \sum_{i}(勝率)_{\mathrm{label}i}\times\mathrm{log}\left(\frac{(勝率)_{\mathrm{label}i}}{(勝率)_{\mathrm{fav}i}}\right)
$$
となる
ここまでの話をみて勘の良いAI好きの方々はピンと来たかと思いますが、この$ VolatilityScore $で定義した計算式(右辺)はKL情報量(Kullback–Leibler divergence)と呼ばれている。
KL情報量については、こちらの記事↓に預けるものとする
$ VolatilityScore $ にKL情報量を使う理由は、オッズはその馬が勝つ確率を表していることと $ 0.8/odds $ の合計が1になる(オッズとは売上金の分配倍率なので; 切り捨てがある以上厳密には1にはならないことに注意)ことから、 $ 0.8/odds $ の値は確率分布と見て良いと考えた。
こうすることで、人気順と着順の2つの $0.8/odds $の分布にどれだけの差があるかを計ることでそのレースの荒れ具合を表しているとみなせる。
よって、分布間の差を見たい場合に有効なものと言えばKL情報量であるため、今回のような定義を設けた。
ここで $ VolatilityScore $ の計算方法について手を加える。
素直にKL情報量を使う場合、すべての着順の値を使って計算する必要があるが、競馬では3着以内に入らないと払戻の対象にならないので4着以降の順番まで見るのはそれはそれでナンセンスである。
なので、実際の計算では以下のようにして $ VolatilityScore $ を計算するようにする。
つまり、4着以降は一つにまとめた確率を用いて計算することにする
4着以降
$$
(4着以降の勝率)=\sum^{n}_{k=4}\frac{0.8}{odds_{\mathrm{label}k}}
$$
$$
(4番人気以降の勝率)=\sum^{n}_{k=4}\frac{0.8}{odds_{\mathrm{fav}k}}
$$
以上から $VolatilityScore $を以下で計算する
$$
VolatilityScore = \sum^{3}_{i=1}(勝率)_{\mathrm{label}i}\times\mathrm{log}\left(\frac{(勝率)_{\mathrm{label}i}}{(勝率)_{\mathrm{fav}i}}\right) + (4着以降の勝率)\times\mathrm{log}\left(\frac{(4着以降の勝率)}{(4番人気以降の勝率)}\right)
$$
1レースごとに荒れ具合を計算する関数を作っておく
# 必要なモジュールのインポート
from scipy.special import rel_entr
import pandas as pd
def volatility_score(dfrace: pd.DataFrame, topN: int = 3):
dfrace["odds_rate"] = 0.8/dfrace["odds"]
dfrace["odds_rate"] /= dfrace["odds_rate"].sum()
odds_fav_topN = dfrace.iloc[dfrace["favorite"].values.argsort(
)]["odds_rate"]
odds_fav_topN_adjust = odds_fav_topN.head(
topN).tolist() + [1-odds_fav_topN.head(topN).sum()]
odds_label_topN = dfrace.iloc[dfrace["label"].values.argsort(
)]["odds_rate"]
odds_label_topN_adjust = odds_label_topN.head(
topN).tolist() + [1-odds_label_topN.head(topN).sum()]
return rel_entr(odds_label_topN_adjust, odds_fav_topN_adjust).sum()
適当に動作確認
比較的荒れたレースを見る
- 2023年高松宮記念
- 1着: 12番人気
- 2着: 2番人気
- 3着: 13番人気
# 2023年高松宮記念
raceId = "202307020611"
dfp = df[df["raceId"].isin([raceId])]
dfp["odds_rate"] = 0.8/dfp["odds"]
display(dfp.set_index("label")[
["odds_rate", "odds", "favorite"]].sort_index().T)
for num in range(1, 4):
print(f"{num}着まで: {volatility_score(dfp,num) = }")
$ VolatilityScore $ は0.11709570538907352~0.22902941995789317
堅いレースを見る
- 2023年秋華賞
- 1着: 1番人気
- 2着: 3番人気
- 3着: 2番人気
# 2023年秋華賞
raceId2 = "202308020511"
dfp = df[df["raceId"].isin([raceId2])]
dfp["odds_rate"] = 0.8/dfp["odds"]
display(dfp.set_index("label")[
["odds_rate", "odds", "favorite"]].sort_index().T)
for num in range(1, 4):
print(f"{num}着まで: {volatility_score(dfp,num) = :.17f}")
$ VolatilityScore $ は0.0~0.00000349079828971
高松宮記念の方は1着が12番人気で1番人気と3番人気が馬券外になってるから大荒れだとは思うが、2番人気が2着に入っておりさほど分布の差が出ている感じはないのかスコアが少し低く出ている。
しかし、堅い結果のレースでみると荒れ具合指標はほぼ0なのでちゃんと計算出来てそうである。
それではすべてのレースで $ VolatilityScore $ を計算する
import tqdm
score_dict = {
g: {
f"label{num}": volatility_score(dfg, num)
for num in range(1, 4)
}
for g, dfg in tqdm.tqdm(df[["raceId", "favorite", "label", "odds"]].groupby("raceId"))
}
idf = pd.DataFrame.from_dict(
score_dict, orient="index").reset_index(names="raceId")
idf = idf[~idf["raceId"].str[:4].isin(
[str(s) for s in range(2000, 2010)])].reset_index(drop=True)
10-4.荒れ具合の分析¶
それぞれ1着まで、2着まで、3着までのスコアを出してるので分布をみる
idf.describe()
1着までのKL情報量を見ると中央値で0.033161程度、2着,3着で見ると0.116764, 0.175660と値が上がっている。
ただKL情報量だけでみるのはあまり直感的ではないので、KL情報量の値が高い = 払戻金が高いということを確認したい。
dfrefund = data_loader.load_racerefund()
bets = ["tan", "uren", "utan", "sanfuku", "santan"]
dflist = {
g: dfg.set_index("bet")["refund"].T.to_dict()
for g, dfg in tqdm.tqdm(dfrefund[dfrefund["bet"].isin(bets)].groupby("raceId"))
}
dfr = pd.DataFrame.from_dict(dflist, orient="index")
for b in bets:
dfr[b] = pd.to_numeric(dfr[b], downcast="integer")
まずは払戻金の情報をdata_loader
のload_racefund
メソッドを使って読み込み、扱いやすいようにレースIDと各馬券の払戻金をテーブル情報に変換する
idf2 = pd.merge(idf, dfr.reset_index(names="raceId"), on="raceId", how="left")
idf2.sort_values("santan")
3着までのKL情報量について90%分位以上と10%以下のデータに絞って払戻金の分布を見てみる
まずは90%以上(荒れたレース)
target_label = "label3"
idf2[idf2[target_label] >= idf2[target_label].quantile(
0.9)][bets+[target_label]].describe().T.convert_dtypes()
単勝の中央値で1,580円、馬単では16,475円と万馬券に、三連単では144,965円と驚異の10万馬券と、かなりKL情報量の値と払戻金には関連があるように見える。(当然ではあるが)
10%分位以下(堅いレース)でみると
target_label = "label3"
idf2[idf2[target_label] <= idf2[target_label].quantile(
0.1)][bets+[target_label]].describe().T.convert_dtypes()
単勝の中央値で250円、馬単で1030円、三連単で4420円といづれも万馬券に届いていない状況である。
つまり、競馬予想においてこのKL情報量が最小になるような馬券の組み合わせを見つけることで回収率を重視する競馬予想AIを作成することができる。
10-5.回収率を重視する競馬AIの作成¶
KL情報量を最適化するとは、正解の分布つまり最終着順のオッズ勝率の分布に近くなるように競走馬を選ぶようにLightGBMを学習させる
上記のような予測をした時のKL情報量は以下となる
このKL情報量の値を小さくするように赤い馬の選び方を最適化するようにモデルを学習する
10-5-1.カスタムObject用の変数を用意¶
import numpy as np
import lightgbm as lgbm
df["odds_rate"] = 0.8/df["odds"]
df["odds_rate"] /= df["raceId"].map(df[["raceId", "odds_rate"]
].groupby("raceId")["odds_rate"].sum().to_dict())
10-5-2.カスタムObjectのインポート¶
以下のような実装でカスタムObjectを作成しています。
必要なのは、1階微分と2階微分の計算値で2階微分は行列ではなく各予測値に対する微分の値だけで問題ありません。
カスタムObjectが受け取る引数は予測結果とDatasetの二つになります。
import numpy as np
import pandas as pd
import lightgbm as lgbm
def kl_divergence_metric(y_pred, train_data):
dflabels = train_data.get_label()
labels = pd.DataFrame()
labels["label"] = dflabels.astype(int)
labels["odds_rate"] = dflabels-labels["label"]
# y_pred = np.clip(y_pred, 1e-15, 1 - 1e-15)
group_info = train_data.get_group()
topN = 1
start = 0
gIdList = []
for num, group_size in enumerate(group_info):
# end = start + group_size
# group_preds = y_pred[start:end]/0.1
# y_pred[start:end] = np.exp(group_preds) / np.sum(np.exp(group_preds))
# start = end
gIdList += [num]*group_size
labels["raceId"] = gIdList
labels["preds"] = y_pred
labels["rank"] = labels.groupby("raceId")["preds"].rank(
ascending=False, method="first")
labels["label"] = labels.groupby("raceId")["label"].rank(method="first")
target = "odds_rate"
labels["p_sum"] = labels["raceId"].map(labels[~labels["label"].isin(
list(range(topN+1)))].groupby("raceId")[target].sum())
labels["q_sum"] = labels["raceId"].map(labels[~labels["rank"].isin(
list(range(topN+1)))].groupby("raceId")[target].sum())
datalist = labels.set_index(["raceId", "label"])[target].to_dict()
labels["proba"] = labels.apply(
lambda row: datalist[(row["raceId"], row["rank"])], axis=1)
kl_div = np.sum(labels["proba"] *
np.log(labels["proba"] / labels["odds_rate"]))
return 'kl_divergence', kl_div, False
def kl_divergence_objective(y_pred, dataset: lgbm.Dataset):
dflabels = dataset.get_label()
labels = pd.DataFrame()
labels["label"] = dflabels.astype(int)
labels["odds_rate"] = dflabels-labels["label"]
# y_pred = np.clip(y_pred, 1e-15, 1 - 1e-15)
group_info = dataset.get_group()
topN = 3
start = 0
gIdList = []
for num, group_size in enumerate(group_info):
# end = start + group_size
# group_preds = y_pred[start:end]/0.1
# y_pred[start:end] = np.exp(group_preds) / np.sum(np.exp(group_preds))
# start = end
gIdList += [num]*group_size
labels["raceId"] = gIdList
labels["preds"] = y_pred
labels["rank"] = labels.groupby("raceId")["preds"].rank(
ascending=False, method="first")
labels["label"] = labels.groupby("raceId")["label"].rank(method="first")
target = "odds_rate"
labels["p_sum"] = labels["raceId"].map(labels[~labels["label"].isin(
list(range(topN+1)))].groupby("raceId")[target].sum())
labels["q_sum"] = labels["raceId"].map(labels[~labels["rank"].isin(
list(range(topN+1)))].groupby("raceId")[target].sum())
labels["p_sum"] = labels["p_sum"].fillna(0)
labels["q_sum"] = labels["q_sum"].fillna(1)
datalist = labels.set_index(["raceId", "label"])[target].to_dict()
labels["proba"] = labels.apply(
lambda row: datalist[(row["raceId"], row["rank"])], axis=1)
labels["threash"] = labels["rank"] <= topN
labels["grad"] = labels["threash"]*(-labels["proba"]/labels[target]) + \
(1-labels["threash"])*(-labels["p_sum"]/labels["q_sum"])
labels["hessian"] = labels["threash"]*(labels["proba"]/labels[target].pow(2)) + \
(1-labels["threash"])*(labels["p_sum"]/labels["q_sum"].pow(2))
return labels["grad"].values, labels["hessian"].values
10-5-3.モデルの学習¶
# 説明変数にするカラム
feature_columns = [
'distance',
'number',
'boxNum',
'age',
'jweight',
'weight',
'gl',
'race_span',
"raceGrade", # グレード情報を追加
] + dataPreP.encoding_columns
# 血統情報を追加
feature_columns += ["stallionId", "breedId", "bStallionId", "b2StallionId"]
# 目的変数用のカラム
label_column = "label_col"
df[label_column] = df["label"] + df["odds_rate"]
dftrain, dfvalid, dftest = df[df["raceId"].str[:4].isin([str(y) for y in range(2014, 2020)])], df[df["raceId"].str[:4].isin(
["2020"])], df[df["raceId"].str[:4].isin(["2021"])]
train_data = lgbm.Dataset(
dftrain[feature_columns],
label=dftrain[label_column],
group=dftrain["raceId"].drop_duplicates().map(
dftrain.groupby("raceId")["raceId"].count().to_dict()).values,
# init_score=np.random.rand(len(dftrain))
)
valid_data = lgbm.Dataset(
dfvalid[feature_columns],
label=dfvalid[label_column],
group=dfvalid["raceId"].drop_duplicates().map(
dfvalid.groupby("raceId")["raceId"].count().to_dict()).values,
# init_score=np.random.rand(len(dfvalid))
)
test_data = lgbm.Dataset(
dftest[feature_columns],
label=dftest[label_column],
group=dftest["raceId"].drop_duplicates().map(
dftest.groupby("raceId")["raceId"].count().to_dict()).values,
# init_score=np.random.rand(len(dftest))
)
# 学習用パラメータ(ここでは適当に設定しておく)
params = {
'boosting_type': 'gbdt',
# 二値分類
'objective': kl_divergence_objective,
'verbose': 1,
'seed': 77777,
'learning_rate': 0.1,
# "n_estimators": 100
}
# モデル学習
model = lgbm.train(
params,
train_data,
num_boost_round=1000,
feval=kl_divergence_metric,
valid_sets=[train_data, valid_data],
callbacks=[
lgbm.early_stopping(stopping_rounds=25, verbose=True,),
lgbm.log_evaluation(10 if True else 0)
],
)
10-6.結果の確認¶
まずは推論
df["pred_proba"] = model.predict(df[feature_columns])
df["pred_rank"] = df[["raceId", "pred_proba"]].groupby(
"raceId")["pred_proba"].rank(ascending=False)
適当に1レースを確認してみる
raceId = "202107010308"
df[df["raceId"].isin([raceId])][
[
"raceId", "label", "favorite",
"odds", "pred_rank", "pred_proba"
]
].sort_values("pred_rank")
pred_proba
が予測確信度で、pred_rank
が確信度のランキングとなっている。
結果から、8番人気の競走馬を当ててたりするので、まあ意図通りに学習できているかなと考える
dftrain["pred_proba"] = model.predict(dftrain[feature_columns])
dftrain["pred_rank"] = dftrain.groupby(
"raceId")["pred_proba"].rank(ascending=False)
dfvalid["pred_proba"] = model.predict(dfvalid[feature_columns])
dfvalid["pred_rank"] = dfvalid.groupby(
"raceId")["pred_proba"].rank(ascending=False)
dftest["pred_proba"] = model.predict(dftest[feature_columns])
dftest["pred_rank"] = dftest.groupby(
"raceId")["pred_proba"].rank(ascending=False)
pred_rank
が1位~5位のものを対象に実際の着順の分布を確認
q = dftrain.groupby("raceId")["pred_proba"].max().quantile(0.0)
display(
pd.concat(
[
dftest[dftest["pred_rank"].isin([p]) & (
dftest["pred_proba"] > q)]["label"].value_counts().sort_index().to_frame(name=p).T
for p in range(1, 6)
]
)
)
display(
pd.concat(
[
dftest[dftest["pred_rank"].isin([p]) & (dftest["pred_proba"] > q)]["label"].value_counts().sort_index().to_frame(
name=p).T/(dftest["pred_rank"].isin([p]) & (dftest["pred_proba"] > q)).sum()
for p in range(1, 6)
]
)
)
10-7.回収率と的中率の確認¶
for mode, dfp in zip(["valid", "test"], [dfvalid, dftest]):
dffilter = dfp["pred_rank"].isin([1]) & dfp["label"].isin(
[1]) & (dfp["pred_proba"] > q)
profit = dfp[dffilter]["odds"].sum()
print(mode, profit, profit/(dfp["pred_rank"].isin([1]) & (dfp["pred_proba"] > q)).sum(),
dffilter.sum(), dffilter.sum()/(dfp["pred_rank"].isin([1]) & (dfp["pred_proba"] > q)).sum())
結果から回収率は72%~80%程度とまあ微妙ではあるが、特徴量やらなんやら突き詰めれてないのでこの程度当たるだけでも十分だろうと考える。
的中率はだいたい15%程度なので、20レースごとに3回当たるぐらいになっている。
コメント