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

【競馬AI開発#41】レース階級を正しく分類して予測精度を改善する

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

    現在は競馬予想AIの開発が一通り完了し、その性能改善に取り組んでいる段階になります。

    ■今回やること
    今回は、レース階級の分類を正しくする改善を行いました。

    画像
    https://www.jra.go.jp/keiba/rules/class.html 

    当然と言えば当然ですが、例えば馬が過去に3着以内に入ったという実績があった場合、どのクラスのレースかによってその馬の実力評価が大きく変わるため、機械学習モデルがこの違いを正確に学習するためには、レース階級の分類が正確である必要があります。

    しかし、現在使用している分類を確認してみると、以下のような分布になっており、実態と乖離しているように見えます。

    race_class
    G1         26    ← 少なすぎる
    G2         41    ← 少なすぎる  
    G3         72    ← 少なすぎる
    3勝クラス    1346
    2勝クラス    1485
    新馬       1894
    オープン     3938  ← 多すぎる(本来は3勝クラスより上位のはず)
    1勝クラス    5596
    未勝利      8155
    Name: count, dtype: int64

    今回はこの分類を正値化し、機械学習モデルの精度を改善していきます。

    今回の内容は動画でも解説していますので、実際の画面を見ながらデータ確認や実験の様子を学習したい方は、動画も合わせてご覧ください。

    ソースコードのダウンロード方法については、記事下部の「ソースコードのダウンロード」の章で案内しているので、記事を読んで興味を持った方は是非使ってみてください。

    現在のレース階級分類の問題点

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

    特徴量を作成している create_features.ipynb で、今回のレース階級分類の処理がどこに関わっているかを見ていきましょう。
    レース階級の分類処理が関わっているのは、主に以下の2つの場所になります。

    1. 馬の過去成績テーブルの前処理
    タイトルの文字列からレース階級を分類しており、ソースコードでいうと以下の部分になります。

    ▼v3_4_2/src/create_features.ipynb

    horse_results_preprocessed = preprocessing.process_horse_results()
    # 重複チェック
    assert horse_results_preprocessed.duplicated(subset=["horse_id", "date"]).sum() == 0
    # 欠損チェック
    horse_results_preprocessed.isnull().sum()

    2. レース情報テーブル
    こちらも、タイトルやその下の情報(以下の例であれば「4歳以上3勝クラス」の部分)からレース階級を分類しています。

    画像

    ソースコードでいうと以下の部分になります。

    ▼v3_4_2/src/create_features.ipynb

    race_info_preprocessed = preprocessing.process_race_info()
    # 重複チェック
    assert race_info_preprocessed.duplicated(subset=["race_id"]).sum() == 0
    # 欠損チェック
    race_info_preprocessed.isnull().sum()

    それぞれ、具体的にどのような処理なのかについては、以下の記事で詳しく解説しているので、詳細はそちらをご覧ください。

    まずはより簡単に分析できるレース情報テーブルの方から、データを詳しく見ていきましょう。

    ■実験フォルダの作成
    v3_4_2/のコードを直接編集しても良いのですが、このディレクトリは今運用中の最新バージョンとして、変更を加えずにとっておきたいところです。
    そこで、おすすめのやり方として、フォルダーごとコピーして実験用の環境を作成するのが良いでしょう。

    keibaAI-v3/
    ├── v3_4_0/
    ├── v3_4_1/
    ├── v3_4_2/          ← 元のフォルダ
    │   ├── data/
    │   └── src/
    └── v3_4_2_exp1/     ← 実験用にコピー(新規作成)
        ├── data/
        └── src/

    このようにv3_4_2/から生やしたv3_4_2_exp1/のようなフォルダを作って、ここで実験していくことにより、失敗したときにも元のデータに影響を与えることなく、安全に実験を進めることができます。

    以降、このディレクトリで作業を進めていきます。

    ▼v3_4_2_exp1/src/create_features.ipynb

    import gc
    import pandas as pd
    import create_population
    import preprocessing
    from feature_engineering import FeatureCreator
    %load_ext autoreload

    まずはレース情報テーブルの前処理(process_race_info)から見ていきますが、元のタイトル情報とともに現在の分類を確認したいため、少し処理を修正していきます。

    ▼v3_4_2_exp1/src/preprocessing.py

    def process_race_info(
        # ...引数...
    ) -> pd.DataFrame:
        """
        未加工のレース情報テーブルをinput_dirから読み込んで加工し、
        output_dirに保存する関数。
        """
        # ... 前処理コード ...
        df["race_class"] = (
            df["title"]
            .str.extract(rf"({regex_race_class})")
            # タイトルからレース階級情報が取れない場合はinfo2から取得
            .fillna(df["info2"].str.extract(rf"({regex_race_class})"))[0]
            .map(race_class_mapping)
        )
        return df  # ここでデータフレームを返すようにする
        
        # 以下の処理は実行されない

    最後に元々の列を削除している処理があるため、その前に return df を追加してデータフレームを返すようにします。
    モジュールを更新したら、ノートブック上でautoreloadを実行することで変更が反映されます。

    ▼v3_4_2_exp1/src/create_features.ipynb

    %autoreload

    これで分析用のデータが取得できるようになったので、実際に問題を確認していきましょう。

    ▼v3_4_2_exp1/src/create_features.ipynb

    df = preprocessing.process_race_info()[["race_id", "title", "info2"]]

    現在の分類システムでは、以下のようなマッピング辞書を使用しています。

    ▼v3_4_2_exp1/src/create_features.ipynb

    mapping = {
        "新馬": 0,
        "未勝利": 1,
        "1勝クラス": 2,
        "1勝クラス": 2,
        "2勝クラス": 3,
        "2勝クラス": 3,
        "3勝クラス": 4,
        "3勝クラス": 4,
        "オープン": 5,
        "G3": 6,
        "G2": 7,
        "G1": 8,
        "OP": 5,
        "特別": 5,
        "500万下": 2,
        "1000万下": 3,
        "1600万下": 4
    }

    このマッピングを使って正規表現で文字列を検索し、分類を行っています。

    ▼v3_4_2_exp1/src/create_features.ipynb

    regex_race_class = "|".join(mapping)
    print(regex_race_class)
    # 出力: '新馬|未勝利|1勝クラス|1勝クラス|2勝クラス|2勝クラス|3勝クラス|3勝クラス|オープン|G3|G2|G1|OP|特別|500万下|1000万下|1600万下'
    
    df["race_class"] = (
        df["title"]
        .str.extract(rf"({regex_race_class})")
        # タイトルからレース階級情報が取れない場合はinfo2から取得
        .fillna(df["info2"].str.extract(rf"({regex_race_class})"))[0]
    )

    この変換手法については、以下の記事で詳しく説明していますので、詳細はそちらをご覧ください。

    ■ 問題点1: 「特別」レースの誤分類
    「特別」に分類されているレースを確認してみます。

    ▼v3_4_2_exp1/src/create_features.ipynb

    df.query("race_class == '特別'").head()
    画像

    例えば1行目のレースは本来は「2勝クラス」に分類して欲しいですが、現在は"特別"という文字列を元に分類するマッピングになっているので、「特別」に分類されてしまっています。

    ■ 問題点2: リステッド(L)レースの未分類
    現在の分類には「リステッド」が含まれていないため、リステッドレースを正しく分類することができていません。

    ▼v3_4_2_exp1/src/create_features.ipynb

    df[df["title"].str.contains("L")].head()
    画像

    現状では「オープン」という分類になってしまっています。
    リステッドは厳密にはオープンよりも上の階級なので、ここを正しく分類したいところです。

    ■ 問題点3: G1/G2/G3のアラビア数字表記を取り逃し
    現在の分類だと「G1」「G2」「G3」は含まれていますが、アラビア数字での「GI」「GII」「GIII」表記を取り逃しています。

    ▼v3_4_2_exp1/src/create_features.ipynb

    df[df["title"].str.contains("GI")].head()
    画像

    このように、「GI」という数字表記のG1レースが「オープン」として分類されてしまっています。これらのレースも正しく重賞として分類する必要があります。

    これらの問題を解決することで、機械学習モデルがより正確な特徴量を学習できるようになり、予測精度の向上が期待できます。

    新しい分類の設計と実装

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

    これらの問題を解決するために、新しい分類マッピングを以下のように作成します。

    ▼v3_4_2_exp1/src/create_features.ipynb

    mapping_new = {
        "新馬": 0,
        "未勝利": 1,
        "1勝クラス": 2,
        "1勝クラス": 2,
        "2勝クラス": 3,
        "2勝クラス": 3,
        "3勝クラス": 4,
        "3勝クラス": 4,
        "オープン": 5,
        "(L)": 6,           # リステッドを新たに追加
        "G3": 7,            # 6から7に変更
        "GIII": 7,          # アラビア数字表記を追加
        "G2": 8,            # 7から8に変更
        "GII": 8,           # アラビア数字表記を追加
        "G1": 9,            # 8から9に変更
        "GI": 9,            # アラビア数字表記を追加
        "OP": 5,
        # "特別": 5,        # 「特別」を削除
        "500万下": 2,
        "1000万下": 3,
        "1600万下": 4
    }

    主な変更点

    1. 「特別」を削除: info2の詳細情報が正しく優先されるようにする

    2. リステッド競走「(L)」を追加: オープンの次の階級「6」として位置付け

    3. G1/G2/G3の階級を上げる: リステッド追加に合わせて7、8、9に変更

    4. アラビア数字表記を追加: 「GI」「GII」「GIII」を追加

    ■ 正規表現エスケープ処理の実装
    リステッドについては、タイトルに別文脈で文字列"L"が含まれていた場合にも該当してしまう可能性があるので、(L) という括弧まで含めています。

    しかし、(L) の括弧は正規表現で特別な意味を持つ文字なので、そのまま使用すると正しく検索できません。

    ▼v3_4_2_exp1/src/create_features.ipynb

    regex_race_class = "|".join(mapping_new)
    print(regex_race_class)
    # '新馬|未勝利|1勝クラス|1勝クラス|2勝クラス|2勝クラス|3勝クラス|3勝クラス|オープン|(L)|G3|G2|G1|OP|500万下|1000万下|1600万下'
    
    df["race_class_new"] = (
        df["title"]
        .str.extract(rf"({regex_race_class})")
        # タイトルからレース階級情報が取れない場合はinfo2から取得
        .fillna(df["info2"].str.extract(rf"({regex_race_class})"))[0]
    )
    
    # リステッドが分類されているかチェック
    df.query("race_class_new == '(L)'").head()
    画像
    リステッドへの分類が存在しない

    この問題を解決するために、re.escape() を使ってエスケープ処理を行います。

    ▼v3_4_2_exp1/src/create_features.ipynb

    import re
    
    regex_race_class = "|".join([re.escape(k) for k in mapping_new])
    
    df["race_class_new"] = (
        df["title"]
        .str.extract(rf"({regex_race_class})")
        # タイトルからレース階級情報が取れない場合はinfo2から取得
        .fillna(df["info2"].str.extract(rf"({regex_race_class})"))[0]
    )
    # 確認
    df.query("race_class_new == '(L)'").head()
    画像

    この処理により、(L) はエスケープ処理され、括弧が文字通りの括弧として認識されています。

    その他についても、新しい分類が正しく機能しているか確認してみます。

    ▼v3_4_2_exp1/src/create_features.ipynb

    df.query("race_class == '特別'").head()
    画像

    ▼v3_4_2_exp1/src/create_features.ipynb

    df[df["title"].str.contains("GI")].head()
    画像

    従来は「オープン」として一括分類されていた重賞レースも、正確に「GI」「GII」「GIII」として分類されるようになりました。

    新しい分類の精度検証

    新しい分類を使用した場合、どのように精度が改善されるか、確認していきましょう。

    まず、process_race_info()から先ほど分析のために一時的に追加した return dfを削除し、re.escape()を使用するよう修正します。

    ▼v3_4_2_exp1/src/preprocessing.py

    import re  # 追加
    
    def process_race_info(
        # ... 他の引数 ...
        race_class_mapping: dict = race_class_mapping,  # 外からmappingを受け取れる
    ):
        # ... 既存の処理 ...
        
        # 正規表現の作成部分を修正
        regex_race_class = "|".join([re.escape(k) for k in race_class_mapping])  # re.escape()を追加
        
        df["race_class"] = (
            df["title"]
            .str.extract(rf"({regex_race_class})")
            .fillna(df["info2"].str.extract(rf"({regex_race_class})"))[0]
        )
        
        # ... 残りの処理は既存のまま ...

    同様に、process_horse_results()にも同じ修正を行い、ノートブック上でautoreloadを実行して変更を反映させます。

    ▼v3_4_2_exp1/src/create_features.ipynb

    %autoreload

    ここで実は、preprocessing関数には `race_class_mapping` という引数を設定しているので、notebook上から一時的に新しいマッピングを渡すことができます。

    def process_race_info(
        # ... 他の引数 ...
        race_class_mapping: dict = race_class_mapping,  # 外からmappingを受け取れる
    ):
        # ... 既存の処理 ...

    また、`output_filename="race_info_new.csv"` として新しいファイル名を指定することで、元のファイルを残しておくことができます。

    ▼v3_4_2_exp1/src/create_features.ipynb

    race_info_preprocessed = preprocessing.process_race_info(
        race_class_mapping=mapping_new,  # 新しいマッピング辞書を指定
        output_filename="race_info_new.csv",  # 新しいファイル名で保存
    )
    # 重複チェック
    assert race_info_preprocessed.duplicated(subset=["race_id"]).sum() == 0
    # 欠損チェック
    race_info_preprocessed.isnull().sum()

    実行後のファイル構成:

    v3_4_2_exp1/
    ├── data/
    │   ├── 00_population/
    │   ├── 01_preprocessed/
    │   │   ├── race_info.csv          ← 元のファイル(保持)
    │   │   ├── race_info_new.csv      ← 新しい分類で作成
    │   │   ├── horse_results.csv      ← 元のファイル(保持)
    │   │   └── horse_results_new.csv  ← 新しい分類で作成
    │   └── 02_features/
    └── src/

    新しいマッピングでの分類を確認してみましょう。

    ▼v3_4_2_exp1/src/create_features.ipynb

    race_info_preprocessed["race_class"].value_counts(dropna=False)

    ▼実行結果

    race_class
    1    8155
    2    6147
    3    3042
    0    1894
    4    1346
    5     662
    7     475
    6     405
    8     258
    9     169
    Name: count, dtype: int64

    今度は数値感としては、適切な分類になっていそうです。
    同様に、馬の過去成績についても新しい分類で前処理を実行します。

    ▼v3_4_2_exp1/src/create_features.ipynb

    horse_results_preprocessed = preprocessing.process_horse_results(
        race_class_mapping=mapping_new,
        output_filename="horse_results_new.csv",
    )
    
    horse_results_preprocessed["race_class"].value_counts(dropna=False)

    ▼実行結果

    race_class
    NaN    360456
    1.0    137661
    2.0     94407
    0.0     32090
    3.0     25025
    4.0     22863
    5.0     20000
    6.0      4673
    7.0      4053
    8.0      3500
    9.0      2800
    Name: count, dtype: int64

    従来の分類結果と比較してみましょう。

    ▼v3_4_2_exp1/src/create_features.ipynb

    old = pd.read_csv("../data/01_preprocessed/horse_results.csv", sep="\t")
    old["race_class"].value_counts(dropna=False)

    ▼従来分類での結果

    race_class
    NaN    306380  # 欠損値
    1.0    137661  # 未勝利
    2.0     94407  # 1勝クラス
    5.0     93435  # オープン(「特別」レースを多く含むため多い)
    0.0     32090  # 新馬
    3.0     25025  # 2勝クラス
    4.0     22863  # 3勝クラス
    8.0        40  # G2(極端に少ない)
    7.0        26  # G1(極端に少ない)
    6.0        12  # G3(極端に少ない)
    Name: count, dtype: int64

    馬の過去成績には地方競馬の結果も含まれているため、分類されない(NaN)レースが多く含まれています。しかし、中央競馬の重賞レース(6-9)については、従来より大幅に分類数が増加していることが分かります。

    次に、新しい分類でできた前処理済みファイルを使って、特徴量ファイルを作成します。

    ▼v3_4_2_exp1/src/create_features.ipynb

    fc = FeatureCreator(
        race_info_filename="race_info_new.csv",
        horse_results_filename="horse_results_new.csv",
        output_filename="features_new.csv",
    )
    features = fc.create_features()
    # 重複チェック
    assert features.duplicated(subset=["race_id", "horse_id"]).sum() == 0
    画像
    特徴量作成処理の詳細
    (https://note.com/dijzpeb/n/n235be224ec2f より引用)

    これで特徴量が完成したので、offline_evaluation.ipynb で精度比較を実施します。
    まず、従来の分類で作成した特徴量(features.csv)における精度を再確認します。

    自作したDatasetクラスでは、下記のように日付を指定することによって、学習・検証・テストデータに分割することができます。

    ▼v3_4_2_exp1/src/offline_evaluation.ipynb

    from train import Dataset, Trainer
    from evaluation import plot_calibration, Evaluator
    %load_ext autoreload
    
    # データセット作成
    dataset = Dataset(
        train_start_date="2019-02-17",
        valid_start_date="2023-11-17",
        test_start_date="2024-02-17",
        test_end_date="2025-02-16",
        target_strategy=2,
    )
    画像

    Trainerクラスでは学習と同時にテストデータに対しての予測スコア付与・精度評価を行います。

    # 学習実行
    trainer = Trainer(config_filepath="config.yaml")
    evaluation_df = trainer.run(
        dataset=dataset,
        importance_filename="importance",
        model_filename="model.pkl",
        calibration_model_filename="calibration_model.pkl",
        evaluation_filename="evaluation.csv",
    )

    ▼学習結果

    Training until validation scores don't improve for 100 rounds
    [100]	valid_0's binary_logloss: 0.237843
    [200]	valid_0's binary_logloss: 0.227721
    [300]	valid_0's binary_logloss: 0.225085
    [400]	valid_0's binary_logloss: 0.224295
    [500]	valid_0's binary_logloss: 0.223962
    [600]	valid_0's binary_logloss: 0.223793
    [700]	valid_0's binary_logloss: 0.223692
    [800]	valid_0's binary_logloss: 0.223677
    Early stopping, best iteration is:
    [726]	valid_0's binary_logloss: 0.223647
    -------------------- result --------------------
    test_df binary_logloss (target_mod vs pred): 0.23755804311874934
    test_df binary_logloss (target vs pred): 0.20951059895363366
    test_df binary_logloss (target vs pred_calibrated): 0.21680657796259503

    学習結果で表示される3つのbinary logLoss(対数損失)は、それぞれ異なる「正解データと予測値の誤差」を計算しており、低いほど精度が良いことを表します。

    1. target_mod vs pred: 学習用目的変数(同着等を考慮した調整済み正解) vs 予測値の誤差

    2. target vs pred: 実際の着順(1着なら1、それ以外なら0) vs 予測値の誤差

    3. target vs pred_calibrated: 実際の着順 vs 確率補正済み予測値の誤差

    学習時の目的変数設定については、以下の記事で詳しく説明していますので、詳細はそちらをご覧ください。

    ただし、上記の学習の特徴量には「単勝オッズ(直前オッズ)」と「人気」が入っているため、予測値はこの二つの特徴量に引っ張られて、それ以外の特徴量の良し悪しを判定しづらくなってしまう問題があります。

    画像
    特徴量重要度。単勝オッズと人気が大きな割合を占めている。

    そこで、オッズと人気を特徴量から省いた場合のデータも確認しておきます。

    ▼v3_4_2_exp1/src/offline_evaluation.ipynb

    # オッズと人気を特徴量から省いた場合
    trainer = Trainer(config_filepath="config_odds_removed.yaml")
    _ = trainer.run(
        dataset=dataset,
        importance_filename="importance_odds_removed",
        model_filename="model_odds_removed.pkl",
        calibration_model_filename="calibration_model_odds_removed.pkl",
        evaluation_filename="evaluation_odds_removed.csv",
    )

    ▼v3_4_2_exp1/src/offline_evaluation.ipynb

    # オッズと人気を特徴量から省いた場合
    trainer = Trainer(config_filepath="config_odds_removed.yaml")
    _ = trainer.run(
        dataset=dataset,
        importance_filename="importance_odds_removed",
        model_filename="model_odds_removed.pkl",
        calibration_model_filename="calibration_model_odds_removed.pkl",
        evaluation_filename="evaluation_odds_removed.csv",
    )

    結果:

    Training until validation scores don't improve for 100 rounds
    [100] valid_0's binary_logloss: 0.258172
    [200] valid_0's binary_logloss: 0.249959
    [300] valid_0's binary_logloss: 0.24669
    ...
    [2079] valid_0's binary_logloss: 0.242375
    -------------------- result --------------------
    test_df binary_logloss (target_mod vs pred): 0.2507791988367869
    test_df binary_logloss (target vs pred): 0.2215636253332842
    test_df binary_logloss (target vs pred_calibrated): 0.22760563431487746

    このあたりは、より良い精度評価の方法を現在も模索中ですが、特徴量開発の際には、筆者はこのtarget_mod vs predを一番メインの指標として比較しています。


    これで現状の分類における精度が確認できたので、この値をベンチマークにしながら、新分類を使ったパターンの精度をみて比較していきます。

    Datasetクラスにもfeatures_filename=パラメータがついているので、ここに新しく作成した特徴量ファイルを入れることができます。

    ▼v3_4_2_exp1/src/offline_evaluation.ipynb

    # データセット作成
    dataset = Dataset(
        train_start_date="2019-02-17",
        valid_start_date="2023-11-17",
        test_start_date="2024-02-17",
        test_end_date="2025-02-16",
        target_strategy=2,
        # odds_source="fixed",
        features_filename="features_new.csv",
    )

    ▼v3_4_2_exp1/src/offline_evaluation.ipynb

    # 学習実行(new)
    trainer = Trainer(config_filepath="config.yaml")
    evaluation_df = trainer.run(
        dataset=dataset,
        importance_filename="importance_new",
        model_filename="model_new.pkl",
        calibration_model_filename="calibration_model_new.pkl",
        evaluation_filename="evaluation_new.csv",
    )

    結果:

    Training until validation scores don't improve for 100 rounds
    [100] valid_0's binary_logloss: 0.237875
    [200] valid_0's binary_logloss: 0.227638
    [300] valid_0's binary_logloss: 0.224962
    [400] valid_0's binary_logloss: 0.224159
    [500] valid_0's binary_logloss: 0.223916
    [600] valid_0's binary_logloss: 0.22378
    [700] valid_0's binary_logloss: 0.223766
    Early stopping, best iteration is:
    [661] valid_0's binary_logloss: 0.223736
    -------------------- result --------------------
    test_df binary_logloss (target_mod vs pred): 0.23763799520486803
    test_df binary_logloss (target vs pred): 0.20956217164860672
    test_df binary_logloss (target vs pred_calibrated): 0.212404191693961

    元のtarget_mod vs predのloglossが0.2375なので、こちらの指標は微妙に悪化してることになります。

    ▼v3_4_2_exp1/src/offline_evaluation.ipynb

    # オッズと人気を特徴量から省いた場合(new)
    trainer = Trainer(config_filepath="config_odds_removed.yaml")
    _ = trainer.run(
        dataset=dataset,
        importance_filename="importance_odds_removed_new",
        model_filename="model_odds_removed_new.pkl",
        calibration_model_filename="calibration_model_odds_removed_new.pkl",
        evaluation_filename="evaluation_odds_removed_new.csv",
    )

    結果:

    Training until validation scores don't improve for 100 rounds
    ...
    [1419] valid_0's binary_logloss: 0.242467
    -------------------- result --------------------
    test_df binary_logloss (target_mod vs pred): 0.2506650577523346
    test_df binary_logloss (target vs pred): 0.22161407998577898
    test_df binary_logloss (target vs pred_calibrated): 0.22182806113323453

    オッズと人気を抜いた場合は、0.2507 → 0.2506に改善しているので、作成した特徴量を用いた予測精度としては、わずかだが改善しているということが分かります。

    次に、回収率を比較してみます。

    ■旧分類での単勝馬券結果

    ▼v3_4_2_exp1/src/offline_evaluation.ipynb

    evaluator = Evaluator(evaluation_filename="evaluation.csv")
    result_df = evaluator.bet_by_expect_return(
        min_exp=0.8, max_exp=2.0, min_pred=0.01, pred_col="pred"
    )
    best result:
    {'expect_return': 1.8545454545454545,
     'n_hits': 8.0,
     'return_rate': 2.176422764227642,
     'sharp_ratio': 1.2392949671527314,
     'std': 0.9492677654702839,
     'total_bet': 246.0,
     'total_payoff': 535.4}
    画像

    ■新分類での単勝馬券結果
    こちらも、evaluation_filename="evaluation_new.csv"に付け替えることができます。

    ▼v3_4_2_exp1/src/offline_evaluation.ipynb

    evaluator = Evaluator(evaluation_filename="evaluation_new.csv")
    result_df = evaluator.bet_by_expect_return(
        min_exp=0.8, max_exp=2.0, min_pred=0.01, pred_col="pred"
    )
    best result:
    {'expect_return': 1.8303030303030303,
     'n_hits': 13.0,
     'return_rate': 3.148301886792453,
     'sharp_ratio': 2.0974496139920262,
     'std': 1.0242448125862917,
     'total_bet': 265.0,
     'total_payoff': 834.3}
    画像

    回収率314%、シャープレシオ2.09と、大幅な改善が見られます。
    このプロットの見方については、以下の記事で解説しているので、詳細はそちらをご覧ください。

    次に、馬連馬券のシミュレーションです。(こちらは結果だけ貼っておきます)

    ■旧分類での馬連馬券結果

    Best result:
    {'expect_return': 4.484848484848484,
     'hits': 9.0,
     'return_rate': 1.978283485045514,
     'sharp_ratio': 0.9358393949817729,
     'std': 1.045354032210375,
     'total_bets': 769.0,
     'total_payoff': 1521.3000000000002}
    画像

    ■新分類での馬連馬券結果

    Best result:
    {'expect_return': 3.0202020202020203,
     'hits': 58.0,
     'return_rate': 1.4170331790123456,
     'sharp_ratio': 1.1745800294942925,
     'std': 0.3550487566112429,
     'total_bets': 5184.0,
     'total_payoff': 7345.9}
    画像

    ソースコードのダウンロード

    ソースコードは以下の記事に最新版が添付されているので、そこからダウンロードすることができます。

    上記記事には、今回の記事で紹介したソースコード含め、データ収集・機械学習モデル構築・サーバー上での自動投票など、本格的な競馬AIの運用に必要な全工程のコードが含まれています。

    v3.4.3の作成

    オフライン検証の結果、改善が確認できたので、この変更を取り入れた新しいバージョンv3.4.3を作成していきます。

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

    あなたへのおすすめ