データ収集から機械学習まで全て行って競馬の予測をしてみた

48 min読了の目安(約29100字TECH技術記事
Likes14

概要

※ Qiitaから移行して、一部追記しました(さらに追記の項を参照)
元タイトル:データ収集からディープラーニングまで全て行って競馬の予測をしてみた

某T大学で情報系を専攻している学生です。Qiitaの記事を色々見ていたら、こんな記事を発見。

この記事の回収率100%達成に関しては、購入シミュレーションした馬券の数が少ないので、他の期間でも成立するのかはわかりません。ソースコードも有料なのでどうやっているのかの詳細もわかりません。しかし、自分で競馬予測をしてみても面白そうだと思ったので、勉強するつもりで実際にやってみました。

データ収集・分析・予測のすべてを行うことになるのでかなり勉強になります。

なぜ競馬なのか?

もしかしたらお金になるかもしれないという欲もありましたが、競馬は控除率が高いらしいのであまり期待はできません。
大きな理由としては最近話題になっていたから・ディープラーニングをやってみたかったから、ですかね。

競馬を選んだ他の理由としては、

  • レース結果が観客によって左右されることが少ない
  • 説明変数が足りていればそこそこの精度で予測ができそう

というのが挙げられます。

株をテーマにするのも良さそうですが、これは多くの人間の意思決定によって値段が変動するので、取引する人がよく見るニュースなどの情報を取り入れないと、良い精度で予測することが難しそうです。
さらに、たくさんの機関投資家がアルゴリズムに応じて注文を自動で行うので、これに左右されることになりそうです。

以上から今の技術では簡単にはできなさそうだと思ったので、競馬のほうがディープラーニング向きだと思いました。

競馬は出走馬の数がレースごとに違うのですが、競艇なんかは出場選手の数が一定らしいです。詳しいデータが得られれば機械学習しやすそうですね。

競馬が初めての人への説明

「競馬(けいば、英: horse racing)は、騎手の乗った馬により競われる競走競技、およびそれの着順を予想する賭博である」(引用:競馬 - Wikipedia)。

私も今回のデータ分析をするまでは競馬に関しての知識がほとんど無かったので、この記事を読むために必要そうだなと思った知識をまとめておきます。

まず基礎知識として馬券の種類について知っておきましょう。単勝・複勝くらいをななめ読みするくらいで大丈夫です。
参考: 馬券の種類:はじめての方へ JRA

他の用語は以下を参考に

  • オッズ: 単勝時にもらえるお金が、掛けたお金の何倍かを表す倍率
  • 上がり: レースや調教における終盤のこと
  • 馬番(うまばん): 出走馬に一意に割り当てられる番号
  • 枠番: 1から8まである。出走時のゲート2つに対して1つの番号
  • 着順: ゴールへの到達順位
  • 中央競馬: 日本中央競馬会が開催する競馬。 札幌・函館・福島・新潟・中山・東京・中京・京都・阪神・小倉の10箇所がある
  • 地方競馬場: 中央競馬とは違い、地方公共団体が主催する競馬

参考: 競馬用語辞典 JRA

そこまで詳しくないので間違っていたら教えて下さい...

機械学習では、ドメイン知識が重要と言われるので、予測精度を上げるためには競馬に詳しくなることが必要条件となるでしょう。

大まかな手順

競馬の予測をすると行っても、考えること・やるべきことは大量にあります。大きく手順を分けると以下のとおりでしょうか。

  1. データ収集(クローリング・スクレイピング)
  2. データ整形(pandas・SQLなど)
  3. モデル作成(機械学習)

競馬予測をしようと思う人にとってまず大きな課題となるのは、データ収集・整形の部分でしょう。Kaggleなどのコンペではデータセットがはじめから与えられるのでかなり楽ですが、今回はデータ収集から始める必要があります。

また、モデル作成についても、様々な方法が考えられるので難しいです。最近はライブラリで簡単に勾配ブースティング・ディープラーニングなどを用いることができますが、予測精度を上げるためには色々な手法を試してみる必要があるでしょう。

前提知識

  • HTML, CSS などの基礎知識
  • Selenium の基本的な使い方
  • Beautifulsoup の基本的な使い方
  • pandas の基本的な使い方
  • keras の基本的な使い方

結果の概要

使用データ

  • 学習データ:2008年1月~2017年7月23日
  • 検証用データ: 2017年7月23日~2019年11月あたり

結果

  • 単勝正解率: 0.2450
  • 複勝正解率: 0.5434

競馬初心者の自分よりも精度が高いモデルができました

さっそくデータ収集からはじめてみる

データも無いのにいきなり機械学習はできません。クローリング・スクレイピングをしましょう。

まずは対象となるサイトから、過去のレース結果や馬の情報を取得します。

ここで得るデータはなるべく生のデータに近いものにして、学習のために後でデータ整形をすることとします。

対象サイト

国内最大規模の競馬情報サイトです。過去のレースデータから馬の血統情報まで、無料でかなり詳しくデータを手に入れることができます。

有料会員になればより詳細なデータを得られるようです。よりモデルの精度を上げたい場合には有効ですね。

収集データ

今回は情報量が多く、仕組みが統一されている中央競馬場におけるレース結果を中心にデータ収集をすることにしました。

たくさんのデータがあるので、様々なデータを収集して使うことで良いモデルが作れるでしょう。ですが、血統情報やオーナー・調教師などのデータまで収集するのはかなり手間なので今回は見送りました。
このあたりのデータを追加すれば予測精度が上がりそうですね。

まずは全レースへのURLを取得する

サイト中のレース詳細検索画面から、Seleniumを使用してレース結果へのURLを全て取得します。

Pythonでクローリング・スクレイピングする際によく用いられるrequestsとBeautifulSoupを使用しない理由としては、検索時のURLも検索結果のURLもhttps://db.netkeiba.com/?pid=race_search_detailで変化していないからです。

JavaScriptやPHPなどで動的に画面を生成している場合は、単純にhtmlをダウンロードしてきても望むデータは得ることができません。

Seleniumを用いると実際のブラウザ操作で画面遷移が行えるので、このようなボタンのクリックなどで表示が変わるサイトや、ログインが必要なサイトなどでもwebクローリングを行うことができます。
(ログインが必要なサイトは、会員規約などでクローリングを禁止している場合も多いのでご注意ください)。

まずは必要なものの準備

import time

from selenium import webdriver
from selenium.webdriver.support.ui import Select,WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.chrome.options import Options

options = Options()
options.add_argument('--headless')    # ヘッドレスモードに
driver = webdriver.Chrome(chrome_options=options) 
wait = WebDriverWait(driver,10)

フォームの入力を埋める

フォーム上の入力欄のうち必要なものを埋めていきます。送信をしたら検索結果が表示されるまで待ちましょう。

URL = "https://db.netkeiba.com/?pid=race_search_detail"
driver.get(URL)
time.sleep(1)
wait.until(EC.presence_of_all_elements_located)

# 月ごとに検索
year = 2019
month = 1

# 期間を選択
start_year_element = driver.find_element_by_name('start_year')
start_year_select = Select(start_year_element)
start_year_select.select_by_value(str(year))
start_mon_element = driver.find_element_by_name('start_mon')
start_mon_select = Select(start_mon_element)
start_mon_select.select_by_value(str(month))
end_year_element = driver.find_element_by_name('end_year')
end_year_select = Select(end_year_element)
end_year_select.select_by_value(str(year))
end_mon_element = driver.find_element_by_name('end_mon')
end_mon_select = Select(end_mon_element)
end_mon_select.select_by_value(str(month))

# 中央競馬場をチェック
for i in range(1,11):
    terms = driver.find_element_by_id("check_Jyo_"+ str(i).zfill(2))
    terms.click()
        
# 表示件数を選択(20,50,100の中から最大の100へ)
list_element = driver.find_element_by_name('list')
list_select = Select(list_element)
list_select.select_by_value("100")

# フォームを送信
frm = driver.find_element_by_css_selector("#db_search_detail_form > form")
frm.submit()
time.sleep(5)
wait.until(EC.presence_of_all_elements_located)

簡単のために2019年1月のURLを入手するようにしてあります。さらに広範囲のデータが欲しい場合は以下のどれかを行って下さい。

  • 年月のフォームを入力しないようにする
  • ループで各年月についてURLを取得する
  • 選択する年月の範囲を変更する

(githubのコード上では、2008年からまだ取得していないレースデータを収集するようにしてあります。)

競馬場の選択までしっかり埋めないと、海外で行われたレースのデータなども入ってしまいます。中央競馬場10個のチェックをちゃんとやっておきましょう。

中央競馬場以外のデータは出走馬が少なかったり、データが不完全であったりすることもあるので、今回は使わないことにしました。

ページ送りをしながらURLを保存

Seleniumでボタンをクリックして、100件ずつ表示させたURLを保存していきます。

with open(str(year)+"-"+str(month)+".txt", mode='w') as f:
    while True:
        time.sleep(5)
        wait.until(EC.presence_of_all_elements_located)
        all_rows = driver.find_element_by_class_name('race_table_01').find_elements_by_tag_name("tr")
        for row in range(1, len(all_rows)):
            race_href=all_rows[row].find_elements_by_tag_name("td")[4].find_element_by_tag_name("a").get_attribute("href")
            f.write(race_href+"\n")
        try:
            target = driver.find_elements_by_link_text("次")[0]
            driver.execute_script("arguments[0].click();", target) #javascriptでクリック処理
        except IndexError:
            break

ファイルをオープンして、得られたURLを行ごとに書き込んで行きます。レースのURLはテーブルの5列目にあるので、配列の要素が0始まりのPythonではfind_elements_by_tag_name("td")[4] などと選択して下さい。

ページ送りをwhileループで行っていきます。最後のページではクリックできなくなるので、tryを使って例外をキャッチしています。

try内のdriver.execute_script("arguments[0].click();", target)の部分ですが、これを単純なtarget.click()にすると、ヘッドレスモードでElementClickInterceptedExceptionが発生しました。
どうやら要素が重なっていると認識されて上手くクリックできなかったようです。こちらに解決方法が乗っていたのですが、上記のようなJavaScriptでのクリック処理にするとうまくできました。

取得したURLをもとにhtmlを得る

先程得られたhtmlは、ページの表示にPHPやJavaScriptなどを大きく利用していないようなので、ここでやっとrequestsを使用していきます。
先程のURLの情報をもとに、htmlを取得して保存するのですが、1ページ取得するごとに数秒待つので結構時間がかかります。

import os
import requests

save_dir = "html"+"/"+str(year)+"/"+str(month)
if not os.path.isdir(save_dir):
    os.makedirs(save_dir)
        
with open(str(year)+"-"+str(month)+".txt", "r") as f:
    urls = f.read().splitlines()
    for url in urls:
        list = url.split("/")
        race_id = list[-2]
        save_file_path = save_dir+"/"+race_id+'.html'
        response = requests.get(url)
        response.encoding = response.apparent_encoding
        html = response.text
        time.sleep(5)
        with open(save_file_path, 'w') as file:
            file.write(html)

文字コードの都合上、素直に取得すると文字化けする可能性があります。response.encoding = response.apparent_encodingとしてやればうまくいきました。
参考: Requestsで日本語を扱うときの文字化けを直す

htmlを解析してcsvを作成する

レースの詳細・各出走馬の情報をcsvにそれぞれします。以下のような形式のcsvを作成することにしました。

  • レース詳細

    • レースID
    • 何ラウンド目か
    • レースのタイトル
    • コースについて
    • 天候
    • 土の状態
    • 日時
    • 競技場
    • 1着から3着までの馬番・枠番
    • 各馬券のオッズ
  • 馬詳細

    • レースID
    • 順位
    • 馬ID
    • 馬番
    • 枠番
    • 性別年齢
    • 負担重量
    • 体重と体重差
    • タイム
    • 着差
    • 上がりのタイム
    • オッズ
    • 人気

他にも取得できる情報はあります。有料会員ではスピード指数と呼ばれるものなども取得できるようです。

import numpy as np
import pandas as pd
from bs4 import BeautifulSoup

CSV_DIR = "csv"
if not os.path.isdir(CSV_DIR):
    os.makedirs(CSV_DIR)
save_race_csv = CSV_DIR+"/race-"+str(year)+"-"+str(month)+".csv"
horse_race_csv = CSV_DIR+"/horse-"+str(year)+"-"+str(month)+".csv"

# race_data_columns, horse_data_columnsは長くなるので省略
race_df = pd.DataFrame(columns=race_data_columns )
horse_df = pd.DataFrame(columns=horse_data_columns )

html_dir = "html"+"/"+str(year)+"/"+str(month)
if os.path.isdir(html_dir):
    file_list = os.listdir(html_dir)
    for file_name in file_list:
        with open(html_dir+"/"+file_name, "r") as f:
            html = f.read()
            list = file_name.split(".")
            race_id = list[-2]
            race_list, horse_list_list = get_rade_and_horse_data_by_html(race_id, html) # 長くなるので省略
            for horse_list in horse_list_list:
                horse_se = pd.Series( horse_list, index=horse_df.columns)
                horse_df = horse_df.append(horse_se, ignore_index=True)
            race_se = pd.Series(race_list, index=race_df.columns )
            race_df = race_df.append(race_se, ignore_index=True )
            
race_df.to_csv(save_race_csv, header=True, index=False)
horse_df.to_csv(horse_race_csv, header=True, index=False)

レースごとに、レースの詳細・各出走馬の情報などをリストに入れて、pandasのデータフレームに1行ずつ追加していきます。

get_rade_and_horse_data_by_html関数や、race_data_columns, horse_data_columnsについては、複雑になってしまうのでここでは載せません。
軽く説明しておくと、get_rade_and_horse_data_by_html関数はBeautifulSoupを利用してhtmlから欲しいデータをリストにして返す関数です。
race_data_columns, horse_data_columnsは取得するデータのカラム名です。

その他注意事項

クローリングをする際は、サーバーへの攻撃とならないように、時間をあけてアクセスするようにしましょう。

他にも法律的な詳しい注意事項をまとめてくださっている方がいるので、実際に行う場合は、Webスクレイピングの注意事項一覧-Qiitaなどを参考にしてください。

データが得られたら整形・分析をやっていく

csv形式のデータを取得するまではできたので、あとはデータを扱いやすい状態にきれいにしましょう。

次にデータの状態を見ながらどのようなモデルを作るか考えます。その後、自分が作成したいモデルに合わせて、trainデータを作成していきましょう。

データを扱いやすく整形する

データを扱いやすい状態に整形して行きましょう。

例えば、文字列の日付データや数値を、datetimeオブジェクトやintに変換する。また、一つのカラムにあるデータはなるべくシンプルな方が今後楽なので、性別と年齢を2つのカラムに分けたりする。
など、やることはたくさんあります。

スクレイピングの際に同時にやってしまったほうが良かったかもしれませんが、スクレイピングのコードが煩雑になりそうだったので今回は分けて行うことにしました。

以下はその一部です。

# 時間情報を抜き出して、日付情報と結合。datetime型にする
race_df["time"] = race_df["time"].str.replace('発走 : (\d\d):(\d\d)(.|\n)*', r'\1時\2分')
race_df["date"] = race_df["date"] + race_df["time"]
race_df["date"] = pd.to_datetime(race_df['date'], format='%Y年%m月%d日%H時%M分')
# もともとのtimeは不要なので削除
race_df.drop(['time'], axis=1, inplace=True)

# 何ラウンド目かのカラムに余分なRや空白・改行が含まれているので取り除く
race_df['race_round'] = race_df['race_round'].str.strip('R \n')

データの分析

整形したデータを分析して、どのような分布になっているのかなどを大まかに確認していきます。モデルの作成をする際は、なるべくデータの偏りがないように学習させる必要があるので、モデルの問題設定にとっても重要です。

また、特徴量をどのようにするかを考える上でもデータの分析は重要になってきます。
ディープラーニング等の場合は特徴量エンジニアリングにそこまでこだわる必要はなさそうですが、LightGBMなどの勾配ブースティングといったディープではない普通の機械学習をする際は、特徴量をどの様にするかをよく考える必要があります。

Kaggleなどでも、良い特徴量を見つけられれば上位に食い込める可能性が上がるみたいですね。

trainデータの作成

先程のデータ分析などを参考にしながら、どのようなモデルを作るか決めたら、trainデータを作成していきましょう。

入力データですが、大体は以下の通りです。

  • 予測したいレースの情報
  • 馬番
  • 枠番
  • 年齢
  • 負担重量
  • 体重
  • 前回からの体重変化
  • 負担重量/体重
  • 性別
  • 距離
  • レースに参加する馬の数
  • 障害レースかどうか
  • 地面の状態
  • 芝かダートか
  • コースが右回りか左回りか直線か
  • 天気
  • 開催地
  • 前回と騎手やオーナーが変わっているかどうか
  • 前回レースとの時間差
  • 過去5レースの情報
  • 予測したいレースの情報と同じ内容
  • 順位
  • ゴールタイム
  • 途中順位の平均
  • 上がりのタイム
  • 平均速度

予測したいレースにおけるオッズは、試合直前まで変動するのでデータに入れないことにします。

いよいよモデル作成(ディープラーニング)

はじめに概要を述べておくと、今回はkerasでディープラーニング行います。
ある馬のデータを入力として、

  • 1位になる確率を予測するモデル
  • 3位以内に入る確率を予測するモデル

の2つを作りました。

モデルをどう決めたか

分類問題・回帰問題のどちらを解くのかを考える必要があります。

回帰問題の場合は、馬が何位になるのか(1.2位みたいなのを許容することになるでしょう)や、タイムなどを予測させることになるかと思います。

分類問題の場合は、馬が何位になるのか(こちらは1~16までの自然数で分類されます)や、1位になるかどうか、上位に入るかどうかなどを予測させることになるでしょう。

タイムや速度は競馬場やコースによって大きく変化するので、分けて予測させないと難しいでしょう。今回は単純に分類問題として「上位に入るかどうか」を予測させます。

モデル作成でやったことや過学習への対応方法など

モデルを作成するにあたって、色々と試したことを書いていきます。

また、モデルを作っても、過学習をさせないような工夫、過学習をしていないかどうかの検証が不可欠です。
機械学習をして、手元のデータで良さそうな結果が出ても、そのモデルが他のデータにも良い精度で予測ができるとは限りません。

学習用とテスト用にデータセットを分割する

まずは基本的なことから。
モデルを作っても、それが良いものかどうかを評価できなければ意味がありません。

収集・整形したデータの80%を学習データとして、20%をテストデータとしました。つまり、

  • 学習データ:2008年1月~2017年7月23日
  • テストデータ: 2017年7月23日~2019年11月あたり

といった形です。はじめに書いた正答率はこのテストデータを用いています。

学習の際は、学習データを更に分割して、train用とvalidation用に分けました。

重みの正則化とドロップアウト

過学習を抑制するための手段として、重みの正則化とドロップアウトがあり、kerasでは簡単にそれらを使用することができます。

ネットワークの損失関数に重みに応じたコストを加えるのが重みの正則化、訓練時に層から出た特徴量をランダムで0にする(落とす)のがドロップアウトです。

重みの正則化にはL2正則化を用いました。

参考: 過学習と学習不足について知る | TensorFlow Core


model = tf.keras.Sequential([
        tf.keras.layers.Dense(300, kernel_regularizer=tf.keras.regularizers.l2(0.001), activation=tf.nn.relu, input_dim=df_columns_len), # l2正則化を加えた層
        tf.keras.layers.Dropout(0.2), # ドロップアウト
        tf.keras.layers.Dense(100, kernel_regularizer=tf.keras.regularizers.l2(0.001), activation=tf.nn.relu), # l2正則化を加えた層
        tf.keras.layers.Dropout(0.2), # ドロップアウト
        tf.keras.layers.Dense(1, activation=tf.nn.sigmoid) 
    ])

クロスバリデーション

特定の期間だけを用いた単純なホールドアウト検証では、たまたまその期間に良い結果がでるように過学習してしまっている可能性があります。

Kaggleなどのコンペでクロスバリデーションが行われているように、手元のデータで良いモデルかどうかの検証をしましょう。

問題となるのは、時系列データなので単純にデータを分割するKFoldではいけないということです。
時系列データを入力とするときは、未来の情報をtrainにして、過去の情報をvalidationにしてしまうと、本来よりも良い結果になってしまうことがあるそうです。実際に、最初間違えて未来のデータを入れて学習させてしまったのですが、複勝の予測確率が7割を超えてしまいました。

そこで今回は、時系列データの交差検証で用いられる分割方法を用いてみました(sklearnのTimeSeriesSplit)。

ざっくりと説明すると、下の図のようにデータセットを時系列を加味して分割して、一部を検証用のデータにします。

この図では3回学習することになりますね。
ただ、一部の学習データが少なくなってしまうので、データ数が少ない場合は単純なホールドアウトの方が良いかもしれません。

tscv = TimeSeriesSplit(n_splits=3)
for train_index, val_index in tscv.split(X_train,Y_train):
    train_data=X_train[train_index]
    train_label=Y_train[train_index]
    val_data=X_train[val_index]
    val_label=Y_train[val_index]
    model = train_model(train_data,train_label,val_data,val_label,target_name)

ハイパーパラメータチューニング

機械学習におけるハイパーパラメータは重要です。
例えば、ディープラーニングでは、間にある層が大きいほど中間変数が多くなり、学習データが少ないと容易にオーバーフッティングしてしまうことになります。逆に小さければ、データ量が十分にあっても柔軟性が足りずに正しく学習できない可能性があります。

やり方については色々と議論が分かれる部分でしょう。これは人によって違うようです。

今回は、hyperasというkerasのパラメータチューニングを自動調整してくれるライブラリがあったのでそれを使ってみることにしました。割と直感的でわかりやすかったです。

簡単な使い方としては、データ準備の関数・trainして最小化させたい値を返す関数の2つをoptim.minimizeに渡すだけです。

調整させたい幅は、整数値ならchoice, 実数ならuniformで指定します。

詳しくはこちらを参考に:https://github.com/maxpumperla/hyperas

import keras
from keras.callbacks import EarlyStopping
from keras.callbacks import CSVLogger
from keras.models import Sequential
from keras.layers.core import Dense, Dropout, Activation

from hyperopt import Trials, STATUS_OK, tpe
from hyperas import optim
from hyperas.distributions import choice, uniform
def prepare_data_is_hukusyo():
    """
    ここでデータの準備を色々やる
    """
    return X_train, Y_train, X_test, Y_test

def create_model_is_hukusyo(X_train, Y_train, X_test, Y_test):
    train_size = int(len(Y_train) * 0.8)
    train_data = X_train[0:train_size]
    train_label = Y_train[0:train_size]
    val_data = X_train[train_size:len(Y_train)]
    val_label = Y_train[train_size:len(Y_train)]

    callbacks = []
    callbacks.append(EarlyStopping(monitor='val_loss', patience=2))

    model = Sequential()
    model.add(Dense({{choice([512,1024])}}, kernel_regularizer=keras.regularizers.l2(0.001), activation="relu", input_dim=train_data.shape[1]))
    model.add(Dropout({{uniform(0, 0.3)}}))
    model.add(Dense({{choice([128, 256, 512])}}, kernel_regularizer=keras.regularizers.l2(0.001), activation="relu"))
    model.add(Dropout({{uniform(0, 0.5)}}))

    if {{choice(['three', 'four'])}} == 'three':
        pass
    elif {{choice(['three', 'four'])}} == 'four':
        model.add(Dense(8, kernel_regularizer=keras.regularizers.l2(0.001), activation="relu"))
        model.add(Dropout({{uniform(0, 0.5)}}))

    model.add(Dense(1, activation="sigmoid"))

    model.compile(
        loss='binary_crossentropy',
        optimizer=keras.optimizers.Adam(),
        metrics=['accuracy'])

    history = model.fit(train_data,
        train_label,
        validation_data=(val_data, val_label),
        epochs=30,
        batch_size=256,
        callbacks=callbacks)

    val_loss, val_acc = model.evaluate(X_test, Y_test, verbose=0)
    print('Best validation loss of epoch:', val_loss)
    return {'loss': val_loss, 'status': STATUS_OK, 'model': model}

# 実際にhyperasで調整する
best_run, best_model = optim.minimize(model=create_model_is_hukusyo,
                                     data=prepare_data_is_hukusyo,
                                     algo=tpe.suggest,
                                     max_evals=15,
                                     trials=Trials())

結果をblendする

様々なモデルから得られた出力を混ぜることで、より良い精度の予測ができることがあります。

1位の予測と、3位以上の予測を平均することで、もともとの予測値よりも若干高い値を得ることができました。

1位になりやすそうな馬の特徴と、上位に入りそうな馬の特徴というのは微妙に異なる可能性があり、両者を混ぜることでより精度の高い予測が可能になっていると考えられます。

例えば、1位になる可能性はあるがレース途中でだめそうだったら無理をしない(させない)馬と、安定して上位に入る馬というのは特徴が少し異なりそうです。

結果

最終的に競馬初心者の自分よりも精度が高いモデルができました。

  • 単勝正解率: 0.2450
  • 複勝正解率: 0.5434

競馬で重要そうな情報はまだまだあるので、改善の余地がありそうですね。

単勝で1位のものを買い続けた時の収支は以下の通り。pandasを使って適当にプロットしました。

image.png

複勝では以下のようになりました。

image.png

大赤字ですね。
予測値の高いものだけを買うようにしたり、低いオッズのものを買わないようにすればもう少しマシになるでしょう。

その他のtips

今回の競馬予測をするにあたって、やってみたことのうち、本筋とは関係の無いことをいくつか残しておきます。

GCPを使う

GCPの無料クレジットの期限が11月末辺りに切れそうだったので、それを消費するのが2つ目の目的でした。

寝る前にプログラムを投げて朝起きたら確認、ということができます。

無料インスタンスではCSVの作成やディープラーニングのためのメモリが足りないので、GCPを使うかたは気をつけて下さい。

LINE Notifyで通知

GCPに関連してですが、プログラムが終了したかや、エラーが起きたかどうかをLINE Notifyで送るようにしていました。

終わったらすぐに結果を確認して、次のプログラムを実行することができるので、かなり捗りました。

終わりにいくつか

学生の適当な余興なので、詳しい方から見ると色々と突っ込みどころがあると思います。間違いなどあれば、勉強になるので、コメントなりツイッターなりで優しくご指摘頂けると嬉しいです。

ツイッターID(あまりつぶやきません):@634kami

ソースコード

github上で公開しています。とりあえず動くものを作りたかったので、あまり人に見せられるものではないのですが、それでも良いとおっしゃる方だけ御覧ください。

Qiita上のコードは見やすくするため一部を変更しています。

改善できそうなところ・やってみたいこと

  • 入力値の欠損データは0埋めしたので、完全なデータだけを予測するようにする
  • 血統データを入れる
  • 騎手データを入れる
  • LightGBMなどの勾配ブースティングを使ってみる
  • 同着レースのスクレイピングに失敗していたので、完全なデータにする
  • etc

その他参考にしたorなりそうなリンク

追記

以下のことを行ったので追記です。

  • trainデータに7頭以下レースの情報が含まれているので削除
  • trianデータから障害レースを削除
  • レース頭数ごとの正解率を算出

以下結果です。

total: 8, random tansyo accuracy:0.125, hukusyo accuracy:0.375
tansyo accuracy: 0.3497536945812808
hukusyo accuracy: 0.7044334975369458

total: 9, random tansyo accuracy:0.1111111111111111, hukusyo accuracy:0.3333333333333333
tansyo accuracy: 0.2693726937269373
hukusyo accuracy: 0.6568265682656826

total: 10, random tansyo accuracy:0.1, hukusyo accuracy:0.3
tansyo accuracy: 0.30563002680965146
hukusyo accuracy: 0.6407506702412868

total: 11, random tansyo accuracy:0.09090909090909091, hukusyo accuracy:0.2727272727272727
tansyo accuracy: 0.2582278481012658
hukusyo accuracy: 0.5468354430379747

total: 12, random tansyo accuracy:0.08333333333333333, hukusyo accuracy:0.25
tansyo accuracy: 0.2600806451612903
hukusyo accuracy: 0.5826612903225806

total: 13, random tansyo accuracy:0.07692307692307693, hukusyo accuracy:0.23076923076923078
tansyo accuracy: 0.2894736842105263
hukusyo accuracy: 0.5855263157894737

total: 14, random tansyo accuracy:0.07142857142857142, hukusyo accuracy:0.21428571428571427
tansyo accuracy: 0.23014586709886548
hukusyo accuracy: 0.5380875202593193

total: 15, random tansyo accuracy:0.06666666666666667, hukusyo accuracy:0.2
tansyo accuracy: 0.2525399129172714
hukusyo accuracy: 0.532656023222061

どの場合でも、完全なランダムな選び方よりも優れた正解率がでました。

さらに追記

以下の点が気になっていたので、Zennへの移行ついでに実験してみることにしました。

  • 競馬場やコースを1つに絞ったほうが精度がよくなるという話を聞いた
  • 馬の血統情報があったほうが予測精度がよくなるのか?
  • ディープラーニングでは精度がでない可能性もあるので、他のモデルも試してみたい

流れ

以下の流れに沿ってデータを取得し、学習をさせます。

  1. 対象レースのURLを取得。東京でダートのみ (変更)
  2. URLからレースのhtmlソースを取得
  3. レースごとに必要な情報をあつめる
    • レース概要
    • 馬ごとの成績
  4. 参加した馬をさかのぼって祖先の馬idを取得し、辞書やcsvで保存 (追加)
  5. 祖先の馬 id から馬の産駒htmlの取得 (追加)
  6. 祖先の馬id から馬の産駒csvの作成 (追加)
  7. データ整形・馬csv,レースcsv,産駒csv を結合 (変更)
  8. lightgbm で学習 (変更)
  9. できたモデルで精度確認 (変更)

東京競馬場・ダートのみに絞る

競馬場やコースを絞って、精度がよくなるのか試してみます。
ダートコースのほうが、芝よりもレースが荒れにくいため予測しやすいとのことなので、今回はダートに絞って予測してみることにしました。

フォームの入力を埋める の部分のコードを一部変更するだけで、

URL = "https://db.netkeiba.com/?pid=race_search_detail"
driver.get(URL)
time.sleep(1)
wait.until(EC.presence_of_all_elements_located)

# 月ごとに検索
year = 2019
month = 1

# 期間を選択
start_year_element = driver.find_element_by_name('start_year')
start_year_select = Select(start_year_element)
start_year_select.select_by_value(str(year))
start_mon_element = driver.find_element_by_name('start_mon')
start_mon_select = Select(start_mon_element)
start_mon_select.select_by_value(str(month))
end_year_element = driver.find_element_by_name('end_year')
end_year_select = Select(end_year_element)
end_year_select.select_by_value(str(year))
end_mon_element = driver.find_element_by_name('end_mon')
end_mon_select = Select(end_mon_element)
end_mon_select.select_by_value(str(month))

# ダートを選択
terms = driver.find_element_by_id("check_track_2")
terms.click()
# 東京競馬場をチェック
terms = driver.find_element_by_id("check_Jyo_05")
terms.click()

ダートコースにも距離が異なるものがいくつかあるようなので、それを絞ったほうが良かったのかもしれませんが、学習データが少なくなりそうだったのでそこは絞りませんでした。
(データを取得する期間を広げれば対処可能なので、ここらへんは改善の余地がありそうです。)

あとは同様の手順でURLを取得したら、html を取得しましょう。東京競馬場だけに絞ってもそこそこ時間がかかります。

参加した馬をさかのぼって祖先の馬idを取得

馬の血統情報を得るために、祖先の馬のid をまず取得します。

対象となるのは、競馬でいうところの「4L」。つまりレースに参加した馬の、 父、父母父、母父、母母父です。
雄馬だけを考慮して、牝馬を考慮しないのは、レースに参加した馬が少ないとかどこかで読んだ気もしますが、あまり詳しくないのでよくわかりません...

血統表のURLは、
 "https://db.netkeiba.com/horse/ped/" + str(horse_id)
となっているので、先程取得したレースに参加した馬の情報に含まれる id から、血統表にアクセスし、祖先の馬 id を選択して取得します。

以下はコードの一部です。

url = "https://db.netkeiba.com/horse/ped/" + str(horse_id)
response = requests.get(url)
response.encoding = response.apparent_encoding  # https://qiita.com/nittyan/items/d3f49a7699296a58605b
html = response.text
time.sleep(1)
# 必要な血統用id を取得
soup = BeautifulSoup(html, 'html.parser')

# 父
furl = soup.select_one("#contents>div.db_main_deta>diary_snap>table>tr:nth-child(1)>td:nth-child(1)>a:nth-child(1)")
if furl is not None:
   f_id = furl.get("href").split(sep='/')[2]
else:
   f_id = None
   	
# 母父
mfurl = soup.select_one("#contents > div.db_main_deta > diary_snap > table > tr:nth-child(17) > td:nth-child(2) > a:nth-child(1)")
if mfurl is not None:
   mf_id = mfurl.get("href").split(sep='/')[2]
else:
   mf_id = None

# 母母父
mmfurl = soup.select_one("#contents > div.db_main_deta > diary_snap > table > tr:nth-child(25) > td:nth-child(2) > a:nth-child(1)")
if mmfurl is not None:
   mmf_id = mmfurl.get("href").split(sep='/')[2]
else:
   mmf_id = None

# 父母父
fmfurl = soup.select_one("#contents > div.db_main_deta > diary_snap > table > tr:nth-child(9) > td:nth-child(2) > a:nth-child(1)")
if fmfurl is not None:
   fmf_id = fmfurl.get("href").split(sep='/')[2]
else:
   fmf_id = None

ped_id_df = ped_id_df.append({'horse_id': horse_id,
                               'f_id': f_id,
                               'mf_id': mf_id,
                               'mmf_id': mmf_id,
                               'fmf_id': fmf_id}, ignore_index=True)
ped_id_set.add(f_id)
ped_id_set.add(mf_id)
ped_id_set.add(mmf_id)
ped_id_set.add(fmf_id)

最終的に得られた祖先の id は Set(ped_id_set) として保存しておき、後で祖先の情報を取得します。
また、レースに参加した馬のidと紐付けられるように、CSV形式でデータも保存します(ped_id_df)。

祖先の馬 id から馬の産駒成績htmlの取得

産駒とは、種牡馬、または種牝馬の子である馬のことらしいです。

種馬としての成績(産駒がどれだけ良い成績だったか)は、
 "https://db.netkeiba.com/horse/sire/" + str(ped_id)
にあります。

さきほど取得した祖先の馬idをもとに、このhtmlを取得しましょう。

save_file_path = SAVE_DIR+"/"+str(ped_id)+'.html'
url = "https://db.netkeiba.com/horse/sire/" + str(ped_id)
if not os.path.isfile(save_file_path):  # まだ取得していなければ取得
    response = requests.get(url)
    response.encoding = response.apparent_encoding 
    html = response.text
    time.sleep(1)
    with open(save_file_path, 'w') as file:
        file.write(html)

馬の数に対して、祖先は重複も多いのかそこまでのデータ量ではありませんでした。

祖先の馬id から馬の産駒csvの作成

さきほど取得したhtmlをスクレイピングします。
産駒がどのレースにどれだけ参加したか、どれだけ勝利したかの情報なども含まれているので、すべての情報を含めるのが良いのかもしれませんが、面倒だったので勝率に変換してデータを学習させることにしました。

種馬としてのランキングという重要そうな情報が含まれているので、こちらもデータに加えます。

trainデータの作成(データ整形・血統情報のマージ)

データ整形に関しては、そこまで大きな変更はありません。
競馬場を東京に制限したり、コースをダートにするなどしたので、いくつかの特徴量が削減できています。

train 用のデータを作成するにあたって、以下のようにしました。

  • 馬ごとの過去3回のレースデータを保持
  • 過去のデータが3回未満である場合は0で穴埋め
  • ped_id で紐付けされた血統の情報をもとに 祖先の産駒成績を merge
  • target はその馬が、そのレースにおいて何着になったかを3つに分類(着順上位(13着)/中位(47着)/下位(8~)の予測)

レースデータを過去3回分にしたのは、レースを東京競馬場のダートのみに絞ったせいで、多くのレースに参加している馬が少なかったからです。
3つに分類することにしたのは、全体に占める割合が近くなるのでモデルが学習しやすくなると思ったからです。(第7回 競馬予測を機械学習で解くための方法と評価方法] などが参考になりました。

LightGBM で学習

lgbm_params = {
        # 多値分類問題
        'objective': 'multiclass',
        # クラス数は 3
        'num_class': 3,
        'metric': {'multi_logloss','multi_error'},
        'seed':0,
        'learning_rate' :0.02,
        'num_leaves': 32
    
}

from sklearn.metrics import accuracy_score

# データセットを生成
lgb_train = lgb.Dataset(X_train, y_train)
lgb_eval = lgb.Dataset(X_eval, y_eval, reference=lgb_train)

# モデルを学習する
model = lgb.train(lgbm_params, lgb_train,
                      valid_sets=lgb_eval,
                      num_boost_round=1000,
                      early_stopping_rounds=100,
                      verbose_eval=10)

y_pred_proba = model.predict(X_valid, num_iteration=model.best_iteration)
y_pred = np.argmax(y_pred_proba, axis=1)

accuracy = accuracy_score(y_valid, y_pred)
print(accuracy)

結果は以下のようになりました。

[LightGBM] [Info] Total Bins 13739
[LightGBM] [Info] Number of data points in the train set: 33715, number of used features: 121
[LightGBM] [Info] Start training from score -1.620623
[LightGBM] [Info] Start training from score -1.333691
[LightGBM] [Info] Start training from score -0.618556
Training until validation scores don't improve for 100 rounds
[10]	valid_0's multi_error: 0.453062	valid_0's multi_logloss: 0.97627
[20]	valid_0's multi_error: 0.453062	valid_0's multi_logloss: 0.961908
[30]	valid_0's multi_error: 0.453062	valid_0's multi_logloss: 0.952035
[40]	valid_0's multi_error: 0.440902	valid_0's multi_logloss: 0.944984
[50]	valid_0's multi_error: 0.436601	valid_0's multi_logloss: 0.939926
[60]	valid_0's multi_error: 0.430965	valid_0's multi_logloss: 0.936094
[70]	valid_0's multi_error: 0.426961	valid_0's multi_logloss: 0.933152
[80]	valid_0's multi_error: 0.42533	valid_0's multi_logloss: 0.930866
[90]	valid_0's multi_error: 0.425478	valid_0's multi_logloss: 0.929086
[100]	valid_0's multi_error: 0.42622	valid_0's multi_logloss: 0.9276
[110]	valid_0's multi_error: 0.425478	valid_0's multi_logloss: 0.926378
[120]	valid_0's multi_error: 0.425478	valid_0's multi_logloss: 0.925394
[130]	valid_0's multi_error: 0.426961	valid_0's multi_logloss: 0.92425
[140]	valid_0's multi_error: 0.427406	valid_0's multi_logloss: 0.923461
[150]	valid_0's multi_error: 0.42711	valid_0's multi_logloss: 0.922703
[160]	valid_0's multi_error: 0.42711	valid_0's multi_logloss: 0.922141
[170]	valid_0's multi_error: 0.427406	valid_0's multi_logloss: 0.921618
[180]	valid_0's multi_error: 0.427851	valid_0's multi_logloss: 0.921208
Early stopping, best iteration is:
[83]	valid_0's multi_error: 0.424144	valid_0's multi_logloss: 0.930171
0.5545670225385528

正解率 55% 程度と、ランダムよりは良さそうなモデルができました。
これが回収率100%を超えるかは別の話なので、別途検証が必要です。

LightGBMは、分類のために使った特徴量の重要度が出力できるのでその結果です。

特徴量が多すぎて見にくいですが、重要だったのは、

  • 前のレース結果
  • 何頭でレースしたか
  • 年齢
  • 前回レースからの期間
  • 父の種馬の産駒成績(ダート)

などのようです。

分類問題として解く以上、何頭でレースをしたかというのは確かに重要そうです。参加する馬の頭数を決めたほうが良かったかもしれません。

また、書いている途中に気がついたのですが、産駒成績を含めると、未来の情報が一部リークしていることになり、このデータは不適切です!!
血統情報を組み込むためには、レース開始前での産駒成績を取得する必要がありますが、かなり難しいことがわかります。

検証

簡単にですが、できたモデルを検証してみました。やはり回収率100%を超えることはできませんでした。

改善案・思ったこと

  • 産駒成績を含めると、未来の情報が部分的にリークしている!
  • 距離も含めてコースを絞る(東京・ダート・1700m など)
  • ダートではなく芝で試してみる
  • LightGBM だけではなく、他のモデルも試してみる
  • 血統情報の結合の仕方を変えてみる
  • 予測モデルのtargetを変更してみる(1位を当てる、上位入賞を当てる)
  • スピード指数など、新しい特徴量を加えてみる