PR

LightGBMのカスタムObjectを使った高回収率を目指す競馬AIの作成

データサイエンス
この記事は約471分で読めます。
スポンサーリンク




0010_race_analyze








10.荒れるレースは予測可能か?

スポンサーリンク

10-0.動機と目的¶

「荒れるレースを予測したい=高い回収率が見込める」という動機から、荒れたレースを事前に知ることが出来れば穴馬へベットするのは大変有効な考え方となるだろうと考える。
よって、本分析の目的は、荒れた荒れてないを評価する指標の決定と、その指標を予測することで荒れたレースも予測できる競馬AIが出来るかを確認する

スポンサーリンク

10-1.下準備¶

ソースの一部は有料のものを使ってます。
同じように分析したい方は、以下の記事から入手ください。

ゼロから作る競馬予想モデル・機械学習入門

In [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
)
2024-11-02 16:28:34.322 | INFO     | src.data_manager.data_loader:load_racedata:23 - Get Year Range: 2000 -> 2023.
2024-11-02 16:28:34.322 | INFO     | src.data_manager.data_loader:load_racedata:24 - Loading Race Info ...
2024-11-02 16:28:35.184 | INFO     | src.data_manager.data_loader:load_racedata:26 - Loading Race Data ...
2024-11-02 16:28:50.289 | INFO     | src.data_manager.data_loader:load_racedata:28 - Merging Race Info and Race Data ...
2024-11-02 16:28:52.389 | INFO     | src.data_manager.data_loader:load_horseblood:45 - Loading Horse Blood ...
2024-11-02 16:29:17.639 | INFO     | src.data_manager.preprocess_tools:load_cache:760 - Loading Cache. file: data\cache_data.pkl
2024-11-02 16:29:28.505 | INFO     | src.data_manager.preprocess_tools:load_cache:771 - Check Cache version... cache ver: 14
2024-11-02 16:29:28.505 | INFO     | src.data_manager.preprocess_tools:exec_pipeline:170 - OK! Completed Loading Cache File. cache ver: 14
スポンサーリンク

10-2.荒れたレースとは?¶

毎度のことながらGPTに聞いてみると

競馬における「荒れたレース」の定義は以下の要素に基づく。

  1. 高配当馬券の発生
    上位人気馬が敗れ、低人気馬が勝つことで馬券のオッズが大きく跳ね上がる。

  2. 人気馬の凡走
    1番人気や2番人気の馬が期待外れの結果となり、レース全体が波乱となる。

  3. 不良馬場や天候の変化
    雨や強風により馬場状態が悪化し、通常とは異なる展開が生じる。

  4. 展開の予測困難さ
    ペースが極端に速くなったり、スローになったりすることで、予想外の結果が生じる。

これらの要素が組み合わさることで、レースが「荒れた」と見なされる。

とのことである。

要は、高配当馬券が出たり人気馬が全く振るわなかったりした場合を指すことが多い
しかし、こと分析の文脈で話す場合では何かしらの定量的な評価を以って堅いレース、荒れたレースと判断できるのが理想
つまり、どこまでを高配当とするかであったり、何番人気までは人気馬なのかだったりとそういった数値的な目安を決める必要がある

スポンサーリンク

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情報量については、こちらの記事↓に預けるものとする

KL情報量の解説
KL情報量(Kullback-Leibler Divergence)とは?¶ KL情報量(Kullback-Leibler divergence、KL divergence)は、2つの確率分布間の「差異」や「情報の損失」を測る指標です。具体...

$ 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レースごとに荒れ具合を計算する関数を作っておく

In [2]:
# 必要なモジュールのインポート
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番人気
In [3]:
# 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) = }")
label 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
odds_rate 0.024768 0.148148 0.019002 0.040404 0.028169 0.114286 0.126984 0.05 0.014733 0.06015 0.027586 0.177778 0.045977 0.003367 0.101266 0.01518 0.003038 0.003127
odds 32.300000 5.400000 42.100000 19.800000 28.400000 7.000000 6.300000 16.00 54.300000 13.30000 29.000000 4.500000 17.400000 237.600000 7.900000 52.70000 263.300000 255.800000
favorite 12.000000 2.000000 13.000000 9.000000 10.000000 4.000000 3.000000 7.00 15.000000 6.00000 11.000000 1.000000 8.000000 16.000000 5.000000 14.00000 18.000000 17.000000
1着まで: volatility_score(dfp,num) = 0.11709570538907352
2着まで: volatility_score(dfp,num) = 0.11981303576712968
3着まで: volatility_score(dfp,num) = 0.22902941995789317

$ VolatilityScore $ は0.11709570538907352~0.22902941995789317

堅いレースを見る

  • 2023年秋華賞
    • 1着: 1番人気
    • 2着: 3番人気
    • 3着: 2番人気
In [4]:
# 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}")
label 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
odds_rate 0.727273 0.061538 0.062016 0.030189 0.027586 0.007456 0.003964 0.043011 0.028674 0.010444 0.013841 0.0037 0.002757 0.006762 0.011494 0.003709 0.005128 0.005731
odds 1.100000 13.000000 12.900000 26.500000 29.000000 107.300000 201.800000 18.600000 27.900000 76.600000 57.800000 216.2000 290.200000 118.300000 69.600000 215.700000 156.000000 139.600000
favorite 1.000000 3.000000 2.000000 5.000000 7.000000 11.000000 15.000000 4.000000 6.000000 10.000000 8.000000 17.0000 18.000000 12.000000 9.000000 16.000000 14.000000 13.000000
1着まで: volatility_score(dfp,num) = 0.00000000000000000
2着まで: volatility_score(dfp,num) = 0.00000214829185368
3着まで: volatility_score(dfp,num) = 0.00000349079828971

$ VolatilityScore $ は0.0~0.00000349079828971

高松宮記念の方は1着が12番人気で1番人気と3番人気が馬券外になってるから大荒れだとは思うが、2番人気が2着に入っておりさほど分布の差が出ている感じはないのかスコアが少し低く出ている。
しかし、堅い結果のレースでみると荒れ具合指標はほぼ0なのでちゃんと計算出来てそうである。

それではすべてのレースで $ VolatilityScore $ を計算する

In [5]:
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"))
}
100%|██████████| 76142/76142 [05:38<00:00, 224.72it/s]
In [6]:
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着までのスコアを出してるので分布をみる

In [7]:
idf.describe()
Out[7]:
label1 label2 label3
count 46573.000000 46573.000000 46573.000000
mean 0.099098 0.171904 0.234928
std 0.141481 0.186439 0.216787
min 0.000000 0.000000 0.000000
25% 0.000000 0.031950 0.077351
50% 0.033161 0.116764 0.175660
75% 0.152209 0.246821 0.328961
max 1.666725 1.965464 2.736957

1着までのKL情報量を見ると中央値で0.033161程度、2着,3着で見ると0.116764, 0.175660と値が上がっている。
ただKL情報量だけでみるのはあまり直感的ではないので、KL情報量の値が高い = 払戻金が高いということを確認したい。

In [8]:
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")
2024-11-02 16:35:11.409 | INFO     | src.data_manager.data_loader:load_racerefund:35 - Loading Race Refund ...
100%|██████████| 82867/82867 [00:36<00:00, 2298.67it/s]

まずは払戻金の情報をdata_loaderload_racefundメソッドを使って読み込み、扱いやすいようにレースIDと各馬券の払戻金をテーブル情報に変換する

In [9]:
idf2 = pd.merge(idf, dfr.reset_index(names="raceId"), on="raceId", how="left")
idf2.sort_values("santan")
Out[9]:
raceId label1 label2 label3 tan uren utan sanfuku santan
42733 202209050203 0.000000 0.000000 0.000000 120 150.0 190.0 120.0 240.0
41817 202207010608 0.000000 0.000000 0.000000 150 150.0 220.0 110.0 270.0
26603 201801010101 0.000000 0.000000 0.000000 110 180.0 210.0 160.0 330.0
40914 202205020805 0.000000 0.000000 0.000000 160 180.0 250.0 160.0 390.0
40177 202202010901 0.000000 0.000000 0.000000 110 150.0 180.0 220.0 420.0
17580 201505020811 0.263350 0.441628 0.640464 1410 36880.0 73990.0 2860480.0 20705810.0
37510 202105010704 0.183305 0.447401 0.626306 5330 218040.0 298240.0 1619910.0 20738890.0
24998 201706050307 0.303215 0.619327 0.697249 25710 446550.0 962580.0 2232180.0 21802320.0
25314 201707040207 0.388675 0.529408 0.760818 45010 365760.0 422310.0 5508830.0 22946150.0
18271 201506040501 0.599156 0.871285 1.174970 6180 83560.0 190630.0 2704790.0 27929360.0

46573 rows × 9 columns

3着までのKL情報量について90%分位以上と10%以下のデータに絞って払戻金の分布を見てみる

まずは90%以上(荒れたレース)

In [10]:
target_label = "label3"
idf2[idf2[target_label] >= idf2[target_label].quantile(
    0.9)][bets+[target_label]].describe().T.convert_dtypes()
Out[10]:
count mean std min 25% 50% 75% max
tan 4658 2835.193216 4024.867474 110.0 870.0 1580.0 3070.0 56940.0
uren 4658 18703.194504 33918.680756 120.0 2795.0 7400.0 19565.0 446550.0
utan 4658 40589.177759 74156.354945 260.0 6422.5 16475.0 42967.5 1087490.0
sanfuku 4658 78165.577501 201453.490119 150.0 4400.0 17420.0 65752.5 5508830.0
santan 4658 566260.740661 1445123.364685 580.0 42895.0 144965.0 492697.5 27929360.0
label3 4658 0.724783 0.194607 0.525214 0.582959 0.667549 0.80712 2.736957

単勝の中央値で1,580円、馬単では16,475円と万馬券に、三連単では144,965円と驚異の10万馬券と、かなりKL情報量の値と払戻金には関連があるように見える。(当然ではあるが)

10%分位以下(堅いレース)でみると

In [11]:
target_label = "label3"
idf2[idf2[target_label] <= idf2[target_label].quantile(
    0.1)][bets+[target_label]].describe().T.convert_dtypes()
Out[11]:
count mean std min 25% 50% 75% max
tan 4658 280.656934 124.765264 110 180.0 250.0 350.0 1050.0
uren 4658 741.889223 486.701919 110 420.0 620.0 930.0 4980.0
utan 4658 1293.246028 949.12895 170 660.0 1030.0 1637.5 8720.0
sanfuku 4658 1561.176471 1295.825639 110 760.0 1210.0 1940.0 19020.0
santan 4658 6410.064405 6599.284378 240 2532.5 4420.0 7770.0 79490.0
label3 4658 0.007979 0.007743 0 0.0002 0.005856 0.014555 0.023832

単勝の中央値で250円、馬単で1030円、三連単で4420円といづれも万馬券に届いていない状況である。

つまり、競馬予想においてこのKL情報量が最小になるような馬券の組み合わせを見つけることで回収率を重視する競馬予想AIを作成することができる。

スポンサーリンク

10-5.回収率を重視する競馬AIの作成¶

KL情報量を最適化するとは、正解の分布つまり最終着順のオッズ勝率の分布に近くなるように競走馬を選ぶようにLightGBMを学習させる

解説

上記のような予測をした時のKL情報量は以下となる

KL情報量

このKL情報量の値を小さくするように赤い馬の選び方を最適化するようにモデルを学習する

10-5-1.カスタムObject用の変数を用意¶

In [12]:
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の二つになります。

In [13]:
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.モデルの学習¶

In [14]:
# 説明変数にするカラム
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)
    ],
)
[LightGBM] [Warning] Categorical features with more bins than the configured maximum bin number found.
[LightGBM] [Warning] For categorical features, max_bin and max_bin_by_feature may be ignored with a large number of categories.
[LightGBM] [Info] Using self-defined objective function
[LightGBM] [Info] Auto-choosing col-wise multi-threading, the overhead of testing was 0.032141 seconds.
You can set `force_col_wise=true` to remove the overhead.
[LightGBM] [Info] Total Bins 37422
[LightGBM] [Info] Number of data points in the train set: 284477, number of used features: 21
[LightGBM] [Info] Using self-defined objective function
Training until validation scores don't improve for 25 rounds
[10]	training's kl_divergence: 23177.3	valid_1's kl_divergence: 3888.72
[20]	training's kl_divergence: 21506.4	valid_1's kl_divergence: 3666.2
[30]	training's kl_divergence: 20721.7	valid_1's kl_divergence: 3595.61
[40]	training's kl_divergence: 20320.5	valid_1's kl_divergence: 3554.12
[50]	training's kl_divergence: 20134.5	valid_1's kl_divergence: 3551.54
[60]	training's kl_divergence: 19858	valid_1's kl_divergence: 3580.44
[70]	training's kl_divergence: 19907.5	valid_1's kl_divergence: 3610.77
Early stopping, best iteration is:
[54]	training's kl_divergence: 19979.6	valid_1's kl_divergence: 3540.08
スポンサーリンク

10-6.結果の確認¶

まずは推論

In [15]:
df["pred_proba"] = model.predict(df[feature_columns])
df["pred_rank"] = df[["raceId", "pred_proba"]].groupby(
    "raceId")["pred_proba"].rank(ascending=False)

適当に1レースを確認してみる

In [16]:
raceId = "202107010308"
df[df["raceId"].isin([raceId])][
    [
        "raceId", "label", "favorite",
        "odds", "pred_rank", "pred_proba"
    ]
].sort_values("pred_rank")
Out[16]:
raceId label favorite odds pred_rank pred_proba
917580 202107010308 1 8 13.6 1.0 0.410446
917579 202107010308 6 2 6.1 2.0 0.384778
917589 202107010308 3 5 8.2 3.0 0.344070
917587 202107010308 7 9 45.2 4.0 0.338104
917585 202107010308 9 1 2.3 5.0 0.337355
917581 202107010308 4 4 7.6 6.0 0.328640
917582 202107010308 10 3 6.7 7.0 0.301410
917584 202107010308 2 10 53.2 8.0 0.298879
917583 202107010308 8 6 12.6 9.0 0.268180
917586 202107010308 5 7 13.5 10.0 0.263843
917588 202107010308 11 11 184.8 11.0 0.216410

pred_probaが予測確信度で、pred_rankが確信度のランキングとなっている。
結果から、8番人気の競走馬を当ててたりするので、まあ意図通りに学習できているかなと考える

In [17]:
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位のものを対象に実際の着順の分布を確認

In [18]:
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)
        ]
    )
)
label 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
1 504 382 315 301 282 232 219 188 165 151 127 139 89 84 72 57 10 9
2 379 319 330 251 260 254 247 226 184 174 166 146 123 112 75 62 11 1
3 301 286 339 308 265 250 274 244 196 156 154 150 133 99 93 50 9 6
4 245 296 283 265 269 265 249 225 221 184 190 161 125 127 84 70 15 5
5 247 273 268 233 239 253 227 241 220 212 174 169 134 128 102 69 15 7
In [19]:
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)
        ]
    )
)
label 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
1 0.151533 0.114853 0.094708 0.090499 0.084787 0.069753 0.065845 0.056524 0.049609 0.045400 0.038184 0.041792 0.026759 0.025256 0.021648 0.017138 0.003007 0.002706
2 0.114157 0.096084 0.099398 0.075602 0.078313 0.076506 0.074398 0.068072 0.055422 0.052410 0.050000 0.043976 0.037048 0.033735 0.022590 0.018675 0.003313 0.000301
3 0.090854 0.086327 0.102324 0.092967 0.079988 0.075460 0.082704 0.073649 0.059161 0.047087 0.046484 0.045276 0.040145 0.029882 0.028071 0.015092 0.002717 0.001811
4 0.074718 0.090271 0.086307 0.080817 0.082037 0.080817 0.075938 0.068618 0.067399 0.056115 0.057944 0.049100 0.038121 0.038731 0.025618 0.021348 0.004575 0.001525
5 0.076923 0.085020 0.083463 0.072563 0.074432 0.078792 0.070694 0.075055 0.068514 0.066023 0.054189 0.052632 0.041732 0.039863 0.031766 0.021489 0.004671 0.002180
スポンサーリンク

10-7.回収率と的中率の確認¶

In [20]:
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())
valid 2677.9 0.8041741741741742 518 0.15555555555555556
test 2406.5 0.723541791942273 504 0.15153337342152737

結果から回収率は72%~80%程度とまあ微妙ではあるが、特徴量やらなんやら突き詰めれてないのでこの程度当たるだけでも十分だろうと考える。
的中率はだいたい15%程度なので、20レースごとに3回当たるぐらいになっている。


コメント

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