JvLink To Importer

その ROI は偶然か? permutation test で確かめる

その ROI は偶然か? permutation test で確かめる

EV 閾値をスキャンして最良の設定を選んだら、バックテストの ROI が 100% を超えた——この数字が「モデルに本当に判別力があった」結果なのか、「閾値を選ぶという手続き自体が作ったノイズの上振れ」なのかは、permutation test (並べ替え検定) で定量的に確かめられます。特別な統計ライブラリは不要で、pandas と乱数があれば書けます。この記事ではレース内シャッフルによる検定の手続きとコード例を紹介します。


何を検定したいのか

知りたいのは「モデルの予測に判別力が全く無かったとしても、同じ選択手続き (閾値スキャン + 最良選択) を踏めばこの ROI は出てしまうのか」です。

そこで、こういう帰無仮説 (null) を機械的に作ります。

  1. レース内で予測値だけをシャッフルする。オッズと結果のペアはそのまま残す
  2. シャッフル後のデータに、観測時とまったく同じ選択手続きを適用して「最良 ROI」を得る
  3. これを何百回も繰り返し、null の最良 ROI の分布を作る
  4. 観測した最良 ROI が、null 分布のどこに位置するかを見る

ポイントは手順 2 です。シャッフル後のデータにも閾値スキャンと最良選択をフルセットで適用します。「閾値を選ぶ」操作が ROI を持ち上げるなら、null 側も同じだけ持ち上がるので、選択手続き由来の盛りが公平に比較できます。


なぜ「レース内」でシャッフルするのか

予測値を全データでまとめてシャッフルすると、「18 頭立てのレースに人気薄の予測値が 18 個集まる」ような現実に存在しない構成が生まれ、null が壊れます。レース内シャッフルなら:

  • 各レースの頭数・オッズ分布・結果 (どの馬が勝ったか) はそのまま
  • 壊れるのは「モデルがどの馬に高い予測値を付けたか」という対応関係だけ

つまり「市場構造は現実のまま、モデルの判別力だけをゼロにした世界」が作れます。検定したいものだけを壊すのが permutation test の設計の肝です。

import numpy as np
import pandas as pd

rng = np.random.default_rng(42)

def permute_within_race(df: pd.DataFrame) -> pd.DataFrame:
"""レース内で予測勝率だけをシャッフルし、EV を再計算する"""
out = df.copy()
out['pred_prob'] = (
df.groupby('race_id')['pred_prob']
.transform(lambda s: rng.permutation(s.to_numpy()))
)
out['ev'] = out['pred_prob'] * out['win_odds']
return out

シャッフル後に EV を再計算するのを忘れないでください。EV はシャッフル対象の予測値から派生する列なので、古い EV のままでは null になっていません。


選択手続きを関数化して null に適用する

観測値と null に同じ手続きを適用するため、「閾値スキャン + 最良選択」を 1 つの関数にまとめます。

THRESHOLDS = np.arange(1.0, 3.0, 0.05)

def best_roi_after_scan(df: pd.DataFrame,
thresholds=THRESHOLDS,
min_bets: int = 100) -> float:
"""閾値をスキャンして最良 ROI を返す (観測でも null でも同じ手続き)"""
best = np.nan
for t in thresholds:
bets = df[df['ev'] >= t]
if len(bets) < min_bets:
continue
roi = (bets['won'] * bets['win_odds']).sum() / len(bets)
if np.isnan(best) or roi > best:
best = roi
return best

observed = best_roi_after_scan(df)

n_perm = 300
null_rois = np.array([
best_roi_after_scan(permute_within_race(df)) for _ in range(n_perm)
])

min_bets (最低購入点数) のような副次条件も、観測時に使ったなら null 側にも必ず入れます。手続きが 1 箇所でも違うと検定の意味がなくなるので、関数を共有するのが一番確実です。


p 値の計算とヒストグラム

p 値は「null の中で観測値以上が出た割合」ですが、+1 補正を入れた次の式を使います。

p_value = (1 + np.sum(null_rois >= observed)) / (1 + n_perm)
print(f'observed best ROI = {observed:.3f}')
print(f'null median       = {np.nanmedian(null_rois):.3f}')
print(f'null > 1.0 の割合 = {np.mean(null_rois > 1.0):.1%}')
print(f'p value           = {p_value:.4f}')

+1 補正の理由は 2 つあります。第一に、観測値自身も「あり得た並べ替えの 1 つ」として数えるのが並べ替え検定の正式な定義であること。第二に、補正なしだと null 300 回で一度も観測値を超えなかったとき p=0 になりますが、それは「300 回では観測できなかった」だけで「確率ゼロ」ではないこと。この式なら p の下限は 1/(n_perm+1) になり、試行回数に見合った主張しかできなくなります。

ヒストグラムで可視化すると直感的です。

import matplotlib.pyplot as plt
plt.hist(null_rois[~np.isnan(null_rois)], bins=30, alpha=0.7, label='null (シャッフル)')
plt.axvline(observed, color='red', label=f'observed (p={p_value:.3f})')
plt.axvline(1.0, color='gray', linestyle='--')
plt.xlabel('best ROI after threshold scan')
plt.legend()

「シャッフルしても ROI>100% は普通に出る」を体感する

このテストを初めて走らせた人がだいたい驚くのがここです。予測値を完全に破壊した null でも、閾値スキャンの最良 ROI が 100% を超えることは珍しくありません。null 分布の右裾は 110% や 120% に届くこともあります。

理由は前述の通り、閾値スキャン + 最良選択という手続き自体に「数十通りの中からノイズの上振れを拾う」力があるからです。レース内シャッフルでも、たまたま高オッズの勝ち馬に高い予測値が割り当てられる並べ替えは一定数あり、どこかの閾値がそれを拾います。つまり ROI>100% という事実だけでは、手続きの産物と判別力を区別できないということです。

だからこそ比較すべきは「観測値 vs 100%」ではなく「観測値 vs 同じ手続きを踏んだ null 分布」です。観測値が null 分布の右端を明確に超えている (p が小さい) なら判別力の証拠になりますし、null 分布のど真ん中にいるなら、その ROI は選択手続きが作った数字と区別がつかない、という結論になります。どちらに転んでも、自分の数字の素性が分かるのは大きな前進です。


実務上の注意点

実際に回すときに引っかかりやすいポイントをいくつか挙げておきます。

  • 試行回数は計算コストと相談で決める。 p の解像度は 1/(n_perm+1) なので、p<0.05 を主張したいなら最低でも数百回は必要です。1 回のスキャンが重い場合は、先に閾値スキャン部分をベクトル化 (レースごとのループをやめて DataFrame 一括処理に) してから回数を増やすのが現実的です。
  • 乱数 seed を固定して再現可能にする。 np.random.default_rng(42) のように seed を固定し、null 分布の配列も保存しておくと、後から「あの検定どうだったっけ」を再現できます。
  • 検定の単位はレースであることを忘れない。 購入点数 n が大きく見えても、的中が特定の開催日に固まっているなら実効サンプルはずっと小さい。null 分布の裾が思ったより広いのは、まさにこの実効サンプルの小ささを反映しています。
  • 「観測値を見てから検定の設計を変えない」。 閾値範囲や min_bets を観測値が有利になるように後から動かすと、検定そのものに後出し選択が混入します。設計は最初に固定して、null 側と共有する関数に閉じ込めましょう。

まとめ

permutation test は「レース内で予測値だけシャッフル → 同じ選択手続きを適用 → null 分布と比較」という 3 ステップで、バックテスト ROI が偶然かどうかを自前のデータだけで検定できます。肝はシャッフルをレース内に限定して市場構造を保つことと、選択手続きを観測側と null 側で完全に共有することの 2 点です。

閾値スキャンで良い数字が出たら、本番投入や設定変更を判断する前に null 分布を 1 枚描いてみてください。数時間の計算で、数ヶ月の運用判断の質が変わります。

この記事をシェア

Post