メインコンテンツへスキップ
見出し画像

【競馬AI開発#57】データ収集を「コマンド一発」で運用できるスクリプトを作成する

    この【競馬AI開発】シリーズでは、競馬予想AIを作ることを通して、機械学習・データサイエンスの勉強になるコンテンツの発信や、筆者が行った実験の共有などを行っています。

    ‍■今回やること
    前回の記事では、競馬AIの予測に必要なデータをnetkeiba.comから集めてくる仕組みについて、‍「期間を指定するだけで、今どこまで取得できているか・次に何を取りに行くべきかを自動で判断してくれる」‍notebookに作り直しました。

    今回はその仕上げで、‍このデータ収集処理を「コマンド一発で完結するシステム」に完成させていきます。これまではnotebook上でセルを上から順に実行していく必要がありましたが、これからは次の1行をターミナル上で実行するだけで、データ収集がすべて完了します。

    # 2020年1月〜2025年12月のデータをまとめて取得する
    uv run python common/src/collect_data.py --from-ym 2020-01 --to-ym 2025-12

    また、--status を付けて実行すると、「今どこまでデータが取れているか」という期間内のデータ取得状況を一覧表示することができます。

    # 取得状況だけを確認する(スクレイピングはしない)
    uv run python common/src/collect_data.py --status --from-ym 2020-01 --to-ym 2025-12

    出力例:

    === 2020-01 〜 2025-12 のデータ取得状況 ===
    
    [race_id] 開催 race_id 一覧 (race_id_list.csv)
      チェック内容: 期間内の各月について race_id_list.csv に該当月の race_id が登録されているか
        対象: 72 ヶ月 (2020-01 〜 2025-12)
        登録済月数: 72 / 72 (100.00%)
        期間内 race_id: 20,733 件
    
    [race] レースページからのデータ取得状況 (results / race_info / return_tables)
      チェック内容: 期間内の全 race_id が各 csv に登録されているか
        対象 race_id: 20,733 件
        results.csv: 20,733 / 20,733 (100.00%)
        race_info.csv: 20,733 / 20,733 (100.00%)
        return_tables.csv: 20,733 / 20,733 (100.00%)
    
    [horse] 馬の過去成績 (horse_results.csv)
      チェック内容: 期間内に出走する horse_id が最新まで取得済か
        対象 horse_id: 35,891 種
        最新まで取得済: 35,878 / 35,891 (99.96%)
        古い (要再取得): 2 件
        未取得: 11 件
    
    [ped] 血統 (peds.csv)
      チェック内容: 期間内に出走する horse_id が peds.csv に登録済か
        対象 horse_id: 35,891 種
        取得済: 35,891 / 35,891 (100.00%)
    
    [leading] リーディング (jockey / trainer / sire)
      チェック内容: 期間内の各年×タイプ について page_id が rawdf に登録済か
        対象年: 6 年 (2019 〜 2024) ※後段で前年データとして使用
        jockey_leading.csv: 6 / 6 年
        trainer_leading.csv: 6 / 6 年
        sire_leading.csv: 6 / 6 年

    本記事では、この新しい collect_data.py の使い方・実装方法を1から解説していきます。

    ‍■競馬AIについて
    netkeiba.comから収集したデータを機械学習モデルに学習させることで期待リターンを計算し、リターンが最大化するように自動投票を行う競馬予想AIを開発しています。

    すでにシステムとしては一通り完成しており、現在はこの完成したシステムをベースに、特徴量の追加やモデルの改善など、さらなる精度向上に取り組んでいる段階です。

    このシリーズでは、こうしたシステムをゼロから構築する過程を、コード付きで詳しく解説しています。ソースコードは、下に進んでダウンロード・解凍すれば使うことができるので、ぜひ実際にコードを動かしてみてください。

    1. なぜ、データ収集をスクリプト化するのか

    1.1. notebook(.ipynb)とスクリプト(.py)の使い分け

    そもそも、なぜnotebookをスクリプトに作り変えるのでしょうか。
    notebook形式でのコーディングに慣れた方にとっては、動機がイマイチ分かりづらいかもしれないので、少し丁寧に説明します。

    画像

    ‍◆Notebook(.ipynb)が得意なこと
    ノートブックは、セルを一つずつ実行しながら、出力を見て次の手を考えられる「対話的(インタラクティブ)」なツールです。開発時の試行錯誤段階では、

    • レスポンス(取得したHTML)の中身を1セルずつ確認したい。

    • 取得した直後のDataFrameをその場で表示して、列の中身を見たい。

    • 一部のセクションだけ実行して、エラーが出たら原因を調べたい。

    といった「探索的な使い方」が欲しくなります。このような場合に、ノートブックは非常に強力なツールとなります。

    一方で裏を返すと、セルをどの順番でも実行できてしまうため、実行したい処理が決まった後のワークフローとしては再現性の担保がしづらい・ユーザーが実行判断を迷いやすいといったデメリットがあります。

    ‍◆スクリプト(.py)が得意なこと
    これに対してスクリプト(.py)は、‍常に上から順に・一本道で実行されるので再現性を担保しやすく、システムに組み込みやすいというメリットがあります。
    特に最近では大きなメリットとなるのが、Claude Code等のAIエージェントからも呼び出しやすくなる点で、スクリプト化(CLI化)しておくと実行・モニタリング・デバッグ等をAIに任せることもできます。

    つまり、‍やり方をまだ探っている処理はnotebook、手順が固まった定型処理はスクリプト、という使い分けが基本になります。データ収集は「やるべきことが固まっていて、欠損や重複なく回したい処理」なので、まさにスクリプト化に向いた題材というわけです。

    1.2. 今回のゴール

    上記を踏まえ、今回作るデータ収集スクリプト( collect_data.py )では、以下のことを実現したいと思います。

    【ゴール】
    どんな状況(新規実行時・再開時)でも「取得したい期間」のみ指定して1行だけコマンドを実行すれば、追加で必要なデータを判定し、自動で集めてくれる仕組みを完成させる

    2. スクリプト版「データ取得システム」の使い方

    具体的な実装方法は後ほど解説するとして、まずは新設したスクリプト版データ取得システム(collect_data.py) の使い方を解説しつつ、完成イメージを見ていきます。

    2.1. 実行の前提と、3つの引数

    本プロジェクトはパッケージ管理に uv を使っているので、実行は次の形になります。

    uv run python common/src/collect_data.py [オプション]

    uvの導入方法・使い方については以下の記事で詳しく解説しているので、参考にしてください。

    まずは、どんな引数(オプション)が使えるのかを確認しましょう。‍--help を付けて実行すると、使い方の一覧が表示されます。

    uv run python common/src/collect_data.py --help

    出力:

    usage: collect_data.py [-h] --from-ym YYYY-MM [--to-ym YYYY-MM] [--status]
    
    netkeiba データ収集
    
    options:
      -h, --help         show this help message and exit
      --from-ym YYYY-MM  取得開始月 (yyyy-mm, 例: 2020-01)
      --to-ym YYYY-MM    取得終了月 (yyyy-mm)。省略時は前月。当月以降は指定不可。
      --status           現在のデータ取得状況 (show_status) のみ表示して終了する

    角括弧で囲まれている引数は「省略可能」という意味で、--from-ym だけは角括弧が付いておらず、これは‍省略できない必須の引数であることを表しています。

    画像

    2.2. まずは現在の状況を見る(--status)

    何かを取得する前に、「いま何がどこまで取れているのか」を確認してみましょう。‍--status を付けると、状況だけを表示して終了します。

    ここでは例として、2025年9月分について「まだ何も取得していない状態」で実行したとします。

    uv run python common/src/collect_data.py --from-ym 2025-09 --to-ym 2025-09 --status

    出力:

    対象期間: 2025-09 〜 2025-09
    === 2025-09 〜 2025-09 のデータ取得状況 ===
    
    [race_id] 開催 race_id 一覧 (race_id_list.csv)
      チェック内容: 期間内の各月について race_id_list.csv に該当月の race_id が登録されているか
        対象: 1 ヶ月 (2025-09 〜 2025-09)
        登録済月数: 0 / 1 (0.00%)
        未取得月: 2025-09
    
    [race] レースページからのデータ取得状況 (results / race_info / return_tables)
      チェック内容: 期間内の全 race_id が各 csv に登録されているか
        → race_id_list.csv の取得完了後に確認可能
    
    [horse] 馬の過去成績 (horse_results.csv)
      チェック内容: 期間内に出走する horse_id が最新まで取得済か
        → results.csv の取得完了後に確認可能
    
    [ped] 血統 (peds.csv)
      チェック内容: 期間内に出走する horse_id が peds.csv に登録済か
        → results.csv の取得完了後に確認可能
    
    [leading] リーディング (jockey / trainer / sire)
      チェック内容: 期間内の各年×タイプ について page_id が rawdf に登録済か
        対象年: 1 年 (2024 〜 2024) ※後段で前年データとして使用
        jockey_leading.csv: 未作成 (0 / 1 年)
        trainer_leading.csv: 未作成 (0 / 1 年)
        sire_leading.csv: 未作成 (0 / 1 年)

    今のデータ収集の仕組みは race_id(レースID)→ race(レース結果)→ horse(馬の過去成績)/ ped(血統) という順番に依存関係があります。
    まだ race_id が取れていない段階では、その先の race や horse は「→ race_id_list.csv の取得完了後に確認可能」というように案内されます。

    画像

    2.3. データ収集を実行する

    それでは、実際にデータを集めてみましょう。‍--status を外して実行すると、収集モードになります。ここでは1ヶ月分だけまず指定してみましょう。

    uv run python common/src/collect_data.py --from-ym 2025-09 --to-ym 2025-09

    このコマンド1つで、すべてのデータが順番に収集されます。

    出力:

    対象期間: 2025-09 〜 2025-09
    
    race_id を取得
    追加スクレイピング対象月数: 1
    100%|██████████| 1/1 [00:01<00:00,  1.20s/it]
    取得した開催日数: 9
    [race_id plan]
      対象期間: 2025-09 〜 2025-09
      対象開催日 (kaisai_calendar から抽出): 9 日
      race_id スクレイピング予定: 9 日
    追加スクレイピング対象日数: 9
    100%|██████████| 9/9 [00:21<00:00,  2.34s/it]
    取得した race_id 数: 240
    
    race ページからのデータ取得
    [race plan]
      対象期間: 2025-09 〜 2025-09
      対象 race_id (race_id_list から抽出): 240 件
      HTML スクレイピング予定: 240 件
      results.csv create 予定: 240 件
      race_info.csv create 予定: 240 件
      return_tables.csv create 予定: 240 件
    100%|██████████| 240/240 [05:53<00:00,  1.47s/it]
    100%|██████████| 240/240 [00:08<00:00, 28.05it/s]
    100%|██████████| 240/240 [00:05<00:00, 44.26it/s]
    100%|██████████| 240/240 [00:07<00:00, 30.01it/s]
    
    horse ページからのデータ取得
    [horse plan]
      HTML 取得予定 horse_id: 2,784 件 (古い 0 + 未取得 2,784)
      horse_results.csv create 予定: 2,784 件
    100%|██████████| 2784/2784 [2:33:21<00:00,  3.30s/it]
    100%|██████████| 2784/2784 [00:59<00:00, 46.85it/s]
    
    血統ページからのデータ取得
    [ped plan]
      HTML スクレイピング予定: 2,784 件
      peds.csv create 予定: 2,784 件
    100%|██████████| 2784/2784 [2:41:06<00:00,  3.47s/it]
    100%|██████████| 2784/2784 [01:02<00:00, 44.30it/s]
    
    リーディングページからのデータ取得
    [jockey leading plan]
      scrape 予定 year: 1 年 [2024]
      jockey_leading.csv create 予定: 0 件
    scrape_html_leading[jockey, 2024]: 取得 4 件, スキップ 0 件
    [trainer leading plan]
      scrape 予定 year: 1 年 [2024]
      trainer_leading.csv create 予定: 0 件
    scrape_html_leading[trainer, 2024]: 取得 5 件, スキップ 0 件
    [sire leading plan]
      scrape 予定 year: 1 年 [2024]
      sire_leading.csv create 予定: 0 件
    scrape_html_leading[sire, 2024]: 取得 15 件, スキップ 0 件
    
    === 収集完了 ===

    ユーザーは‍期間を渡しただけで、「何を・どの順で・何件取るか」をすべてスクリプト側が判断してくれます。
    最後に ‍=== 収集完了 ===‍ が表示されれば、その期間のデータは一通り揃った状態になります。

    2.4. 初回・追加・中断後全て「同じ1コマンド」でよい

    ここがこのシステムで一番こだわったところです。
    ‍どんな場面でも、実行するコマンドは同じでよく、「何を・どの順で・何件取るか」をすべてスクリプト側が判断してくれます。

    画像

    いくつかの典型的なケースを見てみましょう。

    ‍◆もう一度実行しても安全(冪等性)‍
    データ収集が完全に終わっている状態で、もう一度まったく同じコマンドを実行してみます。

    uv run python common/src/collect_data.py --from-ym 2025-09 --to-ym 2025-09

    出力(抜粋):

    対象期間: 2025-09 〜 2025-09
    
    race_id を取得
    追加スクレイピング対象月数: 0
    0it [00:00, ?it/s]
    取得した開催日数: 0
    [race_id plan]
      対象期間: 2025-09 〜 2025-09
      対象開催日 (kaisai_calendar から抽出): 9 日
      race_id スクレイピング予定: 0 日
    追加スクレイピング対象日数: 0
    
    race ページからのデータ取得
    [race plan]
      対象期間: 2025-09 〜 2025-09
      対象 race_id (race_id_list から抽出): 240 件
      HTML スクレイピング予定: 0 件
      results.csv create 予定: 0 件
      race_info.csv create 予定: 0 件
      return_tables.csv create 予定: 0 件
    0it [00:00, ?it/s]
    0it [00:00, ?it/s]
    create_results: 処理対象が0件のためスキップ
    0it [00:00, ?it/s]
    create_race_info: 処理対象が0件のためスキップ
    0it [00:00, ?it/s]
    create_return_tables: 処理対象が0件のためスキップ
    
    horse ページからのデータ取得
    [horse plan]
      HTML 取得予定 horse_id: 0 件 (古い 0 + 未取得 0)
      horse_results.csv create 予定: 0 件
    0it [00:00, ?it/s]
    0it [00:00, ?it/s]
    create_horse_results: 処理対象が0件のためスキップ
    
    血統ページからのデータ取得
    [ped plan]
      HTML スクレイピング予定: 0 件
      peds.csv create 予定: 0 件
    0it [00:00, ?it/s]
    0it [00:00, ?it/s]
    create_peds: 処理対象が0件のためスキップ
    
    リーディングページからのデータ取得
    [jockey leading plan]
      scrape 予定 year: 0 年 []
      jockey_leading.csv create 予定: 0 件
    0it [00:00, ?it/s]
    create_jockey_leading: 処理対象が0件のためスキップ
    [trainer leading plan]
      scrape 予定 year: 0 年 []
      trainer_leading.csv create 予定: 0 件
    0it [00:00, ?it/s]
    create_trainer_leading: 処理対象が0件のためスキップ
    [sire leading plan]
      scrape 予定 year: 0 年 []
      sire_leading.csv create 予定: 0 件
    0it [00:00, ?it/s]
    create_sire_leading: 処理対象が0件のためスキップ
    
    === 収集完了 ===

    すべてのセクションが「予定 0 件」「処理対象が0件のためスキップ」となり、CSVの行数は実行前と完全に一致(何も追加されない)します。

    ‍◆期間を追加したいとき
    前回の記事と同様、様々なケースでの挙動を見ていきましょう。
    例えば、すでに2025年9月分が揃っている状態で、10月分も追加したくなったとします。やることは、‍--to-ym を 2025-10 に変えるだけです。

    uv run python common/src/collect_data.py --from-ym 2025-09 --to-ym 2025-10

    出力例:

    対象期間: 2025-09 〜 2025-10
    
    race_id を取得
    追加スクレイピング対象月数: 1
    HTML取得中...: 100%|██████████| 1/1 [00:01<00:00,  1.35s/it]
    取得した開催日数: 9
    [race_id plan]
      対象期間: 2025-09 〜 2025-10
      対象開催日 (kaisai_calendar から抽出): 18 日
      race_id スクレイピング予定: 9 日
    追加スクレイピング対象日数: 9
    HTML取得中...: 100%|██████████| 9/9 [00:21<00:00,  2.40s/it]
    取得した race_id 数: 264
    
    race ページからのデータ取得
    [race plan]
      対象期間: 2025-09 〜 2025-10
      対象 race_id (race_id_list から抽出): 504 件
      HTML スクレイピング予定: 264 件
      results.csv create 予定: 264 件
      race_info.csv create 予定: 264 件
      return_tables.csv create 予定: 264 件
    HTML取得中...: 100%|██████████| 264/264 [06:31<00:00,  1.48s/it]
    rawdf作成中...: 100%|██████████| 264/264 [00:09<00:00, 28.51it/s]
    rawdf作成中...: 100%|██████████| 264/264 [00:05<00:00, 44.26it/s]
    rawdf作成中...: 100%|██████████| 264/264 [00:08<00:00, 30.01it/s]
    
    horse ページからのデータ取得
    [horse plan]
      HTML 取得予定 horse_id: 2,161 件 (古い 0 + 未取得 2,161)
      horse_results.csv create 予定: 2,161 件
    HTML取得中...: 100%|██████████| 2161/2161 [54:18<00:00,  1.51s/it]
    rawdf作成中...: 100%|██████████| 2161/2161 [01:21<00:00, 26.54it/s]
    
    血統ページからのデータ取得
    [ped plan]
      HTML スクレイピング予定: 2,161 件
      peds.csv create 予定: 2,161 件
    HTML取得中...: 100%|██████████| 2161/2161 [56:02<00:00,  1.56s/it]
    rawdf作成中...: 100%|██████████| 2161/2161 [00:51<00:00, 42.18it/s]
    
    リーディングページからのデータ取得
    [jockey leading plan]
      scrape 予定 year: 0 年 []
      jockey_leading.csv create 予定: 0 件
    create_jockey_leading: 処理対象が0件のためスキップ
    [trainer leading plan]
      scrape 予定 year: 0 年 []
      trainer_leading.csv create 予定: 0 件
    create_trainer_leading: 処理対象が0件のためスキップ
    [sire leading plan]
      scrape 予定 year: 0 年 []
      sire_leading.csv create 予定: 0 件
    create_sire_leading: 処理対象が0件のためスキップ
    
    === 収集完了 ===

    期間内のレースは合計504件に増えましたが、‍すでに取得済みの9月分240件は自動で除外され、新しく追加された10月分264件だけが取得対象になります。horse / ped も同様に、10月分の出走馬で更新が必要なものだけが対象になり、リーディングは2024年分が揃っていれば再取得は不要です。

    ‍◆更新が必要な馬がいるとき
    次に、「HTMLは持っているが、その後に走った新しいレースが反映されていない馬」が存在するパターンです。
    こうした馬がいる状態で ‍--status を見ると、次のように表示されます。

    [horse] 馬の過去成績 (horse_results.csv)
      チェック内容: 期間内に出走する horse_id が最新まで取得済か
        対象 horse_id: 2,784 種
        最新まで取得済: 2,779 / 2,784 (99.82%)
        古い (要再取得): 5 件
        未取得: 0 件

    「古い(要再取得)」が5件と表示されています。この状態で収集を実行すれば、「horse plan」がその5頭だけを再取得対象として拾ってくれます。

    [horse plan]
      HTML 取得予定 horse_id: 5 件 (古い 5 + 未取得 0)
      horse_results.csv create 予定: 5 件

    馬を「最新まで取得済(up_to_date)/古い(stale)/未取得(missing)」の3つに分類する仕組みは#56の記事で詳しく解説しているので、ここでは「‍古い馬も自動で見つけて取り直してくれる」という結果だけ押さえておけば十分です。

    ‍◆スクレイピングが途中で止まったとき
    スクレイピングは長時間かかるため、ネットワークエラーやPCのスリープで途中で止まることがよくあります。そんなときも、‍特別な操作は不要で、もう一度同じコマンドを実行するだけです。

    画像

    たとえばhorseページのスクレイピング中に、処理が途切れてしまった場合の動作例を見てみましょう。

    uv run python common/src/collect_data.py --from-ym 2025-09 --to-ym 2025-09

    出力:

    対象期間: 2025-09 〜 2025-09
    
    race_id を取得
    追加スクレイピング対象月数: 1
    HTML取得中...: 100%|██████████| 1/1 [00:01<00:00,  1.20s/it]
    取得した開催日数: 9
    [race_id plan]
      対象期間: 2025-09 〜 2025-09
      対象開催日 (kaisai_calendar から抽出): 9 日
      race_id スクレイピング予定: 9 日
    追加スクレイピング対象日数: 9
    HTML取得中...: 100%|██████████| 9/9 [00:21<00:00,  2.34s/it]
    取得した race_id 数: 240
    
    race ページからのデータ取得
    [race plan]
      対象期間: 2025-09 〜 2025-09
      対象 race_id (race_id_list から抽出): 240 件
      HTML スクレイピング予定: 240 件
      results.csv create 予定: 240 件
      race_info.csv create 予定: 240 件
      return_tables.csv create 予定: 240 件
    HTML取得中...: 100%|██████████| 240/240 [05:53<00:00,  1.47s/it]
    rawdf作成中...: 100%|██████████| 240/240 [00:08<00:00, 28.05it/s]
    rawdf作成中...: 100%|██████████| 240/240 [00:05<00:00, 44.26it/s]
    rawdf作成中...: 100%|██████████| 240/240 [00:07<00:00, 30.01it/s]
    
    horse ページからのデータ取得
    [horse plan]
      HTML 取得予定 horse_id: 2,784 件 (古い 0 + 未取得 2,784)
      horse_results.csv create 予定: 2,784 件
    HTML取得中...:  86%|████████▌ | 2388/2784 [49:36<08:05,  1.22s/it]
                               ↑ここで処理が途切れたとする

    この場合も「どこから再開するか考える」必要はなく、もう一度同じコマンドを実行するだけです。

    uv run python common/src/collect_data.py --from-ym 2025-09 --to-ym 2025-09

    出力:

    対象期間: 2025-09 〜 2025-09
    
    race_id を取得
    追加スクレイピング対象月数: 0
    HTML取得中...: 0it [00:00, ?it/s]
    取得した開催日数: 0
    [race_id plan]
      対象期間: 2025-09 〜 2025-09
      対象開催日 (kaisai_calendar から抽出): 9 日
      race_id スクレイピング予定: 0 日
    追加スクレイピング対象日数: 0
    
    race ページからのデータ取得
    [race plan]
      対象期間: 2025-09 〜 2025-09
      対象 race_id (race_id_list から抽出): 240 件
      HTML スクレイピング予定: 0 件
      results.csv create 予定: 0 件
      race_info.csv create 予定: 0 件
      return_tables.csv create 予定: 0 件
    HTML取得中...: 0it [00:00, ?it/s]
    rawdf作成中...: 0it [00:00, ?it/s]
    create_results: 処理対象が0件のためスキップ
    rawdf作成中...: 0it [00:00, ?it/s]
    create_race_info: 処理対象が0件のためスキップ
    rawdf作成中...: 0it [00:00, ?it/s]
    create_return_tables: 処理対象が0件のためスキップ
    
    horse ページからのデータ取得
    [horse plan]
      HTML 取得予定 horse_id: 2,784 件 (古い 0 + 未取得 2,784)
      うち bin 取得済み・csv 未登録: 2,388 件 (先行 csv 化対象)
      horse_results.csv create 予定: 2,784 件
    rawdf作成中...: 100%|██████████| 2388/2388 [01:32<00:00, 25.84it/s]
    [horse plan]
      HTML 取得予定 horse_id: 396 件 (古い 0 + 未取得 396)
      horse_results.csv create 予定: 396 件
    HTML取得中...: 100%|██████████| 396/396 [08:16<00:00,  1.25s/it]
    rawdf作成中...: 100%|██████████| 396/396 [00:13<00:00, 28.53it/s]
    
    血統ページからのデータ取得
    [ped plan]
    ...

    処理が終わっているraceページ部分まではスキップされ、horseページ部分は、

    まず先ほど取得した2388件のHTMLをcsv化
    →残りの396件をスクレイピング
    →csvに追加

    という処理が走ることがわかります。

    3. 実装の詳しい解説

    ここからは具体的な実装コードについて詳しく解説していきます。

     
     
    競馬予想AIを作ることを通して、機械学習・データサイエンスの勉強になるコンテンツの発信や、筆者が行った実験の共有などを行っていきます。

    あなたへのおすすめ