はじめに
JRA競馬のAI全頭予想サービス「UmaAI」を個人開発しています。
Next.js 15 + Supabase + OpenAI の構成で、重賞・メインレースの出走全馬をAIが分析し、予想印(◎○▲△☆×)とコメントを毎週自動で公開しています。データはnetkeibaからスクレイピング(EUC-JPとの戦いはそれだけで一本書けるので今回は割愛)。
280レースほど回した時点の成績は ◎の複勝率50%・単勝回収率144%。数字だけ見れば上々でした。ところが結果を溜めて集計してみると、気になる傾向が出てきました。
AIの本命が、だんだん人気馬に寄っていく。
2〜3番人気に◎を打つ「無難な予想」が増え、その人気帯の回収率が最も悪い。人気サイドを買えばオッズは低く、外せば丸損。回収率の源泉である「人気との乖離」が消えていました。この記事では、この人気ドリフトをどう矯正したかを書きます。
LLM予想の3つの構造的な弱点
運用データとプロンプトを見直して、原因を3つに整理しました。
- 相対評価と算数が苦手 — 「18頭立ての5着」と「8頭立ての5着」の価値の違いを、毎回安定して計算してくれない
- 数値アンカーに引きずられる — 騎手の勝率23%といった具体的な数字を渡すと、その騎手の馬を過大評価する
- オッズという「みんなの答え」に迎合する — 人気順はそれ自体が説得力のある情報なので、迷ったら人気に寄せてしまう
対策の方針はシンプルで、**「計算はコードで決定論的にやり、LLMには判断だけさせる」**です。
改善1: 能力スコアをTypeScript側で事前計算する
各馬の「地力」を、LLMではなくコードで算出してランキング化し、それを最優先の評価軸としてプロンプトに渡すようにしました。
基本形は「出走クラスの基礎値 + 着順評価」を直近5走でrecency加重平均するものです。
const RECENCY = [1.0, 0.8, 0.62, 0.46, 0.32] // 直近ほど重く
for (let i = 0; i < results.length; i++) {
const base = classBaseRating(results[i].race_name, results[i]) // G1=112 … 新馬=60
const w = RECENCY[i] ?? 0.25
wsum += w * (base + finishDelta(results[i]))
wtot += w
}
let score = wsum / wtot
ポイントは着順評価 finishDelta を頭数で相対化したことです。着順を0(1着)〜1(最下位)に正規化し、上位15%までは急峻に、以降は緩やかに減点します。
// 少頭数の見かけの好着順を割り引くため分母は最低7
const rel = Math.min((position - 1) / Math.max(field_size - 1, 7), 1)
const delta = rel <= 0.15 ? 9 - 40 * rel : 3 - 15.3 * (rel - 0.15)
さらに着差で補正します。0.3秒以内の僅差負けは減点をほぼ帳消し(×0.1)、1秒以上の圧勝は加点。「負けたけど強い競馬」をコードが拾えるようになりました。クラス表記のないレースは獲得賞金から1着賞金を逆算してクラス帯を推定します(JRAは5着まで本賞金が出るので、着順別の配分率で割り戻せます)。
同じ思想で、レースの「荒れやすさ」も事前計算しています。能力上位のスコアギャップ・オッズの支持集中度・逃げ馬の頭数などを0〜1のリスク成分に正規化し、重み付き平均で0〜100の波乱度スコアにします。前日はオッズが取れないのですが、利用可能な成分だけで重みを再正規化する設計にしたので欠損しても破綻しません。LLMには「このスコアに言及してよいが、再計算・独自変更は禁止」と明示しています。
改善2: 数値アンカーの除去 — 騎手勝率を渡すのをやめる
騎手の生の勝率・連対率を渡すと、LLMはその数字を根拠に能力下位の馬を平気で本命に持ち上げます。実際、集計すると「トップ騎手×能力下位」の◎は明確に低調でした。
そこで生の%を渡すのをやめ、4段階のティアに粗視化して渡すようにしました。
// 生の勝率/連対率%をAIに渡すと数値アンカーで過大評価されるため、ランクのみ提示する
if (t >= 35) return 'トップ'
if (t >= 24) return '上位'
if (t >= 13) return '標準'
return '若手'
プロンプト側でも「騎手は補助情報。タイブレークにのみ使い、騎手ランクで能力上位馬を覆さない」と明記。情報を減らすことで判断が改善するのは、LLM設計の面白いところだと思います。
改善3: 自分の成績をLLMに見せる(フィードバックループ)
予想と結果はDBに溜まっているので、直近80レース分の自己成績を集計し、自然言語でシステムプロンプトに注入するようにしました。
- ◎の人気帯別の単勝回収率(1-2番人気 / 3-5番人気 / 6番人気以下)
- ◎の能力ランク帯別の的中率(事前計算ランキング何位の馬を本命にしたときに当たっているか)
- 「トップ騎手×能力下位」の◎の成績(騎手で持ち上げた疑いの検出)
たとえば1-2番人気の◎の回収率が90%を切っていたら、「1-2番人気サイドの◎は回収率が低い。4〜7番人気の妙味馬を○/☆で拾うこと」という警告文が自動で入ります。同じミスを繰り返させない仕組みです。
まとめ — LLMアプリ設計の一般論として
競馬に限らず、LLMに評価・判断をさせるアプリ全般に効く教訓だと思っています。
- LLMに算数をさせない。 相対化・正規化・集計はコードで決定論的にやり、結果だけ渡す
- 数値アンカーを消す。 生の数字より粗いラベルのほうが正しく扱われることがある
- 自己成績を見せる。 出力と正解のログがあるなら、集計してプロンプトに戻すだけで挙動が変わる
「プロンプトを工夫する」よりも「プロンプトに入れる情報を設計する」ほうが、精度への寄与は大きいというのが運用しての実感です。
矯正後のAIが毎週どんな予想をしているかは、こちらで無料公開しています。
(X: @UmaAI_jp)