機械学習で競馬必勝本に勝てるのか? 〜Pythonで実装するランク学習〜

こんにちは。データサイエンティストの堀部です。

この記事は Enigmo Advent Calendar 2020 の9日目の記事です。

何か社外のデータを使っていい感じのことができないかなと思っていたところ、3日目の竹本さんの記事がおもしろく、パクリ二次創作しました。

短期間で実装したので汚いコードで見苦しいかもしれないですがご了承ください。ちなみに、私は競馬は簡単なルールを知っているくらいでズブの素人です。

目次

使用したライブラリ

import urllib.parse
import urllib.request as req
from time import sleep

import category_encoders as ce
import lightgbm as lgb
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from bs4 import BeautifulSoup
from selenium import webdriver
from tqdm.auto import tqdm

インストール手順は割愛します。*1

データ取得

オッズだけでモデルを組むのはつまらないので簡単に取得できる範囲で下記を追加しました。

  • 馬連
  • 馬名
  • 斤量
  • 騎手
  • 厩舎
  • 馬体重とその増減
  • 年齢
  • 性別

データ取得にあたり、下記の関数とクラスを用意しました。 sleep関数で1秒以上の間隔を空けてnetkeibaからスクレイピングしています。

def get_raceids(date):
    url = "https://race.netkeiba.com/top/race_list_sub.html?kaisai_date=" + date
    res = req.urlopen(url)
    racesoup = BeautifulSoup(res, "html.parser")
    sleep(1)
    racelist = racesoup.select(
        "#RaceTopRace > div > dl > dd > ul > li > a:nth-of-type(1)"
    )
    raceids = [
        urllib.parse.parse_qs(urllib.parse.urlparse(race.get("href")).query)["race_id"][
            0
        ]
        for race in racelist
    ]
    return raceids


def set_selenium():
    options = webdriver.ChromeOptions()
    options.add_argument("--headless")
    options.add_argument("--no-sandbox")
    options.add_argument("--disable-dev-shm-usage")
    driver = webdriver.Chrome("chromedriver", options=options)
    driver.implicitly_wait(15)
    return driver

class HorceRacing:
    def __init__(self, race_id, driver):

        self.race_id = race_id
        self.driver = driver
        try:
            self.result = pd.read_html(
                "https://race.netkeiba.com/race/result.html?race_id=" + self.race_id
            )
            sleep(1)
        except BaseException:
            print("no result yet")

        self.odds = self._get_odds()
        self.info = self._get_info()

        self.dict_columns = {
            "馬番": "horse_no",
            "枠": "gate",
            "馬名": "horse_name",
            "斤量": "burden_weight",
            "騎手": "jockey_name",
            "厩舎": "stable",
            "馬体重": "horse_weight",
            "馬体重_増減": "horse_weight_change",
            "性別": "sextype",
            "年齢": "age",
            "オッズ": "odds",
            "着順": "target",
        }

    def _get_odds(self):
        self.driver.get(
            "https://race.netkeiba.com/odds/index.html?type=b1&race_id="
            + self.race_id
            + "&rf=shutuba_submenu"
        )
        html = self.driver.page_source.encode("utf-8")
        tanhukusoup = BeautifulSoup(html, "html.parser")
        tanhuku_df = pd.read_html(str(tanhukusoup.html))[0].loc[:, ["馬番", "オッズ"]]
        sleep(1)
        return tanhuku_df

    def _get_info(self):
        info_df = pd.read_html(
            "https://race.netkeiba.com/race/shutuba.html?race_id=" + self.race_id
        )[0]
        sleep(1)
        info_df.columns = [col[0] for col in info_df.columns]
        info_df = info_df.loc[:, ["馬番", "枠", "馬名", "性齢", "斤量", "騎手", "厩舎", "馬体重(増減)"]]
        info_df["馬体重"] = (
            info_df["馬体重(増減)"].str.split("(").str[0].replace("--", np.nan).astype(float)
        )
        info_df["馬体重_増減"] = (
            info_df["馬体重(増減)"]
            .str.split("(")
            .str[1]
            .str.replace(")", "")
            .replace("--", np.nan)
            .replace("前計不", np.nan)
            .astype(float)
        )
        info_df["性別"] = info_df["性齢"].str[0]
        info_df["年齢"] = info_df["性齢"].str[1:].astype(int)
        info_df.drop(["馬体重(増減)", "性齢"], axis=1, inplace=True)
        return info_df

    # 同着があり複数パターンある場合は1番初めのパターンだけ取得
    def result_sanrentan(self):
        _result = self.result[2].set_index(0).loc["3連単", [1, 2]]
        return (
            int(_result[2].replace(",", "").split("円")[0]),
            list(map(int, _result[1].split(" "))),
        )

    def result_sanrenpuku(self):
        _result = self.result[2].set_index(0).loc["3連複", [1, 2]]
        return (
            int(_result[2].replace(",", "").split("円")[0]),
            list(map(int, _result[1].split(" ")))[:3],
        )

    def result_tansyo(self):
        _result = self.result[1].set_index(0).loc["単勝", [1, 2]]
        return (
            int(_result[2].replace(",", "").split("円")[0]),
            list(map(int, _result[1].split(" "))),
        )

    def get_df(self):
        df = self.info.merge(self.odds, on="馬番").merge(
            self.result[0].loc[:, ["馬番", "着順"]], on=["馬番"]
        )

        # カラムが日本語だとモデルの学習ができないので置換
        df.columns = df.columns.map(self.dict_columns)

        df["race_id"] = self.race_id

        # 着順が数値以外のものを除外・置換
        df = df.loc[~df["target"].isin(["中止", "除外", "取消"]), :]
        df["target"] = df["target"].replace("失格", 20).astype(int)

        # オッズが数値以外のものを置換
        df["odds"] = df["odds"].replace("---.-", np.nan).astype(float)

        # lighgbmのlambdarankは数値が大きい方がランクが高いという定義なのでtargetを変換
        df["target"] = df["target"].max() - df["target"] + 1
        return df

データの取得期間は下記のように分けました。

  • 訓練データ:2020年9月5日〜2020年10月31日(540レース分)
  • 検証データ:2020年11月1日〜2020年11月23日(252レース分)
  • テストデータ:2020年11月28日〜2020年11月29日(48レース分)*2

モデルの訓練に使えるようなデータ形式で取得し、払戻金の計算が後ほどできるように当たった場合の金額を取得しています。*3

list_date_train = [
    "20200905",
    "20200906",
    "20200912",
    "20200913",
    "20200919",
    "20200920",
    "20200921",
    "20200926",
    "20200927",
    "20201003",
    "20201004",
    "20201010",
    "20201011",
    "20201017",
    "20201018",
    "20201024",
    "20201025",
    "20201031",
]

list_date_val = [
    "20201101",
    "20201107",
    "20201108",
    "20201114",
    "20201115",
    "20201121",
    "20201122",
    "20201123",
]

list_date_test = ["20201128", "20201129"]

list_train_df = []
dict_train_result = dict()
for date in tqdm(list_date_train):
    race_ids = get_raceids(date)
    for race_id in tqdm(race_ids):
        hr = HorceRacing(race_id, driver)
        train_df = hr.get_df()
        train_df["date"] = date
        list_train_df.append(train_df)
        dict_train_result[race_id] = {
            "sanrentan": hr.get_result_sanrentan()[0],
            "sanrenpuku": hr.get_result_sanrenpuku()[0],
            "tansyo": hr.get_result_tansyo()[0],
        }

list_val_df = []
dict_val_result = dict()
for date in tqdm(list_date_val):
    race_ids = get_raceids(date)
    for race_id in tqdm(race_ids):
        hr = HorceRacing(race_id, driver)
        val_df = hr.get_df()
        val_df["date"] = date
        list_val_df.append(val_df)
        dict_val_result[race_id] = {
            "sanrentan": hr.result_sanrentan()[0],
            "sanrenpuku": hr.result_sanrenpuku()[0],
            "tansyo": hr.result_tansyo()[0],
        }

list_test_df = []
dict_test_result = dict()
for date in tqdm(list_date_test):
    race_ids = get_raceids(date)
    for race_id in tqdm(race_ids):
        try:
            hr = HorceRacing(race_id, driver)
            test_df = hr.get_df()
            test_df["date"] = date
            list_test_df.append(test_df)
            dict_test_result[race_id] = {
                "sanrentan": hr.result_sanrentan()[0],
                "sanrenpuku": hr.result_sanrenpuku()[0],
                "tansyo": hr.result_tansyo()[0],
            }
        except BaseException:
            pass

train = pd.concat(list_train_df)
train = train.sort_values("race_id")
train.reset_index(inplace=True, drop=True)

val = pd.concat(list_val_df)
val = val.sort_values("race_id")
val.reset_index(inplace=True, drop=True)

test = pd.concat(list_test_df)
test = test.sort_values("race_id")
test.reset_index(inplace=True, drop=True)

前処理

文字列はそのままだとモデルに入れられないので数値に置き換えます。 今回モデルはlightgbmを使うので、OrdinalEncoderを利用しました。 また、重要そうなオッズを中心に特徴量を追加しました。(関数:add_features)

lightgbmのランク学習(lambdarank)の場合、回帰・分類予測と違い上から順に○行は同じレースだよというqueryを用意する必要があるので作成しておきます。

categorical_cols = ["horse_name","jockey_name","stable","sextype"]

ce_oe = ce.OrdinalEncoder(
    cols=categorical_cols, handle_unknown="return_nan", handle_missing="return_nan"
)

train = ce_oe.fit_transform(train)
val = ce_oe.transform(val)
test = ce_oe.transform(test)

def add_features(df):
    odds_min = df.groupby("race_id")["odds"].min()
    # オッズの最小値との差分
 df["odds_diff"] = df["odds"] - df["race_id"].map(odds_min)
    # オッズの最小値との倍率
    df["odds_ratio"] = df["odds"] / df["race_id"].map(odds_min)
 # オッズの偏差値
    df["odds_deviation"] = (df["odds"] - df["odds"].mean()) / df["odds"].std()
    # 斤量 + 馬の体重
    df["all_weight"] = df["burden_weight"] + df["horse_weight"]


add_features(train)
add_features(val)
add_features(test)

# レースIDを後で参照できるように保持
arr_train_race_ids = train["race_id"].unique()
arr_val_race_ids = val["race_id"].unique()
arr_test_race_ids = test["race_id"].unique()

# ランク学習に必要なqueryを作成
train_query = train.groupby("race_id")["horse_no"].count().values.tolist()
val_query = val.groupby("race_id")["horse_no"].count().values.tolist()
test_query = test.groupby("race_id")["horse_no"].count().values.tolist()

# 学習に不要なカラムを削除
drop_cols = ["race_id","date"]

train.drop(drop_cols, axis=1, inplace=True)
val.drop(drop_cols, axis=1, inplace=True)
test.drop(drop_cols, axis=1, inplace=True)

# 目的変数を分離
target = train_df.pop("target")
val_target = val_df.pop("target")
test_target = test_df.pop("target"

学習

モデルはlightgbmのscikit-learn APILGBMRankerを利用しました。評価指標はNDCG)*4です。

lgb_params = {
    "objective": "lambdarank",
    "metric": "ndcg",
    "n_estimators":2000,
    "boosting_type": "gbdt",
    "num_leaves":31,
    "learning_rate":0.01,
    "importance_type": "gain",
    "random_state": 42,
}
lgb_fit_params = {
    "eval_metric":"ndcg",
    "eval_at":(1,2,3),
    "early_stopping_rounds": 50,
    "verbose": 10,
    "categorical_feature": categorical_cols,
}

lgb_model = lgb.LGBMRanker(**lgb_params)

lgb_model.fit(
    train,
    target,
    group=train_query,
    eval_set=[(train,target),(val,val_target)],
    eval_group=[train_query,val_query],
    **lgb_fit_params
)

学習結果です。出馬情報だけだと予測が難しそうですね。

training's ndcg@1: 0.550162  training's ndcg@2: 0.608893 training's ndcg@3: 0.649182
valid_1's ndcg@1: 0.444402  valid_1's ndcg@2: 0.513605  valid_1's ndcg@3: 0.550583

特徴量の重要度(feature_importance)を算出してみました。オッズ関連が予測に効いていますね。

fti =pd.Series(lgb_model.feature_importances_,index=train.columns).sort_values()
fti.plot(kind="barh")

f:id:enigmo7:20201204172819p:plain

予測・評価

検証データ(val)とテストデータ(test)、それぞれに対して予測結果から単勝・3連複・3連単での的中率と100円ずつ買った場合いくら儲かるのか?を計算してみます。比較としてオッズの低い順(人気順)に買ってみた場合も出してみます。

def get_result(
        target, pred, query, race_ids, dict_result, is_higher_better=True, bet_yen=100
    ):
        ind = 0
        correct_first = 0
        refund_first = 0

        correct_sanrenpuku = 0
        refund_sanrenpuku = 0

        correct_sanrentan = 0
        refund_sanrentan = 0

        for q, race_id in zip(query, race_ids):
            _true_first = np.argmax(target[ind : ind + q])
            if is_higher_better:
                _pred_first = np.argmax(pred[ind : ind + q])
            else:
                _pred_first = np.argmin(pred[ind : ind + q])
            if _true_first == _pred_first:
                correct_first += 1
                refund_first += dict_result[race_id]["tansyo"] / 100 * bet_yen
            else:
                refund_first -= bet_yen

            _true_sanren = np.argsort(target[ind : ind + q].values)[::-1][:3]
            if is_higher_better:
                _pred_sanren = np.argsort(pred[ind : ind + q])[::-1][:3]
            else:
                _pred_sanren = np.argsort(pred[ind : ind + q])[:3]

            if len(set(_true_sanren) & set(_pred_sanren)) == 3:
                correct_sanrenpuku += 1
                refund_sanrenpuku += dict_result[race_id]["sanrenpuku"] / 100 * bet_yen
            else:
                refund_sanrenpuku -= bet_yen

            if _true_sanren.tolist() == _pred_sanren.tolist():
                correct_sanrentan += 1
                refund_sanrentan += dict_result[race_id]["sanrentan"] / 100 * bet_yen
            else:
                refund_sanrentan -= bet_yen

            ind += q
        print(
            f"単勝  的中率: {correct_first / len(query) * 100 :.2f}%, 払戻金-賭け金: {refund_first}"
        )
        print(
            f"3連複 的中率: {correct_sanrenpuku / len(query) * 100 :.2f}%, 払戻金-賭け金: {refund_sanrenpuku}"
        )
        print(
            f"3連単 的中率: {correct_sanrentan / len(query) * 100 :.2f}%, 払戻金-賭け金: {refund_sanrentan}"
        )
        print("-------------------------------------------")
        print(
            f"合計  回収率: {((refund_first+refund_sanrenpuku+refund_sanrentan) / (3 * bet_yen * len(race_ids))+1) * 100 :.2f}%, 払戻金-賭け金: {refund_first+refund_sanrenpuku+refund_sanrentan}"
        )

pred_val = lgb_model.predict(val,num_iteration=lgb_model.best_iteration_)
pred_test = lgb_model.predict(test,num_iteration=lgb_model.best_iteration_)

print("■検証データ(val)")
print("lightgbm モデル")
print("-------------------------------------------")
get_result(
    val_target,
    pred_val,
    val_query,
    arr_val_race_ids,
    dict_val_result,
)
print("")
print("オッズ低い順")
print("-------------------------------------------")
get_result(
    val_target,
    val["odds"],
    val_query,
    arr_val_race_ids,
    dict_val_result,
    is_higher_better=False,
)
print("")
print("■テストデータ(test)")
print("lightgbm モデル")
print("-------------------------------------------")
get_result(
    test_target,
    pred_test,
    test_query,
    arr_test_race_ids,
    dict_test_result
)
print("")
print("オッズ低い順")
print("-------------------------------------------")
get_result(
    test_target,
    test["odds"],
    test_query,
    arr_test_race_ids,
    dict_test_result,
    is_higher_better=False,
)

VSオッズ低い順

検証データ、テストデータ共にオッズ低い順にかけたよりも回収率が高くなりました!検証データでは残念ながらボロ負けですが、テストデータでは回収率100%超えました。特に3連複・3連単の的中率がオッズ低い順より高くなっているのがおもしろいです。

■検証データ(val)
lightgbm モデル
-------------------------------------------
単勝  的中率: 30.95%, 払戻金-賭け金: 4660.0
3連複 的中率: 6.75%, 払戻金-賭け金: -11460.0
3連単 的中率: 1.59%, 払戻金-賭け金: -17060.0
-------------------------------------------
合計  回収率: 68.44%, 払戻金-賭け金: -23860.0

オッズ低い順
-------------------------------------------
単勝  的中率: 32.14%, 払戻金-賭け金: 2450.0
3連複 的中率: 5.56%, 払戻金-賭け金: -15660.0
3連単 的中率: 0.40%, 払戻金-賭け金: -22270.0
-------------------------------------------
合計  回収率: 53.07%, 払戻金-賭け金: -35480.0

■テストデータ(test)
lightgbm モデル
-------------------------------------------
単勝  的中率: 37.50%, 払戻金-賭け金: 1360.0
3連複 的中率: 14.58%, 払戻金-賭け金: 1060.0
3連単 的中率: 4.17%, 払戻金-賭け金: -900.0
-------------------------------------------
合計  回収率: 110.56%, 払戻金-賭け金: 1520.0

オッズ低い順
-------------------------------------------
単勝  的中率: 37.50%, 払戻金-賭け金: 1310.0
3連複 的中率: 12.50%, 払戻金-賭け金: -1040.0
3連単 的中率: 2.08%, 払戻金-賭け金: -3360.0
-------------------------------------------
合計  回収率: 78.54%, 払戻金-賭け金: -3090.0

VS競馬必勝本

竹本さんの記事では検証データと同じ期間にレースを選択して、3連複のみ購入していました。

金額としては14630円負けてしまいました。

競馬必勝本は本当に当たるのかを検証!〜Pythonで実装する馬券自動選択ツール〜 - エニグモ開発者ブログ

そこで検証データの3連複のみ比較すると、

-11,460円(lightgbmモデル × 全レース) > -14,630円(競馬必勝本) > -15,660円(オッズ低い順 × 全レース)

ということで、まぐれかもしれないですが機械学習で競馬必勝本に勝てたと言ってもよいのではないでしょうか?

感想

なかなか簡単には儲かりませんね。

まだまだ改善の余地がありそうなので、気がむいたら趣味で続けてみたいと思います。

  • 特徴量の追加:コースの情報、天気、血統
  • 取得データ期間の延長
  • どのレースに賭ける/賭けないべきか?の予測
  • どの種類の馬券を買うべきか?の予測

参考資料

Welcome to LightGBM’s documentation! — LightGBM 3.1.0.99 documentation

LightGBMでサクッとランク学習やってみる - 人間だったら考えて

馬券の種類:はじめての方へ JRA

レース情報(JRA) | 出馬表やオッズ、プロ予想などの競馬情報は netkeiba.com

最後まで読んでいただきありがとうございました。

明日の記事の担当はエンジニアの齊藤さんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧

hrmos.co

*1:私はpoetryを利用しました。

*2:後から気づいたのですが、正しく比較するにはテストデータを2020年11月1日〜2020年11月23日にすべきでした。

*3:3位以内に同着がある場合は、前処理の簡略化のためサイト上に一番初めに掲載されているパターンのみ取得しています。

*4:NDCGは0〜1の値をとり、高いほど精度がよいことを示します。