バックテストの回収率に信頼区間を付ける — レース単位ブートストラップ
バックテストの回収率に信頼区間を付ける — レース単位ブートストラップ
バックテストで「回収率 112%」という数字が出たとき、その数字はどこまで信用できるでしょうか。競馬の払戻は分散が非常に大きく、特に高配当を拾う戦略では数本の的中が回収率を大きく動かします。この記事では、バックテストの回収率を点推定で終わらせず、レース単位ブートストラップ (cluster bootstrap) で信頼区間を付ける方法を紹介します。
回収率は「点推定」にすぎない
バックテストで計算する回収率は、あくまで「そのテスト期間でたまたま観測された 1 つの値」です。問題になるのは払戻の分散です。
- 単勝 50 倍が 1 本当たると、賭け金 500 レース分の払戻が一撃で入る
- bet 数が 200 件程度の戦略なら、万馬券 1 本の有無で回収率は 20〜30 ポイント平気で動く
- 人気薄を狙う戦略ほど「数本の的中が成績のほぼ全て」という構造になりやすい
つまり同じ戦略でも、テスト期間がほんの少しずれるだけで回収率は大きく変わり得ます。「もう一度同じ条件で別のサンプルを引いたら、どの範囲に収まりそうか」を見積もるのが信頼区間の役割です。
bet 単位の resample はダメ
ブートストラップ自体は古典的な手法です。観測データから復元抽出でリサンプルし、統計量(ここでは回収率)を何千回も計算し直して、その分布から区間を取ります。
ここでやりがちな間違いが、bet を 1 件ずつ独立に resample することです。
同じレースに複数の bet を出す戦略(複数頭の単勝を買う、ワイドを数点買うなど)では、同一レース内の bet は同じレース結果を共有しています。本命が飛んだレースでは、そのレースの bet は全部まとめて外れます。つまり bet 同士は独立ではなく、レース内で強く相関しています。
bet 単位で resample すると、この相関を無視して「全 bet が独立」とみなすことになり、信頼区間が不当に狭く出ます。狭い区間は「この戦略は安定している」という誤った安心感につながるので、過大評価の方向に間違える厄介なバグです。
レース単位で復元抽出する
正しくは、相関の単位であるレースごとにまとめて resample します。手順は次の通りです。
- テスト期間の対象レース(bet を出したレース)を n 個とする
- n 個のレースを復元抽出で n 回引く(同じレースが複数回選ばれてよい)
- 選ばれたレースに属する全 bet を多重度ぶん合算し、賭け金合計と払戻合計から pooled ROI を計算する
- これを 5,000〜10,000 回繰り返し、ROI の分布の 2.5 / 97.5 パーセンタイルを 95% 信頼区間とする
レースを丸ごと引くので、レース内の相関構造はそのまま保たれます。
pandas 実装の罠: 重複レースが潰れる
ここに実装上の落とし穴があります。bet 明細の DataFrame(race_code, bet_amount, return_amount を持つとします)に対して、素直に書くとこうなりがちです。
import numpy as np
race_codes = df['race_code'].unique()
n = len(race_codes)
rng = np.random.default_rng(42)
# NG な例
boot = rng.choice(race_codes, size=n, replace=True)
sample = df[df['race_code'].isin(boot)] # ← 罠
roi = sample['return_amount'].sum() / sample['bet_amount'].sum()
isin は「含まれるかどうか」しか見ないので、復元抽出で同じレースが 3 回選ばれていても 1 回分しか反映されません。これではほぼ「重複なしのサブサンプリング」になってしまい、ブートストラップとして壊れています。同様に、resample 後のデータを groupby('race_code') で集計しても、同じ race_code の draw が 1 グループに潰れます。
明細の粒度を保ったまま正しくやるなら、draw ごとに suffix を付けて別レース扱いにします。
parts = []
for i, rc in enumerate(boot):
part = df[df['race_code'] == rc].copy()
part['boot_race_id'] = f'{rc}_{i}' # 3 回選ばれたら 3 つの独立レースになる
parts.append(part)
sample = pd.concat(parts, ignore_index=True)
# これなら groupby しても潰れない
per_race = sample.groupby('boot_race_id').agg(
bet=('bet_amount', 'sum'),
ret=('return_amount', 'sum'),
)
roi = per_race['ret'].sum() / per_race['bet'].sum()
高速な実装: 先にレース単位へ集約する
pooled ROI だけが欲しいなら、もっと簡単で速い方法があります。先にレースごとの賭け金合計・払戻合計に集約してから、その行を index で復元抽出するやり方です。iloc に重複した index を渡せば多重度はそのまま反映されます。
race_stats = df.groupby('race_code').agg(
bet=('bet_amount', 'sum'),
ret=('return_amount', 'sum'),
).reset_index(drop=True)
n = len(race_stats)
rng = np.random.default_rng(42)
rois = np.empty(10000)
for b in range(10000):
idx = rng.integers(0, n, size=n) # 復元抽出 (重複 index OK)
s = race_stats.iloc[idx]
rois[b] = s['ret'].sum() / s['bet'].sum()
point = race_stats['ret'].sum() / race_stats['bet'].sum()
ci_lo, ci_hi = np.percentile(rois, [2.5, 97.5])
print(f'回収率: {point:.1%} 95% CI: [{ci_lo:.1%}, {ci_hi:.1%}]')
レース数 1,000 件・10,000 回の反復でも数秒で終わります。suffix 方式は「レースごとの統計量がもっと複雑なとき」のための一般形、と覚えておくとよいです。
信頼区間の読み方
出てきた区間はこう読みます。
- 下限が 100% を割っている: 「真の回収率がマイナス圏でも、この程度の上振れは普通に起こる」という意味です。点推定が 110% でも、下限が 85% なら上振れの可能性を否定できません
- 区間の幅そのものが情報です。幅が 50 ポイントもあるなら、bet 数が少なすぎて何も言えない段階だと判ります
- 高配当戦略ほど分布は右に裾を引くので、平均 ± 標準偏差ではなくパーセンタイルで区間を取るのが安全です
逆に、閾値やパラメータをいろいろ試して一番良かった設定の区間だけを見ると、選択バイアスがかかる点にも注意してください。区間はあくまで「その設定を最初から固定していた場合」の不確実性です。
回収率以外にも同じ枠組みが使える
レース単位ブートストラップは pooled ROI 専用の手法ではありません。race_stats の集約カラムを増やしておけば、同じ resample ループで複数の統計量に一度に区間を付けられます。
- 的中率: レースごとの的中 bet 数と bet 数を集約しておき、resample 後に割る
- 的中時の平均配当: 高配当依存度の確認に使えます
- 戦略 A と B の回収率差: 同じレース集合で両戦略を走らせ、レースごとに両方の成績を持たせて resample すれば、「差」の信頼区間が出ます。同じレースを引くので公平な比較になります
特に戦略比較は、別々にブートストラップした区間の重なりを見るより、差の分布を直接作る方が検出力が高くおすすめです。
まとめ
バックテストの回収率は点推定にすぎず、高配当を含む戦略では特に分散が巨大です。bet 単位ではなくレース単位の復元抽出でブートストラップし、pandas では重複 draw が潰れない実装(suffix 付与か事前集約 + iloc)にするのが肝です。信頼区間の下限まで見て初めて、その数字を議論の土台にできます。
この記事をシェア