Repository: sktime/sktime

Issue:
[BUG] get_n_splits of window splitter does not reflect the number of splits for hierarchical data
**Describe the bug**
<!--
A clear and concise description of what the bug is.
-->

`get_n_splits` method returns the wrong number of splits when hierarchical data is passed into window based splitter. 

**To Reproduce**

```python
from sktime.utils._testing.hierarchical import _make_hierarchical
from sktime.forecasting.model_selection import ExpandingWindowSplitter, SlidingWindowSplitter

y_hierarchical = _make_hierarchical(hierarchy_levels=(2, 3), min_timepoints=12, max_timepoints=12, random_state=0)
fh = [1]
step_length = 1
window_length = 10

# each unique series (lowest hierachical level) should have two splits
cv = ExpandingWindowSplitter(initial_window=window_length, fh=fh, step_length=step_length)
sliding = SlidingWindowSplitter(fh=fh, window_length=window_length, step_length=step_length, start_with_window=True)

n_splits_cv = cv.get_n_splits(y_hierarchical)
n_splits_sliding = sliding.get_n_splits(y_hierarchical)

print(f"Expanding Window splits : {n_splits_cv}")
print(f"Sliding Window Number of splits : {n_splits_sliding}")
```

Output
```
Expanding Window Number of splits : 62
Sliding Window Number of splits : 62
```
**Expected behavior**
<!--
A clear and concise description of what you expected to happen.
-->
 
Shouldnt the number of split is based on either of these two?:
1. Return number of splits based on datatype train/test splits which is two in this case
```python
print(f"Expanding Window splits: {len(list(cv.split(y_hierarchical)))}")
print(f"Sliding Window splits: {len(list(sliding.split(y_hierarchical)))}")
```
2. Return number of splits based on splits of the lowest hierachical level series. In this case it will be 6 * 2 = 12

**Versions**
<details>

System:
python: 3.9.17 (main, Jul  5 2023, 20:47:11) [MSC v.1916 64 bit (AMD64)]
machine: Windows-10-10.0.19045-SP0

Python dependencies:
pip: 23.2.1
sktime: 0.21.0
sklearn: 1.3.0
skbase: 0.4.6
numpy: 1.24.3
scipy: 1.11.1
pandas: 2.0.3
matplotlib: 3.7.2
joblib: 1.3.1
statsmodels: 0.14.0
numba: 0.57.1
pmdarima: 2.0.3
tsfresh: 0.20.1
tensorflow: 2.13.0
tensorflow_probability: None

</details>



Discussion:
@fkiraly, do you have opinion on which of the two should be the expected behaviour of `get_n_splits` method for hierarchical data?
I think it should always be equal to what `split` would return?

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
@@ -1089,12 +1089,38 @@ def get_n_splits(self, y: Optional[ACCEPTED_Y_TYPES] = None) -> int:
         n_splits : int
             The number of splits.
         """
+        from sktime.datatypes import check_is_scitype, convert
+
         if y is None:
             raise ValueError(
                 f"{self.__class__.__name__} requires `y` to compute the "
                 f"number of splits."
             )
-        return len(self.get_cutoffs(y))
+
+        multi_scitypes = ["Hierarchical", "Panel"]
+        is_non_single, _, metadata = check_is_scitype(y, multi_scitypes, [])
+
+        # n_splits based on the first instance of the lowest level series cutoffs
+        if is_non_single:
+            from_mtype = metadata.get("mtype")
+            scitype = metadata.get("scitype")
+            if scitype == "Panel":
+                to_mtype = "pd-multiindex"
+            else:
+                to_mtype = "pd_multiindex_hier"
+
+            y = convert(y, from_type=from_mtype, to_type=to_mtype, as_scitype=scitype)
+
+            index = self._coerce_to_index(y)
+            for _, values in y.groupby(index.droplevel(-1)):
+                # convert to a single ts
+                instance_series = values.reset_index().iloc[:, -2:]
+                instance_series.set_index(instance_series.columns[0], inplace=True)
+                n_splits = len(self.get_cutoffs(instance_series))
+                break
+        else:
+            n_splits = len(self.get_cutoffs(y))
+        return n_splits
 
     def get_cutoffs(self, y: Optional[ACCEPTED_Y_TYPES] = None) -> np.ndarray:
         """Return the cutoff points in .iloc[] context.


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
@@ -32,6 +32,7 @@
 )
 from sktime.utils._testing.forecasting import _make_fh
 from sktime.utils._testing.hierarchical import _make_hierarchical
+from sktime.utils._testing.panel import _make_panel
 from sktime.utils._testing.series import _make_series
 from sktime.utils.datetime import _coerce_duration_to_int
 from sktime.utils.validation import (
@@ -43,6 +44,10 @@
 from sktime.utils.validation.forecasting import check_fh
 
 N_TIMEPOINTS = 30
+TEST_Y_PANEL_HIERARCHICAL = [
+    _make_hierarchical((2, 2), N_TIMEPOINTS, N_TIMEPOINTS),
+    _make_panel(n_instances=2, n_timepoints=N_TIMEPOINTS),
+]
 
 
 def _get_windows(cv, y):
@@ -722,3 +727,32 @@ def test_hierachical_singlewindowsplitter():
     splitter = SingleWindowSplitter(fh=[1, 2], window_length=10)
     splits = list(splitter.split(y))
     assert len(splits) == 1, "Should only be one split"
+
+
+@pytest.mark.parametrize("CV", [SlidingWindowSplitter, ExpandingWindowSplitter])
+@pytest.mark.parametrize("fh", [*TEST_FHS, *TEST_FHS_TIMEDELTA])
+@pytest.mark.parametrize("window_length", TEST_WINDOW_LENGTHS)
+@pytest.mark.parametrize("step_length", TEST_STEP_LENGTHS)
+def test_windowbase_splitter_get_n_split_hierarchical(
+    CV, fh, window_length, step_length
+):
+    """Test that WindowBaseSplitter.get_n_splits works for hierarchical data."""
+    # see bugs 4971
+    y = TEST_Y_PANEL_HIERARCHICAL[0]  # hierachical data
+    if _inputs_are_supported([fh, window_length, step_length]):
+        cv = CV(fh, window_length, step_length)
+        assert cv.get_n_splits(y) == len(
+            list(cv.split(y))
+        ), "get_n_splits does not equal the number of splits in the output."
+
+
+@pytest.mark.parametrize("y", TEST_Y_PANEL_HIERARCHICAL)
+@pytest.mark.parametrize("CV", [SlidingWindowSplitter, ExpandingWindowSplitter])
+def test_windowbase_splitter_get_n_split_unequal_series(y, CV):
+    y_unequal = y.copy()  # avoid changing original dataset
+    y_unequal.iloc[:3, :] = None  # make the first series shorter than the rest
+    y_unequal.dropna(inplace=True)
+    cv = CV([1], 24, 1)
+    assert cv.get_n_splits(y_unequal) == len(
+        list(cv.split(y_unequal))
+    ), "get_n_splits does not equal the number of splits in the output."