0
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

競馬予想AIを作りたい(データ収集編)

Last updated at Posted at 2024-05-19

まえがき

とりあえず作ってみようのノリなので、精度とかはそれほど考えてません。
netkeiba.comに記録されている全レース(約16.8万レース)を収集する関係でハチャメチャに時間がかかるので、参考にする人は気をつけてください。

【追記】
馬柱が表示され始めたのは2007年の後半からだったので、最終的には2008年からのデータのみを使うことにした。

環境は以下の通り。

  • Windows10
  • Python3.12

集めるデータ

今回は、レース情報から各馬の着順を予想するAIを目指す。

人間(一般人)が予想に使うデータを考えると、以下の要素に主に注目するはず。

  • 人気
  • 単勝オッズ
  • 馬番
  • 距離
  • レース場
  • 芝 or ダート
  • 馬場
  • 直近の成績
  • ジョッキー
  • 馬主
  • 血統
  • 前走からの期間

これらのうち、今回は以下のデータを収集する。

  • 人気
  • 単勝オッズ
  • 馬番
  • 直近の成績(直近5レース分)
  • 前走からの期間(日数)

芝 or ダート、距離を選ばない理由は、直近5レースと大きく変わることは少ないだろうから。
馬場を選ばない理由は、よくわからないあまり影響しない気がするから。
レース場、ジョッキー、馬主、血統を選ばない理由は、学習時にはユニークIDにする必要があるから。
ユニークIDにしたところで、AI内部では条件分岐とかしないので無駄な気がする。(IDの大小と影響力に相関が無い)

データスクレイピングの方針

今回は中央競馬の全レースを対象としてスクレイピングする。スクレイピングするサイトはnetkeiba.comとする。レース情報が一通り取得できるので選択した。

このサイトから、次の2段階に分けてデータを収集する。

  1. レースIDを収集
  2. レース情報を収集

レース情報を閲覧するには、以下のようなページにアクセスする必要がある。

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万レース分しか終わってない。
まぁ、気長に集めましょう。

0
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?