Repository: sktime/sktime
Pull request #5575 (2023-11-25 13:44:28)

[ENH] cutoff and forecasting horizon loc based splitter
This PR adds a temporal splitter which is cutoff and forecasting horizon based, and anchors fh loc indices in a cutoff.

This may help with the `fit_predict` issue in https://github.com/sktime/sktime/pull/5562.

FYI @arnaujc91, @yarnabrina 


Patch:
diff --git a/sktime/split/__init__.py b/sktime/split/__init__.py
index 78e8d81a8..a872aaf8f 100644
--- a/sktime/split/__init__.py
+++ b/sktime/split/__init__.py
@@ -2,6 +2,7 @@
 
 __all__ = [
     "CutoffSplitter",
+    "CutoffFhSplitter",
     "ExpandingGreedySplitter",
     "ExpandingWindowSplitter",
     "SameLocSplitter",
@@ -12,7 +13,7 @@ __all__ = [
     "temporal_train_test_split",
 ]
 
-from sktime.split.cutoff import CutoffSplitter
+from sktime.split.cutoff import CutoffFhSplitter, CutoffSplitter
 from sktime.split.expandinggreedy import ExpandingGreedySplitter
 from sktime.split.expandingwindow import ExpandingWindowSplitter
 from sktime.split.sameloc import SameLocSplitter
diff --git a/sktime/split/cutoff.py b/sktime/split/cutoff.py
index 8191514ac..b1b455419 100644
--- a/sktime/split/cutoff.py
+++ b/sktime/split/cutoff.py
@@ -6,6 +6,7 @@ __author__ = ["khrapovs"]
 
 __all__ = [
     "CutoffSplitter",
+    "CutoffFhSplitter",
 ]
 
 from typing import Optional
@@ -125,7 +126,7 @@ class CutoffSplitter(BaseSplitter):
     relative to the end of the training window.
     It will contain as many indices
     as there are forecasting horizons provided to the `fh` argument.
-    For a forecasating horizon :math:`(h_1,\ldots,h_H)`, the test window will
+    For a forecasting horizon :math:`(h_1,\ldots,h_H)`, the test window will
     consist of the indices :math:`(k_n+h_1,\ldots, k_n+h_H)`.
 
     The number of splits returned by `.get_n_splits`
@@ -240,3 +241,117 @@ class CutoffSplitter(BaseSplitter):
             `create_test_instance` uses the first (or only) dictionary in `params`
         """
         return [{"cutoffs": np.array([3, 7, 10])}, {"cutoffs": [21, 22]}]
+
+
+class CutoffFhSplitter(BaseSplitter):
+    r"""Temporal train-test splitter, based on cutoff and forecasting horizon.
+
+    Train and test splits are determied as follows:
+
+    for each cutoff point `k=cutoff[i]`, in `split`:
+
+    * training fold is all loc indices up to and including `k`
+    * if `fh` is not passed, test fold is all loc indices strictly after `k`
+    * if `fh is passed`, test fold is all loc indices in `k + fh`, if `fh` is relative.
+      More precisely, `fh.to_absolute_index(cutoff=k)
+      If `fh` is absolute, then the test window is `fh` itself.
+
+    It should be noted that, unlike in `CutoffSplitter`,
+    test folds are not determined by a window length,
+    but by indices of the forecasting horizon `fh`, i.e., test folds can be
+    non-contiguous, even if the data index is regular.
+
+    Parameters
+    ----------
+    cutoff : np.array or pd.Index
+        Cutoff points, positive and integer- or datetime-index like.
+        Type should match the type of `fh` input.
+    fh : None, ForecastingHorizon, int, timedelta, iterable of ints or timedeltas
+        Forecasting horizon, relative or absolute, to determine test folds.
+        Type should match the type of `cutoffs` input.
+        If not ForecastingHorizon, is coerced.
+    """
+
+    _tags = {
+        "split_hierarchical": False,
+        "split_series_uses": "loc",
+    }
+
+    def __init__(self, cutoff, fh=None):
+        self.cutoff = cutoff
+        self.fh = fh
+        super().__init__(fh=fh)
+
+    def _split_loc(self, y):
+        """Get loc references to train/test splits of `y`.
+
+        private _split containing the core logic, called from split_loc
+
+        Parameters
+        ----------
+        y : pd.Index
+            index of time series to split
+
+        Yields
+        ------
+        train : pd.Index
+            Training window indices, loc references to training indices in y
+        test : pd.Index
+            Test window indices, loc references to test indices in y
+        """
+        cutoff = self.cutoff
+        fh = self.fh
+
+        if fh is not None:
+            from sktime.forecasting.base import ForecastingHorizon
+
+            if not isinstance(fh, ForecastingHorizon):
+                fh = ForecastingHorizon(fh)
+
+        for k in cutoff:
+            train = y[y <= k]
+            if fh is not None:
+                test = fh.to_absolute_index(cutoff=k)
+            else:
+                test = y[y > k]
+            yield train, test
+
+    def get_n_splits(self, y=None) -> int:
+        """Return the number of splits.
+
+        Since this splitter returns a single train/test split,
+        this number is trivially 1.
+
+        Parameters
+        ----------
+        y : pd.Series or pd.Index, optional (default=None)
+            Time series to split
+
+        Returns
+        -------
+        n_splits : int
+            The number of splits.
+        """
+        return len(self.cutoffs)
+
+    @classmethod
+    def get_test_params(cls, parameter_set="default"):
+        """Return testing parameter settings for the splitter.
+
+        Parameters
+        ----------
+        parameter_set : str, default="default"
+            Name of the set of test parameters to return, for use in tests. If no
+            special parameters are defined for a value, will return `"default"` set.
+
+        Returns
+        -------
+        params : dict or list of dict, default = {}
+            Parameters to create testing instances of the class
+            Each dict are parameters to construct an "interesting" test instance, i.e.,
+            `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
+            `create_test_instance` uses the first (or only) dictionary in `params`
+        """
+        params1 = {"cutoff": 3}
+        params2 = {"cutoff": [3, 4], "fh": [1, 2]}
+        return [params1, params2]


Test patch:
diff --git a/sktime/split/tests/test_cutoff.py b/sktime/split/tests/test_cutoff.py
index ddd3aa94b..8afe76115 100644
--- a/sktime/split/tests/test_cutoff.py
+++ b/sktime/split/tests/test_cutoff.py
@@ -2,6 +2,7 @@
 """Tests for cutoff splitter."""
 
 import numpy as np
+import pandas as pd
 import pytest
 
 from sktime.forecasting.tests._config import (
@@ -11,7 +12,7 @@ from sktime.forecasting.tests._config import (
     TEST_WINDOW_LENGTHS,
     TEST_YS,
 )
-from sktime.split import CutoffSplitter
+from sktime.split import CutoffFhSplitter, CutoffSplitter
 from sktime.split.base._common import _inputs_are_supported
 from sktime.split.tests.test_split import _check_cv
 
@@ -30,3 +31,30 @@ def test_cutoff_window_splitter(y, cutoffs, fh, window_length):
         match = "Unsupported combination of types"
         with pytest.raises(TypeError, match=match):
             CutoffSplitter(cutoffs, fh=fh, window_length=window_length)
+
+
+def test_cutoff_fh_splitter():
+    """Test CutoffFhSplitter."""
+    from sktime.forecasting.base import ForecastingHorizon
+    from sktime.utils._testing.series import _make_series
+
+    y = _make_series()
+    cutoff = y.index[[10]]
+    cutoff.freq = y.index.freq
+    fh = ForecastingHorizon([1, 2, 3], freq=y.index.freq)
+
+    spl = CutoffFhSplitter(cutoff, fh)
+
+    spl_tt = list(spl.split_loc(y))[0]
+    spl_train = spl_tt[0]
+    spl_test = spl_tt[1]
+
+    assert isinstance(spl_train, pd.DatetimeIndex)
+    assert isinstance(spl_test, pd.DatetimeIndex)
+
+    assert np.all(spl_train == y.index[:11])
+
+    expected_test = pd.DatetimeIndex(
+        ["2000-01-12", "2000-01-13", "2000-01-14"], dtype="datetime64[ns]", freq="D"
+    )
+    assert np.all(spl_test == expected_test)