Source code for numeraire.core.splitter

"""Walk-forward splitters. Yields ``(train, test)`` views, PIT-aware.

The splitter only uses the :class:`~numeraire.core.protocols.DataView` calendar plus
``between``, so it is shape-agnostic. The horizon purge (a train fold's targets never reach
into its test fold) is enforced by the view's :meth:`~numeraire.core.data.TimeSeriesView.aligned`,
which drops any feature whose ``(t, t+h]`` target is not realized by the train cutoff;
``embargo`` adds an optional extra gap on top for serial-correlation safety.
"""

from __future__ import annotations

from collections.abc import Iterator
from dataclasses import dataclass
from datetime import timedelta
from typing import Protocol, TypeVar, cast

import pandas as pd

from numeraire.core.data import TimeSeriesView


class _WindowedView(Protocol):
    """Structural view surface ``validation_split`` needs (both concrete views satisfy it)."""

    @property
    def calendar(self) -> pd.DatetimeIndex: ...
    def between(self, start: object, end: object) -> _WindowedView: ...


_V = TypeVar("_V", bound=_WindowedView)


[docs] def validation_split(view: _V, valid_size: int) -> tuple[_V, _V]: """Split a (train) view into PIT ``(fit, valid)``: valid = the last ``valid_size`` dates. The tuning pattern of the ML-cross-section protocols (fit candidate hyperparameters on ``fit``, score them on ``valid``, both strictly inside the train fold): ``fit`` keeps the fold's calendar up to the cutoff with **data truncated at the cutoff** (so its supervised pairs never see valid-period returns — the usual horizon purge applies at the seam), while ``valid`` keeps the trailing dates with full history available for lagged features. Estimators call this *inside* ``fit(train)``; the engine and splitter stay two-way. Assumes the view's calendar is a contiguous run of its dates (true for engine train folds). """ cal = view.calendar if valid_size < 1: raise ValueError(f"valid_size must be >= 1; got {valid_size}") if valid_size >= len(cal) - 1: raise ValueError( f"valid_size={valid_size} leaves <2 fit dates on a {len(cal)}-date calendar" ) cutoff = cal[-valid_size - 1] lo = cal[0] - pd.Timedelta(1, "ns") # keep the fold's own start (rolling windows stay rolling) fit = view.between(lo, cutoff) valid = view.between(cutoff, cal[-1]) return cast("_V", fit), cast("_V", valid)
[docs] @dataclass(frozen=True) class WalkForwardSplitter: """Expanding- or rolling-window walk-forward splitter. Parameters ---------- min_train: Minimum number of calendar observations in the first train fold. test_size: Number of calendar observations per test fold (also the step between folds). expanding: ``True`` → train grows from the start each fold; ``False`` → rolling window of ``min_train`` observations. embargo: Extra calendar observations to drop between the train cutoff and the first test date, on top of the automatic horizon purge. Default ``0``. """ min_train: int = 60 test_size: int = 12 expanding: bool = True embargo: int = 0 def __post_init__(self) -> None: if self.min_train < 1: raise ValueError("min_train must be >= 1") if self.test_size < 1: raise ValueError("test_size must be >= 1") if self.embargo < 0: raise ValueError("embargo must be >= 0")
[docs] def split(self, view: TimeSeriesView) -> Iterator[tuple[TimeSeriesView, TimeSeriesView]]: """Yield ``(train, test)`` view pairs; each test calendar is strictly future of train.""" cal = view.calendar n = len(cal) i = self.min_train while i + self.embargo + self.test_size <= n: cutoff = cal[i - 1] # last training observation train_lo = 0 if self.expanding else i - self.min_train train_lb = _lower_bound(cal, train_lo) train = view.between(train_lb, cutoff) test_start = cal[i - 1 + self.embargo] test_end = cal[i - 1 + self.embargo + self.test_size] test = view.between(test_start, test_end) yield train, test i += self.test_size
def _lower_bound(cal: pd.DatetimeIndex, lo: int) -> pd.Timestamp: """Timestamp strictly below ``cal[lo]`` so ``between(lb, ...)`` includes ``cal[lo]``.""" first: pd.Timestamp = cal[0] if lo == 0: return first - timedelta(days=1) return cal[lo - 1]