📝 この記事は Zenn で先に公開した記事の再掲です。最新版とコメントは Zenn をご参照ください。
TL;DR
- 個人開発の競馬予想ML(UmaScore)を2026-04-18からフォワードテスト(FT)に投入したところ、バックテスト(BT)回収率 118.4% に対し、FT回収率(単勝)42.4% という大きな劣化が生じた(2026-06-07時点・約7週間累計)。
- これはバグでも実装ミスでもなく、時系列ML・ファイナンス系MLでは構造的に発生する「想定内の劣化」だと判断している。本記事では、その判断根拠を4つの構造(survivorship bias / look-ahead bias / regime shift / 市場効率性)に分解して解説する。
-
3週〜3ヶ月の初動劣化を「戦略破綻」と即断しない規律を、コード上の対策(
race_date < ?フィルタ4箇所)とセットで運用している。 - 読者として想定するのは、自分でも時系列ML(競馬・株・為替・需要予測)を書こうとして、「BTで好成績が出たので本番に投入したら全然合わない」を経験した(あるいは経験する直前の)データサイエンティスト・個人開発者。
1. 起きていること(事実関係)
UmaScoreは中央競馬を対象に、過去走・血統・コース適性・調教などからスコアを算出し、オッズと突き合わせてEV(期待値)を出すツールです。BTで複数戦略を比較したうえで、回収率が105%を超えたものだけをFT(実環境シミュレーション)に投入しています。
| 段階 | 期間 | 馬券種 | 回収率 | 母数 |
|---|---|---|---|---|
| バックテスト | 2025-01-01〜2026-04-12(16ヶ月) | 単勝(EV≥1.5) | 118.4% | 4,684点 |
| フォワードテスト | 2026-04-18〜2026-06-07 | 単勝 | 42.4% | 640点 |
| フォワードテスト | 2026-04-18〜2026-06-07 | 複勝 | 79.8% | 152点 |
注:FT中の実取引はしていません。スナップショットを基に「もし買っていたら」を記録するペーパートレードです。馬券種は単勝・複勝の2本柱。ワイドはBT段階で実運用困難と判定されFT未投入で、再設計中です(本稿の議論対象は単勝+複勝)。
数値だけを切り取れば「BTから76ポイントの劣化」「単勝はマイナス収支」です。これを強い結論として扱わないのが本記事のポイントです。
2. BT→FT劣化を引き起こす4つの構造要因
2.1 Survivorship Bias(生存者バイアス)
「過去データで残っている戦略・銘柄・モデル」は、良い結果を出したから残っているのであって、未来も同じ性能を出すとは限らない、というのが survivorship bias の核です。
競馬予想MLで典型的に発生するのは次のパターン。
- ハイパーパラメータ・特徴量・閾値を探索し、BTで最良のものを採用する
- BTのデータは「すでに終わったレース」で構成されている
- その期間のレース分布・人気馬の傾向・賭けやすい配当帯は、未来の分布と一致する保証がない
UmaScoreでも、BTで複数戦略(top_pick / ev_filter / top3_place / kelly)の中から ev_filter が選ばれましたが、これは 「該当期間で最も良かった」という後ろ向きの選択です。FTでは未来の分布に晒されるため、選択の優位性が部分的に剥がれます。
# services/backtest_engine.py の戦略選択(抜粋)
class BacktestEngine:
def run_simulation(
self,
user_id: int,
start_date: str,
end_date: str,
# ... 重みパラメータ群 ...
strategy: str = "top_pick", # top_pick, ev_filter, top3_place, kelly
min_ev: float = 1.0,
min_confidence: str = "C",
bet_unit: int = 100,
) -> dict:
...
問題は、strategy と min_ev と min_confidence の組み合わせを 何十通り も試して、その中の最良を採用してしまうこと。これは multiple comparison(多重比較) によって偶然の最良が拾われやすく、in-sample性能の上振れを増やします。
緩和策
- Walk-forward テスト:時間を区切って、「過去で最適化 → 次区間で評価」を繰り返す。一括最適化より時間ロバスト
- Nested CV:パラメータ探索の Inner CV と評価の Outer CV を分ける
- Out-of-time(OOT)ホールドアウト:BT期間の最後の数ヶ月は最適化に使わず、別評価用に温存する
UmaScoreでは現在、OOT ホールドアウト + FT(リアルタイム検証)の二段構えで運用しています。
2.2 Look-ahead Bias / データリーケージ
時系列MLで一番怖いリーケージは、「学習時に未来の情報を見てしまっていた」が、本番では使えないというやつです。
競馬データの場合、各馬には「過去走の集合」がぶら下がっていて、ある程度実装が複雑になると 対象レースの当日を含めて参照してしまうことがあります。例:
- ❌ ある馬の
Base_Ability_Index(基礎能力指数)を計算するときに、past_racesから 当日のレースも含めて 直近5走の平均タイムを取ってしまう - ❌ 「コース適性」の集計に、当日のレース結果が混入してしまう
これを潰すために、UmaScore では すべての特徴量計算関数の SQL に AND race_date < ? フィルタを4箇所入れています。
# services/prediction_engine.py 抜粋(calculate_base_ability_index)
def calculate_base_ability_index(
horse_id: int,
race_id: Optional[int] = None,
race_date: Optional[str] = None,
) -> tuple[float, list[str]]:
...
with get_db() as conn:
date_filter = ""
params = [horse_id]
if race_date:
date_filter = " AND race_date < ?" # ← 未来情報の遮断
params.append(race_date)
past_races = conn.execute(
f"""
SELECT race_date, venue, race_name, distance, ...
FROM past_races
WHERE horse_id = ?{date_filter}
ORDER BY race_date DESC
LIMIT 5
""",
params,
).fetchall()
同じパターンで、
-
calculate_aptitude_score(コース適性)のpast_races直接参照 - 同関数内の血統(
sire)からの推定で参照するpast_races集計 - 集計時の
race_date比較
の4箇所で race_date < ? を入れています。詳細は前回のQiita記事「競馬予想 ML でデータリーケージを正攻法で潰した話」に書きましたが、ここまでやっても FT 劣化は消えません。理由は次節以降。
2.3 Regime Shift(市場レジームの変化)
ファイナンスMLで一番影響が大きいのに、コードレベルでは対策しにくいのが 市場レジームの変化 です。
競馬は閉じた人工市場のように見えて、実は実環境がリアルタイムに動いている:
- 各騎手・調教師の調子は週単位で変わる
- 季節(春G1シーズン / 夏のローカル開催)でレース傾向が変わる
- 馬場状態が大きくシフトする年がある
- 自分以外の予想家・AIユーザーも進化している(共通の情報がオッズに反映される速度が上がる)
BT期間(2025-01〜2026-04)と FT期間(2026-04-18〜現在)は市場レジームが違う別物として扱うのが安全です。FT劣化の一部は「対策不能なドリフト」によるもので、特徴量設計を改良しても残る可能性があります。
緩和策
- 学習データの reweight:直近データに重みを乗せる
- オンライン学習 / 周期的再学習:四半期ごとに重み更新
- レジーム検出:分布シフトを検知して再学習トリガーにする
UmaScoreは「BT結果を凍結 → FTで観察」を意図的に選んでおり、再学習はFT3ヶ月以上の累計が出てから(軸ABC統合判定のタイミング、8月末-9月)に行う方針です。「ぐらぐら触りながらFTする」と再現性が消えるためです。
2.4 Market Efficiency(市場効率性)と EV戦略の構造的限界
これは多くの人が見落とす論点ですが、「BT回収率が高いほど FT で削れる量も大きい」 という非線形な関係があります。
なぜか。
- BTで回収率118%を叩いた戦略は、該当期間のオッズ歪みを効率よく拾えたから118%だった
- 同じ歪みが未来に存在する保証はない(オッズは市場参加者の集合的予測で決まり、平均的には人気=勝率に近い)
- 中央競馬の単勝市場は 非常に効率的 で、控除除き後の超過リターンを得るには非常に小さな歪みを拾い続ける必要がある
- 期待値1.0をわずかに超える戦略は、ノイズに極めて敏感
UmaScoreでは softmax で勝率を推定して EV を計算しています:
# services/backtest_engine.py
def _softmax_probabilities(scores: list[float], temperature: float = 5.0) -> list[float]:
"""スコアからsoftmaxで勝率を推定"""
if not scores:
return []
max_s = max(scores)
exps = [math.exp((s - max_s) / temperature) for s in scores]
total = sum(exps)
return [e / total for e in exps]
temperature=5.0 は、過去に8.0を試した結果「穴馬のEVが過大評価される」副作用が出たため意図的に下げた値です。この 温度1つ で BT回収率が二桁ポイント動きうるほど、EV戦略は閾値選択に敏感です。FTでは、この敏感さがそのまま「期間ごとのブレ」として顕在化します。
EV戦略の宿命:期待値1.05倍を中央競馬の効率市場で連続的に拾うのは、ボラティリティを大量に飲み込む覚悟が要る。640点で42%まで凹むのは、サンプル数的にもむしろ「ありうる範囲」。
3. なぜこれを「異常ではなく想定内」と判断するのか
ここまでが構造論。次に、プロジェクト側でどう運用判断しているかを書きます。技術記事として有用な部分は「どう判断ロジックを置くか」だと思うので。
3.1 サンプル数で判断しない
640点(単勝)は分散の評価には少ない。中央競馬の単勝平均回収率の標準偏差を粗く見積もると、
- 単勝1点あたりの分散は配当の歪みで大きい(最大数十倍の払戻があるため)
- 640点での回収率の標準誤差は、ざっくり±20-30ポイント規模
つまり「118% → 42%」の76ポイント差は確かに大きいですが、FTサンプル自体の95%信頼区間が広いので、ここで「BT戦略は破綻」と結論するのは早すぎます。1,000-3,000点が貯まる頃合いまで判断を留保します。
3.2 構造劣化は「点」ではなく「期間累積」で見る
balance_history を持っておくと、ドローダウン期と回復期がはっきり分かれます:
# services/backtest_engine.py(抜粋)
balance_history.append({
"race_date": race["race_date"],
"balance": current_balance,
})
# 最大ドローダウン計算
if current_balance > peak_balance:
peak_balance = current_balance
drawdown = peak_balance - current_balance
if drawdown > max_drawdown:
max_drawdown = drawdown
BT期間でも、118.4%という最終値の途中にはかなり深いドローダウンが含まれていました。FT初動の42%は、その経路の一断面に過ぎないという見立てです。
3.3 判定ラインを「先に」決めておく
これが一番大事だと思っていて、初動の数値悪化に動じないためには、判定ラインを始める前に決めておく必要があります。
UmaScoreでは以下のスタンスを取っています:
- 判定タイミング:FT 3ヶ月以上の累計(8月末-9月)で初めて判定する。それまでは数値は記録するだけ
- 判定軸は単独ではなく統合:システム性能(軸A)だけでなく、運営者の発信量(軸B)・家計持続性(軸C)の3軸を統合
- 撤退ラインも先に決めておく:10月までに統合判定で結論を出す(無限延長を機械的に防ぐ)
数値が悪い時に判定軸を後ろにずらすのは、典型的な認知バイアス(コミットメントの一貫性)の発露なので、先に決めて公開しておくことで自分の判断を縛っています。
3.4 「変えない」のも判断のうち
FT中に「数値が悪いから特徴量を変えよう」と動くと、FT自体のサンプルが汚染されます。BTもFTもやり直しになるため、設計を凍結して観察する期間を意図的に取る。
これは試験運用としてはストイックですが、自分が信じる戦略の本当の性能を知るためにはこのストイックさが要ります。
4. 個人開発者がこの種の劣化に向き合うときに最低限やっておくこと
最後にチェックリスト形式で。汎用的に使える項目に絞ります。
| カテゴリ | 項目 |
|---|---|
| データリーケージ | 特徴量計算の SQL に「予測対象時点より前」のフィルタを必ず入れる |
| データリーケージ | 集計対象に「対象レース自身」を含めない(< であって <= ではない) |
| 生存者バイアス | ハイパーパラメータ探索後、最後の数ヶ月をOOTで温存して検証 |
| 生存者バイアス | 採用戦略のサンプル数を記録(何通り試したか) |
| レジーム変化 | BT期間と評価期間の市場分布が違いうることを前提に置く |
| レジーム変化 | 再学習を「ぐらぐら触る」のではなく、計画タイミングで実施 |
| 市場効率性 | BT回収率が高すぎる場合、まずリーケージを疑う |
| 市場効率性 | 期待値1.0付近の戦略は分散が大きいことを承知の上で運用する |
| 運用規律 | 判定ラインを始める前に決めて、初動の数値悪化で動かさない |
| 運用規律 | 「変えない」期間を作って観察する |
5. 補足:個人投機目的では運用していません
技術文脈の記事ですが、競馬は公営賭博であるため一言補足します。UmaScoreはあくまで運営者個人のフォワードテスト記録の公開であり、購入推奨ではなく、利益保証もありません。FT結果は実投票ではなくスナップショット記録です。
加えて、本記事の数値・判定スタンスは結果の良し悪しを訴求する意図はなく、「BTとFTで何ポイント乖離するかをそのまま記録する」ことそのものを価値として扱っています。
6. まとめ
- BT→FT劣化は、time-series ML / 市場系MLにおいて構造的に発生する
- 主要因は survivorship bias / look-ahead bias / regime shift / 市場効率性 の4つ
- リーケージは「ゼロにできる対策」、他3つは「緩和できるが残る要因」と分けて考える
- 初動の数値悪化を即「戦略破綻」と読まないために、判定ラインを先に決める運用規律をコードと別レイヤーで持つ
個人開発で時系列MLを書いている人にとっては、コードの正しさと同じくらい、運用規律の事前定義が大事です。むしろコードの正しさだけ詰めても、判定軸が後ろにブレるとプロジェクトとしては崩れます。
UmaScoreは引き続き FT を継続中で、3ヶ月超の累計が貯まったタイミングで再評価する予定です。FT継続記録と、撤退ラインまで含めた「挑戦の途中経過」は note(note.com/umascore)でより広い読者向けに連載しています。技術的な続報は本媒体(Qiita/Zenn)で、検証の物語はnoteで、と棲み分けています。
参考
- 前回記事:「競馬予想 ML でデータリーケージを正攻法で潰した話 —
race_dateフィルタを4箇所に入れて気づいたこと」(Qiita): https://qiita.com/umascore/items/de0b9f40212d3446fb13 - Marcos López de Prado, Advances in Financial Machine Learning(特に Walk-forward / Triple-Barrier / Combinatorial Purged CV の章)
- UmaScore: https://umascore.com