Yyatmita

過学習を 1 つの数字にする——autoresearch に PBO/CSCV を入れた話

論文『The Probability of Backtest Overfitting』を読んだ翌日に実装して、自分のトレード戦略に当てたら 0.59 STRONG WARN が出た。その数字が示すこと、示さないこと

自分のエージェント基盤を組む#claude-code#autoresearch#backtest#interview

前回から 2 ヶ月、autoresearch は粛々と GBPJPY の探索を続けてきた。今回は探索そのものではなく、探索の信頼性を測る指標を入れた話。

「OOS が通った」と「本当に勝てる」のあいだには大きな谷がある。その谷の深さを 1 つの数字で出すツール——PBO (Probability of Backtest Overfitting) を autoresearch に組み込んだ。

開発担当の Claude Code に、yatmita 記者が聞いてみた。


1. 75 実験回したあたりで不安になった

記者: 久しぶりに autoresearch の話ですね。

開発者: GBPJPY の探索を続けていて、現行ブランチは exp077 まで来ています。PF も OOS も安定しているんですが、ある不安が消えなかった

記者: 不安?

開発者: 「TRAIN で best だった戦略が、たまたまだった可能性」です。autoresearch は実験を 1 件 keep するたびに OOS (40%) で検証していますが、OOS は単一の split。それで「PF 4.0」と出ても、たまたまその TRAIN/OOS の切り方で良かっただけかもしれない。

記者: 75 件も実験を keep してきたら、その中に「偶然」が混ざる確率は無視できない。

開発者: はい。それで X を眺めていたら、こういうポストが流れてきました。

AIでエッジ候補を探し、すぐにバックテストできる時代になりました。 ただし、その分だけ怖いのが過去データへの過学習です。 見つけたエッジは本物なのか? それともバックテスト上だけの幻なのか?

記者: タイミングが良すぎる。

開発者: 添付の論文が "The Probability of Backtest Overfitting" (Bailey, Borwein, López de Prado, Zhu, 2015) でした。読んだら、まさに私が知りたかったものが提案されていた。

2. PBO とは何か——一言で言うと

記者: PBO というのは?

開発者: TRAIN で best だった戦略が、OOS で median 以下に落ちる確率です。それだけ。

記者: ……それだけ?

開発者: はい。0 から 1 の連続値で「過学習度」を 1 つの数値で表現する。0 に近いほど健全、0.5 はランダム選択と同じ、0.5 を超えるとむしろ TRAIN の評価が逆相関(典型的な過学習)。

記者: ものすごく直感的ですね。

開発者: 著者の López de Prado は『Advances in Financial Machine Learning』(2018) でも繰り返し触れていて、業界では知られた指標です。私が知らなかっただけ。

3. CSCV——PBO を計算する手続き

記者: その確率はどうやって計算するんですか?

開発者: CSCV (Combinatorially Symmetric Cross-Validation) という手法です。3 行で言うと:

  1. データを S 個の等サイズブロックに分ける(S は偶数、論文推奨は S=16)
  2. C(S, S/2) すべての train/test 組合せを列挙する(S=16 なら 12,870 通り)
  3. 各組で「train best 戦略が test 上で何位か」を rank 化 → logit 変換 → 集計

記者: 全組合せ?

開発者: はい。サンプリングではなく全列挙するのが論文の core です。12,870 回の独立した「train で best を選んで test で評価」を集めて、その分布から PBO を出す。

記者: 単一 OOS split に対して 12,870 split やる感じ。

開発者: そういうことです。

4. 実装は思ったよりシンプル

記者: 実装の難易度は?

開発者: 本体は 100 行くらいで書けました。論文の Algorithm 1 が 1 ページに収まっていて、参照実装も GitHub に複数ある。

記者: 速度は?

開発者: ベクトル化の工夫を入れて、CSCV のコア部分は 数百 ms で終わります。

記者: 12,870 通りで数百 ms?

開発者: itertools.combinations でループするのではなく、12,870 通りの組合せを bool 行列 A (12,870 × 16) として保持して、ブロック集計量と行列乗算で全 train/test を一括計算する。numpy の最大の見せ場です。

# 概念コード
combos = list(itertools.combinations(range(S), S // 2))   # 12,870 通り
A = np.zeros((len(combos), S))
A[行散布] = 1.0                                            # train indicator
B = 1.0 - A                                                # test indicator
 
block_sum    = M.reshape(S, -1, N).sum(axis=1)            # (S, N) ブロック和
sum_train = A @ block_sum                                  # (12870, N) 一括算出
sum_test  = B @ block_sum
# あとは Sharpe 計算と rank → logit → 集計

記者: ループが消える。

開発者: はい。バックテスト本体(16 戦略の M15 データを 2 年分回す)は ProcessPoolExecutor(mp_context="fork") で並列化して、それでも 16 戦略全体で約 5 分かかります。律速はバックテスト、CSCV 自体は誤差

5. ハマったのは実装より「テストの期待値」

記者: 実装で詰まったところは?

開発者: 実装本体じゃなく、ユニットテストの期待値設計で詰まりました。これが想像の 3 倍ハマった。

記者: どういうこと?

開発者: 「i.i.d. ノイズ N=16 戦略 → PBO ≈ 0.5」というシナリオを書いた。論文の null hypothesis 通り、skill ゼロの戦略集合なら rank はランダム化されて PBO は 0.5 に収束するはず

記者: 出なかった?

開発者: seed=42 で実行したら PBO = 0.2632。期待は [0.40, 0.60] だったので大きく外れた。

記者: バグ?

開発者: 最初はそう思って色々試した。

  • rank の同点処理を変える → 0.2632
  • 標本標準偏差の ddof を変える → 0.2632
  • Sharpe の代わりに t-statistic にする → 0.2632
  • ベクトル化版と素朴 brute-force 実装が完全一致 → 0.2632

何を変えても 0.2632 ± 数%。algorithm bug ではない

6. Monte Carlo で謎が解けた

記者: 原因は?

開発者: 50 seeds の Monte Carlo を回したらわかりました:

mean PBO = 0.5155
std      = 0.21
min      = 0.0234, max = 0.9425
[0.40, 0.60] に入るのは 50 中 15 seed = 30%

記者: ばらつきが想像以上に大きい。

開発者: はい。私は「12,870 trials の平均だから std は ±0.004 くらい」と素人考えで思い込んでいた。実際は 12,870 trials は M データを共有していて effective independence が低い。実効的には数十回の試行と同程度。

記者: 一発で 0.5 に収束する世界じゃない。

開発者: そうです。seed=42 はたまたま「strategy 10 が +6.5e-5 mean の lucky column」を引いただけで、それが train の argmax を 40% 占めて test rank でも上位になって PBO が 0.27 になった。現象としては正しい

7. 「ほぼ同一 + 微小ノイズ」の罠

記者: どう直したんですか?

開発者: ここでも 1 回ハマりました。research フェーズで自分が書いたメモに、こういう推奨があった。

「ほぼ同一だが微小ノイズで rank がランダム化される設定」(M = base + noise(1e-3)) にすると PBO ≈ 0.5 が出る

これを「fixture の正しい書き方」だと解釈して書き直した。でも同じく PBO = 0.2632 が出た

記者: 直ってない。

開発者: よく読み返したら、同じメモの 5 行下にこう書いてあった。

「合成データを微小に揺らして同点を散らす」と表面上 PBO≈0.5 が出るが、これは DOD-3 を実装に合わせて歪めただけ

記者: 真逆だった。

開発者: 「やめろ」と書いてある手法を「推奨」と読み違えていた。同じメモのガード節には、正しい方法が書いてあった:

合成データは np.zeros((T, N)) のような完全に同一でテストを書く。期待値は「PBO=1.0(rank 1 位の TEST 順位は tie-break 次第)」と論文準拠で書く。

記者: 全 0 で PBO = 1.0?

開発者: はい。全戦略が同一 returns だと、決定的 tie-break(ordinal rank)で常に戦略 0 が train で argmax、戦略 0 の test rank が 1(最弱)→ ω = 1/17 → logit < 0 → 全 12,870 splits で logit < 0 → PBO = 1.0 厳密

記者: finite-sample variance の影響を受けない、決定論的な期待値。

開発者: これが正しい test 設計でした。自分のメモを誤読していたのが原因。

8. 自分の戦略で測ったら STRONG WARN

記者: で、ようやく自分の戦略に当てたんですよね。

開発者: はい。現行 exp072 の GBPJPY 戦略で、PARAM_GRID は {time_limit_bars: [30, 44, 60, 80], tp_atr_mult: [1.0, 1.5, 2.0, 2.5]} の 4×4 = 16 戦略。train 期間 2022-03-30 から 2024-12-30 の 68,721 bars。

PBO (sharpe) = 0.5873  → STRONG WARN
PBO (pf)     = 0.5768  → STRONG WARN
mean_logit = -0.60  median = -0.88  std = 1.08

記者: 0.5 を超えている。

開発者: 「TRAIN で best だった戦略が OOS で median 以下に落ちる確率が 58%」です。判定基準では PBO > 0.5STRONG WARN——「探索プロセスが過学習傾向。keep しても OOS 再現困難」。

記者: ……「現行戦略はダメ」?

開発者: ここが一番伝えたいところです。

9. 数字の解釈で間違えやすい

記者: ダメじゃない?

開発者: PBO=0.59 が示しているのは「現行戦略 exp072 自体がダメではなくて、「この time_limit_bars × tp_atr_mult の grid search が overfit している」です。

記者: どう違うんですか?

開発者: PBO は「与えた N 戦略の中で TRAIN best がどれだけ意味あるか」を測る指標です。私が与えたのは「同じ exp072 ロジックの近傍 16 戦略」。それが過学習傾向だった、というだけ。

記者: ロジック自体は別の話。

開発者: そう。実は実装前の research フェーズで、参考資料がこう予言していました:

tp_atr_mult / time_limit_bars は exp059-064 で何度も振られた軸 → そりゃ過学習する

記者: 何度も振った軸ほど過学習しやすい。

開発者: はい。なので PBO=0.59 が出たのはツールが期待通り機能した証拠で、「探索方針として、この 2 軸の grid search はもう続ける価値がない」というシグナルとして読むべき。戦略を捨てるシグナルではない

10. PBO で何を測れて、何を測れないか

記者: ツールの限界も整理しておきたい。

開発者: 3 点あります。

1 つ目は 戦略集合の選び方が診断の質を決めること。近傍 grid だけ与えると「この grid search の過学習度」しか測れない。ロジック軸(エントリートリガー種類、ADX 閾値、フィルター有無)を加えると「真の戦略多様性での頑健性」が見えるようになる。

2 つ目は 過去 commit を遡って測ると別の情報が出ること。autoresearch なら過去 75 commit を再走させて「探索プロセス全体の過学習度」を測ることもできる。これは別タスクとして残してある。

3 つ目は 単一通貨ペアの結果を一般化しないこと。GBPJPY だけで測った PBO は GBPJPY 固有。USDJPY や EURUSD で別途測るべき。

記者: PBO=0.59 から言えるのは「この grid を振り続けるな」だけ。

開発者:それ以上のことは言わない」が正確です。「戦略 exp072 自体を信じるな」とは PBO は言っていない。

11. 教訓——PBO は「OOS を信じる前に押すボタン」

記者: 今回のまとめは?

開発者: PBO は「OOS が通った戦略を信じる前に 1 回押すべきボタン」だと思います。

押すと:

  • 「あ、これ近傍 grid 振り回しすぎてるな」(PBO > 0.5)
  • 「これは真に頑健っぽい」(PBO < 0.25)

が見える。TRAIN/OOS の単一 split より遥かに情報量がある

記者: 注意点は?

開発者:ボタンが返した数字の解釈は人間の責任」です。PBO 高い → 戦略捨てる、という直結はしない。どこが過学習してるかを読み解く材料として使う。

記者: 実装の感想は?

開発者: 一番強く感じたのは、**「論文を読んだ翌日に試せる時代になった」**ということです。論文紹介ブログを書く時代から、論文を読んだその日に Claude Code で実装して、当日中に自分のデータに当てて、判断材料を手にする時代になっている。

記者: PBO=0.59 という数字が今日の収穫?

開発者: いえ、「PBO=0.59 だから次は何の軸を試すか」が今日の収穫です。数字そのものより、数字が指している方向。次は tp_atr_mult を弄るのをやめて、エントリートリガーのロジック自体を変えた集合で PBO を測り直そうと思います。

記者: 探索方針が 1 つ明確になった。

開発者: はい。PBO=0.59 が出てくれて良かった。


前回の記事: git worktree でエージェントを並行実行する——5回失敗して動いた構成

この記事は Claude Code(開発担当)への実際のインタビューをもとに構成しています。

本記事はバックテスト手法の技術的検証記録であり、特定の金融商品の売買を推奨するものではありません。投資判断はご自身の責任でお願いします。