Repository: sktime/sktime

Issue:
[BUG] latest releases of `sktime` and `statsforecast` are incompatible because of argument differences
## MCVE

```python
from sktime.datasets import load_airline
from sktime.forecasting.statsforecast import StatsForecastAutoARIMA

StatsForecastAutoARIMA().fit(load_airline())
```

## Error

```pycon
>>> from sktime.datasets import load_airline
>>> from sktime.forecasting.statsforecast import StatsForecastAutoARIMA
>>> 
>>> StatsForecastAutoARIMA().fit(load_airline())
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/anirban/sktime-fork/sktime/forecasting/base/_base.py", line 348, in fit
    self._fit(y=y_inner, X=X_inner, fh=fh)
  File "/home/anirban/sktime-fork/sktime/forecasting/base/adapters/_generalised_statsforecast.py", line 108, in _fit
    self._forecaster = self._instantiate_model()
  File "/home/anirban/sktime-fork/sktime/forecasting/base/adapters/_generalised_statsforecast.py", line 73, in _instantiate_model
    params = self._get_validated_statsforecast_params()
  File "/home/anirban/sktime-fork/sktime/forecasting/base/adapters/_generalised_statsforecast.py", line 58, in _get_validated_statsforecast_params
    for sktime_param in sktime_params.keys():
RuntimeError: dictionary changed size during iteration
>>>
```

## Environment

```txt
sktime==0.23, statsforecast==1.5  # working
sktime==0.23, statsforecast==1.6  # failing
```

## Related

#5112, #5149, #5150, #5215, #5207


Discussion:
@arnaujc91 I suppose line 60 in the for loop is affecting the size?

https://github.com/sktime/sktime/blob/c5ea83afd718e4bd7026c2bb4fdda35f28ae990c/sktime/forecasting/base/adapters/_generalised_statsforecast.py#L53-L69
I thought we resolved that?

Unfortunately not, it looks like we forgot to bump the bound and test.

Very much related: https://github.com/sktime/sktime/issues/5292
Hi @yarnabrina  i think it could be solved doing:

```python
for sktime_param in list(sktime_params): 
    if sktime_param not in statsforecast_params: 
        sktime_params.pop(sktime_param) 
        if sktime_param not in sktime_default_params: 
            warn( 
                f"Keyword argument '{sktime_param}' will be omitted as it is" 
                f" not found in the __init__ method " 
                f"from {self._get_statsforecast_class()}. " 
                f"Check your statsforecast version" 
                f"to find out the right API parameters." 
            ) 
return sktime_params


```

instead
@fkiraly , @yarnabrina , let me do a fast PR tomorrow and we solve it. Did not see that one coming.
It's a different error, but also seems to be from the latest release of `statsforecast`. Please find the logs [here](https://github.com/sktime/sktime/actions/runs/6290833989/job/17078629509?pr=5314).

> AttributeError: 'StatsForecastBackAdapter' object has no attribute 'prediction_intervals'

@luca-miniati Can you please take a look? It seems that the expectation is that `trend_forecaster` should have `prediction_intervals` attribute for conformal intervals.
@yarnabrina, how does the `TrendForecaster` relate to this? That is not dependent on `statsmodels` but `sktime` native?
`trend_forecaster` is an argument of `MSTL` class in `statsforecast`, not talking about the estimator in `sktime`. This argument is used for predict trend, and seasonality is dealt in `MSTL` with `STL` from `statsmodels`.

Ref. https://github.com/Nixtla/statsforecast/blob/33c2a0a9b30d279bafa1cbdbbe90635be096ebd4/statsforecast/models.py#L4848-L4855
ah, I see - thanks.

The problem is therefore possibly with the back-adapter? For composites in another framework, you need an adapter and a back-adapter, in order to make them composable with components in `sktime`.

Quite unfortunate that the `statsforecast` interfaces are so unstable that the half-life of the two-way adaptation was just a single minor version...

Resolving patch:
diff --git a/sktime/forecasting/base/adapters/_generalised_statsforecast.py b/sktime/forecasting/base/adapters/_generalised_statsforecast.py
--- a/sktime/forecasting/base/adapters/_generalised_statsforecast.py
+++ b/sktime/forecasting/base/adapters/_generalised_statsforecast.py
@@ -43,30 +43,77 @@ def __init__(self):
     def _get_statsforecast_class(self):
         raise NotImplementedError("abstract method")
 
-    def _get_statsforecast_params(self):
+    def _get_statsforecast_params(self) -> dict:
         return self.get_params()
 
     def _get_init_statsforecast_params(self):
+        """Return parameters in __init__ statsforecast forecaster.
+
+        Return a list of parameters in the __init__ method from
+        the statsforecast forecaster class used in the sktime adapter.
+        """
         statsforecast_class = self._get_statsforecast_class()
         return list(signature(statsforecast_class.__init__).parameters.keys())
 
-    def _get_validated_statsforecast_params(self):
-        sktime_params = self._get_statsforecast_params()
-        sktime_default_params = self.get_param_defaults().keys()
-        statsforecast_params = self._get_init_statsforecast_params()
-
-        for sktime_param in sktime_params.keys():
-            if sktime_param not in statsforecast_params:
-                sktime_params.pop(sktime_param)
-                if sktime_param not in sktime_default_params:
-                    warn(
-                        f"Keyword argument '{sktime_param}' will be omitted as it is"
-                        f" not found in the __init__ method "
-                        f"from {self._get_statsforecast_class()}. "
-                        f"Check your statsforecast version"
-                        f"to find out the right API parameters."
-                    )
-        return sktime_params
+    def _get_statsforecast_default_params(self) -> dict:
+        """Get default parameters for the statsforecast forecaster.
+
+        This will in general be different from self.get_param_defaults(),
+        as the set or names of inner parameters can differ.
+
+        For parameters without defaults, will use the parameter
+        of self instead.
+        """
+        self_params = self.get_params(deep=False)
+        self_default_params = self.get_param_defaults()
+        self_params.update(self_default_params)
+        cls_with_defaults = type(self)(**self_params)
+        return cls_with_defaults._get_statsforecast_params()
+
+    def _get_validated_statsforecast_params(self) -> dict:
+        """Return parameter dict with only parameters accepted by statsforecast API.
+
+        Checks if the parameters passed to the statsforecast forecaster
+        are valid in the __init__ method of the aforementioned forecaster.
+        If the parameter is not there it will just not be passed. Furthermore
+        if the parameter is modified by the sktime user,
+        he will be notified that the parameter does not exist
+        anymore in the version installed of statsforecast by the user.
+
+        """
+        params_sktime_to_statsforecast: dict = self._get_statsforecast_params()
+        params_sktime_to_statsforecast_default: dict = (
+            self._get_statsforecast_default_params()
+        )
+        statsforecast_init_params = set(self._get_init_statsforecast_params())
+
+        # Filter sktime_params to only include keys in statsforecast_params
+        filtered_sktime_params = {
+            key: value
+            for key, value in params_sktime_to_statsforecast.items()
+            if key in statsforecast_init_params
+        }
+
+        non_default_params = [
+            p
+            for p in params_sktime_to_statsforecast
+            if params_sktime_to_statsforecast[p]
+            != params_sktime_to_statsforecast_default[p]
+        ]
+        # Find parameters not in statsforecast_params or sktime_default_params
+        param_diff = set(non_default_params) - statsforecast_init_params
+
+        if param_diff:
+            params_str = ", ".join([f'"{param}"' for param in param_diff])
+            warning_message = (
+                f"Keyword arguments {params_str} "
+                f"will be omitted as they are not found in the __init__ method from "
+                f"{self._get_statsforecast_class()}. Check your statsforecast version "
+                f"to find out the right API parameters."
+            )
+            warn(warning_message)
+
+        return filtered_sktime_params
 
     def _instantiate_model(self):
         cls = self._get_statsforecast_class()
@@ -400,10 +447,17 @@ def __init__(self, estimator):
         super().__init__()
 
         self.estimator = estimator
+        self.prediction_intervals = None
 
     def __repr__(self):
         return "StatsForecastBackAdapter"
 
+    def new(self):
+        """Make new instance of back-adapter."""
+        _self = type(self).__new__(type(self))
+        _self.__dict__.update(self.__dict__)
+        return _self
+
     def fit(self, y, X=None):
         """Fit to training data.
 
@@ -442,6 +496,12 @@ def predict(self, h, X=None, level=None):
         mean = self.estimator.predict(fh=range(1, h + 1), X=X)[:, 0]
         if level is None:
             return {"mean": mean}
+        # if a level is passed, and if prediction_intervals has not been instantiated
+        # yet
+        elif self.prediction_intervals is None:
+            from statsforecast.utils import ConformalIntervals
+
+            self.prediction_intervals = ConformalIntervals(h=h)
 
         level = sorted(level)
         coverage = [round(1 - (_l / 100), 2) for _l in level]
diff --git a/sktime/forecasting/statsforecast.py b/sktime/forecasting/statsforecast.py
--- a/sktime/forecasting/statsforecast.py
+++ b/sktime/forecasting/statsforecast.py
@@ -595,6 +595,12 @@ class StatsForecastMSTL(_GeneralisedStatsForecastAdapter):
     trend_forecaster : estimator, optional, default=StatsForecastAutoETS()
         Sktime estimator used to make univariate forecasts. Multivariate estimators are
         not supported.
+    stl_kwargs : dict, optional
+        Extra arguments to pass to [`statsmodels.tsa.seasonal.STL`]
+        (https://www.statsmodels.org/dev/generated/statsmodels.tsa.seasonal.STL.html#statsmodels.tsa.seasonal.STL).
+        The `period` and `seasonal` arguments are reserved.
+    pred_int_kwargs : dict, optional
+        Extra arguments to pass to [`statsforecast.utils.ConformalIntervals`].
 
     References
     ----------
@@ -623,6 +629,8 @@ def __init__(
         self,
         season_length: Union[int, List[int]],
         trend_forecaster=None,
+        stl_kwargs: Optional[Dict] = None,
+        pred_int_kwargs: Optional[Dict] = None,
     ):
         super().__init__()
 
@@ -634,6 +642,8 @@ def __init__(
             self._trend_forecaster = clone(trend_forecaster)
         else:
             self._trend_forecaster = StatsForecastAutoETS(model="ZZN")
+        self.stl_kwargs = stl_kwargs
+        self.pred_int_kwargs = pred_int_kwargs
 
         # checks if trend_forecaster is already wrapped with
         # StatsForecastBackAdapter
@@ -650,6 +660,14 @@ def __init__(
                     "forecaster."
                 )
 
+        # check if prediction interval kwargs are passed
+        if self.pred_int_kwargs:
+            from statsforecast.utils import ConformalIntervals
+
+            self._trend_forecaster.prediction_intervals = ConformalIntervals(
+                **self.pred_int_kwargs
+            )
+
     def _get_statsforecast_class(self):
         from statsforecast.models import MSTL
 
@@ -695,6 +713,12 @@ def get_test_params(cls, parameter_set="default"):
                 {
                     "season_length": 4,
                 },
+                {
+                    "season_length": 4,
+                    "pred_int_kwargs": {
+                        "n_windows": 2,
+                    },
+                },
             ]
         except ModuleNotFoundError:
             from sktime.forecasting.naive import NaiveForecaster


Test patch: