Repository: sktime/sktime

Issue:
[BUG] SingleWindowSplitter failed to return iloc reference split for hierachical datatypes
**Describe the bug**
<!--
A clear and concise description of what the bug is.
-->

`SingleWindowSplitter` encounters the error below when a hierarchical datatype is passed, even with the simplest example. This raises a question of whether the splitters are actually being tested for hierarchical mtype format. The [docstring](https://www.sktime.net/en/stable/api_reference/auto_generated/sktime.forecasting.model_selection.SingleWindowSplitter.html#sktime.forecasting.model_selection.SingleWindowSplitter.split) indicates that one can pass hierarchical mtype into them.

**To Reproduce**

```python
from sktime.utils._testing.hierarchical import _make_hierarchical
from sktime.forecasting.model_selection import SingleWindowSplitter

y_hierarchical= _make_hierarchical(hierarchy_levels=(2, 3), min_timepoints=12, max_timepoints=12, random_state=0)
splitter = SingleWindowSplitter(fh=[1,2], window_length=10)
list(splitter.split(y_hierarchical)) 
```

Output
```
TypeError: unsupported operand type(s) for *: 'NoneType' and 'int'
```

**Full Traceback**
<details>

Traceback (most recent call last):
File "c:\Users\hazru\Desktop\github\sktime\sktime\_contrib\temp\test-forecastingcv.py", line 6, in <module>
list(splitter.split(y_synthetic))
File "C:\Users\hazru\Desktop\github\sktime\sktime\forecasting\model_selection\_split.py", line 386, in split
for train, test in split(y_index):
File "C:\Users\hazru\Desktop\github\sktime\sktime\forecasting\model_selection\_split.py", line 434, in _split_vectorized
train_inst, test_inst = zip(*split_inst)
File "C:\Users\hazru\Desktop\github\sktime\sktime\forecasting\model_selection\_split.py", line 1325, in _split
window_length = y.freq * window_length
</details>

**Expected behavior**
<!--
A clear and concise description of what you expected to happen.
-->

It should return iloc references to a train & test split of y_hierarchical

**Additional context**
<!--
Add any other context about the problem here.
-->

**Versions**
<details>

<!--
Please run the following code snippet and paste the output here:

from sktime import show_versions; show_versions()
-->

</details>

<!-- Thanks for contributing! -->



Discussion:
Update: It turns out that the problem lies on misalignment of datatype argument inputs. if **y** is a **datetime** datatype and **window_length** is an **int**, `SingleWindowSplitter` will use information about y index `freq` so that it can identifies correctly where to split the y (through addition/subtraction of datetime datatype) However, if the freq is not available (due to unintended loss during data preprocessing into sktime datatypes which is what happen in `_make_hierachical` or it's not being declared anywhere in the first place) then `SingleWindowSplitter` will fail and throw an error as above. 

Solutions
1. Infer `freq` of the series using `pd.infer_freq` function and add a warning message that the freq inference is being done internally.

That is strange - why is the frequency even required for the SingleWindowSplitter? Logic-wise, it shouldn´t be, as the logic is just a greater/smaller comparison.

Resolving patch:
diff --git a/sktime/forecasting/model_selection/_split.py b/sktime/forecasting/model_selection/_split.py
--- a/sktime/forecasting/model_selection/_split.py
+++ b/sktime/forecasting/model_selection/_split.py
@@ -281,6 +281,53 @@ def _check_cutoffs_fh_y(
         raise TypeError("Unsupported type of `cutoffs` and `fh`")
 
 
+def _get_train_window_via_endpoint(
+    y: pd.Index,
+    train_endpoint: VALID_CUTOFF_TYPES,
+    window_length: ACCEPTED_WINDOW_LENGTH_TYPES,
+) -> pd.Index:
+    """
+    Split time series at given end points into a fixed-length training set.
+
+    Parameters
+    ----------
+    y : pd.Index
+        Index of time series to split
+    train_endpoint : int or timedelta
+        Training window's last time point
+    window_length : int or timedelta
+        Length of window
+
+    Returns
+    -------
+    training_window : pd.Index
+        Training window indices
+
+    Notes
+    -----
+    this private function is used to get training window for
+    `CutOffSplitter` and `SingleWindowSplitter`
+    """
+    if isinstance(y, (pd.DatetimeIndex, pd.PeriodIndex)) and is_int(window_length):
+        y_train = pd.Series(index=y, dtype=y.dtype)  # convert pd.index to pd.series
+        train_start = train_endpoint - window_length + 1
+        # adjust start point to account for negative time point
+        train_start = 0 if train_start < 0 else train_start
+        train_window = y_train.iloc[train_start : train_endpoint + 1].index
+    else:
+        train_end = y[train_endpoint] if is_int(train_endpoint) else train_endpoint
+        train_window = get_window(
+            pd.Series(index=y[y <= train_end], dtype=y.dtype),
+            window_length=window_length,
+        ).index
+    # when given train end point is negative no training window is provided
+    null = 0 if is_int(train_endpoint) else pd.Timestamp(0)
+    if train_endpoint < null:
+        train_window = []
+    training_window = y.get_indexer(train_window)
+    return training_window
+
+
 class BaseSplitter(BaseObject):
     r"""Base class for temporal cross-validation splitters.
 
@@ -730,7 +777,7 @@ class CutoffSplitter(BaseSplitter):
     is then trivially equal to :math:`n`.
 
     The sorted array of cutoffs returned by `.get_cutoffs` is then equal to
-    :math:(t(k_1),\ldots,t(k_n))` with :math:`k_i<k_{i+1}`.
+    :math:`(t(k_1),\ldots,t(k_n))` with :math:`k_i<k_{i+1}`.
 
     Parameters
     ----------
@@ -768,20 +815,11 @@ def _split(self, y: pd.Index) -> SPLIT_GENERATOR_TYPE:
         window_length = check_window_length(
             window_length=self.window_length, n_timepoints=n_timepoints
         )
-        if isinstance(y, (pd.DatetimeIndex, pd.PeriodIndex)) and is_int(window_length):
-            window_length = y.freq * window_length
         _check_cutoffs_and_y(cutoffs=cutoffs, y=y)
         _check_cutoffs_fh_y(cutoffs=cutoffs, fh=fh, y=y)
 
         for cutoff in cutoffs:
-            null = 0 if is_int(cutoff) else pd.Timestamp(0)
-            if cutoff >= null:
-                train_end = y[cutoff] if is_int(cutoff) else cutoff
-                y_train = pd.Series(index=y[y <= train_end], dtype=y.dtype)
-                training_window = get_window(y_train, window_length=window_length).index
-            else:
-                training_window = []
-            training_window = y.get_indexer(training_window)
+            training_window = _get_train_window_via_endpoint(y, cutoff, window_length)
             test_window = cutoff + fh.to_numpy()
             if is_datetime(x=cutoff):
                 test_window = y.get_indexer(test_window[test_window >= y.min()])
@@ -846,7 +884,7 @@ def get_test_params(cls, parameter_set="default"):
             `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
             `create_test_instance` uses the first (or only) dictionary in `params`
         """
-        return {"cutoffs": np.array([3, 7, 10])}
+        return [{"cutoffs": np.array([3, 7, 10])}, {"cutoffs": [21, 22]}]
 
 
 class BaseWindowSplitter(BaseSplitter):
@@ -1170,6 +1208,26 @@ def __init__(
     def _split_windows(self, **kwargs) -> SPLIT_GENERATOR_TYPE:
         return self._split_windows_generic(expanding=False, **kwargs)
 
+    @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`
+        """
+        return [{}, {"fh": [2, 4], "window_length": 3, "step_length": 2}]
+
 
 class ExpandingWindowSplitter(BaseWindowSplitter):
     r"""Expanding window splitter.
@@ -1247,6 +1305,26 @@ def _initial_window(self):
     def _split_windows(self, **kwargs) -> SPLIT_GENERATOR_TYPE:
         return self._split_windows_generic(expanding=True, **kwargs)
 
+    @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`
+        """
+        return [{}, {"fh": [2, 4], "initial_window": 5, "step_length": 2}]
+
 
 class ExpandingGreedySplitter(BaseSplitter):
     """Splitter that uses all available data.
@@ -1376,16 +1454,9 @@ def __init__(
     def _split(self, y: pd.Index) -> SPLIT_GENERATOR_TYPE:
         n_timepoints = y.shape[0]
         window_length = check_window_length(self.window_length, n_timepoints)
-        if isinstance(y, (pd.DatetimeIndex, pd.PeriodIndex)) and is_int(window_length):
-            window_length = y.freq * window_length
         fh = _check_fh(self.fh)
         train_end = _get_end(y_index=y, fh=fh)
-
-        training_window = get_window(
-            pd.Series(index=y[y <= y[train_end]], dtype=y.dtype),
-            window_length=window_length,
-        ).index
-        training_window = y.get_indexer(training_window)
+        training_window = _get_train_window_via_endpoint(y, train_end, window_length)
         if array_is_int(fh):
             test_window = train_end + fh.to_numpy()
         else:
@@ -1455,8 +1526,7 @@ def get_test_params(cls, parameter_set="default"):
             `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
             `create_test_instance` uses the first (or only) dictionary in `params`
         """
-        params = {"fh": 3}
-        return params
+        return [{"fh": 3}, {"fh": [2, 4], "window_length": 3}]
 
 
 class SameLocSplitter(BaseSplitter):
@@ -1584,15 +1654,16 @@ def get_test_params(cls, parameter_set="default"):
             `create_test_instance` uses the first (or only) dictionary in `params`
         """
         from sktime.datasets import load_airline
-        from sktime.forecasting.model_selection import ExpandingWindowSplitter
+        from sktime.forecasting.model_selection import (
+            ExpandingWindowSplitter,
+            SingleWindowSplitter,
+        )
 
         y = load_airline()
-        y_template = y[:60]
-        cv_tpl = ExpandingWindowSplitter(fh=[2, 4], initial_window=24, step_length=12)
-
-        params = {"cv": cv_tpl, "y_template": y_template}
-
-        return params
+        y_temp = y[:60]
+        cv_1 = ExpandingWindowSplitter(fh=[2, 4], initial_window=24, step_length=12)
+        cv_2 = SingleWindowSplitter(fh=[2, 4], window_length=24)
+        return [{"cv": cv_1, "y_template": y_temp}, {"cv": cv_2, "y_template": y_temp}]
 
 
 class TestPlusTrainSplitter(BaseSplitter):
@@ -1711,13 +1782,14 @@ def get_test_params(cls, parameter_set="default"):
             `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
             `create_test_instance` uses the first (or only) dictionary in `params`
         """
-        from sktime.forecasting.model_selection import ExpandingWindowSplitter
-
-        cv_tpl = ExpandingWindowSplitter(fh=[2, 4], initial_window=24, step_length=12)
-
-        params = {"cv": cv_tpl}
+        from sktime.forecasting.model_selection import (
+            ExpandingWindowSplitter,
+            SingleWindowSplitter,
+        )
 
-        return params
+        cv_1 = ExpandingWindowSplitter(fh=[2, 4], initial_window=24, step_length=12)
+        cv_2 = SingleWindowSplitter(fh=[2, 4], window_length=24)
+        return [{"cv": cv_1}, {"cv": cv_2}]
 
 
 def temporal_train_test_split(


Test patch:
diff --git a/sktime/forecasting/model_selection/tests/test_split.py b/sktime/forecasting/model_selection/tests/test_split.py
--- a/sktime/forecasting/model_selection/tests/test_split.py
+++ b/sktime/forecasting/model_selection/tests/test_split.py
@@ -711,3 +711,14 @@ def test_same_loc_splitter_hierarchical():
     for (t1, tt1), (t2, tt2) in zip(split_template_loc, split_templated_loc):
         assert np.all(t1 == t2)
         assert np.all(tt1 == tt2)
+
+
+def test_hierachical_singlewindowsplitter():
+    """Test for bugs in SingleWindowSplitter with hierarchical data.
+
+    See #4972
+    """
+    y = _make_hierarchical(hierarchy_levels=(2, 3), random_state=0)
+    splitter = SingleWindowSplitter(fh=[1, 2], window_length=10)
+    splits = list(splitter.split(y))
+    assert len(splits) == 1, "Should only be one split"