競馬AIの係数を0.700から0.900に変えたら、的中率が7.6pt上がった話
〜ミキサーによる自動算出→手動緩和で逆効果ペナルティを解消したV6の記録〜
予想屋yuji — 71歳・隠居・Ubuntu・Python独学。競馬予想AIを半年間開発中。
前回記事:ダークマター係数を実装したら京都だけ壊れた(V5期間)
はじめに
V5期間(5/24〜5/26)でミキサーによる自動係数管理の仕組みが完成した。
「自動算出 → CSV保存 → ミキサーで管理」のサイクルが回るようになったことで、
ある疑問が浮かんだ。
「この騎手ランク係数を馬に適用できないか。距離に対する能力をランク付けして係数を計算に入れる」
この一言がV6期間の起点となった。
V6期間でやったこと(概要)
| No. | 日付 | 内容 | 分類 |
|---|---|---|---|
| No.40 | 06/02 | 馬距離ランク補正 実装(V6改造の核心) | 新機能 |
| No.41 | 06/04 | result.html 人気取得BUGfix | BUGfix |
| No.42 | 06/04 | 競走除外馬の完全除去 | BUGfix |
| No.43 | 06/04 | コメント一覧をターミナル直接キャプチャ方式に変更 | 改善 |
| No.44 | 06/05 | 倍展開 DB同期(horse_db_sync.py連携) | 新機能 |
そして6月6日に868Rの本番評価を実施した。
ミキサーが本領を発揮した:係数0.700 → 0.900 の緊急緩和
なぜ緊急だったか
V5期間の46R検証で、ある異常が確認されていた。
ランクB騎手が乗っている馬:N=4 で 3着内率 100%(seiki補正なし)
それなのにV58pyはランクB騎手の馬に −30%のペナルティをかけていた。係数 0.700 だ。
これは小標本(N=4〜8)で算出した係数が逆効果になった典型例だ。
ミキサーの操作フロー
python hosei_mixer_v1.py
→ [3] venue_correction 設計モード
→ [2] 競馬場×騎手ランクを手動修正
→ 京都 / ランクB: 0.700 → 0.900
→ 中京 / ランクB: 0.702 → 0.900
→ 福島 / ランクB: 0.700 → 0.900
→ 東京 / ランクA: 0.700 → 0.900
→ [4] venue_correction.csv に保存
✅ 保存完了: 40件(8競馬場 × 5属性)
4箇所を未保存のまま積み上げてから一括保存。
コードを一行も直さずに、係数変更が完了した。 これがミキサーの存在意義だ。
No.40:馬距離ランク補正 — 騎手ランク係数の「横展開」
設計思想の核心
騎手ランク係数のアーキテクチャはこうだ。
実績データ → 乖離率自動算出 → venue_correction.csv に保存
→ ミキサーで管理・更新
→ v58.py が読み込んで total に乗算
このパイプラインをそのまま馬に使う。 それだけだ。
# 騎手ランク係数(既存)
total *= venue_rank_f # 競馬場 × 騎手ランク → 係数
# 馬距離ランク係数(今回追加)
total *= horse_dist_f # 競馬場 × 馬距離ランク → 係数
馬のランク算出:Geminiが提案した2軸アルゴリズム
馬を個別×距離別でサンプルを見ると N問題(小標本問題)に陥る。
Geminiが提案したのは 「全レースから2軸だけ抽出する」 方式だ。
| 軸 | 内容 | 意味 |
|---|---|---|
| Sp(スピード) | 全レースの最高速度(距離÷走破秒) | 短距離適性 |
| St(スタミナ) | 着差1.5秒以内で完走した最長距離 | 長距離適性 |
この2軸でS/M/I/Eの4タイプに自動分類する。
1頭あたり4〜5レースあれば分類できる。N問題を回避できる。
def calc_horse_dist_type(df_db):
"""horse_database_accumulated_v35.csvから全馬の距離適性タイプを算出"""
result = {}
for h_id, grp in df_db.groupby('馬ID'):
# Sp: 全レースの最高速度(m/s)
sp = max(dist / sec for dist, sec in zip(grp['距離_num'], grp['走破秒']))
# St: 着差1.5秒以内で完走した最長距離
st = max((grp.loc[grp['着差秒'] <= 1.5, '距離_num']), default=0)
if sp >= SP_THRESH and st < ST_THRESH:
dist_type = 'S' # 短距離型
elif sp >= SP_THRESH and st >= ST_THRESH:
dist_type = 'M' # 万能型
elif st >= ST_THRESH:
dist_type = 'I' # 中長距離型
else:
dist_type = 'E' # その他
result[h_id] = dist_type
return result
新設ファイル:horse_dist_correction.csv
venue_correction.csv と完全同構造。
10会場 × 4ランク(S/M/I/E)= 40行。
初期係数は全1.000(全馬補正なし)でスタート。
係数1.000なら予想スコアへの影響はゼロ。 安全にデプロイできる。
hosei_mixer_v1.py に [5]🐴 馬距離ランク補正 設計モードを追加
| 機能 | venue_correction | horse_dist_correction |
|---|---|---|
| 自動集計 | auto_scan_venue() |
auto_scan_horse_dist() |
| 係数算出 | calc_venue_proposals() |
calc_horse_dist_proposals() |
| メニュー | [3] 自動集計→改善案提示 |
[3] 自動集計→改善案提示 |
| 確定基準 | N≧10 | N≧10 |
全く同じ構造で実装した。V7以降も同じパターンで横展開できる。
BUGfix 2本:システムの信頼性を高める
No.41:最下位人気が 9999 になる問題
症状: モード3(レース後)実行時、約450Rで14レース、最下位人気馬の人気が9999になる。
原因:
result.html は shutuba.html と列構造が異なる。
先頭に「着順」列が追加されるため td[10] が人気列を指さない。
→ 最下位人気馬だけ isdigit() チェックを通過できず _ninki_map に未登録 → 9999
14レース全件が「競走除外馬」が原因だったことが DEBUG ログで判明。
そして除外馬が予想対象として最終ランキングに登場するという別バグも発覚した。
修正:
# 修正前(列インデックス依存)
_nk = int(_tds[10].get_text()) if len(_tds) > 10 else 0
# 修正後(span タグ優先、インデックスはフォールバック)
_ntag = _row.find('span', class_='OddsPeople') or _row.find('span', class_='Popular')
if _ntag and _ntag.get_text(strip=True).isdigit():
_nk = int(_ntag.get_text(strip=True))
No.42:競走除外馬が予想ランキングに表示される問題
原因: _excluded_ids が try ブロック内のローカル変数で終わり、STEP2以降に引き継がれていなかった。
修正:
# tryブロック末尾:excluded_ids を extracted に保存
extracted['excluded_ids'] = _excluded_ids
# STEP2直前:entries・h_ids・j_ids・t_ids を同時フィルタ
_ex_ids = extracted.get('excluded_ids', set())
if _ex_ids:
extracted['entries'] = [e for e in extracted['entries']
if e['id'] not in _ex_ids]
# h_ids / j_ids / t_ids も同時にフィルタ
最終出力末尾に除外馬サマリーを表示する仕組みも追加した。
──────────────────────────────────
⚠️ 競走除外馬(予想対象外)
──────────────────────────────────
馬番 1 アンジュブルー (除外馬 → 人気未取得・予想対象外)
──────────────────────────────────
No.43:ターミナルキャプチャ方式への変更
xlsxのコメント一覧シートとターミナル出力が乖離していた問題を根本解決した。
変更前: _console_log を約200行の手組みで構築(ターミナルと乖離・省略あり)
変更後: sys.stdout をラップする TeeLogger でリアルタイムキャプチャ
class TeeLogger:
"""sys.stdoutをラップし、出力内容をリストに蓄積するロガー"""
def __init__(self):
self._orig = sys.stdout
self._lines = []
def write(self, s):
self._orig.write(s)
if s.strip():
self._lines.append(s.rstrip())
def flush(self): self._orig.flush()
def get_lines(self): return self._lines
# main() 先頭
_tee = TeeLogger()
sys.stdout = _tee
# main() 末尾(除外馬サマリー出力後)
sys.stdout = _tee._orig
console_log = [(line, 'normal') for line in _tee.get_lines()]
効果: ターミナルに表示される全行がそのままxlsxコメント一覧に記録される。
No.44:CSV → SQLite 倍展開DB同期
なぜ倍展開か
horse_database_accumulated_v35.csv が30,000行に近づいていた。
「今の段階でやるとしたら、CSVはそのまま残して、horse_database.db に取り込む並走構成が最もリスクが低いです」
倍展開の構成:
seiki_v5.py ─┐
reisugo_v58.py ─┼──→ horse_database_accumulated_v35.csv ←── 従来のまま
hiseiki_v10.py ─┘ ↕ 同期
horse_database.db ←── SQLite(新設)
実装はシンプル
horse_db_sync.py を 00_tools/ に1本作り、3本のpyに2行追加するだけ。
# 各pyのstep3_fetch_horse_data()
df_merged.to_csv(HORSE_ACCUM_CSV, ...) # 既存(変更なし)
sync_to_db(df_merged) # ← この1行を追加するだけ
DB同期が失敗しても ⚠️ DB同期スキップ(CSV運用は継続) と表示されてCSV運用は止まらない。ロールバック先のCSVは常に生きている。
動作確認結果:
horse_database.db: 29,051件 / 最新データ 2026/06/03 ✅
868R 本番評価 — V6相当システムの実力確定
なぜ868Rか
V5期間の検証は46Rだった。「標本数が少なすぎて信頼できる数字が出ない」問題があった。
V6では 2/28〜5/31の全データ868R で実施した。
868Rは全て06/02の係数変更完了後に一括生成されており、ダークマター係数+騎手ランク係数(修正済み)+馬距離ランク係数(初期値1.000)が全て適用された「V6相当システム」としての評価だ。
3つの検証ポイント
① V5py vs 新V58py(868R)
| 指標 | V5py(補正なし) | 新V58py(全係数) | 差分 |
|---|---|---|---|
| 1着的中率 | 24.6% | 24.6% | 0.0pt |
| 3着内的中率 | 54.1% | 53.7% | -0.4pt |
ほぼ同率。 係数による補正が「1位選択の変化」よりも「2位以下の順位精度」に効いている段階だ。N蓄積が進むにつれてV58pyが逆転する過程にある。
② 旧V58py vs 新V58py — ミキサーの存在意義
| 指標 | 旧V58py(403R・修正前) | 新V58py(868R・修正後) | 変化 |
|---|---|---|---|
| 1着的中率 | 20.6% | 24.6% | +4.0pt |
| 3着内的中率 | 46.1% | 53.7% | +7.6pt |
3着内的中率が +7.6pt 改善。 これがミキサーの仕事の結果だ。
0.700の逆効果ペナルティを0.900に緩和したことが全体の精度向上に直結した。
③ 1〜6R vs 7〜12R — 自慢ポイント
| 区分 | N | V5py 3着内 | 新V58py 3着内 |
|---|---|---|---|
| 1〜6R(未勝利・新馬) | 406R | 66.3% | 65.3% |
| 7〜12R(実績馬) | 443R | 42.9% | 43.1% |
競走成績の少ない新馬・未勝利馬中心の1〜6Rで 66%超。
実績馬の7〜12Rより約23pt高い。
ダークマター係数+騎手ランク係数が「競走成績データが少ない馬でも機能する」ことを406Rで実証した。
会場別の課題
| 会場 | V5py | 新V58py | 差分 | 評価 |
|---|---|---|---|---|
| 阪神 | 63.4% | 64.5% | +1.1pt | V58優位・係数有効 ✅ |
| 東京 | 50.0% | 53.6% | +3.6pt | V58優位 ✅ |
| 中山 | 58.9% | 57.1% | -1.8pt | ほぼ同等 |
| 福島 | 52.8% | 50.0% | -2.8pt | ほぼ同等 |
| 京都 | 51.1% | 47.4% | -3.7pt | 要追跡 ⚠️ |
| 新潟 | 40.5% | 36.9% | -3.6pt | 最重要課題 ⚠️ |
| 中京 | 49.2% | 35.6% | -13.6pt | 要チューニング ❌ |
| 小倉 | 57.1% | 42.9% | -14.2pt | N少・要検討 ❌ |
中京・小倉・新潟はN不足で係数が全て暫定状態。データ蓄積を優先する。
V6→V9 ロードマップ:横展開するだけ
V6でアーキテクチャが確立した。V7以降は「分類ロジック」だけ変えて同じパイプラインを繰り返す。
| バージョン | 軸 | 新設CSV | 状況 |
|---|---|---|---|
| V6(今回) | 距離適性(S/M/I/E) | horse_dist_correction.csv |
✅ 実装完了 |
| V7(次回) | 上がり型(キレ/持続) | horse_agari_correction.csv |
準備中 |
| V8 | コースリピーター | horse_track_correction.csv |
設計段階 |
| V9 | 覚醒・トレンド | horse_trend_correction.csv |
設計段階 |
ミキサーの [3] 自動集計が全バージョンで使い回せる。 これが横展開の強みだ。
今週の気づき
「良い結果であれ悪い結果であれ、その結果を元に次に進む材料を得る」
868Rの本番評価で、中京 -13.6pt・小倉 -14.2pt という厳しい数字が出た。
これを「失敗」と見るのではなく「次のチューニング材料」として受け取る。
正しい数字が確定すること自体に価値がある。
46Rで信頼できなかった数字が868Rで確定した。それだけで十分だ。
ミキサーの本質
「手動で係数を調整する道具」から「自動算出された係数を検証・微調整する道具」へ。
V9が完成する頃には、ミキサーの役割が変わる。
残る手動補正は「同コース同距離補正」「個別適性補正」「厩舎補正」の3つ程度になる見込みだ。
まとめ
- No.40 :騎手ランク係数と同一アーキテクチャで馬距離ランク係数を実装。Gemini提案の2軸アルゴリズムでN問題を回避。初期値1.000で安全デプロイ。
- No.41・42 :result.html人気BUGfixと競走除外馬の完全除去でシステムの信頼性を向上。
- No.43 :TeeLoggerでターミナルとxlsxコメント一覧を完全一致させた。
- No.44 :CSV→SQLite倍展開でDB同期の基盤を確立。ロールバック先のCSVは常に生存。
- 868R検証 :旧V58pyから +7.6pt 改善・1〜6Rで 66%超。V6相当システムの実力を確定した。
次はV7(上がり型係数)と中京・新潟のN蓄積を優先する。
制作者:yuji(@yujiiwadate0247) — 予想屋yuji 競馬予想システム V6期間 2026/06