Repository: alan-turing-institute/sktime
Pull request #3252 (2022-08-12 22:05:13)

[MNT] add soft dependency tag to `CNNClassifier`
adds missing soft dependency tag to `CNNClassifier`


Patch:
diff --git a/.all-contributorsrc b/.all-contributorsrc
index 8aca5c8e5..b641b5a29 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -1747,7 +1747,17 @@
       "contributions": [
         "bug"
       ]
-    }
+    },
+    {
+      "login": "haskarb",
+      "name": "Bhaskar Dhariyal",
+      "avatar_url": "https://avatars.githubusercontent.com/u/20501023?v=4",
+      "profile": "https://haskarb.github.io/",
+      "contributions": [
+        "code",
+        "test"
+      ]
+    },
   ],
   "projectName": "sktime",
   "projectOwner": "alan-turing-institute",
diff --git a/CODEOWNERS b/CODEOWNERS
index 63a8a64ea..de3020a15 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -36,6 +36,7 @@ sktime/transformations/series/kalman_filter.py @NoaBenAmi
 sktime/transformations/panel/augmenter.py @MrPr3ntice @iljamaurer
 sktime/transformations/multiplex.py @miraep8
 sktime/transformations/tests/test_multiplexer.py @miraep8
+sktime/transformations/panel/channel_selection.py @haskarb @a-pasos-ruiz @TonyBagnall
 
 sktime/forecasting/base/ @fkiraly @mloning @aiwalter
 sktime/forecasting/base/adapters/_statsforecast.py @FedericoGarza
diff --git a/examples/channel_selection.ipynb b/examples/channel_selection.ipynb
new file mode 100644
index 000000000..7c5a799e3
--- /dev/null
+++ b/examples/channel_selection.ipynb
@@ -0,0 +1,534 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "89a363cd-8944-475a-88ea-4401785218c5",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import warnings\n",
+    "\n",
+    "warnings.filterwarnings(\"ignore\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "b45cbeda-65bc-4b10-9f9c-9afd43a54d20",
+   "metadata": {},
+   "source": [
+    "# Channel Selection in Multivariate Time Series Classification \n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "08decb5b-8dfb-4666-a66b-3a2349960956",
+   "metadata": {},
+   "source": [
+    "## Overview"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "da743484-17d3-4cec-8eaa-8d8293ce6f35",
+   "metadata": {},
+   "source": [
+    "Sometimes every channel is not required to perform classification; only a few are useful.  The [1] proposed a fast channel selection technique for Multivariate Time Classification. "
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "dcbe2174-a691-4093-ab80-d796edb5121d",
+   "metadata": {},
+   "source": [
+    "[1] : Fast Channel Selection for Scalable Multivariate Time Series Classification [Link](https://www.researchgate.net/publication/354445008_Fast_Channel_Selection_for_Scalable_Multivariate_Time_Series_Classification)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "id": "d1779970-eefb-4577-9c4e-e0a19ceadcc1",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from sklearn.linear_model import RidgeClassifierCV\n",
+    "from sklearn.pipeline import make_pipeline\n",
+    "\n",
+    "from sktime.datasets import load_UCR_UEA_dataset\n",
+    "from sktime.transformations.panel import channel_selection\n",
+    "from sktime.transformations.panel.rocket import Rocket"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "0437ca7a-5b5a-4e28-b565-0b2df4eac60d",
+   "metadata": {},
+   "source": [
+    "# 1 Initialise the Pipeline"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "id": "830137a3-10c3-49b9-9a98-7062dc7ab1d8",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# cs = channel_selection.ElbowClassSum()  # ECS\n",
+    "cs = channel_selection.ElbowClassPairwise()  # ECP"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "id": "89443793-7cf0-4a4c-a4b1-d928a46a1bb2",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "rocket_pipeline = make_pipeline(cs, Rocket(), RidgeClassifierCV())"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "5a268cc1-d5bf-4b02-916b-f3417c1cd3ff",
+   "metadata": {},
+   "source": [
+    "# 2 Load and Fit the Training Data"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "id": "68f508e3-ecc9-4b3a-b4de-7073cd1dfb90",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "data = \"BasicMotions\"\n",
+    "X_train, y_train = load_UCR_UEA_dataset(data, split=\"train\", return_X_y=True)\n",
+    "X_test, y_test = load_UCR_UEA_dataset(data, split=\"test\", return_X_y=True)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "id": "94f421bc-e384-4b98-89af-111a4d8c378b",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "Pipeline(steps=[('elbowclasspairwise', ElbowClassPairwise()),\n",
+       "                ('rocket', Rocket()),\n",
+       "                ('ridgeclassifiercv',\n",
+       "                 RidgeClassifierCV(alphas=array([ 0.1,  1. , 10. ])))])"
+      ]
+     },
+     "execution_count": 6,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "rocket_pipeline.fit(X_train, y_train)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "0c867fdb-5126-44f6-b7f0-f999d3f60457",
+   "metadata": {},
+   "source": [
+    "# 3 Classify the Test Data"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "id": "04573f4d-0b61-4ab8-8355-79f0aa1ca04f",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "1.0"
+      ]
+     },
+     "execution_count": 7,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "rocket_pipeline.score(X_test, y_test)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "d18ac8bc-a83a-4dd7-b577-aefc25d7bed6",
+   "metadata": {},
+   "source": [
+    "# 4 Identify channels"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "id": "35a44d68-7bce-44b0-baf3-e4f11606001c",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "[0, 1]"
+      ]
+     },
+     "execution_count": 8,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "rocket_pipeline.steps[0][1].channels_selected_"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 9,
+   "id": "358ab28f-edbe-49f4-95f5-8a7d0fb5d166",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<div>\n",
+       "<style scoped>\n",
+       "    .dataframe tbody tr th:only-of-type {\n",
+       "        vertical-align: middle;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe tbody tr th {\n",
+       "        vertical-align: top;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe thead th {\n",
+       "        text-align: right;\n",
+       "    }\n",
+       "</style>\n",
+       "<table border=\"1\" class=\"dataframe\">\n",
+       "  <thead>\n",
+       "    <tr style=\"text-align: right;\">\n",
+       "      <th></th>\n",
+       "      <th>Centroid_badminton_running</th>\n",
+       "      <th>Centroid_badminton_standing</th>\n",
+       "      <th>Centroid_badminton_walking</th>\n",
+       "      <th>Centroid_running_standing</th>\n",
+       "      <th>Centroid_running_walking</th>\n",
+       "      <th>Centroid_standing_walking</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th>0</th>\n",
+       "      <td>39.594679</td>\n",
+       "      <td>55.752785</td>\n",
+       "      <td>48.440779</td>\n",
+       "      <td>63.610220</td>\n",
+       "      <td>57.247383</td>\n",
+       "      <td>10.717044</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>1</th>\n",
+       "      <td>57.681767</td>\n",
+       "      <td>24.390543</td>\n",
+       "      <td>27.770269</td>\n",
+       "      <td>60.458125</td>\n",
+       "      <td>62.339120</td>\n",
+       "      <td>16.370347</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>2</th>\n",
+       "      <td>20.175911</td>\n",
+       "      <td>24.126969</td>\n",
+       "      <td>22.331621</td>\n",
+       "      <td>25.671979</td>\n",
+       "      <td>22.991555</td>\n",
+       "      <td>4.897452</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>3</th>\n",
+       "      <td>12.546212</td>\n",
+       "      <td>12.439152</td>\n",
+       "      <td>12.741854</td>\n",
+       "      <td>6.317654</td>\n",
+       "      <td>6.695743</td>\n",
+       "      <td>3.585273</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>4</th>\n",
+       "      <td>10.101196</td>\n",
+       "      <td>8.865871</td>\n",
+       "      <td>9.221908</td>\n",
+       "      <td>6.520172</td>\n",
+       "      <td>6.715702</td>\n",
+       "      <td>1.299989</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>5</th>\n",
+       "      <td>23.464251</td>\n",
+       "      <td>14.568685</td>\n",
+       "      <td>13.953445</td>\n",
+       "      <td>18.878429</td>\n",
+       "      <td>19.768549</td>\n",
+       "      <td>7.228389</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n",
+       "</div>"
+      ],
+      "text/plain": [
+       "   Centroid_badminton_running  Centroid_badminton_standing  \\\n",
+       "0                   39.594679                    55.752785   \n",
+       "1                   57.681767                    24.390543   \n",
+       "2                   20.175911                    24.126969   \n",
+       "3                   12.546212                    12.439152   \n",
+       "4                   10.101196                     8.865871   \n",
+       "5                   23.464251                    14.568685   \n",
+       "\n",
+       "   Centroid_badminton_walking  Centroid_running_standing  \\\n",
+       "0                   48.440779                  63.610220   \n",
+       "1                   27.770269                  60.458125   \n",
+       "2                   22.331621                  25.671979   \n",
+       "3                   12.741854                   6.317654   \n",
+       "4                    9.221908                   6.520172   \n",
+       "5                   13.953445                  18.878429   \n",
+       "\n",
+       "   Centroid_running_walking  Centroid_standing_walking  \n",
+       "0                 57.247383                  10.717044  \n",
+       "1                 62.339120                  16.370347  \n",
+       "2                 22.991555                   4.897452  \n",
+       "3                  6.695743                   3.585273  \n",
+       "4                  6.715702                   1.299989  \n",
+       "5                 19.768549                   7.228389  "
+      ]
+     },
+     "execution_count": 9,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "rocket_pipeline.steps[0][1].distance_frame_"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "c75f99ea-3966-483b-9f08-89e3f0dbffeb",
+   "metadata": {},
+   "source": [
+    "# 5 Standalone"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 10,
+   "id": "82607728-1095-4f15-a06e-d2463bb5c642",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "ElbowClassPairwise()"
+      ]
+     },
+     "execution_count": 10,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "cs.fit(X_train, y_train)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "a1f3ec36-ce86-4388-b7b5-b3b2a087b43a",
+   "metadata": {},
+   "source": [
+    "# 6 Distance Matrix"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 11,
+   "id": "f4a19774-368e-43d4-a45a-d8109ae2d17f",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<div>\n",
+       "<style scoped>\n",
+       "    .dataframe tbody tr th:only-of-type {\n",
+       "        vertical-align: middle;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe tbody tr th {\n",
+       "        vertical-align: top;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe thead th {\n",
+       "        text-align: right;\n",
+       "    }\n",
+       "</style>\n",
+       "<table border=\"1\" class=\"dataframe\">\n",
+       "  <thead>\n",
+       "    <tr style=\"text-align: right;\">\n",
+       "      <th></th>\n",
+       "      <th>Centroid_badminton_running</th>\n",
+       "      <th>Centroid_badminton_standing</th>\n",
+       "      <th>Centroid_badminton_walking</th>\n",
+       "      <th>Centroid_running_standing</th>\n",
+       "      <th>Centroid_running_walking</th>\n",
+       "      <th>Centroid_standing_walking</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th>0</th>\n",
+       "      <td>39.594679</td>\n",
+       "      <td>55.752785</td>\n",
+       "      <td>48.440779</td>\n",
+       "      <td>63.610220</td>\n",
+       "      <td>57.247383</td>\n",
+       "      <td>10.717044</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>1</th>\n",
+       "      <td>57.681767</td>\n",
+       "      <td>24.390543</td>\n",
+       "      <td>27.770269</td>\n",
+       "      <td>60.458125</td>\n",
+       "      <td>62.339120</td>\n",
+       "      <td>16.370347</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>2</th>\n",
+       "      <td>20.175911</td>\n",
+       "      <td>24.126969</td>\n",
+       "      <td>22.331621</td>\n",
+       "      <td>25.671979</td>\n",
+       "      <td>22.991555</td>\n",
+       "      <td>4.897452</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>3</th>\n",
+       "      <td>12.546212</td>\n",
+       "      <td>12.439152</td>\n",
+       "      <td>12.741854</td>\n",
+       "      <td>6.317654</td>\n",
+       "      <td>6.695743</td>\n",
+       "      <td>3.585273</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>4</th>\n",
+       "      <td>10.101196</td>\n",
+       "      <td>8.865871</td>\n",
+       "      <td>9.221908</td>\n",
+       "      <td>6.520172</td>\n",
+       "      <td>6.715702</td>\n",
+       "      <td>1.299989</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>5</th>\n",
+       "      <td>23.464251</td>\n",
+       "      <td>14.568685</td>\n",
+       "      <td>13.953445</td>\n",
+       "      <td>18.878429</td>\n",
+       "      <td>19.768549</td>\n",
+       "      <td>7.228389</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n",
+       "</div>"
+      ],
+      "text/plain": [
+       "   Centroid_badminton_running  Centroid_badminton_standing  \\\n",
+       "0                   39.594679                    55.752785   \n",
+       "1                   57.681767                    24.390543   \n",
+       "2                   20.175911                    24.126969   \n",
+       "3                   12.546212                    12.439152   \n",
+       "4                   10.101196                     8.865871   \n",
+       "5                   23.464251                    14.568685   \n",
+       "\n",
+       "   Centroid_badminton_walking  Centroid_running_standing  \\\n",
+       "0                   48.440779                  63.610220   \n",
+       "1                   27.770269                  60.458125   \n",
+       "2                   22.331621                  25.671979   \n",
+       "3                   12.741854                   6.317654   \n",
+       "4                    9.221908                   6.520172   \n",
+       "5                   13.953445                  18.878429   \n",
+       "\n",
+       "   Centroid_running_walking  Centroid_standing_walking  \n",
+       "0                 57.247383                  10.717044  \n",
+       "1                 62.339120                  16.370347  \n",
+       "2                 22.991555                   4.897452  \n",
+       "3                  6.695743                   3.585273  \n",
+       "4                  6.715702                   1.299989  \n",
+       "5                 19.768549                   7.228389  "
+      ]
+     },
+     "execution_count": 11,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "cs.distance_frame_"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 12,
+   "id": "a29b0ece",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "13"
+      ]
+     },
+     "execution_count": 12,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "cs.train_time_"
+   ]
+  }
+ ],
+ "metadata": {
+  "interpreter": {
+   "hash": "30ff7f6bb2505d289b6e6022e217e794dc64e9153f959b8a264cb3c597a35999"
+  },
+  "kernelspec": {
+   "display_name": "Python 3.7.5 ('sktime-test')",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.7.5"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/sktime/annotation/clasp.py b/sktime/annotation/clasp.py
index 5c1375965..836cc81d0 100644
--- a/sktime/annotation/clasp.py
+++ b/sktime/annotation/clasp.py
@@ -71,7 +71,7 @@ def _is_trivial_match(candidate, change_points, n_timepoints, exclusion_radius=0
     Parameters
     ----------
     candidate : int
-        A single candidate change point. Will me chosen if non-trivial match based
+        A single candidate change point. Will be chosen if non-trivial match based
         on exclusion_radius.
     change_points : list, dtype=int
         List of change points chosen so far
@@ -195,6 +195,8 @@ class ClaSPSegmentation(BaseSeriesAnnotator):
         Annotation output format:
         * If "sparse", a pd.Series of the found Change Points is returned
         * If "dense", a pd.IndexSeries with the Segmenation of X is returned
+    exclusion_radius : int
+        Exclusion Radius for change points to be non-trivial matches
 
     Notes
     -----
@@ -221,9 +223,10 @@ class ClaSPSegmentation(BaseSeriesAnnotator):
 
     _tags = {"univariate-only": True, "fit_is_empty": True}  # for unit test cases
 
-    def __init__(self, period_length=10, n_cps=1, fmt="sparse"):
+    def __init__(self, period_length=10, n_cps=1, fmt="sparse", exclusion_radius=0.05):
         self.period_length = int(period_length)
         self.n_cps = n_cps
+        self.exclusion_radius = exclusion_radius
         super(ClaSPSegmentation, self).__init__(fmt)
 
     def _fit(self, X, Y=None):
@@ -307,10 +310,15 @@ class ClaSPSegmentation(BaseSeriesAnnotator):
         if isinstance(X, pd.Series):
             X = X.to_numpy()
 
-        clasp_transformer = ClaSPTransformer(window_length=self.period_length).fit(X)
+        clasp_transformer = ClaSPTransformer(
+            window_length=self.period_length, exclusion_radius=self.exclusion_radius
+        ).fit(X)
 
         self.found_cps, self.profiles, self.scores = _segmentation(
-            X, clasp_transformer, n_change_points=self.n_cps, exclusion_radius=0.05
+            X,
+            clasp_transformer,
+            n_change_points=self.n_cps,
+            exclusion_radius=self.exclusion_radius,
         )
 
         return self.found_cps, self.profiles, self.scores
diff --git a/sktime/annotation/datagen.py b/sktime/annotation/datagen.py
index 88595d9a8..ebfb740df 100644
--- a/sktime/annotation/datagen.py
+++ b/sktime/annotation/datagen.py
@@ -8,17 +8,144 @@ import numpy.typing as npt
 from sklearn.utils.validation import check_random_state
 
 
-def mean_shift(
+def piecewise_normal_multivariate(
     means: npt.ArrayLike,
     lengths: npt.ArrayLike,
-    noise: Union[npt.ArrayLike, float] = 1.0,
+    variances: Union[npt.ArrayLike, float] = 1.0,
+    covariances: npt.ArrayLike = None,
+    random_state: Union[int, np.random.RandomState] = None,
+) -> npt.ArrayLike:
+    """
+    Generate multivariate series from segments.
+
+    Each segment has length specified in ``lengths`` and data sampled from a
+    multivariate normal distribution with a mean from ``means`` and covariance
+    from ``covariances`` (either specified or built from ``variances`` when
+    unspecified)
+
+    Parameters
+    ----------
+    lengths : array_like
+        Lengths of the segments to be generated of shape (n_segments,)
+    means : array_like
+        Means of the segments to be generated, as an array of shape
+        (n_segments, n_series)
+    variances : float or array_like (default=1.0)
+        Variance of the segments to be generated
+    covariances : array_like (default=None)
+        Covariances of segments to be generated of shape
+        (n_segments, n_series, n_series)
+        If None, this will be constructed from variances by assuming independence
+        of random vairables, i.e. variance as diagonal elements of covariance matrix
+    random_state : int or np.random.RandomState
+        Either a random seed or ``RandomState`` instance
+
+    Returns
+    -------
+    data : array_like
+        Multivariate time series as ``np.array`` of shape (sum(lengths), n_series)
+
+    Examples
+    --------
+    >>> from sktime.annotation.datagen import piecewise_normal_multivariate
+    >>> piecewise_normal_multivariate(means=[[1, 1], [2, 2], [3, 3]],\
+        lengths=[2, 3, 1], random_state=2)
+    array([[ 0.58324215,  0.94373317],
+           [-1.1361961 ,  2.64027081],
+           [ 0.20656441,  1.15825263],
+           [ 2.50288142,  0.75471191],
+           [ 0.94204778,  1.09099239],
+           [ 3.55145404,  5.29220801]])
+
+    >>> from sktime.annotation.datagen import piecewise_normal_multivariate
+    >>> piecewise_normal_multivariate(means=[[1, 1], [2, 2], [3, 3]],\
+        lengths=[2, 3, 1], variances=[[1.0, 1.0], [1.0, 1.0], [1.0, 1.0]],\
+        random_state=2)
+    array([[ 0.58324215,  0.94373317],
+           [-1.1361961 ,  2.64027081],
+           [ 0.20656441,  1.15825263],
+           [ 2.50288142,  0.75471191],
+           [ 0.94204778,  1.09099239],
+           [ 3.55145404,  5.29220801]])
+
+    >>> from sktime.annotation.datagen import piecewise_normal_multivariate
+    >>> piecewise_normal_multivariate(means=[[1, 1], [2, 2], [3, 3]],\
+        lengths=[2, 3, 1], covariances=[[[1.0, 0], [0, 1.0]], [[1.0, 0],\
+            [0, 1.0]], [[1.0, 0], [0, 1.0]]], random_state=2)
+    array([[ 0.58324215,  0.94373317],
+           [-1.1361961 ,  2.64027081],
+           [ 0.20656441,  1.15825263],
+           [ 2.50288142,  0.75471191],
+           [ 0.94204778,  1.09099239],
+           [ 3.55145404,  5.29220801]])
+
+    >>> from sktime.annotation.datagen import piecewise_normal_multivariate
+    >>> piecewise_normal_multivariate(means=[[1, 3], [4, 5]], lengths=[3, 3],\
+        covariances=[[[0.5, 0.3], [0.3, 1.0]], [[1.0, 0.3], [0.3, 0.7]]],\
+        random_state=2)
+    array([[ 0.78066776,  2.61125356],
+           [ 0.92296736,  0.51689669],
+           [-0.2694238 ,  1.47959507],
+           [ 4.00389069,  3.95225998],
+           [ 5.32264874,  5.05088075],
+           [ 2.62479901,  6.08308546]])
+
+    """
+
+    def get_covariances(var):
+        """Fill 1D variance array of length N to 2D covariance array of size (N,N)."""
+        cov = np.zeros((N, N), float)
+        np.fill_diagonal(cov, var)
+        return cov
+
+    L, N = np.array(means).shape
+
+    rng = check_random_state(random_state)
+    assert len(lengths) == L
+
+    # if no covariance is specified, build it from variance
+    # assuming independent random variables
+    if covariances is None:
+        assert variances is not None
+
+        # variances van be specified as a float, make 1D array, repeat L times
+        if isinstance(variances, (float, int)):
+            variances = np.repeat(variances, N)
+            variances = np.tile(variances, (L, 1))
+
+        assert np.array(variances).shape == (L, N)
+
+        # get covariance matrices from variance arrays
+        covariances = [get_covariances(var) for var in variances]
+
+    # if covariance is specified, then check if symmetric (eigenvalues are real)
+    # and check if matrix is positive semidefinite (eigenvalues are nonnegative)
+    else:
+        assert all([np.allclose(np.array(cov), np.array(cov).T) for cov in covariances])
+        assert all([np.all(np.linalg.eigvals(cov) >= 0) for cov in covariances])
+
+    assert np.array(covariances).shape[0] == L
+    assert np.array(covariances).shape[1] == N
+
+    return np.concatenate(
+        [
+            rng.multivariate_normal(mean=mean, cov=cov, size=length)
+            for mean, cov, length in zip(means, covariances, lengths)
+        ]
+    )
+
+
+def piecewise_normal(
+    means: npt.ArrayLike,
+    lengths: npt.ArrayLike,
+    std_dev: Union[npt.ArrayLike, float] = 1.0,
     random_state: Union[int, np.random.RandomState] = None,
 ) -> npt.ArrayLike:
     """
     Generate series from segments.
 
     Each segment has length specified in ``lengths`` and data sampled from a normal
-    distribution with a mean from ``means`` and standard deviation from ``noise``.
+    distribution with a mean from ``means`` and standard deviation from ``std_dev``.
 
     Parameters
     ----------
@@ -26,7 +153,7 @@ def mean_shift(
         Means of the segments to be generated
     lengths : array_like
         Lengths of the segments to be generated
-    noise : float or array_like
+    std_dev : float or array_like
         Standard deviations of the segments to be generated
     random_state : int or np.random.RandomState
         Either a random seed or RandomState instance
@@ -38,18 +165,19 @@ def mean_shift(
 
     Examples
     --------
-    >>> from sktime.annotation.datagen import mean_shift
-    >>> mean_shift([1, 2, 3], lengths=[2, 4, 8], random_state=42)  # doctest: +SKIP
+    >>> from sktime.annotation.datagen import piecewise_normal
+    >>> piecewise_normal([1, 2, 3], lengths=[2, 4, 8], random_state=42) # doctest: +SKIP
     array([1.49671415, 0.8617357 , 2.64768854, 3.52302986, 1.76584663,
         1.76586304, 4.57921282, 3.76743473, 2.53052561, 3.54256004,
         2.53658231, 2.53427025, 3.24196227, 1.08671976])
 
-    >>> from sktime.annotation.datagen import mean_shift
-    >>> mean_shift([1, 2, 3], lengths=[2, 4, 8], noise=0)  # doctest: +SKIP
+    >>> from sktime.annotation.datagen import piecewise_normal
+    >>> piecewise_normal([1, 2, 3], lengths=[2, 4, 8], std_dev=0) # doctest: +SKIP
     array([1., 1., 2., 2., 2., 2., 3., 3., 3., 3., 3., 3., 3., 3.])
 
-    >>> from sktime.annotation.datagen import mean_shift
-    >>> mean_shift([1, 2, 3], lengths=[2, 4, 8], noise=[0, 0.5, 1.0])  # doctest: +SKIP
+    >>> from sktime.annotation.datagen import piecewise_normal
+    >>> piecewise_normal([1, 2, 3], lengths=[2, 4, 8], std_dev=[0, 0.5, 1.0])\
+        # doctest: +SKIP
     array([1.        , 1.        , 2.32384427, 2.76151493, 1.88292331,
         1.88293152, 4.57921282, 3.76743473, 2.53052561, 3.54256004,
         2.53658231, 2.53427025, 3.24196227, 1.08671976])
@@ -58,30 +186,30 @@ def mean_shift(
     rng = check_random_state(random_state)
     assert len(means) == len(lengths)
 
-    if isinstance(noise, (float, int)):
-        noise = np.repeat(noise, len(means))
+    if isinstance(std_dev, (float, int)):
+        std_dev = np.repeat(std_dev, len(means))
 
-    assert len(noise) == len(means)
+    assert len(std_dev) == len(means)
 
     segments_data = [
         rng.normal(loc=mean, scale=sd, size=[length])
-        for mean, length, sd in zip(means, lengths, noise)
+        for mean, length, sd in zip(means, lengths, std_dev)
     ]
     return np.concatenate(tuple(segments_data))
 
 
-def labels_with_repeats(means: npt.ArrayLike, noise: npt.ArrayLike) -> npt.ArrayLike:
-    """Generate labels for unique combinations of meas and noise."""
-    data = [means, noise]
+def labels_with_repeats(means: npt.ArrayLike, std_dev: npt.ArrayLike) -> npt.ArrayLike:
+    """Generate labels for unique combinations of means and std_dev."""
+    data = [means, std_dev]
     unique, indices = np.unique(data, axis=1, return_inverse=True)
     labels = np.arange(unique.shape[1])
     return labels[indices]
 
 
-def label_mean_shift(
+def label_piecewise_normal(
     means: npt.ArrayLike,
     lengths: npt.ArrayLike,
-    noise: Union[npt.ArrayLike, float] = 1.0,
+    std_dev: Union[npt.ArrayLike, float] = 1.0,
     repeated_labels: bool = True,
 ) -> npt.ArrayLike:
     """
@@ -93,11 +221,11 @@ def label_mean_shift(
         Means of the segments to be generated
     lengths : array_like
         Lengths of the segments to be generated
-    noise : float or array_like
+    std_dev : float or array_like
         Standard deviations of the segments to be generated
     repeated_labels : bool
         Flag to indicate whether segment labels should be repeated for similar segments.
-        If ``True`` same label will be assigned for segments with same mean and noise,
+        If ``True`` same label will be assigned for segments with same mean and std_dev,
         independently of length. If ``False`` each consecutive segment will have
         a unique label.
 
@@ -106,10 +234,10 @@ def label_mean_shift(
     labels : np.array
         integer encoded array of labels, same length as data
     """
-    if isinstance(noise, (float, int)):
-        noise = np.repeat(noise, len(means))
+    if isinstance(std_dev, (float, int)):
+        std_dev = np.repeat(std_dev, len(means))
     if repeated_labels:
-        unique_labels = labels_with_repeats(means, noise)
+        unique_labels = labels_with_repeats(means, std_dev)
     else:
         unique_labels = range(len(lengths))
     return np.repeat(unique_labels, lengths)
@@ -118,17 +246,17 @@ def label_mean_shift(
 class GenBasicGauss:
     """Data generator base class in order to allow composition."""
 
-    def __init__(self, means, lengths, noise, random_state=None):
+    def __init__(self, means, lengths, std_dev=1.0, random_state=None):
         self.means = means
         self.lengths = lengths
-        self.noise = noise
+        self.std_dev = std_dev
         self.random_state = random_state
 
     def sample(self):
-        """Generate data sample."""
-        return mean_shift(
+        """Generate univariate mean shift random data sample."""
+        return piecewise_normal(
             means=self.means,
             lengths=self.lengths,
-            noise=self.noise,
+            std_dev=self.std_dev,
             random_state=self.random_state,
         )
diff --git a/sktime/annotation/plotting/utils.py b/sktime/annotation/plotting/utils.py
index 3d25b0477..e8e3cb9ec 100644
--- a/sktime/annotation/plotting/utils.py
+++ b/sktime/annotation/plotting/utils.py
@@ -46,7 +46,8 @@ def plot_time_series_with_change_points(ts_name, ts, true_cps, font_size=16):
     ts = check_X(ts)
 
     fig = plt.figure(figsize=(20, 5))
-    segments = [0] + true_cps.tolist() + [ts.shape[0]]
+    true_cps = np.sort(true_cps)
+    segments = [0] + list(true_cps) + [ts.shape[0]]
 
     for idx in np.arange(0, len(segments) - 1):
         plt.plot(
@@ -122,7 +123,7 @@ def plot_time_series_with_profiles(
     ax = ax.reshape(-1)
 
     if true_cps is not None:
-        segments = [0] + true_cps.tolist() + [ts.shape[0]]
+        segments = [0] + list(true_cps) + [ts.shape[0]]
         for idx in np.arange(0, len(segments) - 1):
             ax[0].plot(
                 np.arange(segments[idx], segments[idx + 1]),
diff --git a/sktime/base/_base.py b/sktime/base/_base.py
index d8490d504..09fb7eead 100644
--- a/sktime/base/_base.py
+++ b/sktime/base/_base.py
@@ -347,7 +347,11 @@ class BaseObject(_BaseEstimator):
         Changes object state by settting tag values in tag_dict as dynamic tags
         in self.
         """
-        self._tags_dynamic.update(deepcopy(tag_dict))
+        tag_update = deepcopy(tag_dict)
+        if hasattr(self, "_tags_dynamic"):
+            self._tags_dynamic.update(tag_update)
+        else:
+            self._tags_dynamic = tag_update
 
         return self
 
diff --git a/sktime/classification/deep_learning/cnn.py b/sktime/classification/deep_learning/cnn.py
index 9cc3650e6..7fb3678a0 100644
--- a/sktime/classification/deep_learning/cnn.py
+++ b/sktime/classification/deep_learning/cnn.py
@@ -67,6 +67,8 @@ class CNNClassifier(BaseDeepClassifier):
     CNNClassifier(...)
     """
 
+    _tags = {"python_dependencies": "tensorflow"}
+
     def __init__(
         self,
         n_epochs=2000,
diff --git a/sktime/forecasting/ardl.py b/sktime/forecasting/ardl.py
new file mode 100644
index 000000000..243415165
--- /dev/null
+++ b/sktime/forecasting/ardl.py
@@ -0,0 +1,557 @@
+# -*- coding: utf-8 -*-
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+"""Implements ARDL Model as interface to statsmodels."""
+import warnings
+
+import pandas as pd
+from statsmodels.tsa.ardl import ARDL as _ARDL
+from statsmodels.tsa.ardl import ardl_select_order as _ardl_select_order
+
+from sktime.forecasting.base._base import BaseForecaster
+from sktime.forecasting.base.adapters import _StatsModelsAdapter
+from sktime.forecasting.base.adapters._statsmodels import _coerce_int_to_range_index
+
+_all_ = ["ARDL"]
+__author__ = ["kcc-lion"]
+
+
+class ARDL(_StatsModelsAdapter):
+    """Autoregressive Distributed Lag (ARDL) Model.
+
+    Direct interface for statsmodels.tsa.ardl.ARDL
+
+    Parameters
+    ----------
+    lags : {int, list[int]}, optional
+        Only considered if auto_ardl is False
+        The number of lags to include in the model if an integer or the
+        list of lag indices to include.  For example, [1, 4] will only
+        include lags 1 and 4 while lags=4 will include lags 1, 2, 3, and 4.
+    order : {int, sequence[int], dict}, optional
+        Only considered if auto_ardl is False
+        If int, uses lags 0, 1, ..., order  for all exog variables. If
+        sequence[int], uses the ``order`` for all variables. If a dict,
+        applies the lags series by series. If ``exog`` is anything other
+        than a DataFrame, the keys are the column index of exog (e.g., 0,
+        1, ...). If a DataFrame, keys are column names.
+    fixed : array_like, optional
+        Additional fixed regressors that are not lagged.
+    causal : bool, optional
+        Whether to include lag 0 of exog variables.  If True, only includes
+        lags 1, 2, ...
+    trend : {'n', 'c', 't', 'ct'}, optional
+        The trend to include in the model:
+
+        * 'n' - No trend.
+        * 'c' - Constant only.
+        * 't' - Time trend only.
+        * 'ct' - Constant and time trend.
+
+        The default is 'c'.
+
+    seasonal : bool, optional
+        Flag indicating whether to include seasonal dummies in the model. If
+        seasonal is True and trend includes 'c', then the first period
+        is excluded from the seasonal terms.
+    deterministic : DeterministicProcess, optional
+        A deterministic process.  If provided, trend and seasonal are ignored.
+        A warning is raised if trend is not "n" and seasonal is not False.
+    hold_back : {None, int}, optional
+        Initial observations to exclude from the estimation sample.  If None,
+        then hold_back is equal to the maximum lag in the model.  Set to a
+        non-zero value to produce comparable models with different lag
+        length.  For example, to compare the fit of a model with lags=3 and
+        lags=1, set hold_back=3 which ensures that both models are estimated
+        using observations 3,...,nobs. hold_back must be >= the maximum lag in
+        the model.
+    period : {None, int}, optional
+        The period of the data. Only used if seasonal is True. This parameter
+        can be omitted if using a pandas object for endog that contains a
+        recognized frequency.
+    missing : {"none", "drop", "raise"}, optional
+        Available options are 'none', 'drop', and 'raise'. If 'none', no nan
+        checking is done. If 'drop', any observations with nans are dropped.
+        If 'raise', an error is raised. Default is 'none'.
+    cov_type : str, optional
+        The covariance estimator to use. The most common choices are listed
+        below.  Supports all covariance estimators that are available
+        in ``OLS.fit``.
+
+        * 'nonrobust' - The class OLS covariance estimator that assumes
+          homoskedasticity.
+        * 'HC0', 'HC1', 'HC2', 'HC3' - Variants of White's
+          (or Eiker-Huber-White) covariance estimator. `HC0` is the
+          standard implementation.  The other make corrections to improve
+          the finite sample performance of the heteroskedasticity robust
+          covariance estimator.
+        * 'HAC' - Heteroskedasticity-autocorrelation robust covariance
+          estimation. Supports cov_kwds.
+
+          - `maxlags` integer (required) : number of lags to use.
+          - `kernel` callable or str (optional) : kernel
+              currently available kernels are ['bartlett', 'uniform'],
+              default is Bartlett.
+          - `use_correction` bool (optional) : If true, use small sample
+              correction.
+    cov_kwds : dict, optional
+        A dictionary of keyword arguments to pass to the covariance
+        estimator. `nonrobust` and `HC#` do not support cov_kwds.
+    use_t : bool, optional
+        A flag indicating that inference should use the Student's t
+        distribution that accounts for model degree of freedom.  If False,
+        uses the normal distribution. If None, defers the choice to
+        the cov_type. It also removes degree of freedom corrections from
+        the covariance estimator when cov_type is 'nonrobust'.
+    auto_ardl : bool, optional
+        A flag indicating whether the number of lags should be determined automatically.
+    maxlag : int, optional
+        Only considered if auto_ardl is True.
+        The maximum lag to consider for the endogenous variable.
+    maxorder : {int, dict}
+        Only considered if auto_ardl is True.
+        If int, sets a common max lag length for all exog variables. If
+        a dict, then sets individual lag length. They keys are column names
+        if exog is a DataFrame or column indices otherwise.
+    ic : {"aic", "bic", "hqic"}, optional
+        Only considered if auto_ardl is True.
+        The information criterion to use in model selection.
+    glob : bool, optional
+        Only considered if auto_ardl is True.
+        Whether to consider all possible submodels of the largest model
+        or only if smaller order lags must be included if larger order
+        lags are.  If ``True``, the number of model considered is of the
+        order 2**(maxlag + k * maxorder) assuming maxorder is an int. This
+        can be very large unless k and maxorder are bot relatively small.
+        If False, the number of model considered is of the order
+        maxlag*maxorder**k which may also be substantial when k and maxorder
+        are large.
+    X_oos : array_like, optional
+        An array containing out-of-sample values of the exogenous
+        variables. Must have the same number of columns as the X
+        and at least as many rows as the number of out-of-sample forecasts.
+    fixed_oos : array_like, optional
+        An array containing out-of-sample values of the fixed variables.
+        Must have the same number of columns as the fixed array
+        and at least as many rows as the number of out-of-sample forecasts.
+    dynamic : {bool, int, str, datetime, Timestamp}, optional
+        Integer offset relative to `start` at which to begin dynamic
+        prediction. Prior to this observation, true endogenous values
+        will be used for prediction; starting with this observation and
+        continuing through the end of prediction, forecasted endogenous
+        values will be used instead. Datetime-like objects are not
+        interpreted as offsets. They are instead used to find the index
+        location of `dynamic` which is then used to to compute the offset.
+
+    Notes
+    -----
+    The full specification of an ARDL is
+
+    .. math ::
+
+       Y_t = delta_0 + delta_1 t + delta_2 t^2
+             + sum_{i=1}^{s-1} gamma_i I_{[(mod(t,s) + 1) = i]}
+             + sum_{j=1}^p phi_j Y_{t-j}
+             + sum_{l=1}^k sum_{m=0}^{o_l} beta_{l,m} X_{l, t-m}
+             + Z_t lambda
+             + epsilon_t
+
+    where :math:`delta_bullet` capture trends, :math:`gamma_bullet`
+    capture seasonal shifts, s is the period of the seasonality, p is the
+    lag length of the endogenous variable, k is the number of exogenous
+    variables :math:`X_{l}`, :math:`o_l` is included the lag length of
+    :math:`X_{l}`, :math:`Z_t` are ``r`` included fixed regressors and
+    :math:`epsilon_t` is a white noise shock. If ``causal`` is ``True``,
+    then the 0-th lag of the exogenous variables is not included and the
+    sum starts at ``m=1``.
+
+    See Also
+    --------
+    statsmodels.tsa.ar_model.AutoReg
+        Autoregressive model estimation with optional exogenous regressors
+    statsmodels.tsa.ardl.UECM
+        Unconstrained Error Correction Model estimation
+    statsmodels.tsa.statespace.sarimax.SARIMAX
+        Seasonal ARIMA model estimation with optional exogenous regressors
+    statsmodels.tsa.arima.model.ARIMA
+        ARIMA model estimation
+
+    Examples
+    --------
+    Use ARDL on macroeconomic data
+    >>> from sktime.datasets import load_macroeconomic
+    >>> from sktime.forecasting.ardl import ARDL
+    >>> from sktime.forecasting.base import ForecastingHorizon
+    >>> data = load_macroeconomic()
+    >>> oos = data.iloc[-5:, :]
+    >>> data = data.iloc[:-5, :]
+    >>> y = data.realgdp
+    >>> X = data[["realcons", "realinv"]]
+    >>> X_oos = oos[["realcons", "realinv"]]
+    >>> ardl = ARDL(lags=2, order={"realcons": 1, "realinv": 2}, trend="c")
+    >>> ardl.fit(y=y, X=X)
+    ARDL(lags=2, order={'realcons': 1, 'realinv': 2})
+    >>> fh = ForecastingHorizon([1, 2, 3])
+    >>> y_pred = ardl.predict(fh=fh, X=X_oos)
+    """
+
+    _tags = {
+        "scitype:y": "univariate",  # which y are fine? univariate/multivariate/both
+        "ignores-exogeneous-X": False,  # does estimator ignore the exogeneous X?
+        "handles-missing-data": False,  # can estimator handle missing data?
+        "y_inner_mtype": "pd.Series",  # which types do _fit, _predict, assume for y?
+        "X_inner_mtype": "pd.DataFrame",  # which types do _fit, _predict, assume for X?
+        "requires-fh-in-fit": False,  # is forecasting horizon already required in fit?
+        "X-y-must-have-same-index": True,  # can estimator handle different X/y index?
+        "enforce_index_type": None,  # index type that needs to be enforced in X/y
+        "capability:pred_int": False,  # does forecaster implement proba forecasts?
+        "python_version": None,  # PEP 440 python version specifier to limit versions
+    }
+
+    def __init__(
+        self,
+        lags=None,
+        order=None,
+        fixed=None,
+        causal=False,
+        trend="c",
+        seasonal=False,
+        deterministic=None,
+        hold_back=None,
+        period=None,
+        missing="none",
+        cov_type="nonrobust",
+        cov_kwds=None,
+        use_t=True,
+        auto_ardl=False,
+        maxlag=None,
+        maxorder=None,
+        ic="bic",
+        glob=False,
+        fixed_oos=None,
+        X_oos=None,
+        dynamic=False,
+    ):
+
+        # Model Params
+        self.lags = lags
+        self.order = order
+        self.fixed = fixed
+        self.causal = causal
+        self.trend = trend
+        self.seasonal = seasonal
+        self.deterministic = deterministic
+        self.hold_back = hold_back
+        self.period = period
+        self.missing = missing
+
+        # Fit Params
+        self.cov_type = cov_type
+        self.cov_kwds = cov_kwds
+        self.use_t = use_t
+
+        # Predict Params
+        self.fixed_oos = fixed_oos
+        self.X_oos = X_oos
+        self.dynamic = dynamic
+
+        # Auto ARDL params
+        self.auto_ardl = auto_ardl
+        self.maxlag = maxlag
+        self.ic = ic
+        self.glob = glob
+        self.maxorder = maxorder
+
+        if not self.auto_ardl:
+            assert self.lags is not None
+
+        if self.auto_ardl and self.lags is not None:
+            raise ValueError("lags should not be specified if auto_ardl is True")
+
+        super(ARDL, self).__init__()
+
+    def check_param_validity(self, X):
+        """Check for the validity of entered parameter combination."""
+        inner_order = self.order
+        inner_auto_ardl = self.auto_ardl
+
+        if not self.auto_ardl:
+            if inner_order is not None and not isinstance(X, pd.DataFrame):
+                inner_order = 0
+                warnings.warn(
+                    "X is none but the order for the exogenous variables was"
+                    " specified. Order was thus set to 0"
+                )
+        else:
+            if not isinstance(X, pd.DataFrame):
+                inner_order = 0
+                inner_auto_ardl = False
+                warnings.warn(
+                    "X is none but auto_ardl was set to True. auto_ardl was"
+                    " thus set to False with order=0"
+                )
+        return inner_order, inner_auto_ardl
+
+    # todo: implement this, mandatory
+    def _fit(self, y, X=None, fh=None):
+        """Fit forecaster to training data.
+
+        private _fit containing the core logic, called from fit
+
+        Writes to self:
+            Sets fitted model attributes ending in "_".
+
+        Parameters
+        ----------
+        y : guaranteed to be of a type in self.get_tag("y_inner_mtype")
+            Time series to which to fit the forecaster.
+            if self.get_tag("scitype:y")=="univariate":
+                guaranteed to have a single column/variable
+            A 1-d endogenous response variable. The dependent variable.
+        X : optional (default=None)
+            guaranteed to be of a type in self.get_tag("X_inner_mtype")
+            Exogeneous time series to fit to.
+            Exogenous variables to include in the model. Either a DataFrame or
+            an 2-d array-like structure that can be converted to a NumPy array.
+
+        Returns
+        -------
+        self : reference to self
+        """
+        # statsmodels does not support the pd.Int64Index as required,
+        # so we coerce them here to pd.RangeIndex
+        if isinstance(y, pd.Series) and y.index.is_integer():
+            y, X = _coerce_int_to_range_index(y, X)
+
+        # validity check of passed params
+        # certain parameter combinations (e.g. (1) order != 0 and X=None,
+        # (2) auto_ardl=True and X=None) cause errors
+        # Thus, the below function checks for validity of params,
+        # resets them appropriately and issues a  warning if need be
+        inner_order, inner_auto_ardl = self.check_param_validity(X)
+
+        if not inner_auto_ardl:
+            self._forecaster = _ARDL(
+                endog=y,
+                lags=self.lags,
+                exog=X,
+                order=inner_order,
+                trend=self.trend,
+                fixed=self.fixed,
+                causal=self.causal,
+                seasonal=self.seasonal,
+                deterministic=self.deterministic,
+                hold_back=self.hold_back,
+                period=self.period,
+                missing=self.missing,
+            )
+
+            self._fitted_forecaster = self._forecaster.fit(
+                cov_type=self.cov_type, cov_kwds=self.cov_kwds, use_t=self.use_t
+            )
+        else:
+            self._forecaster = _ardl_select_order(
+                endog=y,
+                maxlag=self.maxlag,
+                exog=X,
+                maxorder=self.maxorder,
+                trend=self.trend,
+                fixed=self.fixed,
+                causal=self.causal,
+                ic=self.ic,
+                glob=self.glob,
+                seasonal=self.seasonal,
+                deterministic=self.deterministic,
+                hold_back=self.hold_back,
+                period=self.period,
+                missing=self.missing,
+            )
+
+            self._fitted_forecaster = self._forecaster.model.fit(
+                cov_type=self.cov_type, cov_kwds=self.cov_kwds, use_t=self.use_t
+            )
+        return self
+
+    def summary(self):
+        """Get a summary of the fitted forecaster."""
+        self.check_is_fitted()
+        return self._fitted_forecaster.summary()
+
+    def _predict(self, fh, X=None):
+        """Forecast time series at future horizon.
+
+        private _predict containing the core logic, called from predict
+
+        State required:
+            Requires state to be "fitted".
+
+        Accesses in self:
+            Fitted model attributes ending in "_"
+            self.cutoff
+
+        Parameters
+        ----------
+        fh : guaranteed to be ForecastingHorizon or None, optional (default=None)
+            The forecasting horizon with the steps ahead to to predict.
+            If not passed in _fit, guaranteed to be passed here
+        X : optional (default=None)
+            guaranteed to be of a type in self.get_tag("X_inner_mtype")
+            Exogeneous time series for the forecast
+
+        Returns
+        -------
+        y_pred : pd.Series
+            Point predictions
+        """
+        # statsmodels requires zero-based indexing starting at the
+        # beginning of the training series when passing integers
+
+        start, end = fh.to_absolute_int(self._y.index[0], self.cutoff)[[0, -1]]
+        # statsmodels forecasts all periods from start to end of forecasting
+        # horizon, but only return given time points in forecasting horizon
+        valid_indices = fh.to_absolute(self.cutoff).to_pandas()
+
+        y_pred = self._fitted_forecaster.predict(
+            start=start, end=end, exog=self._X, exog_oos=X, fixed_oos=self.fixed_oos
+        )
+        return y_pred.loc[valid_indices]
+
+    def _update(self, y, X=None, update_params=True):
+        """Update time series to incremental training data.
+
+        private _update containing the core logic, called from update
+
+        State required:
+            Requires state to be "fitted".
+
+        Accesses in self:
+            Fitted model attributes ending in "_"
+            self.cutoff
+
+        Writes to self:
+            Sets fitted model attributes ending in "_", if update_params=True.
+            Does not write to self if update_params=False.
+
+        Parameters
+        ----------
+        y : guaranteed to be of a type in self.get_tag("y_inner_mtype")
+            Time series with which to update the forecaster.
+            if self.get_tag("scitype:y")=="univariate":
+                guaranteed to have a single column/variable
+            if self.get_tag("scitype:y")=="multivariate":
+                guaranteed to have 2 or more columns
+            if self.get_tag("scitype:y")=="both": no restrictions apply
+        X : optional (default=None)
+            guaranteed to be of a type in self.get_tag("X_inner_mtype")
+            Exogeneous time series for the forecast
+        update_params : bool, optional (default=True)
+            whether model parameters should be updated
+
+        Returns
+        -------
+        self : reference to self
+        """
+        warnings.warn("Defaulting to `update_params=True`")
+        update_params = True
+        if update_params:
+            # default to re-fitting if update is not implemented
+            warnings.warn(
+                f"NotImplementedWarning: {self.__class__.__name__} "
+                f"does not have a custom `update` method implemented. "
+                f"{self.__class__.__name__} will be refit each time "
+                f"`update` is called with update_params=True."
+            )
+            # we need to overwrite the mtype last seen, since the _y
+            #    may have been converted
+            mtype_last_seen = self._y_mtype_last_seen
+            # refit with updated data, not only passed data
+            self.fit(y=self._y, X=self._X, fh=self._fh)
+            # todo: should probably be self._fit, not self.fit
+            # but looping to self.fit for now to avoid interface break
+            self._y_mtype_last_seen = mtype_last_seen
+
+        # if update_params=False, and there are no components, do nothing
+        # if update_params=False, and there are components, we update cutoffs
+        elif self.is_composite():
+            # default to calling component _updates if update is not implemented
+            warnings.warn(
+                f"NotImplementedWarning: {self.__class__.__name__} "
+                f"does not have a custom `update` method implemented. "
+                f"{self.__class__.__name__} will update all component cutoffs each time"
+                f" `update` is called with update_params=False."
+            )
+            comp_forecasters = self._components(base_class=BaseForecaster)
+            for comp in comp_forecasters.values():
+                comp.update(y=y, X=X, update_params=False)
+
+        return self
+
+    def get_fitted_params(self):
+        """Get fitted parameters.
+
+        State required:
+            Requires state to be "fitted".
+
+        Returns
+        -------
+        fitted_params : dict
+        """
+        self.check_is_fitted()
+        fitted_params = {}
+        if isinstance(self._forecaster, _ARDL):
+            fitted_params["score"] = self._forecaster.score(
+                self._fitted_forecaster.params
+            )
+            fitted_params["hessian"] = self._forecaster.hessian(
+                self._fitted_forecaster.params
+            )
+            fitted_params["information"] = self._forecaster.information(
+                self._fitted_forecaster.params
+            )
+            fitted_params["loglike"] = self._forecaster.loglike(
+                self._fitted_forecaster.params
+            )
+        else:
+            if self._X is not None:
+                fitted_params["score"] = self._fitted_forecaster.model.score(
+                    self._fitted_forecaster.params
+                )
+                for x in ["_aic", "_bic", "_hqic"]:
+                    fitted_params[x[1:]] = eval("self._forecaster." + x)
+                fitted_params["hessian"] = self._fitted_forecaster.model.hessian(
+                    self._fitted_forecaster.params
+                )
+                fitted_params[
+                    "information"
+                ] = self._fitted_forecaster.model.information(
+                    self._fitted_forecaster.params
+                )
+                fitted_params["loglike"] = self._fitted_forecaster.model.loglike(
+                    self._fitted_forecaster.params
+                )
+        return fitted_params
+
+    @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`
+        """
+        params = [
+            {"lags": 1, "trend": "c", "order": 2},
+            {"lags": 1, "trend": "ct"},
+            {"auto_ardl": True, "maxlag": 2, "maxorder": 2},
+        ]
+        return params
diff --git a/sktime/forecasting/base/_base.py b/sktime/forecasting/base/_base.py
index 1ba3683f8..e7b31d5a2 100644
--- a/sktime/forecasting/base/_base.py
+++ b/sktime/forecasting/base/_base.py
@@ -147,7 +147,7 @@ class BaseForecaster(BaseEstimator):
             return NotImplemented
 
     def __rmul__(self, other):
-        """Magic * method, return (left) concatenated TransformerPipeline.
+        """Magic * method, return (left) concatenated TransformedTargetForecaster.
 
         Implemented for `other` being a transformer, otherwise returns `NotImplemented`.
 
@@ -177,6 +177,37 @@ class BaseForecaster(BaseEstimator):
         else:
             return NotImplemented
 
+    def __rpow__(self, other):
+        """Magic ** method, return (left) concatenated ForecastingPipeline.
+
+        Implemented for `other` being a transformer, otherwise returns `NotImplemented`.
+
+        Parameters
+        ----------
+        other: `sktime` transformer, must inherit from BaseTransformer
+            otherwise, `NotImplemented` is returned
+
+        Returns
+        -------
+        TransformedTargetForecaster object,
+            concatenation of `other` (first) with `self` (last).
+            not nested, contains only non-TransformerPipeline `sktime` steps
+        """
+        from sktime.forecasting.compose import ForecastingPipeline
+        from sktime.transformations.base import BaseTransformer
+        from sktime.transformations.series.adapt import TabularToSeriesAdaptor
+        from sktime.utils.sklearn import is_sklearn_transformer
+
+        # we wrap self in a pipeline, and concatenate with the other
+        #   the ForecastingPipeline does the rest, e.g., dispatch on other
+        if isinstance(other, BaseTransformer):
+            self_as_pipeline = ForecastingPipeline(steps=[self])
+            return other**self_as_pipeline
+        elif is_sklearn_transformer(other):
+            return TabularToSeriesAdaptor(other) ** self
+        else:
+            return NotImplemented
+
     def __or__(self, other):
         """Magic | method, return MultiplexForecaster.
 
diff --git a/sktime/forecasting/compose/_pipeline.py b/sktime/forecasting/compose/_pipeline.py
index d913e118e..38cb78fa2 100644
--- a/sktime/forecasting/compose/_pipeline.py
+++ b/sktime/forecasting/compose/_pipeline.py
@@ -211,11 +211,10 @@ class _Pipeline(
         """
         from sklearn.preprocessing import StandardScaler
 
-        from sktime.forecasting.arima import ARIMA
         from sktime.forecasting.naive import NaiveForecaster
+        from sktime.forecasting.sarimax import SARIMAX
         from sktime.transformations.series.adapt import TabularToSeriesAdaptor
         from sktime.transformations.series.exponent import ExponentTransformer
-        from sktime.utils.validation._dependencies import _check_estimator_deps
 
         # StandardScaler does not skip fit, NaiveForecaster is not probabilistic
         STEPS1 = [
@@ -224,20 +223,16 @@ class _Pipeline(
         ]
         params1 = {"steps": STEPS1}
 
-        if _check_estimator_deps(ARIMA, severity="none"):
-            # ARIMA has probabilistic methods, ExponentTransformer skips fit
-            STEPS2 = [
-                ("transformer", ExponentTransformer()),
-                ("forecaster", ARIMA()),
-            ]
-            params2 = {"steps": STEPS2}
-
-            params3 = {"steps": [ExponentTransformer(), ARIMA()]}
+        # ARIMA has probabilistic methods, ExponentTransformer skips fit
+        STEPS2 = [
+            ("transformer", ExponentTransformer()),
+            ("forecaster", SARIMAX()),
+        ]
+        params2 = {"steps": STEPS2}
 
-            return [params1, params2, params3]
+        params3 = {"steps": [ExponentTransformer(), SARIMAX()]}
 
-        else:
-            return params1
+        return [params1, params2, params3]
 
 
 # we ensure that internally we convert to pd.DataFrame for now
@@ -261,21 +256,40 @@ class ForecastingPipeline(_Pipeline):
     >>> from sktime.datasets import load_longley
     >>> from sktime.forecasting.naive import NaiveForecaster
     >>> from sktime.forecasting.compose import ForecastingPipeline
-    >>> from sktime.transformations.series.impute import Imputer
     >>> from sktime.transformations.series.adapt import TabularToSeriesAdaptor
+    >>> from sktime.transformations.series.impute import Imputer
     >>> from sktime.forecasting.base import ForecastingHorizon
     >>> from sktime.forecasting.model_selection import temporal_train_test_split
     >>> from sklearn.preprocessing import MinMaxScaler
     >>> y, X = load_longley()
     >>> y_train, _, X_train, X_test = temporal_train_test_split(y, X)
     >>> fh = ForecastingHorizon(X_test.index, is_relative=False)
+
+        Example 1: string/estimator pairs
     >>> pipe = ForecastingPipeline(steps=[
     ...     ("imputer", Imputer(method="mean")),
     ...     ("minmaxscaler", TabularToSeriesAdaptor(MinMaxScaler())),
-    ...     ("forecaster", NaiveForecaster(strategy="drift"))])
+    ...     ("forecaster", NaiveForecaster(strategy="drift")),
+    ... ])
     >>> pipe.fit(y_train, X_train)
     ForecastingPipeline(...)
     >>> y_pred = pipe.predict(fh=fh, X=X_test)
+
+        Example 2: without strings
+    >>> pipe = ForecastingPipeline([
+    ...     Imputer(method="mean"),
+    ...     TabularToSeriesAdaptor(MinMaxScaler()),
+    ...     NaiveForecaster(strategy="drift"),
+    ... ])
+
+        Example 3: using the dunder method
+        Note: * (= apply to `y`) has precedence over ** (= apply to `X`)
+    >>> forecaster = NaiveForecaster(strategy="drift")
+    >>> imputer = Imputer(method="mean")
+    >>> pipe = (imputer * MinMaxScaler()) ** forecaster
+
+        Example 3b: using the dunder method, alternative
+    >>> pipe = imputer ** MinMaxScaler() ** forecaster
     """
 
     _tags = {
@@ -310,6 +324,49 @@ class ForecastingPipeline(_Pipeline):
         """Return reference to the forecaster in the pipeline. Valid after _fit."""
         return self.steps_[-1][1]
 
+    def __rpow__(self, other):
+        """Magic ** method, return (left) concatenated ForecastingPipeline.
+
+        Implemented for `other` being a transformer, otherwise returns `NotImplemented`.
+
+        Parameters
+        ----------
+        other: `sktime` transformer, must inherit from BaseTransformer
+            otherwise, `NotImplemented` is returned
+
+        Returns
+        -------
+        ForecastingPipeline object,
+            concatenation of `other` (first) with `self` (last).
+            not nested, contains only non-TransformerPipeline `sktime` steps
+        """
+        from sktime.transformations.base import BaseTransformer
+        from sktime.transformations.compose import TransformerPipeline
+
+        _, ests = zip(*self.steps_)
+        names = tuple(self._get_estimator_names(self.steps))
+        if isinstance(other, TransformerPipeline):
+            _, trafos_o = zip(*other.steps_)
+            names_o = tuple(other._get_estimator_names(other.steps))
+            new_names = names_o + names
+            new_ests = trafos_o + ests
+        elif isinstance(other, BaseTransformer):
+            new_names = (type(other).__name__,) + names
+            new_ests = (other,) + ests
+        elif self._is_name_and_est(other, BaseTransformer):
+            other_name = other[0]
+            other_trafo = other[1]
+            new_names = (other_name,) + names
+            new_ests = (other_trafo,) + ests
+        else:
+            return NotImplemented
+
+        # if all the names are equal to class names, we eat them away
+        if all(type(x[1]).__name__ == x[0] for x in zip(new_names, new_ests)):
+            return ForecastingPipeline(steps=list(new_ests))
+        else:
+            return ForecastingPipeline(steps=list(zip(new_names, new_ests)))
+
     def _fit(self, y, X=None, fh=None):
         """Fit to training data.
 
diff --git a/sktime/forecasting/compose/_reduce.py b/sktime/forecasting/compose/_reduce.py
index 3ec20ac87..ee54baa99 100644
--- a/sktime/forecasting/compose/_reduce.py
+++ b/sktime/forecasting/compose/_reduce.py
@@ -1315,6 +1315,7 @@ class DirectReductionForecaster(BaseForecaster):
 
     _tags = {
         "requires-fh-in-fit": True,  # is the forecasting horizon required in fit?
+        "ignores-exogeneous-X": False,
         "X_inner_mtype": "pd.DataFrame",
         "y_inner_mtype": "pd.DataFrame",
     }
diff --git a/sktime/transformations/panel/channel_selection.py b/sktime/transformations/panel/channel_selection.py
new file mode 100644
index 000000000..103cbe002
--- /dev/null
+++ b/sktime/transformations/panel/channel_selection.py
@@ -0,0 +1,332 @@
+# -*- coding: utf-8 -*-
+"""Channel Selection techniques for Multivariate Time Series Classification.
+
+A transformer that selects a subset of channels/dimensions for time series
+classification using a scoring system with an elbow point method.
+"""
+
+__author__ = ["haskarb", "a-pasos-ruiz", "TonyBagnall"]
+__all__ = ["ElbowClassSum", "ElbowClassPairwise"]
+
+
+import itertools
+import time
+
+import numpy as np
+import pandas as pd
+from scipy.spatial.distance import euclidean
+from sklearn.neighbors import NearestCentroid
+
+from sktime.datatypes._panel._convert import from_3d_numpy_to_nested
+from sktime.transformations.base import BaseTransformer
+
+
+def _eu_dist(x, y):
+    """Calculate the euclidean distance."""
+    return euclidean(x, y)
+
+
+def _detect_knee_point(values, indices):
+    """Find elbow point."""
+    n_points = len(values)
+    all_coords = np.vstack((range(n_points), values)).T
+    first_point = all_coords[0]
+    line_vec = all_coords[-1] - all_coords[0]
+    line_vec_norm = line_vec / np.sqrt(np.sum(line_vec**2))
+    vec_from_first = all_coords - first_point
+    scalar_prod = np.sum(vec_from_first * np.tile(line_vec_norm, (n_points, 1)), axis=1)
+    vec_from_first_parallel = np.outer(scalar_prod, line_vec_norm)
+    vec_to_line = vec_from_first - vec_from_first_parallel
+    dist_to_line = np.sqrt(np.sum(vec_to_line**2, axis=1))
+    knee_idx = np.argmax(dist_to_line)
+    knee = values[knee_idx]
+    best_dims = [idx for (elem, idx) in zip(values, indices) if elem > knee]
+    if len(best_dims) == 0:
+        return [knee_idx], knee_idx
+
+    return (best_dims,)
+
+
+class _distance_matrix:
+    """Create distance matrix."""
+
+    def distance(self, centroid_frame):
+        """Fuction to create DM."""
+        distance_pair = list(
+            itertools.combinations(range(0, centroid_frame.shape[0]), 2)
+        )
+        # exit()
+
+        map_cls = centroid_frame.class_vals.to_dict()
+        distance_frame = pd.DataFrame()
+        for class_ in distance_pair:
+
+            class_pair = []
+            # calculate the distance of centroid here
+            for _, (q, t) in enumerate(
+                zip(
+                    centroid_frame.drop(["class_vals"], axis=1).iloc[class_[0], :],
+                    centroid_frame.iloc[class_[1], :],
+                )
+            ):
+                # print(eu_dist(q.values, t.values))
+                class_pair.append(_eu_dist(q.values, t.values))
+                dict_ = {
+                    f"Centroid_{map_cls[class_[0]]}_{map_cls[class_[1]]}": class_pair
+                }
+                # print(class_[0])
+
+            distance_frame = pd.concat([distance_frame, pd.DataFrame(dict_)], axis=1)
+
+        return distance_frame
+
+
+class _shrunk_centroid:
+    """Create centroid."""
+
+    def __init__(self, shrink=0):
+        self.shrink = shrink
+
+    def create_centroid(self, X, y):
+        """Create the centroid for each class."""
+        _, ncols, _ = X.shape
+        cols = ["dim_" + str(i) for i in range(ncols)]
+        ts = X
+        centroids = []
+
+        # le = LabelEncoder()
+        # y_ind = le.fit_transform(y)
+
+        for dim in range(ts.shape[1]):
+            train = ts[:, dim, :]
+            clf = NearestCentroid(train)
+            clf = NearestCentroid(shrink_threshold=self.shrink)
+            clf.fit(train, y)
+            centroids.append(clf.centroids_)
+
+        centroid_frame = from_3d_numpy_to_nested(
+            np.stack(centroids, axis=1), column_names=cols
+        )
+        centroid_frame["class_vals"] = clf.classes_
+
+        return centroid_frame.reset_index(drop=True)
+
+
+class ElbowClassSum(BaseTransformer):
+    """Elbow Class Sum (ECS) transformer to select a subset of channels.
+
+    Overview: From the input of multivariate time series data, create a distance
+    matrix [1] by calculating the distance between each class centroid. The
+    ECS selects the subset of channels using the elbow method, which maximizes the
+    distance between the class centroids by aggregating the distance for every
+    class pair across each channel.
+
+    Note: Channels, variables, dimensions, features are used interchangeably in
+    literature.
+
+    Attributes
+    ----------
+    channels_selected_ : list of integers; integer being the index of the channel
+        List of channels selected by the ECS.
+    distance_frame_ : DataFrame
+        distance matrix of the class centroids pair and channels.
+            ``shape = [n_channels, n_class_centroids_pairs]``
+        Table 1 provides an illustration in [1].
+    train_time_ : int
+        Time taken to train the ECS.
+
+    Notes
+    -----
+    Original repository: https://github.com/mlgig/Channel-Selection-MTSC
+
+    References
+    ----------
+    ..[1]: Bhaskar Dhariyal et al. “Fast Channel Selection for Scalable Multivariate
+    Time Series Classification.” AALTD, ECML-PKDD, Springer, 2021
+
+    Examples
+    --------
+    >>> from sktime.transformations.panel.channel_selection import ElbowClassSum
+    >>> from sktime.datasets import load_UCR_UEA_dataset
+    >>> cs = ElbowClassSum()
+    >>> X_train, y_train = load_UCR_UEA_dataset(
+    ...     "Cricket", split="train", return_X_y=True
+    ... )
+    >>> cs.fit(X_train, y_train)
+    ElbowClassSum(...)
+    >>> Xt = cs.transform(X_train)
+    """
+
+    _tags = {
+        "scitype:transform-input": "Series",
+        # what is the scitype of X: Series, or Panel
+        # "scitype:transform-output": "Primitives",
+        # what scitype is returned: Primitives, Series, Panel
+        "scitype:instancewise": True,  # is this an instance-wise transform?
+        "univariate-only": False,  # can the transformer handle multivariate X?
+        "X_inner_mtype": "numpy3D",  # which mtypes do _fit/_predict support for X?
+        "y_inner_mtype": "numpy1D",  # which mtypes do _fit/_predict support for y?
+        "requires_y": True,  # does y need to be passed in fit?
+        "fit_is_empty": False,  # is fit empty and can be skipped? Yes = True
+        "skip-inverse-transform": True,  # is inverse-transform skipped when called?
+        "capability:unequal_length": False,
+        # can the transformer handle unequal length time series (if passed Panel)?
+    }
+
+    def __init__(self):
+
+        super(ElbowClassSum, self).__init__()
+
+    def _fit(self, X, y):
+        """Fit ECS to a specified X and y.
+
+        Parameters
+        ----------
+        X: pandas DataFrame or np.ndarray
+            The training input samples.
+        y: array-like or list
+            The class values for X.
+
+        Returns
+        -------
+        self : reference to self.
+
+        """
+        self.channels_selected_ = []
+        start = int(round(time.time() * 1000))
+        centroid_obj = _shrunk_centroid(0)
+        centroids = centroid_obj.create_centroid(X.copy(), y)
+        distances = _distance_matrix()
+        self.distance_frame_ = distances.distance(centroids)
+        distance = self.distance_frame_.sum(axis=1).sort_values(ascending=False).values
+        indices = self.distance_frame_.sum(axis=1).sort_values(ascending=False).index
+        self.channels_selected_.extend(_detect_knee_point(distance, indices)[0])
+        self.train_time_ = int(round(time.time() * 1000)) - start
+        return self
+
+    def _transform(self, X, y=None):
+        """
+        Transform X and return a transformed version.
+
+        Parameters
+        ----------
+        X : pandas DataFrame or np.ndarray
+            The input data to transform.
+
+        Returns
+        -------
+        output : pandas DataFrame
+            X with a subset of channels
+        """
+        return X[:, self.channels_selected_, :]
+
+
+class ElbowClassPairwise(BaseTransformer):
+    """Elbow Class Pairwise (ECP) transformer to select a subset of channels.
+
+    Overview: From the input of multivariate time series data, create a distance
+    matrix [1] by calculating the distance between each class centroid. The ECP
+    selects the subset of channels using the elbow method that maximizes the
+    distance between each class centroids pair across all channels.
+
+    Note: Channels, variables, dimensions, features are used interchangeably in
+    literature.
+
+    Attributes
+    ----------
+    channels_selected_ : list of integers; integer being the index of the channel
+        List of channels selected by the ECS.
+    distance_frame_ : DataFrame
+        distance matrix of the class centroids pair and channels.
+            ``shape = [n_channels, n_class_centroids_pairs]``
+        Table 1 provides an illustration in [1].
+    train_time_ : int
+        Time taken to train the ECP.
+
+    Notes
+    -----
+    Original repository: https://github.com/mlgig/Channel-Selection-MTSC
+
+    References
+    ----------
+    ..[1]: Bhaskar Dhariyal et al. “Fast Channel Selection for Scalable Multivariate
+    Time Series Classification.” AALTD, ECML-PKDD, Springer, 2021
+
+    Examples
+    --------
+    >>> from sktime.transformations.panel.channel_selection import ElbowClassPairwise
+    >>> from sktime.datasets import load_UCR_UEA_dataset
+    >>> cs = ElbowClassPairwise()
+    >>> X_train, y_train = load_UCR_UEA_dataset(
+    ...     "Cricket", split="train", return_X_y=True
+    ... )
+    >>> cs.fit(X_train, y_train)
+    ElbowClassPairwise(...)
+    >>> Xt = cs.transform(X_train)
+    """
+
+    _tags = {
+        "scitype:transform-input": "Series",
+        # what is the scitype of X: Series, or Panel
+        # "scitype:transform-output": "Primitives",
+        # what scitype is returned: Primitives, Series, Panel
+        "scitype:instancewise": True,  # is this an instance-wise transform?
+        "univariate-only": False,  # can the transformer handle multivariate X?
+        "X_inner_mtype": "numpy3D",  # which mtypes do _fit/_predict support for X?
+        "y_inner_mtype": "numpy1D",  # which mtypes do _fit/_predict support for y?
+        "requires_y": True,  # does y need to be passed in fit?
+        "fit_is_empty": False,  # is fit empty and can be skipped? Yes = True
+        "skip-inverse-transform": True,  # is inverse-transform skipped when called?
+        "capability:unequal_length": False,
+        # can the transformer handle unequal length time series (if passed Panel)?
+    }
+
+    def __init__(self):
+        super(ElbowClassPairwise, self).__init__()
+
+    def _fit(self, X, y):
+        """Fit ECP to a specified X and y.
+
+        Parameters
+        ----------
+        X: pandas DataFrame or np.ndarray
+            The training input samples.
+        y: array-like or list
+            The class values for X.
+
+        Returns
+        -------
+        self : reference to self.
+
+        """
+        self.channels_selected_ = []
+        start = int(round(time.time() * 1000))
+        centroid_obj = _shrunk_centroid(0)
+        df = centroid_obj.create_centroid(X.copy(), y)
+        obj = _distance_matrix()
+        self.distance_frame_ = obj.distance(df)
+
+        for pairdistance in self.distance_frame_.iteritems():
+            distance = pairdistance[1].sort_values(ascending=False).values
+            indices = pairdistance[1].sort_values(ascending=False).index
+            self.channels_selected_.extend(_detect_knee_point(distance, indices)[0])
+            self.channels_selected_ = list(set(self.channels_selected_))
+        self.train_time_ = int(round(time.time() * 1000)) - start
+        # self._is_fitted = True
+        return self
+
+    def _transform(self, X, y=None):
+        """
+        Transform X and return a transformed version.
+
+        Parameters
+        ----------
+        X : pandas DataFrame or np.ndarray
+            The input data to transform.
+
+        Returns
+        -------
+        output : pandas DataFrame
+            X with a subset of channels
+        """
+        return X[:, self.channels_selected_, :]
diff --git a/sktime/transformations/series/clasp.py b/sktime/transformations/series/clasp.py
index 96c297f0c..49a5b9d45 100644
--- a/sktime/transformations/series/clasp.py
+++ b/sktime/transformations/series/clasp.py
@@ -364,6 +364,8 @@ class ClaSPTransformer(BaseTransformer):
         size of window for sliding.
     scoring_metric :      string, default = ROC_AUC
         the scoring metric to use in ClaSP - choose from ROC_AUC or F1
+    exclusion_radius : int
+        Exclusion Radius for change points to be non-trivial matches
 
     Notes
     -----
@@ -398,9 +400,12 @@ class ClaSPTransformer(BaseTransformer):
         "fit_is_empty": True,
     }
 
-    def __init__(self, window_length=10, scoring_metric="ROC_AUC"):
+    def __init__(
+        self, window_length=10, scoring_metric="ROC_AUC", exclusion_radius=0.05
+    ):
         self.window_length = int(window_length)
         self.scoring_metric = scoring_metric
+        self.exclusion_radius = exclusion_radius
         super(ClaSPTransformer, self).__init__()
 
     def _transform(self, X, y=None):
@@ -426,7 +431,12 @@ class ClaSPTransformer(BaseTransformer):
         scoring_metric_call = self._check_scoring_metric(self.scoring_metric)
 
         X = X.flatten()
-        Xt, _ = clasp(X, self.window_length, score=scoring_metric_call)
+        Xt, _ = clasp(
+            X,
+            self.window_length,
+            score=scoring_metric_call,
+            exclusion_radius=self.exclusion_radius,
+        )
 
         return Xt
 
diff --git a/sktime/transformations/series/lag.py b/sktime/transformations/series/lag.py
index 1fa39932f..88d7644b3 100644
--- a/sktime/transformations/series/lag.py
+++ b/sktime/transformations/series/lag.py
@@ -259,13 +259,10 @@ class Lag(BaseTransformer):
 
             Xt_list.append(Xt)
 
-        if len(Xt_list) == 1:
-            Xt = Xt_list[0]
-        else:
-            lag_names = self._yield_shift_param_names()
-            Xt = pd.concat(Xt_list, axis=1, keys=lag_names, names=["lag", "variable"])
-            if self.flatten_transform_index:
-                Xt.columns = flatten_multiindex(Xt.columns)
+        lag_names = self._yield_shift_param_names()
+        Xt = pd.concat(Xt_list, axis=1, keys=lag_names, names=["lag", "variable"])
+        if self.flatten_transform_index:
+            Xt.columns = flatten_multiindex(Xt.columns)
 
         # some pandas versions do not sort index automatically after concat
         # so removing will break specific pandas versions


Test patch:
diff --git a/sktime/forecasting/compose/tests/test_pipeline.py b/sktime/forecasting/compose/tests/test_pipeline.py
index e3e1a77d1..11de3c6a8 100644
--- a/sktime/forecasting/compose/tests/test_pipeline.py
+++ b/sktime/forecasting/compose/tests/test_pipeline.py
@@ -8,16 +8,15 @@ __all__ = []
 
 import numpy as np
 import pandas as pd
-import pytest
 from sklearn.preprocessing import MinMaxScaler
 
 from sktime.datasets import load_airline
 from sktime.datatypes import get_examples
 from sktime.datatypes._utilities import get_window
-from sktime.forecasting.arima import ARIMA
 from sktime.forecasting.compose import ForecastingPipeline, TransformedTargetForecaster
 from sktime.forecasting.model_selection import temporal_train_test_split
 from sktime.forecasting.naive import NaiveForecaster
+from sktime.forecasting.sarimax import SARIMAX
 from sktime.forecasting.trend import PolynomialTrendForecaster
 from sktime.transformations.hierarchical.aggregate import Aggregator
 from sktime.transformations.series.adapt import TabularToSeriesAdaptor
@@ -25,7 +24,8 @@ from sktime.transformations.series.detrend import Detrender
 from sktime.transformations.series.exponent import ExponentTransformer
 from sktime.transformations.series.impute import Imputer
 from sktime.transformations.series.outlier_detection import HampelFilter
-from sktime.utils.validation._dependencies import _check_estimator_deps
+from sktime.utils._testing.estimator_checks import _assert_array_almost_equal
+from sktime.utils._testing.series import _make_series
 
 
 def test_pipeline():
@@ -130,10 +130,6 @@ def test_pipeline_with_detrender():
     trans_fc.predict(1)
 
 
-@pytest.mark.skipif(
-    not _check_estimator_deps(ARIMA, severity="none"),
-    reason="skip test if required soft dependencies not available",
-)
 def test_nested_pipeline_with_index_creation_y_before_X():
     """Tests a nested pipeline where y indices are created before X indices.
 
@@ -148,7 +144,7 @@ def test_nested_pipeline_with_index_creation_y_before_X():
     X_test = get_window(X, window_length=1)
 
     # Aggregator creates indices for y (via *), then for X (via ForecastingPipeline)
-    f = Aggregator() * ForecastingPipeline([Aggregator(), ARIMA()])
+    f = Aggregator() * ForecastingPipeline([Aggregator(), SARIMAX()])
 
     f.fit(y=y_train, X=X_train, fh=1)
     y_pred = f.predict(X=X_test)
@@ -159,10 +155,6 @@ def test_nested_pipeline_with_index_creation_y_before_X():
     assert len(y_pred) == 9
 
 
-@pytest.mark.skipif(
-    not _check_estimator_deps(ARIMA, severity="none"),
-    reason="skip test if required soft dependencies not available",
-)
 def test_nested_pipeline_with_index_creation_X_before_y():
     """Tests a nested pipeline where X indices are created before y indices.
 
@@ -177,7 +169,7 @@ def test_nested_pipeline_with_index_creation_X_before_y():
     X_test = get_window(X, window_length=1)
 
     # Aggregator creates indices for X (via ForecastingPipeline), then for y (via *)
-    f = ForecastingPipeline([Aggregator(), Aggregator() * ARIMA()])
+    f = ForecastingPipeline([Aggregator(), Aggregator() * SARIMAX()])
 
     f.fit(y=y_train, X=X_train, fh=1)
     y_pred = f.predict(X=X_test)
@@ -186,3 +178,88 @@ def test_nested_pipeline_with_index_creation_X_before_y():
     assert isinstance(y_pred, pd.DataFrame)
     assert isinstance(y_pred.index, pd.MultiIndex)
     assert len(y_pred) == 9
+
+
+def test_forecasting_pipeline_dunder_endog():
+    """Test forecasting pipeline dunder for endogeneous transformation."""
+    y = load_airline()
+    y_train, y_test = temporal_train_test_split(y)
+
+    forecaster = ExponentTransformer() * MinMaxScaler() * NaiveForecaster()
+
+    assert isinstance(forecaster, TransformedTargetForecaster)
+    assert isinstance(forecaster.steps[0], ExponentTransformer)
+    assert isinstance(forecaster.steps[1], TabularToSeriesAdaptor)
+    assert isinstance(forecaster.steps[2], NaiveForecaster)
+
+    fh = np.arange(len(y_test)) + 1
+    forecaster.fit(y_train, fh=fh)
+    actual = forecaster.predict()
+
+    def compute_expected_y_pred(y_train, fh):
+        # fitting
+        yt = y_train.copy()
+        t1 = ExponentTransformer()
+        yt = t1.fit_transform(yt)
+        t2 = TabularToSeriesAdaptor(MinMaxScaler())
+        yt = t2.fit_transform(yt)
+        forecaster = NaiveForecaster()
+        forecaster.fit(yt, fh=fh)
+
+        # predicting
+        y_pred = forecaster.predict()
+        y_pred = t2.inverse_transform(y_pred)
+        y_pred = t1.inverse_transform(y_pred)
+        return y_pred
+
+    expected = compute_expected_y_pred(y_train, fh)
+    np.testing.assert_array_equal(actual, expected)
+
+
+def test_forecasting_pipeline_dunder_exog():
+    """Test forecasting pipeline dunder for exogeneous transformation."""
+    y = _make_series()
+    y_train, y_test = temporal_train_test_split(y)
+    X = _make_series(n_columns=2)
+    X_train, X_test = temporal_train_test_split(X)
+
+    forecaster = ExponentTransformer() ** MinMaxScaler() ** SARIMAX(random_state=3)
+    forecaster_alt = (ExponentTransformer() * MinMaxScaler()) ** SARIMAX(random_state=3)
+
+    assert isinstance(forecaster, ForecastingPipeline)
+    assert isinstance(forecaster.steps[0], ExponentTransformer)
+    assert isinstance(forecaster.steps[1], TabularToSeriesAdaptor)
+    assert isinstance(forecaster.steps[2], SARIMAX)
+    assert isinstance(forecaster_alt, ForecastingPipeline)
+    assert isinstance(forecaster_alt.steps[0], ExponentTransformer)
+    assert isinstance(forecaster_alt.steps[1], TabularToSeriesAdaptor)
+    assert isinstance(forecaster_alt.steps[2], SARIMAX)
+
+    fh = np.arange(len(y_test)) + 1
+    forecaster.fit(y_train, fh=fh, X=X_train)
+    actual = forecaster.predict(X=X_test)
+
+    forecaster_alt.fit(y_train, fh=fh, X=X_train)
+    actual_alt = forecaster_alt.predict(X=X_test)
+
+    def compute_expected_y_pred(y_train, X_train, X_test, fh):
+        # fitting
+        yt = y_train.copy()
+        Xt = X_train.copy()
+        t1 = ExponentTransformer()
+        Xt = t1.fit_transform(Xt)
+        t2 = TabularToSeriesAdaptor(MinMaxScaler())
+        Xt = t2.fit_transform(Xt)
+        forecaster = SARIMAX(random_state=3)
+        forecaster.fit(yt, fh=fh, X=Xt)
+
+        # predicting
+        Xtt = X_test.copy()
+        Xtt = t1.transform(Xtt)
+        Xtt = t2.transform(Xtt)
+        y_pred = forecaster.predict(X=Xtt)
+        return y_pred
+
+    expected = compute_expected_y_pred(y_train, X_train, X_test, fh)
+    _assert_array_almost_equal(actual, expected, decimal=2)
+    _assert_array_almost_equal(actual_alt, expected, decimal=2)
diff --git a/sktime/forecasting/tests/test_ardl.py b/sktime/forecasting/tests/test_ardl.py
new file mode 100644
index 000000000..fb9b5332d
--- /dev/null
+++ b/sktime/forecasting/tests/test_ardl.py
@@ -0,0 +1,147 @@
+# -*- coding: utf-8 -*-
+"""Tests the ARDL model."""
+__author__ = ["kcc-lion"]
+
+from numpy.testing import assert_allclose
+from statsmodels.datasets import danish_data, grunfeld, longley
+from statsmodels.tsa.ardl import ARDL as _ARDL
+from statsmodels.tsa.ardl import ardl_select_order as _ardl_select_order
+
+from sktime.datasets import load_macroeconomic
+from sktime.forecasting.ardl import ARDL
+from sktime.forecasting.base import ForecastingHorizon
+
+
+def test_against_statsmodels():
+    """Compare sktime's ARDL interface with statsmodels ARDL."""
+    # data
+    data = longley.load_pandas().data
+    oos = data.iloc[-5:, :]
+    data = data.iloc[:-5, :]
+    y = data.TOTEMP
+    X = data[["GNPDEFL", "GNP"]]
+    X_oos = oos[["GNPDEFL", "GNP"]]
+    # fit
+    sm_ardl = _ARDL(y, 2, X, {"GNPDEFL": 1, "GNP": 2}, trend="c")
+    res = sm_ardl.fit()
+    ardl_sktime = ARDL(lags=2, order={"GNPDEFL": 1, "GNP": 2}, trend="c")
+    ardl_sktime.fit(y=y, X=X, fh=None)
+    # predict
+    fh = ForecastingHorizon([1, 2, 3])
+    start, end = X.shape[0] + fh[0] - 1, X.shape[0] + fh[-1] - 1
+    y_pred_stats = sm_ardl.predict(res.params, start=start, end=end, exog_oos=X_oos)
+    y_pred = ardl_sktime.predict(fh=fh, X=X_oos)
+    return assert_allclose(y_pred, y_pred_stats)
+
+
+def test_against_statsmodels_2():
+    """Compare sktime's ARDL interface with statsmodels ARDL with different data."""
+    # data
+    data = grunfeld.load_pandas().data
+    oos = data.iloc[-5:, :]
+    data = data.iloc[:-5, :]
+    y = data.value
+    X = data[["capital", "invest"]]
+    X_oos = oos[["capital", "invest"]]
+    # fit
+    lags = 1
+    trend = "ct"
+    order = 2
+    sm_ardl = _ARDL(y, lags, X, order=order, trend=trend)
+    res = sm_ardl.fit()
+    ardl_sktime = ARDL(lags=lags, order=order, trend=trend)
+    ardl_sktime.fit(y=y, X=X, fh=None)
+    # predict
+    fh = ForecastingHorizon([1, 2, 3])
+    start, end = X.shape[0] + fh[0] - 1, X.shape[0] + fh[-1] - 1
+    y_pred_stats = sm_ardl.predict(res.params, start=start, end=end, exog_oos=X_oos)
+    y_pred = ardl_sktime.predict(fh=fh, X=X_oos)
+    return assert_allclose(y_pred, y_pred_stats)
+
+
+def test_against_statsmodels_3():
+    """Compare sktime's ARDL interface with statsmodels ARDL with X=None."""
+    # data
+    data = longley.load_pandas().data
+    data = data.iloc[:-5, :]
+    y = data.TOTEMP
+    X = None
+    X_oos = None
+    # fit
+    sm_ardl = _ARDL(y, lags=2, exog=None, trend="c")
+    res = sm_ardl.fit()
+    ardl_sktime = ARDL(lags=2, trend="c")
+    ardl_sktime.fit(y=y, X=X, fh=None)
+    # predict
+    fh = ForecastingHorizon([1, 2, 3])
+    start, end = y.shape[0] + fh[0] - 1, y.shape[0] + fh[-1] - 1
+    y_pred_stats = sm_ardl.predict(res.params, start=start, end=end, exog_oos=X_oos)
+    y_pred = ardl_sktime.predict(fh=fh, X=X_oos)
+    return assert_allclose(y_pred, y_pred_stats)
+
+
+def test_against_statsmodels_4():
+    """Compare sktime's ARDL interface with statsmodels ARDL."""
+    # data
+    data = load_macroeconomic()
+    data = data.iloc[:-5, :]
+    y = data.realgdp
+    X = None
+    X_oos = None
+    # fit
+    sm_ardl = _ARDL(y, lags=2, exog=None, trend="c")
+    res = sm_ardl.fit()
+    ardl_sktime = ARDL(lags=2, trend="c")
+    ardl_sktime.fit(y=y, X=X, fh=None)
+    # predict
+    fh = ForecastingHorizon([1, 2, 3])
+    start, end = y.shape[0] + fh[0] - 1, y.shape[0] + fh[-1] - 1
+    y_pred_stats = sm_ardl.predict(res.params, start=start, end=end, exog_oos=X_oos)
+    y_pred = ardl_sktime.predict(fh=fh, X=X_oos)
+    return assert_allclose(y_pred, y_pred_stats)
+
+
+def test_auto_ardl():
+    """Compare sktime's ARDL interface with statsmodels ardl_select_order."""
+    # data
+    data = longley.load_pandas().data
+    oos = data.iloc[-5:, :]
+    data = data.iloc[:-5, :]
+    y = data.TOTEMP
+    X = data[["GNPDEFL", "GNP"]]
+    X_oos = oos[["GNPDEFL", "GNP"]]
+    maxlag = 2
+    maxorder = 2
+    trend = "c"
+    # fit
+    sm_ardl = _ardl_select_order(
+        endog=y, maxlag=maxlag, exog=X, maxorder=maxorder, trend=trend
+    )
+    res = sm_ardl.model.fit()
+    ardl_sktime = ARDL(auto_ardl=True, maxlag=maxlag, maxorder=maxorder, trend=trend)
+    ardl_sktime.fit(y=y, X=X, fh=None)
+    # predict
+    fh = ForecastingHorizon([0])
+    start, end = X.shape[0] + fh[0] - 1, X.shape[0] + fh[-1] - 1
+    y_pred_stats = res.predict(start=start, end=end, exog_oos=X_oos)
+    y_pred = ardl_sktime.predict(fh=fh, X=X_oos)
+    return assert_allclose(y_pred, y_pred_stats)
+
+
+def test_against_statsmodels_5():
+    """Compare sktime's ARDL interface with statsmodels ARDL."""
+    # data
+    data = danish_data.load().data
+    data[["lrm", "lry", "ibo", "ide"]]
+    y = data.lrm
+    X = data[["lry", "ibo", "ide"]]
+    # fit
+    sel_res = _ardl_select_order(
+        data.lrm, 3, data[["lry", "ibo", "ide"]], 3, ic="aic", trend="c"
+    )
+    res = sel_res.model.fit()
+    ardl_sktime = ARDL(auto_ardl=True, maxlag=3, maxorder=3, trend="c", ic="aic")
+    ardl_sktime.fit(y=y, X=X, fh=None)
+    ardl_loglik = ardl_sktime.get_fitted_params()["loglike"]
+    sm_loglik = sel_res.model.loglike(res.params)
+    return assert_allclose(ardl_loglik, sm_loglik)
diff --git a/sktime/transformations/panel/tests/test_channel_selection.py b/sktime/transformations/panel/tests/test_channel_selection.py
new file mode 100644
index 000000000..9af05f459
--- /dev/null
+++ b/sktime/transformations/panel/tests/test_channel_selection.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+"""Channel selection test code."""
+from sktime.datasets import load_basic_motions
+from sktime.transformations.panel.channel_selection import ElbowClassPairwise
+
+
+def test_cs_basic_motions():
+    """Test channel selection on basic motions dataset."""
+    X, y = load_basic_motions(split="train", return_X_y=True)
+
+    ecp = ElbowClassPairwise()
+
+    ecp.fit(X, y)
+
+    # transform the training data
+
+    ecp.transform(X, y)
+
+    # test shape pf transformed data should be (n_samples, n_channels_selected)
+    assert ecp.transform(X, y).shape == (X.shape[0], len(ecp.channels_selected_))
+
+    # test shape of transformed data should be (n_samples, n_channels_selected)
+
+    X_test, y_test = load_basic_motions(split="test", return_X_y=True)
+
+    assert ecp.transform(X_test).shape == (X_test.shape[0], len(ecp.channels_selected_))
diff --git a/sktime/transformations/series/tests/test_featureizer.py b/sktime/transformations/series/tests/test_featureizer.py
index 53a00eb38..1f7149e1b 100644
--- a/sktime/transformations/series/tests/test_featureizer.py
+++ b/sktime/transformations/series/tests/test_featureizer.py
@@ -28,4 +28,4 @@ def test_featurized_values():
     exp_transformer = ExponentTransformer()
     expected_len = lags + len(y_test)
     y_hat = exp_transformer.fit_transform(y[-expected_len:])
-    assert_array_equal(X_hat["TOTEMP"].values, y_hat.values)
+    assert_array_equal(X_hat[f"lag_{lags}__TOTEMP"].values, y_hat.values)
diff --git a/sktime/transformations/series/tests/test_lag.py b/sktime/transformations/series/tests/test_lag.py
index 02819643b..e496f72db 100644
--- a/sktime/transformations/series/tests/test_lag.py
+++ b/sktime/transformations/series/tests/test_lag.py
@@ -4,6 +4,8 @@
 
 __author__ = ["fkiraly"]
 
+import itertools
+
 import pandas as pd
 import pytest
 
@@ -63,3 +65,31 @@ def test_lag_fit_transform_columns(X, index_out, lag):
             return 1
 
     assert ncols(Xt) == ncols(X) * len_lag
+
+
+@pytest.mark.parametrize("X", X_fixtures)
+@pytest.mark.parametrize("index_out", index_outs)
+@pytest.mark.parametrize("lags", [2, [2, 4]])
+def test_lag_fit_transform_column_names(X, index_out, lags):
+    """Test expected column names."""
+    t = Lag(lags=lags, index_out=index_out)
+    Xt = t.fit_transform(X)
+
+    if isinstance(Xt, pd.DataFrame):
+        lag_col_names = set(Xt.columns)
+
+        if isinstance(X, pd.DataFrame):
+            col_names = X.columns
+        elif isinstance(X, pd.Series):
+            col_names = [X.name if X.name else 0]
+        else:
+            pass
+
+        lags = [lags] if isinstance(lags, int) else lags
+        expected = {
+            f"lag_{lag}__{col}" for lag, col in itertools.product(lags, col_names)
+        }
+        assert lag_col_names == expected
+
+    elif isinstance(Xt, pd.Series):
+        assert Xt.name is None
diff --git a/sktime/transformations/series/tests/test_optional_passthrough.py b/sktime/transformations/series/tests/test_optional_passthrough.py
new file mode 100644
index 000000000..490d5d5a3
--- /dev/null
+++ b/sktime/transformations/series/tests/test_optional_passthrough.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+"""Tests for OptionalPassthrough transformer."""
+
+import pytest
+from pandas.testing import assert_series_equal
+
+from sktime.transformations.series.boxcox import BoxCoxTransformer
+from sktime.transformations.series.compose import OptionalPassthrough
+from sktime.utils._testing.series import _make_series
+
+
+@pytest.mark.parametrize("passthrough", [True, False])
+def test_passthrough(passthrough):
+    """Test that passthrough works as expected."""
+    y = _make_series(n_columns=1)
+
+    optional_passthourgh = OptionalPassthrough(
+        BoxCoxTransformer(), passthrough=passthrough
+    )
+    box_cox = BoxCoxTransformer()
+
+    y_hat_passthrough = optional_passthourgh.fit_transform(y)
+    y_inv_passthrough = optional_passthourgh.inverse_transform(y_hat_passthrough)
+
+    y_hat_boxcox = box_cox.fit_transform(y)
+    y_inv_boxcox = box_cox.inverse_transform(y_hat_boxcox)
+
+    assert_series_equal(y, y_inv_passthrough)
+
+    if passthrough:
+        assert_series_equal(y, y_hat_passthrough)
+        with pytest.raises(AssertionError):
+            assert_series_equal(y_hat_boxcox, y_hat_passthrough)
+    else:
+        assert_series_equal(y_hat_passthrough, y_hat_boxcox)
+        assert_series_equal(y_inv_passthrough, y_inv_boxcox)
+
+        with pytest.raises(AssertionError):
+            assert_series_equal(y, y_hat_passthrough)