バックテスト過剰適合を防ぐ統合検証パイプライン ― V-in-V・CPCV・CSCVによる堅牢な戦略検証
はじめに
アルゴリズムトレーダーであれば、誰もが一度は「出来すぎている」と感じるバックテスト結果に遭遇したことがあるでしょう。エクイティカーブは右肩上がりの美しい階段を描き、シャープレシオは驚異的です。ドローダウンは浅く、しかも短期間で回復しています。
ところが、その戦略を実運用に移した途端に機能しなくなります。こういったケースは決して珍しくありません。
この現象は、定量分析の分野ではあまりにも頻繁に見られるため、独自の呼び名まで付けられています。その元凶は、ほとんどの場合、何らかの形の過剰適合です。つまり、アルゴリズムが将来にも通用する市場構造を学習したのではなく、特定の過去データに含まれる偶然のノイズを学習してしまったのです。しかし、過剰適合は単一の現象ではありません。複数の異なる経路で発生し、それぞれに異なる対策が必要です。そのため、最も一般的な「学習データとテストデータを単純に分割する方法」だけを採用していても、他の種類の過剰適合には無防備なままになります。
本記事では、アルゴリズム売買戦略の開発において過剰適合を防ぐための、最も厳密な手法として知られるTimothy Mastersが提唱したV-in-V (Validation-within-Validation)、Marcos López de Pradoが開発したCPCV (Combinatorially Purged Cross-Validation)、 BaileyとLópez de Pradoが提案したCSCV (Combinatorially Symmetric Cross-Validation)の3つを紹介します。これらはそれぞれ異なる失敗要因に対処するための手法です。そして3つを組み合わせることで、定量分析における代表的な統計的自己欺瞞を包括的に防ぐことができます。

図1:統合された研究パイプライン。CPCVは個々の評価における時間的リーケージを除去し、V-in-Vは研究プロセス全体を管理し、 CSCVは戦略選択プロセスを最終的に定量監査する。
第I部:「壁にスパゲッティを投げる」問題
総当たり探索という誘惑
「壁にスパゲッティを投げる(Spaghetti-on-the-Wall)」という表現は、膨大な数のインジケータ、売買ルール、パラメータの組み合わせを過去データに対して片っ端から試し、「どれか当たるものがあるだろう」と期待する開発手法を指します。現代の計算能力では、このような探索を実行すること自体は極めて容易です。しかし、その結果を正しく解釈することは非常に難しく、誤った結論に至る危険性も極めて高くなります。
問題は数学的にも説明できます。有限のデータセットに対して十分な数の検定を繰り返せば、偶然だけで統計的に有意と思われる結果が必ず現れます。これは研究者の能力や誠実さの問題ではなく、仮説検定を繰り返すこと自体が持つ構造的な性質です。同じデータに対する検証回数が増えるほど、最後に「最良」と判断された戦略が、本当の市場優位性ではなく単なる統計的偶然である確率は高くなります。
主な危険性
データスヌーピングバイアス:同じ過去データを使って何度も異なる仮説を検証すると、最終的に最も成績が良かった戦略が、単に偶然当たりを引いただけである可能性が高くなります。たとえば、同じデータから5個の候補の中で選ばれた戦略と、1万個の候補から選ばれた戦略では、その統計的意味合いはまったく異なります。
カーブフィッティング:過去データに対して自由に最適化を繰り返したアルゴリズムは、多かれ少なかれ、その価格系列特有のノイズまで学習してしまいます。その結果、「過去に起こったこと」に対して高い成績を示します。しかし、それは単に「過去に起こったこと」を学習したからにすぎません。過去のノイズは、未来のシグナルではありません。
研究者の自由度:研究者がどれほど誠実に研究を進めていても、開発を重ねる過程で無意識のバイアスは蓄積します。特徴量の選択、パラメータ範囲の調整、売買ルールの修正など、テスト結果を少しでも参考にして下された判断は、すべて「自由度」として蓄積されます。そして、その積み重ねが最終的な成績を実際以上によく見せてしまいます。この種の汚染は一つひとつの判断では見えませんが、積み重なることで深刻な影響を及ぼします。
「壁にスパゲッティを投げる」ような探索そのものが悪いわけではありません。幅広い探索によって、本当に優位性のある戦略が見つかることもあります。重要なのは、その探索によって必然的に生じる統計的な見せかけと、本物の優位性を区別できるだけの厳密な検証手法を備えているかどうかです。本稿で紹介する3段階の防御をすべて導入しない限り、それを十分に保証することはできません。
第II部:V-in-V (Validation-within-Validation)
標準的なウォークフォワードテストに潜む欠陥
一見すると、標準的なウォークフォワード法は完全な解決策のように思えます。まず過去データで戦略を学習し、その後のアウトオブサンプル期間で性能を検証します。十分な成績が得られれば、そのまま実運用へ移行します。考え方自体は正しいものです。問題は、その運用方法にあります。
研究者は、アウトオブサンプル期間の結果を見ながら、パラメータを調整したり、特徴量を削除したり、複数のルール候補から最終候補を選んだりします。このような意思決定をおこなった時点で、本来は未知であるはずのアウトオブサンプル期間が、実質的に学習データの一部になってしまいます。つまり、研究者自身がその情報を利用してしまったのです。
Mastersはこれを研究者の自由度の蓄積と呼んでいます。これは、目に見えないため、最も危険な過剰適合の一種と言えるでしょう。 データ分割は形式上は正しく見えます。コードにも問題はありません。しかし、アウトオブサンプル結果を見ながら繰り返し意思決定をおこなうことで、通常のデータ分割では検出も補正もできないバイアスが入り込んでしまいます。
3層構造のアーキテクチャ
Mastersが提案する解決策は、データを厳密に3つの領域へ分割し、それぞれに明確に定められた役割を持たせることです。そして、各データ領域は、割り当てられた段階でのみ使用され、他の段階でその情報を先取りして利用しないよう厳密に管理されます。
Total Historical Data │ ├── OUTER TRAINING SET (~60%) ← Full exploratory search occurs here │ ├── INNER VALIDATION SET (~20%) ← Candidate shortlisting occurs here │ (Intentionally exposed to limited candidates) │ └── FINAL TEST SET (~20%) ← Opened exactly once, after full commitment
外側トレーニングセット(Outer Training Set)では、総当たりによる探索を全面的に実施します。数千に及ぶルール、インジケータ、パラメータの組み合わせをここで試します。あらゆる順列検定、正則化、探索的分析は、この範囲の中だけでおこなわれます。研究者は、このデータについては自由に性能を観察して構いません。なぜなら、このデータは探索のために使用することが明示的に定められているからです。
内部検証セット(Inner Validation Set)では、候補戦略を選抜します。第1段階を通過した候補だけを、この時点まで一度も見ていないデータに適用します。ここで性能が大きく低下する候補は、過剰適合している可能性が高いものとして除外します。この段階は慎重に使用します。研究者は、このデータを公開する前に、最終候補となる少数の構成まで候補を絞り込んでおかなければなりません。
最終テストセットは不可侵です。このデータを開くことが許されるのは、研究者が最終的な戦略構成を1つに完全に決定し、単一の最終戦略構成に書面で正式にコミットした後、一度だけです。この一度限りの評価結果が、公表される最終的なパフォーマンスになります。もし、この結果を利用してさらに調整を加えようとしたなら、その時点でテストは無効になります。

図2:3層構造のデータ分割。各領域は、それぞれ明確に定められた1つの役割だけを担う。 最終テストセットは、単一の戦略に対する完全な書面で正式にコミットするまで、決して開いてはならない。
アンカー型ウォークフォワード拡張
この手法が解決する本質的な問題:データを3つ(60%、20%、20%)に分割する方法は、原理的には統計的に妥当です。しかし、実際には非常に厳しい制約があります。たとえば、5年分の日足データしかなく、観測数が1,250本だとします。最終テストセットは20%なので約250本しかありません。これは、シャープレシオを意味のある信頼区間とともに推定するには十分とは言えず、市場レジームに対する頑健性について論じるには、なおさら少なすぎます。一方で、テストデータを増やそうとして分割比率を変更すれば、今度はトレーニングセットが小さくなり、問題がそちらへ移るだけです。
標準的なローリングウォークフォワードとの違い:標準的なウォークフォワードでは、トレーニングウィンドウの長さを固定したまま、それを時間とともに前へスライドさせます。つまり、トレーニングセットはローリングします。古いデータは左端から順に捨てられ、新しいデータが右端に追加されます。その結果、毎回、同じ大きさではあるものの異なるサンプルを使って戦略が一から推定されます。初期の市場レジームは、最終的には学習対象から失われます。
これに対してアンカー型ウォークフォワードは、Mastersをはじめ多くの研究者によって明確に説明されている広く利用されている手法であり、トレーニングセットの開始位置を固定したまま、時間の経過とともに前方へ拡張していきます。トレーニングセットは常に同じ歴史的な起点から始まります。そして、各ウィンドウでは新しいデータが追加されるだけです。一方で、最終テストウィンドウと内部検証ウィンドウは時間とともに前進しますが、それまでに使用されたデータ全体に対する相対的な比率は維持されます。
個々の最終テスト結果を都合よく選び出すことはありません。それらは、一つの証拠群として評価されます。戦略が大部分のテストウィンドウで優位性を維持しているのであれば、その戦略は異なる市場レジームや異なる期間にわたって頑健性を示したことになります。これは、単一のテストセットの結果だけから導ける主張よりも、はるかに強い主張です。
この手法が機能する理由:相互に補完し合う5つの理由
- 各最終テストウィンドウは、真のアウトオブサンプルである:各テストウィンドウに適用される戦略構成は、そのウィンドウが開かれる前に、すでに確定されています。時間的な境界は厳密に守られています。
- テスト結果は、実質的に互いに独立している:各最終テストウィンドウは、カレンダー時間上の異なる、互いに重複しない期間を対象としているため、それぞれのテスト結果は市場パフォーマンスに対するほぼ独立した観測と見なすことができます。ただし、トレーニングデータは互いに重複しているため、パラメータ推定にはある程度の系列相関が生じます。この点は、結果を集約して評価する際に考慮する必要があります。
- アンカー方式は、情報を捨てるのではなく蓄積する:ローリングウォークフォワードでは、ウィンドウが前方へ進むたびに、初期のデータは切り捨てられます。一方、アンカー方式では、トレーニングセットは拡張されるにつれて、過去のすべての履歴データを保持し続けます。
- 複数のテスト結果は、市場レジーム依存性を明らかにする:すべてのテストウィンドウを通じて優位性を維持できる戦略は、対象とした全履歴期間において現れたさまざまな市場環境に対して、その有効性を実証したことになります。一方、一部のウィンドウでは良好な成績を示し、別のウィンドウでは失敗する戦略は、市場レジームに対する真の感応度を示しています。これは、単一のテストウィンドウでは完全に見落としてしまうような知見です。
- 3層構造の完全性は、すべてのウィンドウで維持される:各ウィンドウは、それぞれ独自の内部検証セットを保持しています。V-in-Vを意味あるものにしているライフサイクル上の規律は緩められることなく、各ウィンドウで繰り返し適用されます。
注意点、そして、それが見かけほど重大ではない理由
個々のテストウィンドウは、完全に独立しているわけではありません。後続のウィンドウは、それ以前のウィンドウとトレーニングデータの大部分を共有しているため、各ウィンドウの結果を生み出すパラメータ推定には系列相関が生じます。したがって、これらの結果は、サンプルサイズを過大評価した単一の統合統計量として扱うのではなく、エッジが持続しているか、その大きさが概ね一貫しているかを問う証拠の集合として定性的に評価すべきです。

図3:標準的なウォークフォワード拡張

図4:アンカー型ウォークフォワード拡張。トレーニングセットが拡張される一方で、内部検証セットと最終テストセットが前方へ移動していく3つのウィンドウを示している
実装
以下のコードは、PurgedWalkForwardCVを用いて、V-in-Vの3層パイプライン全体をどのように構成するかを示しています。PurgedWalkForwardCVに渡すのは外部トレーニングセットのみです。内部検証セットと最終テストセットは、それより前の段階で手動で分離しておきます。
import numpy as np import pandas as pd from sklearn.base import clone from cross_validation import PurgedWalkForwardCV def vin_v_anchored_walkforward( X: pd.DataFrame, y: pd.Series, t1: pd.Series, estimator, n_splits: int = 5, inner_val_pct: float = 0.20, final_test_pct: float = 0.20, pct_embargo: float = 0.01, scorer=None, ) -> dict: """ Three-layer V-in-V pipeline with anchored expanding-window walkforward. Data is partitioned once, strictly in temporal order: [─── Outer Training (60%) ───][─ Inner Val (20%) ─][─ Final Test (20%) ─] The outer training set is further split by PurgedWalkForwardCV into n_splits expanding windows, each producing its own purged train/test pair. The inner validation set is touched only for shortlisting; the final test set is opened exactly once after a written commitment to a single config. Parameters ---------- X, y, t1 : aligned DataFrame, Series, Series estimator : sklearn-compatible estimator n_splits : number of anchored walkforward windows within outer training inner_val_pct, final_test_pct : fractions of total data reserved pct_embargo : embargo fraction passed to PurgedWalkForwardCV scorer : callable(y_true, y_pred) -> float, or None (returns raw preds) Returns ------- dict with keys: outer_scores – per-window score within outer training inner_val_score – score on the inner validation set (shortlisting only) final_test_score– score on the final test set (opened once, at the end) outer_cv – the fitted PurgedWalkForwardCV object """ n = len(X) # ── 1. Partition the data into three zones ─────────────────────────────── outer_end = int(n * (1.0 - inner_val_pct - final_test_pct)) val_end = int(n * (1.0 - final_test_pct)) X_outer, y_outer, t1_outer = X.iloc[:outer_end], y.iloc[:outer_end], t1.iloc[:outer_end] X_inner_val, y_inner_val, t1_inner_val = X.iloc[outer_end:val_end], y.iloc[outer_end:val_end], t1.iloc[outer_end:val_end] X_final, y_final = X.iloc[val_end:], y.iloc[val_end:] # ── 2. Phase 1 — exhaustive search within outer training ───────────────── # PurgedWalkForwardCV(expanding_window=True) anchors the start at index 0 # and grows the training window forward — this IS anchored walkforward. outer_cv = PurgedWalkForwardCV( n_splits=n_splits, t1=t1_outer, pct_embargo=pct_embargo, expanding_window=True, # ← anchored, not rolling ) outer_scores = [] outer_models = [] for train_idx, test_idx in outer_cv.split(X_outer, y_outer): model = clone(estimator) model.fit(X_outer.iloc[train_idx], y_outer.iloc[train_idx]) preds = model.predict(X_outer.iloc[test_idx]) score = scorer(y_outer.iloc[test_idx], preds) if scorer else None outer_scores.append(score) outer_models.append(model) # At this point the researcher reviews outer_scores, shortlists candidates, # and selects a small number of finalist configurations. The inner # validation set is NOT touched yet. # ── 3. Phase 2 — shortlist on inner validation (sparingly) ─────────────── # Only finalist models are exposed here. This set must not be used # to refine parameters — observation terminates candidate selection. finalist_model = outer_models[-1] # placeholder: researcher selects this val_preds = finalist_model.predict(X_inner_val) inner_val_score = scorer(y_inner_val, val_preds) if scorer else None # ── 4. Phase 3 — final test (opened exactly once) ──────────────────────── # The researcher commits in writing to finalist_model before this line. # Any adjustment after seeing this result invalidates the test. final_preds = finalist_model.predict(X_final) final_test_score = scorer(y_final, final_preds) if scorer else None return { "outer_scores": outer_scores, "inner_val_score": inner_val_score, "final_test_score": final_test_score, "outer_cv": outer_cv, } # ── Example usage ───────────────────────────────────────────────────────── if __name__ == "__main__": from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import accuracy_score dates = pd.date_range("2018-01-01", periods=1000, freq="B") X_demo = pd.DataFrame(np.random.randn(1000, 5), index=dates) y_demo = pd.Series(np.random.randint(0, 2, 1000), index=dates) t1_demo = pd.Series(dates + pd.Timedelta(days=5), index=dates) results = vin_v_anchored_walkforward( X=X_demo, y=y_demo, t1=t1_demo, estimator=RandomForestClassifier(n_estimators=50, random_state=42), n_splits=5, scorer=accuracy_score, ) print("Outer window scores:", results["outer_scores"]) print("Inner validation: ", results["inner_val_score"]) print("Final test score: ", results["final_test_score"])
第III部:CPCV (Combinatorially Purged Cross-Validation)
CPCVが解決するために考案された問題
V-in-Vは、外部トレーニングセット内の個々の評価そのものが適切であることを前提としています。V-in-Vは研究プロセス全体のライフサイクルを管理しますが、個々の学習/テスト評価の内部的な妥当性までは検証しません。
Marcos López de Pradoが著書Advances in Financial Machine Learningで提案したCPCVは、まさにこの問題を解決するために考案されました。CPCVが対処する汚染は、研究者の行動に起因するものではありません。それは、金融時系列データそのものが持つ統計的性質に内在する、構造的な問題です。
金融データが特別な理由
機械学習で広く用いられている標準的なk分割交差検証は、各観測値が独立同分布に従う(IID, independently and identically distributed)に従うことを前提としています。しかし、金融時系列データは、この前提に大きく、しかも複数の形で重なり合いながら違反しています。
たとえば、特徴量として20日移動平均を使用するとします。21日目に計算される観測値は、20日目に計算された観測値と、その基となる20個のデータのうち19個を共有しています。この2つの観測値が異なるフォールドに割り当てられ、一方がトレーニング用、もう一方がテスト用になった場合、テストフォールドは、対応するトレーニング観測値を生成したデータのほとんどを、実質的にすでに見ていることになります。つまり、トレーニングとテストの境界は見かけだけのものになってしまいます。
この問題は、ラベル付けの方法によってさらに深刻になります。金融機械学習では、観測値のラベルは通常、将来の一定保有期間における結果に基づいて付与されます。たとえば、今後10日間のリターンによってラベルを作成した場合、そのラベルは当該観測値と、その後の10本の価格バーとの間に依存関係を生じさせます。そして、それらの価格バーは、データセットの別の箇所では特徴量の入力としてほぼ確実に使用されます。この結果、標準的なパージだけでは防ぐことのできない情報漏洩の経路が生まれます。
図5は、k = 5のk分割交差検証において実行される、k回の学習/テスト分割を示しています。この方式では、次の手順で処理がおこなわれます。
- データセットはk個の部分集合に分割されます。
- i = 1,…,kについて、次の処理を繰り返します。
- (a) 機械学習アルゴリズムは、i番目以外のすべての部分集合で学習されます。
- (b) 学習済みアルゴリズムは、i番目の部分集合でテストされます。

図5:5分割CVにおける学習/テスト分割
2つのパージメカニズム
パージテストフォールドが割り当てられると、CPCVは、そのラベル生成期間がテスト期間と時間的に重複するすべての観測値を、トレーニングセットから除外します。これは単に期間を分離するという問題ではありません。パージは、それぞれの観測値のラベルがどの期間にわたって算出されたかを考慮しておこなう必要があります。
エンバーゴさらに、テストフォールドの直後に位置する一定数の観測値も、トレーニングセットから除外します。これらのテスト後の観測値から計算される特徴量は、テスト期間をさかのぼって参照する場合があり、その結果、逆方向の情報漏洩が生じる可能性があります。エンバーゴ期間の長さは、特徴量セットの中で最も長いルックバック期間に合わせて設定します。

図6:テスト前のトレーニング観測値のパージと、テスト後のトレーニング観測値のエンバーゴ
組合せによるバックテストパス
パージとエンバーゴに加えて、CPCVは標準的なk分割交差検証に対する構造的な改善を導入しています。標準的なk分割交差検証は、あらかじめ定められたk個の分割に基づいて評価しますが、CPCVはそれを超えて、フォールド割り当ての可能な全組合せを生成します。そして、それらの分割を組み合わせ直して、完全なバックテストパスを構築します。
シャッフルせずに、T個の観測値をN個のグループに分割するとします。このとき、n = 1, …, N − 1の各グループの大きさは⌊T/N⌋、最後のN番目のグループの大きさはT − ⌊T/N⌋ (N − 1)とし、⌊・⌋は床関数(整数部分)を表します。テストセットの大きさをkグループとすると、取り得る学習/テスト分割の総数は次式で与えられます。

各組合せではk個のグループがテストに使用されるため、テスト対象となるグループの総数は
となります。さらに、可能なすべての組合せを生成しているため、これらのテスト対象グループはN個のすべてのグループに均等に分布します。つまり、各グループは同じ回数だけトレーニングセットとテストセットの両方に含まれます。その結果、N個のグループから大きさkのテストセットを用いることで、合計φ[N, k]本のバックテストパスを構築できます。

...C(6, 4) = 15通りの分割があり、それぞれS1~S15と番号付けされています。各分割では、図中で印が付いていないグループがトレーニングセットを構成します。各グループはφ[6, 2] = 5 個のテストセットに含まれるため、この学習/テスト分割方式では5本のバックテストパスを計算できます。
図7は、それぞれのテストグループが5本のバックテストパスのどれに割り当てられるかを示しています。たとえば、パス1は、(G1, S1)、(G2, S1)、(G3, S2)、(G4, S3)、(G5, S4)、(G6, S5)の予測を結合して構成されます。パス2は、(G1, S2)、(G2, S6)、(G3, S6)、(G4, S7)、(G5, S8)、(G6, S9)の予測を結合して構成されます。以降も同様です。
これらのパスは、各組合せについてデータのθ = 1 − k/Nの割合を用いて分類器を学習することで生成されます。理論上はθ < 1/2の割合で学習することも可能ですが、実際にはk ≤ N/2を仮定します。トレーニングセットに含まれるデータの割合θは、N → Tとともに増加し、一方でk → N/2とともに減少します。バックテストパスの本数φ[N, k]は、N → Tとともに増加し、またk → N/2とともに増加します。極限では、N = T、かつ k = N/2 = T/2とすることで、最大数のバックテストパスが得られます。ただし、その代償として、各組合せで分類器を学習する際に利用できるデータは半分だけ(𝜃 = 1∕2)になります。

図7:5本のバックテストパスへのテストグループの割り当て
Lopez de Prado, 2018, p. 164-165
N = 6のグループ、k = 2のテストグループとした場合、各バックテストパスは、複数の分割から得られたアウトオブサンプル区間をつなぎ合わせることで、タイムライン全体を重複なく一度だけカバーします。その結果、C(6, 2) × k / N = 5本の完全なバックテストパスが得られます。シャープレシオを計算し、また分布としての頑健性を評価する際に基礎とすべきなのは、個々の分割ではなく、これらのバックテストパスです。
この分布自体も、有益な情報を提供します。異なるすべてのフォールドの組合せにわたって、各バックテストパスの成績分布が狭い範囲にまとまり、かつ一貫してプラスである戦略は、多様な時間的構成に対して頑健性を示したことになります。一方、バックテストパスによって成績が大きく変動する戦略は、平均的な成績が良好であったとしても、脆弱な戦略であると言えます。

図8:頑健な戦略と脆弱な戦略について、5本のCPCVバックテストパスのシャープレシオを並べて比較した棒グラフ
実装
import numpy as np import pandas as pd from sklearn.ensemble import RandomForestClassifier from combinatorial import CombinatorialPurgedCV, CPCVAnalyzer, optimal_folds_number # ── 1. Configure the CV generator ──────────────────────────────────────── # Use optimal_folds_number to find N and k that meet your targets. # Here: aim for ~600 obs in each training set and 5 backtest paths. n_folds, n_test_folds = optimal_folds_number( n_observations=1000, target_train_size=600, target_n_test_paths=5, ) print(f"n_folds={n_folds}, n_test_folds={n_test_folds}") cv = CombinatorialPurgedCV( n_folds=n_folds, n_test_folds=n_test_folds, t1=t1_outer, # pd.Series: index=event start, values=event end pct_embargo=0.01, # removes 1% of obs after each test block ) print(cv.summary(X_outer)) # Number of Observations 1000 # Total Number of Folds 6 # Number of Test Folds 2 # Number of Test Paths 5 # Number of Training Combinations 15 # ── 2. Fit and predict across all combinatorial splits ─────────────────── # CPCVAnalyzer handles the parallel execution and path recombination. analyzer = CPCVAnalyzer( estimator=RandomForestClassifier(n_estimators=100, random_state=42), cv_gen=cv, close_prices=close_prices, # pd.Series of price for MtM Sharpe calculation ) recombined_preds = analyzer.fit_predict(X_outer, y_outer) # ── 3. Inspect the distribution of path Sharpe ratios ─────────────────── # Each path is an independent backtest covering the full timeline once. # A tight, positive distribution signals robustness. metrics = analyzer.get_distribution_metrics(primary_sides=sides) path_sharpes = metrics.xs("binary", level="method")["mtm_sharpe"] print("Path Sharpe ratios:") print(path_sharpes.to_string()) print(f"Mean: {path_sharpes.mean():.3f} Std: {path_sharpes.std():.3f}") # ── 4. Visualise train/test fold assignments ───────────────────────────── # Run split() fully first so index_train_test_ is complete. _ = [s for s in cv.split(X_outer)] # exhaust generator fig = cv.plot_train_test_folds() fig.show()
第IV部:CSCV (Combinatorially Symmetric Cross-Validation)
保護から測定へ
V-in-VとCPCVは保護のためのメカニズムです。すなわち、研究プロセスを適切に構造化し、データリーケージを除去することで、過剰適合を抑制します。これに対して、CSCVは主として診断および測定のためのツールです。その出力は、バックテスト過剰適合確率(PBO, Probability of Backtest Overfitting)という、たった1つの数値です。
CSCVは、David BaileyとMarcos López de Pradoによって、戦略選択プロセスが生み出した結果のうち、どの程度がインサンプル最適化によるものであり、どの程度が真のアウトオブサンプル予測能力によるものなのかを定量化する手法として提案されました。PBOは、定性的な判断ではありません。反証可能であり、他者に説明可能な指標です。また、候補となる戦略群が存在する研究パイプライン上のあらゆる段階に適用できます。V-in-Vにおける候補の絞り込み段階の後だけに限定されるものではなく、柔軟に利用できる監査ツールとなっています。
手順
まず、履歴データをS個の等しいサブセットに分割します。通常、Sは8から16の間に設定されます。続いて、それらのサブセットを半分をトレーニング用、半分をテスト用として分割するすべての組合せC(S, S/2)を生成します。各分割について、まずインサンプルで最も優れたパフォーマンスを示した戦略を特定します。次に、その戦略が、同じ分割のアウトオブサンプル側において、すべての戦略の中で何位に位置するかを記録します。

図9:CSCVステップ1 - データをS個の等しいサブセットに分割する。その後、それらの半分をインサンプル(IS、緑)、残り半分をアウトオブサンプル(OOS、赤)へ割り当てるすべての組合せC(S, S/2)を列挙する

図10:CSCVステップ2 - 各分割について、インサンプル(IS)で最も優れた戦略(S3★)を特定し、その戦略がアウトオブサンプル(OOS)側で何位にランク付けされるかを記録する。この例では、6戦略中4位であり、中央値を下回っているため、この分割はPBOのカウントに加えられる

図11:CSCVステップ3 - インサンプルで最良だった戦略が、アウトオブサンプルで中央値未満に順位付けされた分割の割合が、PBOの推定値となる。たとえば、PBO = 0.38は、研究プロセスに意味のある選択バイアスが存在することを示唆している

図12:PBOの解釈。PBOが0に近い場合、戦略選択プロセスは、真にアウトオブサンプルで優れた戦略を、一貫して正しく選択できていることを示す。PBOが0.5に近い場合、インサンプルで最良だった戦略は、アウトオブサンプルでは本質的にランダムな順位しか得られていないことを示す。「許容できる」とされるPBOの閾値は、適用する状況や候補戦略群の規模によって異なります。これは経験則であり、厳密な統計的境界ではない
CSCVでできないこと
CSCVは、過剰適合そのものを防ぐものではありません。CSCVは、時間的な情報漏洩を除去することも、研究者の自由度を制御することも、研究プロセスのライフサイクルを構造化することもありません。PBOが高いという結果は、戦略選択プロセスの信頼性が低いことを示しています。しかし、そのプロセスをどのようにすれば信頼できるものになるのかまでは教えてくれません。
CSCVが最も威力を発揮するのは、最終監査として使用した場合です。V-in-Vフレームワークを用いて候補戦略を絞り込んだ後にCSCVを実行すると、戦略選択プロセス全体に対するPBOを算出できます。PBOが低い場合は、その戦略選択プロセスがノイズではなくシグナルを捉えていたことを、定量的かつ他者に説明可能な形で裏付けることができます。ただし、CSCVは戦略の試行結果の集合であれば、どのようなものに対しても適用できます。そのため、最終段階だけでなく、より早い段階でも利用できます。たとえば、外部トレーニングセット内での候補戦略の生成プロセスを監査するために適用することも可能です。
実装
CSCVでは、CombinatorialPurgedCVをそのまま利用します。CSCVでは、すべてのサブセットを対称的に扱います。すなわち、インサンプルとアウトオブサンプルの間には時間的な前後関係を設けません。そのため、pct_embargo=0.0とn_test_folds=n_folds//2を設定します。これにより、前述したC(S, S/2)個の対称的な分割が、そのまま生成されます。
""" Probability of Backtest Overfitting (PBO) — Bailey & Lopez de Prado (2014). Uses CombinatorialPurgedCV with pct_embargo=0.0 and n_test_folds=n_folds//2 to generate the C(S, S/2) symmetric IS/OOS splits required by CSCV. """ from math import comb from typing import Callable, Dict, List import numpy as np import pandas as pd from scipy.stats import norm from combinatorial import CombinatorialPurgedCV def compute_pbo( returns_matrix: pd.DataFrame, t1: pd.Series, n_folds: int = 8, metric: Callable = None, ) -> Dict: """ Compute the Probability of Backtest Overfitting (PBO). Parameters ---------- returns_matrix : pd.DataFrame, shape (T, N) Columns are candidate strategies; rows are time-ordered observations. Values should be per-period returns (or any scalar performance measure that is comparable across strategies at a given time step). t1 : pd.Series Event end-times aligned with returns_matrix.index. Because CSCV is symmetric (no temporal arrow), pct_embargo is set to 0.0, so t1 is used only to satisfy the CombinatorialPurgedCV interface — you may pass t1 = pd.Series(index, index=index) as a neutral value. n_folds : int, default=8 Number of equal subsets S. Must be even. C(S, S/2) splits are generated. Bailey & Lopez de Prado recommend 8–16. metric : callable(returns: pd.Series) -> float, optional Aggregates a column of returns into a scalar performance measure. Defaults to the Sharpe ratio (mean/std). Returns ------- dict with keys: pbo – float in [0, 1]: estimated probability of overfitting n_splits – total number of IS/OOS splits evaluated (= C(S, S/2)) below_median – number of splits where best-IS ranked below OOS median oos_ranks – list of normalised OOS ranks for the best-IS strategy (0 = best OOS, 1 = worst OOS) logit_sr – logit-transformed OOS score (if in (0,1)) for best-IS strategy per split """ if n_folds % 2 != 0: raise ValueError(f"n_folds must be even for symmetric CSCV; got {n_folds}.") n_test_folds = n_folds // 2 # ← symmetric split: half IS, half OOS if metric is None: def metric(r: pd.Series) -> float: """Sharpe ratio.""" return r.mean() / r.std(ddof=1) if r.std() > 0 else 0.0 # ── Build the CSCV generator ────────────────────────────────────────── # pct_embargo=0.0 → no embargo (CSCV is symmetric, not temporal) # n_test_folds=n_folds//2 → exactly half the folds form the OOS set cv = CombinatorialPurgedCV( n_folds=n_folds, n_test_folds=n_test_folds, # = n_folds // 2 t1=t1, pct_embargo=0.0, # ← required for symmetric CSCV ) n_strategies = returns_matrix.shape[1] below_median = 0 oos_ranks = [] logit_sr = [] # ── Iterate over all C(n_folds, n_folds//2) symmetric splits ──────── for train_idx, test_idx_list in cv.split(returns_matrix): test_idx = np.concatenate(test_idx_list) # IS half: compute metric for every strategy is_scores = returns_matrix.iloc[train_idx].apply(metric, axis=0) # OOS half: compute metric for every strategy oos_scores = returns_matrix.iloc[test_idx].apply(metric, axis=0) # Best IS strategy best_is_col = is_scores.idxmax() # Rank of that strategy OOS (0-based; 0 = best OOS performer) oos_sorted = oos_scores.rank(ascending=False) # rank 1 = best oos_rank = oos_sorted[best_is_col] # 1-indexed # Normalise rank to [0, 1]: 0 = best OOS, 1 = worst OOS norm_rank = (oos_rank - 1) / (n_strategies - 1) if n_strategies > 1 else 0.0 oos_ranks.append(float(norm_rank)) # Below median? (norm_rank > 0.5 means worse than median) if norm_rank > 0.5: below_median += 1 # Logit-transformed OOS score (if in (0,1)) for the best-IS strategy oos_sr = float(oos_scores[best_is_col]) logit_sr.append(np.log(oos_sr / (1 - oos_sr)) if 0 < oos_sr < 1 else np.nan) n_splits = cv.n_splits pbo = below_median / n_splits return { "pbo": pbo, "n_splits": n_splits, "below_median": below_median, "oos_ranks": oos_ranks, "logit_sr": [v for v in logit_sr if not np.isnan(v)], } # ── Example usage ───────────────────────────────────────────────────────── if __name__ == "__main__": # Simulate 50 strategy return streams over 500 time steps rng = np.random.default_rng(42) dates = pd.date_range("2020-01-01", periods=500, freq="B") returns = pd.DataFrame( rng.normal(0.0001, 0.01, size=(500, 50)), index=dates, columns=[f"strategy_{i}" for i in range(50)], ) # Neutral t1: event start == event end (no label horizon, no purging effect) t1_neutral = pd.Series(dates, index=dates) result = compute_pbo( returns_matrix=returns, t1=t1_neutral, n_folds=8, # → n_test_folds=4, C(8,4)=70 splits ) print(f"PBO : {result['pbo']:.3f}") print(f"Splits evaluated : {result['n_splits']}") print(f"Below median (IS best → OOS below median): {result['below_median']}") # Interpret: if returns are pure noise, PBO ≈ 0.5 # A strategy with genuine edge should push PBO toward 0
第V部:統合されたパイプライン
各手法を研究プロセスの各段階に対応付ける
これら3つの手法の関係は、V-in-Vによる研究ライフサイクルの各段階に対応付けることで明確になります。これらの手法は互いに競合するものではありません。それぞれがパイプラインの異なる位置を担い、異なる種類の脅威から研究プロセスを保護します。
| 手法 | パイプライン内での位置 | 対処する脅威 | 出力 |
|---|---|---|---|
| CPCV | 外部トレーニングセット内(フェーズ1) | 各学習/テスト評価における時間的情報漏洩 | 評価ごとのクリーンなバックテストパス |
| V-in-V | 研究ライフサイクル全体を統括 | 繰り返しの試行を通じて蓄積する研究者の自由度 | 3つの保護領域を備えたライフサイクル構造 |
| CSCV | 候補戦略の絞り込み後(フェーズ2)、または候補戦略群が存在する任意の段階 | 候補戦略群に対する選択バイアス | PBO(反証可能な確率推定値) |
これら3つの手法は、同じ問題を解決しているのか
これら3つの手法に共通しているのは、金融時系列データでは単純な学習/テスト分割が楽観的な性能評価を生み出してしまうという問題を前提としている点です。しかし、それぞれが対処しているのは、その共通する問題を引き起こす異なるメカニズムです。
Validation-within-Validationは、人間の研究者が開発過程で繰り返しモデリング上の意思決定をおこなうことによって蓄積する行動的な汚染に対処します。この種の汚染は研究者自身によって生じるものであり、最初から一度だけ戦略を選択し、その後に一切の反復的な改善をおこなわなければ発生しません。
CPCVは、金融時系列データの時間的依存性に内在する構造的な汚染に対処します。この汚染はデータそのものに存在するものであり、研究者が反復的な意思決定を一切おこなわなかったとしても残ります。これは金融時系列データが持つ数学的な性質です。
Combinatorially Symmetric Cross-Validation は、戦略選択プロセス全体が過剰適合した結果を生み出した確率を定量化します。これにより、選択された戦略の信頼性について、他の2つの手法では得られない反証可能で、他者に説明可能な評価を提供します。
V-in-Vが要求するライフサイクル上の規律なしにCPCVだけを実行した場合、個々の学習/テスト評価はクリーンになりますが、研究者の自由度は制御されません。たとえば、開発者が50回にわたって戦略を改良し、そのたびにCPCVを実行し、各結果を見ながら次のモデリング上の意思決定をおこなったとします。個々の評価はクリーンであっても、研究プロセス全体は汚染されてしまいます。
逆に、フェーズ1の内部でCPCVなしでV-in-Vだけを実施した場合、最終テストセットそのものは完全に保護されます。しかし、フェーズ1で選ばれた候補戦略が、情報漏洩によって水増しされた評価結果に基づいて選択されている可能性があります。つまり、3層構造は最終テストセットを保護していても、そこへ送り込まれる候補戦略の選抜プロセス自体が損なわれています。
CSCVを省略すると、戦略選択プロセスがどの程度信頼できるものであったかを示す定量的な推定値が得られません。既知の情報漏洩経路からは研究プロセスを守ることができたとしても、選択された戦略が偶然の産物ではなく真の優位性を持っている確率について、監査可能で他者に説明可能な根拠を示すことはできません。
どの手法が最も重要なのか
それぞれの手法を単独で省略した場合に生じるバイアスの重大性という観点から優先順位を付けるのであれば、以下の順序が妥当な出発点となるでしょう。ただし、これは確立された実証的な結論ではなく、著者としての見解です。また、それぞれの重要性は、データセットの長さ、特徴量の複雑さ、そして研究プログラムの規模によって変化することにも注意してください。
1. CPCV:金融時系列データでは不可欠。金融時系列データに対して標準的なk分割交差検証は、単に最適とは言えないというだけではありません。構造的に誤っています。その前提となるIID(独立同一分布)仮定は、実質的にあらゆる金融データの特徴量で破られています。時間的情報漏洩は性能評価を水増しし、その影響は後から補正することはできません。
2.V-in-V:反復的な研究プロセスでは不可欠。実際には、本格的な戦略開発は何週間、あるいは何か月にもわたる反復的な改善を伴います。V-in-Vによる構造的な規律がなければ、その後の意思決定に影響を与えるアウトオブサンプル性能の観測はすべて、考慮されていない研究者の自由度となります。長期間にわたる集中的な研究では、この累積効果は単一の情報漏洩源よりも大きな影響を持つ可能性があります。このことは、実務においてはV-in-VがCPCVと同程度、あるいはそれ以上に重要である可能性を強く示しています。
3. CSCV:研究成果を説明するうえで他に代えがたい価値を持つ。PBOという定量的な出力は、他の2つの手法では得られないものを提供します。すなわち、戦略選択プロセス全体の信頼性を要約した、単一で、根拠をもって説明できる数値です。このため、研究成果がレビューやピアレビューの対象となる場合や、手法の詳細を確認できない関係者へ説明する必要がある組織的な環境では、特に大きな価値を持ちます。
結論
戦略を発見するための「壁にスパゲッティを投げる」ようなアプローチは、必ずしも知的に信用できないものではありません。膨大な数のインジケータ空間を対象とした幅広い探索は、本物の市場における優位性を発見できる可能性があります。ただし、そのためには、総当たり探索によって必然的に生じる統計的ノイズと真のシグナルを区別できるだけの、十分に厳密な検証基盤を研究者自身が構築していなければなりません。
どれか一つの手法だけでは十分ではありません。V-in-V、CPCV、CSCVは、それぞれ異なる失敗要因に対処し、研究パイプラインの異なる段階で機能し、それぞれ異なる形の保護または測定を提供します。一つだけを導入し、残りを省略すれば、重要な弱点が残ります。
CPCVは、探索段階におけるすべての学習/テスト評価から時間的情報漏洩を取り除きます。V-in-Vは、研究ライフサイクル全体を通じて研究者の自由度が蓄積し、最終的な戦略選択の見かけ上の有意性を水増ししてしまうことを防ぎます。CSCVは、戦略選択プロセスそのものが、利用可能なデータに最適化された人工的な成果ではなく、真の優位性を生み出した確率について、定量的で監査可能な推定値を提供します。
この3つの手法を組み合わせることで、バックテストにおけるシャープレシオ(あるいはその他の性能指標)は、研究者が見つけたいと期待した結果を表す数値ではなく、その戦略が実際に提供すると期待できる成果を表す数値へと変わります。
コードリファレンス
この記事全体の実装例は、5つのファイルを基にしています。以下の表では、それぞれの役割と関係をまとめています。
| ファイル | 目的 | 主な内容 | 依存関係 |
|---|---|---|---|
| cross_validation.py | コアとなるパージ処理およびウォークフォワード交差検証(CV)の基本機能を提供します。すべての上位コンポーネントの基盤となります。 | ml_get_train_times:イベントベースのパージをベクトル化して実行します。PurgedKFold:標準的なパージ付きk分割交差検証。PurgedWalkForwardCV:パージおよびエンバーゴを伴うアンカー型(拡張型)またはローリング型ウォークフォワード。ml_cross_val_score:単一の評価指標を使用するCV実行器。analyze_cross_val_scores:混同行列の内訳を含む複数評価指標対応のCV実行器。 | なし(基本モジュール) |
| combinatorial.py | CPCVのエンジンです。すべてのC(N,k)学習/テスト分割を生成し、それらをφ[N,k]個の完全なバックテストパスへ再構成し、パス単位の性能指標の完全な分布を計算します。 | CombinatorialPurgedCV:パージ、エンバーゴ、パス再構成、フォールド可視化機能を備えたscikit-learn互換のCV生成器。CPCVAnalyzer:全パスに対する並列fit/predict処理、およびMtMシャープレシオ分布の分析。optimal_folds_number:目標とするトレーニングサイズおよびパス数に最適に一致する(N, k)の組み合わせを探索します。 | cross_validation.py (ml_get_train_timesをインポート) |
| anchored_walkforward.py | アンカー型拡張ウィンドウを使用して、V-in-Vの3層研究ライフサイクル全体を実装します。外部トレーニング、内部検証、最終テストの各領域を厳密に分離します。 | vin_v_anchored_walkforwardデータを3つの時間領域に分割し、外部領域内でPurgedWalkForwardCV(expanding_window=True)を実行します。内部検証セットは候補戦略の絞り込み時のみ公開され、最終テストセットは書面で正式にコミットした後に一度だけ開かれます。 | cross_validation.py |
| cpcv_usage.py | V-in-Vパイプラインの外部トレーニング領域内でCPCVを構成・実行する方法を示す使用例です。また、パス間のシャープレシオ分布の確認方法、およびフォールド可視化メソッドを正しく呼び出す方法を示します。 | optimal_folds_number、CombinatorialPurgedCV.summary、CPCVAnalyzer.fit_predict、get_distribution_metricsの使用方法を示します。また、plot_train_test_indexおよびplot_train_test_foldsの正しい呼び出し順序も示します。 | combinatorial.py |
| pbo.py | 対称的なCSCV分割用に設定されたCombinatorialPurgedCVを使用して、PBOを計算します。任意の戦略リターン行列と性能指標を受け取り、PBO推定値と各分割におけるOOS順位診断結果を返します。 | compute_pbo:CombinatorialPurgedCVを使用して、CSCVに必要な対称分割を生成します。pct_embargo=0.0およびn_test_folds=n_folds//2を設定し、C(S, S/2)の対称的な分割を作成します。各分割について、インサンプルで最良の戦略を特定し、その正規化されたアウトオブサンプル順位を記録します。さらに、OOS中央値を下回った順位となった分割の割合を集計し、PBO推定値を算出します。 | combinatorial.py |
参考文献
- Masters, T.(1995).Advanced Algorithms for Neural Networks.John Wiley & Sons.
- Masters, T.(2013).Permutation and Randomization Tests for Trading System Development.Self-published.
- Lopez de Prado, M.(2018).Advances in Financial Machine Learning.John Wiley & Sons.
- Bailey, D. H., & Lopez de Prado, M.(2014).The Deflated Sharpe Ratio:Correcting for Selection Bias, Backtest Overfitting and Non-Normality.Journal of Portfolio Management.
- Bailey, D. H., Borwein, J., Lopez de Prado, M., & Zhu, Q. J.(2014).Pseudo-Mathematics and Financial Charlatanism:The Effects of Backtest Overfitting on Out-of-Sample Performance.Notices of the AMS.
- White, H.(2000).A Reality Check for Data Snooping.Econometrica, 68(5), 1097–1126.
MetaQuotes Ltdにより英語から翻訳されました。
元の記事: https://www.mql5.com/en/articles/21603
警告: これらの資料についてのすべての権利はMetaQuotes Ltd.が保有しています。これらの資料の全部または一部の複製や再プリントは禁じられています。
この記事はサイトのユーザーによって執筆されたものであり、著者の個人的な見解を反映しています。MetaQuotes Ltdは、提示された情報の正確性や、記載されているソリューション、戦略、または推奨事項の使用によって生じたいかなる結果についても責任を負いません。
MetaTrader 5における低頻度定量戦略(第1回):OLAP対応データストアの構築
MQL5経済指標カレンダーを用いたニュースフィルタリング(第2回):ニュースリリース中に管理ポジションを停止する
初心者からエキスパートへ:MQL5におけるマルチタイムフレーム構造確認による流動性戦略の強化
グラフ理論:取引における深さ優先探索(DFS)の応用
- 無料取引アプリ
- 8千を超えるシグナルをコピー
- 金融ニュースで金融マーケットを探索
ああ、私の意見は遅れてしまいましたね。ウォーレン・ギディングスさんがすでに的確な指摘をされています。;-)
具体的には、この記事では非常に重要なメタ最適化が裏で省略されている点について触れておきたい。すなわち、インサンプル・ウィンドウとフォワード・ステップサイズの調整だ。ウォークフォワードは、ローリングやアンカーによる制限だけでなく、クラスター・ウォークフォワードによる最適化も存在するからだ。
したがって、記事で説明されたすべての手法は、「いわば」サイズに関するIS/OOSの組み合わせという別の直交次元で再適用し、テスト期間で検証されるべきです。
『 MetaTrader 5 機械学習ブループリント』シリーズの核心です。この記事は、私がここで取り上げたような過学習についてこれまであまり考えたことがなかった読者の方々に、新たな気づきを与えることを意図したものです。そのため、実際に『MLブループリント』シリーズの他の記事とは別個のものとして作成しました。
あ、私の感想は遅れてしまいましたね。ウォーレン・ギディングスさんがすでに的確な指摘をしていましたから。;-)
具体的には、この記事では非常に重要なメタ最適化が裏で省略されている点について触れておきたいと思います。それは、インサンプル・ウィンドウとフォワード・ステップサイズの調整です。ウォークフォワードには、ローリングやアンカーによる制限だけでなく、クラスター・ウォークフォワードによる最適化もあるからです。
したがって、記事で説明されたすべての手法は、「いわば」サイズに関するIS/OOSの組み合わせという別の直交次元で再適用し、テスト期間で検証されるべきです。
素晴らしい仕事ですね!ありがとうございます!