はじめに
前回の記事では、AI予想システム ORACLE PRO の概要をお話ししました。今回は実装の出発点である「データ収集」について書いていきます。
機械学習モデルを動かす前に必要なのは、当たり前ですが学習用のデータです。競馬予想モデルの場合、必要になるのは過去のレース結果と出馬表のデータ。これをどうやって集めるか、どこに溜めるか、どう更新するか。ここを設計しないと、後から痛い目を見ます。実際、今もたまに痛い目に遭っています。
この記事では、ORACLE PRO で実際に採用している構成と、開発の中で得た知見を書いておきます。
データ収集の設計思想
最初に決めたのは、3つのシンプルなルールでした。
- データソースは1つに絞る
- 取得は冪等(べきとう)に
- 検証可能な状態を保つ
データソースを複数にすると、競合や整合性チェックが地獄になります。今回は競馬データに定評のある 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 個の特徴量を作っていますが、その設計思想と、効いた特徴量・効かなかった特徴量の話をする予定です。
ここまで読んでくれてありがとうございました。

コメント