Skip to content

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 split(). See TimesLike for accepted containers.

required
evaluation_times TimesLike

End of each sample's label horizon. Must have the same length and temporal dtype family (datetime or timedelta) as prediction_times, with no missing values. Each value must be at or after its prediction time. Labels occupy half-open intervals [prediction_time, evaluation_time).

required
purge_horizon HorizonLike | None

Non-negative duration padding both ends of each test label horizon before checking training labels for overlap. None means zero padding. Zero padding does not disable label-overlap purging. Durations are parsed by parse_horizon.

None
embargo HorizonLike | None

Post-test wall-clock duration. When positive, removes training rows whose prediction times fall in any closed interval [test_evaluation_time, test_evaluation_time + embargo]. Must be non-negative; zero removes no rows.

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 floor(n_samples * embargo_fraction) embargoed row positions after each contiguous test block. Must be finite and in [0, 1]. A count that rounds to zero removes no rows.

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 GroupLeakageError is raised. This argument enables validation; the concrete splitter decides how test folds are formed. The groups argument passed later to split() is ignored.

None

get_n_splits abstractmethod

get_n_splits(X: object = None, y: object = None, groups: object = None) -> int

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

with_times(prediction_times: TimesLike, evaluation_times: TimesLike) -> BaseTemporalSplitter

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. n_splits * test_size must be strictly less than n_samples.

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 train_size=100 the effective training set may use indices spanning more than 100 rows. Must be None when window='expanding' and a positive integer when window='sliding'.

None
window WindowMode

"expanding" (default) uses all pre-test data as training; matches sklearn.model_selection.TimeSeriesSplit when purge is zero. "sliding" caps each training set at the most recent train_size rows after purge.

'expanding'
prediction_times TimesLike

Per-sample prediction times for the dataset. Bound at construction so split matches the sklearn signature.

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 means no purge.

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 window is not one of the literal values, if n_splits < 1 or test_size < 1, if train_size is supplied with window='expanding', or if train_size is None with window='sliding'.

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 split matches the sklearn signature.

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 means no purge.

None
embargo HorizonLike | None

Post-test embargo duration; training rows whose prediction time falls in any closed window [test_evaluation_time, test_evaluation_time + embargo] are dropped. None means no embargo.

None
embargo_observations int | None

Number of row positions to embargo after each test block. Mutually exclusive with embargo and embargo_fraction.

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 [0, 1] and is mutually exclusive with the other modes.

None

Raises:

Type Description
ValueError

if n_splits < 2.

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 prediction_times. Unique groups are partitioned into n_splits contiguous blocks in the order returned by pd.Series.unique().

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 means no purge.

None
embargo HorizonLike | None

Post-test embargo duration. None means no embargo.

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 embargo and embargo_fraction.

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 n_splits < 2 or n_splits > len(groups.unique()).

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 [1, n_splits - 1].

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 means no purge.

None
embargo HorizonLike | None

Post-test embargo duration; training rows whose prediction time falls in any closed window [test_evaluation_time, test_evaluation_time + embargo] are dropped. None means no embargo.

None
embargo_observations int | None

Number of row positions to embargo after each contiguous test block. Mutually exclusive with embargo and embargo_fraction.

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 n_splits < 2, if n_splits exceeds the number of samples, or if n_test_groups is not in [1, n_splits - 1].

backtest_paths

backtest_paths(estimator: object, X: NDArrayAny | DataFrame, y: NDArrayAny | Series) -> NDArrayAny

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:

  1. Clone the estimator (so per-fold fits do not contaminate each other or the original).
  2. Fit on the fold's training set (after purge + embargo).
  3. Predict on the fold's test set.
  4. 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 fit(X, y) and predict(X) methods.

required
X NDArrayAny | DataFrame

Feature matrix of shape (n_samples, n_features).

required
y NDArrayAny | Series

Target vector of shape (n_samples,).

required

Returns:

Type Description
NDArrayAny

(n_paths, n_samples) array of out-of-sample predictions

NDArrayAny

with n_paths = C(n_splits - 1, n_test_groups - 1).

NDArrayAny

Affected rows contain NaN when an upstream fold could not be

NDArrayAny

fit.

Raises:

Type Description
AttributeError or TypeError

if estimator lacks fit or predict.

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

reconstruct_paths(fold_predictions: Sequence[NDArrayAny]) -> NDArrayAny

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 split yields folds; fold_predictions[f] holds the predictions for that fold's test rows, in test-index order. There must be C(n_splits, n_test_groups) arrays.

required

Returns:

Type Description
NDArrayAny

(n_paths, n_samples) array of out-of-sample predictions with

NDArrayAny

n_paths = C(n_splits - 1, n_test_groups - 1).

Raises:

Type Description
ValueError

on a fold-count or fold-prediction length mismatch (propagated from reconstruct_paths).

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 n_splits is odd or below 2.