Backtest overfitting and Optuna¶
Estimate selection overfitting with PBO, or record a search for the
deflated-Sharpe calculation.
The Optuna integration is optional; install purgedcv[optuna] to run an Optuna study.
Backtest overfitting¶
purgedcv.PBOResult
dataclass
¶
PBOResult(pbo: float, logits: NDArrayAny, slope: float, is_oos_performance: NDArrayAny, n_combos: int)
Return type of probability_of_backtest_overfitting.
A frozen dataclass: read fields by attribute (result.pbo), and call
dataclasses.asdict if you need a plain dict to serialise.
Attributes:
| Name | Type | Description |
|---|---|---|
pbo |
float
|
Probability of backtest overfitting in [0, 1]. The fraction of
CSCV combinations whose in-sample-best configuration ranked
below the OOS median (equivalently, logit < 0). Read it as
|
logits |
NDArrayAny
|
Per-combination logit of the OOS relative rank of the
in-sample-best configuration. Negative values are the overfit
cases; the distribution's mass below 0 is |
slope |
float
|
OLS slope of OOS performance on IS performance across the
combinations (for the IS-best configuration). Direction is the
signal: |
is_oos_performance |
NDArrayAny
|
|
n_combos |
int
|
Number of CSCV combinations scored, |
purgedcv.PerformanceMetric
module-attribute
¶
A configuration-performance metric: maps a 1-D return slice to a scalar where larger is better.
Pass this callback as metric to
probability_of_backtest_overfitting
to score and rank candidate configurations. The default is a Sharpe ratio
computed as mean return divided by sample standard deviation. Negate a loss
if smaller values would otherwise mean better performance.
purgedcv.probability_of_backtest_overfitting ¶
probability_of_backtest_overfitting(returns: NDArrayAny, n_splits: int = 16, *, metric: PerformanceMetric = sharpe, prediction_times: TimesLike | None = None, evaluation_times: TimesLike | None = None, purge_horizon: HorizonLike | None = None, embargo: HorizonLike | None = None, embargo_observations: int | None = None, embargo_fraction: float | None = None) -> PBOResult
Estimate the Probability of Backtest Overfitting (PBO) via CSCV.
Given the per-period returns of n_configs candidate configurations,
PBO is the probability that the configuration selected as best
in-sample performs below the median out-of-sample. The estimate is the
relative frequency of that event across all CSCV combinations.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
returns
|
NDArrayAny
|
|
required |
n_splits
|
int
|
Number of contiguous blocks the time axis is cut into
( |
16
|
metric
|
PerformanceMetric
|
Performance metric mapping a 1-D return slice to a scalar
(larger is better). Defaults to |
sharpe
|
prediction_times
|
TimesLike | None
|
Optional per-period prediction times. Supply with
|
None
|
evaluation_times
|
TimesLike | None
|
Optional per-period evaluation times; see
|
None
|
purge_horizon
|
HorizonLike | None
|
Optional purge horizon (requires the time series). |
None
|
embargo
|
HorizonLike | None
|
Optional embargo horizon (requires the time series). |
None
|
embargo_observations
|
int | None
|
Optional number of row positions to embargo after each OOS block (requires the time series). |
None
|
embargo_fraction
|
float | None
|
Optional fraction of rows to embargo after each OOS block (requires the time series). |
None
|
Returns:
| Type | Description |
|---|---|
PBOResult
|
A |
PBOResult
|
e.g. |
Raises:
| Type | Description |
|---|---|
ValueError
|
on a malformed |
Examples:
>>> import numpy as np
>>> from purgedcv import probability_of_backtest_overfitting
>>> rng = np.random.default_rng(0)
>>> returns = rng.standard_normal((10, 240)) # 10 configs, pure noise
>>> result = probability_of_backtest_overfitting(returns, n_splits=8)
>>> 0.0 <= result.pbo <= 1.0
True
>>> result.n_combos
70
Optuna integration¶
purgedcv.optuna_integration.TrialSharpeRecorder ¶
Optuna study callback that collects per-trial Sharpe ratios.
Pass an instance as a callback to study.optimize and store each
trial's Sharpe in a user attribute (default key "sharpe"); the
recorder accumulates them and reports the variance for
deflated_sharpe_ratio. If a trial has no such user
attribute, the recorder falls back to that trial's objective value.
Non-finite and missing values are ignored.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
attr
|
str
|
The |
'sharpe'
|
Examples:
>>> from types import SimpleNamespace
>>> from purgedcv.optuna_integration import TrialSharpeRecorder
>>> recorder = TrialSharpeRecorder()
>>> # Optuna calls the recorder with (study, frozen_trial) per trial;
>>> # SimpleNamespace stands in for the trial here.
>>> for s in (1.2, 0.8, 1.5):
... recorder(study=None, trial=SimpleNamespace(value=s, user_attrs={"sharpe": s}))
>>> recorder.n_trials()
3
>>> round(recorder.var_sharpe(), 4)
0.1233
Usage with a real study::
recorder = TrialSharpeRecorder()
study.optimize(objective, n_trials=300, callbacks=[recorder])
dsr = deflated_sharpe_ratio(
best_returns, n_trials=recorder.n_trials(), var_sharpe=recorder.var_sharpe()
)
__call__ ¶
Record one trial's Sharpe ratio. Matches the Optuna callback signature.
Missing, None, non-numeric, and non-finite values are skipped
rather than raised: a callback that throws would abort the whole
study.optimize run over a single malformed trial.
n_effective ¶
Effective number of independent trials, for correlated samplers.
TPE and CMA-ES draw each trial conditioned on the previous ones, so
the raw trial count overstates the independent search effort and makes
deflated_sharpe_ratio overly conservative. This
applies effective_n_trials to the recorded Sharpe
series; pass the result as n_trials to deflate more realistically.
Returns 0 when nothing has been recorded yet. See
effective_n_trials for the heuristic's caveats.
var_sharpe ¶
Variance of the recorded Sharpe ratios, for deflated_sharpe_ratio.
Returns nan until more than ddof trials have been recorded.
UNITS: the variance is in the units of whatever Sharpe you stored
per trial. deflated_sharpe_ratio compares against
the per-period Sharpe of its returns argument, so record a
per-period Sharpe in each trial (the same convention as the
returns you will later deflate). Do that and the recorder feeds
deflated_sharpe_ratio directly, with no unit conversion.