メインコンテンツへスキップ
見出し画像

キャリブレーション・プロットを利用して賭ける馬券を見極める【競馬AI開発#21】

    この【競馬AI開発】シリーズでは、競馬予想AIを作ることを通して、機械学習・データサイエンスの勉強になるコンテンツの発信や、筆者が行った実験の共有などを行っていきます。
    この記事は、最後まで無料で読むことができます。

    ■今回やること
    今開発している競馬予想AIでは、netkeiba.comから6年間分のデータを取得し、「1着に入るかどうか」を予測する機械学習モデルを作成しています。このモデルは、馬の過去成績や騎手の成績など、約480個の特徴量を使用して予測を行っています。

    画像

    そして、この予測された「1着に入る確率」と単勝オッズを掛け合わせた期待値を元に賭けることで、最大で回収率126.8%に到達することが前回のシミュレーションで分かりました。

    ここで、この賭け方をする場合、その「1着に入る確率」が正しく予測できている必要があります。もし予測勝率が実際の確率と大きくずれていれば、期待値の計算も狂ってしまい、効果的な馬券購入は望めません。
    今回は、この「予測勝率の信頼性」を可視化する「キャリブレーション・プロット」という手法について詳しく解説します。

    画像
    競馬AIの予測値に対するキャリブレーション・プロット

    そして、これを踏まえた馬券購入を考えることで、回収率126%→147%に改善できることが分かります。

    画像

    キャリブレーション・プロットは、機械学習で確率予測をする時に必ず見ておいた方が良い指標なので、競馬以外に実務などの場面で機械学習を使う方も、ぜひ今回の記事が参考になれば幸いです。

    動画中のソースコード

    前回までのコードのv3_0_7/src/に配置することで使用できます。転載・再配布はお控えください。

    キャリブレーション・プロットとは何か

    ここからの内容は、動画の2:38〜でも解説しています。

    例えば、あるレースで以下のような勝率予測をしたとしましょう。

    画像

    この予測勝率はどの程度「正確」だと言えるでしょうか?

    実は、確率予測の「正確さ」を考えるのは意外と難しい問題です。
    なぜなら目的変数は0or1の二値であり、1レースだけを見てもその馬が「実際に勝つ」か「実際に負ける」かの2択しかないため、「確率として」どの程度正確なのか判断ができないからです。

    そこで、この予測勝率を一定の間隔で区切り、その区間に含まれる正例割合を集計することで、この「確率値の正確さ」を測れないか考えてみましょう。

    イメージとしては、

    1. 似たような予測確率(例:21%~25%)の馬たちを集める

    2. その中で実際に何割の馬が勝ったかを計算して比較する

    3. これを様々な予測確率の範囲で行い、プロットする

    みたいな感じです。

    具体的な計算方法を見ていきましょう。
    #19の記事で作成した 競馬AI v3.0.7の学習コードを回すと、v3_0_7/data/03_train/evaluation.csvに2023年の1年間のレースに対する予測結果が格納されるので、まずはこれを読み込んでいきます。

    import pandas as pd
    import numpy as np
    import seaborn as sns
    import matplotlib.pyplot as plt
    sns.set()
    
    # 予測結果のデータを読み込み
    df = pd.read_csv("../data/03_train/evaluation.csv", sep="\t")
    画像
    dfの中身(predが予測勝率の列)

    pandasとseabornを使った実装

    まず、予測確率を区間に分ける必要があります。pandasのcut()を使うと、binsで指定した間隔に値を区切ることができるので、これを活用していきます。

    # 予測確率を20個の区間に分割
    df["pred_bins"] = pd.cut(df["pred"], bins=20)
    # 出現回数をカウント
    df["pred_bins"].value_counts()
    (0.000431, 0.0344]    23756
    (0.0344, 0.0677]       7285
    (0.0677, 0.101]        4593
    (0.101, 0.134]         3081
    (0.134, 0.168]         2184
    (0.168, 0.201]         1742
    (0.201, 0.234]         1123
    (0.234, 0.267]          787
    (0.267, 0.301]          653
    (0.301, 0.334]          576
    (0.334, 0.367]          488
    (0.367, 0.401]          301
    (0.401, 0.434]          204
    (0.434, 0.467]          124
    (0.567, 0.6]             99
    (0.467, 0.501]           82
    (0.534, 0.567]           63
    (0.6, 0.634]             60
    (0.501, 0.534]           52
    (0.634, 0.667]           20

    predの値が0.03程度の間隔で区切られていることが分かります。
    また、predの分布は0に寄っているため、0に近いビンには値が集中し、1に近いビンは逆に値が少なくなっていることが分かります。

    【用語】
    この区切られた一つ一つの範囲のことを「ビン」といいます。

    次に、各区間の代表値として予測確率の平均値を計算しておきます。
    #19の記事で解説したtransform()を使うことで、集計結果をそのまま新しい列として追加することができます。

    df["pred_mean"] = df.groupby("pred_bins")["pred"].transform("mean")

    この列を用いて、以下のように書くことでビンごとの予測勝率と実際の勝率をプロットすることができます。

    sns.lineplot(x="pred_mean", y="target", data=df)
    sns.lineplot(x=[0, 1], y=[0, 1], color="black", linestyle="--")
    plt.show()
    画像

    このプロットは、

    • x軸:予測確率(各区間の平均値)

    • y軸:実際の勝率(各区間でのtarget=1の割合)

    • 黒の点線:完璧な予測の場合の理想線(y=x)

    をそれぞれ表しており、seabornのlineplot()を使うことで、xの各値ごとにyを集計してプロットすることができます。

    seabornの使い方については、以下の記事で詳しく解説しているので、参考にしてみてください。

    上のようなプロットを「キャリブレーション・プロット」といい、理想線y=xと実際の予測値の乖離を見ることで、「予測確率がどの程度実際の確率と一致しているか」を可視化することができます。

    画像
    再掲

    プロットを確認すると、xが小さい範囲ではy=xのラインに正しく乗っており、逆にxが大きい範囲ではぶれが生じていることが分かります。(ただし、この解釈には落とし穴があるので後述)

    scikit-learnを使った実装

    実はこのキャリブレーションプロットはscikit-learnにも実装されており、pd.cut()などの処理をしなくても、以下のように簡単に描画することができます

    from sklearn.calibration import calibration_curve
    
    prob_true, prob_pred = calibration_curve(
        df['target'], df['pred'], n_bins=20
    )
    
    plt.plot(prob_pred, prob_true, marker='o', label='Model')
    plt.plot([0, 1], [0, 1], linestyle='--', color='black', label='Perfectly calibrated')
    plt.xlabel('Predicted probability')
    plt.ylabel('True probability')
    plt.title('Calibration plot')
    plt.legend()
    plt.show()
    画像

    先ほどと同様のプロットを描くことができました。

    キャリブレーション・プロットのよくある落とし穴

    ここからの内容は、動画の11:33〜でも解説しています。

    ただし、このキャリブレーション・プロットには一つ問題点があります。

    それは、等間隔でビンに区切っているため高確率域の区間には少数のデータしか含まれず、プロットが不安定になってしまう、ということです。
    また、逆に低確率域の区間については、データの集中度合いに対して区切り方が粗くなってしまうため、実際の確率と乖離が生じている部分があったとしても、粗いビンで均されて気が付きにくくなってしまいます。

    これは、実際のpred列の分布を確認してみると分かりやすいです。

    sns.histplot(df["pred"])
    画像

    こう見ると、ビンの区切り方は「等間隔」ではなく、「各区間のデータ数が等しくなるように」区切りたくなります。

    scikit-learn様は優秀で、ちゃんとそのような問題を先回りして対応するオプションが用意されています。
    strategy="quantile"を指定することで、各区間のデータ数が等しくなるようにビンを分割することができます。

    prob_true, prob_pred = calibration_curve(
        df['target'], df['pred'], n_bins=20, strategy='quantile'
    )
    
    plt.plot(prob_pred, prob_true, marker='o', label='Model')
    plt.plot([0, 1], [0, 1], linestyle='--', color='black', label='Perfectly calibrated')
    plt.xlabel('Predicted probability')
    plt.ylabel('True probability')
    plt.title('Calibration plot')
    plt.legend()
    plt.show()
    画像

    これにより、どの区間でも十分なデータ数が確保され、評価の信頼性を保つことができます。

    ただし、これだと0付近の区間に点が密集して、プロットが見づらくなっていることが分かります。
    このような場合は、軸を対数スケールにすることで、低確率域の細かい違いと高確率域の広い区間の両方を見やすく表示することができます。

    prob_true, prob_pred = calibration_curve(
        df['target'], df['pred'], n_bins=20, strategy="quantile"
    )
    
    plt.plot(prob_pred, prob_true, marker='o', label='Model')
    plt.plot([0.001, 1], [0.001, 1], linestyle='--', color='black', label='Perfectly calibrated')
    plt.xlabel('Predicted probability')
    plt.ylabel('True probability')
    plt.title('Calibration plot')
    pltxyscale("log")  # x軸を対数スケールに
    plt.yscale("log")  # y軸を対数スケールに
    plt.legend()
    plt.show()
    画像

    このようにプロットすると、先ほどとは全く違った景色が見えることに気がつきます。

    • 予測確率が0.01(1%)を下回る低確率域では、実際の勝率よりも予測確率が高めに出ていそう

    • 高確率域(10%以上)では、比較的正確な予測ができていそう

    予測確率の正確さを表す「Brier score」

    ここからの内容は、動画の14:56〜でも解説しています。

    キャリブレーション・プロットで予測確率の正確性を視覚的に確認できましたが、その正確性を一つの数値で評価できると便利です。そのような指標として一般的に使われるのが、以下のような「Brier score」というものです。

    BS=1N∑i=1N(fi−oi)2BS = \frac{1}{N} \sum_{i=1}^{N} (f_i - o_i)^2

    • NN:データ数

    • fif_i:予測確率(予測値)

    • oio_i:実際の結果(0または1)

    つまり、予測確率と実際の結果の差の二乗の平均を取ったものです。この値が小さいほど、予測が正確だということになります。

    このBrier scoreも、scikit-learnを使えば、簡単に計算することができます。

    from sklearn.metrics import brier_score_loss
    
    brier_score_loss(df["target"], df["pred"])
    0.05805568826771087

    キャリブレーション・プロットを描画する際に、以下のように表示しておくことにしましょう。

    brier_score = brier_score_loss(df['target'], df['pred'])
    prob_true, prob_pred = calibration_curve(
        df['target'], df['pred'], n_bins=20, strategy="quantile"
    )
    
    plt.plot(
        prob_pred,
        prob_true,
        marker='o',
        label=f'Model (Brier score: {brier_score:.5f})'
    )
    plt.plot(
        [0.001, 1],
        [0.001, 1],
        linestyle='--',
        color='black',
        label='Perfectly calibrated'
    )
    plt.xlabel('Predicted probability')
    plt.ylabel('True probability')
    plt.title('Calibration plot')
    plt.xscale("log")
    plt.yscale("log")
    plt.legend()
    plt.show()
    画像

    また、この処理を使い回せるように関数にしておきましょう。

    def plot_calibration(
        df, pred_col="pred", target_col="target", n_bins=20, strategy="quantile"
    ):
        brier_score = brier_score_loss(df[target_col], df[pred_col])
        prob_true, prob_pred = calibration_curve(
            df[target_col], df[pred_col], n_bins=n_bins, strategy=strategy
        )
        plt.plot(
            prob_pred,
            prob_true,
            marker='o',
            label=f'Model (Brier score: {brier_score:.5f})'
        )
        plt.plot(
            [0.001, 1],
            [0.001, 1],
            linestyle='--',
            color='black',
            label='Perfectly calibrated'
        )
        plt.xlabel('Predicted probability')
        plt.ylabel('True probability')
        plt.title('Calibration plot')
        if strategy == "quantile":
            plt.xscale("log")
            plt.yscale("log")
        plt.legend()
        plt.show()
    plot_calibration(df)
    画像

    等分割にビンを区切りたい時はstrategy="unique"でも使えるようにしてあります。

    plot_calibration(df, strategy="uniform")
    画像

    【補足】LoglossとBrier scoreの違い

    学習時にはLoglossを使っていますが、「これも確率の正確さを表す指標ではないのか?Loglossと何が違うのか?」という疑問を持つ方もいるかもしれません。

    そこで違いを確認するために、正例のデータに対してそれぞれどのような損失が与えられるか、実際にプロットして確認してみます。

    preds = np.linspace(0.001, 1, 100)
    true_label = 1  # サンプルとして正例のデータを考える
    
    logloss = -np.log(preds)
    brier_score = (preds - true_label) ** 2
    
    plt.plot(preds, logloss, label="Logloss", color='blue')
    plt.plot(preds, brier_score, label="Brier Score", color='red')
    plt.xlabel("Predicted Probability")
    plt.ylabel("Error")
    plt.legend()
    plt.show()
    画像

    このプロットから、Loglossは実際の結果と大きく異なる予測をした場合に非常に大きなペナルティを与える一方、Brier scoreはどんなに大きく値を間違えても最大1のペナルティしか与えない、という違いが見えます。

    学習時には誤差が大きい部分を重点的に学習できるLoglossが適している一方、評価時は確率出力の品質を平等に扱うBrier scoreの方が適している、とのことです。

    単勝オッズから分かる「競馬の難しさ」

    実は、単勝オッズの値にも「市場が予測する勝率」の情報が含まれているのでキャリブレーション・プロットを描くことができます。

    まずは、オッズがどのように決まるのか、その仕組みから見ていきましょう。
    単勝オッズは以下の式で計算されています。

    単勝オッズ=1−控除率支持率\text{単勝オッズ} = \frac{1 - \text{控除率}}{\text{支持率}}

    ここで、

    • 支持率:その馬に投票された金額 ÷ レース全体の投票金額

    • 控除率:JRAが運営費用として差し引く比率(20%)

    です。よって、この式を変形すると、支持率(≒市場が予想する勝率)を求めることができます。

    支持率=1−控除率単勝オッズ=0.8単勝オッズ\text{支持率} = \frac{1 - \text{控除率}}{\text{単勝オッズ}} = \frac{0.8}{\text{単勝オッズ}}

    先ほどのデータにはtansho_odds列に単勝オッズの値が入っているので、これを支持率に変換してみます。

    # 単勝オッズを支持率に変換
    df["odd_prob"] = 0.8 / df["tansho_odds"]
    # 分布を確認してみる
    sns.histplot(df["odd_prob"])
    画像

    このodds_prob列を使ってキャリブレーション・プロットを描いてみましょう。

    plot_calibration(df, pred_col="odd_prob")
    画像

    これを見ると、オッズの精度の高さに驚かされます。特に勝率5%以上(0.5×10−10.5 \times 10^{-1})の領域では、オッズから計算された勝率は実際の勝率とほぼ一致しているように見えます。

    この結果は、競馬市場が効率的に機能していることを示しています。
    つまり、多くの競馬ファンの予想が集約された「群衆の知恵」が、馬の実力をある程度正確に評価できているということです。

    一方、Brier scoreを見ると0.05811なので、AIの予測0.05806の方が精度が高いことも分かります。
    とはいえ、わずかな差ではあるので競馬は「AIを使えば簡単に勝てる」というゲームではないのだということを思い知らされます。

    キャリブレーション・プロットを利用して賭ける馬券を見極める

    ここからの内容は、動画の21:37〜でも解説しています。

    ここでもう一度、機械学習モデルの予測値に対するキャリブレーション・プロットを見てみしょう。

    画像

    これを見ると、予測勝率1%以下の低確率域では予測値に対して実績値が下回っていることが分かります。

    これは勝率(と、そこから計算される期待値)を過大評価していることを表しており、勝率1%程度ということは、先ほどの式で単勝オッズに変換すると80倍程度の穴馬券になるので、いわば「穴馬券を狙いすぎて外している」状況なわけです。

    この問題への対処方法はいくつかありますが、ここでは予測勝率1%以下の馬券を思い切って除外してしまう戦略を試してみます。

    シミュレーション

    まずはシミュレーションの流れをおさらいしておきます。
    Evaluatorクラスのbet_by_expect_return()を実行すると、期待値の閾値をmin_expからmax_expまで変化させた時の回収率とシャープレシオがプロットされるのでした。

    from evaluation import Evaluator
    
    evaluator = Evaluator()
    result_df = evaluator.bet_by_expect_return(min_exp=0.8, max_exp=2.0)
    画像

    これを見ると、期待値1.78以上の馬券に賭けると回収率126.8%、シャープレシオ0.458に到達することが分かります。

    このシミュレーションについて詳しくは、前回の記事を参照してください。

    この時、evaluator.evaluation_dfに、最初に読み込んだevaluation.csvと同じものが入っているので、このデータを予測勝率1%以上のものに絞り込んであげれば、今回の戦略におけるシミュレーションを実行することができます。

    # 予測確率1%以下の馬を除外
    evaluator.evaluation_df = evaluator.evaluation_df.query("pred > 0.01")
    画像
    evaluator.evaluation_dfの中身
    pred > 0.01に絞り込まれている

    これで同様に結果を確認してみましょう。

    result_df = evaluator.bet_by_expect_return(min_exp=0.8, max_exp=2.0)
    画像
    result_df.sort_values("sharp_ratio", ascending=False).head()
    画像

    同じ閾値1.78で回収率147.3%、シャープレシオ0.699に大幅改善していることが分かります。

    適切なエリアに賭ける馬券を絞ることで、かなり高い回収率が期待できるのではないでしょうか。
    今後は、もう少し賭ける数を広げた時にも同程度(もしくはやや下でも)の回収率を維持できるよう、モデルを改善することが課題となりそうです。

    【検証】競馬はもう「勝てない」ゲームなのか?

    最後に、ちょっとした検証をしてみようと思います。
    先ほどの分析では、単勝オッズから計算された勝率が実際の勝率とおよそ一致していそうな結果が見えました。

    画像

    このキャリブレーションが正確であればあるほど、いわゆる「オッズの歪み」が無い状態なので、「大衆の予想を出し抜いて競馬で勝つ」ことが難しい状態であるともいえます。

    ここで、近年様々な競馬AIによる予想などが公開されてきていることを考えると、「この単勝オッズの精度は、年を追うにつれて上がっているのではないか?」という仮説が浮かびます。

    これを、現在取得している2018年〜2023年のデータで比較して実際に検証してみましょう。(データ取得のコードは#1の記事から全て公開しているのでぜひそちらも参考に)

    # 読み込み
    df = pd.read_csv("../data/01_preprocessed/results.csv", sep="\t")
    # race_id列から開催年の情報を取得
    df["year"] = df["race_id"].astype(str).str[:4].astype(int)
    # プロットに必要な列の作成
    df["odds_prob"] = 0.8 / df["tansho_odds"]
    df["target"] = (df["rank"] == 1).astype(int)
    
    # プロット
    colors = sns.color_palette("tab10", n_colors=6)
    for year, color in zip(df["year"].unique(), colors):
        df_year = df.query(f"year == {year}")
        brier_score = brier_score_loss(df_year['target'], df_year['odds_prob'])
        prob_true, prob_pred = calibration_curve(
            df_year['target'], df_year['odds_prob'], n_bins=20, strategy="quantile"
        )
        plt.plot(
            prob_pred,
            prob_true,
            marker='o',
            label=f'{year} (Brier score: {brier_score:.5f})',
            color=color
        )
    plt.plot(
        [0.001, 1],
        [0.001, 1],
        linestyle='--',
        color='black',
        label='Perfectly calibrated'
    )
    plt.xlabel('Predicted probability')
    plt.ylabel('True probability')
    plt.title('Calibration plot')
    plt.xscale("log")
    plt.yscale("log")
    plt.legend()
    plt.show()
    画像

    Brier scoreの値を見ると、2018年だけ低い値になっていますが(何かしら分布の違いなどがあるのでしょうか)、2019年から2023年にかけて概ね右肩下がりになっています。
    よって、オッズの精度(≒大衆の馬券購入の精度)は上がっていて、たしかに昔ほど勝ちにくくななっているということが言えそうです。

    一方で、歪みが完全に消えているわけではなく、機械学習による予測のBrier scoreは0.5806であることを思い出すと、機械学習を使えば、まだまだ勝てる余地は残されているとも言えます。

    我々は引き続き精度改善の実験を続けていきましょう。
    良いシミュレーション結果も出てきているので、来年くらいには実際に運用するところまで行きたいですね。

     
     
    競馬予想AIを作ることを通して、機械学習・データサイエンスの勉強になるコンテンツの発信や、筆者が行った実験の共有などを行っていきます。

    あなたへのおすすめ