Splitters¶
Choose a splitter here. Start with BaseTemporalSplitter
for the shared time, purge, embargo, and group-validation parameters. Each concrete
splitter documents which options it accepts and any differences in behavior.
Use diagnostics to inspect its folds before fitting a model.
purgedcv.BaseTemporalSplitter ¶
BaseTemporalSplitter(*, prediction_times: TimesLike, evaluation_times: TimesLike, purge_horizon: HorizonLike | None = None, embargo: HorizonLike | None = None, embargo_observations: int | None = None, embargo_fraction: float | None = None, groups: ArrayLike1D | None = None)
Bases: ABC
Duck-typed sklearn CV splitter with purge + embargo orchestration.
Concrete subclasses implement _iter_test_indices to yield the
raw test-index arrays for each fold. The base class handles purge,
embargo, optional group-disjointness, and the sklearn-compatible
split /
get_n_splits protocol.
Times are bound to the splitter at construction. This couples the splitter to a specific dataset's timestamps, which is intentional: a splitter for one dataset is rarely meaningful for another.
Note
The subclassing interface (_iter_test_indices,
_candidate_train_idx, _finalize_train_idx) is an implementation
detail, not part of the maintained 0.1.x public contract.
Subclasses may need adjustments before v1.0.
Bind times and shared purge, embargo, and group settings.
Supply at most one embargo mode, even when its value is zero. With
all three modes left as None, no embargo is applied. Purging
still removes training labels that overlap test labels.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
prediction_times
|
TimesLike
|
Prediction times for all samples in positional row order.
Must be 1-D, non-missing, and monotonically non-decreasing;
duplicate times are allowed. Its length must match the data
passed to |
required |
evaluation_times
|
TimesLike
|
End of each sample's label horizon. Must have
the same length and temporal dtype family (datetime or
timedelta) as |
required |
purge_horizon
|
HorizonLike | None
|
Non-negative duration padding both ends of each
test label horizon before checking training labels for overlap.
|
None
|
embargo
|
HorizonLike | None
|
Post-test wall-clock duration. When positive, removes
training rows whose prediction times fall in any closed interval
|
None
|
embargo_observations
|
int | None
|
Number of row positions immediately after each contiguous test block to exclude from training. Must be a non-negative integer; zero removes no rows. Counts positions in the full dataset, not the rows remaining after purge. |
None
|
embargo_fraction
|
float | None
|
Fraction of the full dataset length used to compute
|
None
|
groups
|
ArrayLike1D | None
|
Optional group labels in positional row order, with the same
length as the times and no missing values. When provided, each
final train/test pair must have disjoint group labels or
|
None
|
get_n_splits
abstractmethod
¶
Return the total number of splits the iterator will yield.
split ¶
split(X: NDArrayAny | DataFrame, y: object = None, groups: object = None) -> Iterator[tuple[NDArrayAny, NDArrayAny]]
Yield (train_idx, test_idx) pairs for each fold.
The y and groups parameters of this method are accepted for
sklearn protocol compatibility but ignored — group information must
be bound at construction via the groups argument of __init__.
When groups were bound at construction,
assert_groups_disjoint
is called on every fold after purge, embargo, and splitter-specific finalization; a
GroupLeakageError is raised if any
group identifier appears in both train and test of the same fold.
with_times ¶
Return a copy of this splitter with new times bound. All other
parameters (n_splits, purge_horizon, every embargo mode,
groups, and any subclass-specific state such as a cached unique-group list)
are preserved unchanged.
To change groups or any other construction parameter, build a
fresh splitter via the constructor — this avoids surprising
interactions between cached state and rebound inputs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
prediction_times
|
TimesLike
|
New prediction times, sorted in non-decreasing order, with the same number of rows as the original splitter. |
required |
evaluation_times
|
TimesLike
|
New label-end times, aligned with the prediction times. Each end must be at or after its prediction time. |
required |
Returns:
| Type | Description |
|---|---|
BaseTemporalSplitter
|
A shallow copy of the same concrete splitter type with the new |
BaseTemporalSplitter
|
times. The original splitter is not modified. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the new times are invalid or their row count differs from the original splitter. |
Examples:
Reuse a three-fold configuration for a later dataset whose labels span three days instead of one. Longer labels remove more training rows, while the original splitter keeps its one-day horizons:
>>> import numpy as np
>>> import pandas as pd
>>> from purgedcv import PurgedKFold
>>> pred = pd.date_range("2024-01-01", periods=12, freq="D")
>>> cv = PurgedKFold(
... n_splits=3, prediction_times=pred,
... evaluation_times=pred + pd.Timedelta("1D"),
... )
>>> new_pred = pred + pd.Timedelta("30D")
>>> rebound = cv.with_times(new_pred, new_pred + pd.Timedelta("3D"))
>>> rebound is cv
False
>>> rebound.get_n_splits()
3
>>> features = np.zeros((12, 1))
>>> [len(train) for train, _ in rebound.split(features)]
[6, 4, 6]
>>> [len(train) for train, _ in cv.split(features)]
[8, 8, 8]
purgedcv.WalkForwardSplit ¶
WalkForwardSplit(n_splits: int, test_size: int, *, train_size: int | None = None, window: WindowMode = 'expanding', prediction_times: TimesLike, evaluation_times: TimesLike, purge_horizon: HorizonLike | None = None, embargo: HorizonLike | None = None, embargo_observations: int | None = None, embargo_fraction: float | None = None)
Bases: BaseTemporalSplitter
Walk-forward CV with sliding or expanding training window.
For window="expanding" and zero purge/embargo, this matches
sklearn.model_selection.TimeSeriesSplit. For
window="sliding", the training window has a fixed maximum length
(the most recent train_size samples before the test fold).
Post-test embargo parameters are accepted for API consistency with the
other temporal splitters, but they cannot remove rows here: every
candidate training index is strictly before the test fold. Use
purge_horizon to remove pre-test rows whose label horizons overlap the
test boundary.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> from purgedcv import WalkForwardSplit
>>> pred = pd.Series(pd.date_range("2024-01-01", periods=10, freq="D"))
>>> evalu = pred + pd.Timedelta(days=1)
>>> cv = WalkForwardSplit(
... n_splits=3, test_size=2,
... prediction_times=pred, evaluation_times=evalu,
... )
>>> cv.get_n_splits()
3
Configure a walk-forward CV splitter.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n_splits
|
int
|
Number of folds to yield. Test folds tile the END of the dataset; each fold trains only on data strictly before its test indices. |
required |
test_size
|
int
|
Number of consecutive rows in each test fold.
|
required |
train_size
|
int | None
|
Maximum number of training rows per fold after purge.
Counts kept rows, not raw indices, so with a purge gap and
|
None
|
window
|
WindowMode
|
|
'expanding'
|
prediction_times
|
TimesLike
|
Per-sample prediction times for the dataset.
Bound at construction so |
required |
evaluation_times
|
TimesLike
|
Per-sample evaluation times. Required to apply purge correctly. |
required |
purge_horizon
|
HorizonLike | None
|
Symmetric padding around the test fold's label
window; training rows whose label horizon overlaps the
padded test horizon are dropped. |
None
|
embargo
|
HorizonLike | None
|
Accepted for consistency, but has no effect because walk-forward training rows are all before the test fold. |
None
|
embargo_observations
|
int | None
|
Accepted for consistency; also a no-op for the strictly pre-test candidate training set. Mutually exclusive with the other modes. |
None
|
embargo_fraction
|
float | None
|
Accepted for consistency; also a no-op for the strictly pre-test candidate training set. Mutually exclusive with the other modes. |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
purgedcv.PurgedKFold ¶
PurgedKFold(n_splits: int, *, prediction_times: TimesLike, evaluation_times: TimesLike, purge_horizon: HorizonLike | None = None, embargo: HorizonLike | None = None, embargo_observations: int | None = None, embargo_fraction: float | None = None)
Bases: BaseTemporalSplitter
K-fold CV with contiguous test folds and purge + embargo applied.
Test folds tile the index space contiguously: fold k holds rows
[start_k, start_k + size_k) where size_k differs across folds
by at most one due to integer division. Train = complement of the
test fold, with D2 purge and D3 embargo applied by the base class.
For zero purge_horizon and embargo the test folds are
identical to sklearn.model_selection.KFold(shuffle=False).
Purge still drops any training row whose own label horizon
[prediction_time, evaluation_time) overlaps the test labels, so
the training set equals the full complement only when no two label
horizons overlap (for instance when evaluation_times equals
prediction_times). The splitter then degrades exactly to
KFold(shuffle=False).
The first n_samples % n_splits folds receive
n_samples // n_splits + 1 rows; the remaining folds receive
n_samples // n_splits. When n_splits > n_samples, the
excess folds are empty — the base class purge and embargo handle
empty arrays gracefully.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> from purgedcv import PurgedKFold
>>> pred = pd.Series(pd.date_range("2024-01-01", periods=20, freq="D"))
>>> evalu = pred + pd.Timedelta(days=1)
>>> cv = PurgedKFold(
... n_splits=5,
... prediction_times=pred, evaluation_times=evalu,
... )
>>> cv.get_n_splits()
5
Configure a purged k-fold splitter.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n_splits
|
int
|
Number of folds. Must be at least 2. |
required |
prediction_times
|
TimesLike
|
Per-sample prediction times. Bound at
construction so |
required |
evaluation_times
|
TimesLike
|
Per-sample evaluation times. |
required |
purge_horizon
|
HorizonLike | None
|
Symmetric padding around the test fold's label
window; training rows whose label horizon overlaps the
padded test horizon are dropped. |
None
|
embargo
|
HorizonLike | None
|
Post-test embargo duration; training rows whose
prediction time falls in any closed window
|
None
|
embargo_observations
|
int | None
|
Number of row positions to embargo after
each test block. Mutually exclusive with |
None
|
embargo_fraction
|
float | None
|
Fraction of the full dataset to embargo after
each test block, rounded down to a row count. Must be in
|
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
purgedcv.PurgedGroupKFold ¶
PurgedGroupKFold(n_splits: int, *, prediction_times: TimesLike, evaluation_times: TimesLike, groups: ArrayLike1D, purge_horizon: HorizonLike | None = None, embargo: HorizonLike | None = None, embargo_observations: int | None = None, embargo_fraction: float | None = None)
Bases: BaseTemporalSplitter
Group-aware k-fold variant of PurgedKFold.
Each test fold consists of all rows from a contiguous block of unique
group identifiers, so no group_id ever appears in both train and
test of the same fold. Purge and embargo still apply ACROSS group
boundaries — training rows from other groups whose horizons overlap
the test window are dropped by D2/D3 in the base class.
Groups are assigned to fold blocks in first-appearance order within
the groups Series (the order returned by pd.Series.unique()).
For temporal coherence — patients enrolled in chronological order,
assets in time-of-IPO order — ensure the groups Series is sorted
by first occurrence time.
Observation-count and fractional embargo operate on row positions, not group identities. They apply after every contiguous run of test positions. If a fold's groups are interleaved through the dataset, that fold contains several short test runs and positional embargo can remove substantially more rows than a single fold-level buffer would.
The base class's assert_groups_disjoint enforcement runs on
every fold automatically because groups is bound at construction.
Useful in clinical ML (no patient appears in both sides of a fold), asset CV (no symbol appears in both sides), and any setting where grouped observations must not leak across the train/test boundary.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> from purgedcv import PurgedGroupKFold
>>> pred = pd.Series(pd.date_range("2024-01-01", periods=12, freq="D"))
>>> evalu = pred + pd.Timedelta(days=1)
>>> groups = pd.Series(np.repeat([0, 1, 2, 3], 3))
>>> cv = PurgedGroupKFold(
... n_splits=2,
... prediction_times=pred, evaluation_times=evalu,
... groups=groups,
... )
>>> cv.get_n_splits()
2
Configure a group-aware purged k-fold splitter.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n_splits
|
int
|
Number of folds. Must be at least 2 and must not exceed the number of unique group identifiers. |
required |
prediction_times
|
TimesLike
|
Per-sample prediction times. |
required |
evaluation_times
|
TimesLike
|
Per-sample evaluation times. |
required |
groups
|
ArrayLike1D
|
Group identifier per sample. Must have the same
length as |
required |
purge_horizon
|
HorizonLike | None
|
Symmetric padding around the test fold's
label window; cross-group training rows whose label
horizon overlaps the padded test horizon are dropped.
|
None
|
embargo
|
HorizonLike | None
|
Post-test embargo duration. |
None
|
embargo_observations
|
int | None
|
Number of row positions to embargo after
each contiguous run of test positions. Interleaved test
groups may create many such runs. Mutually exclusive with
|
None
|
embargo_fraction
|
float | None
|
Fraction of the full dataset to embargo after each contiguous run of test positions, rounded down. Interleaved groups may create many runs. Mutually exclusive with the other modes. |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
purgedcv.CombinatorialPurgedCV ¶
CombinatorialPurgedCV(n_splits: int, n_test_groups: int, *, prediction_times: TimesLike, evaluation_times: TimesLike, purge_horizon: HorizonLike | None = None, embargo: HorizonLike | None = None, embargo_observations: int | None = None, embargo_fraction: float | None = None)
Bases: BaseTemporalSplitter
Combinatorial Purged Cross-Validation (fold enumeration).
Partitions the time-ordered samples into n_splits contiguous group
blocks. For each combination of n_test_groups chosen from those
blocks, yields one fold whose test indices are the union of the
chosen blocks. Total folds: C(n_splits, n_test_groups).
Each group block appears as test in exactly C(n_splits - 1,
n_test_groups - 1) folds.
The base class applies D2 purge and D3 embargo to each fold's train
set. backtest_paths then assembles the C(N,K) folds into
n_paths time-ordered out-of-sample sequences.
See Advances in Financial Machine Learning (Lopez de Prado, Wiley 2018), chapter 12 section 12.4, for the original method.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> from purgedcv import CombinatorialPurgedCV
>>> pred = pd.Series(pd.date_range("2024-01-01", periods=24, freq="D"))
>>> evalu = pred + pd.Timedelta(days=1)
>>> cv = CombinatorialPurgedCV(
... n_splits=6, n_test_groups=2,
... prediction_times=pred, evaluation_times=evalu,
... )
>>> cv.get_n_splits()
15
Configure a Combinatorial Purged CV splitter.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n_splits
|
int
|
Number of contiguous group blocks to partition the samples into. Must be at least 2. |
required |
n_test_groups
|
int
|
Number of group blocks chosen as the test
set in each fold. Must be in |
required |
prediction_times
|
TimesLike
|
Per-sample prediction times. |
required |
evaluation_times
|
TimesLike
|
Per-sample evaluation times. |
required |
purge_horizon
|
HorizonLike | None
|
Symmetric padding around the test fold's
label window; training rows whose label horizon overlaps
the padded test horizon are dropped. |
None
|
embargo
|
HorizonLike | None
|
Post-test embargo duration; training rows whose
prediction time falls in any closed window
|
None
|
embargo_observations
|
int | None
|
Number of row positions to embargo after
each contiguous test block. Mutually exclusive with
|
None
|
embargo_fraction
|
float | None
|
Fraction of the full dataset to embargo after each contiguous test block, rounded down. Mutually exclusive with the other modes. |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
backtest_paths ¶
Fit estimator on each fold and reconstruct the C(N-1, K-1)
out-of-sample backtest paths.
For each of the C(N, K) folds:
- Clone the estimator (so per-fold fits do not contaminate each other or the original).
- Fit on the fold's training set (after purge + embargo).
- Predict on the fold's test set.
- If the fold has no training rows under an unusually aggressive purge/embargo configuration, the predictions for that fold are NaN.
The per-fold predictions are then handed to reconstruct_paths,
which assembles them into an (n_paths, n_samples) matrix where
each row is a complete time-ordered out-of-sample prediction
sequence.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
estimator
|
object
|
A scikit-learn estimator with |
required |
X
|
NDArrayAny | DataFrame
|
Feature matrix of shape |
required |
y
|
NDArrayAny | Series
|
Target vector of shape |
required |
Returns:
| Type | Description |
|---|---|
NDArrayAny
|
|
NDArrayAny
|
with |
NDArrayAny
|
Affected rows contain NaN when an upstream fold could not be |
NDArrayAny
|
fit. |
Raises:
| Type | Description |
|---|---|
AttributeError or TypeError
|
if |
Examples:
>>> import warnings
>>> import numpy as np
>>> import pandas as pd
>>> from sklearn.dummy import DummyRegressor
>>> from sklearn.exceptions import FitFailedWarning
>>> from purgedcv import CombinatorialPurgedCV
>>> pred = pd.Series(pd.date_range("2024-01-01", periods=16, freq="D"))
>>> evalu = pred + pd.Timedelta(days=1)
>>> cv = CombinatorialPurgedCV(
... n_splits=4, n_test_groups=2,
... prediction_times=pred, evaluation_times=evalu,
... )
>>> X = np.arange(16).reshape(-1, 1).astype(float)
>>> y = np.arange(16).astype(float)
>>> with warnings.catch_warnings():
... warnings.simplefilter("ignore", FitFailedWarning)
... paths = cv.backtest_paths(DummyRegressor(strategy="mean"), X, y)
>>> paths.shape
(3, 16)
reconstruct_paths ¶
Assemble per-fold predictions into the C(N-1, K-1) backtest paths.
Ergonomic wrapper around reconstruct_paths. The
splitter already knows n_splits, n_test_groups, the fold
test-index layout, and n_samples (from the bound times), so the
caller supplies only one prediction array per fold, in
split order. Use this when you ran the fits yourself (for
example a per-fold backtest loop) rather than via
backtest_paths.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fold_predictions
|
Sequence[NDArrayAny]
|
One array per fold, in the same order as
|
required |
Returns:
| Type | Description |
|---|---|
NDArrayAny
|
|
NDArrayAny
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
on a fold-count or fold-prediction length mismatch
(propagated from |
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> from purgedcv import CombinatorialPurgedCV
>>> pred = pd.Series(pd.date_range("2024-01-01", periods=16, freq="D"))
>>> evalu = pred + pd.Timedelta(days=1)
>>> cv = CombinatorialPurgedCV(
... n_splits=4, n_test_groups=2,
... prediction_times=pred, evaluation_times=evalu,
... )
>>> X = np.zeros((16, 1))
>>> fold_preds = [
... np.full(len(test), float(i))
... for i, (_, test) in enumerate(cv.split(X))
... ]
>>> cv.reconstruct_paths(fold_preds).shape
(3, 16)
purgedcv.CombinatoriallySymmetricCV ¶
CombinatoriallySymmetricCV(n_splits: int, *, prediction_times: TimesLike, evaluation_times: TimesLike, purge_horizon: HorizonLike | None = None, embargo: HorizonLike | None = None, embargo_observations: int | None = None, embargo_fraction: float | None = None)
Bases: CombinatorialPurgedCV
Combinatorially Symmetric Cross-Validation (CSCV).
The special case of CombinatorialPurgedCV with
n_test_groups = n_splits // 2: every fold cuts the timeline into two
equal halves, one in-sample and one out-of-sample. CSCV is the substrate
of probability_of_backtest_overfitting; expose it
directly when you want the symmetric IS/OOS folds without going through
the PBO estimator.
See Advances in Financial Machine Learning (Lopez de Prado, Wiley 2018), chapter 11.
Examples:
>>> import pandas as pd
>>> from purgedcv import CombinatoriallySymmetricCV
>>> pred = pd.Series(pd.date_range("2024-01-01", periods=24, freq="D"))
>>> evalu = pred + pd.Timedelta(days=1)
>>> cv = CombinatoriallySymmetricCV(
... n_splits=6, prediction_times=pred, evaluation_times=evalu,
... )
>>> cv.get_n_splits()
20
Configure a CSCV splitter.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n_splits
|
int
|
Number of contiguous group blocks. Must be even and at least 2; half are chosen as the test (out-of-sample) groups in each fold. |
required |
prediction_times
|
TimesLike
|
Per-sample prediction times. |
required |
evaluation_times
|
TimesLike
|
Per-sample evaluation times. |
required |
purge_horizon
|
HorizonLike | None
|
Optional purge horizon applied per fold. |
None
|
embargo
|
HorizonLike | None
|
Optional embargo horizon applied per fold. |
None
|
embargo_observations
|
int | None
|
Optional number of post-test row positions embargoed per contiguous block. |
None
|
embargo_fraction
|
float | None
|
Optional fraction of the dataset embargoed as row positions per contiguous block. |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if |