Repository: sktime/sktime
Pull request #5727 (2024-01-11 00:49:42)

[ENH] testing estimators whose package dependencies are changed in `pyproject.toml`
This PR adds a condition to differential testing, so classes whose dependencies have been updated in `pyproject.toml` are always tested.

This logic is based on an utility that determines which package dependencies are changed by a pull request, and adds a condition

The utility could further be useful in:

* hypothetical test environment setup per estimator, such as discussed in https://github.com/sktime/sktime/issues/5719

FYI @yarnabrina 

fkiraly: Added diagnostic PR: https://github.com/sktime/sktime/pull/5774

Patch:
diff --git a/docs/source/api_reference/forecasting.rst b/docs/source/api_reference/forecasting.rst
index ee546ad03..4abc25749 100644
--- a/docs/source/api_reference/forecasting.rst
+++ b/docs/source/api_reference/forecasting.rst
@@ -232,6 +232,7 @@ All "ARIMA" and "Auto-ARIMA" models below include SARIMAX capability.
     :template: class.rst
 
     ARIMA
+    StatsModelsARIMA
 
 .. currentmodule:: sktime.forecasting.sarimax
 
@@ -293,6 +294,14 @@ ARCH models
 Structural time series models
 -----------------------------
 
+.. currentmodule:: sktime.forecasting.ardl
+
+.. autosummary::
+    :toctree: auto_generated/
+    :template: class.rst
+
+    ARDL
+
 .. currentmodule:: sktime.forecasting.bats
 
 .. autosummary::
@@ -309,6 +318,14 @@ Structural time series models
 
     TBATS
 
+.. currentmodule:: sktime.forecasting.statsforecast
+
+.. autosummary::
+    :toctree: auto_generated/
+    :template: class.rst
+
+    StatsForecastAutoTBATS
+
 .. currentmodule:: sktime.forecasting.fbprophet
 
 .. autosummary::
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 40e617197..d59bdf156 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -4,7 +4,6 @@
 import datetime
 import os
 import sys
-from importlib import import_module
 
 import sktime
 
@@ -337,21 +336,16 @@ def _make_estimator_overview(app):
         return not input_string.startswith("_")
 
     # creates dataframe as df
-    COLNAMES = ["Class Name", "Estimator Type", "Authors"]
+    COLNAMES = ["Class Name", "Estimator Type", "Authors", "Maintainers"]
 
-    df = pd.DataFrame([], columns=COLNAMES)
+    records = []
 
     for modname, modclass in all_estimators():
-        algorithm_type = "::".join(str(modclass).split(".")[1:-2])
-        try:
-            author_info = _process_author_info(modclass.__author__)
-        except AttributeError:
-            try:
-                author_info = _process_author_info(
-                    import_module(modclass.__module__).__author__
-                )
-            except AttributeError:
-                author_info = "no author info"
+        algorithm_type = modclass.get_class_tag("object_type", "object")
+        author_tag = modclass.get_class_tag("authors", "sktime developers")
+        author_info = _process_author_info(author_tag)
+        maintainer_tag = modclass.get_class_tag("maintainers", "sktime developers")
+        maintainer_info = _process_author_info(maintainer_tag)
 
         # includes part of class string
         modpath = str(modclass)[8:-2]
@@ -368,8 +362,9 @@ def _make_estimator_overview(app):
             + "</a>"
         )
 
-        record = pd.DataFrame([modname, algorithm_type, author_info], index=COLNAMES).T
-        df = pd.concat([df, record], ignore_index=True)
+        records.append([modname, algorithm_type, author_info, maintainer_info])
+
+    df = pd.DataFrame(records, columns=COLNAMES)
     with open("estimator_overview_table.md", "w") as file:
         df.to_markdown(file, index=False)
 
diff --git a/docs/source/installation.rst b/docs/source/installation.rst
index f5340428f..5b7a14038 100644
--- a/docs/source/installation.rst
+++ b/docs/source/installation.rst
@@ -10,8 +10,6 @@ Installation
 
 See here for a `full list of precompiled wheels available on PyPI <https://pypi.org/simple/sktime/>`_.
 
-We appreciate community contributions towards compatibility with python 3.10, or other operating systems.
-
 .. contents::
    :local:
 
diff --git a/pyproject.toml b/pyproject.toml
index 05c50ca8b..c1b5c1683 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -200,7 +200,7 @@ regression = [
 transformations = [
   'esig<0.10,>=0.9.7; python_version < "3.11"',
   "filterpy<1.5,>=1.4.5",
-  "holidays>=0.29,<0.42",
+  "holidays>=0.29,<0.43",
   "mne>=1.5,<1.7",
   'numba<0.59,>=0.53; python_version < "3.12"',
   "pycatch22>=0.4,<0.4.4",
diff --git a/sktime/forecasting/arima/__init__.py b/sktime/forecasting/arima/__init__.py
new file mode 100644
index 000000000..e248ffb30
--- /dev/null
+++ b/sktime/forecasting/arima/__init__.py
@@ -0,0 +1,10 @@
+"""Time series forecasting with ARIMA models."""
+
+__all__ = [
+    "AutoARIMA",
+    "ARIMA",
+    "StatsModelsARIMA",
+]
+
+from sktime.forecasting.arima._pmdarima import ARIMA, AutoARIMA
+from sktime.forecasting.arima._statsmodels import StatsModelsARIMA
diff --git a/sktime/forecasting/arima.py b/sktime/forecasting/arima/_pmdarima.py
similarity index 99%
rename from sktime/forecasting/arima.py
rename to sktime/forecasting/arima/_pmdarima.py
index 67dd09054..9b88479a6 100644
--- a/sktime/forecasting/arima.py
+++ b/sktime/forecasting/arima/_pmdarima.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3 -u
 # copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
-"""Implements autoregressive integrated moving average (ARIMA) models."""
+"""Interface to ARIMA and AutoARIMA models from pmdarima package."""
 
 __author__ = ["mloning", "hyang1996", "fkiraly", "ilkersigirci"]
 __all__ = ["AutoARIMA", "ARIMA"]
@@ -9,7 +9,7 @@ from sktime.forecasting.base.adapters._pmdarima import _PmdArimaAdapter
 
 
 class AutoARIMA(_PmdArimaAdapter):
-    """Wrapper of the pmdarima implementation of fitting Auto-(S)ARIMA(X) models.
+    """Auto-(S)ARIMA(X) forecaster, from pmdarima package.
 
     Includes automated fitting of (S)ARIMA(X) hyper-parameters (p, d, q, P, D, Q).
 
@@ -248,6 +248,7 @@ class AutoARIMA(_PmdArimaAdapter):
     See Also
     --------
     ARIMA
+    StatsForecastAutoARIMA
 
     References
     ----------
@@ -487,7 +488,7 @@ class AutoARIMA(_PmdArimaAdapter):
 
 
 class ARIMA(_PmdArimaAdapter):
-    """Wrapper of the pmdarima implementation of fitting (S)ARIMA(X) models.
+    """(S)ARIMA(X) forecaster, from pmdarima package.
 
     Exposes `pmdarima.arima.ARIMA` [1]_ under the `sktime` interface.
     Seasonal ARIMA models and exogeneous input is supported, hence this estimator is
diff --git a/sktime/forecasting/arima/_statsmodels.py b/sktime/forecasting/arima/_statsmodels.py
new file mode 100644
index 000000000..4325604f8
--- /dev/null
+++ b/sktime/forecasting/arima/_statsmodels.py
@@ -0,0 +1,327 @@
+# !/usr/bin/env python3 -u
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+"""Interface to ARIMA from statsmodels package."""
+
+__all__ = ["StatsModelsARIMA"]
+__author__ = ["arnaujc91"]
+
+from typing import Iterable, Optional, Tuple, Union
+
+import numpy as np
+import pandas as pd
+
+from sktime.forecasting.base.adapters import _StatsModelsAdapter
+
+
+class StatsModelsARIMA(_StatsModelsAdapter):
+    """ARIMA forecaster, from statsmodels package.
+
+    Direct interface for `statsmodels.tsa.arima.model.ARIMA`.
+
+    Parameters
+    ----------
+    order : tuple, optional
+        The (p,d,q) order of the model for the autoregressive, differences, and
+        moving average components. d is always an integer, while p and q may
+        either be integers or lists of integers.
+    seasonal_order : tuple, optional
+        The (P,D,Q,s) order of the seasonal component of the model for the
+        AR parameters, differences, MA parameters, and periodicity. Default
+        is (0, 0, 0, 0). D and s are always integers, while P and Q
+        may either be integers or lists of positive integers.
+    trend : str{'n','c','t','ct'} or iterable, optional
+        Parameter controlling the deterministic trend. Can be specified as a
+        string where 'c' indicates a constant term, 't' indicates a
+        linear trend in time, and 'ct' includes both. Can also be specified as
+        an iterable defining a polynomial, as in `numpy.poly1d`, where
+        `[1,1,0,1]` would denote :math:`a + bt + ct^3`. Default is 'c' for
+        models without integration, and no trend for models with integration.
+        Note that all trend terms are included in the model as exogenous
+        regressors, which differs from how trends are included in ``SARIMAX``
+        models.  See the Notes section for a precise definition of the
+        treatment of trend terms.
+    enforce_stationarity : bool, optional
+        Whether or not to require the autoregressive parameters to correspond
+        to a stationarity process.
+    enforce_invertibility : bool, optional
+        Whether or not to require the moving average parameters to correspond
+        to an invertible process.
+    concentrate_scale : bool, optional
+        Whether or not to concentrate the scale (variance of the error term)
+        out of the likelihood. This reduces the number of parameters by one.
+        This is only applicable when considering estimation by numerical
+        maximum likelihood.
+    trend_offset : int, optional
+        The offset at which to start time trend values. Default is 1, so that
+        if `trend='t'` the trend is equal to 1, 2, ..., nobs. Typically is only
+        set when the model created by extending a previous dataset.
+    dates : array_like of datetime, optional
+        If no index is given by `endog` or `exog`, an array-like object of
+        datetime objects can be provided.
+    freq : str, optional
+        If no index is given by `endog` or `exog`, the frequency of the
+        time-series may be specified here as a Pandas offset or offset string.
+    missing : str
+        Available options are 'none', 'drop', and 'raise'. If 'none', no nan
+        checking is done. If 'drop', any observations with nans are dropped.
+        If 'raise', an error is raised. Default is 'none'.
+    start_params : array_like, optional
+        Initial guess of the solution for the loglikelihood maximization.
+        If None, the default is given by Model.start_params.
+    transformed : bool, optional
+        Whether or not `start_params` is already transformed. Default is
+        True.
+    includes_fixed : bool, optional
+        If parameters were previously fixed with the `fix_params` method,
+        this argument describes whether or not `start_params` also includes
+        the fixed parameters, in addition to the free parameters. Default
+        is False.
+    method : str, optional
+        The method used for estimating the parameters of the model. Valid
+        options include 'statespace', 'innovations_mle', 'hannan_rissanen',
+        'burg', 'innovations', and 'yule_walker'. Not all options are
+        available for every specification (for example 'yule_walker' can
+        only be used with AR(p) models).
+    method_kwargs : dict, optional
+        Arguments to pass to the fit function for the parameter estimator
+        described by the `method` argument.
+    gls : bool, optional
+        Whether or not to use generalized least squares (GLS) to estimate
+        regression effects. The default is False if `method='statespace'`
+        and is True otherwise.
+    gls_kwargs : dict, optional
+        Arguments to pass to the GLS estimation fit method. Only applicable
+        if GLS estimation is used (see `gls` argument for details).
+    cov_type : str, optional
+        The `cov_type` keyword governs the method for calculating the
+        covariance matrix of parameter estimates. Can be one of:
+
+        - 'opg' for the outer product of gradient estimator
+        - 'oim' for the observed information matrix estimator, calculated
+          using the method of Harvey (1989)
+        - 'approx' for the observed information matrix estimator,
+          calculated using a numerical approximation of the Hessian matrix.
+        - 'robust' for an approximate (quasi-maximum likelihood) covariance
+          matrix that may be valid even in the presence of some
+          misspecifications. Intermediate calculations use the 'oim'
+          method.
+        - 'robust_approx' is the same as 'robust' except that the
+          intermediate calculations use the 'approx' method.
+        - 'none' for no covariance matrix calculation.
+
+        Default is 'opg' unless memory conservation is used to avoid
+        computing the loglikelihood values for each observation, in which
+        case the default is 'oim'.
+    cov_kwds : dict or None, optional
+        A dictionary of arguments affecting covariance matrix computation.
+
+        **opg, oim, approx, robust, robust_approx**
+
+        - 'approx_complex_step' : bool, optional - If True, numerical
+          approximations are computed using complex-step methods. If False,
+          numerical approximations are computed using finite difference
+          methods. Default is True.
+        - 'approx_centered' : bool, optional - If True, numerical
+          approximations computed using finite difference methods use a
+          centered approximation. Default is False.
+    return_params : bool, optional
+        Whether or not to return only the array of maximizing parameters.
+        Default is False.
+    low_memory : bool, optional
+        If set to True, techniques are applied to substantially reduce
+        memory usage. If used, some features of the results object will
+        not be available (including smoothed results and in-sample
+        prediction), although out-of-sample forecasting is possible.
+        Default is False.
+
+    See Also
+    --------
+    ARIMA
+    SARIMAX
+    AutoARIMA
+    StatsForecastAutoARIMA
+
+    Examples
+    --------
+    >>> from sktime.datasets import load_airline
+    >>> from sktime.forecasting.arima import StatsModelsARIMA
+    >>> y = load_airline()
+    >>> forecaster = StatsModelsARIMA(order=(0, 0, 12))  # doctest: +SKIP
+    >>> forecaster.fit(y)  # doctest: +SKIP
+    >>> y_pred = forecaster.predict(fh=[1,2,3])  # doctest: +SKIP
+    """
+
+    _tags = {
+        # packaging info
+        # --------------
+        "authors": ["arnaujc91"],
+        "maintainers": ["arnaujc91"],
+        "ignores-exogeneous-X": False,
+        "capability:pred_int": True,
+        "capability:pred_int:insample": True,
+        "python_dependencies": ["statsmodels"],
+    }
+
+    def __init__(
+        self,
+        order: Tuple[int, int, int] = (0, 0, 0),
+        seasonal_order: Tuple[int, int, int, int] = (0, 0, 0, 0),
+        trend: Optional[Union[str, Iterable]] = None,
+        enforce_stationarity: bool = True,
+        enforce_invertibility: bool = True,
+        concentrate_scale: bool = False,
+        trend_offset: int = 1,
+        dates: Optional[np.ndarray] = None,
+        freq: Optional[str] = None,
+        missing: Optional[str] = None,
+        validate_specification: bool = True,
+        start_params: Optional[np.ndarray] = None,
+        transformed: bool = True,
+        includes_fixed: bool = False,
+        method: Optional[str] = None,
+        method_kwargs: Optional[dict] = None,
+        gls: bool = False,
+        gls_kwargs: Optional[dict] = None,
+        cov_type: str = "opg",
+        cov_kwds: Optional[dict] = None,
+        return_params: bool = False,
+        low_memory: bool = False,
+    ):
+        self.order = order
+        self.seasonal_order = seasonal_order
+        self.trend = trend
+        self.enforce_stationarity = enforce_stationarity
+        self.enforce_invertibility = enforce_invertibility
+        self.concentrate_scale = concentrate_scale
+        self.trend_offset = trend_offset
+        self.dates = dates
+        self.freq = freq
+        self.missing = missing
+        self.validate_specification = validate_specification
+
+        # Fit params
+        self.start_params = start_params
+        self.transformed = transformed
+        self.includes_fixed = includes_fixed
+        self.method = method
+        self.method_kwargs = method_kwargs
+        self.gls = gls
+        self.gls_kwargs = gls_kwargs
+        self.cov_type = cov_type
+        self.cov_kwds = cov_kwds
+        self.return_params = return_params
+        self.low_memory = low_memory
+
+        super().__init__()
+
+    def _fit_forecaster(self, y, X=None):
+        from statsmodels.tsa.arima.model import ARIMA as _ARIMA
+
+        self._forecaster = _ARIMA(
+            endog=y,
+            exog=X,
+            order=self.order,
+            seasonal_order=self.seasonal_order,
+            trend=self.trend,
+            enforce_stationarity=self.enforce_stationarity,
+            enforce_invertibility=self.enforce_invertibility,
+            concentrate_scale=self.concentrate_scale,
+            trend_offset=self.trend_offset,
+            dates=self.dates,
+            freq=self.freq,
+            missing=self.missing,
+            validate_specification=self.validate_specification,
+        )
+        self._fitted_forecaster = self._forecaster.fit(
+            start_params=self.start_params,
+            transformed=self.transformed,
+            includes_fixed=self.includes_fixed,
+            method=self.method,
+            method_kwargs=self.method_kwargs,
+            gls=self.gls,
+            gls_kwargs=self.gls_kwargs,
+            cov_type=self.cov_type,
+            cov_kwds=self.cov_kwds,
+            return_params=self.return_params,
+            low_memory=self.low_memory,
+        )
+
+    def summary(self):
+        """Get a summary of the fitted forecaster.
+
+        This is the same as the implementation in statsmodels:
+        https://www.statsmodels.org/dev/examples/notebooks/generated/statespace_structural_harvey_jaeger.html
+        """
+        return self._fitted_forecaster.summary()
+
+    @staticmethod
+    def _extract_conf_int(prediction_results, alpha) -> pd.DataFrame:
+        """Construct confidence interval at specified `alpha` for each timestep.
+
+        Parameters
+        ----------
+        prediction_results : PredictionResults
+            results class, as returned by ``self._fitted_forecaster.get_prediction``
+        alpha : float
+            one minus nominal coverage
+
+        Returns
+        -------
+        pd.DataFrame
+            confidence intervals at each timestep
+
+            The dataframe must have at least two columns ``lower`` and ``upper``, and
+            the row indices must be integers relative to ``self.cutoff``. Order of
+            columns do not matter, and row indices must be a superset of relative
+            integer horizon of ``fh``.
+        """
+        conf_int = prediction_results.conf_int(alpha=alpha)
+        conf_int.columns = ["lower", "upper"]
+
+        return conf_int
+
+    @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 : 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 [
+            {
+                "order": (0, 1, 2),
+                "trend": "n",
+                "enforce_stationarity": False,
+                "enforce_invertibility": False,
+                "concentrate_scale": True,
+                "method": "statespace",
+            },
+            {
+                "order": (1, 1, 2),
+                "trend": "t",
+                "enforce_stationarity": False,
+                "enforce_invertibility": False,
+                "method": "statespace",
+            },
+            {
+                "order": (0, 0, 1),
+                "trend": "ct",
+                "seasonal_order": (1, 0, 1, 2),
+                "cov_type": "opg",
+                "gls": True,
+                "method": "statespace",
+            },
+            {"cov_type": "robust", "gls": True, "method": "burg"},
+        ]
diff --git a/sktime/forecasting/base/_delegate.py b/sktime/forecasting/base/_delegate.py
index 1a9a9af3f..05d001aed 100644
--- a/sktime/forecasting/base/_delegate.py
+++ b/sktime/forecasting/base/_delegate.py
@@ -38,6 +38,57 @@ class _DelegatedForecaster(BaseForecaster):
     def _get_delegate(self):
         return getattr(self, self._delegate_name)
 
+    def _set_delegated_tags(self, delegate=None):
+        """Set delegated tags, only tags for boilerplate control.
+
+        Writes tags to self.
+        Can be used by descendant classes to set dependent tags.
+        Makes safe baseline assumptions about tags, which can be overwritten.
+
+        * data mtype tags are set to the most general value.
+          This is to ensure that conversion is left to the inner estimator.
+        * packaging tags such as "author" or "python_depedencies" are not cloned.
+        * other boilerplate tags are cloned.
+
+        Parameters
+        ----------
+        delegate : object, optional (default=None)
+            object to get tags from, if None, uses self._get_delegate()
+
+        Returns
+        -------
+        self : reference to self
+        """
+        from sktime.datatypes import ALL_TIME_SERIES_MTYPES
+
+        if delegate is None:
+            delegate = self._get_delegate()
+
+        TAGS_TO_DELEGATE = [
+            "requires-fh-in-fit",
+            "handles-missing-data",
+            "ignores-exogeneous-X",
+            "capability:insample",
+            "capability:pred_int",
+            "capability:pred_int:insample",
+            "handles-missing-data",
+            "requires-fh-in-fit",
+            "X-y-must-have-same-index",
+            "enforce_index_type",
+            "fit_is_empty",
+        ]
+
+        TAGS_TO_SET = {
+            "scitype:y": "both",
+            "y_inner_mtype": ALL_TIME_SERIES_MTYPES,
+            "X_inner_mtype": ALL_TIME_SERIES_MTYPES,
+        }
+
+        self.clone_tags(delegate, tag_names=TAGS_TO_DELEGATE)
+        self.set_tags(**TAGS_TO_SET)
+
+        return self
+
     def _fit(self, y, X, fh):
         """Fit forecaster to training data.
 
diff --git a/sktime/forecasting/base/adapters/_pytorch.py b/sktime/forecasting/base/adapters/_pytorch.py
index 29cab7668..76b1f7799 100644
--- a/sktime/forecasting/base/adapters/_pytorch.py
+++ b/sktime/forecasting/base/adapters/_pytorch.py
@@ -57,14 +57,7 @@ class BaseDeepNetworkPyTorch(BaseForecaster, ABC):
         """
         from sktime.forecasting.base import ForecastingHorizon
 
-        # save fh and y for prediction later
-        if fh.is_relative:
-            self._fh = fh
-        else:
-            fh = fh.to_relative(self.cutoff)
-            self._fh = fh
-
-        self._y = y
+        fh = fh.to_relative(self.cutoff)
 
         if type(fh) is ForecastingHorizon:
             self.network = self._build_network(fh._values[-1])
@@ -122,6 +115,7 @@ class BaseDeepNetworkPyTorch(BaseForecaster, ABC):
 
         if fh is None:
             fh = self._fh
+        fh = fh.to_relative(self.cutoff)
 
         if max(fh._values) > self.network.pred_len or min(fh._values) < 0:
             raise ValueError(
@@ -166,7 +160,7 @@ class BaseDeepNetworkPyTorch(BaseForecaster, ABC):
             dataset = PyTorchTrainDataset(
                 y=y,
                 seq_len=self.network.seq_len,
-                fh=self._fh._values[-1],
+                fh=self._fh.to_relative(self.cutoff)._values[-1],
             )
 
         return DataLoader(
diff --git a/sktime/forecasting/bats.py b/sktime/forecasting/bats.py
index 0f1413fdd..5ea5d68b3 100644
--- a/sktime/forecasting/bats.py
+++ b/sktime/forecasting/bats.py
@@ -77,6 +77,7 @@ class BATS(_TbatsAdapter):
     See Also
     --------
     TBATS
+    StatsForecastAutoTBATS
 
     References
     ----------
diff --git a/sktime/forecasting/compose/_fallback.py b/sktime/forecasting/compose/_fallback.py
index a6e75b6ff..e23f5f5be 100644
--- a/sktime/forecasting/compose/_fallback.py
+++ b/sktime/forecasting/compose/_fallback.py
@@ -13,11 +13,11 @@ __all__ = ["FallbackForecaster"]
 
 from sktime.base import _HeterogenousMetaEstimator
 from sktime.datatypes import ALL_TIME_SERIES_MTYPES
-from sktime.forecasting.base import BaseForecaster
+from sktime.forecasting.base._delegate import _DelegatedForecaster
 from sktime.utils.warnings import warn
 
 
-class FallbackForecaster(_HeterogenousMetaEstimator, BaseForecaster):
+class FallbackForecaster(_HeterogenousMetaEstimator, _DelegatedForecaster):
     """Forecaster that sequentially tries a list of forecasting models.
 
     Attempts to fit the provided forecasters in the order they are given. If a
@@ -33,7 +33,7 @@ class FallbackForecaster(_HeterogenousMetaEstimator, BaseForecaster):
         These are "blueprint" transformers resp forecasters,
         forecaster states do not change when `fit` is called
 
-    warn : bool, default=False
+    verbose : bool, default=False
         If True, raises warnings when a forecaster fails to fit or predict.
 
     Attributes
@@ -85,7 +85,6 @@ class FallbackForecaster(_HeterogenousMetaEstimator, BaseForecaster):
         "X_inner_mtype": ALL_TIME_SERIES_MTYPES,
         "fit_is_empty": False,
     }
-
     # for default get_params/set_params from _HeterogenousMetaEstimator
     # _steps_attr points to the attribute of self
     # which contains the heterogeneous set of estimators
@@ -111,6 +110,10 @@ class FallbackForecaster(_HeterogenousMetaEstimator, BaseForecaster):
         self.forecasters_ = self._check_estimators(forecasters, "forecasters")
 
         self._anytagis_then_set("requires-fh-in-fit", True, False, self._forecasters)
+        self._anytagis_then_set("capability:pred_int", False, True, self._forecasters)
+
+    def _get_delegate(self):
+        return self.current_forecaster_
 
     def _fit(self, y, X=None, fh=None):
         """Fit the forecasters in the given order until one succeeds.
diff --git a/sktime/forecasting/compose/_grouped.py b/sktime/forecasting/compose/_grouped.py
index ce88b4d49..c2c4e44a4 100644
--- a/sktime/forecasting/compose/_grouped.py
+++ b/sktime/forecasting/compose/_grouped.py
@@ -75,7 +75,7 @@ class ForecastByLevel(_DelegatedForecaster):
 
         super().__init__()
 
-        self.clone_tags(self.forecaster_)
+        self._set_delegated_tags(self.forecaster_)
         self.set_tags(**{"fit_is_empty": False})
 
         if groupby == "local":
diff --git a/sktime/forecasting/compose/_ignore_x.py b/sktime/forecasting/compose/_ignore_x.py
index 411abe970..6ff8e8bdb 100644
--- a/sktime/forecasting/compose/_ignore_x.py
+++ b/sktime/forecasting/compose/_ignore_x.py
@@ -3,7 +3,6 @@
 
 __author__ = ["fkiraly"]
 
-from sktime.datatypes import ALL_TIME_SERIES_MTYPES
 from sktime.forecasting.base._delegate import _DelegatedForecaster
 
 
@@ -35,9 +34,7 @@ class IgnoreX(_DelegatedForecaster):
     _delegate_name = "forecaster_"
 
     _tags = {
-        "authors": ["fkiraly", "tpvasconcelos"],
         "ignores-exogeneous-X": True,
-        "scitype:y": "both",
     }
 
     def __init__(self, forecaster, ignore_x=True):
@@ -48,21 +45,8 @@ class IgnoreX(_DelegatedForecaster):
 
         self.forecaster_ = forecaster.clone()
 
-        TAGS_TO_CLONE = [
-            "capability:insample",
-            "capability:pred_int",
-            "capability:pred_int:insample",
-            "handles-missing-data",
-            "requires-fh-in-fit",
-            "X-y-must-have-same-index",
-            "enforce_index_type",
-        ]
-
-        self.clone_tags(forecaster, TAGS_TO_CLONE)
-
-        # this ensures that we convert in the inner estimator, not in the multiplexer
-        self.set_tags(**{"y_inner_mtype": ALL_TIME_SERIES_MTYPES})
-        self.set_tags(**{"X_inner_mtype": ALL_TIME_SERIES_MTYPES})
+        self._set_delegated_tags(self.forecaster_)
+        self.set_tags(**{"ignores-exogeneous-X": True})
 
         if not ignore_x:
             self.set_tags(**{"ignores-exogeneous-X": ignore_x})
diff --git a/sktime/forecasting/compose/_multiplexer.py b/sktime/forecasting/compose/_multiplexer.py
index 95cae7411..4b48bcc2d 100644
--- a/sktime/forecasting/compose/_multiplexer.py
+++ b/sktime/forecasting/compose/_multiplexer.py
@@ -119,7 +119,7 @@ class MultiplexForecaster(_HeterogenousMetaEstimator, _DelegatedForecaster):
         )
         self._set_forecaster()
 
-        self.clone_tags(self.forecaster_)
+        self._set_delegated_tags()
         self.set_tags(**{"fit_is_empty": False})
         # this ensures that we convert in the inner estimator, not in the multiplexer
         self.set_tags(**{"y_inner_mtype": ALL_TIME_SERIES_MTYPES})
diff --git a/sktime/forecasting/compose/_pipeline.py b/sktime/forecasting/compose/_pipeline.py
index 6725c7539..7c73f2520 100644
--- a/sktime/forecasting/compose/_pipeline.py
+++ b/sktime/forecasting/compose/_pipeline.py
@@ -1730,17 +1730,8 @@ class Permute(_DelegatedForecaster, BaseForecaster, _HeterogenousMetaEstimator):
         self.steps_arg = steps_arg
 
         super().__init__()
-        tags_to_clone = [
-            "ignores-exogeneous-X",  # does estimator ignore the exogeneous X?
-            "capability:insample",
-            "capability:pred_int",  # can the estimator produce prediction intervals?
-            "capability:pred_int:insample",
-            "requires-fh-in-fit",  # is forecasting horizon already required in fit?
-            "enforce_index_type",  # index type that needs to be enforced in X/y
-            "fit_is_empty",
-        ]
 
-        self.clone_tags(self.estimator, tags_to_clone)
+        self._set_delegated_tags(estimator)
 
         self._set_permuted_estimator()
 
diff --git a/sktime/forecasting/model_selection/_tune.py b/sktime/forecasting/model_selection/_tune.py
index 84686d446..8c32e28ca 100644
--- a/sktime/forecasting/model_selection/_tune.py
+++ b/sktime/forecasting/model_selection/_tune.py
@@ -76,18 +76,9 @@ class BaseGridSearch(_DelegatedForecaster):
 
         super().__init__()
 
-        tags_to_clone = [
-            "requires-fh-in-fit",
-            "capability:pred_int",
-            "capability:pred_int:insample",
-            "capability:insample",
-            "ignores-exogeneous-X",
-            "handles-missing-data",
-            "y_inner_mtype",
-            "X_inner_mtype",
-            "X-y-must-have-same-index",
-            "enforce_index_type",
-        ]
+        self._set_delegated_tags(forecaster)
+
+        tags_to_clone = ["y_inner_mtype", "X_inner_mtype"]
         self.clone_tags(forecaster, tags_to_clone)
         self._extend_to_all_scitypes("y_inner_mtype")
         self._extend_to_all_scitypes("X_inner_mtype")
diff --git a/sktime/forecasting/statsforecast.py b/sktime/forecasting/statsforecast.py
index 8291ab923..237bbb5c2 100644
--- a/sktime/forecasting/statsforecast.py
+++ b/sktime/forecasting/statsforecast.py
@@ -7,6 +7,7 @@ __all__ = [
     "StatsForecastAutoARIMA",
     "StatsForecastAutoCES",
     "StatsForecastAutoETS",
+    "StatsForecastAutoTBATS",
     "StatsForecastAutoTheta",
     "StatsForecastMSTL",
 ]
@@ -356,7 +357,7 @@ class StatsForecastAutoTheta(_GeneralisedStatsForecastAdapter):
 
     References
     ----------
-    .. [1] https://nixtla.github.io/statsforecast/models.html#autotheta
+    .. [1] https://nixtlaverse.nixtla.io/statsforecast/src/core/models.html#autotheta
 
     See Also
     --------
@@ -459,7 +460,7 @@ class StatsForecastAutoETS(_GeneralisedStatsForecastAdapter):
 
     References
     ----------
-    .. [1] https://nixtla.github.io/statsforecast/models.html#autoets
+    .. [1] https://nixtlaverse.nixtla.io/statsforecast/src/core/models.html#autoets
     .. [2] https://github.com/robjhyndman/forecast
 
     See Also
@@ -556,7 +557,7 @@ class StatsForecastAutoCES(_GeneralisedStatsForecastAdapter):
 
     References
     ----------
-    .. [1] https://nixtla.github.io/statsforecast/models.html#autoces
+    .. [1] https://nixtlaverse.nixtla.io/statsforecast/src/core/models.html#autoces
     """
 
     _tags = {
@@ -617,6 +618,119 @@ class StatsForecastAutoCES(_GeneralisedStatsForecastAdapter):
         return params
 
 
+class StatsForecastAutoTBATS(_GeneralisedStatsForecastAdapter):
+    """StatsForecast TBATS model.
+
+    Direct interface to `statsforecast.models.AutoTBATS`,
+    from `statsforecast` [1]_ by Nixtla.
+
+    Automatically selects the best TBATS model from all feasible combinations of the
+    parameters `use_boxcox`, `use_trend`, `use_damped_trend`, and `use_arma_errors`.
+    Selection is made using the AIC.
+
+    Default value for `use_arma_errors` is `True` since this enables the evaluation of
+    models with and without ARMA errors.
+
+    Parameters
+    ----------
+    seasonal_periods : int or list of int.
+        Number of observations per unit of time. Ex: 24 Hourly data.
+    use_boxcox : bool (default=None)
+        Whether or not to use a Box-Cox transformation. By default tries both.
+    bc_lower_bound : float (default=0.0)
+        Lower bound for the Box-Cox transformation.
+    bc_upper_bound : float (default=1.5)
+        Upper bound for the Box-Cox transformation.
+    use_trend : bool (default=None)
+        Whether or not to use a trend component. By default tries both.
+    use_damped_trend : bool (default=None)
+        Whether or not to dampen the trend component. By default tries both.
+    use_arma_errors : bool (default=True)
+        Whether or not to use a ARMA errors.
+        Default is True and this evaluates both models.
+
+    See Also
+    --------
+    BATS
+    TBATS
+
+    References
+    ----------
+    .. [1] https://nixtlaverse.nixtla.io/statsforecast/src/core/models.html#autotbats
+    """
+
+    _tags = {
+        # packaging info
+        # --------------
+        # "authors": ["yarnabrina"],
+        # "maintainers": ["yarnabrina"],
+        # "python_dependencies": "statsforecast"
+        # inherited from _GeneralisedStatsForecastAdapter
+        # estimator type
+        # --------------
+        "ignores-exogeneous-X": True,
+        "capability:pred_int": True,
+        "capability:pred_int:insample": True,
+        "python_dependencies": ["statsforecast>=1.7.2"],
+    }
+
+    def __init__(
+        self,
+        seasonal_periods: Union[int, List[int]],
+        use_boxcox: Optional[bool] = None,
+        use_trend: Optional[bool] = None,
+        use_damped_trend: Optional[bool] = None,
+        use_arma_errors: bool = True,
+    ):
+        self.seasonal_periods = seasonal_periods
+        self.use_boxcox = use_boxcox
+        self.use_trend = use_trend
+        self.use_damped_trend = use_damped_trend
+        self.use_arma_errors = use_arma_errors
+
+        super().__init__()
+
+    def _get_statsforecast_class(self):
+        """Create underlying forecaster instance."""
+        from statsforecast.models import AutoTBATS
+
+        return AutoTBATS
+
+    def _get_statsforecast_params(self) -> dict:
+        return {
+            "seasonal_periods": self.seasonal_periods,
+            "use_boxcox": self.use_boxcox,
+            "use_trend": self.use_trend,
+            "use_damped_trend": self.use_damped_trend,
+            "use_arma_errors": self.use_arma_errors,
+        }
+
+    @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.
+
+        params = [{"seasonal_periods": 3}, {"seasonal_periods": [3, 12]}]
+
+        return params
+
+
 class StatsForecastMSTL(_GeneralisedStatsForecastAdapter):
     """StatsForecast Multiple Seasonal-Trend decomposition using LOESS model.
 
diff --git a/sktime/forecasting/stream/_update.py b/sktime/forecasting/stream/_update.py
index 10ab69fd9..23c6f55da 100644
--- a/sktime/forecasting/stream/_update.py
+++ b/sktime/forecasting/stream/_update.py
@@ -9,13 +9,6 @@ from sktime.datatypes import ALL_TIME_SERIES_MTYPES
 from sktime.datatypes._utilities import get_window
 from sktime.forecasting.base._delegate import _DelegatedForecaster
 
-# prepare tags to clone - exceptions are TAGS_TO_KEEP
-TAGS_TO_KEEP = ["fit_is_empty", "X_inner_mtype", "y_inner_mtype"]
-# fit must be executed to fit the wrapped estimator and remember the cutoff
-# mtype tags are set so X/y is passed through, conversions happen in wrapped estimator
-TAGS_TO_CLONE = _DelegatedForecaster().get_tags().keys()
-TAGS_TO_CLONE = list(set(TAGS_TO_CLONE).difference(TAGS_TO_KEEP))
-
 
 class UpdateRefitsEvery(_DelegatedForecaster):
     """Refits periodically when update is called.
@@ -69,7 +62,8 @@ class UpdateRefitsEvery(_DelegatedForecaster):
 
         super().__init__()
 
-        self.clone_tags(forecaster, TAGS_TO_CLONE)
+        self._set_delegated_tags(self.forecaster_)
+        self.set_tags(**{"fit_is_empty": False})
 
     def _fit(self, y, X, fh):
         """Fit forecaster to training data.
@@ -251,7 +245,8 @@ class UpdateEvery(_DelegatedForecaster):
 
         super().__init__()
 
-        self.clone_tags(forecaster, TAGS_TO_KEEP)
+        self._set_delegated_tags(self.forecaster_)
+        self.set_tags(**{"fit_is_empty": False})
 
     def _fit(self, y, X, fh):
         """Fit forecaster to training data.
@@ -415,7 +410,8 @@ class DontUpdate(_DelegatedForecaster):
 
         super().__init__()
 
-        self.clone_tags(forecaster, TAGS_TO_CLONE)
+        self._set_delegated_tags(self.forecaster_)
+        self.set_tags(**{"fit_is_empty": False})
 
     def _update(self, y, X=None, update_params=True):
         """Update time series to incremental training data.
diff --git a/sktime/forecasting/tbats.py b/sktime/forecasting/tbats.py
index a346bacd3..79fbaca85 100644
--- a/sktime/forecasting/tbats.py
+++ b/sktime/forecasting/tbats.py
@@ -79,6 +79,7 @@ class TBATS(_TbatsAdapter):
     See Also
     --------
     BATS
+    StatsForecastAutoTBATS
 
     References
     ----------
diff --git a/sktime/param_est/plugin/_forecaster.py b/sktime/param_est/plugin/_forecaster.py
index 143a30b12..fac00d660 100644
--- a/sktime/param_est/plugin/_forecaster.py
+++ b/sktime/param_est/plugin/_forecaster.py
@@ -128,10 +128,19 @@ class PluginParamsForecaster(_DelegatedForecaster):
         self.update_params = update_params
 
         super().__init__()
-        self.clone_tags(self.forecaster_)
+
+        self._set_delegated_tags(self.forecaster_)
+
+        # parameter estimators that are univariate do not broadcast,
+        # so broadcasting needs to be done by the composite (i.e., self)
+        if param_est.get_tags()["object_type"] == "param_est":
+            if not param_est.get_tags()["capability:multivariate"]:
+                self.set_tags(**{"scitype:y": "univariate"})
+
         self.set_tags(**{"fit_is_empty": False})
         # todo: only works for single series now
         #   think about how to deal with vectorization later
+        self.set_tags(**{"X_inner_mtype": ["pd.DataFrame", "pd.Series", "np.ndarray"]})
         self.set_tags(**{"y_inner_mtype": ["pd.DataFrame", "pd.Series", "np.ndarray"]})
 
     def _fit(self, y, X, fh):
diff --git a/sktime/performance_metrics/forecasting/_classes.py b/sktime/performance_metrics/forecasting/_classes.py
index b5ec908aa..9e4eec76d 100644
--- a/sktime/performance_metrics/forecasting/_classes.py
+++ b/sktime/performance_metrics/forecasting/_classes.py
@@ -1863,6 +1863,56 @@ class MeanAbsolutePercentageError(BaseForecastingErrorMetricFunc):
         self.symmetric = symmetric
         super().__init__(multioutput=multioutput, multilevel=multilevel)
 
+    def _evaluate_by_index(self, y_true, y_pred, **kwargs):
+        """Return the metric evaluated at each time point.
+
+        private _evaluate_by_index containing core logic, called from evaluate_by_index
+
+        Parameters
+        ----------
+        y_true : time series in sktime compatible pandas based data container format
+            Ground truth (correct) target values
+            y can be in one of the following formats:
+            Series scitype: pd.DataFrame
+            Panel scitype: pd.DataFrame with 2-level row MultiIndex
+            Hierarchical scitype: pd.DataFrame with 3 or more level row MultiIndex
+        y_pred :time series in sktime compatible data container format
+            Forecasted values to evaluate
+            must be of same format as y_true, same indices and columns if indexed
+
+        Returns
+        -------
+        loss : pd.Series or pd.DataFrame
+            Calculated metric, by time point (default=jackknife pseudo-values).
+            pd.Series if self.multioutput="uniform_average" or array-like
+                index is equal to index of y_true
+                entry at index i is metric at time i, averaged over variables
+            pd.DataFrame if self.multioutput="raw_values"
+                index and columns equal to those of y_true
+                i,j-th entry is metric at time i, at variable j
+        """
+        multioutput = self.multioutput
+        symmetric = self.symmetric
+
+        numer_values = (y_true - y_pred).abs()
+
+        if symmetric:
+            denom_values = (y_true.abs() + y_pred.abs()) / 2
+        else:
+            denom_values = y_true.abs()
+
+        raw_values = numer_values / denom_values
+
+        if isinstance(multioutput, str):
+            if multioutput == "raw_values":
+                return raw_values
+
+            if multioutput == "uniform_average":
+                return raw_values.mean(axis=1)
+
+        # else, we expect multioutput to be array-like
+        return raw_values.dot(multioutput)
+
     @classmethod
     def get_test_params(cls, parameter_set="default"):
         """Return testing parameter settings for the estimator.
diff --git a/sktime/pipeline/pipeline.py b/sktime/pipeline/pipeline.py
index 207e6bcb1..8f23b6cb0 100644
--- a/sktime/pipeline/pipeline.py
+++ b/sktime/pipeline/pipeline.py
@@ -598,10 +598,18 @@ class Pipeline(BaseEstimator):
     def _initiate_call(self, X, y, kwargs):
         if not self._assembled:
             self._assemble_steps()
-        for step in self.assembled_steps.values():
-            step.reset()
-        self.assembled_steps["X"].buffer = X
-        self.assembled_steps["y"].buffer = y
+        for key, step in self.assembled_steps.items():
+            # Empty the buffer of all steps except for the dummy
+            # steps X and y (input steps)
+            if key in ["X", "y"]:
+                step.reset(reset_buffer=False)
+            else:
+                step.reset()
+        # Overwrite the buffer of X and y if data are provided
+        if X is not None:
+            self.assembled_steps["X"].buffer = X
+        if y is not None:
+            self.assembled_steps["y"].buffer = y
         self.kwargs.update(kwargs)
 
     def _method_allowed(self, method):
diff --git a/sktime/pipeline/step.py b/sktime/pipeline/step.py
index e2c582860..01d2274f0 100644
--- a/sktime/pipeline/step.py
+++ b/sktime/pipeline/step.py
@@ -73,9 +73,10 @@ class Step:
         self.input_edges = input_edges
         self.params = params
 
-    def reset(self):
+    def reset(self, reset_buffer=True):
         """Reset the step."""
-        self.buffer = None
+        if reset_buffer:
+            self.buffer = None
         self.mode = ""
 
     def get_allowed_method(self):
diff --git a/sktime/transformations/panel/interpolate.py b/sktime/transformations/panel/interpolate.py
index 7493ec947..3805c26c8 100644
--- a/sktime/transformations/panel/interpolate.py
+++ b/sktime/transformations/panel/interpolate.py
@@ -100,5 +100,6 @@ class TSInterpolator(BaseTransformer):
             `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
             `create_test_instance` uses the first (or only) dictionary in `params`.
         """
-        params = {"length": 10}
-        return params
+        params1 = {"length": 10}
+        params2 = {"length": 5}
+        return [params1, params2]
diff --git a/sktime/utils/git_diff.py b/sktime/utils/git_diff.py
index a88a7e9ac..fb0b29a70 100644
--- a/sktime/utils/git_diff.py
+++ b/sktime/utils/git_diff.py
@@ -78,3 +78,83 @@ def is_class_changed(cls):
     """
     module_str = get_module_from_class(cls)
     return is_module_changed(module_str)
+
+
+def get_changed_lines(file_path, only_indented=True):
+    """Get changed or added lines from a file.
+
+    Compares the current branch to the origin-main branch.
+
+    Parameters
+    ----------
+    file_path : str
+        path to file to get changed lines from
+    only_indented : bool, default=True
+        if True, only indented lines are returned, otherwise all lines are returned;
+        more precisely, only changed/added lines starting with a space are returned
+
+    Returns
+    -------
+    list of str : changed or added lines on current branch
+    """
+    cmd = f"git diff remotes/origin/main -- {file_path}"
+
+    try:
+        # Run 'git diff' command to get the changes in the specified file
+        result = subprocess.check_output(cmd, shell=True, text=True)
+
+        # if only indented lines are requested, add space to start_chars
+        start_chars = "+"
+        if only_indented:
+            start_chars += " "
+
+        # Extract the changed or new lines and return as a list of strings
+        changed_lines = [
+            line.strip() for line in result.split("\n") if line.startswith(start_chars)
+        ]
+        # remove first character ('+') from each line
+        changed_lines = [line[1:] for line in changed_lines]
+
+        return changed_lines
+
+    except subprocess.CalledProcessError:
+        return []
+
+
+def get_packages_with_changed_specs():
+    """Get packages with changed or added specs.
+
+    Returns
+    -------
+    list of str : names of packages with changed or added specs
+    """
+    from packaging.requirements import Requirement
+
+    changed_lines = get_changed_lines("pyproject.toml")
+
+    packages = []
+    for line in changed_lines:
+        if line.find("'") > line.find('"') and line.find('"') != -1:
+            sep = '"'
+        elif line.find("'") == -1:
+            sep = '"'
+        else:
+            sep = "'"
+
+        splits = line.split(sep)
+        if len(splits) < 2:
+            continue
+
+        req = line.split(sep)[1]
+
+        # deal with ; python_version >= "3.7" in requirements
+        if ";" in req:
+            req = req.split(";")[0]
+
+        pkg = Requirement(req).name
+        packages.append(pkg)
+
+    # make unique
+    packages = list(set(packages))
+
+    return packages


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 3dda6b820..a08da3e6b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -20,7 +20,7 @@ jobs:
           output: " "
       - name: List changed files
         run: echo '${{ steps.file_changes.outputs.files}}'
-      - uses: pre-commit/action@v3.0.0
+      - uses: pre-commit/action@v3.0.1
         with:
           extra_args: --files ${{ steps.file_changes.outputs.files}}
       - name: Check for missing init files
diff --git a/sktime/forecasting/compose/tests/test_fallback.py b/sktime/forecasting/compose/tests/test_fallback.py
index 92e69f922..61c8c7bce 100644
--- a/sktime/forecasting/compose/tests/test_fallback.py
+++ b/sktime/forecasting/compose/tests/test_fallback.py
@@ -494,3 +494,38 @@ def test_fallbackforecaster_fails_many_simple():
     ]
     names_raised_expected = ["f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8"]
     assert names_raised_actual == names_raised_expected
+
+
+def test_fallbackforecaster_pred_int():
+    """Predict interval works bc all forecasters have them enabled, first forecaster
+    expected
+    """
+    y = make_forecasting_problem(random_state=42)
+    forecaster1 = NaiveForecaster("mean")
+    forecaster2 = NaiveForecaster("last")
+    forecaster = FallbackForecaster(
+        [("naive_mean", forecaster1), ("naive_last", forecaster2)]
+    )
+    fh = [1, 2, 3]
+    forecaster.fit(y, fh=fh)
+    pred_int_actual = forecaster.predict_interval()
+
+    forecaster1.fit(y, fh=fh)
+    pred_int_expected = forecaster1.predict_interval()
+    pd.testing.assert_frame_equal(pred_int_expected, pred_int_actual)
+
+
+def test_fallbackforecaster_pred_int_raises():
+    """Predict int raises because EnsembleForecaster does not have this capability"""
+    y = make_forecasting_problem(random_state=42)
+    forecaster1 = NaiveForecaster("mean")
+    forecaster2 = EnsembleForecaster(
+        [("naive_last", NaiveForecaster("last")), ("poly", PolynomialTrendForecaster())]
+    )
+    forecaster = FallbackForecaster(
+        [("naive_mean", forecaster1), ("ensemble", forecaster2)]
+    )
+    fh = [1, 2, 3]
+    forecaster.fit(y, fh=fh)
+    with pytest.raises(NotImplementedError):
+        forecaster.predict_interval()
diff --git a/sktime/pipeline/tests/regression_tests/test_pipeline_regression.py b/sktime/pipeline/tests/regression_tests/test_pipeline_regression.py
index e32b398d1..c555139ed 100644
--- a/sktime/pipeline/tests/regression_tests/test_pipeline_regression.py
+++ b/sktime/pipeline/tests/regression_tests/test_pipeline_regression.py
@@ -2,10 +2,11 @@ import numpy as np
 import pandas as pd
 import pytest
 from skbase.utils.dependencies import _check_soft_dependencies
+from sklearn.linear_model import Ridge
 
 from sktime.classification.dummy import DummyClassifier
-from sktime.datasets import load_arrow_head, load_longley
-from sktime.forecasting.compose import ForecastX
+from sktime.datasets import load_airline, load_arrow_head, load_longley
+from sktime.forecasting.compose import ForecastX, make_reduction
 from sktime.forecasting.naive import NaiveForecaster
 from sktime.forecasting.sarimax import SARIMAX
 from sktime.pipeline.pipeline import Pipeline
@@ -317,3 +318,20 @@ def test_forecasterX_regression():
     general_pipeline.fit(y=y, X=X, fh=[1, 2, 3])
     result_general = general_pipeline.predict(None, None)
     pd.testing.assert_series_equal(result, result_general)
+
+
+def test_lagged_y_prediction():
+    # regression test for issue 5830
+    y = load_airline()
+    y_train, y_test = temporal_train_test_split(y)
+
+    forecaster = make_reduction(Ridge(), window_length=12, strategy="recursive")
+
+    pipe = Pipeline()
+    pipe = pipe.add_step(Differencer(lags=[1, 3]), "differencer", edges={"X": "y"})
+    pipe = pipe.add_step(
+        forecaster, name="forecaster", edges={"X": "differencer", "y": "y"}
+    )
+    pipe.fit(y=y_train)
+    y_pred = pipe.predict(fh=y_test.index)
+    assert y_pred.shape == y_test.shape
diff --git a/sktime/tests/test_switch.py b/sktime/tests/test_switch.py
index 443be8569..742cc457d 100644
--- a/sktime/tests/test_switch.py
+++ b/sktime/tests/test_switch.py
@@ -16,26 +16,36 @@ def run_test_for_class(cls):
 
     1. whether all required soft dependencies are present.
        If not, does not run the test.
-       If yes, runs the test if and only if
-       at least one of conditions 2, 3 below are met.
+       If yes, behaviour depends on ONLY_CHANGED_MODULES setting:
+       if off (False), always runs the test (return True);
+       if on (True), runs test if and only if
+       at least one of conditions 2, 3, 4 below are met.
 
     2. Condition 2:
 
-      * if ONLY_CHANGED_MODULES setting is on, condition 2 is met if and only
-      if the module containing the class/func has changed according to is_class_changed
-      * if ONLY_CHANGED_MODULES if off, condition 2 is always met.
+      If the module containing the class/func has changed according to is_class_changed,
+      or one of the modules containing any parent classes in sktime,
+      then condition 2 is met.
 
     3. Condition 3:
 
-      If the object is an sktime BaseObject, and one of the test classes
+      If the object is an sktime ``BaseObject``, and one of the test classes
       covering the class have changed, then condition 3 is met.
 
+    4. Condition 4:
+
+      If the object is an sktime ``BaseObject``, and the package requirements
+      for any of its dependencies have changed in ``pyproject.toml``,
+      condition 4 is met.
+
     cls can also be a list of classes or functions,
-    in this case the test is run if and only if:
+    in this case the test is run if and only if both of the following are True:
 
     * all required soft dependencies are present
-    * if yes, if any of the estimators in the list should be tested by
-      criterion 2 or 3 above
+    * if ``ONLY_CHANGED_MODULES`` is True, additionally,
+      if any of the estimators in the list should be tested by
+      at least one of criteria 2-4 above.
+      If ``ONLY_CHANGED_MODULES`` is False, this condition is always True.
 
     Parameters
     ----------
@@ -51,9 +61,11 @@ def run_test_for_class(cls):
         cls = [cls]
 
     from sktime.tests.test_all_estimators import ONLY_CHANGED_MODULES
-    from sktime.utils.git_diff import is_class_changed
+    from sktime.utils.git_diff import get_packages_with_changed_specs, is_class_changed
     from sktime.utils.validation._dependencies import _check_estimator_deps
 
+    PACKAGE_REQ_CHANGED = get_packages_with_changed_specs()
+
     def _required_deps_present(obj):
         """Check if all required soft dependencies are present, return bool."""
         if hasattr(obj, "get_class_tag"):
@@ -81,24 +93,46 @@ def run_test_for_class(cls):
         test_classes = get_test_classes_for_obj(cls)
         return any(is_class_changed(x) for x in test_classes)
 
+    def _is_impacted_by_pyproject_change(cls):
+        """Check if the dep specifcations of cls have changed, return bool."""
+        from packaging.requirements import Requirement
+
+        if not isclass(cls) or not hasattr(cls, "get_class_tags"):
+            return False
+
+        cls_reqs = cls.get_class_tag("python_dependencies", [])
+        if cls_reqs is None:
+            cls_reqs = []
+        if not isinstance(cls_reqs, list):
+            cls_reqs = [cls_reqs]
+        package_deps = [Requirement(req).name for req in cls_reqs]
+
+        return any(x in PACKAGE_REQ_CHANGED for x in package_deps)
+
     # Condition 1:
     # if any of the required soft dependencies are not present, do not run the test
     if not all(_required_deps_present(x) for x in cls):
         return False
     # otherwise, continue
 
+    # if ONLY_CHANGED_MODULES is off: always True
+    # tests are always run if soft dependencies are present
+    if not ONLY_CHANGED_MODULES:
+        return True
+
     # Condition 2:
-    # if ONLY_CHANGED_MODULES is on, run the test if and only if
     # any of the modules containing any of the classes in the list have changed
-    if ONLY_CHANGED_MODULES:
-        cond2 = any(_is_class_changed_or_sktime_parents(x) for x in cls)
-    else:
-        cond2 = True
+    # or any of the modules containing any parent classes in sktime have changed
+    cond2 = any(_is_class_changed_or_sktime_parents(x) for x in cls)
 
     # Condition 3:
     # if the object is an sktime BaseObject, and one of the test classes
     # covering the class have changed, then run the test
     cond3 = any(_tests_covering_class_changed(x) for x in cls)
 
-    # run the test if and only if at least one of the conditions 2, 3 are met
-    return cond2 or cond3
+    # Condition 4:
+    # the package requirements for any dependency in pyproject.toml have changed
+    cond4 = any(_is_impacted_by_pyproject_change(x) for x in cls)
+
+    # run the test if and only if at least one of the conditions 2-4 are met
+    return cond2 or cond3 or cond4