Repository: alan-turing-institute/sktime
Pull request #3270 (2022-08-14 17:01:10)

[ENH] forecasting pipeline test case with `Detrender`
Switches the transformer in one of the three forecasting pipeline test cases out for the `Detrender`, to cover transformer `update` from a component, and the bug https://github.com/alan-turing-institute/sktime/issues/3267.

No coverage is removed, since the replaced `ExponentTransformer` was used in another test case already, as the transformer.

Depends on:
* https://github.com/alan-turing-institute/sktime/pull/3287 since it triggers the edge case with `infer_freq` on short time series
* https://github.com/alan-turing-institute/sktime/pull/3290 as it also triggers bugs around the `name` attribute

fkiraly: Should detect #3267 before the fix is merged.
fkiraly: Detects #3267, and some other stuff - trying to vectorize over single indices?
Looks like a mistake in not recognizing the instance index, and instead treating time index as instance index.

Patch:
diff --git a/docs/source/api_reference/transformations.rst b/docs/source/api_reference/transformations.rst
index 178623662..e0e3174d7 100644
--- a/docs/source/api_reference/transformations.rst
+++ b/docs/source/api_reference/transformations.rst
@@ -51,17 +51,12 @@ Pipeline building
 
     TransformerPipeline
     FeatureUnion
+    ColumnwiseTransformer
     FitInTransform
     MultiplexTransformer
-
-.. currentmodule:: sktime.transformations.series.compose
-
-.. autosummary::
-    :toctree: auto_generated/
-    :template: class.rst
-
     OptionalPassthrough
-    ColumnwiseTransformer
+    InvertTransform
+    Id
     YtoX
 
 .. currentmodule:: sktime.transformations.panel.compose
diff --git a/docs/source/get_involved/governance.rst b/docs/source/get_involved/governance.rst
index 98b53654e..ffab7b81a 100644
--- a/docs/source/get_involved/governance.rst
+++ b/docs/source/get_involved/governance.rst
@@ -484,29 +484,36 @@ deprecate and remove contributions.
 
 We have the following guidelines:
 
--  We only consider published algorithms which have been shown to be
-   competitive in comparative benchmarking studies or practically useful
-   in applied projects. A technique that provides a clear-cut
-   improvement (e.g. an enhanced data structure or a more efficient
-   approximation technique) on a widely-used method will also be
-   considered for inclusion.
+-  ``sktime`` aims to provide a repository for algorithms to enhance reproducible research,
+   putting no lower bounds on number of citations, algorithmic performance, or frequency of use.
+-  For inclusion, a scientific reference must be available and linked to the python estimator.
+   A scientific reference is a formal description of the algorithm which
+   satisfies basic scientific requirements, e.g., be formally correct, complete, and
+   adhere with common conventions on presentation in the field of data science.
+-  The scientific reference must be free from unfounded scientific claims, pseudo-science,
+   commercial marketing, or other content inappropriate for a scientific reference.
+   The scientific reference must adhere to proper scientific citation standards,
+   i.e., citing primary sources, giving proper credit.
+   The form of the scientific reference can be a description in the class docstring,
+   or a link to a scientific document, e.g., on the arXiv. Such a scientific document
+   need not be peer-reviewed or journal published, but must adhere to scientific standards.
+-  We strive to consolidate existing functionality if it helps to improve
+   the usability and maintainability of the project. For example, when
+   there are multiple techniques for the same purpose, we may choose to present one variant as the "primary default",
+   and rarer variants as less accessible or findable alternatives. The choice of the "primary default"
+   may change with use and relevance in the user community.
+   We are aware that the choice of the "primary default" may give or remove visibility,
+   and aim to make the choice for usability and quality of the selection.
+-  We are happy to accept historical algorithms of interest, as references to use in
+   reproduction studies, including historical versions that are faulty implementations.
+   Algorithms of historical interest will be clearly labelled as such, and inclusion
+   is primarily guided by relevance, e.g., as a reference in an important study,
+   relevance in the scientific discourse, or as an important algorithmic baseline.
 -  From the algorithms or techniques that meet the above criteria, only
-   those which fit well within the current API of sktime are accepted.
-   For algorithms that do not fit well into the current API, the API
+   those which fit well within the current framework of sktime are accepted.
+   For algorithms that do not fit well into one of the current API definitions, the API
    will have to be extended first. For extending current API, see the
    `decision making process <#Decision-making>`__ for major changes.
--  The contributor should support the importance of the proposed
-   addition with research papers and/or implementations in other similar
-   packages, demonstrate its usefulness via common
-   use-cases/applications and corroborate performance improvements, if
-   any, with benchmarks and/or plots. It is expected that the proposed
-   algorithm should outperform the methods that are already implemented
-   in sktime in at least some areas.
--  We strive to consolidate existing functionality if helps to improve
-   the usability and maintainability of the project. For example, when
-   there are multiple techniques for the same purpose, we prefer to
-   combine them into a single class and make case distinctions based on
-   hyper-parameters.
 
 Note that your implementation need not be in sktime to be used together
 with sktime tools. You can implement your favorite algorithm in a sktime
@@ -516,9 +523,7 @@ to list it under `related
 software <https://github.com/alan-turing-institute/sktime/wiki/related-software>`__.
 
 If algorithms require major dependencies, we encourage to create a
-separate companion repository. For example, for deep learning techniques
-based on TensorFlow and Keras, we have
-`sktime-dl <https://github.com/sktime/sktime-dl>`__. For smaller
+separate companion repository. For smaller
 dependencies which are limited to a few files, we encourage to use soft
 dependencies, which are only required for particular modules, but not
 for most of sktime’s functionality and not for installing sktime.
diff --git a/sktime/datasets/_data_io.py b/sktime/datasets/_data_io.py
index 4106cf731..dbe764114 100644
--- a/sktime/datasets/_data_io.py
+++ b/sktime/datasets/_data_io.py
@@ -1573,9 +1573,9 @@ def write_ndarray_to_tsfile(
     # write class label line
     if class_label is not None:
         space_separated_class_label = " ".join(str(label) for label in class_label)
-        file.write(f"@classLabel true {space_separated_class_label}\n")
+        file.write(f"@classlabel true {space_separated_class_label}\n")
     else:
-        file.write("@class_label false\n")
+        file.write("@classlabel false\n")
     # begin writing the core data for each case
     # which are the series and the class value list if there is any
     file.write("@data\n")
diff --git a/sktime/datatypes/_vectorize.py b/sktime/datatypes/_vectorize.py
index 9ad6d6a23..92c1111b4 100644
--- a/sktime/datatypes/_vectorize.py
+++ b/sktime/datatypes/_vectorize.py
@@ -383,5 +383,6 @@ def _enforce_index_freq(item: pd.Series) -> pd.Series:
         it will stay None
     """
     if hasattr(item.index, "freq") and item.index.freq is None:
-        item.index.freq = pd.infer_freq(item.index)
+        if len(item.index) > 2:  # pandas.infer_freq errors out for length 1 or 2
+            item.index.freq = pd.infer_freq(item.index)
     return item
diff --git a/sktime/distances/_distance.py b/sktime/distances/_distance.py
index e0b647335..f4724dae0 100644
--- a/sktime/distances/_distance.py
+++ b/sktime/distances/_distance.py
@@ -694,8 +694,11 @@ def dtw_distance(
 def msm_distance(
     x: np.ndarray,
     y: np.ndarray,
-    c: float = 0.0,
-    **kwargs: Any,
+    c: float = 1.0,
+    window: float = None,
+    itakura_max_slope: float = None,
+    bounding_matrix: np.ndarray = None,
+    **kwargs: dict,
 ) -> float:
     """Compute the move-split-merge distance.
 
@@ -713,8 +716,21 @@ def msm_distance(
         First time series.
     y: np.ndarray (1d or 2d array)
         Second time series.
-    kwargs: Any
-        Extra kwargs.
+    c: float, default = 1.0
+        Cost for split or merge operation.
+    window: Float, defaults = None
+        Float that is the radius of the sakoe chiba window (if using Sakoe-Chiba
+        lower bounding). Must be between 0 and 1.
+    itakura_max_slope: float, defaults = None
+        Gradient of the slope for itakura parallelogram (if using Itakura
+        Parallelogram lower bounding). Must be between 0 and 1.
+    bounding_matrix: np.ndarray (2d array of shape (m1,m2)), defaults = None
+        Custom bounding matrix to use. If defined then other lower_bounding params
+        are ignored. The matrix should be structure so that indexes considered in
+        bound should be the value 0. and indexes outside the bounding matrix should
+        be infinity.
+    kwargs: any
+        extra kwargs.
 
     Returns
     -------
@@ -731,7 +747,6 @@ def msm_distance(
         NumbaDistance.
         If a resolved metric is not no_python compiled.
         If the metric type cannot be determined
-
     References
     ----------
     .. [1]A.  Stefan,  V.  Athitsos,  and  G.  Das.   The  Move-Split-Merge  metric
@@ -740,6 +755,9 @@ def msm_distance(
     """
     format_kwargs = {
         "c": c,
+        "window": window,
+        "itakura_max_slope": itakura_max_slope,
+        "bounding_matrix": bounding_matrix,
     }
     format_kwargs = {**format_kwargs, **kwargs}
 
@@ -1601,8 +1619,11 @@ def msm_alignment_path(
     x: np.ndarray,
     y: np.ndarray,
     return_cost_matrix: bool = False,
-    c: float = 0.0,
-    **kwargs: Any,
+    c: float = 1.0,
+    window: float = None,
+    itakura_max_slope: float = None,
+    bounding_matrix: np.ndarray = None,
+    **kwargs: dict,
 ) -> AlignmentPathReturn:
     """Compute the move-split-merge alignment path.
 
@@ -1622,8 +1643,21 @@ def msm_alignment_path(
         Second time series.
     return_cost_matrix: bool, defaults = False
         Boolean that when true will also return the cost matrix.
-    kwargs: Any
-        Extra kwargs.
+    c: float, default = 1.0
+        Cost for split or merge operation.
+    window: float, defaults = None
+        Float that is the radius of the sakoe chiba window (if using Sakoe-Chiba
+        lower bounding). Must be between 0 and 1.
+    itakura_max_slope: float, defaults = None
+        Gradient of the slope for itakura parallelogram (if using Itakura
+        Parallelogram lower bounding). Must be between 0 and 1.
+    bounding_matrix: np.ndarray (2d array of shape (m1,m2)), defaults = None
+        Custom bounding matrix to use. If defined then other lower_bounding params
+        are ignored. The matrix should be structure so that indexes considered in
+        bound should be the value 0. and indexes outside the bounding matrix should
+        be infinity.
+    kwargs: any
+        extra kwargs.
 
     Returns
     -------
@@ -1645,7 +1679,6 @@ def msm_alignment_path(
         NumbaDistance.
         If a resolved metric is not no_python compiled.
         If the metric type cannot be determined
-
     References
     ----------
     .. [1]A.  Stefan,  V.  Athitsos,  and  G.  Das.   The  Move-Split-Merge  metric
@@ -1654,6 +1687,9 @@ def msm_alignment_path(
     """
     format_kwargs = {
         "c": c,
+        "window": window,
+        "itakura_max_slope": itakura_max_slope,
+        "bounding_matrix": bounding_matrix,
     }
     format_kwargs = {**format_kwargs, **kwargs}
 
diff --git a/sktime/distances/_msm.py b/sktime/distances/_msm.py
index b68e3f63c..d15126268 100644
--- a/sktime/distances/_msm.py
+++ b/sktime/distances/_msm.py
@@ -49,7 +49,7 @@ class _MsmDistance(NumbaDistance):
         x: np.ndarray,
         y: np.ndarray,
         return_cost_matrix: bool = False,
-        c: float = 0.0,
+        c: float = 1.0,
         window: float = None,
         itakura_max_slope: float = None,
         bounding_matrix: np.ndarray = None,
@@ -58,19 +58,31 @@ class _MsmDistance(NumbaDistance):
         """Create a no_python compiled MSM distance path callable.
 
         Series should be shape (1, m), where m is the series length. Series can be
-        different
-        lengths.
+        different lengths.
 
         Parameters
         ----------
-        x: np.ndarray (2d array of shape (1,m1)).
+        x: np.ndarray (2d array of shape (d,m1)).
             First time series.
-        y: np.ndarray (2d array of shape (1,m2)).
+        y: np.ndarray (2d array of shape (d,m2)).
             Second time series.
         return_cost_matrix: bool, defaults = False
             Boolean that when true will also return the cost matrix.
-        c: float
-            parameter used in MSM (update later!)
+        c: float, default = 1.0
+            Cost for split or merge operation.
+        window: float, defaults = None
+            Float that is the radius of the sakoe chiba window (if using Sakoe-Chiba
+            lower bounding). Must be between 0 and 1.
+        itakura_max_slope: float, defaults = None
+            Gradient of the slope for itakura parallelogram (if using Itakura
+            Parallelogram lower bounding). Must be between 0 and 1.
+        bounding_matrix: np.ndarray (2d array of shape (m1,m2)), defaults = None
+            Custom bounding matrix to use. If defined then other lower_bounding params
+            are ignored. The matrix should be structure so that indexes considered in
+            bound should be the value 0. and indexes outside the bounding matrix should
+            be infinity.
+        kwargs: any
+            extra kwargs.
 
         Returns
         -------
@@ -93,6 +105,7 @@ class _MsmDistance(NumbaDistance):
                 f"univariate series, passed seris shape {x.shape[0]} and"
                 f"shape {y.shape[0]}"
             )
+
         _bounding_matrix = resolve_bounding_matrix(
             x, y, window, itakura_max_slope, bounding_matrix
         )
@@ -104,7 +117,7 @@ class _MsmDistance(NumbaDistance):
                 _x: np.ndarray,
                 _y: np.ndarray,
             ) -> Tuple[List, float, np.ndarray]:
-                cost_matrix = _cost_matrix(_x, _y, c, _bounding_matrix)
+                cost_matrix = _cost_matrix(_x, _y, _bounding_matrix, c)
                 path = compute_min_return_path(cost_matrix, _bounding_matrix)
                 return path, cost_matrix[-1, -1], cost_matrix
 
@@ -115,7 +128,7 @@ class _MsmDistance(NumbaDistance):
                 _x: np.ndarray,
                 _y: np.ndarray,
             ) -> Tuple[List, float]:
-                cost_matrix = _cost_matrix(_x, _y, c, _bounding_matrix)
+                cost_matrix = _cost_matrix(_x, _y, _bounding_matrix, c)
                 path = compute_min_return_path(cost_matrix, _bounding_matrix)
                 return path, cost_matrix[-1, -1]
 
@@ -125,7 +138,7 @@ class _MsmDistance(NumbaDistance):
         self,
         x: np.ndarray,
         y: np.ndarray,
-        c: float = 0.0,
+        c: float = 1.0,
         window: float = None,
         itakura_max_slope: float = None,
         bounding_matrix: np.ndarray = None,
@@ -133,18 +146,30 @@ class _MsmDistance(NumbaDistance):
     ) -> DistanceCallable:
         """Create a no_python compiled MSM distance callable.
 
-        Series should be shape (1, m), where m is the series length. Series can be
-        different
-        lengths.
+        Series should be shape (d, m) where m is the series length. Series can be
+        different lengths.
 
         Parameters
         ----------
-        x: np.ndarray (2d array of shape (1,m1)).
+        x: np.ndarray (2d array of shape (d,m1)).
             First time series.
-        y: np.ndarray (2d array of shape (1,m2)).
+        y: np.ndarray (2d array of shape (d,m2)).
             Second time series.
-        c: float
-            parameter used in MSM (update later!)
+        c: float, default = 1.0
+            Cost for split or merge operation.
+        window: float, defaults = None
+            Float that is the radius of the sakoe chiba window (if using Sakoe-Chiba
+            lower bounding). Must be between 0 and 1.
+        itakura_max_slope: float, defaults = None
+            Gradient of the slope for itakura parallelogram (if using Itakura
+            Parallelogram lower bounding). Must be between 0 and 1.
+        bounding_matrix: np.ndarray (2d array of shape (m1,m2)), defaults = None
+            Custom bounding matrix to use. If defined then other lower_bounding params
+            are ignored. The matrix should be structure so that indexes considered in
+            bound should be the value 0. and indexes outside the bounding matrix should
+            be infinity.
+        kwargs: any
+            extra kwargs.
 
         Returns
         -------
@@ -176,69 +201,44 @@ class _MsmDistance(NumbaDistance):
             _x: np.ndarray,
             _y: np.ndarray,
         ) -> float:
-            cost_matrix = _cost_matrix(_x, _y, c, _bounding_matrix)
+            cost_matrix = _cost_matrix(_x, _y, _bounding_matrix, c)
             return cost_matrix[-1, -1]
 
         return numba_msm_distance
 
 
-@njit(cache=True, fastmath=True)
-def _dimension_sum(x: np.ndarray, j: int):
-    total = 0
-    for i in range(x.shape[0]):
-        total += x[i][j]
-
-    return total
-
-
 @njit(cache=True)
-def _calc_cost_cell(
-    new_point: np.ndarray,
-    x: np.ndarray,
-    y: np.ndarray,
-    c: float,
-) -> float:
-    """Cost calculation function for MSM."""
-    new_point_sum = _dimension_sum(new_point)
-    x_sum = _dimension_sum(x)
-    y_sum = _dimension_sum(y)
-
-    if ((x_sum <= new_point_sum) and (new_point_sum <= y_sum)) or (
-        (y_sum <= new_point_sum) and new_point_sum <= x_sum
-    ):
-        return c
-    else:
-        a = np.abs(new_point_sum - x_sum)
-        b = np.abs(new_point_sum - y_sum)
-
-        if a < b:
-            return c + a
-        else:
-            return c + b
-        # return c + np.min([np.abs(new_point - x), np.abs(new_point - y)])
+def _cost_function(x: float, y: float, z: float, c: float) -> float:
+    """Compute the cost function for the MSM algorithm.
 
+    Parameters
+    ----------
+    x: float
+        First value.
+    y: float
+        Second value.
+    z: float
+        Third value.
+    c: float
+        Parameter used in MSM (update later!)
 
-@njit(cache=True)
-def _cost_function(x: float, y: float, z: float, c: float) -> float:
-    if (y <= x and x <= z) or (y >= x and x >= z):
+    Returns
+    -------
+    float
+        Cost function value.
+    """
+    if (y <= x <= z) or (y >= x >= z):
         return c
-    a = x - y
-    if a < 0:
-        a = -a
-    b = x - z
-    if b < 0:
-        b = -b
-    if a > b:
-        return c + b
-    return c + a
+
+    return c + min(abs(x - y), abs(x - z))
 
 
 @njit(cache=True)
 def _cost_matrix(
     x: np.ndarray,
     y: np.ndarray,
-    c: float,
     bounding_matrix: np.ndarray,
+    c: float,
 ) -> float:
     """MSM distance compiled to no_python.
 
@@ -276,16 +276,9 @@ def _cost_matrix(
     for i in range(1, x_size):
         for j in range(1, y_size):
             if np.isfinite(bounding_matrix[i, j]):
-                d1 = cost[i - 1][j - 1] + np.abs(x[0][i] - y[0][j])
-                d2 = cost[i - 1][j] + _cost_function(x[0][i], x[0][i - 1], y[0][j], c)
-                d3 = cost[i][j - 1] + _cost_function(y[0][j], x[0][i], y[0][j - 1], c)
-
-            temp = d1
-            if d2 < temp:
-                temp = d2
-            if d3 < temp:
-                temp = d3
-
-            cost[i][j] = temp
+                d1 = cost[i - 1, j - 1] + np.abs(x[0][i] - y[0][j])
+                d2 = cost[i - 1, j] + _cost_function(x[0][i], x[0][i - 1], y[0][j], c)
+                d3 = cost[i, j - 1] + _cost_function(y[0][j], x[0][i], y[0][j - 1], c)
+                cost[i][j] = min(d1, d2, d3)
 
     return cost[0:, 0:]
diff --git a/sktime/dists_kernels/_base.py b/sktime/dists_kernels/_base.py
index 3087f4c05..f1222c9fe 100644
--- a/sktime/dists_kernels/_base.py
+++ b/sktime/dists_kernels/_base.py
@@ -187,7 +187,7 @@ class BasePairwiseTransformerPanel(BaseEstimator):
         "fit_is_empty": True,  # is "fit" empty? Yes, for all pairwise transforms
         "capability:missing_values": True,  # can estimator handle missing data?
         "capability:multivariate": True,  # can estimator handle multivariate data?
-        "capability:unequal_length": False,  # can dist handle unequal length panels?
+        "capability:unequal_length": True,  # can dist handle unequal length panels?
     }
 
     def __init__(self):
diff --git a/sktime/dists_kernels/dummy.py b/sktime/dists_kernels/dummy.py
new file mode 100644
index 000000000..f004646b0
--- /dev/null
+++ b/sktime/dists_kernels/dummy.py
@@ -0,0 +1,86 @@
+# -*- coding: utf-8 -*-
+"""Dummy distances/kernels."""
+
+__author__ = ["fkiraly"]
+
+import numpy as np
+
+from sktime.dists_kernels._base import BasePairwiseTransformerPanel
+
+SUPPORTED_MTYPES = ["df-list", "nested_univ", "numpy3D"]
+
+
+class ConstantPwTrafoPanel(BasePairwiseTransformerPanel):
+    """Pairwise panel transformer that always returns a constant value.
+
+    Parameters
+    ----------
+    constant : float, optional, default = 0
+        the constant value that this transformer returns
+    """
+
+    _tags = {
+        "X_inner_mtype": SUPPORTED_MTYPES,
+        "capability:missing_values": True,  # can estimator handle missing data?
+        "capability:multivariate": True,  # can estimator handle multivariate data?
+        "capability:unequal_length": True,  # can dist handle unequal length panels?
+    }
+
+    def __init__(self, constant=0):
+
+        self.constant = constant
+
+        super(ConstantPwTrafoPanel, self).__init__()
+
+    def _transform(self, X, X2=None):
+        """Compute distance/kernel matrix.
+
+        private _transform containing core logic, called from public transform
+
+        Parameters
+        ----------
+        X: sktime Panel data container
+        X2: sktime Panel data container
+
+        Returns
+        -------
+        distmat: np.array of shape [n, m]
+            (i,j)-th entry contains distance/kernel between X.iloc[i] and X2.iloc[j]
+        """
+        # get metadata from self - this is not always len(X) etc
+        #   but for the mtypes allowed, it is
+        # todo: conversion is wasteful, replace this perhaps by base class functionality
+        #   we check the input type anyway, so we could store n_instances
+        #   (but the problem there is, we should not write to self in transform)
+        n = len(X)
+        if X2 is not None:
+            m = len(X2)
+        else:
+            m = n
+
+        distmat = self.constant * np.ones((n, m))
+
+        return distmat
+
+    @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.
+
+        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`.
+        """
+        params1 = {}
+        params2 = {"constant": 42}
+
+        return [params1, params2]
diff --git a/sktime/forecasting/base/adapters/_statsmodels.py b/sktime/forecasting/base/adapters/_statsmodels.py
index c6ff7c0d9..5084c1ee8 100644
--- a/sktime/forecasting/base/adapters/_statsmodels.py
+++ b/sktime/forecasting/base/adapters/_statsmodels.py
@@ -85,7 +85,11 @@ class _StatsModelsAdapter(BaseForecaster):
 
         # statsmodels forecasts all periods from start to end of forecasting
         # horizon, but only return given time points in forecasting horizon
-        return y_pred.loc[fh.to_absolute(self.cutoff).to_pandas()]
+        y_pred = y_pred.loc[fh.to_absolute(self.cutoff).to_pandas()]
+        # ensure that name is not added nor removed
+        # otherwise this may upset conversion to pd.DataFrame
+        y_pred.name = self._y.name
+        return y_pred
 
     def get_fitted_params(self):
         """Get fitted parameters.
diff --git a/sktime/forecasting/compose/_pipeline.py b/sktime/forecasting/compose/_pipeline.py
index 38cb78fa2..38f6a90f3 100644
--- a/sktime/forecasting/compose/_pipeline.py
+++ b/sktime/forecasting/compose/_pipeline.py
@@ -214,6 +214,7 @@ class _Pipeline(
         from sktime.forecasting.naive import NaiveForecaster
         from sktime.forecasting.sarimax import SARIMAX
         from sktime.transformations.series.adapt import TabularToSeriesAdaptor
+        from sktime.transformations.series.detrend import Detrender
         from sktime.transformations.series.exponent import ExponentTransformer
 
         # StandardScaler does not skip fit, NaiveForecaster is not probabilistic
@@ -230,7 +231,7 @@ class _Pipeline(
         ]
         params2 = {"steps": STEPS2}
 
-        params3 = {"steps": [ExponentTransformer(), SARIMAX()]}
+        params3 = {"steps": [Detrender(), SARIMAX()]}
 
         return [params1, params2, params3]
 
diff --git a/sktime/transformations/base.py b/sktime/transformations/base.py
index eb1bef898..7f3ae85af 100644
--- a/sktime/transformations/base.py
+++ b/sktime/transformations/base.py
@@ -278,6 +278,31 @@ class BaseTransformer(BaseEstimator):
         else:
             return NotImplemented
 
+    def __invert__(self):
+        """Magic unary ~ (inversion) method, return InvertTransform of self.
+
+        Returns
+        -------
+        `InvertTransform` object, containing `self`.
+        """
+        from sktime.transformations.compose import InvertTransform
+
+        return InvertTransform(self)
+
+    def __neg__(self):
+        """Magic unary - (negation) method, return OptionalPassthrough of self.
+
+        Intuition: `OptionalPassthrough` is "not having transformer", as an option.
+
+        Returns
+        -------
+        `OptionalPassthrough` object, containing `self`, with `passthrough=False`.
+            The `passthrough` parameter can be set via `set_params`.
+        """
+        from sktime.transformations.compose import OptionalPassthrough
+
+        return OptionalPassthrough(self, passthrough=False)
+
     def __getitem__(self, key):
         """Magic [...] method, return column subsetted transformer.
 
diff --git a/sktime/transformations/compose.py b/sktime/transformations/compose.py
index 93c412dab..ff561bb67 100644
--- a/sktime/transformations/compose.py
+++ b/sktime/transformations/compose.py
@@ -2,8 +2,11 @@
 """Meta-transformers for building composite transformers."""
 # copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
 
+from warnings import warn
+
 import pandas as pd
 from sklearn import clone
+from sklearn.utils.metaestimators import if_delegate_has_method
 
 from sktime.base import _HeterogenousMetaEstimator
 from sktime.transformations._delegate import _DelegatedTransformer
@@ -14,13 +17,33 @@ from sktime.utils.sklearn import (
     is_sklearn_regressor,
     is_sklearn_transformer,
 )
+from sktime.utils.validation.series import check_series
 
-__author__ = ["fkiraly", "mloning", "miraep8"]
+__author__ = ["fkiraly", "mloning", "miraep8", "aiwalter", "SveaMeyer13"]
 __all__ = [
-    "TransformerPipeline",
+    "ColumnwiseTransformer",
     "FeatureUnion",
     "FitInTransform",
+    "Id",
+    "InvertTransform",
     "MultiplexTransformer",
+    "OptionalPassthrough",
+    "TransformerPipeline",
+    "YtoX",
+]
+
+
+# mtypes for Series, Panel, Hierarchical,
+# with exception of some ambiguous and discouraged mtypes
+CORE_MTYPES = [
+    "pd.DataFrame",
+    "np.ndarray",
+    "pd.Series",
+    "pd-multiindex",
+    "df-list",
+    "nested_univ",
+    "numpy3D",
+    "pd_multiindex_hier",
 ]
 
 
@@ -128,16 +151,7 @@ class TransformerPipeline(BaseTransformer, _HeterogenousMetaEstimator):
 
     _tags = {
         # we let all X inputs through to be handled by first transformer
-        "X_inner_mtype": [
-            "pd.DataFrame",
-            "np.ndarray",
-            "pd.Series",
-            "pd-multiindex",
-            "df-list",
-            "nested_univ",
-            "numpy3D",
-            "pd_multiindex_hier",
-        ],
+        "X_inner_mtype": CORE_MTYPES,
         "univariate-only": False,
     }
 
@@ -1061,3 +1075,635 @@ class MultiplexTransformer(_DelegatedTransformer, _HeterogenousMetaEstimator):
             attr_name="forecasters",
             concat_order="right",
         )
+
+
+class InvertTransform(_DelegatedTransformer):
+    """Invert a series-to-series transformation.
+
+    Switches `transform` and `inverse_transform`, leaves `fit` and `update` the same.
+
+    Parameters
+    ----------
+    transformer : sktime transformer, must transform Series input to Series output
+        this is a "blueprint" transformer, state does not change when `fit` is called
+
+    Attributes
+    ----------
+    transformer_: transformer,
+        this clone is fitted when `fit` is called and provides `transform` and inverse
+
+    Examples
+    --------
+    >>> from sktime.datasets import load_airline
+    >>> from sktime.transformations.compose import InvertTransform
+    >>> from sktime.transformations.series.exponent import ExponentTransformer
+    >>>
+    >>> inverse_exponent = InvertTransform(ExponentTransformer(power=3))
+    >>> X = load_airline()
+    >>> Xt = inverse_exponent.fit_transform(X)  # computes 3rd square root
+    """
+
+    _tags = {
+        "scitype:transform-input": "Series",
+        # what is the scitype of X: Series, or Panel
+        "scitype:transform-output": "Series",
+        # what scitype is returned: Primitives, Series, Panel
+        "scitype:instancewise": True,  # is this an instance-wise transform?
+        "X_inner_mtype": ["pd.DataFrame", "pd.Series"],
+        # which mtypes do _fit/_predict support for X?
+        "y_inner_mtype": "None",  # which mtypes do _fit/_predict support for y?
+        "univariate-only": False,
+        "fit_is_empty": False,
+        "capability:inverse_transform": True,
+    }
+
+    def __init__(self, transformer):
+        self.transformer = transformer
+
+        super(InvertTransform, self).__init__()
+
+        self.transformer_ = transformer.clone()
+
+        # should be all tags, but not fit_is_empty
+        #   (_fit should not be skipped)
+        tags_to_clone = [
+            "scitype:transform-input",
+            "scitype:transform-output",
+            "scitype:instancewise",
+            "X_inner_mtype",
+            "y_inner_mtype",
+            "handles-missing-data",
+            "X-y-must-have-same-index",
+            "transform-returns-same-time-index",
+            "skip-inverse-transform",
+        ]
+        self.clone_tags(transformer, tag_names=tags_to_clone)
+
+        if not transformer.get_tag("capability:inverse_transform", False):
+            warn(
+                "transformer does not have capability to inverse transform, "
+                "according to capability:inverse_transform tag. "
+                "If the tag was correctly set, this transformer will likely crash"
+            )
+        inner_output = transformer.get_tag("scitype:transform-output")
+        if not transformer.get_tag("scitype:transform-output") != "Series":
+            warn(
+                f"transformer output is not Series but {inner_output}, "
+                "according to scitype:transform-output tag. "
+                "The InvertTransform wrapper supports only Series output, therefore"
+                " this transformer will likely crash on input."
+            )
+
+    _delegate_name = "transformer_"
+
+    def _transform(self, X, y=None):
+        """Transform X and return a transformed version.
+
+        private _transform containing the core logic, called from transform
+
+        Returns a transformed version of X by iterating over specified
+        columns and applying the wrapped transformer to them.
+
+        Parameters
+        ----------
+        X : sktime compatible time series container
+            Data to be transformed
+        y : Series or Panel, default=None
+            Additional data, e.g., labels for transformation
+
+        Returns
+        -------
+        Xt : sktime compatible time series container
+            transformed version of X
+        """
+        return self.transformer_.inverse_transform(X=X, y=y)
+
+    def _inverse_transform(self, X, y=None):
+        """Logic used by `inverse_transform` to reverse transformation on `X`.
+
+        Returns an inverse-transformed version of X by iterating over specified
+        columns and applying the univariate series transformer to them.
+
+        Only works if `self.transformer` has an `inverse_transform` method.
+
+        Parameters
+        ----------
+        X : sktime compatible time series container
+            Data to be inverse transformed
+        y : Series or Panel, default=None
+            Additional data, e.g., labels for transformation
+
+        Returns
+        -------
+        Xt : sktime compatible time series container
+            inverse transformed version of X
+        """
+        return self.transformer_.transform(X=X, y=y)
+
+    @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.
+
+        Returns
+        -------
+        params : dict or list of dict, default = {}
+            Parameters to create testing instances of the class
+            Each dict are parameters to construct an "interesting" test instance, i.e.,
+            `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
+            `create_test_instance` uses the first (or only) dictionary in `params`
+        """
+        from sktime.transformations.series.boxcox import BoxCoxTransformer
+        from sktime.transformations.series.exponent import ExponentTransformer
+
+        # ExponentTransformer skips fit
+        params1 = {"transformer": ExponentTransformer()}
+        # BoxCoxTransformer has fit
+        params2 = {"transformer": BoxCoxTransformer()}
+
+        return [params1, params2]
+
+
+class Id(_DelegatedTransformer):
+    """Identity transformer, returns data unchanged in transform/inverse_transform."""
+
+    _tags = {
+        "capability:inverse_transform": True,  # can the transformer inverse transform?
+        "univariate-only": False,  # can the transformer handle multivariate X?
+        "X_inner_mtype": CORE_MTYPES,  # which mtypes do _fit/_predict support for X?
+        # this can be a Panel mtype even if transform-input is Series, vectorized
+        "y_inner_mtype": "None",  # which mtypes do _fit/_predict support for y?
+        "fit_is_empty": True,  # is fit empty and can be skipped? Yes = True
+        "transform-returns-same-time-index": True,
+        # does transform return have the same time index as input X
+        "handles-missing-data": True,  # can estimator handle missing data?
+    }
+
+    def _transform(self, X, y=None):
+        """Transform X and return a transformed version.
+
+        private _transform containing the core logic, called from transform
+
+        Parameters
+        ----------
+        X : any sktime compatible data, Series, Panel, or Hierarchical
+        y : optional, default=None
+            ignored, argument present for interface conformance
+
+        Returns
+        -------
+        X, identical to input
+        """
+        return X
+
+    def _inverse_transform(self, X, y=None):
+        """Inverse transform X and return an inverse transformed version.
+
+        private _inverse_transform containing core logic, called from inverse_transform
+
+        Parameters
+        ----------
+        X : any sktime compatible data, Series, Panel, or Hierarchical
+        y : optional, default=None
+            ignored, argument present for interface conformance
+
+        Returns
+        -------
+        X, identical to input
+        """
+        return X
+
+
+class OptionalPassthrough(_DelegatedTransformer):
+    """Wrap an existing transformer to tune whether to include it in a pipeline.
+
+    Allows tuning the implicit hyperparameter whether or not to use a
+    particular transformer inside a pipeline (e.g. TransformedTargetForecaster)
+    or not. This is achieved by the hyperparameter `passthrough`
+    which can be added to a tuning grid then (see example).
+
+    Parameters
+    ----------
+    transformer : Estimator
+        scikit-learn-like or sktime-like transformer to fit and apply to series.
+        this is a "blueprint" transformer, state does not change when `fit` is called
+    passthrough : bool, default=False
+       Whether to apply the given transformer or to just
+        passthrough the data (identity transformation). If, True the transformer
+        is not applied and the OptionalPassthrough uses the identity
+        transformation.
+
+    Attributes
+    ----------
+    transformer_: transformer,
+        this clone is fitted when `fit` is called and provides `transform` and inverse
+        if passthrough = False, a clone of `transformer`passed
+        if passthrough = True, the identity transformer `Id`
+
+    Examples
+    --------
+    >>> from sktime.datasets import load_airline
+    >>> from sktime.forecasting.naive import NaiveForecaster
+    >>> from sktime.transformations.series.compose import OptionalPassthrough
+    >>> from sktime.transformations.series.detrend import Deseasonalizer
+    >>> from sktime.transformations.series.adapt import TabularToSeriesAdaptor
+    >>> from sktime.forecasting.compose import TransformedTargetForecaster
+    >>> from sktime.forecasting.model_selection import (
+    ...     ForecastingGridSearchCV,
+    ...     SlidingWindowSplitter)
+    >>> from sklearn.preprocessing import StandardScaler
+    >>> # create pipeline
+    >>> pipe = TransformedTargetForecaster(steps=[
+    ...     ("deseasonalizer", OptionalPassthrough(Deseasonalizer())),
+    ...     ("scaler", OptionalPassthrough(TabularToSeriesAdaptor(StandardScaler()))),
+    ...     ("forecaster", NaiveForecaster())])
+    >>> # putting it all together in a grid search
+    >>> cv = SlidingWindowSplitter(
+    ...     initial_window=60,
+    ...     window_length=24,
+    ...     start_with_window=True,
+    ...     step_length=48)
+    >>> param_grid = {
+    ...     "deseasonalizer__passthrough" : [True, False],
+    ...     "scaler__transformer__transformer__with_mean": [True, False],
+    ...     "scaler__passthrough" : [True, False],
+    ...     "forecaster__strategy": ["drift", "mean", "last"]}
+    >>> gscv = ForecastingGridSearchCV(
+    ...     forecaster=pipe,
+    ...     param_grid=param_grid,
+    ...     cv=cv,
+    ...     n_jobs=-1)
+    >>> gscv_fitted = gscv.fit(load_airline())
+    """
+
+    _tags = {
+        "scitype:transform-input": "Series",
+        # what is the scitype of X: Series, or Panel
+        "scitype:transform-output": "Series",
+        # what scitype is returned: Primitives, Series, Panel
+        "scitype:instancewise": True,  # is this an instance-wise transform?
+        "X_inner_mtype": CORE_MTYPES,
+        # which mtypes do _fit/_predict support for X?
+        "y_inner_mtype": "None",  # which mtypes do _fit/_predict support for y?
+        "univariate-only": False,
+        "fit_is_empty": False,
+        "capability:inverse_transform": True,
+    }
+
+    def __init__(self, transformer, passthrough=False):
+        self.transformer = transformer
+        self.passthrough = passthrough
+
+        super(OptionalPassthrough, self).__init__()
+
+        # should be all tags, but not fit_is_empty
+        #   (_fit should not be skipped)
+        tags_to_clone = [
+            "scitype:transform-input",
+            "scitype:transform-output",
+            "scitype:instancewise",
+            "X_inner_mtype",
+            "y_inner_mtype",
+            "capability:inverse_transform",
+            "handles-missing-data",
+            "X-y-must-have-same-index",
+            "transform-returns-same-time-index",
+            "skip-inverse-transform",
+        ]
+        self.clone_tags(transformer, tag_names=tags_to_clone)
+
+        if passthrough:
+            self.transformer_ = Id()
+        else:
+            self.transformer_ = transformer.clone()
+
+    _delegate_name = "transformer_"
+
+    @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.
+
+
+        Returns
+        -------
+        params : dict or list of dict, default = {}
+            Parameters to create testing instances of the class
+            Each dict are parameters to construct an "interesting" test instance, i.e.,
+            `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
+            `create_test_instance` uses the first (or only) dictionary in `params`
+        """
+        from sktime.transformations.series.boxcox import BoxCoxTransformer
+
+        return {"transformer": BoxCoxTransformer(), "passthrough": False}
+
+
+class ColumnwiseTransformer(BaseTransformer):
+    """Apply a transformer columnwise to multivariate series.
+
+    Overview: input multivariate time series and the transformer passed
+    in `transformer` parameter is applied to specified `columns`, each
+    column is handled as a univariate series. The resulting transformed
+    data has the same shape as input data.
+
+    Parameters
+    ----------
+    transformer : Estimator
+        scikit-learn-like or sktime-like transformer to fit and apply to series.
+    columns : list of str or None
+            Names of columns that are supposed to be transformed.
+            If None, all columns are transformed.
+
+    Attributes
+    ----------
+    transformers_ : dict of {str : transformer}
+        Maps columns to transformers.
+    columns_ : list of str
+        Names of columns that are supposed to be transformed.
+
+    See Also
+    --------
+    OptionalPassthrough
+
+    Examples
+    --------
+    >>> from sktime.datasets import load_longley
+    >>> from sktime.transformations.series.detrend import Detrender
+    >>> from sktime.transformations.series.compose import ColumnwiseTransformer
+    >>> _, X = load_longley()
+    >>> transformer = ColumnwiseTransformer(Detrender())
+    >>> Xt = transformer.fit_transform(X)
+    """
+
+    _tags = {
+        "scitype:transform-input": "Series",
+        # what is the scitype of X: Series, or Panel
+        "scitype:transform-output": "Series",
+        # what scitype is returned: Primitives, Series, Panel
+        "scitype:instancewise": True,  # is this an instance-wise transform?
+        "X_inner_mtype": "pd.DataFrame",
+        # which mtypes do _fit/_predict support for X?
+        "y_inner_mtype": "None",  # which mtypes do _fit/_predict support for y?
+        "univariate-only": False,
+        "fit_is_empty": False,
+    }
+
+    def __init__(self, transformer, columns=None):
+        self.transformer = transformer
+        self.columns = columns
+        super(ColumnwiseTransformer, self).__init__()
+
+        tags_to_clone = [
+            "y_inner_mtype",
+            "capability:inverse_transform",
+            "handles-missing-data",
+            "X-y-must-have-same-index",
+            "transform-returns-same-time-index",
+            "skip-inverse-transform",
+        ]
+        self.clone_tags(transformer, tag_names=tags_to_clone)
+
+    def _fit(self, X, y=None):
+        """Fit transformer to X and y.
+
+        private _fit containing the core logic, called from fit
+
+        Parameters
+        ----------
+        X : pd.DataFrame
+            Data to fit transform to
+        y : Series or Panel, default=None
+            Additional data, e.g., labels for transformation
+
+        Returns
+        -------
+        self: a fitted instance of the estimator
+        """
+        # check that columns are None or list of strings
+        if self.columns is not None:
+            if not isinstance(self.columns, list) and all(
+                isinstance(s, str) for s in self.columns
+            ):
+                raise ValueError("Columns need to be a list of strings or None.")
+
+        # set self.columns_ to columns that are going to be transformed
+        # (all if self.columns is None)
+        self.columns_ = self.columns
+        if self.columns_ is None:
+            self.columns_ = X.columns
+
+        # make sure z contains all columns that the user wants to transform
+        _check_columns(X, selected_columns=self.columns_)
+
+        # fit by iterating over columns
+        self.transformers_ = {}
+        for colname in self.columns_:
+            transformer = self.transformer.clone()
+            self.transformers_[colname] = transformer
+            self.transformers_[colname].fit(X[colname], y)
+        return self
+
+    def _transform(self, X, y=None):
+        """Transform X and return a transformed version.
+
+        private _transform containing the core logic, called from transform
+
+        Returns a transformed version of X by iterating over specified
+        columns and applying the wrapped transformer to them.
+
+        Parameters
+        ----------
+        X : pd.DataFrame
+            Data to be transformed
+        y : Series or Panel, default=None
+            Additional data, e.g., labels for transformation
+
+        Returns
+        -------
+        Xt : pd.DataFrame
+            transformed version of X
+        """
+        # make copy of z
+        X = X.copy()
+
+        # make sure z contains all columns that the user wants to transform
+        _check_columns(X, selected_columns=self.columns_)
+        for colname in self.columns_:
+            X[colname] = self.transformers_[colname].transform(X[colname], y)
+        return X
+
+    def _inverse_transform(self, X, y=None):
+        """Logic used by `inverse_transform` to reverse transformation on `X`.
+
+        Returns an inverse-transformed version of X by iterating over specified
+        columns and applying the univariate series transformer to them.
+        Only works if `self.transformer` has an `inverse_transform` method.
+
+        Parameters
+        ----------
+        X : pd.DataFrame
+            Data to be inverse transformed
+        y : Series or Panel, default=None
+            Additional data, e.g., labels for transformation
+
+        Returns
+        -------
+        Xt : pd.DataFrame
+            inverse transformed version of X
+        """
+        # make copy of z
+        X = X.copy()
+
+        # make sure z contains all columns that the user wants to transform
+        _check_columns(X, selected_columns=self.columns_)
+
+        # iterate over columns that are supposed to be inverse_transformed
+        for colname in self.columns_:
+            X[colname] = self.transformers_[colname].inverse_transform(X[colname], y)
+
+        return X
+
+    @if_delegate_has_method(delegate="transformer")
+    def update(self, X, y=None, update_params=True):
+        """Update parameters.
+
+        Update the parameters of the estimator with new data
+        by iterating over specified columns.
+        Only works if `self.transformer` has an `update` method.
+
+        Parameters
+        ----------
+        X : pd.Series
+            New time series.
+        update_params : bool, optional, default=True
+
+        Returns
+        -------
+        self : an instance of self
+        """
+        z = check_series(X)
+
+        # make z a pd.DataFrame in univariate case
+        if isinstance(z, pd.Series):
+            z = z.to_frame()
+
+        # make sure z contains all columns that the user wants to transform
+        _check_columns(z, selected_columns=self.columns_)
+        for colname in self.columns_:
+            self.transformers_[colname].update(z[colname], X)
+        return self
+
+    @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.
+
+
+        Returns
+        -------
+        params : dict or list of dict, default = {}
+            Parameters to create testing instances of the class
+            Each dict are parameters to construct an "interesting" test instance, i.e.,
+            `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
+            `create_test_instance` uses the first (or only) dictionary in `params`
+        """
+        from sktime.transformations.series.detrend import Detrender
+
+        return {"transformer": Detrender()}
+
+
+def _check_columns(z, selected_columns):
+    # make sure z contains all columns that the user wants to transform
+    z_wanted_keys = set(selected_columns)
+    z_new_keys = set(z.columns)
+    difference = z_wanted_keys.difference(z_new_keys)
+    if len(difference) != 0:
+        raise ValueError("Missing columns" + str(difference) + "in Z.")
+
+
+def _check_is_pdseries(z):
+    # make z a pd.Dataframe in univariate case
+    is_series = False
+    if isinstance(z, pd.Series):
+        z = z.to_frame()
+        is_series = True
+    return z, is_series
+
+
+class YtoX(BaseTransformer):
+    """Create exogeneous features which are a copy of the endogenous data.
+
+    Replaces exogeneous features (`X`) by endogeneous data (`y`).
+
+    To *add* instead of *replace*, use `FeatureUnion`.
+
+    Parameters
+    ----------
+    no parameters
+    """
+
+    _tags = {
+        "transform-returns-same-time-index": True,
+        "skip-inverse-transform": False,
+        "univariate-only": False,
+        "X_inner_mtype": ["pd.DataFrame", "pd-multiindex", "pd_multiindex_hier"],
+        "y_inner_mtype": ["pd.DataFrame", "pd-multiindex", "pd_multiindex_hier"],
+        "scitype:y": "both",
+        "fit_is_empty": True,
+        "requires_y": True,
+    }
+
+    def __init__(self):
+        super(YtoX, self).__init__()
+
+    def _transform(self, X, y=None):
+        """Transform X and return a transformed version.
+
+        private _transform containing core logic, called from transform
+
+        Parameters
+        ----------
+        X : time series or panel in one of the pd.DataFrame formats
+            Data to be transformed
+        y : time series or panel in one of the pd.DataFrame formats
+            Additional data, e.g., labels for transformation
+
+        Returns
+        -------
+        y, as a transformed version of X
+        """
+        return y
+
+    def _inverse_transform(self, X, y=None):
+        """Inverse transform, inverse operation to transform.
+
+        Drops featurized column that was added in transform().
+
+        Parameters
+        ----------
+        X : Series or Panel of mtype X_inner_mtype
+            if X_inner_mtype is list, _inverse_transform must support all types in it
+            Data to be inverse transformed
+        y : Series or Panel of mtype y_inner_mtype, optional (default=None)
+            Additional data, e.g., labels for transformation
+
+        Returns
+        -------
+        inverse transformed version of X
+        """
+        return y
diff --git a/sktime/transformations/series/compose.py b/sktime/transformations/series/compose.py
index 58fc0fe71..8238efc48 100644
--- a/sktime/transformations/series/compose.py
+++ b/sktime/transformations/series/compose.py
@@ -6,488 +6,17 @@
 __author__ = ["aiwalter", "SveaMeyer13", "fkiraly"]
 __all__ = ["OptionalPassthrough", "ColumnwiseTransformer", "YtoX"]
 
-import pandas as pd
-from sklearn.utils.metaestimators import if_delegate_has_method
-
-from sktime.transformations.base import BaseTransformer
-from sktime.utils.validation.series import check_series
-
-
-class OptionalPassthrough(BaseTransformer):
-    """Wrap an existing transformer to tune whether to include it in a pipeline.
-
-    Allows tuning the implicit hyperparameter whether or not to use a
-    particular transformer inside a pipeline (e.g. TranformedTargetForecaster)
-    or not. This is achieved by the hyperparameter `passthrough`
-    which can be added to a tuning grid then (see example).
-
-    Parameters
-    ----------
-    transformer : Estimator
-        scikit-learn-like or sktime-like transformer to fit and apply to series.
-    passthrough : bool, default=False
-       Whether to apply the given transformer or to just
-        passthrough the data (identity transformation). If, True the transformer
-        is not applied and the OptionalPassthrough uses the identity
-        transformation.
-
-    Examples
-    --------
-    >>> from sktime.datasets import load_airline
-    >>> from sktime.forecasting.naive import NaiveForecaster
-    >>> from sktime.transformations.series.compose import OptionalPassthrough
-    >>> from sktime.transformations.series.detrend import Deseasonalizer
-    >>> from sktime.transformations.series.adapt import TabularToSeriesAdaptor
-    >>> from sktime.forecasting.compose import TransformedTargetForecaster
-    >>> from sktime.forecasting.model_selection import (
-    ...     ForecastingGridSearchCV,
-    ...     SlidingWindowSplitter)
-    >>> from sklearn.preprocessing import StandardScaler
-    >>> # create pipeline
-    >>> pipe = TransformedTargetForecaster(steps=[
-    ...     ("deseasonalizer", OptionalPassthrough(Deseasonalizer())),
-    ...     ("scaler", OptionalPassthrough(TabularToSeriesAdaptor(StandardScaler()))),
-    ...     ("forecaster", NaiveForecaster())])
-    >>> # putting it all together in a grid search
-    >>> cv = SlidingWindowSplitter(
-    ...     initial_window=60,
-    ...     window_length=24,
-    ...     start_with_window=True,
-    ...     step_length=48)
-    >>> param_grid = {
-    ...     "deseasonalizer__passthrough" : [True, False],
-    ...     "scaler__transformer__transformer__with_mean": [True, False],
-    ...     "scaler__passthrough" : [True, False],
-    ...     "forecaster__strategy": ["drift", "mean", "last"]}
-    >>> gscv = ForecastingGridSearchCV(
-    ...     forecaster=pipe,
-    ...     param_grid=param_grid,
-    ...     cv=cv,
-    ...     n_jobs=-1)
-    >>> gscv_fitted = gscv.fit(load_airline())
-    """
-
-    _tags = {
-        "scitype:transform-input": "Series",
-        # what is the scitype of X: Series, or Panel
-        "scitype:transform-output": "Series",
-        # what scitype is returned: Primitives, Series, Panel
-        "scitype:instancewise": True,  # is this an instance-wise transform?
-        "X_inner_mtype": ["pd.DataFrame", "pd.Series"],
-        # which mtypes do _fit/_predict support for X?
-        "y_inner_mtype": "None",  # which mtypes do _fit/_predict support for y?
-        "univariate-only": False,
-        "fit_is_empty": False,
-        "capability:inverse_transform": True,
-    }
-
-    def __init__(self, transformer, passthrough=False):
-        self.transformer = transformer
-        self.transformer_ = None
-        self.passthrough = passthrough
-        self._is_fitted = False
-        super(OptionalPassthrough, self).__init__()
-
-        # should be all tags, but not fit_is_empty
-        #   (_fit should not be skipped)
-        tags_to_clone = [
-            "scitype:transform-input",
-            "scitype:transform-output",
-            "scitype:instancewise",
-            "X_inner_mtype",
-            "y_inner_mtype",
-            "capability:inverse_transform",
-            "handles-missing-data",
-            "X-y-must-have-same-index",
-            "transform-returns-same-time-index",
-            "skip-inverse-transform",
-        ]
-        self.clone_tags(transformer, tag_names=tags_to_clone)
-
-    def _fit(self, X, y=None):
-        """Fit transformer to X and y.
-
-        private _fit containing the core logic, called from fit
-
-        Parameters
-        ----------
-        X : Series or Panel of mtype X_inner_mtype
-            if X_inner_mtype is list, _fit must support all types in it
-            Data to fit transform to
-        y : Series or Panel of mtype y_inner_mtype, default=None
-            Additional data, e.g., labels for tarnsformation
-
-        Returns
-        -------
-        self: a fitted instance of the estimator
-        """
-        if not self.passthrough:
-            self.transformer_ = self.transformer.clone()
-            self.transformer_._fit(X, y)
-        return self
-
-    def _transform(self, X, y=None):
-        """Transform X and return a transformed version.
-
-        private _transform containing the core logic, called from transform
-
-        Parameters
-        ----------
-        X : Series or Panel of mtype X_inner_mtype
-            if X_inner_mtype is list, _transform must support all types in it
-            Data to be transformed
-        y : Series or Panel of mtype y_inner_mtype, default=None
-            Additional data, e.g., labels for transformation
-
-        Returns
-        -------
-        transformed version of X
-        """
-        if not self.passthrough:
-            X = self.transformer_._transform(X, y)
-        return X
-
-    def _inverse_transform(self, X, y=None):
-        """Inverse transform, inverse operation to transform.
-
-        core logic
-
-        Parameters
-        ----------
-        X : Series or Panel of mtype X_inner_mtype
-            if X_inner_mtype is list, _inverse_transform must support all types in it
-            Data to be inverse transformed
-        y : Series or Panel of mtype y_inner_mtype, optional (default=None)
-            Additional data, e.g., labels for transformation
-
-        Returns
-        -------
-        inverse transformed version of X
-        """
-        if not self.passthrough:
-            X = self.transformer_._inverse_transform(X, y)
-        return X
-
-    @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.
-
-
-        Returns
-        -------
-        params : dict or list of dict, default = {}
-            Parameters to create testing instances of the class
-            Each dict are parameters to construct an "interesting" test instance, i.e.,
-            `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
-            `create_test_instance` uses the first (or only) dictionary in `params`
-        """
-        from sktime.transformations.series.boxcox import BoxCoxTransformer
-
-        return {"transformer": BoxCoxTransformer(), "passthrough": False}
-
-
-class ColumnwiseTransformer(BaseTransformer):
-    """Apply a transformer columnwise to multivariate series.
-
-    Overview: input multivariate time series and the transformer passed
-    in `transformer` parameter is applied to specified `columns`, each
-    column is handled as a univariate series. The resulting transformed
-    data has the same shape as input data.
-
-    Parameters
-    ----------
-    transformer : Estimator
-        scikit-learn-like or sktime-like transformer to fit and apply to series.
-    columns : list of str or None
-            Names of columns that are supposed to be transformed.
-            If None, all columns are transformed.
-
-    Attributes
-    ----------
-    transformers_ : dict of {str : transformer}
-        Maps columns to transformers.
-    columns_ : list of str
-        Names of columns that are supposed to be transformed.
-
-    See Also
-    --------
-    OptionalPassthrough
-
-    Examples
-    --------
-    >>> from sktime.datasets import load_longley
-    >>> from sktime.transformations.series.detrend import Detrender
-    >>> from sktime.transformations.series.compose import ColumnwiseTransformer
-    >>> _, X = load_longley()
-    >>> transformer = ColumnwiseTransformer(Detrender())
-    >>> Xt = transformer.fit_transform(X)
-    """
-
-    _tags = {
-        "scitype:transform-input": "Series",
-        # what is the scitype of X: Series, or Panel
-        "scitype:transform-output": "Series",
-        # what scitype is returned: Primitives, Series, Panel
-        "scitype:instancewise": True,  # is this an instance-wise transform?
-        "X_inner_mtype": "pd.DataFrame",
-        # which mtypes do _fit/_predict support for X?
-        "y_inner_mtype": "None",  # which mtypes do _fit/_predict support for y?
-        "univariate-only": False,
-        "fit_is_empty": False,
-    }
-
-    def __init__(self, transformer, columns=None):
-        self.transformer = transformer
-        self.columns = columns
-        super(ColumnwiseTransformer, self).__init__()
-
-        tags_to_clone = [
-            "y_inner_mtype",
-            "capability:inverse_transform",
-            "handles-missing-data",
-            "X-y-must-have-same-index",
-            "transform-returns-same-time-index",
-            "skip-inverse-transform",
-        ]
-        self.clone_tags(transformer, tag_names=tags_to_clone)
-
-    def _fit(self, X, y=None):
-        """Fit transformer to X and y.
-
-        private _fit containing the core logic, called from fit
-
-        Parameters
-        ----------
-        X : pd.DataFrame
-            Data to fit transform to
-        y : Series or Panel, default=None
-            Additional data, e.g., labels for transformation
-
-        Returns
-        -------
-        self: a fitted instance of the estimator
-        """
-        # check that columns are None or list of strings
-        if self.columns is not None:
-            if not isinstance(self.columns, list) and all(
-                isinstance(s, str) for s in self.columns
-            ):
-                raise ValueError("Columns need to be a list of strings or None.")
-
-        # set self.columns_ to columns that are going to be transformed
-        # (all if self.columns is None)
-        self.columns_ = self.columns
-        if self.columns_ is None:
-            self.columns_ = X.columns
-
-        # make sure z contains all columns that the user wants to transform
-        _check_columns(X, selected_columns=self.columns_)
-
-        # fit by iterating over columns
-        self.transformers_ = {}
-        for colname in self.columns_:
-            transformer = self.transformer.clone()
-            self.transformers_[colname] = transformer
-            self.transformers_[colname].fit(X[colname], y)
-        return self
-
-    def _transform(self, X, y=None):
-        """Transform X and return a transformed version.
-
-        private _transform containing the core logic, called from transform
-
-        Returns a transformed version of X by iterating over specified
-        columns and applying the wrapped transformer to them.
-
-        Parameters
-        ----------
-        X : pd.DataFrame
-            Data to be transformed
-        y : Series or Panel, default=None
-            Additional data, e.g., labels for transformation
-
-        Returns
-        -------
-        Xt : pd.DataFrame
-            transformed version of X
-        """
-        # make copy of z
-        X = X.copy()
-
-        # make sure z contains all columns that the user wants to transform
-        _check_columns(X, selected_columns=self.columns_)
-        for colname in self.columns_:
-            X[colname] = self.transformers_[colname].transform(X[colname], y)
-        return X
-
-    def _inverse_transform(self, X, y=None):
-        """Logic used by `inverse_transform` to reverse transformation on `X`.
-
-        Returns an inverse-transformed version of X by iterating over specified
-        columns and applying the univariate series transformer to them.
-        Only works if `self.transformer` has an `inverse_transform` method.
-
-        Parameters
-        ----------
-        X : pd.DataFrame
-            Data to be inverse transformed
-        y : Series or Panel, default=None
-            Additional data, e.g., labels for transformation
-
-        Returns
-        -------
-        Xt : pd.DataFrame
-            inverse transformed version of X
-        """
-        # make copy of z
-        X = X.copy()
-
-        # make sure z contains all columns that the user wants to transform
-        _check_columns(X, selected_columns=self.columns_)
-
-        # iterate over columns that are supposed to be inverse_transformed
-        for colname in self.columns_:
-            X[colname] = self.transformers_[colname].inverse_transform(X[colname], y)
-
-        return X
-
-    @if_delegate_has_method(delegate="transformer")
-    def update(self, X, y=None, update_params=True):
-        """Update parameters.
-
-        Update the parameters of the estimator with new data
-        by iterating over specified columns.
-        Only works if `self.transformer` has an `update` method.
-
-        Parameters
-        ----------
-        X : pd.Series
-            New time series.
-        update_params : bool, optional, default=True
-
-        Returns
-        -------
-        self : an instance of self
-        """
-        z = check_series(X)
-
-        # make z a pd.DataFrame in univariate case
-        if isinstance(z, pd.Series):
-            z = z.to_frame()
-
-        # make sure z contains all columns that the user wants to transform
-        _check_columns(z, selected_columns=self.columns_)
-        for colname in self.columns_:
-            self.transformers_[colname].update(z[colname], X)
-        return self
-
-    @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.
-
-
-        Returns
-        -------
-        params : dict or list of dict, default = {}
-            Parameters to create testing instances of the class
-            Each dict are parameters to construct an "interesting" test instance, i.e.,
-            `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
-            `create_test_instance` uses the first (or only) dictionary in `params`
-        """
-        from sktime.transformations.series.detrend import Detrender
-
-        return {"transformer": Detrender()}
-
-
-def _check_columns(z, selected_columns):
-    # make sure z contains all columns that the user wants to transform
-    z_wanted_keys = set(selected_columns)
-    z_new_keys = set(z.columns)
-    difference = z_wanted_keys.difference(z_new_keys)
-    if len(difference) != 0:
-        raise ValueError("Missing columns" + str(difference) + "in Z.")
-
-
-def _check_is_pdseries(z):
-    # make z a pd.Dataframe in univariate case
-    is_series = False
-    if isinstance(z, pd.Series):
-        z = z.to_frame()
-        is_series = True
-    return z, is_series
-
-
-class YtoX(BaseTransformer):
-    """Create exogeneous features which are a copy of the endogenous data.
-
-    Replaces exogeneous features (`X`) by endogeneous data (`y`).
-
-    To *add* instead of *replace*, use `FeatureUnion`.
-
-    Parameters
-    ----------
-    no parameters
-    """
-
-    _tags = {
-        "transform-returns-same-time-index": True,
-        "skip-inverse-transform": False,
-        "univariate-only": False,
-        "X_inner_mtype": ["pd.DataFrame", "pd-multiindex", "pd_multiindex_hier"],
-        "y_inner_mtype": ["pd.DataFrame", "pd-multiindex", "pd_multiindex_hier"],
-        "scitype:y": "both",
-        "fit_is_empty": True,
-        "requires_y": True,
-    }
-
-    def __init__(self):
-        super(YtoX, self).__init__()
-
-    def _transform(self, X, y=None):
-        """Transform X and return a transformed version.
-
-        private _transform containing core logic, called from transform
-
-        Parameters
-        ----------
-        X : time series or panel in one of the pd.DataFrame formats
-            Data to be transformed
-        y : time series or panel in one of the pd.DataFrame formats
-            Additional data, e.g., labels for transformation
-
-        Returns
-        -------
-        y, as a transformed version of X
-        """
-        return y
-
-    def _inverse_transform(self, X, y=None):
-        """Inverse transform, inverse operation to transform.
-
-        Drops featurized column that was added in transform().
-
-        Parameters
-        ----------
-        X : Series or Panel of mtype X_inner_mtype
-            if X_inner_mtype is list, _inverse_transform must support all types in it
-            Data to be inverse transformed
-        y : Series or Panel of mtype y_inner_mtype, optional (default=None)
-            Additional data, e.g., labels for transformation
-
-        Returns
-        -------
-        inverse transformed version of X
-        """
-        return y
+from warnings import warn
+
+from sktime.transformations.compose import (
+    ColumnwiseTransformer,
+    OptionalPassthrough,
+    YtoX,
+)
+
+warn(
+    "transformations.series.compose is deprecated and will be removed in"
+    " version 0.15.0. All estimators in it will be moved to transformations.compose. "
+    "Please change imports to transformations.compose to avoid breakage.",
+    DeprecationWarning,
+)
diff --git a/sktime/transformations/series/detrend/_deseasonalize.py b/sktime/transformations/series/detrend/_deseasonalize.py
index 906e8c7ce..bf3e2e190 100644
--- a/sktime/transformations/series/detrend/_deseasonalize.py
+++ b/sktime/transformations/series/detrend/_deseasonalize.py
@@ -204,7 +204,9 @@ class Deseasonalizer(BaseTransformer):
         """
         X_full = X.combine_first(self._X)
         self._X = X_full
-        return self._fit(X_full, update_params=update_params)
+        if update_params:
+            self._fit(X_full, update_params=update_params)
+        return self
 
 
 class ConditionalDeseasonalizer(Deseasonalizer):


Test patch:
diff --git a/sktime/classification/distance_based/tests/test_time_series_neighbors.py b/sktime/classification/distance_based/tests/test_time_series_neighbors.py
index 59f2d3404..513b5cb99 100644
--- a/sktime/classification/distance_based/tests/test_time_series_neighbors.py
+++ b/sktime/classification/distance_based/tests/test_time_series_neighbors.py
@@ -28,6 +28,17 @@ expected_correct = {
     "edr": 20,
 }
 
+# expected correct on test set using window params.
+expected_correct_window = {
+    "euclidean": 19,
+    "dtw": 21,
+    "wdtw": 21,
+    "msm": 10,
+    "erp": 19,
+    "edr": 20,
+    "lcss": 12,
+}
+
 
 @pytest.mark.parametrize("distance_key", distance_functions)
 def test_knn_on_unit_test(distance_key):
@@ -61,4 +72,4 @@ def test_knn_bounding_matrix(distance_key):
     for j in range(0, len(pred)):
         if pred[j] == y_test[j]:
             correct = correct + 1
-    assert correct == expected_correct[distance_key]
+    assert correct == expected_correct_window[distance_key]
diff --git a/sktime/distances/tests/_expected_results.py b/sktime/distances/tests/_expected_results.py
index 54161e854..3943ae416 100644
--- a/sktime/distances/tests/_expected_results.py
+++ b/sktime/distances/tests/_expected_results.py
@@ -13,7 +13,7 @@ _expected_distance_results = {
     "ddtw": [0.0, 2.0884818837222006, 34.837800040564005],
     "wdtw": [12.343758137512241, 0.985380547171357, 21.265839226825413],
     "wddtw": [0.0, 1.0442409418611003, 17.418900020282003],
-    "msm": [5.0, 3.0922226886554434, None],
+    "msm": [5.0, 6.828557434224288, None],
     "twe": [5.0, 11.548698748091073, 39.87793560457224],
 }
 
diff --git a/sktime/forecasting/tests/test_structural.py b/sktime/forecasting/tests/test_structural.py
index a3d215ccf..ec5f79d9c 100644
--- a/sktime/forecasting/tests/test_structural.py
+++ b/sktime/forecasting/tests/test_structural.py
@@ -196,6 +196,7 @@ def test_results_consistency(level, fh_length, y_airlines):
     model = _UnobservedComponents(level=level, endog=y_airlines)
     result = model.fit(disp=0)
     y_pred_base = result.forecast(steps=fh_length)
+    y_pred_base.name = y_airlines.name
     assert_series_equal(left=y_pred_forecaster, right=y_pred_base)
     assert len(fh) == y_pred_forecaster.shape[0]
 
@@ -222,6 +223,7 @@ def test_result_consistency_exog(level_sample_data_split):
     result = model.fit(disp=0)
     n_test = X_test.shape[0]
     y_pred_base = result.forecast(steps=n_test, exog=X_test)
+    y_pred_base.name = y_train.name
     assert_series_equal(left=y_pred_forecaster, right=y_pred_base)
     assert len(fh) == y_pred_forecaster.shape[0]
 
diff --git a/sktime/transformations/series/detrend/tests/test_deseasonalise.py b/sktime/transformations/series/detrend/tests/test_deseasonalise.py
index 0509326c4..1017e5e01 100644
--- a/sktime/transformations/series/detrend/tests/test_deseasonalise.py
+++ b/sktime/transformations/series/detrend/tests/test_deseasonalise.py
@@ -1,17 +1,18 @@
-#!/usr/bin/env python3 -u
 # -*- coding: utf-8 -*-
+"""Tests for Deseasonalizer."""
 # copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
 
-__author__ = ["Markus Löning"]
+__author__ = ["mloning"]
 __all__ = []
 
 import numpy as np
 import pytest
+from statsmodels.tsa.seasonal import seasonal_decompose
+
 from sktime.forecasting.model_selection import temporal_train_test_split
 from sktime.forecasting.tests._config import TEST_SPS
 from sktime.transformations.series.detrend import Deseasonalizer
 from sktime.utils._testing.forecasting import make_forecasting_problem
-from statsmodels.tsa.seasonal import seasonal_decompose
 
 MODELS = ["additive", "multiplicative"]
 
@@ -56,3 +57,23 @@ def test_transform_inverse_transform_equivalence(sp, model):
     yit = transformer.inverse_transform(transformer.transform(y_train))
     np.testing.assert_array_equal(y_train.index, yit.index)
     np.testing.assert_array_almost_equal(y_train, yit)
+
+
+def test_deseasonalizer_in_pipeline():
+    """Test deseasonalizer in pipeline, see issue #3267."""
+    from sktime.datasets import load_airline
+    from sktime.forecasting.compose import TransformedTargetForecaster
+    from sktime.forecasting.theta import ThetaForecaster
+    from sktime.transformations.series.detrend import Deseasonalizer
+
+    all_df = load_airline().to_frame()
+
+    model = TransformedTargetForecaster(
+        [
+            ("deseasonalize", Deseasonalizer(model="additive", sp=12)),
+            ("forecast", ThetaForecaster()),
+        ]
+    )
+    train_df = all_df["1949":"1950"]
+    model.fit(train_df)
+    model.update(y=all_df["1951"])
diff --git a/sktime/transformations/tests/test_compose.py b/sktime/transformations/tests/test_compose.py
index 24be02488..1d578ea71 100644
--- a/sktime/transformations/tests/test_compose.py
+++ b/sktime/transformations/tests/test_compose.py
@@ -10,9 +10,14 @@ from sklearn.preprocessing import StandardScaler
 
 from sktime.datasets import load_airline
 from sktime.datatypes import get_examples
-from sktime.transformations.compose import FeatureUnion, TransformerPipeline
+from sktime.transformations.compose import (
+    FeatureUnion,
+    InvertTransform,
+    TransformerPipeline,
+)
 from sktime.transformations.panel.padder import PaddingTransformer
 from sktime.transformations.series.boxcox import LogTransformer
+from sktime.transformations.series.compose import OptionalPassthrough
 from sktime.transformations.series.exponent import ExponentTransformer
 from sktime.transformations.series.impute import Imputer
 from sktime.transformations.series.subset import ColumnSelect
@@ -224,3 +229,34 @@ def test_subset_getitem():
     )
     _assert_array_almost_equal(t_both.fit_transform(X), X_theta[["b__0", "b__2"]])
     _assert_array_almost_equal(t_none.fit_transform(X), X_theta)
+
+
+def test_dunder_invert():
+    """Test the invert dunder method, for wrapping in OptionalPassthrough."""
+    X = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
+
+    t = ExponentTransformer(power=3)
+
+    t_inv = ~t
+
+    assert isinstance(t_inv, InvertTransform)
+    assert isinstance(t_inv.get_params()["transformer"], ExponentTransformer)
+
+    _assert_array_almost_equal(
+        t_inv.fit_transform(X), ExponentTransformer(1 / 3).fit_transform(X)
+    )
+
+
+def test_dunder_neg():
+    """Test the neg dunder method, for wrapping in OptionalPassthrough."""
+    X = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
+
+    t = ExponentTransformer(power=2)
+
+    tp = -t
+
+    assert isinstance(tp, OptionalPassthrough)
+    assert not tp.get_params()["passthrough"]
+    assert isinstance(tp.get_params()["transformer"], ExponentTransformer)
+
+    _assert_array_almost_equal(tp.fit_transform(X), X)
diff --git a/sktime/utils/_testing/forecasting.py b/sktime/utils/_testing/forecasting.py
index 525e4e99f..eb072bfa4 100644
--- a/sktime/utils/_testing/forecasting.py
+++ b/sktime/utils/_testing/forecasting.py
@@ -140,11 +140,18 @@ def _assert_correct_columns(y_pred, y_train):
     """Check that forecast object has right column names."""
     if isinstance(y_pred, pd.DataFrame) and isinstance(y_train, pd.DataFrame):
         msg = (
-            "forecast must have same columns index as past data, "
+            "forecast pd.DataFrame must have same column index as past data, "
             f"expected {y_train.columns} but found {y_pred.columns}"
         )
         assert (y_pred.columns == y_train.columns).all(), msg
 
+    if isinstance(y_pred, pd.Series) and isinstance(y_train, pd.Series):
+        msg = (
+            "forecast pd.Series must have same name as past data, "
+            f"expected {y_train.name} but found {y_pred.name}"
+        )
+        assert y_pred.name == y_train.name, msg
+
 
 def _make_fh(cutoff, steps, fh_type, is_relative):
     """Construct forecasting horizons for testing."""