🐎

学生が個人開発でLightGBM競馬予想アプリを運用してわかったこと

に公開

はじめに

こんにちは、モカ(@mocha_lily)です。学生をしながら個人で「keiba」という競馬予想・分析アプリを作り、毎週実際に使いながら結果をROIで検証して運用しています

学習用に作って放置…ではなく、データ収集・モデル学習・予想・結果記録までを launchd で自動運用 している本番システムです。この記事では、その構成と、作る中でハマったポイントを共有します。

※投資・馬券購入を勧める記事ではありません。あくまで個人開発・機械学習の実装記録です。

全体構成

データ収集(Python/スクレイピング)
   → SQLite に蓄積
   → 特徴量エンジニアリング
   → LightGBM(LambdaRank)で学習・予想
   → Plattスケーリングで確率にキャリブレーション
   → Tauri 2 デスクトップアプリで可視化
   → launchd で週次自動運用
  • 言語/スタック: Python(モデル・CLI)/ Rust + Tauri 2(デスクトップUI)/ JavaScript + Tailwind
  • データ: SQLite / Parquet
  • ML: LightGBM / pandas / numpy / scikit-learn

CLIが本体(keiba <subcommand>)で、デスクトップアプリはそれを叩いて結果を表示するHUD風UI、という分離にしています。

モデル:なぜ「分類」ではなく「ランキング学習」か

着順予想は「この馬が1着か?」の分類より、「出走馬の中での順位付け」のほうが問題設定として自然です。なので LightGBM の LambdaRank(ランキング学習)を採用しています。

  • レースを1グループとして、馬を相対的にランク付け
  • 出力スコアを Plattスケーリング で「勝つ確率」っぽい値にキャリブレーション
  • K-fold 交差検証で過学習をチェックしてから採用

実装は training/lambdarank.py / training/calibration.py / training/lgbm_phase1.py あたりに分けています。

特徴量エンジニアリング

予想精度はほぼ特徴量で決まります。keibaで使っている主なカテゴリ:

  • 馬の過去成績(horse_history)/条件別の成績(conditional_history
  • 血統(pedigree
  • オッズとオッズの動きodds / odds_movement
  • 市場全体の集計(market_aggregate)・票数(votes
  • 騎手・厩舎などの関係性(connections
  • 調教タイム(training_times

さらに、重賞ごとの評価軸を YAMLで辞書化race_profiles.yaml、130重賞分)して、レースの性質をモデルに渡しています。ドメイン知識をコードに落とす作業がいちばん効きました。

自動運用:launchd で「ほっといても回る」状態に

macOSの launchd で、以下を定期実行しています:

  • collect-odds … オッズなどデータ収集
  • weekly-retrain … 週次でモデル再学習
  • weekly-record … レース結果の記録
  • predict-cache … 予想の事前計算キャッシュ

「動くものを作る」より「使い続けられる状態にする」ほうが何倍も大事だと感じています。DB破損時の整合性チェック・復旧や、データ取得のフォールバック(リアルタイム→確定値→外部API)も入れています。

Tauri 2 でハマった話(実用Tips)

デスクトップUIは Tauri 2 + window-vibrancy(HudWindow)で半透明HUD風に。ここで沼ったので共有します。

1. ウィンドウがドラッグできない

CSSの -webkit-app-region: dragdata-tauri-drag-region も効かない。真因は permission 不足でした。Tauri 2 は src-tauri/capabilities/default.json に window 操作権限がないとサイレント失敗します。

{
  "permissions": [
    "core:default",
    "core:window:allow-start-dragging",
    "core:window:allow-set-position",
    "core:window:allow-minimize",
    "core:window:allow-close"
  ]
}

これで appWindow.startDragging() がやっと動く。CSSだけでは無理。

2. Python の JSON に NaN が混入してRust側でパースエラー

Python の json.dumpsNaN をリテラル出力(非標準)、Rust の serde_json は拒否します。

df = df.astype(object).where(pd.notnull(df), None)
print(json.dumps(out, allow_nan=False))  # NaN混入を即エラーに

3. Rust subprocess が UI をブロック

Command::output() の同期実行だとUIスレッドが固まる(カーソルがレインボーに)。tokio::task::spawn_blocking で逃がします。

#[tauri::command]
async fn keiba_predict_weekend() -> Result<...> {
    tokio::task::spawn_blocking(move || run_keiba(&args)).await?
}

結果:ROIで検証する

ただ予想を出すだけでなく、累積回収率(ROI)と的中率を自動集計して、施策が効いたかをデータで判断する仕組みにしています(具体的な収支の数値は本記事では割愛します)。

ポイントは数字そのものより、特徴量を足したりモデルを変えたときに「本当に効いたか」を毎回データで確認してから採用/不採用を決めるという回し方です。「とりあえず実装」をしない、ここがいちばん大事だと思っています。

まとめ

  • ランキング学習(LambdaRank)+キャリブレーションで競馬予想モデルを構築
  • launchdで週次自動運用、ROIで継続検証
  • Tauri 2 は permission 周りでサイレント失敗するので注意

個人開発でも「データ収集→モデル→UI→自動運用」を一気通貫で回せます。同じような自動化・データ分析・ツール開発のご相談はDM(@mocha_lily)までお気軽に。

Discussion