競艇予想AIの作り方 — LightGBMで「当たる順位」を学習させる実装ガイド

アプリの作り方

競艇予想AIの作り方 — LightGBMで「当たる順位」を学習させる実装ガイド

競艇には、2022〜2025年の4年間で21万レース分の公式記録が積み上がっている。勘に頼って「次こそは」を繰り返すより、この蓄積を学習させて働いてもらう方が筋がいい。問題は「どの数字を、どう学習させれば当たるのか」だ。このガイドは、私が実際に作った予想AIを、データの集め方から特徴量・学習・予測・画面までまるごと手元で再現できる形で公開する。うまくいかなかった設計判断も残す。

目次
  1. このガイドで作るもの
  2. 全体像 — データは一方向に流れる
  3. Step 1. データ基盤:公式K-fileを溜める
  4. Step 2. 特徴量設計:効く1本を見つける
  5. Step 3. モデル:分類ではなく「順位学習」
  6. Step 4. 予測:ルール+MLのハイブリッド
  7. Step 5. 画面:Streamlit で組む
  8. よくある詰まりどころ
  9. うまくいかなかった所と、性能の天井
  10. ここから先(発展)
  11. まとめ

このガイドで作るもの

作るのは、公式データを溜めて3連単の有力上位10点を提示する Streamlit アプリだ。会場・レースを選ぶと、6艇の予想スコアと買い目、その根拠(展開予想)まで出る。実測の正直値で単勝的中率59.4% / 3連単50.1% / 回収率93.4%。最初に断っておくと、これは「必ず儲かる魔法」ではない。回収率は100%に届かず、その天井も隠さず扱う。

競艇予想AIのメイン画面。桐生1Rの出走表とAIの推奨買い目上位10点、予想スコア。
完成形のメイン画面。会場とレースを選ぶと、出走表の下に 本命/中穴/大穴 のタイプ付きで買い目Top10と、艇ごとの予想スコアが並ぶ。この一画面を最後に組み上げる。

再現する中核は5つ。データ基盤 → 特徴量 → 学習(順位モデル)→ 予測(ルール+ML)→ 画面だ。本番アプリにはこの先に日次の自動記録・確率較正・期待値ベット・Web版(FastAPI+Next.js)もあるが、そこは記事の最後に「発展」として地図だけ渡す。まず、この5層を一本の線で通す。

全体像 — データは一方向に流れる

先に結論を言う。予想AIの精度を決めるのは、モデルの豪華さではない。「何を当てる問題か」の定義・効く特徴量を1本見つけること・予測時に手に入らない情報を設計で断つこと、この3点だ。派手なニューラルネットを積むより、ここを外さない方がよほど効く。以降の各ステップは、全部この3点の話に還元される。

K-file(公式競走成績)
  → parse_kfile.py       固定幅Shift-JISを解体
  → SQLite               race_results / racer_results
  → feature_extractor    1艇=1行の37特徴量に変換
  → LightGBM LambdaRank  6艇を「正しい順番」に並べる学習
  → prediction           ルール40% + ML60% でスコア合成 → 3連単Top10
  → Streamlit            出走表・買い目・根拠を表示

データは左から右へ一方向にしか流れない。画面が予測を、予測がモデルを、モデルが特徴量を、特徴量がDBを呼ぶ。逆流させないから、あとで機能を足しても土台が崩れない。

Step 1. データ基盤:公式K-fileを溜める

予想の土台は綺麗なデータだ。ボートレース公式は、その日の全レース結果をK-fileという固定幅テキスト(Shift-JIS・LZH圧縮)で毎日公開している。まずこれを期間指定で落として解凍し、SQLiteに流し込む。

# 公式アーカイブから過去K-fileを一括DL+解凍(まずは3か月分から)
.venv\Scripts\python ingest\download_kfiles.py --start 2025-01-01 --end 2025-03-31

# K*.TXT を正本DBへ投入(INSERT OR REPLACE なので再実行で修復される)
.venv\Scripts\python ingest\parse_kfile.py --txt-dir data\raw\txt

K-fileは1バイトずれると全部ずれる固定幅で、選手名に全角スペースが混ざるため空白区切りが効かない。だからバイト位置を決め打ちで切り出す。ここは横着せず、公式フォーマットに合わせて素直に書くのが一番速い。

# parse_kfile.py:選手1行(66バイト)の切り出し位置
_SLICE = {
    "position": (2, 4), "boat_no": (6, 7), "racer_id": (8, 12),
    "name": (13, 29), "motor_no": (29, 32), "exhibition_time": (37, 43),
    "course": (43, 47), "start_timing": (47, 55), "race_time": (55, 66),
}

保存先にSQLiteを選んだのは、ファイル1個で完結してサーバも要らないからだ。個人開発の分析用途なら、これで24万件を捌いても遅さは気にならない。全期間のバックフィルは公式アクセスへの配慮で時間がかかるので、焦らず直近の数か月から始めればいい。

投入先は2つのテーブルに分ける。レース単位の race_results(3連単の出目・払戻・決まり手・天候)と、艇×選手単位の racer_results(着順・進入コース・スタートタイミング・モーター番号)だ。学習で主に使うのは後者になる。

CREATE TABLE racer_results (
    date TEXT, place_cd INTEGER, race_no INTEGER,
    racer_id INTEGER, boat_no INTEGER, course INTEGER,
    position INTEGER, start_timing REAL,
    motor_no INTEGER, exhibition_time REAL,
    UNIQUE(date, place_cd, race_no, racer_id)   -- 再投入で重複しない
);
分析ダッシュボードのデータベース概要。レース結果245,906件、選手成績1,475,367件、期間2022-01-01から2026-06-17。
溜まったデータの実測。本番DBはレース結果245,906件・選手成績147万件(2022-01-01〜)。読者は3か月分から始めて構わない。同じコマンドで期間を広げれば、そのままこの規模までスケールする。

「21万件そろえないと動かない」と身構える必要はない。特徴量抽出も学習も、渡したデータの範囲でそのまま動く。まず数千レースで一度通し、手応えを見てから期間を伸ばすのがいい。料理でいえば、フルコースの前に一皿だけ作って味を確かめる感覚だ。

Step 2. 特徴量設計:効く1本を見つける

ここが精度の分かれ目になる。feature_extractor.py は1艇を37個の数値に変換するが、決定的に効いたのはたった1本だった。racer_place_wr(選手×会場の勝率を平滑化した target encoding)で、これ単体で top1_hit が +4.35pt 伸びた。「この選手はこの会場だと強い/弱い」を1つの数字に畳んだものだ。

# 選手×会場ごとの1着率を、サンプルの薄い組はグローバル平均(1/6)へ寄せて平滑化
grp = hist.groupby(['racer_id', 'place_cd'])['position']
wins, counts = grp.apply(lambda s: (s == 1).sum()), grp.count()
racer_place_wr = (wins + 30 * (1/6)) / (counts + 30)   # m=30 の加算平滑化

37個の内訳は、コース情報(1〜3コースか)、展示タイム、選手の履歴(直近勝率・平均着順・まくり率)、モーターの調子、番組表の級別・全国勝率といったカテゴリに分かれる。最後の「番組表」系だけは、結果を記録したK-fileではなく事前に配られるB-file(番組表)という別ソースから入れる。K-fileだけで組むと番組表由来の1本が抜けて36本になるが、それでも学習は問題なく通る。実際、K-fileを2〜3週間ぶん入れただけの小さなDBでも、36本の特徴で検証NDCG@1が0.72前後のモデルは作れた。まず小さく回して確かめるといい。数を並べること自体は難しくない。難しいのは、効く1本を見抜くことだ。racer_place_wr が強いのは、それが「選手の実力」と「会場の癖」の相性を1本で表すからだ。たとえば同じA1級でも、静水面で数字を稼ぐ選手と、荒れ水面で崩れない選手は別のタイプで、その差が会場別の勝率にはっきり出る。人間が「あの選手はこの会場が得意」と言うのを、数字1個に畳んだものだと思えばいい。

逆に、モーター履歴・平均スタート・まくり率などを何本足しても、重要度は上位10圏外で改善はほぼゼロだった。理由は多重共線性。すでにある特徴量が同じ情報を持っていたからだ。特徴量は「増やす」より「効く1本を見極める」方が遥かに効く。新しい列を足す前に、必ず ablation(その列を抜いて測り直す)で寄与を確かめてから採否を決める。この規律だけは崩さない。

3連単の出目頻度分析。過去約25万レースから出現頻度と平均払戻を集計。
特徴量設計の前に、まずデータの偏りを見る。3連単の出目には強い偏り(1-2-3が突出)がある。この「どこが固いか」の感覚が、効く特徴量の当たりをつける入口になる。

もう1つ、設計で必ず守ることがある。予測時に手に入らない情報を、学習に混ぜない。実レースのスタートタイミング start_timing はレースが終わるまで分からない値だ。これを学習に入れた瞬間、未来を覗いたリークになる。だから ML特徴からは外し、因果的に手に入る avg_st_history(過去STの平均)へ一本化した。選手の過去成績も、必ずそのレースより前の日付だけを見る(date < cutoff_date で切る)。

Step 3. モデル:分類ではなく「順位学習」

最初の設計を、私はここで根本から間違えた。「1着になるか否か」を当てる二値分類で組んでいたのだ。だが競艇予想は、6艇を正しい順番に並べる問題だ。そこで LightGBM の LambdaRank(ランキング学習)へ切り替えたところ、それだけで top1_hit が +4.29pt 改善した。「何を予測する問題なのか」を取り違えると、特徴量をいくら足しても伸びない。

これが、この開発で最大の分岐点だった。

実装の勘所は3つ。1レース=6艇を1グループとして渡すこと、着順を「並べ替えの正解ラベル」に変換すること(1着=5 … 6着=0)、そして検証を時系列で切ることだ。訓練は古い側、検証は直近20%。ここをランダム分割にすると「未来で学習して過去を当てる」リークになる。絶対にやらない。

# ml_model.py:LambdaRank の中核設定
params = {
    'objective': 'lambdarank', 'metric': 'ndcg',
    'ndcg_eval_at': [1, 3], 'lambdarank_truncation_level': 6,
    'num_leaves': 215, 'learning_rate': 0.16, 'max_depth': 4, 'seed': 42,
}
# 1グループ=6艇。訓練/検証は日付順に前80%/後20%で分ける(シャッフル禁止)
train = lgb.Dataset(X_train, label=y_train, group=group_sizes_train)
model = lgb.train(params, train, num_boost_round=800,
                  callbacks=[lgb.early_stopping(80)])

ここの num_leaveslearning_rate は勘で置いた値ではなく、Optuna で60試行まわして NDCG@1 が最大になった組み合わせだ(検証で NDCG@1 ≈ 0.759)。学習が終わったら、順位スコアはそのままだと大小しか意味を持たないので、softmax で「擬似的な確率」に直してから予測側へ渡す。次のステップでルールスコアと混ぜるための下ごしらえだ。

学習の起動は1本のコマンドで済む。DBから特徴量を作り、順位モデルを学習し、data/models/boat_predictor.pkl に保存する。

.venv\Scripts\python train_model.py
予想精度バックテスト。honest評価で対象56,676レース、単勝的中率59.4%、3連単50.1%、回収率93.4%。
学習に使っていない期間だけで測った正直値。単勝59.4% / 3連単50.1% / 回収率93.4%。honest参考(in-sample) を切り替えて、楽観値と正直値を必ず分けて見る。この分離をサボると精度を自分で盛ってしまう。

Step 4. 予測:ルール+MLのハイブリッド

MLモデル1本に全部任せてもいいが、私はルールベースのスコアとMLスコアを6:4で混ぜる設計にした。ルール側は勝率・進入コース・級別・モーター・過去STといった「人間が説明できる」加点で、たとえば1コース進入に+300点、A1級に+50点、といった重みを config.pySCORE_WEIGHTS に集約してある。ST評価は当日STではなく履歴から作った blended_st を使う(ここでもリークを避ける)。ML側は順位モデルの確率。片方が外した時にもう片方が支える。ハイブリッドの比率 ML_WEIGHT は0.6だ。

# prediction.py:ルールスコアとMLスコアを合成(ルール40% + ML60%)
ml_score = ml_probs.get(boat_no, 1/6) * 500          # ML確率を0〜500点へ
scores[boat_no] = 0.4 * rule_score + 0.6 * ml_score

# 6艇から3連単を全順列で作り、頭・2着・3着に重みをつけて上位10点
for first, second, third in itertools.permutations(boats, 3):
    combo = scores[first] * 1.5 + scores[second] * 1.0 + scores[third] * 0.5
top10 = sorted(all_combos, key=lambda x: x['score'], reverse=True)[:10]

頭(1着)に重み1.5を置くのは、3連単は1着を外すと全部外れるからだ。買い目には 本命/中穴/大穴 のタイプも付ける。オッズを取得していればオッズ帯で、なければスコア順位で分類する。冒頭のメイン画面で並んでいた買い目は、この関数の出力そのものだ。

買い目の数字だけ並べても、人はなかなか信じない。だから予測の隣に「なぜこの並びなのか」を短い日本語で添える。『3号艇はまくり率55.6%のまくり屋』『4号艇のモーターは2連対率45.7%で会場4位』のように根拠を1行ずつ出すだけで、同じ予測でも納得感がまるで変わる。当てることと同じくらい、なぜそう読んだかを見せることを大事にした。

Step 5. 画面:Streamlit で組む

UIは Streamlit の1ファイル+マルチページで組む。app.py がメイン(日付→会場→レース番号を選ぶ→予測→買い目表示)、pages/ 配下に分析ダッシュボード・レース結果・予想精度分析の3画面を置くと、サイドバーに自動で並ぶ。予測結果は st.dataframe の2カラム(買い目Top10/予想スコア)で出し、根拠は st.info で「3号艇はまくり率55.6%のまくり屋」のように言葉にする。

# セットアップと起動(これだけで冒頭の画面が立ち上がる)
python -m venv .venv
.venv\Scripts\python -m pip install -r requirements.txt   # streamlit / lightgbm==4.6.0 / pandas ほか
.venv\Scripts\python -m streamlit run app.py               # → http://localhost:8501

依存は軽い。streamlitlightgbm 4.6.0pandasplotly あたりが要で、GPUも外部APIも学習には要らない。K-fileの解凍だけ lhafile を別途 pip install する。予測は保存済みの boat_predictor.pkl だけで完結するので、一度学習すれば再現は速い。出走表は公式の艇番カラー(1白・2黒・3赤・4青・5黄・6緑)で色分けしておくと、実際の画面と頭の中が一致して読みやすい。

よくある詰まりどころ

実際に組むと引っかかる所は、だいたい決まっている。私が踏んだものを4つ置いておく。

  • K-fileが集まらない:公式アーカイブは日付単位なので、期間を広げると数百リクエストになる。--interval 1.0(1秒間隔)は下げすぎない。落とし損ねた日は --no-skip で取り直せる。フライング(F)・欠場(K)・失格(S)の行は着順が数字にならないので、パーサ側で None に倒しておくと後段が楽になる。
  • 学習サンプルが少なくて精度が出ないmin_train_samples の目安は1万行。数千レースだと num_leaves=215 は大きすぎて過学習する。小さいスライスで試すときは num_leaves を下げ、min_child_samples を上げるだけで安定する。
  • 精度が良すぎて怪しい:まず疑うのはリークだ。検証をランダム分割にしていないか、予測時に無い値(当日ST・確定天候)を特徴量に入れていないか。honest 評価が 参考(in-sample) と大きく開くなら、その差がそのままリークの量だと思っていい。
  • 買い目が本命ばかりになる:オッズ未取得だとタイプ分類がスコア順位ベースになり、上位が本命に寄る。オッズを取得すればオッズ帯(20倍未満=本命、100倍未満=中穴)で振り直される。妙味を見たいならオッズ取得は必須だ。

うまくいかなかった所と、性能の天井

失敗も1つ残す。「風 × コース」の交互作用を6特徴量ぶん足したら、精度は逆に -0.36pt 悪化した。原因は、予測時に天候情報がデフォルト0埋めになり、実観測値で学習したモデルとズレたことだ。訓練時にあって予測時に無い情報は、こうして静かに毒になる。足す前に抜いて測る。Step 2で書いた ablation の規律は、この手の事故を防ぐための保険でもある。

そして正直な限界。回収率は honest 値で93.4%、100%には届いていない。競艇のオッズには胴元の取り分が最初から抜かれていて、的中率を上げても回収率がそこを超えるのは容易ではない。このAIは「勝ちを保証する machine」ではなく、「勘で買うのとは別の土俵に立つための道具」だ。数字を盛らないことが、こういうツールを長く使える条件だと思っている。

ここから先(発展)

中核はここまでで動く。本番アプリはこの上に、運用と精度の仕組みを積んでいる。深追いは別記事に譲るが、地図だけ置いておく。

  • 日次の自動記録:毎晩バッチで「AI予想 vs 実結果」を照合し、当て方そのものを記録で磨く(run_daily.py)。
  • 確率較正:順位スコアを実際の的中確率に寄せる(calibrator.pkl)。期待値を測る土台になる。
  • 期待値(妙味)買い目:AIの確率 × オッズで、過小評価されている買い目だけを拾う。
  • Web版:同じ予想ロジックを FastAPI で薄く包み、Next.js から叩く構成。

まとめ

もう一度だけ。予想AIの精度を決めたのは、モデルの豪華さではなかった。問題を「順位学習」と定義し直したこと・効く特徴量を1本(選手×会場)に絞ったこと・予測時に無い情報を設計で断ったこと。この3点だけで、個人でも回収率9割台の予想AIは手元で組める。データの集め方から画面まで一本の線で通してきたが、迷ったら常にこの3点に立ち返ればいい。

👉 ▶ ほかのバイブコーディング開発実例いろんなアプリの作り方

この記事について:掲載コードは実際のソースからの抜粋で、数値(的中率・回収率・特徴量寄与)は学習外データを含むバックテストの実測値です。公式K-fileの取得は各サイトの利用規約に従い、節度あるアクセス間隔で行ってください。投資は自己責任で。© 2026 Cooliris(記事本文と掲載コードの権利は作者に帰属します)。

New Eden Intelligence Hub の作り方 — EVE Online の SSO×ESI API で市場・資産・戦闘を一画面に集約する自分専用ダッシュボード次のページNew Eden Intelligence Hub の作り方 — EVE Online の SSO×ESI API で市場・資産・戦闘を一画面に集約する自分専用ダッシュボード

ピックアップ記事

  1. 高精度OCRデスクトップアプリの作り方 — PaddleOCR-VLとPyIns…

  2. 競艇予想AIの作り方 — LightGBMで「当たる順位」を学習させる実装ガイド…

  3. New Eden Intelligence Hub の作り方 — EVE Onl…

  4. Claude Mission Control の作り方 — Tauri+Pyth…

関連記事

  1. AIアセットでゲームマップを作る — 生成スケッチの透過切り出しからレベルセレクトまで
  2. 背景除去アプリを作る④|BiRefNetをONNXでローカル推論する — モデル別前処理とDirectML→CPUフォールバック【背景除去Studio制作】
  3. JavaScriptでブラウザゲームを作る①|ビルド不要(file://)で動くシーン制の土台と全体設計【Archipelago Saga制作】
  4. 背景除去アプリを作る②|AIなしでここまで抜ける — FloodFill・クロマキー・Color-to-Alphaの古典CVエンジン【背景除去Studio制作】

    アプリの作り方

    背景除去アプリを作る②|AIなしでここまで抜ける — FloodFill・クロマキー・Color-t…

    白背景の商品写真はAIより古典CVの方が速くて正確。四隅FloodFi…

  5. 背景除去アプリを作る⑥|QGraphicsViewズーム・スポイト・QThread非同期・D&D一括処理【背景除去Studio制作】
  6. JavaScriptでRPGを作る④|ワールドマップ・シーン遷移・セーブ(localStorage)【Archipelago Saga制作】

    アプリの作り方

    JavaScriptでRPGを作る④|ワールドマップ・シーン遷移・セーブ(localStorage)…

    ステージデータから蛇行するワールドマップを描き、クリアでロックを解放し…

注目

AIで、ここまで作れる

AIで作った2D RPGを、ブラウザでそのまま遊べます。その「作り方=最後まで完成させる進め方」も実例つきで公開中。

▶ ゲームを遊ぶやり方を読む

PR

お名前.com 独自ドメイン取得(PR)

独自ドメイン:お名前.com(本サイトで使用・PR)

  1. AI・テック動向

    Claude Code 2.1.219 でサブエージェントが深さ3までネスト可能…
  2. エラーが出たらどうする?AIへの聞き方とデバッグの基本【入門5】

    バイブコーディング

    エラーが出たらどうする?AIへの聞き方とデバッグの基本【入門5】
  3. Claude API が会話途中の system メッセージに対応 — キャッシュを壊さず方針を差し替える

    AI・テック動向

    Claude API が会話途中の system メッセージに対応 — キャッシ…
  4. JavaScriptでRPGを作る③|CTB戦闘システムをDOM非依存で実装する【Archipelago Saga制作】

    アプリの作り方

    JavaScriptでRPGを作る③|CTB戦闘システムをDOM非依存で実装する…
  5. Claude Code の /doctor が診断して直す — 肥大化した設定の健診

    AI・テック動向

    Claude Code の /doctor が診断して直す — 肥大化した設定の…
PAGE TOP

TAG CLOUD

ドラッグで回転・クリックでそのタグの記事一覧へ