Repository: sktime/sktime
Pull request #5662 (2023-12-25 15:03:36)

[ENH] intermediate base class for panel tasks - classification, regression
Separating refactor and cleanup of #5408 into a new PR.

This PR introduces an intemediate base class for panel tasks, including classification and regression.

The intermediate class contains code that is otherwise duplicated across the descendant classes `BaseClassifier` and `BaseRegressor`.

The multiplication has become especially apparent in adding multioutput functionality to classifiers and regressor.

Depends on https://github.com/sktime/sktime/pull/5408 which should be merged first.


Patch:
diff --git a/README.md b/README.md
index e9e621732..b7d2cb4b6 100644
--- a/README.md
+++ b/README.md
@@ -169,8 +169,8 @@ flexible choice of soft dependencies is unavailable via `conda`)
 ``` python
 from sktime.datasets import load_airline
 from sktime.forecasting.base import ForecastingHorizon
-from sktime.forecasting.model_selection import temporal_train_test_split
 from sktime.forecasting.theta import ThetaForecaster
+from sktime.split import temporal_train_test_split
 from sktime.performance_metrics.forecasting import mean_absolute_percentage_error
 
 y = load_airline()
diff --git a/docs/source/api_reference/transformations.rst b/docs/source/api_reference/transformations.rst
index 25513ffd2..0bbc48474 100644
--- a/docs/source/api_reference/transformations.rst
+++ b/docs/source/api_reference/transformations.rst
@@ -665,6 +665,19 @@ Bootstrap transformations
     SplitterBootstrapTransformer
     STLBootstrapTransformer
 
+Panel-to-Series transformers
+----------------------------
+
+These transformers create a single series from a panel.
+
+.. currentmodule:: sktime.transformations.merger
+
+.. autosummary::
+    :toctree: auto_generated/
+    :template: class.rst
+
+    Merger
+
 Outlier detection, changepoint detection
 ----------------------------------------
 
diff --git a/docs/source/get_started.rst b/docs/source/get_started.rst
index cce05ec13..5295cf594 100644
--- a/docs/source/get_started.rst
+++ b/docs/source/get_started.rst
@@ -75,9 +75,9 @@ Forecasting
 
     >>> from sktime.datasets import load_airline
     >>> from sktime.forecasting.base import ForecastingHorizon
-    >>> from sktime.forecasting.model_selection import temporal_train_test_split
     >>> from sktime.forecasting.theta import ThetaForecaster
     >>> from sktime.performance_metrics.forecasting import mean_absolute_percentage_error
+    >>> from sktime.split import temporal_train_test_split
 
     >>> y = load_airline()
     >>> y_train, y_test = temporal_train_test_split(y)
diff --git a/docs/source/user_guide/introduction.rst b/docs/source/user_guide/introduction.rst
index 672dca8a6..70f6c7fd8 100644
--- a/docs/source/user_guide/introduction.rst
+++ b/docs/source/user_guide/introduction.rst
@@ -31,8 +31,8 @@ can simply write:
     from sktime.datasets import load_airline
     from sktime.forecasting.compose import make_reduction
     from sklearn.ensemble import RandomForestRegressor
-    from sktime.forecasting.model_selection import temporal_train_test_split
     from sktime.performance_metrics.forecasting import MeanAbsolutePercentageError
+    from sktime.split import temporal_train_test_split
 
     y = load_airline()
     y_train, y_test = temporal_train_test_split(y)
diff --git a/sktime/base/__init__.py b/sktime/base/__init__.py
index 4a30db2d0..6cf431c83 100644
--- a/sktime/base/__init__.py
+++ b/sktime/base/__init__.py
@@ -6,10 +6,12 @@ __author__ = ["mloning", "RNKuhns", "fkiraly"]
 __all__ = [
     "BaseObject",
     "BaseEstimator",
+    "BasePanelMixin",
     "_HeterogenousMetaEstimator",
     "load",
 ]
 
 from sktime.base._base import BaseEstimator, BaseObject
+from sktime.base._base_panel import BasePanelMixin
 from sktime.base._meta import _HeterogenousMetaEstimator
 from sktime.base._serialize import load
diff --git a/sktime/base/_base.py b/sktime/base/_base.py
index 6ff54472b..677b35257 100644
--- a/sktime/base/_base.py
+++ b/sktime/base/_base.py
@@ -78,7 +78,15 @@ class BaseObject(_BaseObject):
     Extends skbase BaseObject with additional features.
     """
 
-    _config = {"warnings": "on"}
+    _config = {
+        "warnings": "on",
+        "backend:parallel": None,  # parallelization backend for broadcasting
+        #  {None, "dask", "loky", "multiprocessing", "threading"}
+        #  None: no parallelization
+        #  "loky", "multiprocessing" and "threading": uses `joblib` Parallel loops
+        #  "dask": uses `dask`, requires `dask` package in environment
+        "backend:parallel:params": None,  # params for parallelization backend,
+    }
 
     _config_doc = {
         "display": """
@@ -101,6 +109,36 @@ class BaseObject(_BaseObject):
             * "on" = will raise warnings from sktime
             * "off" = will not raise warnings from sktime
         """,
+        "backend:parallel": """
+        backend:parallel : str, optional, default="None"
+            backend to use for parallelization when broadcasting/vectorizing, one of
+
+            - "None": executes loop sequentally, simple list comprehension
+            - "loky", "multiprocessing" and "threading": uses ``joblib.Parallel``
+            - "joblib": custom and 3rd party ``joblib`` backends, e.g., ``spark``
+            - "dask": uses ``dask``, requires ``dask`` package in environment
+        """,
+        "backend:parallel:params": """
+        backend:parallel:params : dict, optional, default={} (no parameters passed)
+            additional parameters passed to the parallelization backend as config.
+            Valid keys depend on the value of ``backend:parallel``:
+
+            - "None": no additional parameters, ``backend_params`` is ignored
+            - "loky", "multiprocessing" and "threading": default ``joblib`` backends
+              any valid keys for ``joblib.Parallel`` can be passed here, e.g.,
+              ``n_jobs``, with the exception of ``backend`` which is directly
+              controlled by ``backend``.
+              If ``n_jobs`` is not passed, it will default to ``-1``, other parameters
+              will default to ``joblib`` defaults.
+            - "joblib": custom and 3rd party ``joblib`` backends,
+              e.g., ``spark``. Any valid keys for ``joblib.Parallel``
+              can be passed here, e.g., ``n_jobs``,
+              ``backend`` must be passed as a key of ``backend_params`` in this case.
+              If ``n_jobs`` is not passed, it will default to ``-1``, other parameters
+              will default to ``joblib`` defaults.
+            - "dask": any valid keys for ``dask.compute`` can be passed,
+              e.g., ``scheduler``
+        """,
     }
 
     def __init__(self):
diff --git a/sktime/base/_base_panel.py b/sktime/base/_base_panel.py
new file mode 100644
index 000000000..745b82e23
--- /dev/null
+++ b/sktime/base/_base_panel.py
@@ -0,0 +1,463 @@
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+"""Intermediate base class mixin with common functionality for panel tasks.
+
+Inherits from BaseEstimator, descendents are:
+
+BaseClassifier
+BaseRegressor
+"""
+
+__author__ = ["fkiraly"]
+__all__ = ["BasePanelMixin"]
+
+
+import numpy as np
+import pandas as pd
+
+from sktime.base import BaseEstimator
+from sktime.datatypes import (
+    MTYPE_LIST_PANEL,
+    MTYPE_LIST_TABLE,
+    VectorizedDF,
+    check_is_error_msg,
+    check_is_scitype,
+    convert,
+)
+from sktime.utils.warnings import warn
+
+
+class BasePanelMixin(BaseEstimator):
+    """Abstract base class for time series panel tasks, e.g., classifiers, regressors.
+
+    The class contains boilerplate for checks and data conversions,
+    which are common to multiple descendants, most importantly,
+    BaseClassifier and BaseRegressor.
+    """
+
+    # convenience constant to control which metadata of input data
+    # are regularly retrieved in input checks
+    METADATA_REQ_IN_CHECKS = [
+        "n_instances",
+        "has_nans",
+        "is_univariate",
+        "is_equal_length",
+    ]
+
+    # attribute name where vectorized estimators are stored
+    VECTORIZATION_ATTR = "estimators_"  # e.g., classifiers_, regressors_
+
+    # used in error messages
+    TASK = "panel data tasks"  # e.g., classification, regression
+    EST_TYPE = "estimator"  # e.g., classifier, regressor
+    EST_TYPE_PLURAL = "estimators"  # e.g., classifiers, regressors
+
+    def _vectorize(self, methodname, **kwargs):
+        """Vectorized/iterated loop over methods of BaseClassifier, BaseRegressor.
+
+        Stores one estimator per loop index.
+        """
+        y = kwargs.get("y")
+        X = kwargs.get("X")
+        if X is not None:
+            kwargs.pop("X")
+        if y is not None:
+            kwargs.pop("y")
+            self._y_vec = y
+        ests_ = self._y_vec.vectorize_est(self, method="clone")
+        if methodname == "fit":
+            ests_fit = self._y_vec.vectorize_est(
+                ests_,
+                method=methodname,
+                args={"y": y},
+                X=X,
+                rowname_default=self.EST_TYPE_PLURAL,
+                colname_default=self.EST_TYPE_PLURAL,
+                backend=self.get_config()["backend:parallel"],
+                backend_params=self.get_config()["backend:parallel:params"],
+            )
+            setattr(self, self.VECTORIZATION_ATTR, ests_fit)
+            return self
+        else:
+            ests_ = getattr(self, self.VECTORIZATION_ATTR)
+            y_pred = self._y_vec.vectorize_est(
+                ests_,
+                method=methodname,
+                # return_type="list",
+                X=X,
+                args={"y": y} if y is not None else {},
+                **kwargs,  # contains X inside
+                backend=self.get_config()["backend:parallel"],
+                backend_params=self.get_config()["backend:parallel:params"],
+            )
+            y_pred = pd.DataFrame(
+                {str(i): y_pred[col].values[0] for i, col in enumerate(y_pred.columns)}
+            )
+            return y_pred
+
+    def _fit_predict_boilerplate(self, X, y, cv, change_state, method):
+        """Boilerplate logic for fit_predict and fit_predict_proba."""
+        from sklearn.model_selection import KFold
+
+        if isinstance(cv, int):
+            random_state = getattr(self, "random_state", None)
+            cv = KFold(cv, random_state=random_state, shuffle=True)
+
+        if change_state:
+            self.reset()
+            est = self
+        else:
+            est = self.clone()
+
+        if cv is None:
+            return getattr(est.fit(X, y), method)(X)
+        elif change_state:
+            self.fit(X, y)
+
+        # we now know that cv is an sklearn splitter
+        X, y = self._internal_convert(X, y)
+        X_metadata = self._check_input(
+            X, y, return_metadata=self.METADATA_REQ_IN_CHECKS
+        )
+        X_mtype = X_metadata["mtype"]
+        # Check this estimator can handle characteristics
+        self._check_capabilities(X_metadata)
+
+        # handle single class case
+        if len(self._class_dictionary) == 1:
+            return self._single_class_y_pred(X)
+
+        # Convert data to format easily usable for applying cv
+        if isinstance(X, np.ndarray):
+            X = convert(
+                X,
+                from_type=X_mtype,
+                to_type="numpy3D",
+                as_scitype="Panel",
+                store_behaviour="freeze",
+            )
+        else:
+            X = convert(
+                X,
+                from_type=X_mtype,
+                to_type="nested_univ",
+                as_scitype="Panel",
+                store_behaviour="freeze",
+            )
+
+        if method == "predict_proba":
+            y_pred = np.empty([len(y), len(np.unique(y))])
+        else:
+            y_pred = np.empty_like(y)
+        y_pred[:] = -1
+        if isinstance(X, np.ndarray):
+            for tr_idx, tt_idx in cv.split(X):
+                X_train = X[tr_idx]
+                X_test = X[tt_idx]
+                y_train = y[tr_idx]
+                fitted_est = self.clone().fit(X_train, y_train)
+                y_pred[tt_idx] = getattr(fitted_est, method)(X_test)
+        else:
+            for tr_idx, tt_idx in cv.split(X):
+                X_train = X.iloc[tr_idx]
+                X_test = X.iloc[tt_idx]
+                y_train = y[tr_idx]
+                fitted_est = self.clone().fit(X_train, y_train)
+                y_pred[tt_idx] = getattr(fitted_est, method)(X_test)
+
+        return y_pred
+
+    def _check_convert_X_for_predict(self, X):
+        """Input checks, capability checks, repeated in all predict/score methods.
+
+        Parameters
+        ----------
+        X : any object (to check/convert)
+            should be of a supported Panel mtype or 2D numpy.ndarray
+
+        Returns
+        -------
+        X: an object of a supported Panel mtype, numpy3D if X was a 2D numpy.ndarray
+
+        Raises
+        ------
+        ValueError if X is of invalid input data type, or there is not enough data
+        ValueError if the capabilities in self._tags do not handle the data.
+        """
+        X = self._internal_convert(X)
+        X_metadata = self._check_input(X, return_metadata=self.METADATA_REQ_IN_CHECKS)
+        X_mtype = X_metadata["mtype"]
+        # Check that estimator can handle characteristics
+        self._check_capabilities(X_metadata)
+        # Convert data as dictated by the estimator tags
+        X = self._convert_X(X, X_mtype=X_mtype)
+
+        return X
+
+    def _check_capabilities(self, X_metadata):
+        """Check whether this estimator can handle the data characteristics.
+
+        Parameters
+        ----------
+        missing : boolean, does the data passed to fit contain missing values?
+        multivariate : boolean, does the data passed to fit contain missing values?
+        unequal : boolea, do the time series passed to fit have variable lengths?
+
+        Raises
+        ------
+        ValueError if the capabilities in self._tags do not handle the data.
+        """
+        missing = X_metadata["has_nans"]
+        multivariate = not X_metadata["is_univariate"]
+        unequal = not X_metadata["is_equal_length"]
+
+        allow_multivariate = self.get_tag("capability:multivariate")
+        allow_missing = self.get_tag("capability:missing_values")
+        allow_unequal = self.get_tag("capability:unequal_length")
+
+        self_name = type(self).__name__
+
+        # identify problems, mismatch of capability and inputs
+        problems = []
+        if missing and not allow_missing:
+            problems += ["missing values"]
+        if multivariate and not allow_multivariate:
+            problems += ["multivariate series"]
+        if unequal and not allow_unequal:
+            problems += ["unequal length series"]
+
+        # construct error message
+        problems_and = " and ".join(problems)
+        problems_or = " or ".join(problems)
+        msg = (
+            f"Data seen by {self_name} instance has {problems_and}, "
+            f"but this {self_name} instance cannot handle {problems_or}. "
+            f"Calls with {problems_or} may result in error or unreliable results."
+        )
+
+        # raise exception or warning with message
+        # if self is composite, raise a warning, since passing could be fine
+        #   see discussion in PR 2366 why
+        if len(problems) > 0:
+            if self.is_composite():
+                warn(msg, obj=self)
+            else:
+                raise ValueError(msg)
+
+    def _convert_X(self, X, X_mtype):
+        """Convert equal length series from DataFrame to numpy array or vice versa.
+
+        Parameters
+        ----------
+        X : input data for the estimator, any Panel mtype
+        X_mtype : str, a Panel mtype string, e.g., "pd_multiindex", "numpy3D"
+
+        Returns
+        -------
+        X : input X converted to type in "X_inner_mtype" tag
+            usually a pd.DataFrame (nested) or 3D np.ndarray
+            Checked and possibly converted input data
+        """
+        inner_type = self.get_tag("X_inner_mtype")
+        # convert pd.DataFrame
+        X = convert(
+            X,
+            from_type=X_mtype,
+            to_type=inner_type,
+            as_scitype="Panel",
+        )
+        return X
+
+    def _check_y(self, y=None):
+        """Check and coerce X/y for fit/transform functions.
+
+        Parameters
+        ----------
+        y : pd.DataFrame, pd.Series or np.ndarray
+
+        Returns
+        -------
+        y : object of sktime compatible time series type
+            can be Series, Panel, Hierarchical
+        y_metadata : dict
+            metadata of y, retured by check_is_scitype
+        """
+        if y is None:
+            return None
+
+        capa_multioutput = self.get_tag("capability:multioutput")
+        y_inner_mtype = self.get_tag("y_inner_mtype")
+
+        y_valid, y_msg, y_metadata = check_is_scitype(
+            y, "Table", return_metadata=["is_univariate"]
+        )
+
+        if not y_valid:
+            allowed_msg = (
+                f"In {self.TASK}, y must be of a supported type, "
+                f"for instance 1D or 2D numpy arrays, pd.DataFrame, or pd.Series. "
+                f"Allowed compatible mtype format specifications are:"
+                f" {MTYPE_LIST_TABLE} ."
+            )
+            check_is_error_msg(
+                y_msg, var_name="y", allowed_msg=allowed_msg, raise_exception=True
+            )
+
+        y_uni = y_metadata["is_univariate"]
+        y_mtype = y_metadata["mtype"]
+
+        requires_vectorization = not capa_multioutput and not y_uni
+
+        if requires_vectorization:
+            y_df = convert(
+                y,
+                from_type=y_mtype,
+                to_type="pd_DataFrame_Table",
+                as_scitype="Table",
+                store=self._converter_store_y,
+            )
+            y_vec = VectorizedDF([y_df], iterate_cols=True)
+            return y_vec, y_metadata
+
+        y_inner = convert(
+            y,
+            from_type=y_mtype,
+            to_type=y_inner_mtype,
+            as_scitype="Table",
+            store=self._converter_store_y,
+        )
+
+        return y_inner, y_metadata
+
+    def _convert_output_y(self, y):
+        """Convert output y to original format.
+
+        Parameters
+        ----------
+        y : np.ndarray or pd.DataFrame
+
+        Returns
+        -------
+        y : np.ndarray or pd.DataFrame
+        """
+        # for consistency with legacy behaviour:
+        # output is coerced to numpy1D in case of univariate output
+        if not self._y_metadata["is_univariate"]:
+            output_mtype = self._y_metadata["mtype"]
+            converter_store = self._converter_store_y
+        else:
+            output_mtype = "numpy1D"
+            converter_store = None
+
+        y = convert(
+            y,
+            from_type=self.get_tag("y_inner_mtype"),
+            to_type=output_mtype,
+            as_scitype="Table",
+            store=converter_store,
+            store_behaviour="freeze",
+        )
+        return y
+
+    def _check_input(self, X, y=None, enforce_min_instances=1, return_metadata=True):
+        """Check whether input X and y are valid formats with minimum data.
+
+        Raises a ValueError if the input is not valid.
+
+        Parameters
+        ----------
+        X : check whether conformant with any sktime Panel mtype specification
+        y : check whether a pd.Series or np.array
+        enforce_min_instances : int, optional (default=1)
+            check there are a minimum number of instances.
+        return_metadata : bool, str, or list of str
+            metadata fields to return with X_metadata, input to check_is_scitype
+
+        Returns
+        -------
+        metadata : dict with metadata for X returned by datatypes.check_is_scitype
+
+        Raises
+        ------
+        ValueError
+            If y or X is invalid input data type, or there is not enough data
+        """
+        # Check X is valid input type and recover the data characteristics
+        X_valid, msg, X_metadata = check_is_scitype(
+            X, scitype="Panel", return_metadata=return_metadata
+        )
+        # raise informative error message if X is in wrong format
+        allowed_msg = (
+            f"Allowed scitypes for {self.EST_TYPE_PLURAL} are Panel mtypes, "
+            f"for instance a pandas.DataFrame with MultiIndex and last(-1) "
+            f"level an sktime compatible time index. "
+            f"Allowed compatible mtype format specifications are: {MTYPE_LIST_PANEL} ."
+        )
+        if not X_valid:
+            check_is_error_msg(
+                msg, var_name="X", allowed_msg=allowed_msg, raise_exception=True
+            )
+
+        n_cases = X_metadata["n_instances"]
+        if n_cases < enforce_min_instances:
+            raise ValueError(
+                f"Minimum number of cases required is {enforce_min_instances} but X "
+                f"has : {n_cases}"
+            )
+
+        # Check y if passed
+        if y is not None:
+            # Check y valid input
+            if not isinstance(y, (pd.Series, pd.DataFrame, np.ndarray)):
+                raise ValueError(
+                    "y must be a np.array or a pd.Series or pd.DataFrame, but found ",
+                    f"type: {type(y)}",
+                )
+            # Check matching number of labels
+            n_labels = y.shape[0]
+            if n_cases != n_labels:
+                raise ValueError(
+                    f"Mismatch in number of cases. Number in X = {n_cases} nos in y = "
+                    f"{n_labels}"
+                )
+            if isinstance(y, np.ndarray):
+                if y.ndim > 2:
+                    raise ValueError(
+                        f"np.ndarray y must be 1-dimensional or 2-dimensional, "
+                        f"but found {y.ndim} dimensions"
+                    )
+            # warn if only a single class label is seen
+            # this should not raise exception since this can occur by train subsampling
+            if len(np.unique(y)) == 1:
+                warn(
+                    "only single label seen in y passed to "
+                    f"fit of {self.EST_TYPE} {type(self).__name__}",
+                    obj=self,
+                )
+
+        return X_metadata
+
+    def _internal_convert(self, X, y=None):
+        """Convert X and y if necessary as a user convenience.
+
+        Convert X to a 3D numpy array if already a 2D and convert y into an 1D numpy
+        array if passed as a Series.
+
+        Parameters
+        ----------
+        X : an object of a supported Panel mtype, or 2D numpy.ndarray
+        y : np.ndarray or pd.Series
+
+        Returns
+        -------
+        X: an object of a supported Panel mtype, numpy3D if X was a 2D numpy.ndarray
+        y: np.ndarray
+        """
+        if isinstance(X, np.ndarray):
+            # Temporary fix to insist on 3D numpy. For univariate problems,
+            # most panel estimators simply convert back to 2D. This squeezing should be
+            # done here, but touches a lot of files, so will get this to work first.
+            if X.ndim == 2:
+                X = X.reshape(X.shape[0], 1, X.shape[1])
+        if y is None:
+            return X
+        return X, y
diff --git a/sktime/classification/base.py b/sktime/classification/base.py
index 74728f65d..8f218969c 100644
--- a/sktime/classification/base.py
+++ b/sktime/classification/base.py
@@ -23,27 +23,17 @@ __all__ = [
 __author__ = ["mloning", "fkiraly", "TonyBagnall", "MatthewMiddlehurst"]
 
 import time
-from abc import ABC, abstractmethod
 
 import numpy as np
-import pandas as pd
-
-from sktime.base import BaseEstimator
-from sktime.datatypes import (
-    MTYPE_LIST_PANEL,
-    MTYPE_LIST_TABLE,
-    VectorizedDF,
-    check_is_error_msg,
-    check_is_scitype,
-    convert,
-)
+
+from sktime.base import BasePanelMixin
+from sktime.datatypes import VectorizedDF, check_is_scitype, convert
 from sktime.utils.sklearn import is_sklearn_transformer
 from sktime.utils.validation import check_n_jobs
 from sktime.utils.validation._dependencies import _check_estimator_deps
-from sktime.utils.warnings import warn
 
 
-class BaseClassifier(BaseEstimator, ABC):
+class BaseClassifier(BasePanelMixin):
     """Abstract base class for time series classifiers.
 
     The base classifier specifies the methods and method signatures that all
@@ -85,6 +75,14 @@ class BaseClassifier(BaseEstimator, ABC):
         "is_equal_length",
     ]
 
+    # attribute name where vectorized estimators are stored
+    VECTORIZATION_ATTR = "classifiers_"  # e.g., classifiers_, regressors_
+
+    # used in error messages
+    TASK = "classification"  # e.g., classification, regression
+    EST_TYPE = "classifier"  # e.g., classifier, regressor
+    EST_TYPE_PLURAL = "classifiers"  # e.g., classifiers, regressors
+
     def __init__(self):
         # reserved attributes written to in fit
         self.classes_ = []  # classes seen in y, unique labels
@@ -199,17 +197,14 @@ class BaseClassifier(BaseEstimator, ABC):
         # convenience conversions to allow user flexibility:
         # if X is 2D array, convert to 3D, if y is Series, convert to numpy
         X, y = self._internal_convert(X, y)
-        X_metadata = self._check_classifier_input(
+        X_metadata = self._check_input(
             X, y, return_metadata=self.METADATA_REQ_IN_CHECKS
         )
-        missing = X_metadata["has_nans"]
-        multivariate = not X_metadata["is_univariate"]
-        unequal = not X_metadata["is_equal_length"]
         X_mtype = X_metadata["mtype"]
         self._X_metadata = X_metadata
 
         # Check this classifier can handle characteristics
-        self._check_capabilities(missing, multivariate, unequal)
+        self._check_capabilities(X_metadata)
 
         # remember class labels
         self.classes_ = np.unique(y)
@@ -389,46 +384,6 @@ class BaseClassifier(BaseEstimator, ABC):
             X=X, y=y, cv=cv, change_state=change_state, method="predict"
         )
 
-    def _vectorize(self, methodname, **kwargs):
-        """Vectorized/iterated loop over method of BaseClassifier.
-
-        Uses classifiers_ attribute to store one classifier per loop index.
-        """
-        y = kwargs.get("y")
-        X = kwargs.get("X")
-        if X is not None:
-            kwargs.pop("X")
-        if y is not None:
-            kwargs.pop("y")
-            self._y_vec = y
-        classifiers_ = self._y_vec.vectorize_est(
-            self,
-            method="clone",
-        )
-        if methodname == "fit":
-            self.classifiers_ = self._y_vec.vectorize_est(
-                classifiers_,
-                method=methodname,
-                args={"y": y},
-                X=X,
-            )
-            return self
-        else:
-            if self.classifiers_ is not None:
-                classifiers_ = self.classifiers_
-            y_pred = self._y_vec.vectorize_est(
-                classifiers_,
-                method=methodname,
-                # return_type="list",
-                X=X,
-                args={"y": y} if y is not None else {},
-                **kwargs,  # contains X inside
-            )
-            y_pred = pd.DataFrame(
-                {str(i): y_pred[col].values[0] for i, col in enumerate(y_pred.columns)}
-            )
-            return y_pred
-
     def _fit_predict_boilerplate(self, X, y, cv, change_state, method):
         """Boilerplate logic for fit_predict and fit_predict_proba."""
         from sklearn.model_selection import KFold
@@ -450,15 +405,12 @@ class BaseClassifier(BaseEstimator, ABC):
 
         # we now know that cv is an sklearn splitter
         X, y = self._internal_convert(X, y)
-        X_metadata = self._check_classifier_input(
+        X_metadata = self._check_input(
             X, y, return_metadata=self.METADATA_REQ_IN_CHECKS
         )
-        missing = X_metadata["has_nans"]
-        multivariate = not X_metadata["is_univariate"]
-        unequal = not X_metadata["is_equal_length"]
         X_mtype = X_metadata["mtype"]
         # Check this classifier can handle characteristics
-        self._check_capabilities(missing, multivariate, unequal)
+        self._check_capabilities(X_metadata)
 
         # handle single class case
         if len(self._class_dictionary) == 1:
@@ -627,7 +579,6 @@ class BaseClassifier(BaseEstimator, ABC):
         """
         return super().get_test_params(parameter_set=parameter_set)
 
-    @abstractmethod
     def _fit(self, X, y):
         """Fit time series classifier to training data.
 
@@ -658,7 +609,7 @@ class BaseClassifier(BaseEstimator, ABC):
         -------
         self : Reference to self.
         """
-        ...
+        raise NotImplementedError("abstract method")
 
     def _predict(self, X) -> np.ndarray:
         """Predicts labels for sequences in X.
@@ -725,302 +676,3 @@ class BaseClassifier(BaseEstimator, ABC):
             dists[i, self._class_dictionary[preds[i]]] = 1
 
         return dists
-
-    def _check_convert_X_for_predict(self, X):
-        """Input checks, capability checks, repeated in all predict/score methods.
-
-        Parameters
-        ----------
-        X : any object (to check/convert)
-            should be of a supported Panel mtype or 2D numpy.ndarray
-
-        Returns
-        -------
-        X: an object of a supported Panel mtype, numpy3D if X was a 2D numpy.ndarray
-
-        Raises
-        ------
-        ValueError if X is of invalid input data type, or there is not enough data
-        ValueError if the capabilities in self._tags do not handle the data.
-        """
-        X = self._internal_convert(X)
-        X_metadata = self._check_classifier_input(
-            X, return_metadata=self.METADATA_REQ_IN_CHECKS
-        )
-        missing = X_metadata["has_nans"]
-        multivariate = not X_metadata["is_univariate"]
-        unequal = not X_metadata["is_equal_length"]
-        X_mtype = X_metadata["mtype"]
-        # Check this classifier can handle characteristics
-        self._check_capabilities(missing, multivariate, unequal)
-        # Convert data as dictated by the classifier tags
-        X = self._convert_X(X, X_mtype=X_mtype)
-
-        return X
-
-    def _check_capabilities(self, missing, multivariate, unequal):
-        """Check whether this classifier can handle the data characteristics.
-
-        Parameters
-        ----------
-        missing : boolean, does the data passed to fit contain missing values?
-        multivariate : boolean, does the data passed to fit contain missing values?
-        unequal : boolea, do the time series passed to fit have variable lengths?
-
-        Raises
-        ------
-        ValueError if the capabilities in self._tags do not handle the data.
-        """
-        allow_multivariate = self.get_tag("capability:multivariate")
-        allow_missing = self.get_tag("capability:missing_values")
-        allow_unequal = self.get_tag("capability:unequal_length")
-
-        self_name = type(self).__name__
-
-        # identify problems, mismatch of capability and inputs
-        problems = []
-        if missing and not allow_missing:
-            problems += ["missing values"]
-        if multivariate and not allow_multivariate:
-            problems += ["multivariate series"]
-        if unequal and not allow_unequal:
-            problems += ["unequal length series"]
-
-        # construct error message
-        problems_and = " and ".join(problems)
-        problems_or = " or ".join(problems)
-        msg = (
-            f"Data seen by {self_name} instance has {problems_and}, "
-            f"but this {self_name} instance cannot handle {problems_or}. "
-            f"Calls with {problems_or} may result in error or unreliable results."
-        )
-
-        # raise exception or warning with message
-        # if self is composite, raise a warning, since passing could be fine
-        #   see discussion in PR 2366 why
-        if len(problems) > 0:
-            if self.is_composite():
-                warn(msg, obj=self)
-            else:
-                raise ValueError(msg)
-
-    def _convert_X(self, X, X_mtype):
-        """Convert equal length series from DataFrame to numpy array or vice versa.
-
-        Parameters
-        ----------
-        X : input data for the classifier
-        X_mtype : str, a Panel mtype string, e.g., "pd_multiindex", "numpy3D"
-
-        Returns
-        -------
-        X : input X converted to type in "X_inner_mtype" tag
-            usually a pd.DataFrame (nested) or 3D np.ndarray
-            Checked and possibly converted input data
-        """
-        inner_type = self.get_tag("X_inner_mtype")
-        # convert pd.DataFrame
-        X = convert(
-            X,
-            from_type=X_mtype,
-            to_type=inner_type,
-            as_scitype="Panel",
-        )
-        return X
-
-    def _check_y(self, y=None):
-        """Check and coerce X/y for fit/transform functions.
-
-        Parameters
-        ----------
-        y : pd.DataFrame, pd.Series or np.ndarray
-
-        Returns
-        -------
-        y : object of sktime compatible time series type
-            can be Series, Panel, Hierarchical
-        y_metadata : dict
-            metadata of y, retured by check_is_scitype
-        """
-        if y is None:
-            return None
-
-        capa_multioutput = self.get_tag("capability:multioutput")
-        y_inner_mtype = self.get_tag("y_inner_mtype")
-
-        y_valid, y_msg, y_metadata = check_is_scitype(
-            y, "Table", return_metadata=["is_univariate"]
-        )
-
-        if not y_valid:
-            allowed_msg = (
-                f"In classification, y must be of a supported type, "
-                f"for instance 1D or 2D numpy arrays, pd.DataFrame, or pd.Series. "
-                f"Allowed compatible mtype format specifications are:"
-                f" {MTYPE_LIST_TABLE} ."
-            )
-            check_is_error_msg(
-                y_msg, var_name="y", allowed_msg=allowed_msg, raise_exception=True
-            )
-
-        y_uni = y_metadata["is_univariate"]
-        y_mtype = y_metadata["mtype"]
-
-        requires_vectorization = not capa_multioutput and not y_uni
-
-        if requires_vectorization:
-            y_df = convert(
-                y,
-                from_type=y_mtype,
-                to_type="pd_DataFrame_Table",
-                as_scitype="Table",
-                store=self._converter_store_y,
-            )
-            y_vec = VectorizedDF([y_df], iterate_cols=True)
-            return y_vec, y_metadata
-
-        y_inner = convert(
-            y,
-            from_type=y_mtype,
-            to_type=y_inner_mtype,
-            as_scitype="Table",
-            store=self._converter_store_y,
-        )
-
-        return y_inner, y_metadata
-
-    def _convert_output_y(self, y):
-        """Convert output y to original format.
-
-        Parameters
-        ----------
-        y : np.ndarray or pd.DataFrame
-
-        Returns
-        -------
-        y : np.ndarray or pd.DataFrame
-        """
-        # for consistency with legacy behaviour:
-        # output is coerced to numpy1D in case of univariate output
-        if not self._y_metadata["is_univariate"]:
-            output_mtype = self._y_metadata["mtype"]
-            converter_store = self._converter_store_y
-        else:
-            output_mtype = "numpy1D"
-            converter_store = None
-
-        y = convert(
-            y,
-            from_type=self.get_tag("y_inner_mtype"),
-            to_type=output_mtype,
-            as_scitype="Table",
-            store=converter_store,
-            store_behaviour="freeze",
-        )
-        return y
-
-    def _check_classifier_input(
-        self, X, y=None, enforce_min_instances=1, return_metadata=True
-    ):
-        """Check whether input X and y are valid formats with minimum data.
-
-        Raises a ValueError if the input is not valid.
-
-        Parameters
-        ----------
-        X : check whether conformant with any sktime Panel mtype specification
-        y : check whether a pd.Series or np.array
-        enforce_min_instances : int, optional (default=1)
-            check there are a minimum number of instances.
-        return_metadata : bool, str, or list of str
-            metadata fields to return with X_metadata, input to check_is_scitype
-
-        Returns
-        -------
-        metadata : dict with metadata for X returned by datatypes.check_is_scitype
-
-        Raises
-        ------
-        ValueError
-            If y or X is invalid input data type, or there is not enough data
-        """
-        # Check X is valid input type and recover the data characteristics
-        X_valid, msg, X_metadata = check_is_scitype(
-            X, scitype="Panel", return_metadata=return_metadata
-        )
-        # raise informative error message if X is in wrong format
-        allowed_msg = (
-            f"Allowed scitypes for classifiers are Panel mtypes, "
-            f"for instance a pandas.DataFrame with MultiIndex and last(-1) "
-            f"level an sktime compatible time index. "
-            f"Allowed compatible mtype format specifications are: {MTYPE_LIST_PANEL} ."
-        )
-        if not X_valid:
-            check_is_error_msg(
-                msg, var_name="X", allowed_msg=allowed_msg, raise_exception=True
-            )
-
-        n_cases = X_metadata["n_instances"]
-        if n_cases < enforce_min_instances:
-            raise ValueError(
-                f"Minimum number of cases required is {enforce_min_instances} but X "
-                f"has : {n_cases}"
-            )
-
-        # Check y if passed
-        if y is not None:
-            # Check y valid input
-            if not isinstance(y, (pd.Series, pd.DataFrame, np.ndarray)):
-                raise ValueError(
-                    "y must be a np.array or a pd.Series or pd.DataFrame, but found ",
-                    f"type: {type(y)}",
-                )
-            # Check matching number of labels
-            n_labels = y.shape[0]
-            if n_cases != n_labels:
-                raise ValueError(
-                    f"Mismatch in number of cases. Number in X = {n_cases} nos in y = "
-                    f"{n_labels}"
-                )
-            if isinstance(y, np.ndarray):
-                if y.ndim > 2:
-                    raise ValueError(
-                        f"np.ndarray y must be 1-dimensional or 2-dimensional, "
-                        f"but found {y.ndim} dimensions"
-                    )
-            # warn if only a single class label is seen
-            # this should not raise exception since this can occur by train subsampling
-            if len(np.unique(y)) == 1:
-                warn(
-                    "only single class label seen in y passed to "
-                    f"fit of classifier {type(self).__name__}",
-                    obj=self,
-                )
-
-        return X_metadata
-
-    def _internal_convert(self, X, y=None):
-        """Convert X and y if necessary as a user convenience.
-
-        Convert X to a 3D numpy array if already a 2D and convert y into an 1D numpy
-        array if passed as a Series.
-
-        Parameters
-        ----------
-        X : an object of a supported Panel mtype, or 2D numpy.ndarray
-        y : np.ndarray or pd.Series
-
-        Returns
-        -------
-        X: an object of a supported Panel mtype, numpy3D if X was a 2D numpy.ndarray
-        y: np.ndarray
-        """
-        if isinstance(X, np.ndarray):
-            # Temporary fix to insist on 3D numpy. For univariate problems,
-            # most classifiers simply convert back to 2D. This squeezing should be
-            # done here, but touches a lot of files, so will get this to work first.
-            if X.ndim == 2:
-                X = X.reshape(X.shape[0], 1, X.shape[1])
-        if y is None:
-            return X
-        return X, y
diff --git a/sktime/classification/early_classification/_probability_threshold.py b/sktime/classification/early_classification/_probability_threshold.py
index 0464bfd3e..4697bf9de 100644
--- a/sktime/classification/early_classification/_probability_threshold.py
+++ b/sktime/classification/early_classification/_probability_threshold.py
@@ -20,7 +20,7 @@ from sktime.classification.interval_based import CanonicalIntervalForest
 from sktime.utils.validation.panel import check_X
 
 
-# TODO: fix this in 0.25.0
+# TODO: fix this in 0.26.0
 # base class should have been changed to BaseEarlyClassifier
 class ProbabilityThresholdEarlyClassifier(BaseClassifier):
     """Probability Threshold Early Classifier.
diff --git a/sktime/classification/early_classification/base.py b/sktime/classification/early_classification/base.py
index 3e6642569..f0702c5af 100644
--- a/sktime/classification/early_classification/base.py
+++ b/sktime/classification/early_classification/base.py
@@ -73,6 +73,14 @@ class BaseEarlyClassifier(BaseEstimator, ABC):
         "is_equal_length",
     ]
 
+    # attribute name where vectorized estimators are stored
+    VECTORIZATION_ATTR = "classifiers_"  # e.g., classifiers_, regressors_
+
+    # used in error messages
+    TASK = "early classification"  # e.g., classification, regression
+    EST_TYPE = "early classifier"  # e.g., classifier, regressor
+    EST_TYPE_PLURAL = "early classifiers"  # e.g., classifiers, regressors
+
     def __init__(self):
         self.classes_ = []
         self.n_classes_ = 0
@@ -591,21 +599,19 @@ class BaseEarlyClassifier(BaseEstimator, ABC):
         _check_convert_X_for_predict = BaseClassifier._check_convert_X_for_predict
         return _check_convert_X_for_predict(self, X)
 
-    def _check_capabilities(self, missing, multivariate, unequal):
+    def _check_capabilities(self, X_metadata):
         """Check whether this classifier can handle the data characteristics.
 
         Parameters
         ----------
-        missing : boolean, does the data passed to fit contain missing values?
-        multivariate : boolean, does the data passed to fit contain missing values?
-        unequal : boolea, do the time series passed to fit have variable lengths?
+        X_metadata : dict with metadata for X returned by datatypes.check_is_scitype
 
         Raises
         ------
         ValueError if the capabilities in self._tags do not handle the data.
         """
         _check_capabilities = BaseClassifier._check_capabilities
-        return _check_capabilities(self, missing, multivariate, unequal)
+        return _check_capabilities(self, X_metadata)
 
     def _convert_X(self, X, X_mtype):
         """Convert equal length series from DataFrame to numpy array or vice versa.
@@ -639,9 +645,7 @@ class BaseEarlyClassifier(BaseEstimator, ABC):
         _check_y = BaseClassifier._check_y
         return _check_y(self, y)
 
-    def _check_classifier_input(
-        self, X, y=None, enforce_min_instances=1, return_metadata=True
-    ):
+    def _check_input(self, X, y=None, enforce_min_instances=1, return_metadata=True):
         """Check whether input X and y are valid formats with minimum data.
 
         Raises a ValueError if the input is not valid.
@@ -664,10 +668,8 @@ class BaseEarlyClassifier(BaseEstimator, ABC):
         ValueError
             If y or X is invalid input data type, or there is not enough data
         """
-        _check_classifier_input = BaseClassifier._check_classifier_input
-        return _check_classifier_input(
-            self, X, y, enforce_min_instances, return_metadata
-        )
+        _check_input = BaseClassifier._check_input
+        return _check_input(self, X, y, enforce_min_instances, return_metadata)
 
     def _internal_convert(self, X, y=None):
         """Convert X and y if necessary as a user convenience.
diff --git a/sktime/forecasting/all/__init__.py b/sktime/forecasting/all/__init__.py
index 2d72d78b8..7ac4e1a8e 100644
--- a/sktime/forecasting/all/__init__.py
+++ b/sktime/forecasting/all/__init__.py
@@ -11,13 +11,6 @@ import pandas as pd
 from sktime.datasets import load_airline, load_longley, load_lynx, load_shampoo_sales
 from sktime.forecasting.base import ForecastingHorizon
 from sktime.forecasting.model_evaluation import evaluate
-from sktime.forecasting.model_selection import (
-    CutoffSplitter,
-    ExpandingWindowSplitter,
-    SingleWindowSplitter,
-    SlidingWindowSplitter,
-    temporal_train_test_split,
-)
 from sktime.performance_metrics.forecasting import (
     GeometricMeanRelativeAbsoluteError,
     GeometricMeanRelativeSquaredError,
@@ -58,6 +51,13 @@ from sktime.performance_metrics.forecasting import (
     relative_loss,
 )
 from sktime.registry import all_estimators
+from sktime.split import (
+    CutoffSplitter,
+    ExpandingWindowSplitter,
+    SingleWindowSplitter,
+    SlidingWindowSplitter,
+    temporal_train_test_split,
+)
 from sktime.transformations.series.detrend import Deseasonalizer, Detrender
 from sktime.utils.plotting import plot_series
 
diff --git a/sktime/forecasting/base/_base.py b/sktime/forecasting/base/_base.py
index ed0d26ff9..f22c39e6b 100644
--- a/sktime/forecasting/base/_base.py
+++ b/sktime/forecasting/base/_base.py
@@ -100,48 +100,17 @@ class BaseForecaster(BaseEstimator):
     }
 
     # configs and default config values
+    # see set_config documentation for details
     _config = {
         "backend:parallel": None,  # parallelization backend for broadcasting
         #  {None, "dask", "loky", "multiprocessing", "threading"}
         #  None: no parallelization
         #  "loky", "multiprocessing" and "threading": uses `joblib` Parallel loops
+        #  "joblib": uses custom joblib backend, set via `joblib_backend` tag
         #  "dask": uses `dask`, requires `dask` package in environment
         "backend:parallel:params": None,  # params for parallelization backend
     }
 
-    _config_doc = {
-        "backend:parallel": """
-        backend:parallel : str, optional, default="None"
-            backend to use for parallelization when broadcasting/vectorizing, one of
-
-            - "None": executes loop sequentally, simple list comprehension
-            - "loky", "multiprocessing" and "threading": uses ``joblib.Parallel``
-            - "joblib": custom and 3rd party ``joblib`` backends, e.g., ``spark``
-            - "dask": uses ``dask``, requires ``dask`` package in environment
-        """,
-        "backend:parallel:params": """
-        backend:parallel:params : dict, optional, default={} (no parameters passed)
-            additional parameters passed to the parallelization backend as config.
-            Valid keys depend on the value of ``backend:parallel``:
-
-            - "None": no additional parameters, ``backend_params`` is ignored
-            - "loky", "multiprocessing" and "threading": default ``joblib`` backends
-              any valid keys for ``joblib.Parallel`` can be passed here, e.g.,
-              ``n_jobs``, with the exception of ``backend`` which is directly
-              controlled by ``backend``.
-              If ``n_jobs`` is not passed, it will default to ``-1``, other parameters
-              will default to ``joblib`` defaults.
-            - "joblib": custom and 3rd party ``joblib`` backends,
-              e.g., ``spark``. Any valid keys for ``joblib.Parallel``
-              can be passed here, e.g., ``n_jobs``,
-            ``backend`` must be passed as a key of ``backend_params`` in this case.
-              If ``n_jobs`` is not passed, it will default to ``-1``, other parameters
-              will default to ``joblib`` defaults.
-            - "dask": any valid keys for ``dask.compute`` can be passed,
-              e.g., ``scheduler``
-        """,
-    }
-
     def __init__(self):
         self._is_fitted = False
 
diff --git a/sktime/forecasting/base/_fh.py b/sktime/forecasting/base/_fh.py
index ff5341c96..7a71c6e9a 100644
--- a/sktime/forecasting/base/_fh.py
+++ b/sktime/forecasting/base/_fh.py
@@ -806,7 +806,7 @@ def _to_relative(fh: ForecastingHorizon, cutoff=None) -> ForecastingHorizon:
             absolute = _coerce_to_period(absolute, freq=fh.freq)
             cutoff = _coerce_to_period(cutoff, freq=fh.freq)
 
-        # TODO: 0.25.0:
+        # TODO: 0.26.0:
         # Check at every minor release whether lower pandas bound >=0.15.0
         # if yes, can remove the workaround in the "else" condition and the check
         #
diff --git a/sktime/forecasting/compose/_pipeline.py b/sktime/forecasting/compose/_pipeline.py
index b802dfa38..cae9a4c1e 100644
--- a/sktime/forecasting/compose/_pipeline.py
+++ b/sktime/forecasting/compose/_pipeline.py
@@ -170,7 +170,7 @@ class _Pipeline(_HeterogenousMetaEstimator, BaseForecaster):
                         if len(levels) == 1:
                             levels = levels[0]
                         yt[ix] = y.xs(ix, level=levels, axis=1)
-                        # todo 0.25.0 - check why this cannot be easily removed
+                        # todo 0.26.0 - check why this cannot be easily removed
                         # in theory, we should get rid of the "Coverage" case treatment
                         # (the legacy naming convention was removed in 0.23.0)
                         # deal with the "Coverage" case, we need to get rid of this
diff --git a/sktime/forecasting/model_evaluation/_functions.py b/sktime/forecasting/model_evaluation/_functions.py
index f2082a86d..a0a62c01c 100644
--- a/sktime/forecasting/model_evaluation/_functions.py
+++ b/sktime/forecasting/model_evaluation/_functions.py
@@ -318,8 +318,6 @@ def _evaluate_window(x, meta):
         return result
 
 
-# todo 0.25.0: remove compute argument and docstring
-# todo 0.25.0: remove kwargs and docstring
 def evaluate(
     forecaster,
     cv,
@@ -330,7 +328,6 @@ def evaluate(
     return_data: bool = False,
     error_score: Union[str, int, float] = np.nan,
     backend: Optional[str] = None,
-    compute: bool = None,
     cv_X=None,
     backend_params: Optional[dict] = None,
     **kwargs,
@@ -422,9 +419,6 @@ def evaluate(
         "threading" is unlikely to see speed ups due to the GIL and the serialization
         backend (``cloudpickle``) for "dask" and "loky" is generally more robust
         than the standard ``pickle`` library used in "multiprocessing".
-    compute : bool, default=True, deprecated and will be removed in 0.25.0.
-        If backend="dask", whether returned DataFrame is computed.
-        If set to True, returns `pd.DataFrame`, otherwise `dask.dataframe.DataFrame`.
     cv_X : sktime BaseSplitter descendant, optional
         determines split of ``X`` into test and train folds
         default is ``X`` being split to identical ``loc`` indices as ``y``
@@ -525,10 +519,10 @@ def evaluate(
                 "installed, but dask is not present in the python environment"
             )
 
-    # todo 0.25.0: remove kwargs and this warning
+    # todo 0.26.0: remove kwargs and this warning
     if kwargs != {}:
         warnings.warn(
-            "in evaluate, kwargs will no longer be supported from sktime 0.25.0. "
+            "in evaluate, kwargs are no longer supported. "
             "to pass configuration arguments to the parallelization backend, "
             "use backend_params instead. "
             f"The following kwargs were found: {kwargs.keys()}, pass these as "
@@ -537,28 +531,8 @@ def evaluate(
             stacklevel=2,
         )
 
-    # todo 0.25.0: remove compute argument and logic, and remove this warning
-    if compute is not None:
-        warnings.warn(
-            "the compute argument of evaluate is deprecated and will be removed "
-            "in sktime 0.25.0. For the same behaviour in the future, "
-            'use backend="dask_lazy"',
-            DeprecationWarning,
-            stacklevel=2,
-        )
-    if compute is None:
-        compute = True
-    if backend == "dask" and not compute:
-        backend = "dask_lazy"
-
     _check_strategy(strategy)
     cv = check_cv(cv, enforce_start_with_window=True)
-    # TODO: remove lines(four lines below) and 599-612 in v0.25.0
-    if isinstance(scoring, list):
-        raise_warn, num = True, len(scoring)
-    else:
-        raise_warn, num = False, 1
-    # removal until here
     scoring = _check_scores(scoring)
 
     ALLOWED_SCITYPES = ["Series", "Panel", "Hierarchical"]
@@ -669,21 +643,4 @@ def evaluate(
     # final formatting of results DataFrame
     results = results.reset_index(drop=True)
 
-    # TODO: remove 16 lines below and 451-455 in v0.25.0
-    if raise_warn:
-        warnings.warn(
-            "Starting v0.25.0 model_evaluation.evaluate module will rearrange "
-            "all metric columns to the left of its output result DataFrame. "
-            "Please use loc references when addressing the columns. You can "
-            "safely ignore this warning if you don't use evaluate function directly.",
-            DeprecationWarning,
-            stacklevel=2,
-        )
-        columns = results.columns.to_list()
-        non_first_metrics = []
-        for _ in range(1, num):
-            metric = columns.pop(1)
-            non_first_metrics.append(metric)
-        results = results.reindex(columns=columns + non_first_metrics)
-    #  removal until here
     return results
diff --git a/sktime/forecasting/model_selection/__init__.py b/sktime/forecasting/model_selection/__init__.py
index c55f73960..311a2157e 100644
--- a/sktime/forecasting/model_selection/__init__.py
+++ b/sktime/forecasting/model_selection/__init__.py
@@ -4,29 +4,11 @@
 
 __author__ = ["mloning", "kkoralturk"]
 __all__ = [
-    "CutoffSplitter",
-    "SameLocSplitter",
-    "SingleWindowSplitter",
-    "SlidingWindowSplitter",
-    "temporal_train_test_split",
-    "ExpandingGreedySplitter",
-    "ExpandingWindowSplitter",
-    "TestPlusTrainSplitter",
     "ForecastingGridSearchCV",
     "ForecastingRandomizedSearchCV",
     "ForecastingSkoptSearchCV",
 ]
 
-from sktime.forecasting.model_selection._split import (
-    CutoffSplitter,
-    ExpandingGreedySplitter,
-    ExpandingWindowSplitter,
-    SameLocSplitter,
-    SingleWindowSplitter,
-    SlidingWindowSplitter,
-    TestPlusTrainSplitter,
-    temporal_train_test_split,
-)
 from sktime.forecasting.model_selection._tune import (
     ForecastingGridSearchCV,
     ForecastingRandomizedSearchCV,
diff --git a/sktime/forecasting/model_selection/_split.py b/sktime/forecasting/model_selection/_split.py
deleted file mode 100644
index 3d77d9d77..000000000
--- a/sktime/forecasting/model_selection/_split.py
+++ /dev/null
@@ -1,42 +0,0 @@
-#!/usr/bin/env python3 -u
-# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
-"""Implement dataset splitting for model evaluation and selection."""
-
-# This module is deprecated. Below the deprecation cycle :
-# - Until the 0.25.0 release, imports are allowed from this location but raise a
-# DeprecationWarning.
-# - In the 0.25.0 release, this file (sktime/forecasting/model_selection/_split.py)
-# will be removed.
-# todo 0.25.0 : Please ensure this file is deleted.
-
-__all__ = [
-    "ExpandingGreedySplitter",
-    "ExpandingWindowSplitter",
-    "SlidingWindowSplitter",
-    "CutoffSplitter",
-    "SingleWindowSplitter",
-    "SameLocSplitter",
-    "temporal_train_test_split",
-    "TestPlusTrainSplitter",
-]
-
-import warnings
-
-from sktime.split import (
-    CutoffSplitter,
-    ExpandingGreedySplitter,
-    ExpandingWindowSplitter,
-    SameLocSplitter,
-    SingleWindowSplitter,
-    SlidingWindowSplitter,
-    TestPlusTrainSplitter,
-    temporal_train_test_split,
-)
-
-warnings.warn(
-    "Please import sktime splitters from `sktime.split`. Importing splitters from "
-    "`sktime.forecasting.model_selection` is deprecated and will be removed in release "
-    "0.25.0",
-    DeprecationWarning,
-    stacklevel=2,
-)
diff --git a/sktime/forecasting/model_selection/_tune.py b/sktime/forecasting/model_selection/_tune.py
index fd0fcd585..5c11825bf 100644
--- a/sktime/forecasting/model_selection/_tune.py
+++ b/sktime/forecasting/model_selection/_tune.py
@@ -544,9 +544,7 @@ class ForecastingGridSearchCV(BaseGridSearch):
     --------
     >>> from sktime.datasets import load_shampoo_sales
     >>> from sktime.forecasting.model_selection import ForecastingGridSearchCV
-    >>> from sktime.split import (
-    ...     ExpandingWindowSplitter,
-    ...     ExpandingWindowSplitter)
+    >>> from sktime.split import ExpandingWindowSplitter
     >>> from sktime.forecasting.naive import NaiveForecaster
     >>> y = load_shampoo_sales()
     >>> fh = [1,2,3]
diff --git a/sktime/forecasting/trend/_pwl_trend_forecaster.py b/sktime/forecasting/trend/_pwl_trend_forecaster.py
index c3d947660..e59bc3fb9 100644
--- a/sktime/forecasting/trend/_pwl_trend_forecaster.py
+++ b/sktime/forecasting/trend/_pwl_trend_forecaster.py
@@ -60,8 +60,8 @@ class ProphetPiecewiseLinearTrendForecaster(_ProphetAdapter):
     --------
     >>> from sktime.datasets import load_airline
     >>> from sktime.forecasting.trend import ProphetPiecewiseLinearTrendForecaster
-    >>> from sktime.forecasting.model_selection import temporal_train_test_split
     >>> from sktime.forecasting.base import ForecastingHorizon
+    >>> from sktime.split import temporal_train_test_split
     >>> y =load_airline().to_timestamp(freq='M')
     >>> y_train, y_test = temporal_train_test_split(y)
     >>> fh = ForecastingHorizon(y.index, is_relative=False)
diff --git a/sktime/regression/base.py b/sktime/regression/base.py
index 4c0351608..f89e1aa86 100644
--- a/sktime/regression/base.py
+++ b/sktime/regression/base.py
@@ -23,26 +23,16 @@ __all__ = [
 __author__ = ["mloning", "fkiraly"]
 
 import time
-from abc import ABC, abstractmethod
 
 import numpy as np
-import pandas as pd
-
-from sktime.base import BaseEstimator
-from sktime.datatypes import (
-    MTYPE_LIST_PANEL,
-    MTYPE_LIST_TABLE,
-    VectorizedDF,
-    check_is_error_msg,
-    check_is_scitype,
-    convert,
-)
+
+from sktime.base import BasePanelMixin
+from sktime.datatypes import VectorizedDF
 from sktime.utils.sklearn import is_sklearn_transformer
 from sktime.utils.validation import check_n_jobs
-from sktime.utils.warnings import warn
 
 
-class BaseRegressor(BaseEstimator, ABC):
+class BaseRegressor(BasePanelMixin):
     """Abstract base class for time series regressors.
 
     The base regressor specifies the methods and method signatures that all
@@ -79,6 +69,14 @@ class BaseRegressor(BaseEstimator, ABC):
         "is_equal_length",
     ]
 
+    # attribute name where vectorized estimators are stored
+    VECTORIZATION_ATTR = "regressors_"  # e.g., classifiers_, regressors_
+
+    # used in error messages
+    TASK = "regression"  # e.g., classification, regression
+    EST_TYPE = "regressor"  # e.g., classifier, regressor
+    EST_TYPE_PLURAL = "regressors"  # e.g., classifiers, regressors
+
     def __init__(self):
         self.fit_time_ = 0
         self._class_dictionary = {}
@@ -192,19 +190,21 @@ class BaseRegressor(BaseEstimator, ABC):
 
         # convenience conversions to allow user flexibility:
         # if X is 2D array, convert to 3D, if y is Series, convert to numpy
-        X, y = _internal_convert(X, y)
-        X_metadata = _check_regressor_input(
+        X, y = self._internal_convert(X, y)
+
+        # y float coercion
+        if y is not None and isinstance(y, np.ndarray):
+            y = y.astype("float")
+
+        # input checks
+        X_metadata = self._check_input(
             X, y, return_metadata=self.METADATA_REQ_IN_CHECKS
         )
         self._X_metadata = X_metadata
-        missing = X_metadata["has_nans"]
-        multivariate = not X_metadata["is_univariate"]
-        unequal = not X_metadata["is_equal_length"]
         X_mtype = X_metadata["mtype"]
-        self._X_metadata = X_metadata
 
         # Check this regressor can handle characteristics
-        self._check_capabilities(missing, multivariate, unequal)
+        self._check_capabilities(X_metadata)
 
         # Convert data as dictated by the regressor tags
         X = self._convert_X(X, X_mtype)
@@ -298,47 +298,6 @@ class BaseRegressor(BaseEstimator, ABC):
 
         return r2_score(y, self.predict(X), normalize=True, multioutput=multioutput)
 
-    def _vectorize(self, methodname, **kwargs):
-        """Vectorized/iterated loop over method of BaseClassifier.
-
-        Uses regressors_ attribute to store one regressor per loop index.
-        """
-        y = kwargs.get("y")
-        X = kwargs.get("X")
-        if X is not None:
-            kwargs.pop("X")
-        if y is not None:
-            kwargs.pop("y")
-            self._y_vec = y
-        regressors_ = self._y_vec.vectorize_est(
-            self,
-            method="clone",
-        )
-        if methodname == "fit":
-            self.regressors_ = self._y_vec.vectorize_est(
-                regressors_,
-                method=methodname,
-                args={"y": y},
-                X=X,
-            )
-            return self
-        else:
-            regressors_ = self.regressors_
-            y_pred = self._y_vec.vectorize_est(
-                regressors_,
-                method=methodname,
-                # return_type="list",
-                X=X,
-                args={"y": y} if y is not None else {},
-                **kwargs,  # contains X inside
-            )
-            y_pred = pd.DataFrame(
-                {str(i): y_pred[col].values[0] for i, col in enumerate(y_pred.columns)}
-            )
-            return y_pred
-        # add code for score
-
-    @abstractmethod
     def _fit(self, X, y):
         """Fit time series regressor to training data.
 
@@ -372,9 +331,8 @@ class BaseRegressor(BaseEstimator, ABC):
         -----
         Changes state by creating a fitted model that updates attributes ending in "_"
         """
-        ...
+        raise NotImplementedError("abstract method")
 
-    @abstractmethod
     def _predict(self, X) -> np.ndarray:
         """Predicts labels for sequences in X.
 
@@ -395,304 +353,4 @@ class BaseRegressor(BaseEstimator, ABC):
         y : 1D np.array of float, of shape [n_instances] - predicted regression labels
             indices correspond to instance indices in X
         """
-        ...
-
-    def _check_convert_X_for_predict(self, X):
-        """Input checks, capability checks, repeated in all predict/score methods.
-
-        Parameters
-        ----------
-        X : any object (to check/convert)
-            should be of a supported Panel mtype or 2D numpy.ndarray
-
-        Returns
-        -------
-        X: an object of a supported Panel mtype, numpy3D if X was a 2D numpy.ndarray
-
-        Raises
-        ------
-        ValueError if X is of invalid input data type, or there is not enough data
-        ValueError if the capabilities in self._tags do not handle the data.
-        """
-        X = _internal_convert(X)
-        X_metadata = _check_regressor_input(
-            X, return_metadata=self.METADATA_REQ_IN_CHECKS
-        )
-        missing = X_metadata["has_nans"]
-        multivariate = not X_metadata["is_univariate"]
-        unequal = not X_metadata["is_equal_length"]
-        X_mtype = X_metadata["mtype"]
-        # Check this classifier can handle characteristics
-        self._check_capabilities(missing, multivariate, unequal)
-        # Convert data as dictated by the classifier tags
-        X = self._convert_X(X, X_mtype=X_mtype)
-
-        return X
-
-    def _check_y(self, y=None):
-        """Check and coerce X/y for fit/transform functions.
-
-        Parameters
-        ----------
-        y : pd.DataFrame, pd.Series or np.ndarray
-
-        Returns
-        -------
-        y : object of sktime compatible time series type
-            can be Series, Panel, Hierarchical
-        y_metadata : dict
-            metadata of y, retured by check_is_scitype
-        """
-        if y is None:
-            return None
-
-        capa_multioutput = self.get_tag("capability:multioutput")
-        y_inner_mtype = self.get_tag("y_inner_mtype")
-
-        y_valid, y_msg, y_metadata = check_is_scitype(
-            y, "Table", return_metadata=["is_univariate"]
-        )
-
-        if not y_valid:
-            allowed_msg = (
-                f"In regression, y must be of a supported type, "
-                f"for instance 1D or 2D numpy arrays, pd.DataFrame, or pd.Series. "
-                f"Allowed compatible mtype format specifications are:"
-                f" {MTYPE_LIST_TABLE} ."
-            )
-            check_is_error_msg(
-                y_msg, var_name="y", allowed_msg=allowed_msg, raise_exception=True
-            )
-
-        y_uni = y_metadata["is_univariate"]
-        y_mtype = y_metadata["mtype"]
-
-        requires_vectorization = not capa_multioutput and not y_uni
-
-        if requires_vectorization:
-            y_df = convert(
-                y,
-                from_type=y_mtype,
-                to_type="pd_DataFrame_Table",
-                as_scitype="Table",
-                store=self._converter_store_y,
-            )
-            y_vec = VectorizedDF([y_df], iterate_cols=True)
-            return y_vec, y_metadata
-
-        y_inner = convert(
-            y,
-            from_type=y_mtype,
-            to_type=y_inner_mtype,
-            as_scitype="Table",
-            store=self._converter_store_y,
-        )
-
-        return y_inner, y_metadata
-
-    def _convert_output_y(self, y):
-        """Convert output y to original format.
-
-        Parameters
-        ----------
-        y : np.ndarray or pd.DataFrame
-
-        Returns
-        -------
-        y : np.ndarray or pd.DataFrame
-        """
-        # for consistency with legacy behaviour:
-        # output is coerced to numpy1D in case of univariate output
-        if not self._y_metadata["is_univariate"]:
-            output_mtype = self._y_metadata["mtype"]
-            converter_store = self._converter_store_y
-        else:
-            output_mtype = "numpy1D"
-            converter_store = None
-
-        y = convert(
-            y,
-            from_type=self.get_tag("y_inner_mtype"),
-            to_type=output_mtype,
-            as_scitype="Table",
-            store=converter_store,
-            store_behaviour="freeze",
-        )
-        return y
-
-    def _check_capabilities(self, missing, multivariate, unequal):
-        """Check whether this regressor can handle the data characteristics.
-
-        Parameters
-        ----------
-        missing : boolean, does the data passed to fit contain missing values?
-        multivariate : boolean, does the data passed to fit contain missing values?
-        unequal : boolea, do the time series passed to fit have variable lengths?
-
-        Raises
-        ------
-        ValueError if the capabilities in self._tags do not handle the data.
-        """
-        allow_multivariate = self.get_tag("capability:multivariate")
-        allow_missing = self.get_tag("capability:missing_values")
-        allow_unequal = self.get_tag("capability:unequal_length")
-
-        self_name = type(self).__name__
-
-        # identify problems, mismatch of capability and inputs
-        problems = []
-        if missing and not allow_missing:
-            problems += ["missing values"]
-        if multivariate and not allow_multivariate:
-            problems += ["multivariate series"]
-        if unequal and not allow_unequal:
-            problems += ["unequal length series"]
-
-        # construct error message
-        problems_and = " and ".join(problems)
-        problems_or = " or ".join(problems)
-        msg = (
-            f"Data seen by {self_name} instance has {problems_and}, "
-            f"but this {self_name} instance cannot handle {problems_or}. "
-            f"Calls with {problems_or} may result in error or unreliable results."
-        )
-
-        # raise exception or warning with message
-        # if self is composite, raise a warning, since passing could be fine
-        #   see discussion in PR 2366 why
-        if len(problems) > 0:
-            if self.is_composite():
-                warn(msg, obj=self)
-            else:
-                raise ValueError(msg)
-
-    def _convert_X(self, X, X_mtype):
-        """Convert equal length series from DataFrame to numpy array or vice versa.
-
-        Parameters
-        ----------
-        X : input data for the classifier
-        X_mtype : str, a Panel mtype string, e.g., "pd_multiindex", "numpy3D"
-
-        Returns
-        -------
-        X : input X converted to type in "X_inner_mtype" tag
-            usually a pd.DataFrame (nested) or 3D np.ndarray
-            Checked and possibly converted input data
-        """
-        inner_type = self.get_tag("X_inner_mtype")
-        # convert pd.DataFrame
-        X = convert(
-            X,
-            from_type=X_mtype,
-            to_type=inner_type,
-            as_scitype="Panel",
-        )
-        return X
-
-
-def _check_regressor_input(
-    X,
-    y=None,
-    enforce_min_instances=1,
-    return_metadata=True,
-):
-    """Check whether input X and y are valid formats with minimum data.
-
-    Raises a ValueError if the input is not valid.
-
-    Parameters
-    ----------
-    X : check whether conformant with any sktime Panel mtype specification
-    y : check whether a pd.Series or np.array
-    enforce_min_instances : int, optional (default=1)
-        check there are a minimum number of instances.
-    return_metadata : bool, str, or list of str
-        metadata fields to return with X_metadata, input to check_is_scitype
-
-    Returns
-    -------
-    metadata : dict with metadata for X returned by datatypes.check_is_scitype
-
-    Raises
-    ------
-    ValueError
-        If y or X is invalid input data type, or there is not enough data
-    """
-    # Check X is valid input type and recover the data characteristics
-    X_valid, msg, X_metadata = check_is_scitype(
-        X, scitype="Panel", return_metadata=return_metadata
-    )
-    # raise informative error message if X is in wrong format
-    allowed_msg = (
-        f"Allowed scitypes for regressors are Panel mtypes, "
-        f"for instance a pandas.DataFrame with MultiIndex and last(-1) "
-        f"level an sktime compatible time index. "
-        f"Allowed compatible mtype format specifications are: {MTYPE_LIST_PANEL} ."
-    )
-    if not X_valid:
-        check_is_error_msg(
-            msg, var_name="X", allowed_msg=allowed_msg, raise_exception=True
-        )
-
-    n_cases = X_metadata["n_instances"]
-    if n_cases < enforce_min_instances:
-        raise ValueError(
-            f"Minimum number of cases required is {enforce_min_instances} but X "
-            f"has : {n_cases}"
-        )
-
-    # Check y if passed
-    if y is not None:
-        # Check y valid input
-        if not isinstance(y, (pd.Series, pd.DataFrame, np.ndarray)):
-            raise ValueError(
-                "y must be a np.array or a pd.Series or pd.DataFrame, but found ",
-                f"type: {type(y)}",
-            )
-        # Check matching number of labels
-        n_labels = y.shape[0]
-        if n_cases != n_labels:
-            raise ValueError(
-                f"Mismatch in number of cases. Number in X = {n_cases} nos in y = "
-                f"{n_labels}"
-            )
-        if isinstance(y, np.ndarray):
-            if y.ndim > 2:
-                raise ValueError(
-                    f"np.ndarray y must be 1-dimensional or 2-dimensional, "
-                    f"but found {y.ndim} dimensions"
-                )
-    return X_metadata
-
-
-def _internal_convert(X, y=None):
-    """Convert X and y if necessary as a user convenience.
-
-    Convert X to a 3D numpy array if already a 2D and convert y into an 1D numpy
-    array if passed as a Series.
-
-    Parameters
-    ----------
-    X : an object of a supported Panel mtype, or 2D numpy.ndarray
-    y : np.ndarray or pd.Series
-
-    Returns
-    -------
-    X: an object of a supported Panel mtype, numpy3D if X was a 2D numpy.ndarray
-    y: np.ndarray
-    """
-    if isinstance(X, np.ndarray):
-        # Temporary fix to insist on 3D numpy. For univariate problems,
-        # most regressors simply convert back to 2D. This squeezing should be
-        # done here, but touches a lot of files, so will get this to work first.
-        if X.ndim == 2:
-            X = X.reshape(X.shape[0], 1, X.shape[1])
-    if y is not None and isinstance(y, pd.Series):
-        # y should be a numpy array, although we allow Series for user convenience
-        y = pd.Series.to_numpy(y)
-    if y is not None and isinstance(y, np.ndarray):
-        y = y.astype("float")
-    if y is None:
-        return X
-    return X, y
+        raise NotImplementedError("abstract method")
diff --git a/sktime/split/sameloc.py b/sktime/split/sameloc.py
index 040c748d3..c94e1bb6c 100644
--- a/sktime/split/sameloc.py
+++ b/sktime/split/sameloc.py
@@ -141,10 +141,7 @@ class SameLocSplitter(BaseSplitter):
             `create_test_instance` uses the first (or only) dictionary in `params`
         """
         from sktime.datasets import load_airline
-        from sktime.forecasting.model_selection import (
-            ExpandingWindowSplitter,
-            SingleWindowSplitter,
-        )
+        from sktime.split import ExpandingWindowSplitter, SingleWindowSplitter
 
         y = load_airline()
         y_temp = y[:60]
diff --git a/sktime/transformations/base.py b/sktime/transformations/base.py
index 0dff77c85..23005244c 100644
--- a/sktime/transformations/base.py
+++ b/sktime/transformations/base.py
@@ -138,6 +138,7 @@ class BaseTransformer(BaseEstimator):
     }
 
     # default config values
+    # see set_config documentation for details
     _config = {
         "input_conversion": "on",
         # controls input checks and conversions,
@@ -156,41 +157,12 @@ class BaseTransformer(BaseEstimator):
         #  {None, "dask", "loky", "multiprocessing", "threading"}
         #  None: no parallelization
         #  "loky", "multiprocessing" and "threading": uses `joblib` Parallel loops
+        #  "joblib": uses custom joblib backend, set via `joblib_backend` tag
         #  "dask": uses `dask`, requires `dask` package in environment
         "backend:parallel:params": None,  # params for parallelization backend
     }
 
     _config_doc = {
-        "backend:parallel": """
-        backend:parallel : str, optional, default="None"
-            backend to use for parallelization when broadcasting/vectorizing, one of
-
-            - "None": executes loop sequentally, simple list comprehension
-            - "loky", "multiprocessing" and "threading": uses ``joblib.Parallel``
-            - "joblib": custom and 3rd party ``joblib`` backends, e.g., ``spark``
-            - "dask": uses ``dask``, requires ``dask`` package in environment
-        """,
-        "backend:parallel:params": """
-        backend:parallel:params : dict, optional, default={} (no parameters passed)
-            additional parameters passed to the parallelization backend as config.
-            Valid keys depend on the value of ``backend:parallel``:
-
-            - "None": no additional parameters, ``backend_params`` is ignored
-            - "loky", "multiprocessing" and "threading": default ``joblib`` backends
-              any valid keys for ``joblib.Parallel`` can be passed here, e.g.,
-              ``n_jobs``, with the exception of ``backend`` which is directly
-              controlled by ``backend``.
-              If ``n_jobs`` is not passed, it will default to ``-1``, other parameters
-              will default to ``joblib`` defaults.
-            - "joblib": custom and 3rd party ``joblib`` backends,
-              e.g., ``spark``. Any valid keys for ``joblib.Parallel``
-              can be passed here, e.g., ``n_jobs``,
-            ``backend`` must be passed as a key of ``backend_params`` in this case.
-              If ``n_jobs`` is not passed, it will default to ``-1``, other parameters
-              will default to ``joblib`` defaults.
-            - "dask": any valid keys for ``dask.compute``
-              can be passed, e.g., ``scheduler``
-        """,
         "input_conversion": """
         input_conversion : str, one of "on", "off", valid mtype string
             controls input checks and conversions,
@@ -1186,10 +1158,20 @@ class BaseTransformer(BaseEstimator):
         #   skipped for output_scitype = "Primitives"
         #       since then the output always is a pd.DataFrame
         if case == "case 2: higher scitype supported" and output_scitype == "Series":
-            Xt = convert_to(
-                Xt,
-                to_type=["pd-multiindex", "numpy3D", "df-list", "pd_multiindex_hier"],
-            )
+            if self.get_tags()["scitype:transform-input"] == "Panel":
+                # Conversion from Series to Panel done for being compatible with
+                # algorithm. Thus, the returned Series should stay a Series.
+                pass
+            else:
+                Xt = convert_to(
+                    Xt,
+                    to_type=[
+                        "pd-multiindex",
+                        "numpy3D",
+                        "df-list",
+                        "pd_multiindex_hier",
+                    ],
+                )
             Xt = convert_to_scitype(Xt, to_scitype=X_input_scitype)
 
         # now, in all cases, Xt is in the right scitype,
@@ -1203,7 +1185,6 @@ class BaseTransformer(BaseEstimator):
         if output_scitype == "Series":
             # output mtype is input mtype
             X_output_mtype = X_input_mtype
-
             # exception to this: if the transformer outputs multivariate series,
             #   we cannot convert back to pd.Series, do pd.DataFrame instead then
             #   this happens only for Series, not Panel
@@ -1229,6 +1210,13 @@ class BaseTransformer(BaseEstimator):
                     )
                 if X_input_mtype == "pd.Series" and not metadata["is_univariate"]:
                     X_output_mtype = "pd.DataFrame"
+            elif self.get_tags()["scitype:transform-input"] == "Panel":
+                # Input has always to be Panel
+                X_output_mtype = "pd.DataFrame"
+            else:
+                # Input can be Panel or Hierachical, since it is supported
+                # by the used mtype
+                output_scitype = X_input_scitype
                 # Xt_mtype = metadata["mtype"]
             # else:
             #     Xt_mtype = X_input_mtype
@@ -1241,10 +1229,10 @@ class BaseTransformer(BaseEstimator):
             #     store=_converter_store_X,
             #     store_behaviour="freeze",
             # )
-            Xt = convert_to(
+            return convert_to(
                 Xt,
                 to_type=X_output_mtype,
-                as_scitype=X_input_scitype,
+                as_scitype=output_scitype,
                 store=_converter_store_X,
                 store_behaviour="freeze",
             )
@@ -1260,7 +1248,7 @@ class BaseTransformer(BaseEstimator):
                 # else this is only zeros and should be reset to RangeIndex
                 else:
                     Xt = Xt.reset_index(drop=True)
-            Xt = convert_to(
+            return convert_to(
                 Xt,
                 to_type="pd_DataFrame_Table",
                 as_scitype="Table",
diff --git a/sktime/transformations/merger.py b/sktime/transformations/merger.py
new file mode 100644
index 000000000..10c7024fb
--- /dev/null
+++ b/sktime/transformations/merger.py
@@ -0,0 +1,134 @@
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+"""Implements a merger for panel data."""
+import numpy as np
+
+__author__ = ["benHeid"]
+
+
+from sktime.transformations.base import BaseTransformer
+
+
+class Merger(BaseTransformer):
+    """Aggregates Panel data containing overlapping windows of one time series.
+
+    The input data contains multiple overlapping time series elements that could
+    arranged as follows:
+    xxxx.....
+    .xxxx....
+    ..xxxx...
+    ...xxxx..
+    ....xxxx.
+    .....xxxx
+    ......xxxx
+    .......xxxx
+    ........xxxx
+    .........xxxx
+    The merger aggregates the data by aligning the time series windows as shown above
+    and applying a aggregation function to the overlapping data points.
+    The aggregation function can be one of "mean" or "median". I.e., the `mean` or
+    `median` of each column is calculated, resulting in a univariate time series.
+
+    Parameters
+    ----------
+    method : {`median`, `mean`}, default="median"
+        The method to use for aggregation. Can be one of "mean" or "median".
+    stride : int, default=0
+        The stride to use for the aggregation. The stride determines the number of
+        shifts between consecutive instances. A stride of 0 means no shift. A
+        stride of 1 means that the time series is aggregated as above.
+
+    Examples
+    --------
+    >>> from sktime.transformations.merger import Merger
+    >>> from sktime.utils._testing.panel import _make_panel
+    >>> y = _make_panel(n_instances=10, n_columns=3, n_timepoints=5)
+    >>> result = Merger(method="median").fit_transform(y)
+    >>> result.shape
+    (3, 5)
+
+    >>> from sktime.transformations.merger import Merger
+    >>> from sktime.utils._testing.panel import _make_panel
+    >>> y = _make_panel(n_instances=10, n_columns=3, n_timepoints=5)
+    >>> result = Merger(method="median", stride=1).fit_transform(y)
+    >>> result.shape
+    (14, 3)
+    """
+
+    _tags = {
+        "scitype:transform-input": "Panel",
+        "scitype:transform-output": "Series",
+        "X_inner_mtype": "numpy3D",
+        "fit_is_empty": True,
+    }
+
+    def __init__(self, method="median", stride=0):
+        self.stride = stride
+        if method not in ["median", "mean"]:
+            raise ValueError(f"{method} must be 'mean' or 'median'.")
+        self.method = method
+        super().__init__()
+
+    def _transform(self, X=None, y=None):
+        """Merge the Panel data by aligning them temporally.
+
+        Parameters
+        ----------
+        X : pd.DataFrame
+            The input panel data.
+        y : pd.Series
+            ignored
+
+        Returns
+        -------
+        returns a single time series
+        """
+        horizon = X.shape[-1]
+
+        if self.method == "mean":
+            result = np.nanmean(self._align_temporal(horizon, X), axis=0)
+        elif self.method == "median":
+            result = np.nanmedian(self._align_temporal(horizon, X), axis=0)
+        else:
+            raise ValueError(f"{self.method} must be 'mean' or 'median'.")
+        return result
+
+    def _align_temporal(self, horizon, x):
+        x = x.astype(float)
+        if self.stride > 0:
+            x = np.insert(
+                x, np.arange(1, x.shape[0]).repeat(self.stride - 1), np.nan, axis=0
+            )
+        elif self.stride == 0:
+            return x
+        r = []
+        for i in range(horizon):
+            _res = np.concatenate(
+                [
+                    np.full(fill_value=np.nan, shape=(i, x.shape[1])),
+                    x[:, :, i],
+                    np.full(fill_value=np.nan, shape=((horizon - 1 - i), x.shape[1])),
+                ]
+            )
+            r.append(_res)
+        return np.stack(r)
+
+    @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`
+        """
+        return [{"method": "mean"}, {"method": "median"}]


Test patch:
diff --git a/sktime/classification/tests/test_all_classifiers.py b/sktime/classification/tests/test_all_classifiers.py
index e8f362266..cda9a0245 100644
--- a/sktime/classification/tests/test_all_classifiers.py
+++ b/sktime/classification/tests/test_all_classifiers.py
@@ -208,7 +208,7 @@ class TestAllClassifiers(ClassifierFixtureGenerator, QuickTester):
         X, y = make_classification_problem()
         y[:] = 42
 
-        error_msg = "single class label"
+        error_msg = "single label"
 
         with pytest.warns(UserWarning, match=error_msg):
             estimator_instance.fit(X, y)
diff --git a/sktime/classification/tests/test_base.py b/sktime/classification/tests/test_base.py
index 525c29125..33d0409f1 100644
--- a/sktime/classification/tests/test_base.py
+++ b/sktime/classification/tests/test_base.py
@@ -170,36 +170,41 @@ def test_check_capabilities(missing, multivariate, unequal):
     handle it and that cannot. Obvs could loop, but I think its clearer to just
     explicitly test;
     """
+    X_metadata = {
+        "has_nans": missing,
+        "is_univariate": not multivariate,
+        "is_equal_length": not unequal,
+    }
     handles_none = _DummyClassifier()
     handles_none_composite = _DummyComposite(_DummyClassifier())
 
     # checks that errors are raised
     if missing:
         with pytest.raises(ValueError, match=missing_message):
-            handles_none._check_capabilities(missing, multivariate, unequal)
+            handles_none._check_capabilities(X_metadata)
     if multivariate:
         with pytest.raises(ValueError, match=multivariate_message):
-            handles_none._check_capabilities(missing, multivariate, unequal)
+            handles_none._check_capabilities(X_metadata)
     if unequal:
         with pytest.raises(ValueError, match=unequal_message):
-            handles_none._check_capabilities(missing, multivariate, unequal)
+            handles_none._check_capabilities(X_metadata)
     if not missing and not multivariate and not unequal:
-        handles_none._check_capabilities(missing, multivariate, unequal)
+        handles_none._check_capabilities(X_metadata)
 
     if missing:
         with pytest.warns(UserWarning, match=missing_message):
-            handles_none_composite._check_capabilities(missing, multivariate, unequal)
+            handles_none_composite._check_capabilities(X_metadata)
     if multivariate:
         with pytest.warns(UserWarning, match=multivariate_message):
-            handles_none_composite._check_capabilities(missing, multivariate, unequal)
+            handles_none_composite._check_capabilities(X_metadata)
     if unequal:
         with pytest.warns(UserWarning, match=unequal_message):
-            handles_none_composite._check_capabilities(missing, multivariate, unequal)
+            handles_none_composite._check_capabilities(X_metadata)
     if not missing and not multivariate and not unequal:
-        handles_none_composite._check_capabilities(missing, multivariate, unequal)
+        handles_none_composite._check_capabilities(X_metadata)
 
     handles_all = _DummyHandlesAllInput()
-    handles_all._check_capabilities(missing, multivariate, unequal)
+    handles_all._check_capabilities(X_metadata)
 
 
 def test_convert_input():
@@ -253,9 +258,10 @@ def test__check_classifier_input():
     4. Test incorrect: y as a list
     5. Test incorrect: too few cases or too short a series
     """
+    clf = _DummyClassifier()
 
     def _check_classifier_input(X, y=None, enforce_min_instances=1):
-        return BaseClassifier._check_classifier_input(None, X, y, enforce_min_instances)
+        return clf._check_input(X, y, enforce_min_instances)
 
     # 1. Test correct: X: np.array of 2 and 3 dimensions vs y:np.array and np.Series
     test_X1 = np.random.uniform(-1, 1, size=(5, 10))
diff --git a/sktime/forecasting/compose/tests/test_pipeline.py b/sktime/forecasting/compose/tests/test_pipeline.py
index d3b650dca..299cd7229 100644
--- a/sktime/forecasting/compose/tests/test_pipeline.py
+++ b/sktime/forecasting/compose/tests/test_pipeline.py
@@ -485,7 +485,7 @@ def test_forecastx_logic():
 )
 def test_forecastx_fit_behavior():
     from sktime.forecasting.compose import ForecastX
-    from sktime.forecasting.model_selection import temporal_train_test_split
+    from sktime.split import temporal_train_test_split
 
     y, X = load_longley()
     y_train, y_test, X_train, X_test = temporal_train_test_split(y, X)
diff --git a/sktime/forecasting/trend/tests/test_pwl_trend.py b/sktime/forecasting/trend/tests/test_pwl_trend.py
index a1e4d938c..b5ab77109 100644
--- a/sktime/forecasting/trend/tests/test_pwl_trend.py
+++ b/sktime/forecasting/trend/tests/test_pwl_trend.py
@@ -11,11 +11,11 @@ import pytest
 
 from sktime.datasets import load_airline
 from sktime.forecasting.base import ForecastingHorizon
-from sktime.forecasting.model_selection import temporal_train_test_split
 from sktime.forecasting.trend import (
     PolynomialTrendForecaster,
     ProphetPiecewiseLinearTrendForecaster,
 )
+from sktime.split import temporal_train_test_split
 from sktime.tests.test_switch import run_test_for_class
 
 
diff --git a/sktime/split/temporal_train_test_split.py b/sktime/split/temporal_train_test_split.py
index 2509b0ec9..55e7e9195 100644
--- a/sktime/split/temporal_train_test_split.py
+++ b/sktime/split/temporal_train_test_split.py
@@ -91,8 +91,8 @@ def temporal_train_test_split(
 
     Examples
     --------
-    >>> from sktime.forecasting.model_selection import temporal_train_test_split
     >>> from sktime.datasets import load_airline, load_osuleaf
+    >>> from sktime.split import temporal_train_test_split
     >>> from sktime.utils._testing.panel import _make_panel
     >>> # univariate time series
     >>> y = load_airline()
diff --git a/sktime/split/testplustrain.py b/sktime/split/testplustrain.py
index 73981a9c6..a803a52c9 100644
--- a/sktime/split/testplustrain.py
+++ b/sktime/split/testplustrain.py
@@ -131,10 +131,7 @@ class TestPlusTrainSplitter(BaseSplitter):
             `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
             `create_test_instance` uses the first (or only) dictionary in `params`
         """
-        from sktime.forecasting.model_selection import (
-            ExpandingWindowSplitter,
-            SingleWindowSplitter,
-        )
+        from sktime.split import ExpandingWindowSplitter, SingleWindowSplitter
 
         cv_1 = ExpandingWindowSplitter(fh=[2, 4], initial_window=24, step_length=12)
         cv_2 = SingleWindowSplitter(fh=[2, 4], window_length=24)
diff --git a/sktime/transformations/tests/test_all_transformers.py b/sktime/transformations/tests/test_all_transformers.py
index 493fb63d3..b6a9699ac 100644
--- a/sktime/transformations/tests/test_all_transformers.py
+++ b/sktime/transformations/tests/test_all_transformers.py
@@ -78,6 +78,8 @@ class TestAllTransformers(TransformerFixtureGenerator, QuickTester):
                 return "Panel"
             if X_scitype in ["Panel", "Hierarchical"]:
                 return "Hierarchical"
+        if trafo_input == "Panel" and trafo_output == "Series":
+            return "Series"
 
     def test_fit_transform_output(self, estimator_instance, scenario):
         """Test that transform output is of expected scitype."""