Quickstart¶
Start with the row-level primitives, then use a splitter with sklearn.
Section 3 builds CPCV prediction paths and shows a separate
deflated-Sharpe example. Section 4 checks a whole search: paths from
your own fitting loop, CSCV and PBO, and the minimum backtest length. Each numbered section runs after a fresh
pip install purgedcv. Run it from top to bottom: the smaller follow-on
snippets reuse variables from that section.
1. Row-level primitives¶
The two functions purge and apply_embargo are the building blocks
every splitter ships on top of. They take positional indices, the
prediction and evaluation timestamps for every row, and return the
training indices that survive.
import numpy as np
import pandas as pd
from purgedcv import apply_embargo, purge
n = 1000
pred = pd.Series(pd.date_range("2024-01-01", periods=n, freq="D"))
evalu = pred + pd.Timedelta(days=5) # 5-day forward label
train_idx = np.arange(0, 800)
test_idx = np.arange(800, 900)
train_kept = purge(
train_idx, test_idx,
prediction_times=pred,
evaluation_times=evalu,
purge_horizon=pd.Timedelta("5D"),
)
train_kept = apply_embargo(
train_kept, test_idx,
prediction_times=pred,
evaluation_times=evalu,
embargo="2D",
)
print(len(train_idx), "->", len(train_kept), "after purge + embargo")
The standalone purge primitive takes a pd.Timedelta for its padding.
Splitter constructors also accept strings such as "5D".
Choosing an embargo unit¶
Every splitter, apply_embargo, PBO, and assert_embargo_respected accepts
the same three mutually exclusive modes:
# Wall-clock duration
apply_embargo(train_idx, test_idx, pred, evalu, embargo="2D")
# Fixed number of rows after each contiguous test block
apply_embargo(train_idx, test_idx, pred, evalu, embargo_observations=10)
# floor(n_samples * 0.01) rows after each contiguous test block
apply_embargo(train_idx, test_idx, pred, evalu, embargo_fraction=0.01)
Use a duration when elapsed time carries the dependency. Use rows or a
fraction for bar-based data where serial dependence is naturally expressed in
observations. CPCV applies the positional window separately after every
non-adjacent test block. Supplying more than one mode raises ValueError.
WalkForwardSplit accepts the same parameters for constructor consistency,
but its training candidates are always strictly before the test fold, so an
asymmetric post-test embargo has no rows to remove. Its effective boundary
control is purge_horizon.
2. PurgedKFold in cross_val_score¶
Every splitter follows the scikit-learn splitter protocol, so it works
inside cross_val_score, GridSearchCV, and Pipeline without glue
code.
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.model_selection import cross_val_score
from purgedcv import PurgedKFold
n, h = 1000, 5
rng = np.random.default_rng(0)
features = rng.standard_normal((n, 4))
labels = rng.standard_normal(n)
pred = pd.Series(pd.date_range("2024-01-01", periods=n, freq="D"))
evalu = pred + pd.Timedelta(days=h)
cv = PurgedKFold(
n_splits=5,
prediction_times=pred,
evaluation_times=evalu,
purge_horizon=f"{h}D",
embargo=f"{h}D",
)
scores = cross_val_score(GradientBoostingRegressor(random_state=0), features, labels, cv=cv)
print("honest R^2 per fold:", scores)
PurgedGroupKFold (entity-level holdout) and WalkForwardSplit
(chronological train-on-the-past, expanding or rolling) follow the same
constructor pattern.
Audit what each fold removed¶
Inspect the splitter first. Before a large model search, call
audit_splitter. It uses the same internal fold pipeline as split() and
reports each filtering stage rather than trying to reconstruct it from the
final indices.
from purgedcv import audit_splitter
report = audit_splitter(cv, features)
print(report[[
"fold",
"candidate_train_size",
"final_train_size",
"train_nonempty",
"rows_removed_by_purge",
"rows_removed_by_embargo",
"rows_removed_by_finalization",
"rows_added_by_finalization",
"candidate_overlap_fraction",
"final_overlap_fraction",
"train_block_count",
"test_block_count",
]])
candidate_overlap_fraction describes the unfiltered candidate train set
using the splitter's configured purge_horizon. For a non-empty candidate set
it equals rows_removed_by_purge / candidate_train_size; it is defined as zero
when the candidate set is empty. Indices returned by cv.split() are already
final, so passing them to compute_overlap_fraction reproduces
final_overlap_fraction, not the candidate value. The standalone function can
reproduce the candidate value only when you already own the pre-purge indices
of a custom split and pass the same purge horizon.
For built-in splitters, final_overlap_fraction should be zero and
temporal_leakage_free true. On an empty train set that truth is vacuous, so
check train_nonempty before treating a fold as usable. A sliding
WalkForwardSplit performs its window truncation during finalization, hence
the count appears under rows_removed_by_finalization. Built-in splitters
never add rows there; a positive rows_added_by_finalization flags a custom
finalizer that introduced or swapped indices.
The four time-envelope columns are outer bounds, not claims that the rows form
one continuous window. CPCV folds can have several disjoint blocks whose train
and test envelopes overlap while the actual horizons remain clean; use
train_block_count, test_block_count, and final_overlap_fraction together.
Subclasses that override split() cannot be audited and raise a clear error;
customize the documented internal hooks instead. Malformed inputs and final
indices also fail validation normally.
Sample weights¶
The splitters carry no scorer of their own; they stay drop-in to
scikit-learn, so sample weights travel through sklearn's own metadata
routing rather than through any purgedcv argument. Enable routing once,
then have the estimator request the weight for fit. The split itself is
unaffected: weights ride along with the rows each fold keeps.
import numpy as np
import pandas as pd
import sklearn
from sklearn.linear_model import Ridge
from sklearn.metrics import make_scorer, mean_squared_error
from sklearn.model_selection import cross_val_score
from purgedcv import PurgedKFold
n, h = 300, 5
rng = np.random.default_rng(0)
features = rng.standard_normal((n, 3))
labels = rng.standard_normal(n)
weights = rng.uniform(0.5, 1.5, n)
pred = pd.Series(pd.date_range("2024-01-01", periods=n, freq="D"))
evalu = pred + pd.Timedelta(days=h)
cv = PurgedKFold(n_splits=5, prediction_times=pred, evaluation_times=evalu,
purge_horizon=f"{h}D")
sklearn.set_config(enable_metadata_routing=True)
# Train-time weighting only. The estimator requests the weight for fit and
# explicitly declines it for score, so cross_val_score knows where it goes.
est = Ridge().set_fit_request(sample_weight=True).set_score_request(sample_weight=False)
scores = cross_val_score(est, features, labels, cv=cv, params={"sample_weight": weights})
# Weight the score as well: route the weight into fit and into a scorer that
# also requests it. Without this, the default scorer leaves the test fold
# unweighted.
est = Ridge().set_fit_request(sample_weight=True)
scorer = make_scorer(mean_squared_error, greater_is_better=False).set_score_request(
sample_weight=True
)
scores_w = cross_val_score(est, features, labels, cv=cv, scoring=scorer,
params={"sample_weight": weights})
The gotcha is the second case: if you route sample_weight but leave the
default scorer, sklearn raises UnsetMetadataPassedError because the scorer
neither requested nor declined the weight. Decline it with
set_score_request(sample_weight=False) for train-only weighting, or pass a
scorer that requests it for weighted evaluation.
3. CPCV + backtest paths + deflated Sharpe¶
CPCV fits on each purged fold and assembles out-of-sample predictions into backtest paths. With six groups and two test groups per fold, there are 15 folds and five paths. The example below uses random regression targets, not market returns.
import numpy as np
import pandas as pd
from sklearn.linear_model import Ridge
from purgedcv import CombinatorialPurgedCV
n = 800
rng = np.random.default_rng(0)
features = rng.standard_normal((n, 5))
labels = rng.standard_normal(n)
pred = pd.Series(pd.date_range("2024-01-01", periods=n, freq="D"))
evalu = pred + pd.Timedelta(days=3)
cpcv = CombinatorialPurgedCV(
n_splits=6,
n_test_groups=2,
prediction_times=pred,
evaluation_times=evalu,
purge_horizon="3D",
)
paths = cpcv.backtest_paths(Ridge(), features, labels)
print("Prediction paths:", paths.shape) # (5, 800)
These are predictions. They are not strategy returns, and five CPCV paths are not five independent model trials. A trading application must define how predictions become positions and then account for realised returns and costs before computing a Sharpe ratio.
Deflated Sharpe needs a return series¶
Here is a separate example. Simulate 20 independent candidate return series, then select the candidate with the highest observed Sharpe. DSR takes that candidate's full return series, not its scalar Sharpe. The correction uses all 20 trials and the variance of their per-observation Sharpes, including the candidates that lost the selection.
import numpy as np
from purgedcv import deflated_sharpe_ratio
rng = np.random.default_rng(42)
n_trials, n_obs = 20, 252
trial_returns = rng.normal(0.0, 0.01, size=(n_trials, n_obs))
trial_sharpes = trial_returns.mean(axis=1) / trial_returns.std(axis=1, ddof=1)
best_trial = int(np.argmax(trial_sharpes))
selected_returns = trial_returns[best_trial]
var_sharpe = float(trial_sharpes.var(ddof=1))
dsr = deflated_sharpe_ratio(
selected_returns,
n_trials=n_trials,
var_sharpe=var_sharpe,
)
print("Deflated Sharpe probability:", dsr)
The simulated trials are independent by construction. Real model searches often produce correlated candidates; the trial count then needs more care. See the Optuna cookbook for recording a search and estimating an effective count. Keep Sharpe units consistent: this example does not annualise them.
For a worked time-series regression example, see
examples/energy_demand_pjm.ipynb.
4. Checking a search: own fit loop, CSCV, PBO and MinBTL¶
backtest_paths fits a scikit-learn estimator for you. When you fit each
fold yourself, for example with a custom training loop or a model outside
scikit-learn, collect one prediction array per fold in split order and
pass the list to reconstruct_paths. The result has the same layout as
backtest_paths.
import numpy as np
import pandas as pd
from sklearn.linear_model import Ridge
from purgedcv import CombinatorialPurgedCV
n = 600
rng = np.random.default_rng(1)
features = rng.standard_normal((n, 4))
labels = features @ np.array([0.4, -0.2, 0.0, 0.1]) + rng.standard_normal(n)
pred = pd.Series(pd.date_range("2024-01-01", periods=n, freq="D"))
evalu = pred + pd.Timedelta(days=2)
cpcv = CombinatorialPurgedCV(
n_splits=6,
n_test_groups=2,
prediction_times=pred,
evaluation_times=evalu,
purge_horizon="2D",
)
fold_predictions = []
for train_idx, test_idx in cpcv.split(features):
model = Ridge().fit(features[train_idx], labels[train_idx])
fold_predictions.append(model.predict(features[test_idx]))
manual_paths = cpcv.reconstruct_paths(fold_predictions)
print("Paths from the manual loop:", manual_paths.shape) # (5, 600)
The module-level purgedcv.reconstruct_paths does the same job when you
only have the fold predictions and fold test indices, without the
splitter object.
CSCV and the probability of backtest overfitting¶
CombinatoriallySymmetricCV is CPCV with half of the blocks held out in
every split. probability_of_backtest_overfitting runs it over a matrix
of candidate return series. In each split it picks the candidate with the
best in-sample Sharpe and checks where that candidate ranks out of sample.
PBO is the share of splits where it lands below the median. In the example
none of the 50 candidates has an edge, so PBO should sit near 0.5.
import numpy as np
import pandas as pd
from purgedcv import CombinatoriallySymmetricCV, probability_of_backtest_overfitting
rng = np.random.default_rng(7)
n_configs, n_obs = 50, 960
candidate_returns = rng.normal(0.0, 0.01, size=(n_configs, n_obs))
days = pd.Series(pd.date_range("2022-01-03", periods=n_obs, freq="B"))
cscv = CombinatoriallySymmetricCV(
n_splits=8, prediction_times=days, evaluation_times=days
)
in_sample, out_of_sample = next(cscv.split(candidate_returns.T))
print("CSCV splits:", cscv.get_n_splits()) # C(8, 4) = 70
print("Rows per half:", len(in_sample), len(out_of_sample)) # 480 480
result = probability_of_backtest_overfitting(candidate_returns, n_splits=8)
print(f"PBO {result.pbo:.2f} over {result.n_combos} splits") # PBO 0.57 over 70 splits
Minimum backtest length¶
The best of 50 noise candidates still shows a positive annualised Sharpe.
minimum_backtest_length gives the number of years a backtest needs
before an annualised Sharpe of a given size is more than the best of that
many lucky tries. This sample has fewer years than that, so even a Sharpe
of 1 would not be evidence of skill.
from purgedcv import minimum_backtest_length
annual_sharpes = (
candidate_returns.mean(axis=1) / candidate_returns.std(axis=1, ddof=1) * np.sqrt(252)
)
years_of_data = n_obs / 252
min_years = minimum_backtest_length(n_configs, target_sharpe=1.0)
print(f"Best annualised Sharpe: {annual_sharpes.max():.2f}") # 0.85
print(f"{years_of_data:.1f} years of data; MinBTL {min_years:.1f} years") # 3.8 vs 5.2
examples/backtest_overfitting_audit.ipynb runs the same
checks on a real Optuna search over BTC/USDT strategies.