Repository: sktime/sktime

Issue:
[ENH][MNT] Interface StatsForecast MSTL
<!--
Thanks for contributing a pull request! Please ensure you have taken a look
at our contribution guide: https://github.com/sktime/sktime/blob/main/CONTRIBUTING.md
-->

#### Reference Issues/PRs
<!--
Example: Fixes #1234. See also #3456.

Please use keywords (e.g., Fixes) to create link to the issues or pull requests
you resolved, so that they will automatically be closed when your pull request
is merged. See https://github.com/blog/1506-closing-issues-via-pull-requests
-->
Umbrella issue #4643


#### What does this implement/fix? Explain your changes.
<!--
A clear and concise description of what you have implemented.
-->
Interface for StatsForecast MSTL

#### Did you add any tests for the change?

<!-- This section is useful if you have added a test in addition to the existing ones. This will ensure that further changes to these files won't introduce the same kind of bug. It is considered good practice to add tests with newly added code to enforce the fact that the code actually works. This will reduce the chance of introducing logical bugs.
-->
Tests found in `get_test_params`, passed all tests in `check_estimator` 


#### PR checklist
<!--
Please go through the checklist below. Please feel free to remove points if they are not applicable.
-->

##### For all contributions
- [x] I've added myself to the [list of contributors](https://github.com/sktime/sktime/blob/main/CONTRIBUTORS.md) with any new badges I've earned :-)
  How to: add yourself to the [all-contributors file](https://github.com/sktime/sktime/blob/main/.all-contributorsrc) in the `sktime` root directory (not the `CONTRIBUTORS.md`). Common badges: `code` - fixing a bug, or adding code logic. `doc` - writing or improving documentation or docstrings. `bug` - reporting or diagnosing a bug (get this plus `code` if you also fixed the bug in the PR).`maintenance` - CI, test framework, release.
  See here for [full badge reference](https://allcontributors.org/docs/en/emoji-key)
- [ ] Optionally, I've added myself and possibly others to the [CODEOWNERS](https://github.com/sktime/sktime/blob/main/CODEOWNERS) file - do this if you want to become the owner or maintainer of an estimator you added.
  See here for further details on the [algorithm maintainer role](https://www.sktime.net/en/latest/get_involved/governance.html#algorithm-maintainers).
- [x] The PR title starts with either [ENH], [MNT], [DOC], or [BUG]. [BUG] - bugfix, [MNT] - CI, test framework, [ENH] - adding or improving code, [DOC] - writing or improving documentation or docstrings.

##### For new estimators
- [x] I've added the estimator to the API reference - in `docs/source/api_reference/taskname.rst`, follow the pattern.
- [x] I've added one or more illustrative usage examples to the docstring, in a pydocstyle compliant `Examples` section.
- [ ] If the estimator relies on a soft dependency, I've set the `python_dependencies` tag and ensured
  dependency isolation, see the [estimator dependencies guide](https://www.sktime.net/en/latest/developer_guide/dependencies.html#adding-a-soft-dependency).


<!--
Thanks for contributing!
-->



Discussion:

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
@@ -267,3 +267,177 @@ def _predict_interval(self, fh, X, coverage, legacy_interface=True):
         final_interval_predictions = pandas.concat(interval_predictions, copy=False)
 
         return final_interval_predictions
+
+
+class StatsForecastBackAdapter:
+    """StatsForecast Back Adapter.
+
+    StatsForecastBackAdapter is a wrapper for sktime forecasters to be used in
+    StatsForecast composite models.
+
+    Parameters
+    ----------
+    estimator : sktime forecaster
+
+    Examples
+    --------
+    >>> from sktime.datasets import load_airline
+    >>> from sktime.forecasting.statsforecast import StatsForecastMSTL
+    >>> from sktime.forecasting.ets import AutoETS
+
+    >>> y = load_airline()
+    >>> trend_forecaster = AutoETS() # doctest: +SKIP
+    >>> model = StatsForecastMSTL( # doctest: +SKIP
+            season_length=[3,12],
+            trend_forecaster=trend_forecaster
+        )
+    >>> fitted_model = model.fit(y=y) # doctest: +SKIP
+    >>> y_pred = fitted_model.predict(fh=[1,2,3]) # doctest: +SKIP
+    """
+
+    _tags = {
+        "python_dependencies": ["statsforecast"],
+    }
+
+    def __init__(self, estimator):
+        super().__init__()
+
+        self.estimator = estimator
+
+    def __repr__(self):
+        return "StatsForecastBackAdapter"
+
+    def fit(self, y, X=None):
+        """Fit to training data.
+
+        Parameters
+        ----------
+        y : ndarray
+            Time series of shape (t, ) without missing values
+        X : typing.Optional[numpy.ndarray], default=None
+
+        Returns
+        -------
+        self : returns an instance of self.
+        """
+        self.estimator = self.estimator.fit(y=y, X=X)
+
+        return self
+
+    def predict(self, h, X=None, level=None):
+        """Make forecasts.
+
+        Parameters
+        ----------
+        h : int
+            Forecast horizon.
+        X : typing.Optional[numpy.ndarray], default=None
+            Optional exogenous of shape (h, n_x).
+        level : typing.Optional[typing.Tuple[int]], default=None
+            Confidence levels (0-100) for prediction intervals.
+
+        Returns
+        -------
+        y_pred : dict
+            Dictionary with entries mean for point predictions and level_* for
+            probabilistic predictions.
+        """
+        mean = self.estimator.predict(fh=range(1, h + 1), X=X)[:, 0]
+        if level is None:
+            return {"mean": mean}
+
+        level = sorted(level)
+        coverage = [round(1 - (_l / 100), 2) for _l in level]
+
+        pred_int = self.estimator.predict_interval(
+            fh=range(1, h + 1), X=X, coverage=coverage
+        )
+
+        return self.format_pred_int("mean", mean, pred_int, coverage, level)
+
+    def predict_in_sample(self, level=None):
+        """Access fitted MSTL insample predictions.
+
+        Parameters
+        ----------
+        level : typing.Optional[typing.Tuple[int]]
+            Confidence levels (0-100) for prediction intervals.
+
+        Returns
+        -------
+        y_pred : dict
+            Dictionary with entries mean for point predictions and level_* for
+            probabilistic predictions.
+        """
+        fitted = self.estimator.predict(self.estimator._y.index)[:, 0]
+
+        if level is None:
+            return {"fitted": fitted}
+
+        level = sorted(level)
+        coverage = [round(1 - (_l / 100), 2) for _l in level]
+        pred_int = self.estimator.predict_interval(
+            fh=self.estimator._y.index, X=self.estimator._X, coverage=coverage
+        )
+        return self.format_pred_int("fitted", fitted, pred_int, coverage, level)
+
+    def format_pred_int(self, y_pred_name, y_pred, pred_int, coverage, level):
+        pred_int_prefix = "fitted-" if y_pred_name == "fitted" else ""
+
+        return {
+            y_pred_name: y_pred,
+            **{
+                f"{pred_int_prefix}lo-{_l}": pred_int[("Coverage", c, "lower")].values
+                for c, _l in zip(reversed(coverage), reversed(level))
+            },
+            **{
+                f"{pred_int_prefix}hi-{_l}": pred_int[("Coverage", c, "upper")].values
+                for c, _l in zip(coverage, level)
+            },
+        }
+
+    @classmethod
+    def get_test_params(cls, parameter_set="default"):
+        """Return testing parameter settings for the estimator.
+
+        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.
+            There are currently no reserved values for forecasters.
+
+        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`
+        """
+        from sktime.utils.validation._dependencies import _check_soft_dependencies
+
+        del parameter_set  # to avoid being detected as unused by ``vulture`` etc.
+
+        if _check_soft_dependencies("statsmodels", severity="none"):
+            from sktime.forecasting.theta import ThetaForecaster
+            from sktime.forecasting.var import VAR
+
+            params = [
+                {
+                    "estimator": ThetaForecaster(),
+                },
+                {
+                    "estimator": VAR(),
+                },
+            ]
+        else:
+            from sktime.forecasting.naive import NaiveForecaster
+
+            params = [
+                {
+                    "estimator": NaiveForecaster(),
+                },
+            ]
+
+        return params
diff --git a/sktime/forecasting/statsforecast.py b/sktime/forecasting/statsforecast.py
--- a/sktime/forecasting/statsforecast.py
+++ b/sktime/forecasting/statsforecast.py
@@ -8,14 +8,16 @@
     "StatsForecastAutoCES",
     "StatsForecastAutoETS",
     "StatsForecastAutoTheta",
+    "StatsForecastMSTL",
 ]
+from typing import Dict, List, Optional, Union
 
-
-from typing import Dict, Optional
-
+from sktime.forecasting.base import BaseForecaster
 from sktime.forecasting.base.adapters._generalised_statsforecast import (
+    StatsForecastBackAdapter,
     _GeneralisedStatsForecastAdapter,
 )
+from sktime.utils.validation._dependencies import _check_soft_dependencies
 
 
 class StatsForecastAutoARIMA(_GeneralisedStatsForecastAdapter):
@@ -552,3 +554,137 @@ def get_test_params(cls, parameter_set="default"):
         params = [{}, {"season_length": 4, "model": "Z"}]
 
         return params
+
+
+class StatsForecastMSTL(_GeneralisedStatsForecastAdapter):
+    """StatsForecast Multiple Seasonal-Trend decomposition using LOESS model.
+
+    This implementation is a wrapper over Nixtla implementation in
+    statsforecast [1]_.
+
+    The MSTL (Multiple Seasonal-Trend decomposition using LOESS) decomposes the time
+    series in multiple seasonalities using LOESS. Then forecasts the trend using
+    a custom non-seasonal model (`trend_forecaster`) and each seasonality using a
+    SeasonalNaive model. MSTL requires the input time series data to be univariate.
+
+    Parameters
+    ----------
+    season_length : Union[int, List[int]]
+        Number of observations per unit of time. For multiple seasonalities use a
+        list.
+    trend_forecaster : estimator, optional, default=StatsForecastAutoETS()
+        Sktime estimator used to make univariate forecasts. Multivariate estimators are
+        not supported.
+
+    References
+    ----------
+    .. [1]
+        https://nixtla.github.io/statsforecast/src/core/models.html#mstl
+
+    Examples
+    --------
+    >>> from sktime.datasets import load_airline
+    >>> from sktime.forecasting.statsforecast import StatsForecastMSTL
+
+    >>> y = load_airline()
+    >>> model = StatsForecastMSTL(season_length=[3,12]) # doctest: +SKIP
+    >>> fitted_model = model.fit(y=y) # doctest: +SKIP
+    >>> y_pred = fitted_model.predict(fh=[1,2,3]) # doctest: +SKIP
+    """
+
+    _tags = {
+        "ignores-exogeneous-X": True,
+        "capability:pred_int": True,
+        "capability:pred_int:insample": True,
+        "python_dependencies": ["statsforecast"],
+    }
+
+    def __init__(
+        self,
+        season_length: Union[int, List[int]],
+        trend_forecaster=None,
+    ):
+        super().__init__()
+
+        from sklearn.base import clone
+
+        self.trend_forecaster = trend_forecaster
+        self.season_length = season_length
+        if trend_forecaster:
+            self._trend_forecaster = clone(trend_forecaster)
+        else:
+            self._trend_forecaster = StatsForecastAutoETS(model="ZZN")
+
+        # checks if trend_forecaster is already wrapped with
+        # StatsForecastBackAdapter
+        if not isinstance(self._trend_forecaster, StatsForecastBackAdapter):
+            # if trend_forecaster is sktime forecaster
+            if isinstance(self._trend_forecaster, BaseForecaster):
+                self._trend_forecaster = StatsForecastBackAdapter(
+                    self._trend_forecaster
+                )
+            else:
+                raise TypeError(
+                    "The provided forecaster is not compatible with MSTL. Please ensure"
+                    " that the forecaster you pass into the model is a sktime "
+                    "forecaster."
+                )
+
+    def _instantiate_model(self):
+        """Create underlying forecaster instance."""
+        from statsforecast.models import MSTL
+
+        return MSTL(
+            season_length=self.season_length,
+            trend_forecaster=self._trend_forecaster,
+        )
+
+    @classmethod
+    def get_test_params(cls, parameter_set="default"):
+        """Return testing parameter settings for the estimator.
+
+        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.
+            There are currently no reserved values for forecasters.
+
+        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`
+        """
+        del parameter_set  # to avoid being detected as unused by ``vulture`` etc.
+
+        try:
+            _check_soft_dependencies("statsmodels")
+            from sktime.forecasting.theta import ThetaForecaster
+
+            params = [
+                {
+                    "season_length": [3, 12],
+                    "trend_forecaster": ThetaForecaster(),
+                },
+                {
+                    "season_length": 4,
+                },
+            ]
+        except ModuleNotFoundError:
+            from sktime.forecasting.naive import NaiveForecaster
+
+            params = [
+                {
+                    "season_length": [3, 12],
+                    "trend_forecaster": NaiveForecaster(),
+                },
+                {
+                    "season_length": 4,
+                },
+            ]
+
+        return params


Test patch: