2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

競馬AI予想を「自己学習型」に進化させた話 — 2段推論・コース別バイアス・条件別フィードバックで精度を底上げ

2
Posted at

はじめに

JRA競馬のAI全頭予想サービス「UmaAI」を個人開発しています。

前回の記事では54レース・印3着内カバー率96%という数値で「◎より○の1着率が高い」という構造的な課題を晒しました。あれから約2ヶ月、「AIに自分の予想の癖を学習させる」方向に大きく舵を切ったので、その過程と効いた施策を共有します。

結論から言うと、AI予想で精度を上げるにはプロンプトを巨大化させる前にやることがある、という話です。


この2ヶ月で入れた施策

時系列で整理するとこんな感じです。

時期 施策 カテゴリ
4/30 Web検索を撤廃 引き算
4/30 脚質推定・ペース集計を機械的に事前計算 事前集計
4/30 タイム指数・上り3F・ラップを構造化して投入 事前集計
4/30 AI分析を「展開予測 → 各馬評価」の2段推論化 推論設計
5/3 reasoning_effort を medium → high 推論設計
5/3 血統(父・母父)を取得して投入 データ追加
5/3 コース別脚質バイアスを事前集計してAIに渡す 事前集計
5/4 直近実績の自己フィードバックを systemPrompt に注入 自己学習
5/10 過去成績にレース名・クラス区分を付与 データ追加
5/24 自己フィードバックをレース条件別に絞り込み 自己学習

特に効いた(と感じる)のは太字の2つ、自己フィードバック系です。順番に書いていきます。


1. まず「Web検索」をやめた

前回までは gpt-4o-search-preview で重賞と一般戦のWeb検索を回していました。が、これを撤廃しました。

理由:

  • 検索結果がノイズすぎる — 「○○記念 予想」で検索すると、競合の予想記事や情報商材っぽいページが返ってくる。AIがそれを引用してしまうとUmaAI独自の予想ではなくなる
  • 遅い — Web検索は1リクエスト10〜20秒。並列でも全体のレイテンシを押し上げ、Vercelの300秒制限を圧迫
  • コスト — search-preview は通常のChat Completionsより高い

代わりに、netkeibaから取得できる構造化データを徹底的に増やす方向に切り替えました。Web検索で得ていた「予想家の見解」は、結局のところ過去データから導出できる情報の言い換えに過ぎないことが多かった。

- Web検索(gpt-4o-search-preview)× 4並列
+ netkeibaから取得した構造化データのみで分析

レイテンシが半分以下になり、AI出力の安定性も上がりました。「外部情報を増やすより、手元データの構造化を徹底する」が個人開発レベルでは正解だったようです。


2. AIに渡す前に、機械でできる集計は機械でやる

これが地味に一番効いた気がしています。

以前は「全レース履歴のテキストをそのままAIに渡して、AIに脚質もペースも判断させる」やり方でした。が、これだと:

  • 出力が毎回ブレる(同じ馬でも「先行」と判断したり「差し」と判断したり)
  • トークン消費が多い
  • 推論を脚質判定に使ってしまい、肝心の予想精度に回らない

そこで、決定的に判定できる項目はTypeScriptで事前集計してAIに「確定値」として渡すようにしました。

// lib/analysis.ts
export function inferRunningStyle(results: HorseRaceResult[]): RunningStyle {
  // 過去5走の通過順位から脚質を機械判定
  // 1コーナーで先頭〜2番手なら逃げ、3〜5番手なら先行、など
}

export function predictPace(entries: RaceEntry[]): PacePrediction {
  // 全馬の脚質分布から、レース全体のペースを推定
  // 逃げ2頭 + 先行5頭 → ハイペース、逃げ1頭 + 先行2頭 → スロー、など
}

export function calcCourseBias(entries, venue, surface, distance): CourseBias {
  // 出走各馬の同条件レース履歴から、コース特有のバイアスを抽出
  // 「東京芝1600では差し馬の連対率が高い」など
}

これらの結果を、AIプロンプトの先頭で「事前計算済みデータ(変更不可)」として注入します。

【想定ペース(事前集計)】
内訳: 逃げ2 / 先行5 / 差し4 / 追込3 / 不明1
逃げ候補: 3、9
先行候補: 1、5、7、12、14
予想ペース: ミドル〜ややハイ
解説: 逃げ2頭・先行5頭で前が引きずられる展開

AIはこの集計結果を前提として各馬を評価します。これで脚質判定のブレが消え、推論を本来の「展開と各馬の噛み合わせ評価」に集中できるようになりました。


3. 2段推論化(展開予測 → 各馬評価)

人間の予想家も「まずレース展開を読む → その上で各馬がどう動くか考える」という順序で予想しているはず。これをAIにも強制しました。

Step1: reasoning_effort=low で展開予測のみを300文字以内で確定
       ↓
Step2: Step1の結果を「変更不可の前提」として、reasoning_effort=high で
       各馬評価とJSON出力

Step1の出力例:

- 逃げ候補3、9で前傾ラップ濃厚、平均ペースよりやや速い
- 差し有利な展開、特に外差し
- 馬場は良で内も問題ないがやや外有利
- 主導権: 3番
- 3着候補ゾーン: 中団〜後方の差し脚を持つ馬

これをStep2のプロンプトにそのまま埋め込んで、各馬評価に進みます。

const step2Prompt = `${sharedContext}

【確定済みの展開予測(内部参照用)】
${paceAnalysis}

【タスク: 各馬評価とJSON出力】
上の展開予測を絶対に尊重した上で、各馬を評価し...
`

メリット:

  • 同じレースで「展開がブレる」がほぼ消えた — Step1で1回確定するので、各馬評価が同じ展開予測の上に乗る
  • Step1とStep2でsystemPrompt + sharedContextが完全一致 → OpenAIのprompt cacheが効いてコスト・レイテンシ削減
  • 各馬コメントの一貫性が向上(「ハイペース想定」と「スロー想定」が混在しなくなった)

地味だけど、出力品質に直結しました。


4. 血統・コースバイアスの追加

データの幅を広げる施策。

血統取得db.netkeiba.com/horse/ped/{id}/ から父・母父をスクレイプ。AIには「父:ハーツクライ / 母父:ディープインパクト」のようなテキストで渡し、プロンプトの評価チェックリストに「血統からダート/芝・距離・道悪適性を判断」を追加。

コース別脚質バイアス:出走各馬の過去レース履歴から、そのレースの会場・距離・馬場条件に近いレースだけを抜き出し、勝ち馬の脚質分布を集計:

// 出走馬全員の同条件レース履歴 → 勝ち馬の脚質を集計
// 「過去20戦のうち、逃げ4勝 / 先行9勝 / 差し5勝 / 追込2勝」→ 先行有利

これを「コース別バイアス(出走各馬の過去同条件レースから自動集計)」としてAIに渡します。AIに「東京芝1600は差し有利」と知識として持たせるより、「今日の出走馬の過去レースを集計したらこうだった」と実データで示す方が圧倒的に信頼性が高い


5. 本命:自己フィードバックループ(5/4・5/24)

ここからが本題です。

それまでのAIは「毎レース、ゼロから予想していた」。過去に自分が外した・当てた経験を一切活かせていない構造でした。これを修正したのが自己フィードバックループです。

仕組み

lib/feedback.ts で、過去30レースの「自分の予想印 × 実際の結果」を集計し、systemPromptに注入します。

export async function calcRecentFeedback(supabase, options) {
  // 過去30レースの horse_scores と race results を結合
  // 印別の3着内率、ai_score帯別の的中率、人気帯別の的中率を集計
  // 弱点が明らかな項目には「→ 指針」として強めの警告を出す
}

出力例(実データ):

【直近30レースのあなたの予想傾向 — 同じミスを繰り返さないこと】
- 印別3着内率: ◎60% / ○43% / ▲25% / △28% / ☆10% / ×15%
- ◎の ai_score 帯別3着内率: 80以上 70% / 70-79 45% / 70未満 15%
  → ai_score 70未満の◎は精度が著しく低い。◎は ai_score 75以上を要件にすること
- ☆の ana_score 帯別3着内率: 70以上 25% / 70未満 5%
  → ana_score 70未満の☆は機能していない。
- ◎の最終人気帯別3着内率(参考): 1-2番人気 75% / 3-5番人気 45% / 6番人気以下 20%

これをsystemPromptの末尾に貼って、AIに自分自身の予想の癖を意識させるわけです。

const systemPrompt = `あなたはJRA競馬の専門分析AIです...
${feedback ? `\n${feedback}` : ''}`

なぜ効くのか

LLMは「過去の自分の出力」をデフォルトでは知りません。なので、明示的に「過去のお前の予想はこういう傾向で外している」と示すと、その傾向を回避するように振る舞います。

例えば「ai_score 70未満の◎は精度15%」と示されたAIは、ai_scoreが微妙な馬を◎にしようとしません。「☆は機能していない」と示されたAIは、安易に☆を打たなくなります。

これは厳密にはRAGでも fine-tuning でもない、プロンプト注入による疑似的な自己学習です。実装コストは低いのにインパクトは大きい。


6. 仕上げ:フィードバックを「レース条件別」に(5/24・今週)

ここまでの自己フィードバックは全レース通算でした。が、競馬では「東京芝1600は得意だがローカル芝1200は外す」というような条件依存の癖が出ます。それを全部混ぜて平均してしまうと、得意・苦手が見えなくなる。

そこで calcRecentFeedbackcontext オプションを追加し、現在分析中のレースに条件が近い過去レースだけを抜き出して別セクションを生成するようにしました。

const fb = await calcRecentFeedback(supabase, {
  limit: 80,
  context: {
    venue: race.venue,           // 同会場
    surface: race.surface,        // 同馬場種別
    distance: race.distance,      // ±200m
    grade: race.grade,            // 同グレード帯
  },
})

出力例:

【このレース条件に近い過去12戦の傾向 — 東京 芝1600m±200 (G1クラス)】
- 印別3着内率: ◎75% / ○50% / ▲33% / ☆12%
  → このタイプのレースで◎の精度は良好。確信を持って本命を指名してよい

逆パターン(苦手条件):

【このレース条件に近い過去8戦の傾向 — 福島 ダート1700m±200】
- 印別3着内率: ◎12% / ○25% / ▲25% / ☆0%
  → このタイプのレースで◎の精度が全体より大きく低い。
    本命選定は特に慎重に、能力データが他馬を明確に上回る馬だけを◎にすること

全体平均と±15%以上の乖離があれば、強めの指針メッセージを追記します。

これによりAIは:

  • 得意条件では確信を持って◎を打つ
  • 苦手条件では本命選定の閾値を上げる

という条件依存の判断ができるようになりました。データが蓄積するほど精度が上がる構造です。


設計上のポイント

ここまでの施策を貫いている設計原則を抽出すると:

① 機械でできることを機械にやらせる

脚質判定・ペース集計・コースバイアス・タイム指数の比較は、AIに任せず先にTypeScriptで処理。AIの推論は「総合判断」にだけ使う。

② AIに渡す情報は「確定値」と「参考値」を分ける

事前集計値は「変更不可の前提」、過去成績テキストは「参考データ」として明示。AIが事実を捏造する余地を減らす。

③ 過去の自分を教師データにする

fine-tuning は個人開発レベルだと運用が重い。「過去の予想精度をプロンプトに注入する」だけで実質的な自己学習が成立する。

④ コンテキストは細かく絞る

全体傾向よりも「このレースに似た過去レースだけ」の方が、AIにとって行動指針として有効。


効果の体感

定量評価のためには長期のバックテストが必要なので、ここでは体感ベースの所感です。

良くなったところ

  • ◎のコメントが具体的になった — 「ハイペース想定で先行型のこの馬は不利、ただし...」のような展開噛み合わせ言及が増えた
  • ☆の濫用が減った — 「☆は機能していない」フィードバックを入れてから、AIが穴を打つ頻度が明らかに下がった
  • 得意条件での迷いが減った — フィードバックで「このタイプの◎は良好」と示すと、自信を持った本命選定をするようになった

まだ課題

  • 苦手条件(フィードバックで×が付くタイプ)の◎精度が劇的に上がるわけではない。「慎重に」と言われても、データが少ない条件では選択肢自体が限られる
  • フィードバックが効きすぎて保守的になる傾向(人気馬を◎にしがち)が出ると、また○ > ◎逆転が起きそうで継続観察中

構成図(現状)

cron (Vercel / maxDuration=300s / hnd1)
  ↓
[1] netkeibaスクレイピング
    - 出走表 / オッズ / 馬歴5走 / 調教 / 騎手成績 / 血統
  ↓
[2] 機械的な事前集計(lib/analysis.ts)
    - 脚質推定 / ペース予測 / コースバイアス / タイム指数集計
  ↓
[3] 自己フィードバック生成(lib/feedback.ts)
    - 全体傾向 + このレース条件に近い過去◯戦の傾向
  ↓
[4] Step1: 展開予測(reasoning_effort=low)
  ↓
[5] Step2: 各馬評価とJSON出力(reasoning_effort=high)
    ※Step1のsharedContext完全一致でprompt cache活用
  ↓
[6] バリデーション・後処理
    - 印数の強制(◎○▲ 各1頭・△最大2・☆最大1・×最大2)
    - ☆の4番人気以内禁止
    - is_ana_pick同期 / 内部用語除去 / 重複排除
  ↓
[7] Supabase保存

まとめ

前回から2ヶ月で入れた施策の中で、本当に効いたのは:

  1. Web検索を捨てる勇気 — ノイズを減らした方が精度が上がるケースは普通にある
  2. 機械でできる集計は機械で — AIの推論力を「総合判断」に集中させる
  3. 2段推論化 — 展開予測を先に確定してブレを抑える
  4. 自己フィードバックループ — 過去の自分の予想精度をプロンプトに注入
  5. コンテキスト別フィードバック — レース条件ごとに得意・苦手を学習

個人開発レベルでLLMの予想精度を上げるなら、プロンプトの巨大化やモデルサイズの引き上げよりも、「AIに渡す情報の設計」と「過去の自分の活用」が効くというのが今のところの結論です。

サービスはこちらで公開しています。土日のメインレースを中心に全頭分析を更新中です。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?