学生が個人開発で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: drag も data-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.dumps は NaN をリテラル出力(非標準)、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