Repository: sktime/sktime (python)

Issue:
[BUG] `all_estimators` from `sktime.registry` fails if `torch` is present in env
While trying to find all the forecasters with multivariate data support, I encountered an `ModuleNotFoundError` from `sktime/libs/uni2ts/forecast.py` when I had `torch` installed in my development enviroment. Interestingly I encounter no errors if `torch` is not present in the environment.

**To Reproduce**

`torch` should be installed in the enviroment and then

```python
from sktime.registry import all_estimators
forecasters = all_estimators(estimator_types="forecaster", return_names=False)
```


**Versions**
System:
    python: 3.12.8 | packaged by Anaconda, Inc. | (main, Dec 11 2024, 16:31:09) [GCC 11.2.0]
executable: /home/jigyasu/anaconda3/envs/sktime-dev/bin/python3
   machine: Linux-6.8.0-57-generic-x86_64-with-glibc2.35

Python dependencies:
          pip: 25.0
       sktime: 0.35.1
      sklearn: 1.5.2
       skbase: 0.12.0
        numpy: 1.26.4
        scipy: 1.15.1
       pandas: 2.2.3
   matplotlib: 3.10.0
       joblib: 1.4.2
        numba: 0.60.0
  statsmodels: 0.14.4
     pmdarima: 2.0.4
statsforecast: 2.0.0
      tsfresh: None
      tslearn: None
        torch: 2.6.0
   tensorflow: None


Pull request description:
[BUG] `_safe_import` to avoid failures due to soft dependency chains in `sktime/libs/uni2ts/forecast.py`
Fixes #8191 

This PR uses `_safe_import` to import `gluonts` classes in `sktime/libs/uni2ts/forecast.py`. Due to soft dependency chain, i.e., `gluonts` being a soft dependency and `lightning` being a soft dependency of `gluonts.torch`, it led to failures in certain conditions. One of those conditions that I encountered is mentioned in the linked issue above. 

Patch:
diff --git a/docs/source/api_reference/split.rst b/docs/source/api_reference/split.rst
index 8f2be4a40..1053ed74a 100644
--- a/docs/source/api_reference/split.rst
+++ b/docs/source/api_reference/split.rst
@@ -50,6 +50,7 @@ They have tag ``"split_type"="temporal"``.
     CutoffSplitter
     SingleWindowSplitter
     SlidingWindowSplitter
+    SlidingGreedySplitter
     ExpandingWindowSplitter
     ExpandingCutoffSplitter
     ExpandingGreedySplitter
diff --git a/pyproject.toml b/pyproject.toml
index 0b1efd73b..d6d91b184 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -92,7 +92,7 @@ all_extras = [
   'matplotlib!=3.9.1,>=3.3.2; python_version < "3.13"',
   'mne; python_version < "3.13"',
   'numba<0.62,>=0.53; python_version < "3.13"',
-  'optuna<4.3',
+  'optuna<4.4',
   'pmdarima!=1.8.1,<3.0.0,>=1.8; python_version < "3.12"',
   'polars[pandas]>=0.20,<2.0; python_version < "3.13"',
   'prophet>=1.1; python_version < "3.12"',
@@ -137,7 +137,7 @@ all_extras_pandas2 = [
   'matplotlib!=3.9.1,>=3.3.2; python_version < "3.13"',
   'mne; python_version < "3.13"',
   'numba<0.62,>=0.53; python_version < "3.13"',
-  'optuna<4.3',
+  'optuna<4.4',
   'pmdarima!=1.8.1,<3.0.0,>=1.8; python_version < "3.12"',
   'polars[pandas]>=0.20,<2.0; python_version < "3.13"',
   'prophet>=1.1; python_version < "3.12"',
diff --git a/sktime/forecasting/model_selection/_tune.py b/sktime/forecasting/model_selection/_tune.py
index 657d7b5b9..a51bd0212 100644
--- a/sktime/forecasting/model_selection/_tune.py
+++ b/sktime/forecasting/model_selection/_tune.py
@@ -1717,8 +1717,7 @@ class ForecastingOptunaSearchCV(BaseGridSearch):
     >>> from sktime.transformations.series.adapt import TabularToSeriesAdaptor
     >>> from sktime.transformations.series.detrend import Deseasonalizer, Detrender
     >>> from sktime.forecasting.naive import NaiveForecaster
-    >>> from sktime.forecasting.theta import ThetaForecaster
-    >>> from sktime.forecasting.trend import STLForecaster
+    >>> from sktime.forecasting.trend import STLForecaster, TrendForecaster
     >>> import optuna
     >>> from  optuna.distributions import CategoricalDistribution
 
@@ -1741,7 +1740,7 @@ class ForecastingOptunaSearchCV(BaseGridSearch):
     ...             (True, False)
     ...         ),
     ...     "forecaster": CategoricalDistribution(
-    ...             (NaiveForecaster(), ThetaForecaster())
+    ...             (NaiveForecaster(), TrendForecaster())
     ...         ),
     ...     }
     >>> gscv = ForecastingOptunaSearchCV(
diff --git a/sktime/libs/uni2ts/forecast.py b/sktime/libs/uni2ts/forecast.py
index de2835201..2952b856b 100644
--- a/sktime/libs/uni2ts/forecast.py
+++ b/sktime/libs/uni2ts/forecast.py
@@ -21,6 +21,8 @@ from typing import Any, Optional
 import numpy as np
 from skbase.utils.dependencies import _check_soft_dependencies
 
+from sktime.utils.dependencies import _safe_import
+
 if _check_soft_dependencies("lightning", severity="none"):
     import lightning as L
 
@@ -39,16 +41,19 @@ if _check_soft_dependencies("torch", severity="none"):
 if _check_soft_dependencies("einops", severity="none"):
     from einops import rearrange, reduce, repeat
 
-if _check_soft_dependencies(["gluonts", "torch"], severity="none"):
-    from gluonts.model import Input, InputSpec
-    from gluonts.torch import PyTorchPredictor
-    from gluonts.transform import (
-        AddObservedValuesIndicator,
-        AsNumpyArray,
-        ExpandDimArray,
-        TestSplitSampler,
-    )
-    from gluonts.transform.split import TFTInstanceSplitter
+Input = _safe_import("gluonts.model.Input")
+InputSpec = _safe_import("gluonts.model.InputSpec")
+
+PyTorchPredictor = _safe_import("gluonts.torch.PyTorchPredictor")
+
+AddObservedValuesIndicator = _safe_import(
+    "gluonts.transform.AddObservedValuesIndicator"
+)
+AsNumpyArray = _safe_import("gluonts.transform.AsNumpyArray")
+ExpandDimArray = _safe_import("gluonts.transform.ExpandDimArray")
+TestSplitSampler = _safe_import("gluonts.transform.TestSplitSampler")
+
+TFTInstanceSplitter = _safe_import("gluonts.transform.split.TFTInstanceSplitter")
 
 
 from sktime.libs.uni2ts.common.torch_util import safe_div
diff --git a/sktime/split/__init__.py b/sktime/split/__init__.py
index 4eb27f63e..5850b8157 100644
--- a/sktime/split/__init__.py
+++ b/sktime/split/__init__.py
@@ -10,6 +10,7 @@ __all__ = [
     "InstanceSplitter",
     "SameLocSplitter",
     "SingleWindowSplitter",
+    "SlidingGreedySplitter",
     "SlidingWindowSplitter",
     "TemporalTrainTestSplitter",
     "TestPlusTrainSplitter",
@@ -24,6 +25,7 @@ from sktime.split.expandingwindow import ExpandingWindowSplitter
 from sktime.split.instance import InstanceSplitter
 from sktime.split.sameloc import SameLocSplitter
 from sktime.split.singlewindow import SingleWindowSplitter
+from sktime.split.slidinggreedy import SlidingGreedySplitter
 from sktime.split.slidingwindow import SlidingWindowSplitter
 from sktime.split.temporal_train_test_split import (
     TemporalTrainTestSplitter,
diff --git a/sktime/split/slidinggreedy.py b/sktime/split/slidinggreedy.py
new file mode 100644
index 000000000..24afd4e89
--- /dev/null
+++ b/sktime/split/slidinggreedy.py
@@ -0,0 +1,141 @@
+#!/usr/bin/env python3 -u
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+"""Splitter that combines sliding window with greedy splitting."""
+
+__author__ = ["marrov"]
+
+__all__ = [
+    "SlidingGreedySplitter",
+]
+
+from typing import Optional, Union
+
+import numpy as np
+import pandas as pd
+
+from sktime.split.base import BaseSplitter
+from sktime.split.base._common import SPLIT_GENERATOR_TYPE
+
+
+class SlidingGreedySplitter(BaseSplitter):
+    """Splitter that combines sliding window with greedy splitting.
+
+    Takes integers or floats ``train_size`` and ``test_size`` that define the
+    number of steps included in the train and test sets of each fold.
+    If float values are provided, they are interpreted as proportions of the
+    total length of the time series. If the data contains multiple instances,
+    sizes are _per instance_.
+
+    If no ``step_length`` is defined, the test sets (one for each fold) will be
+    adjacent and disjoint, taken from the end of the dataset.
+
+    Parameters
+    ----------
+    train_size : int or float
+        If int: the number of steps included in the train set of each fold.
+            Formally, steps are consecutive ``iloc`` indices.
+        If float: the proportion of steps included in the train set of each fold,
+            as a proportion of the total number of consecutive ``iloc`` indices.
+            Must be between 0.0 and 1.0. Proportions are rounded to the
+            next higher integer count of samples (ceil).
+    test_size : int or float
+        If int: the number of steps included in the test set of each fold.
+            Formally, steps are consecutive ``iloc`` indices.
+        If float: the proportion of steps included in the test set of each fold,
+            as a proportion of the total number of consecutive ``iloc`` indices.
+            Must be between 0.0 and 1.0. Proportions are rounded to the
+            next higher integer count of samples (ceil).
+    folds : int, default = 5
+        The number of folds.
+    step_length : int, optional
+        The number of steps advanced for each fold. Defaults to ``test_size``.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> from sktime.split import SlidingGreedySplitter
+
+    >>> ts = np.arange(10)
+    >>> splitter = SlidingGreedySplitter(train_size=4, test_size=2, folds=2)
+    >>> list(splitter.split(ts))  # doctest: +SKIP
+    [
+        (array([2, 3, 4, 5]), array([6, 7])),
+        (array([4, 5, 6, 7]), array([8, 9]))
+    ]
+    """
+
+    _tags = {"split_hierarchical": True}
+
+    def __init__(
+        self,
+        train_size: Union[int, float],
+        test_size: Union[int, float],
+        folds: int = 5,
+        step_length: Optional[int] = None,
+    ):
+        super().__init__()
+        self.train_size = train_size
+        self.test_size = test_size
+        self.folds = folds
+        self.step_length = step_length
+
+        if isinstance(train_size, float) or isinstance(test_size, float):
+            self.set_tags(**{"split_hierarchical": False})
+
+    def _split(self, y: pd.Index) -> SPLIT_GENERATOR_TYPE:
+        train_size = self.train_size
+        test_size = self.test_size
+
+        if isinstance(train_size, float):
+            _train_size = int(np.ceil(len(y) * train_size))
+        else:
+            _train_size = train_size
+
+        if isinstance(test_size, float):
+            _test_size = int(np.ceil(len(y) * test_size))
+        else:
+            _test_size = test_size
+
+        if isinstance(y, pd.MultiIndex):
+            groups = pd.Series(index=y, dtype="float64").groupby(y.names[:-1])
+            reverse_idx = groups.transform("size") - groups.cumcount() - 1
+        else:
+            reverse_idx = np.arange(len(y))[::-1]
+
+        step_length = self.step_length or _test_size
+
+        for i in reversed(range(self.folds)):
+            tst_end = i * step_length
+            tst_start = tst_end + _test_size
+            trn_end = tst_start
+            trn_start = trn_end + _train_size
+
+            trn_indices = np.flatnonzero(
+                (reverse_idx >= trn_end) & (reverse_idx < trn_start)
+            )
+            tst_indices = np.flatnonzero(
+                (reverse_idx >= tst_end) & (reverse_idx < tst_start)
+            )
+
+            if len(trn_indices) > 0 and len(tst_indices) > 0:
+                yield trn_indices, tst_indices
+
+    @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
+        """
+        params1 = {"train_size": 3, "test_size": 1}
+        params2 = {"train_size": 4, "test_size": 2, "folds": 2, "step_length": 2}
+        params3 = {"train_size": 0.3, "test_size": 0.2, "folds": 2}
+        return [params1, params2, params3]


Test patch:
diff --git a/sktime/split/tests/test_slidinggreedy.py b/sktime/split/tests/test_slidinggreedy.py
new file mode 100644
index 000000000..8e06b8022
--- /dev/null
+++ b/sktime/split/tests/test_slidinggreedy.py
@@ -0,0 +1,249 @@
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+"""Tests for sliding greedy splitter."""
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from sktime.split import SlidingGreedySplitter
+from sktime.tests.test_switch import run_test_for_class
+from sktime.utils._testing.hierarchical import _make_hierarchical
+from sktime.utils._testing.series import _make_series
+
+
+@pytest.mark.skipif(
+    not run_test_for_class(SlidingGreedySplitter),
+    reason="run test only if softdeps are present and incrementally (if requested)",
+)
+def test_sliding_greedy_splitter_lengths():
+    """Test that SlidingGreedySplitter returns the correct lengths."""
+    y = np.arange(10)
+    cv = SlidingGreedySplitter(train_size=4, test_size=2, folds=3)
+
+    lengths = [(len(trn), len(tst)) for trn, tst in cv.split_series(y)]
+    assert lengths == [(4, 2), (4, 2), (4, 2)]
+
+
+@pytest.mark.skipif(
+    not run_test_for_class(SlidingGreedySplitter),
+    reason="run test only if softdeps are present and incrementally (if requested)",
+)
+def test_sliding_greedy_splitter_indices():
+    """Test that SlidingGreedySplitter returns the correct indices."""
+    y = np.arange(10)
+    cv = SlidingGreedySplitter(train_size=4, test_size=2, folds=2)
+
+    # Convert to list of tuples for easy comparison
+    splits = [(list(trn), list(tst)) for trn, tst in cv.split(y)]
+
+    # The expected indices for a sliding greedy splitter with train_size=4, test_size=2,
+    # folds=2
+    expected = [([2, 3, 4, 5], [6, 7]), ([4, 5, 6, 7], [8, 9])]
+
+    # Compare actual and expected splits
+    assert len(splits) == len(expected)
+    for i, (actual, exp) in enumerate(zip(splits, expected)):
+        assert actual[0] == exp[0], f"Train indices mismatch in fold {i}"
+        assert actual[1] == exp[1], f"Test indices mismatch in fold {i}"
+
+
+@pytest.mark.skipif(
+    not run_test_for_class(SlidingGreedySplitter),
+    reason="run test only if softdeps are present and incrementally (if requested)",
+)
+def test_sliding_greedy_splitter_dates():
+    """Test that SlidingGreedySplitter splits on dates correctly."""
+    ts = _make_series(index_type="period")
+    cv = SlidingGreedySplitter(train_size=4, test_size=2, folds=3)
+
+    train_starts = []
+    train_ends = []
+    test_starts = []
+    test_ends = []
+
+    for trn, tst in cv.split_series(ts):
+        train_starts.append(trn.index[0])
+        train_ends.append(trn.index[-1])
+        test_starts.append(tst.index[0])
+        test_ends.append(tst.index[-1])
+
+    # Check that train/test windows slide properly
+    assert test_ends[-1] == ts.index[-1]  # Last test ends at series end
+
+    # Check correct number of folds
+    assert len(train_starts) == 3
+    assert len(test_starts) == 3
+
+    # Check train/test sizes
+    for trn, tst in cv.split_series(ts):
+        assert len(trn) == 4  # train_size=4
+        assert len(tst) == 2  # test_size=2
+
+    # Check train/test contiguity and sliding behavior
+    assert train_ends[0] == pd.Period("2003-08", "M")
+    assert test_starts[0] == pd.Period("2003-09", "M")
+    assert test_ends[0] == pd.Period("2003-10", "M")
+
+    # Check that windows slide properly with correct step size
+    assert train_starts[1] == pd.Period("2003-07", "M")  # Slides by test_size (2)
+    assert test_starts[1] == pd.Period("2003-11", "M")
+
+    # Check non-overlapping test windows
+    assert test_ends[0] < test_starts[1]
+    assert test_ends[1] < test_starts[2]
+
+    # Check that test windows immediately follow train windows
+    for i in range(len(train_ends)):
+        next_month = train_ends[i].asfreq("M") + 1
+        assert test_starts[i] == next_month
+
+    # Check overall correctness of window positions
+    expected_train_starts = [
+        pd.Period("2003-05", "M"),
+        pd.Period("2003-07", "M"),
+        pd.Period("2003-09", "M"),
+    ]
+    expected_test_ends = [
+        pd.Period("2003-10", "M"),
+        pd.Period("2003-12", "M"),
+        pd.Period("2004-02", "M"),
+    ]
+
+    assert train_starts == expected_train_starts
+    assert test_ends == expected_test_ends
+
+
+@pytest.mark.skipif(
+    not run_test_for_class(SlidingGreedySplitter),
+    reason="run test only if softdeps are present and incrementally (if requested)",
+)
+def test_sliding_greedy_splitter_hierarchy():
+    """Test that SlidingGreedySplitter handles uneven hierarchical data."""
+    y_panel = _make_hierarchical(
+        hierarchy_levels=(2, 4),
+        max_timepoints=20,
+        min_timepoints=10,
+        same_cutoff=False,
+    )
+
+    cv = SlidingGreedySplitter(train_size=4, test_size=2, folds=3)
+    splits = list(cv.split_series(y_panel))
+
+    # Test we have the expected number of folds
+    assert len(splits) == 3
+
+    for train, test in splits:
+        # Check correct sizes for train and test sets
+        assert all(len(group) == 4 for _, group in train.groupby(level=[0, 1]))
+        assert all(len(group) == 2 for _, group in test.groupby(level=[0, 1]))
+
+        # Verify hierarchical integrity is maintained
+        assert set(train.index.droplevel(-1)) == set(y_panel.index.droplevel(-1))
+        assert set(test.index.droplevel(-1)) == set(y_panel.index.droplevel(-1))
+
+    # Test that the last test fold ends at the correct end date for each instance
+    last_test_fold = splits[-1][1]
+
+    last_test_dates_actual = (
+        last_test_fold.reset_index(-1)
+        .groupby(last_test_fold.index.names[:-1])
+        .tail(1)["time"]
+    )
+    last_test_dates_expected = (
+        y_panel.reset_index(-1).groupby(y_panel.index.names[:-1]).tail(1)["time"]
+    )
+
+    assert last_test_dates_actual.eq(last_test_dates_expected).all()
+
+    # Verify that test sets are contiguous and follow train sets for each instance
+    for i, (train, test) in enumerate(splits):
+        # Group by hierarchy levels
+        train_grouped = train.groupby(level=[0, 1])
+        test_grouped = test.groupby(level=[0, 1])
+
+        for group_key in train_grouped.groups:
+            train_group = train_grouped.get_group(group_key)
+            test_group = test_grouped.get_group(group_key)
+
+            # Test set should immediately follow train set
+            train_last_date = train_group.index.get_level_values("time").max()
+            test_first_date = test_group.index.get_level_values("time").min()
+
+            # The first date in test should be the next day after the last date in train
+            next_day = pd.Timestamp(train_last_date) + pd.Timedelta(days=1)
+            assert test_first_date == next_day
+
+
+@pytest.mark.skipif(
+    not run_test_for_class(SlidingGreedySplitter),
+    reason="run test only if softdeps are present and incrementally (if requested)",
+)
+def test_sliding_greedy_splitter_consecutive():
+    """Test that SlidingGreedySplitter results in consecutive periods within windows."""
+    y_panel = _make_hierarchical(
+        hierarchy_levels=(2, 4),
+        max_timepoints=20,
+        min_timepoints=10,
+        same_cutoff=False,
+    )
+
+    cv = SlidingGreedySplitter(train_size=4, test_size=2, folds=3)
+
+    for trn, tst in cv.split_series(y_panel):
+        # Check train windows are consecutive
+        train_consec = (
+            trn.reset_index(-1)
+            .groupby(trn.index.names[:-1])["time"]
+            .agg(lambda s: len(s.diff().value_counts()) == 1)
+        )
+        assert train_consec.all()
+
+        # Check test windows are consecutive
+        test_consec = (
+            tst.reset_index(-1)
+            .groupby(tst.index.names[:-1])["time"]
+            .agg(lambda s: len(s.diff().value_counts()) == 1)
+        )
+        assert test_consec.all()
+
+
+@pytest.mark.skipif(
+    not run_test_for_class(SlidingGreedySplitter),
+    reason="run test only if softdeps are present and incrementally (if requested)",
+)
+def test_sliding_greedy_splitter_step_length():
+    """Test that step_length parameter works correctly."""
+    y = np.arange(15)
+    cv = SlidingGreedySplitter(train_size=4, test_size=2, folds=3, step_length=3)
+
+    splits = list(cv.split(y))
+
+    # Check correct number of folds
+    assert len(splits) == 3
+
+    for train_idx, test_idx in splits:
+        # Check train and test set sizes
+        assert len(train_idx) == 4
+        assert len(test_idx) == 2
+
+    # For step_length=3, the test sets should be spaced 3 indices apart
+    test_sets = [test for _, test in splits]
+
+    # Check first fold's test set
+    assert np.array_equal(test_sets[0], np.array([7, 8]))
+
+    # Check second fold's test set (should be 3 steps before)
+    assert np.array_equal(test_sets[1], np.array([10, 11]))
+
+    # Check third fold's test set (should be 3 steps before)
+    assert np.array_equal(test_sets[2], np.array([13, 14]))
+
+    # Check that the train windows slide correctly
+    train_sets = [train for train, _ in splits]
+    assert np.array_equal(train_sets[0], np.array([3, 4, 5, 6]))
+    assert np.array_equal(train_sets[1], np.array([6, 7, 8, 9]))
+    assert np.array_equal(train_sets[2], np.array([9, 10, 11, 12]))
+
+    # Confirm that step_length is respected between sets
+    assert test_sets[1][0] - test_sets[0][0] == 3
+    assert test_sets[2][0] - test_sets[1][0] == 3