まえがき
とりあえず作ってみようのノリなので、精度とかはそれほど考えてません。
netkeiba.comに記録されている全レース(約16.8万レース)を収集する関係でハチャメチャに時間がかかるので、参考にする人は気をつけてください。
【追記】
馬柱が表示され始めたのは2007年の後半からだったので、最終的には2008年からのデータのみを使うことにした。
環境は以下の通り。
- Windows10
- Python3.12
集めるデータ
今回は、レース情報から各馬の着順を予想するAIを目指す。
人間(一般人)が予想に使うデータを考えると、以下の要素に主に注目するはず。
- 人気
- 単勝オッズ
- 馬番
- 距離
- レース場
- 芝 or ダート
- 馬場
- 直近の成績
- ジョッキー
- 馬主
- 血統
- 前走からの期間
これらのうち、今回は以下のデータを収集する。
- 人気
- 単勝オッズ
- 馬番
- 直近の成績(直近5レース分)
- 前走からの期間(日数)
芝 or ダート、距離を選ばない理由は、直近5レースと大きく変わることは少ないだろうから。
馬場を選ばない理由は、よくわからないあまり影響しない気がするから。
レース場、ジョッキー、馬主、血統を選ばない理由は、学習時にはユニークIDにする必要があるから。
ユニークIDにしたところで、AI内部では条件分岐とかしないので無駄な気がする。(IDの大小と影響力に相関が無い)
データスクレイピングの方針
今回は中央競馬の全レースを対象としてスクレイピングする。スクレイピングするサイトはnetkeiba.comとする。レース情報が一通り取得できるので選択した。
このサイトから、次の2段階に分けてデータを収集する。
- レースIDを収集
- レース情報を収集
レース情報を閲覧するには、以下のようなページにアクセスする必要がある。
https://race.netkeiba.com/race/shutuba.html?race_id=202305021011&rf=race_submenu
URLを見るとrace_id=202305021011
というパラメータが存在する。このrace_id
はただの日付ではないので、総当たり的なことは難しい(というかカッコ悪いのでヤダ)。
ということで、事前にレースIDを全て収集しておき、そのIDを順番に使いながら予想に使うデータを集めることにする。
レースIDを収集
スクレイピングのアプローチを検討
全レースを検索
レースIDを収集するため、レース検索サービスを使用する。
中央のレース場を全て選択し、期間についても初年度っぽい1975年1月から2022年12月までとする。(2023年以降はテスト用に残しておく)
これで検索すると160,886レース
あることが確認できる。
これらを全て記録していくためにURLを分析する。
最初のページURLを見ると以下のようになっているが、このままではページ番号の指定ができないので、seleniumが必要になってしまう。
https://db.netkeiba.com/?pid=race_list&word=&start_year=1975&start_mon=1&end_year=2022&end_mon=12&(省略)&sort=date&list=100
しかし、往々にしてURLにはページ番号が付くもの。2ページ目を開いてURLを見直してみるとアラ不思議。
https://db.netkeiba.com//?(省略)&list=100&page=2
という感じで、末尾にpage=2
という表示が確認できる。このパラメータを1
に変えてアクセスしてみると1ページ目が表示されたので、これでrequests.get
で読み込めそうだと分かる。
また、何ページまであるか確認するために計算すると、1609ページになる。
以上のことから、1ページ目から1609ページ目までをスクレイピングすれば良いと分かった。
レースIDを取得するには、各レースのURLを解析する。
デベロッパツールでレース名を選択すると、以下のようにhref
で指定されていることが確認できる。
<a href="/race/202209060605/" title="2歳新馬">2歳新馬</a>
この/race/202209060605/
の202209060605
がレースIDとなるので、これを片っ端から収集する。
そのURLにアクセスすれば良いのでは?
開けば分かるけど、レース結果しか無いので直近5レースの情報が得られない。そのため、一旦レースIDを記録しておいて、あとからレースごとに解析する。
解析プログラム
以下のコマンドで必要なライブラリをインストールしておく。
pip install requests beautifulsoup4
まずはHTMLを読み込むプログラムから記述する。
import requests as req
from bs4 import BeautifulSoup
def parseHTML(url):
res = req.get(url)
res.encoding = "euc-jp"
bs = BeautifulSoup(res.text, features="html.parser")
return bs
url = "https://db.netkeiba.com//?pid=race_list&word=&start_year=1975&start_mon=1&end_year=2022&end_mon=12&jyo%5B0%5D=01&jyo%5B1%5D=02&jyo%5B2%5D=03&jyo%5B3%5D=04&jyo%5B4%5D=05&jyo%5B5%5D=06&jyo%5B6%5D=07&jyo%5B7%5D=08&jyo%5B8%5D=09&jyo%5B9%5D=10&kyori_min=&kyori_max=&sort=date&list=100&page={}"
bs = parseHTML(url.format(1))
print(bs.body)
以下の部分で読み込むページ番号を指定している。requests.get
でHTMLをeuc-jp
で読み込み、BS4でHTMLをパースしている。
bs = parseHTML(url.format(1))
今度はレース一覧のテーブルから1行ずつ読み込む必要がある。
デベロッパツールを使って解析すると、目的のテーブルはrace_table_01
というclass属性を持っていることが確認できるため、以下のように書くことで抽出できる。
table = bs.body.find("table", class_="race_table_01")
あとは1行ずつ読み込んでから、目的の列情報を取ってくるだけ。
trs = table.find_all("tr")[1:]
for tr in trs:
title = tr.find_all("td")[4]
raceID = title.find("a").get("href").split("/")[-2]
print(raceID)
これで1ページ目のレース100件のIDを一通り取得できた。他のページについてはループでぶん回せば取得可能。
あとは取得したレースIDをテキストファイルなどに記録しておく。
レース情報を取得
(再掲)以下の情報をスクレイピングしていく。
- 人気
- 単勝オッズ
- 馬番
- 直近の成績(直近5レース分)
- 前走からの期間(日数)
これらに加えて、着順の情報も集める。
スクレイピングのアプローチを検討
まず着順と馬番、人気と単勝オッズから収集する。これは以下のURLのレース結果から取得する。
https://db.netkeiba.com/race/201608030901
このURLは、末尾にレースIDが付くことが分かる。レースIDはさっき集めたものを使う。
次に直近5レースの成績を収集する。
これは馬柱と呼ばれる情報であり、以下のURLから取得できる。
https://race.netkeiba.com/race/shutuba_past.html?race_id=201608030901&rf=shutuba_submenu
このURLもド直球にrace_id=
なんて書いてくれてるので分かりやすい。
ただし、このページは動的生成なのでseleniumが必要である。
解析プログラム
まずはseleniumをインストールする。
pip install selenium
あとはseleniumのセットアップを以下のプログラムで行う。
from selenium import webdriver
from time import sleep
from datetime import datetime
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
driver = webdriver.Chrome(options=options)
着順などをスクレイピングする
レースによっては出走中止や取り消し、除外などによって一部情報が取得できない。
そういった欠落情報は全て0で埋める。
raceResult = "https://db.netkeiba.com/race/{}/"
raceID = "202405021001"
# レースごとの馬情報を辞書で管理
raceInfo = {}
for i in range(1, 19):
raceInfo[i] = {
"着順": 0,
"単勝": 0.0,
"人気": 0,
"前走からの日数": 0,
"前走順位": 0,
"2走順位": 0,
"3走順位": 0,
"4走順位": 0,
"5走順位": 0
}
rankingBS = parseHTML(raceResult.format(raceID))
# レース開催日を取得
raceDay = rankingBS.body.find("p", class_="smalltxt").text.split(" ")[0]
raceDay = datetime.strptime(raceDay, "%Y年%m月%d日")
# 着順、馬番、単勝、人気を取得
table = rankingBS.body.find("table", class_="race_table_01")
trs = table.find_all("tr")
for tr in trs[1:]:
tds = tr.find_all("td")
number = int(tds[2].text) # 馬番
if tds[0].text.isdigit():
raceInfo[number]["着順"] = int(tds[0].text)
raceInfo[number]["単勝"] = float(tds[12].text)
raceInfo[number]["人気"] = int(tds[13].text)
else:
if tds[0].text in ["除", "取"]:
raceInfo[number]["人気"] = 0
else:
raceInfo[number]["人気"] = int(tds[13].text)
raceInfo[number]["着順"] = 0
raceInfo[number]["単勝"] = 0.0
直近5レース(馬柱)を取得
このページは、前述のとおりseleniumが必要となる。
馬柱テーブルが動的生成されるため、1秒待ってからHTMLを取得している。
このページで前走の日付も取得できるので、さっき抽出したレース日との差分を取って前走からの期間(日数)
を求めている。
hasira5 = "https://race.netkeiba.com/race/shutuba_past.html?race_id={}&rf=shutuba_submenu"
driver.get(hasira5.format(raceID))
sleep(1)
hasiraBS = BeautifulSoup(driver.page_source, features="html.parser")
body = hasiraBS.body
table = body.find("table", class_="Shutuba_Past5_Table").find("tbody")
trs = table.find_all("tr")
for tr in trs:
tds = tr.find_all("td")
number = int(tds[1].text)
zenso = tds[5:10]
for i in range(5):
if zenso[i].get("class")[0] == "Past":
data = zenso[i].find("div", class_="Data_Item")
data1 = data.find("div", class_="Data01")
data1Span = data1.find_all("span")
lastDay = data1Span[0].text.split(" ")[0]
data1Rank = data1Span[1].text
data1Rank = int(data1Rank) if data1Rank.isdigit() else 0
raceInfo[number][hasiraTitle[i]] = data1Rank
del data, data1, data1Span, data1Rank
# 前走からの経過日数を計算
if i == 0:
lastDay = datetime.strptime(lastDay, "%Y.%m.%d")
dd = raceDay - lastDay
raceInfo[number]["前走からの日数"] = dd.days
del lastDay, dd # メモリリーク対策
# メモリリーク対策に、不要なものは削除
del number, raceDay, trs, tds, zenso
del rankingBS, hasiraBS, body, table
レース情報を保存
あとは順番に保存するだけ。
# レース情報を保存
with open(f"race_data/{raceID}.csv", "w", encoding="utf-8") as f2:
f2.write("着順,馬番,単勝,人気,前走からの日数,前走順位,2走順位,3走順位,4走順位,5走順位\n")
for i in range(1, 19):
row = f"{raceInfo[i]["着順"]},"
row += f"{i},"
row += f"{raceInfo[i]["単勝"]},"
row += f"{raceInfo[i]["人気"]},"
row += f"{raceInfo[i]["前走からの日数"]},"
row += f"{raceInfo[i]["前走順位"]},"
row += f"{raceInfo[i]["2走順位"]},"
row += f"{raceInfo[i]["3走順位"]},"
row += f"{raceInfo[i]["4走順位"]},"
row += f"{raceInfo[i]["5走順位"]}\n"
f2.write(row)
del raceInfo, row
driver.close()
あとがき
これで全160,886レースの収集が可能となる。
ただ、1万レースくらいごとにseleniumの読み込みが固まって無限Loading編が始まるし、1件ごとに数秒かかるので死ぬほど時間がかかる。
執筆時点で24時間が経過したが、未だに2.3万レース分しか終わってない。
まぁ、気長に集めましょう。