AI予想システム ORACLE PRO を個人開発した記録 #2 – データ収集とスクレイピング設計

AI開発

はじめに

前回の記事では、AI予想システム ORACLE PRO の概要をお話ししました。今回は実装の出発点である「データ収集」について書いていきます。

機械学習モデルを動かす前に必要なのは、当たり前ですが学習用のデータです。競馬予想モデルの場合、必要になるのは過去のレース結果と出馬表のデータ。これをどうやって集めるか、どこに溜めるか、どう更新するか。ここを設計しないと、後から痛い目を見ます。実際、今もたまに痛い目に遭っています。

この記事では、ORACLE PRO で実際に採用している構成と、開発の中で得た知見を書いておきます。

データ収集の設計思想

最初に決めたのは、3つのシンプルなルールでした。

  1. データソースは1つに絞る
  2. 取得は冪等(べきとう)に
  3. 検証可能な状態を保つ

データソースを複数にすると、競合や整合性チェックが地獄になります。今回は競馬データに定評のある netkeiba(ネットケイバ)一本に絞りました。レース結果、出馬表、馬の血統データまで一通り取れる点が大きいです。

冪等性、つまり「何回実行しても同じ結果になる」ことは、スクレイピングでは特に重要です。途中で失敗してリトライしたときに、データが二重に入ったり消えたりすると、それを追いかけるだけで時間が溶けます。

検証可能性は、後で「あれ、このデータ本当に取れてる?」と確認できる状態のことです。これが疎かだと、モデルの精度が悪いときに「データのせい」なのか「特徴量のせい」なのか「モデルのせい」なのか切り分けられなくなります。

技術選定: Selenium と SQLite

ORACLE PRO のスクレイパーは Python + Selenium で書いています。データの保存先は SQLite。

なぜ Selenium か

候補は他にもありました。requests + BeautifulSoup は軽くて速い。scrapy は本格的なクローラ向け。Playwright は最近の主流。

それでも Selenium を選んだ理由は、netkeiba の一部ページが JavaScript で動的に描画される構造になっていたからです。実際に運用してみると、開発者ツールでネットワークを見ながら API エンドポイントを掘り当てる方が早かった場面もあったので、最初から Playwright で組んでも良かったかもしれません。ただ、Selenium には豊富な情報と Stack Overflow の知見の蓄積という強みがあります。詰まったときに調べやすい。これは個人開発では大きな価値です。

なぜ SQLite か

データベースは PostgreSQL や MySQL も検討しました。最終的に SQLite にした理由は3つです。

  • ファイル1つで完結: バックアップが cp で済む
  • サーバープロセス不要: PC 起動だけで動く、運用が楽
  • 十分速い: 約100万件程度なら全く問題ない

将来的にデータ量が増えたら PostgreSQL への移行を考えればいいだけで、現時点では SQLite で何の不便もありません。むしろローカル開発の機動力が抜群です。

データベース設計

ORACLE PRO のデータベース構成は、ざっくり以下のテーブルで成り立っています。

  • race_raw: 過去レース結果(出走全頭、約88万件)
  • today_entry: 当日 + 翌日 + 翌々日の出馬表
  • horse_stats: 馬ごとの集計統計(約7.7万頭)
  • keiba_predict: ML モデルの予測結果

race_raw には日付、競馬場、レース番号、馬名、騎手、着順、タイム、上がり3F、通過順位、馬体重など、レース1つで取れる項目をほぼ全部入れています。データ容量よりも、後から「あの情報も欲しかった」と気づいて再取得する手間のほうがはるかに痛い。なので「取れるものは全部取る」方針です。

today_entry は予想用の出馬表データ。確定済みの枠順、騎手、斤量、想定オッズなどを保持します。レース当日の朝までに揃っていれば良いのですが、ここで盲点があって、それは後で書きます。

horse_stats は馬ごとの過去成績集計です。勝率、複勝率、得意距離、得意馬場、平均上がりタイムなどを事前計算しておきます。レース予測のたびに集計し直すと遅いので、バッチで先回りして計算しておく構造です。

スクレイピング戦略

ORACLE PRO のスクレイパーには2つのモードがあります。

python keiba_scraper.py --today
python keiba_scraper.py --from-date 20251001 --to-date 20251231

--today は当日 + 翌日 + 翌々日の出馬表を取りに行きます。これを朝のバッチで毎日回します。--from-date / --to-date は過去レースの結果取得用。データの欠落補完や、新規導入時の一括取得に使います。

過去レースと当日レースで取得ロジックが分かれているのは、URL 構造とページ構造が違うからです。過去レースは結果が確定しているので「レース結果ページ」、当日レースはまだ走っていないので「出馬表ページ」。同じ馬データを取るにも、見るページが全く違います。

落とし穴と教訓

ここからが今回の本題かもしれません。実装は動くのですが、運用していると思わぬバグが出ます。

落とし穴1: 開催日の特定が意外と難しい

JRA の競馬開催は「第N回阪神 X日目」のような単位で表現されます。レースID もそれに連動していて、たとえば 202509050611 という ID は「2025年・5回阪神・6日目・11レース目」を意味します。

問題は、この「日目」をスクレイパー側で正確に算出するロジックです。週末2日開催なら土曜と日曜で +1 ずつ進む、というシンプルなルールに見えますが、開催延期や祝日が絡むと崩れます。

私のスクレイパーには、ある時期からフォールバック関数が仕込まれていました。「前週の日目から推測して、その日のレースIDを組み立てる」という処理です。これが原因で、ある日突然「2025年12月21日の朝日FS(5回阪神6日目)」が DB に存在しない、という事件が発生しました。

調べてみると、フォールバック関数が 日目=05(つまり12月20日のレース)を 12月21日 扱いで保存していたのです。日付照合のロジックが甘く、ページ内に偶然「12月21日」の文言が含まれていただけで通過してしまっていました。

落とし穴2: 馬名の表記揺れ

これも実装してから気づきました。netkeiba は同じ馬を、ページによってカタカナ表記と英語表記で出してきます。たとえばカヴァレリッツォは、あるページでは カヴァレリッツォ、別のページでは Cavallerizzo

スクレイパー側で表記を統一する処理を入れていなかった時期があり、結果として2025年のデータの15〜25% が英語名で保存されていました。同じ馬が日本語名と英語名で別レコード扱いになって、馬別成績集計が壊滅状態。

これを発見したのは「カヴァレリッツォの過去成績を取得したら、皐月賞しか出てこない」という違和感からでした。後から見直すと、英語名の方には朝日FS優勝の記録が残っていたのです。

落とし穴3: DB ロックと並行実行

これは設計時から想定していた話ですが、SQLite はライターが1つしか持てません。スクレイピング中に Flask API から DB を読みに行くとロックを取り合います。timeout=30 等の引数で待たせる対応は基本ですが、本気で並行制御するなら PostgreSQL や WAL モードの検討も必要です。

バグを直したついでに、運用も見直した

今回のバグ修正で入れた改修は、結果的に「データ品質の担保」という観点で大事な変更でした。

  • フォールバック関数の delta 範囲を拡大(+1 だけでなく +10 まで試す)
  • リトライ回数を増強(3回、各 wait=10秒、間に 5秒 sleep)
  • --force オプション追加: 既存データを DELETE してから INSERT
  • DELETE はトランザクション化(失敗時に ROLLBACK)

これで 2025年10月〜12月の3ヶ月分を再取得し、欠落していた朝日FS や英語名問題のレコードを正しい日本語名で再登録できました。

教訓は3つです。過去データの取得は、取得日と実日付の照合を厳密にやる。表記揺れは取得元のせいにせず自分で吸収する。再取得バッチを最初から用意しておく。

おわりに(次回予告)

データを集めるだけで、ここまで書くことがあります。実際に手を動かすと、「スクレイピングってちゃんと動かすの大変だな」と毎回思います。動いているように見えて、データの中身は微妙に間違っている、というのが一番怖いパターンです。

次回 #3 は、集めたデータをどう「特徴量」に変換していくかを書きます。血統情報、騎手相性、コース適性、追い切り評価。LightGBM で 247 個の特徴量を作っていますが、その設計思想と、効いた特徴量・効かなかった特徴量の話をする予定です。

ここまで読んでくれてありがとうございました。

コメント

タイトルとURLをコピーしました