Repository: sktime/sktime
Pull request #6452 (2024-05-20 00:42:45)

[BUG] in `CNNClassifier` and `CNNRegressor`, ensure `filter_sizes` and `padding` is passed on
This PR adds `filter_sizes` and `padding` parameters to `CNNClassifier` and `CNNRegressor`, and ensures these are passed on to `CNNNetwork`.

Fixes https://github.com/sktime/sktime/issues/6436.


Patch:
diff --git a/.all-contributorsrc b/.all-contributorsrc
index 4d039c995..29fcfca6d 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -2649,7 +2649,8 @@
       "avatar_url": "https://avatars.githubusercontent.com/u/37468543?v=4",
       "profile": "https://github.com/meraldoantonio",
       "contributions": [
-        "doc"
+        "doc",
+        "bug"
       ]
     },
     {
@@ -2803,6 +2804,35 @@
       "contributions": [
         "maintenance"
       ]
+    },
+    {
+      "login": "cedricdonie",
+      "name": "Cedric Donié",
+      "avatar_url": "https://avatars.githubusercontent.com/u/6626593?v=4",
+      "profile": "https://github.com/cedricdonie",
+      "contributions": [
+        "bug",
+        "code"
+      ]
+    },
+    {
+      "login": "helloplayer1",
+      "name": "Julian Haderlein",
+      "avatar_url": "https://avatars.githubusercontent.com/u/32032467?v=4",
+      "profile": "https://github.com/helloplayer1",
+      "contributions": [
+        "doc"
+      ]
+    },
+    {
+      "login": "ishanpai",
+      "name": "Ishan Paidhungat",
+      "avatar_url": "https://avatars.githubusercontent.com/u/73134788?v=4",
+      "profile": "https://github.com/ishanpai",
+      "contributions": [
+        "code",
+        "doc"
+      ]
     }
   ]
 }
diff --git a/README.md b/README.md
index bad9a6f9b..8aecfd334 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ sktime is a library for time series analysis in Python. It provides a unified in
 | **Community** | [![!discord](https://img.shields.io/static/v1?logo=discord&label=discord&message=chat&color=lightgreen)](https://discord.com/invite/54ACzaFsn7) [![!slack](https://img.shields.io/static/v1?logo=linkedin&label=LinkedIn&message=news&color=lightblue)](https://www.linkedin.com/company/scikit-time/)  |
 | **CI/CD** | [![github-actions](https://img.shields.io/github/actions/workflow/status/sktime/sktime/wheels.yml?logo=github)](https://github.com/sktime/sktime/actions/workflows/wheels.yml) [![!codecov](https://img.shields.io/codecov/c/github/sktime/sktime?label=codecov&logo=codecov)](https://codecov.io/gh/sktime/sktime) [![readthedocs](https://img.shields.io/readthedocs/sktime?logo=readthedocs)](https://www.sktime.net/en/latest/?badge=latest) [![platform](https://img.shields.io/conda/pn/conda-forge/sktime)](https://github.com/sktime/sktime) |
 | **Code** |  [![!pypi](https://img.shields.io/pypi/v/sktime?color=orange)](https://pypi.org/project/sktime/) [![!conda](https://img.shields.io/conda/vn/conda-forge/sktime)](https://anaconda.org/conda-forge/sktime) [![!python-versions](https://img.shields.io/pypi/pyversions/sktime)](https://www.python.org/) [![!black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)  |
-| **Downloads**| [![Downloads](https://static.pepy.tech/personalized-badge/sktime?period=week&units=international_system&left_color=grey&right_color=blue&left_text=weekly%20(pypi))](https://pepy.tech/project/sktime) [![Downloads](https://static.pepy.tech/personalized-badge/sktime?period=month&units=international_system&left_color=grey&right_color=blue&left_text=monthly%20(pypi))](https://pepy.tech/project/sktime) [![Downloads](https://static.pepy.tech/personalized-badge/sktime?period=total&units=international_system&left_color=grey&right_color=blue&left_text=cumulative%20(pypi))](https://pepy.tech/project/sktime) |
+| **Downloads** | ![PyPI - Downloads](https://img.shields.io/pypi/dw/sktime) ![PyPI - Downloads](https://img.shields.io/pypi/dm/sktime) [![Downloads](https://static.pepy.tech/personalized-badge/sktime?period=total&units=international_system&left_color=grey&right_color=blue&left_text=cumulative%20(pypi))](https://pepy.tech/project/sktime) |
 | **Citation** | [![!zenodo](https://zenodo.org/badge/DOI/10.5281/zenodo.3749000.svg)](https://doi.org/10.5281/zenodo.3749000) |
 
 ## :books: Documentation
diff --git a/docs/source/api_reference/forecasting.rst b/docs/source/api_reference/forecasting.rst
index 97cc002fe..1b03d86a6 100644
--- a/docs/source/api_reference/forecasting.rst
+++ b/docs/source/api_reference/forecasting.rst
@@ -388,6 +388,14 @@ Deep learning based forecasters
     NeuralForecastRNN
     NeuralForecastLSTM
 
+.. currentmodule:: sktime.forecasting.pykan_forecaster
+
+.. autosummary::
+    :toctree: auto_generated/
+    :template: class.rst
+
+    PyKANForecaster
+
 
 Intermittent time series forecasters
 ------------------------------------
diff --git a/docs/source/api_reference/performance_metrics.rst b/docs/source/api_reference/performance_metrics.rst
index 1a7a67c83..333ab2680 100644
--- a/docs/source/api_reference/performance_metrics.rst
+++ b/docs/source/api_reference/performance_metrics.rst
@@ -98,6 +98,7 @@ Distribution forecasts
     :toctree: auto_generated/
     :template: class_with_call.rst
 
+    AUCalibration
     CRPS
     LogLoss
     SquaredDistrLoss
diff --git a/docs/source/api_reference/transformations.rst b/docs/source/api_reference/transformations.rst
index 1bedaa6f2..18e70affd 100644
--- a/docs/source/api_reference/transformations.rst
+++ b/docs/source/api_reference/transformations.rst
@@ -392,6 +392,14 @@ Filtering and denoising
 
     ThetaLinesTransformer
 
+.. currentmodule:: sktime.transformations.series.bollinger
+
+.. autosummary::
+    :toctree: auto_generated/
+    :template: class.rst
+
+    Bollinger
+
 Differencing, slope, kinematics
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
diff --git a/examples/02a_classification_multivariate_cnn.ipynb b/examples/02a_classification_multivariate_cnn.ipynb
deleted file mode 100644
index f4b464240..000000000
--- a/examples/02a_classification_multivariate_cnn.ipynb
+++ /dev/null
@@ -1,136 +0,0 @@
-{
- "cells": [
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "# Multi-variate time series classification using a simple CNN\n",
-    "\n",
-    "In this notebook, we use sktime to perform for multi-variate time series classification by deep learning."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "import numpy as np\n",
-    "import seaborn as sns\n",
-    "from sklearn.model_selection import GridSearchCV\n",
-    "\n",
-    "from sktime.classification.deep_learning.cnn import CNNClassifier\n",
-    "from sktime.datasets import load_basic_motions\n",
-    "\n",
-    "sns.set_style(\"whitegrid\")"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "# Load a dataset\n",
-    "The Basic Motions dataset, from [timeseriesclassification.com](http://www.timeseriesclassification.com), has time series in six dimensions."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "X_train, y_train = load_basic_motions(split=\"train\", return_X_y=True)\n",
-    "X_test, y_test = load_basic_motions(split=\"test\", return_X_y=True)\n",
-    "print(X_train.shape)\n",
-    "print(X_test.shape)\n",
-    "print(type(X_train.iloc[1, 1]))\n",
-    "X_train.head()"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# The class labels\n",
-    "np.unique(y_train)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "# Train a deep neural network classifier\n",
-    "Here we choose to use the CNN (convolutional neural network) classifier. Other classifiers provided by sktime-dl include MLP, ResNet, InceptionTime and MCDCNN (Multi Channel Deep Convolutional Neural Network)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "network = CNNClassifier(n_epochs=50, verbose=True)\n",
-    "network.fit(X_train, y_train)\n",
-    "network.score(X_test, y_test)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "# Grid Search\n",
-    "sktime-dl is compatible with scikit-learn and can use sklearn's GridSearchCV.\n",
-    "\n",
-    "Here we search over two parameters, CNN kernel size and pooling size."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {
-    "pycharm": {
-     "is_executing": true
-    }
-   },
-   "outputs": [],
-   "source": [
-    "param_grid = {\"kernel_size\": [7, 9], \"avg_pool_size\": [3, 5]}\n",
-    "grid = GridSearchCV(network, param_grid=param_grid, cv=3)\n",
-    "grid.fit(X_train, y_train)\n",
-    "\n",
-    "print(\"Best cross-validation accuracy: {:.2f}\".format(grid.best_score_))\n",
-    "print(\"Test set score: {:.2f}\".format(grid.score(X_test, y_test)))\n",
-    "print(\"Best parameters: {}\".format(grid.best_params_))"
-   ]
-  }
- ],
- "metadata": {
-  "file_extension": ".py",
-  "kernelspec": {
-   "display_name": "Python 3 (ipykernel)",
-   "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"
-  },
-  "mimetype": "text/x-python",
-  "name": "python",
-  "npconvert_exporter": "python",
-  "pygments_lexer": "ipython3",
-  "version": 3
- },
- "nbformat": 4,
- "nbformat_minor": 2
-}
diff --git a/examples/02b_classification_multivariate_lstmfcn.ipynb b/examples/02b_classification_multivariate_lstmfcn.ipynb
deleted file mode 100644
index f31975c52..000000000
--- a/examples/02b_classification_multivariate_lstmfcn.ipynb
+++ /dev/null
@@ -1,116 +0,0 @@
-{
- "cells": [
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "# Multi-variate time series classification using a simple CNN\n",
-    "\n",
-    "In this notebook, we use sktime to perform for multi-variate time series classification by deep learning.\n"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "import numpy as np\n",
-    "import seaborn as sns\n",
-    "\n",
-    "from sktime.classification.deep_learning.lstmfcn import LSTMFCNClassifier\n",
-    "from sktime.datasets import load_basic_motions\n",
-    "\n",
-    "sns.set_style(\"whitegrid\")"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "# Load a dataset\n",
-    "\n",
-    "The Basic Motions dataset, from [timeseriesclassification.com](http://www.timeseriesclassification.com), has time series in six dimensions.\n"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "X_train, y_train = load_basic_motions(split=\"train\", return_X_y=True)\n",
-    "X_test, y_test = load_basic_motions(split=\"test\", return_X_y=True)\n",
-    "print(X_train.shape)\n",
-    "print(X_test.shape)\n",
-    "print(type(X_train.iloc[1, 1]))\n",
-    "X_train.head()"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# The class labels\n",
-    "np.unique(y_train)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "# Train a LSTM-FCN\n",
-    "\n",
-    "In this exampe we use a LSTM-FCN (LongShort Term Memory Fully Convolutional Network) classifier originally implemented in sktime-dl.\n",
-    "Other classifiers provided by sktime-dl include CNN, MLP, ResNet, InceptionTime and MCDCNN.\n",
-    "\n",
-    "The LSTM-FCN estimator is compatible with scikit-learn and can use sklearn's GridSearchCV."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "network = LSTMFCNClassifier(n_epochs=65, verbose=0)\n",
-    "network.fit(X_train, y_train)\n",
-    "network.score(X_test, y_test)"
-   ]
-  }
- ],
- "metadata": {
-  "file_extension": ".py",
-  "kernelspec": {
-   "display_name": "Python 3.9.6 64-bit ('sktime')",
-   "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.9.6"
-  },
-  "mimetype": "text/x-python",
-  "name": "python",
-  "npconvert_exporter": "python",
-  "pygments_lexer": "ipython3",
-  "version": 3,
-  "vscode": {
-   "interpreter": {
-    "hash": "b1429b21f9b0cdf277ed408d65e56e85a8ed0b1a3006279bd7cbcc8cf36ab395"
-   }
-  }
- },
- "nbformat": 4,
- "nbformat_minor": 2
-}
diff --git a/examples/02c_classification_multivariate_inceptiontime.ipynb b/examples/02c_classification_multivariate_inceptiontime.ipynb
deleted file mode 100644
index 6094630fd..000000000
--- a/examples/02c_classification_multivariate_inceptiontime.ipynb
+++ /dev/null
@@ -1,153 +0,0 @@
-{
- "cells": [
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "# Multi-variate time series classification using InceptionTime\n",
-    "\n",
-    "In this notebook, we use InceptionTime to perform for multi-variate time series classification by deep learning."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {
-    "pycharm": {
-     "is_executing": true
-    }
-   },
-   "outputs": [],
-   "source": [
-    "\"\"\" Imports \"\"\"\n",
-    "import numpy as np\n",
-    "import seaborn as sns\n",
-    "from sklearn.model_selection import GridSearchCV\n",
-    "\n",
-    "from sktime.classification.deep_learning.inceptiontime import InceptionTimeClassifier\n",
-    "from sktime.datasets import load_basic_motions\n",
-    "\n",
-    "sns.set_style(\"whitegrid\")"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "# Load a dataset\n",
-    "The Basic Motions dataset, from [timeseriesclassification.com](http://www.timeseriesclassification.com), has time series in six dimensions."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {
-    "pycharm": {
-     "is_executing": true
-    }
-   },
-   "outputs": [],
-   "source": [
-    "X_train, y_train = load_basic_motions(split=\"train\", return_X_y=True)\n",
-    "X_test, y_test = load_basic_motions(split=\"test\", return_X_y=True)\n",
-    "print(X_train.shape)\n",
-    "print(X_test.shape)\n",
-    "X_train.head()"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {
-    "pycharm": {
-     "is_executing": true
-    }
-   },
-   "outputs": [],
-   "source": [
-    "# The class labels\n",
-    "np.unique(y_train)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "# Train a deep neural network classifier\n",
-    "Here we choose to use the InceptionTime classifier. Other classifiers provided by sktime-dl include MLP, ResNet, MCDCNN (Multi Channel Deep Convolutional Neural Network)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {
-    "pycharm": {
-     "is_executing": true
-    }
-   },
-   "outputs": [],
-   "source": [
-    "network = InceptionTimeClassifier(n_epochs=75, verbose=False)\n",
-    "network.fit(X_train, y_train)\n",
-    "network.score(X_test, y_test)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "# Grid Search\n",
-    "sktime-dl is compatible with scikit-learn and can use sklearn's GridSearchCV.\n",
-    "\n",
-    "Here we search over two parameters, CNN kernel size and pooling size."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {
-    "pycharm": {
-     "is_executing": true
-    }
-   },
-   "outputs": [],
-   "source": [
-    "param_grid = {\"nb_filters\": [16, 32], \"bottleneck_size\": [16, 32]}\n",
-    "grid = GridSearchCV(network, param_grid=param_grid, cv=5)\n",
-    "# commented fit call to pass checks (meet time constraints)\n",
-    "# grid.fit(X_train, y_train)\n",
-    "\n",
-    "# print(\"Best cross-validation accuracy: {:.2f}\".format(grid.best_score_))\n",
-    "# print(\"Test set score: {:.2f}\".format(grid.score(X_test, y_test)))\n",
-    "# print(\"Best parameters: {}\".format(grid.best_params_))"
-   ]
-  }
- ],
- "metadata": {
-  "file_extension": ".py",
-  "kernelspec": {
-   "display_name": "Python 3 (ipykernel)",
-   "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"
-  },
-  "mimetype": "text/x-python",
-  "name": "python",
-  "npconvert_exporter": "python",
-  "pygments_lexer": "ipython3",
-  "version": 3
- },
- "nbformat": 4,
- "nbformat_minor": 2
-}
diff --git a/examples/AA_datatypes_and_datasets.ipynb b/examples/AA_datatypes_and_datasets.ipynb
index acff86913..adb440011 100644
--- a/examples/AA_datatypes_and_datasets.ipynb
+++ b/examples/AA_datatypes_and_datasets.ipynb
@@ -339,7 +339,7 @@
     "* variable names: the `\"np.ndarray\"` mtype cannot represent variable names.\n",
     "* time points: the rows of `obj` correspond to different, distinct time points. \n",
     "* time index: The time index is implicit and by-convention. The `i`-th row (for an integer `i`) is interpreted as an observation at the time point `i`.\n",
-    "* capabilities: cannot represent multivariate series; cannot represent unequally spaced series"
+    "* capabilities: can represent multivariate series; cannot represent unequally spaced series"
    ]
   },
   {
diff --git a/pyproject.toml b/pyproject.toml
index e7a336ab5..cbb0ffd6f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -95,7 +95,7 @@ all_extras = [
   'numba<0.60,>=0.53',
   'pmdarima!=1.8.1,<3.0.0,>=1.8; python_version < "3.12"',
   'prophet>=1.1; python_version < "3.12"',
-  "pycatch22<0.4.4",
+  "pycatch22<0.4.6",
   "pykalman<0.10,>=0.9.5",
   'pyod>=0.8; python_version < "3.11"',
   "pyts<0.14.0; python_version < '3.12'",
@@ -136,7 +136,7 @@ all_extras_pandas2 = [
   'numba<0.60,>=0.53',
   'pmdarima!=1.8.1,<3.0.0,>=1.8; python_version < "3.12"',
   'prophet>=1.1; python_version < "3.12"',
-  "pycatch22<0.4.4",
+  "pycatch22<0.4.6",
   "pykalman<0.10,>=0.9.5",
   'pyod>=0.8; python_version < "3.11"',
   "scikit_posthocs>=0.6.5",
@@ -165,7 +165,7 @@ all_extras_pandas2 = [
 # users can install via "pip install sktime[forecasting,transformations]" etc
 #
 alignment = [
-  "dtw-python>=1.3,<1.5",
+  "dtw-python>=1.3,<1.6",
   'numba<0.60,>=0.53',
 ]
 annotation = [
@@ -207,7 +207,7 @@ regression = [
 transformations = [
   'esig<0.10,>=0.9.7; python_version < "3.11"',
   "filterpy<1.5,>=1.4.5",
-  "holidays>=0.29,<0.49",
+  "holidays>=0.29,<0.50",
   "mne>=1.5,<1.8",
   'numba<0.60,>=0.53',
   "pycatch22>=0.4,<0.4.6",
@@ -244,7 +244,7 @@ docs = [
   "pydata-sphinx-theme",
   "Sphinx!=7.2.0,<8.0.0",
   "sphinx-copybutton",
-  "sphinx-design<0.6.0",
+  "sphinx-design<0.7.0",
   "sphinx-gallery<0.17.0",
   "sphinx-issues<5.0.0",
   "tabulate",
@@ -274,6 +274,7 @@ dl = [
   'tensorflow<2.17,>=2; python_version < "3.12"',
   'torch; python_version < "3.12"',
   'transformers[torch]<4.41.0; python_version < "3.12"',
+  'pykan; python_version > "3.9.7"'
 ]
 mlflow = [
   "mlflow",
diff --git a/sktime/classification/base.py b/sktime/classification/base.py
index cca5c0e8e..4f09ca6da 100644
--- a/sktime/classification/base.py
+++ b/sktime/classification/base.py
@@ -727,6 +727,9 @@ class BaseClassifier(BasePanelMixin):
         y_proba = self._predict_proba(X)
         y_pred = y_proba.argmax(axis=1)
 
+        # restore class labels if they were not originally integer
+        y_pred = self.classes_[y_pred]
+
         return y_pred
 
     def _predict_proba(self, X) -> np.ndarray:
diff --git a/sktime/classification/deep_learning/base.py b/sktime/classification/deep_learning/base.py
index 7a7d6b511..b9205dbbc 100644
--- a/sktime/classification/deep_learning/base.py
+++ b/sktime/classification/deep_learning/base.py
@@ -3,6 +3,7 @@
 The reason for this class between BaseClassifier and deep_learning classifiers is
 because we can generalise tags, _predict and _predict_proba
 """
+
 __author__ = ["James-Large", "ABostrom", "TonyBagnall", "aurunmpegasus", "achieveordie"]
 __all__ = ["BaseDeepClassifier"]
 
@@ -103,7 +104,7 @@ class BaseDeepClassifier(BaseClassifier, ABC):
         probs = probs / probs.sum(axis=1, keepdims=1)
         return probs
 
-    def convert_y_to_keras(self, y):
+    def _convert_y_to_keras(self, y):
         """Convert y to required Keras format."""
         self.label_encoder = LabelEncoder()
         y = self.label_encoder.fit_transform(y)
@@ -122,6 +123,19 @@ class BaseDeepClassifier(BaseClassifier, ABC):
         y = self.onehot_encoder.fit_transform(y)
         return y
 
+    def convert_y_to_keras(self, y):
+        """Convert y to required Keras format."""
+        from sktime.utils.warnings import warn
+
+        warn(
+            "convert_y_to_keras of sktime deep learning estimators is "
+            "deprecated and will be removed in 0.31.0. For equivalent "
+            "behaviour, please use sklearn OneHotEncoder.fit_transform "
+            "directly.",
+            obj=self,
+        )
+        return self._convert_y_to_keras(y=y)
+
     def __getstate__(self):
         """Get Dict config that will be used when a serialization method is called.
 
diff --git a/sktime/classification/deep_learning/cnn.py b/sktime/classification/deep_learning/cnn.py
index 2c4b4349c..fd9541f65 100644
--- a/sktime/classification/deep_learning/cnn.py
+++ b/sktime/classification/deep_learning/cnn.py
@@ -27,14 +27,14 @@ class CNNClassifier(BaseDeepClassifier):
         size of the average pooling windows
     n_conv_layers   : int, default = 2
         the number of convolutional plus average pooling layers
-    filter_sizes    : array of shape (n_conv_layers) default = [6, 12]
-    random_state    : int or None, default=None
-        Seed for random number generation.
+    callbacks       : list of keras.callbacks, default = None
     verbose         : boolean, default = False
         whether to output extra information
     loss            : string, default="categorical_crossentropy"
         fit parameter for the keras model
     metrics         : list of strings, default=["accuracy"],
+    random_state    : int or None, default=None
+        Seed for random number generation.
     activation      : string or a tf callable, default="softmax"
         Activation function used in the output linear layer.
         List of available activation functions:
@@ -43,6 +43,13 @@ class CNNClassifier(BaseDeepClassifier):
         whether the layer uses a bias vector.
     optimizer       : keras.optimizers object, default = Adam(lr=0.01)
         specify the optimizer and the learning rate to be used.
+    filter_sizes    : array of shape (n_conv_layers) default = [6, 12]
+    padding : string, default = "auto"
+        Controls padding logic for the convolutional layers,
+        i.e. whether ``'valid'`` and ``'same'`` are passed to the ``Conv1D`` layer.
+        - "auto": as per original implementation, ``"same"`` is passed if
+          ``input_shape[0] < 60`` in the input layer, and ``"valid"`` otherwise.
+        - "valid", "same", and other values are passed directly to ``Conv1D``
 
     Notes
     -----
@@ -89,6 +96,8 @@ class CNNClassifier(BaseDeepClassifier):
         activation="softmax",
         use_bias=True,
         optimizer=None,
+        filter_sizes=None,
+        padding="auto",
     ):
         _check_dl_dependencies(severity="error")
 
@@ -107,6 +116,8 @@ class CNNClassifier(BaseDeepClassifier):
         self.use_bias = use_bias
         self.optimizer = optimizer
         self.history = None
+        self.filter_sizes = filter_sizes
+        self.padding = padding
 
         super().__init__()
 
@@ -114,7 +125,9 @@ class CNNClassifier(BaseDeepClassifier):
             kernel_size=self.kernel_size,
             avg_pool_size=self.avg_pool_size,
             n_conv_layers=self.n_conv_layers,
+            filter_sizes=self.filter_sizes,
             activation=self.activation,
+            padding=self.padding,
             random_state=self.random_state,
         )
 
@@ -180,7 +193,7 @@ class CNNClassifier(BaseDeepClassifier):
         -------
         self : object
         """
-        y_onehot = self.convert_y_to_keras(y)
+        y_onehot = self._convert_y_to_keras(y)
         # Transpose to conform to Keras input style.
         X = X.transpose(0, 2, 1)
 
diff --git a/sktime/classification/deep_learning/cntc.py b/sktime/classification/deep_learning/cntc.py
index 18ce61e18..4b04df8c8 100644
--- a/sktime/classification/deep_learning/cntc.py
+++ b/sktime/classification/deep_learning/cntc.py
@@ -229,7 +229,7 @@ class CNTCClassifier(BaseDeepClassifier):
         """
         if self.callbacks is None:
             self._callbacks = []
-        y_onehot = self.convert_y_to_keras(y)
+        y_onehot = self._convert_y_to_keras(y)
         # Transpose to conform to Keras input style.
         X = X.transpose(0, 2, 1)
 
diff --git a/sktime/classification/deep_learning/fcn.py b/sktime/classification/deep_learning/fcn.py
index d27c8d6ec..401e59bf9 100644
--- a/sktime/classification/deep_learning/fcn.py
+++ b/sktime/classification/deep_learning/fcn.py
@@ -161,7 +161,7 @@ class FCNClassifier(BaseDeepClassifier):
         -------
         self : object
         """
-        y_onehot = self.convert_y_to_keras(y)
+        y_onehot = self._convert_y_to_keras(y)
         # Transpose to conform to Keras input style.
         X = X.transpose(0, 2, 1)
 
diff --git a/sktime/classification/deep_learning/inceptiontime.py b/sktime/classification/deep_learning/inceptiontime.py
index 354009a8f..897e2accd 100644
--- a/sktime/classification/deep_learning/inceptiontime.py
+++ b/sktime/classification/deep_learning/inceptiontime.py
@@ -159,7 +159,7 @@ class InceptionTimeClassifier(BaseDeepClassifier):
         -------
         self : object
         """
-        y_onehot = self.convert_y_to_keras(y)
+        y_onehot = self._convert_y_to_keras(y)
         # Transpose to conform to Keras input style.
         X = X.transpose(0, 2, 1)
 
diff --git a/sktime/classification/deep_learning/lstmfcn.py b/sktime/classification/deep_learning/lstmfcn.py
index e288a80d2..698062ae8 100644
--- a/sktime/classification/deep_learning/lstmfcn.py
+++ b/sktime/classification/deep_learning/lstmfcn.py
@@ -178,7 +178,7 @@ class LSTMFCNClassifier(BaseDeepClassifier):
         """
         check_random_state(self.random_state)
 
-        y_onehot = self.convert_y_to_keras(y)
+        y_onehot = self._convert_y_to_keras(y)
 
         # Remove?
         # Transpose to conform to Keras input style.
diff --git a/sktime/classification/deep_learning/macnn.py b/sktime/classification/deep_learning/macnn.py
index 270b7c864..ac653b656 100644
--- a/sktime/classification/deep_learning/macnn.py
+++ b/sktime/classification/deep_learning/macnn.py
@@ -201,7 +201,7 @@ class MACNNClassifier(BaseDeepClassifier):
         -------
         self : object
         """
-        y_onehot = self.convert_y_to_keras(y)
+        y_onehot = self._convert_y_to_keras(y)
         X = X.transpose(0, 2, 1)
 
         check_random_state(self.random_state)
diff --git a/sktime/classification/deep_learning/mcdcnn.py b/sktime/classification/deep_learning/mcdcnn.py
index 4e4d2998b..dd68a04ae 100644
--- a/sktime/classification/deep_learning/mcdcnn.py
+++ b/sktime/classification/deep_learning/mcdcnn.py
@@ -206,7 +206,7 @@ class MCDCNNClassifier(BaseDeepClassifier):
         -------
         self : object
         """
-        y_onehot = self.convert_y_to_keras(y)
+        y_onehot = self._convert_y_to_keras(y)
         X = X.transpose(0, 2, 1)
         self.input_shape = X.shape[1:]
         X = self._network._prepare_input(X)
diff --git a/sktime/classification/deep_learning/mlp.py b/sktime/classification/deep_learning/mlp.py
index 0d3659c2a..6d7c65094 100644
--- a/sktime/classification/deep_learning/mlp.py
+++ b/sktime/classification/deep_learning/mlp.py
@@ -163,7 +163,7 @@ class MLPClassifier(BaseDeepClassifier):
         -------
         self : object
         """
-        y_onehot = self.convert_y_to_keras(y)
+        y_onehot = self._convert_y_to_keras(y)
         # Transpose to conform to Keras input style.
         X = X.transpose(0, 2, 1)
 
diff --git a/sktime/classification/deep_learning/resnet.py b/sktime/classification/deep_learning/resnet.py
index d15bd4b3a..7e6f147d6 100644
--- a/sktime/classification/deep_learning/resnet.py
+++ b/sktime/classification/deep_learning/resnet.py
@@ -164,7 +164,7 @@ class ResNetClassifier(BaseDeepClassifier):
         -------
         self : object
         """
-        y_onehot = self.convert_y_to_keras(y)
+        y_onehot = self._convert_y_to_keras(y)
         # Transpose to conform to Keras input style.
         X = X.transpose(0, 2, 1)
 
diff --git a/sktime/classification/deep_learning/rnn.py b/sktime/classification/deep_learning/rnn.py
index 3f89323a6..17b44b2ec 100644
--- a/sktime/classification/deep_learning/rnn.py
+++ b/sktime/classification/deep_learning/rnn.py
@@ -157,7 +157,7 @@ class SimpleRNNClassifier(BaseDeepClassifier):
         """
         from tensorflow import keras
 
-        y_onehot = self.convert_y_to_keras(y)
+        y_onehot = self._convert_y_to_keras(y)
         X = X.transpose(0, 2, 1)
 
         check_random_state(self.random_state)
diff --git a/sktime/classification/deep_learning/tapnet.py b/sktime/classification/deep_learning/tapnet.py
index 4ff4b4ee6..bd032b45b 100644
--- a/sktime/classification/deep_learning/tapnet.py
+++ b/sktime/classification/deep_learning/tapnet.py
@@ -228,7 +228,7 @@ class TapNetClassifier(BaseDeepClassifier):
         -------
         self: object
         """
-        y_onehot = self.convert_y_to_keras(y)
+        y_onehot = self._convert_y_to_keras(y)
         # Transpose to conform to expectation format by keras
         X = X.transpose(0, 2, 1)
 
diff --git a/sktime/classification/dictionary_based/_boss.py b/sktime/classification/dictionary_based/_boss.py
index 0bcb4def2..0c1b234b1 100644
--- a/sktime/classification/dictionary_based/_boss.py
+++ b/sktime/classification/dictionary_based/_boss.py
@@ -639,9 +639,7 @@ class IndividualBOSS(BaseClassifier):
             Predicted class labels.
         """
         test_bags = self._transformer.transform(X)
-        data_type = type(self._class_vals[0])
-        if data_type == np.str_ or data_type == str:
-            data_type = "object"
+        data_type = self._class_vals.dtype
 
         classes = np.zeros(test_bags.shape[0], dtype=data_type)
 
diff --git a/sktime/classification/distance_based/_proximity_forest.py b/sktime/classification/distance_based/_proximity_forest.py
index 1626b7e42..eb35e836e 100644
--- a/sktime/classification/distance_based/_proximity_forest.py
+++ b/sktime/classification/distance_based/_proximity_forest.py
@@ -900,33 +900,6 @@ class ProximityStump(BaseClassifier):
         self.entropy = gini_gain(self.y, self.y_branches)
         return self
 
-    def _predict(self, X) -> np.ndarray:
-        """Predicts labels for sequences in X.
-
-        Parameters
-        ----------
-        X : array-like or sparse matrix of shape = [n_instances, n_columns]
-            The training input samples.
-            If a Pandas data frame is passed (sktime format)
-            If a Pandas data frame is passed, a check is performed that it
-            only has one column.
-            If not, an exception is thrown, since this classifier does not
-            yet have
-            multivariate capability.
-
-        Returns
-        -------
-        y : array-like, shape =  [n_instances] - predicted class labels
-        """
-        distributions = self._predict_proba(X)
-        predictions = []
-        for instance_index in range(0, X.shape[0]):
-            distribution = distributions[instance_index]
-            prediction = np.argmax(distribution)
-            predictions.append(prediction)
-
-        return np.array(predictions)
-
     def _predict_proba(self, X) -> np.ndarray:
         """Find probability estimates for each class for all cases in X.
 
@@ -1118,33 +1091,6 @@ class ProximityTree(BaseClassifier):
 
         return self
 
-    def _predict(self, X) -> np.ndarray:
-        """Predicts labels for sequences in X.
-
-        Parameters
-        ----------
-        X : array-like or sparse matrix of shape = [n_instances, n_columns]
-            The training input samples.
-            If a Pandas data frame is passed (sktime format)
-            If a Pandas data frame is passed, a check is performed that it
-            only has one column.
-            If not, an exception is thrown, since this classifier does not
-            yet have
-            multivariate capability.
-
-        Returns
-        -------
-        y : array-like, shape =  [n_instances] - predicted class labels
-        """
-        distributions = self._predict_proba(X)
-        predictions = []
-        for instance_index in range(0, X.shape[0]):
-            distribution = distributions[instance_index]
-            prediction = np.argmax(distribution)
-            predictions.append(prediction)
-
-        return np.array(predictions)
-
     def _predict_proba(self, X) -> np.ndarray:
         """Find probability estimates for each class for all cases in X.
 
@@ -1494,33 +1440,6 @@ class ProximityForest(BaseClassifier):
         """
         return tree.predict_proba(X)
 
-    def _predict(self, X) -> np.ndarray:
-        """Predicts labels for sequences in X.
-
-        Parameters
-        ----------
-        X : array-like or sparse matrix of shape = [n_instances, n_columns]
-            The training input samples.
-            If a Pandas data frame is passed (sktime format)
-            If a Pandas data frame is passed, a check is performed that it
-            only has one column.
-            If not, an exception is thrown, since this classifier does not
-            yet have
-            multivariate capability.
-
-        Returns
-        -------
-        y : array-like, shape =  [n_instances] - predicted class labels
-        """
-        distributions = self._predict_proba(X)
-        predictions = []
-        for instance_index in range(0, X.shape[0]):
-            distribution = distributions[instance_index]
-            prediction = np.argmax(distribution)
-            predictions.append(prediction)
-
-        return np.array(predictions)
-
     def _predict_proba(self, X) -> np.ndarray:
         """Find probability estimates for each class for all cases in X.
 
diff --git a/sktime/classification/ensemble/_bagging.py b/sktime/classification/ensemble/_bagging.py
index 17bbcf391..ccb55f30c 100644
--- a/sktime/classification/ensemble/_bagging.py
+++ b/sktime/classification/ensemble/_bagging.py
@@ -163,6 +163,7 @@ class BaggingClassifier(BaseClassifier):
             n_features_ = n_features
 
         self.estimators_ = []
+        self._col_ixis = []
         for _i in range(n_estimators):
             esti = estimator.clone()
             row_iloc = pd.RangeIndex(n)
@@ -183,6 +184,7 @@ class BaggingClassifier(BaseClassifier):
 
             yi = y[row_ss]
             self.estimators_ += [esti.fit(Xi, yi)]
+            self._col_ixis += [col_ix_i]
 
         return self
 
@@ -200,7 +202,14 @@ class BaggingClassifier(BaseClassifier):
             Predicted probabilities using the ordering in classes_.
         """
         classes = pd.Index(self.classes_)
-        y_probas = [est.predict_proba(X) for est in self.estimators_]
+
+        y_probas = []
+        for esti, col_ix_i in zip(self.estimators_, self._col_ixis):
+            Xi = X.loc[:, col_ix_i]
+            if self.bootstrap_features:
+                Xi.columns = pd.RangeIndex(len(col_ix_i))
+
+            y_probas += [esti.predict_proba(Xi)]
 
         est_shape = (len(y_probas[0]), len(classes))
         y_proba_np = np.zeros((len(y_probas), est_shape[0], est_shape[1]))
@@ -238,16 +247,16 @@ class BaggingClassifier(BaseClassifier):
             instance.
             ``create_test_instance`` uses the first (or only) dictionary in ``params``.
         """
-        from sktime.classification.dummy import DummyClassifier
+        from sktime.classification.feature_based import SummaryClassifier
 
-        params1 = {"estimator": DummyClassifier()}
+        params1 = {"estimator": SummaryClassifier()}
         params2 = {
-            "estimator": DummyClassifier(),
+            "estimator": SummaryClassifier(),
             "n_samples": 0.5,
             "n_features": 0.5,
         }
         params3 = {
-            "estimator": DummyClassifier(),
+            "estimator": SummaryClassifier(),
             "n_samples": 7,
             "n_features": 2,
             "bootstrap": False,
@@ -255,7 +264,7 @@ class BaggingClassifier(BaseClassifier):
         }
 
         # force-create a classifier that cannot handle multivariate
-        univariate_dummy = DummyClassifier()
+        univariate_dummy = SummaryClassifier()
         univariate_dummy.set_tags(**{"capability:multivariate": False})
         # this should still result in a multivariate classifier
         params4 = {
diff --git a/sktime/datatypes/_adapter/polars.py b/sktime/datatypes/_adapter/polars.py
index 899cf1afc..e118fddbe 100644
--- a/sktime/datatypes/_adapter/polars.py
+++ b/sktime/datatypes/_adapter/polars.py
@@ -4,6 +4,103 @@ from sktime.datatypes._common import _req
 from sktime.datatypes._common import _ret as ret
 
 
+def get_mi_cols(obj):
+    """Get multiindex or index cols from a polars object."""
+    return [x for x in obj.columns if isinstance(x, str) and x.startswith("__index__")]
+
+
+def convert_pandas_to_polars(
+    obj, schema_overrides=None, rechunk=True, nan_to_null=True, lazy=False
+):
+    """Convert pandas DataFrame to polars DataFrame. Preserving MultiIndex.
+
+    Parameters
+    ----------
+    obj : pandas.DataFrame
+    schema_overrides : dict, optional (default=None)
+        Support override of inferred types for one or more columns.
+    rechunk : bool, optional (default=True)
+        Make sure that all data is in contiguous memory.
+    nan_to_null : bool, optional (default=True)
+        If data contains NaN values PyArrow will convert the NaN to None
+    lazy : bool, optional (default=False)
+        If True, return a LazyFrame instead of a DataFrame
+    Returns
+    -------
+    polars.DataFrame or polars.LazyFrame (if lazy=True)
+        MultiIndex levels 0 .. -1 of X are converted to columns of name
+        __index__[indexname], where indexname is name of multiindex level,
+        or the integer index if the level has no name
+        other columns and column names are identical to those of X
+    """
+    from polars import from_pandas
+
+    def polars_index_columns(names):
+        col = names.copy()
+        for i, name in enumerate(names):
+            if name is None:
+                col[i] = f"__index__{i}"
+            else:
+                col[i] = f"__index__{name}"
+        return col
+
+    index_names = obj.index.names
+    index_names = polars_index_columns(index_names)
+    obj.index.names = index_names
+    obj = obj.reset_index()
+
+    obj = from_pandas(
+        obj, schema_overrides=schema_overrides, rechunk=rechunk, nan_to_null=nan_to_null
+    )
+
+    if lazy:
+        obj = obj.lazy()
+
+    return obj
+
+
+def convert_polars_to_pandas(obj):
+    """Convert polars DataFrame to pandas DataFrame, preserving MultiIndex.
+
+    Parameters
+    ----------
+    obj : polars.DataFrame, polars.LazyFrame
+
+    Returns
+    -------
+    pandas.DataFrame
+        MultiIndex levels are preserved in the columns of the returned DataFrame
+        by converting columns with names __index__[indexname] to MultiIndex levels,
+        and other columns identical to those of obj.
+    """
+    from polars.lazyframe.frame import LazyFrame
+
+    # convert to DataFrame if LazyFrame
+    if isinstance(obj, LazyFrame):
+        obj = obj.collect()
+
+    obj = obj.to_pandas()
+
+    def index_name(x):
+        return x.split("__index__")[1]
+
+    def get_index_names(cols):
+        names = [index_name(x) for x in cols if x.startswith("__index__")]
+        for i, name in enumerate(names):
+            if name == str(i):
+                names[i] = None
+        return names
+
+    pd_index_names = get_index_names(obj.columns)
+
+    if len(pd_index_names) > 0:
+        pl_index_names = get_mi_cols(obj)
+        obj = obj.set_index(pl_index_names)
+        obj.index.names = pd_index_names
+
+    return obj
+
+
 def check_polars_frame(obj, return_metadata=False, var_name="obj", lazy=False):
     """Check polars frame, generic format."""
     import polars as pl
diff --git a/sktime/datatypes/_panel/_check.py b/sktime/datatypes/_panel/_check.py
index b00f811c7..0ef577e69 100644
--- a/sktime/datatypes/_panel/_check.py
+++ b/sktime/datatypes/_panel/_check.py
@@ -270,7 +270,10 @@ def check_pdmultiindex_panel(obj, return_metadata=False, var_name="obj", panel=T
         "is_equally_spaced",
     ]
     if _req(requires_series_grps, return_metadata):
-        series_groups = obj.groupby(level=list(range(index.nlevels - 1)), sort=False)
+        levels = list(range(index.nlevels - 1))
+        if len(levels) == 1:
+            levels = levels[0]
+        series_groups = obj.groupby(level=levels, sort=False)
         n_series = series_groups.ngroups
 
         if _req("n_instances", return_metadata):
diff --git a/sktime/forecasting/compose/_pipeline.py b/sktime/forecasting/compose/_pipeline.py
index 0338e3615..8d372f6dd 100644
--- a/sktime/forecasting/compose/_pipeline.py
+++ b/sktime/forecasting/compose/_pipeline.py
@@ -1376,13 +1376,13 @@ class ForecastX(BaseForecaster):
         self.fh_X = fh_X
         self.behaviour = behaviour
         self.columns = columns
-        self.forecaster_X_exogeneous = forecaster_X_exogeneous
         if isinstance(forecaster_X_exogeneous, str):
             if forecaster_X_exogeneous not in ["None", "complement"]:
                 raise ValueError(
                     'forecaster_X_exogeneous must be one of "None", "complement",'
                     "or a pandas.Index coercible"
                 )
+        self.forecaster_X_exogeneous = forecaster_X_exogeneous
 
         if predict_behaviour not in ["use_forecasts", "use_actuals"]:
             raise ValueError(
@@ -1583,9 +1583,11 @@ class ForecastX(BaseForecaster):
             return None
 
         if ixx == "complement":
-            X_for_fcX = X.drop(columns=self.columns)
+            X_for_fcX = X.drop(columns=self.columns, errors="ignore")
+
             if X_for_fcX.shape[1] < 1:
                 return None
+
             return X_for_fcX
 
         ixx_pd = pd.Index(ixx)
diff --git a/sktime/forecasting/pykan_forecaster.py b/sktime/forecasting/pykan_forecaster.py
new file mode 100644
index 000000000..a84ff789e
--- /dev/null
+++ b/sktime/forecasting/pykan_forecaster.py
@@ -0,0 +1,310 @@
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+"""Dummy forecasters."""
+
+__author__ = ["bheidri"]
+
+import pandas as pd
+from skbase.utils.dependencies import _check_soft_dependencies
+
+from sktime.forecasting.base import BaseForecaster
+from sktime.split import temporal_train_test_split
+
+if _check_soft_dependencies("torch", severity="none"):
+    import torch
+    from torch.utils.data import Dataset
+else:
+
+    class Dataset:
+        """Dummy class if torch is unavailable."""
+
+        pass
+
+
+if _check_soft_dependencies("kan", severity="none"):
+    from kan import KAN
+
+
+class PyKANForecaster(BaseForecaster):
+    """
+    PyKANForecaster uses Kolmogorov Arnold Network [1] to forecast time series data.
+
+    This forecaster uses the pykan library to create a KAN model to forecast
+    time series data. The model is trained on the training data and then used to
+    forecast the future values of the time series.
+    **Note** This forecaster is experimental and the used library for implementing
+    the KANs may be exchanged in the future to a more stable and efficient library.
+
+    Parameters
+    ----------
+    hidden_layers : tuple, optional (default=(5,5))
+        The number of hidden layers in the network.
+    input_layer_size : int, optional (default=12)
+        The size of the input layer.
+    k : int, optional (default=3)
+        The number of nearest neighbors to consider.
+    grids : np.array, optional (default=np.array([5,10,20, 50, 100]))
+        The grid sizes to use in the model.
+    model_params : dict, optional (default=None)
+        The parameters to pass to the model. See pykan documentation for more details.
+    fit_params : dict, optional (default=None)
+        The parameters to pass to the fit method. See pykan documentation
+        for more details.
+    val_size : float, optional (default=0.5)
+        The size of the validation set to use in the training.
+    device : str, optional (default="cpu")
+        The device to use for training the model.
+
+    References
+    ----------
+    .. [1] Liu, Ziming, et al. "KAN: Kolmogorov-Arnold Networks."
+      arXiv preprint arXiv:2404.19756 (2024).
+    """
+
+    _tags = {
+        "y_inner_mtype": "pd.Series",
+        "X_inner_mtype": "pd.DataFrame",
+        "scitype:y": "univariate",
+        "ignores-exogeneous-X": False,
+        "requires-fh-in-fit": True,
+        "X-y-must-have-same-index": True,
+        "enforce_index_type": None,
+        "handles-missing-data": False,
+        "capability:pred_int": False,
+        "capability:pred_int:insample": False,
+        "capability:insample": False,
+        "python_version": None,
+        "python_dependencies": ["kan", "torch"],
+    }
+
+    def __init__(
+        self,
+        hidden_layers=(5, 5),
+        input_layer_size=12,
+        grids=None,
+        model_params=None,
+        fit_params=None,
+        val_size=0.5,
+        device="cpu",
+    ):
+        self.hidden_layers = hidden_layers
+        self.input_layer_size = input_layer_size
+        self.grids = grids
+        self._grids = grids if grids is not None else [5, 10, 20, 50, 100]
+        self.val_size = val_size
+        self.model_params = model_params
+        self._model_params = model_params if model_params is not None else {}
+        self.fit_params = fit_params
+        self._fit_params = fit_params if fit_params is not None else {}
+        self.device = device
+        super().__init__()
+
+    def _fit(self, y, X, fh):
+        """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 : sktime time series object
+            guaranteed to be of an mtype 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
+            if self.get_tag("scitype:y")=="multivariate":
+                guaranteed to have 2 or more columns
+            if self.get_tag("scitype:y")=="both": no restrictions apply
+        fh : guaranteed to be ForecastingHorizon or None, optional (default=None)
+            The forecasting horizon with the steps ahead to to predict.
+            guaranteed to be passed in _predict
+        X :  sktime time series object, optional (default=None)
+            guaranteed to be of an mtype in self.get_tag("X_inner_mtype")
+            Exogeneous time series to fit to.
+
+        Returns
+        -------
+        self : reference to self
+        """
+        output_size = max(fh.to_relative(self.cutoff)._values)
+        if X is not None:
+            y_train, y_test, X_train, X_test = temporal_train_test_split(
+                y, X=X, test_size=(self.val_size)
+            )
+            ds_train = PyTorchTrainDataset(
+                y_train, self.input_layer_size, output_size, X=X_train
+            )
+            ds_test = PyTorchTrainDataset(
+                y_test, self.input_layer_size, output_size, X=X_test
+            )
+            input_layer_size = self.input_layer_size + X_train.shape[1] * output_size
+        else:
+            y_train, y_test = temporal_train_test_split(y, test_size=(self.val_size))
+            ds_train = PyTorchTrainDataset(y_train, self.input_layer_size, output_size)
+            ds_test = PyTorchTrainDataset(y_test, self.input_layer_size, output_size)
+            input_layer_size = self.input_layer_size
+        train_input = [ds_train[i][0] for i in range(len(ds_train))]
+        train_target = [ds_train[i][1] for i in range(len(ds_train))]
+        test_input = [ds_test[i][0] for i in range(len(ds_test))]
+        test_target = [ds_test[i][1] for i in range(len(ds_test))]
+        # no fitting, we already know the forecast values
+
+        ds_new = {
+            "train_input": torch.stack(train_input).type(torch.float32).to(self.device),
+            "train_label": torch.stack(train_target)
+            .type(torch.float32)
+            .to(self.device),
+            "test_input": torch.stack(test_input).type(torch.float32).to(self.device),
+            "test_label": torch.stack(test_target).type(torch.float32).to(self.device),
+        }
+
+        self.train_losses = []
+        self.test_losses = []
+
+        self._layer_sizes = [input_layer_size, *self.hidden_layers, output_size]
+        for i in range(len(self._grids)):
+            if i == 0:
+                model = KAN(
+                    width=self._layer_sizes,
+                    grid=self.grids[i],
+                    device=self.device,
+                    **self._model_params,
+                )
+            if i != 0:
+                model = KAN(
+                    width=self._layer_sizes,
+                    grid=self._grids[i],
+                    device=self.device,
+                    **self._model_params,
+                ).initialize_from_another_model(model, ds_new["train_input"])
+            results = model.train(ds_new, device=self.device, **self._fit_params)
+            if len(self.test_losses) == 0 or results["test_loss"][-1] < min(
+                self.test_losses
+            ):
+                self._state_dict = model.state_dict()
+                self._best_grid = self._grids[i]
+            self.train_losses += results["train_loss"]
+            self.test_losses += results["test_loss"]
+        # self.state_dict = best_model.state_dict()
+        return self
+
+    def _predict(self, fh, X):
+        """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.
+        X : sktime time series object, optional (default=None)
+            guaranteed to be of an mtype in self.get_tag("X_inner_mtype")
+            Exogeneous time series for the forecast
+
+        Returns
+        -------
+        y_pred : sktime time series object
+            should be of the same type as seen in _fit, as in "y_inner_mtype" tag
+            Point predictions
+        """
+        model = KAN(width=self._layer_sizes, grid=self._best_grid, **self._model_params)
+        model.load_state_dict(self._state_dict)
+        if X is not None:
+            input_ = torch.cat(
+                [
+                    torch.from_numpy(self._y.values[-self.input_layer_size :]).reshape(
+                        (1, -1)
+                    ),
+                    torch.from_numpy(X.values).reshape((1, -1)),
+                ],
+                dim=-1,
+            )
+        else:
+            input_ = torch.from_numpy(self._y.values[-self.input_layer_size :]).reshape(
+                (1, -1)
+            )
+
+        prediction = model(input_).detach().numpy().reshape((-1,))
+        index = list(fh.to_absolute(self.cutoff))
+        return pd.Series(
+            prediction[fh.to_relative(self._cutoff) - 1], index=index, name=self._y.name
+        )
+
+    @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 = [
+            {
+                "grids": [2, 3],
+                "model_params": {"k": 2},
+                "fit_params": {"steps": 1},
+                "input_layer_size": 2,
+                "hidden_layers": (1,),
+            },
+            {
+                "input_layer_size": 2,
+                "grids": [3],
+                "model_params": {"k": 2},
+                "fit_params": {"steps": 1},
+                "hidden_layers": (1, 1),
+            },
+        ]
+        return params
+
+
+class PyTorchTrainDataset(Dataset):
+    """Dataset for use in sktime deep learning forecasters."""
+
+    def __init__(self, y, seq_len, fh=None, X=None):
+        self.y = y.values
+        self.X = X.values if X is not None else X
+        self.seq_len = seq_len
+        self.fh = fh
+
+    def __len__(self):
+        """Return length of dataset."""
+        return max(len(self.y) - self.seq_len - self.fh + 1, 0)
+
+    def __getitem__(self, i):
+        """Return data point."""
+        from torch import from_numpy, tensor
+
+        hist_y = tensor(self.y[i : i + self.seq_len]).type(torch.float32)
+        if self.X is not None:
+            exog_data = (
+                tensor(self.X[i + self.seq_len : i + self.seq_len + self.fh])
+                .type(torch.float32)
+                .flatten()
+            )
+        else:
+            exog_data = tensor([])
+        return (
+            torch.cat([hist_y, exog_data]),
+            from_numpy(self.y[i + self.seq_len : i + self.seq_len + self.fh]).type(
+                torch.float32
+            ),
+        )
diff --git a/sktime/forecasting/var.py b/sktime/forecasting/var.py
index d4396751e..9bd0f9a77 100644
--- a/sktime/forecasting/var.py
+++ b/sktime/forecasting/var.py
@@ -245,7 +245,9 @@ class VAR(_StatsModelsAdapter):
 
         for cov in coverage:
             alpha = 1 - cov
-
+            # A hacky way to coerce error-inducing alpha==1 into its approximant
+            if alpha >= 0.99999:
+                alpha = 0.99999
             fcast_interval = model.forecast_interval(
                 self._y.values[-n_lags:], steps=steps, alpha=alpha
             )
diff --git a/sktime/performance_metrics/forecasting/probabilistic/__init__.py b/sktime/performance_metrics/forecasting/probabilistic/__init__.py
index 5d17073e0..68a072958 100644
--- a/sktime/performance_metrics/forecasting/probabilistic/__init__.py
+++ b/sktime/performance_metrics/forecasting/probabilistic/__init__.py
@@ -17,14 +17,18 @@ __all__ = [
     "EmpiricalCoverage",
     "ConstraintViolation",
     "CRPS",
+    "IntervalWidth",
     "LogLoss",
     "SquaredDistrLoss",
+    "AUCalibration",
 ]
 
 from sktime.performance_metrics.forecasting.probabilistic._classes import (
     CRPS,
+    AUCalibration,
     ConstraintViolation,
     EmpiricalCoverage,
+    IntervalWidth,
     LogLoss,
     PinballLoss,
     SquaredDistrLoss,
diff --git a/sktime/performance_metrics/forecasting/probabilistic/_classes.py b/sktime/performance_metrics/forecasting/probabilistic/_classes.py
index 42d727677..820821a15 100644
--- a/sktime/performance_metrics/forecasting/probabilistic/_classes.py
+++ b/sktime/performance_metrics/forecasting/probabilistic/_classes.py
@@ -406,7 +406,10 @@ def _groupby_dot(df, weights):
     out : pd.DataFrame
         dataframe with weighted groupby dot product
     """
-    out = df.groupby(axis=1, level=1).apply(lambda x: x.dot(weights))
+    out = df.T.groupby(level=1).apply(lambda x: x.T.dot(weights)).T
+    # formerly
+    # out = df.groupby(axis=1, level=1).apply(lambda x: x.dot(weights))
+    # but groupby(axis=1) is deprecated
     return out
 
 
@@ -599,6 +602,70 @@ class EmpiricalCoverage(_BaseProbaForecastingErrorMetric):
         return [params1]
 
 
+class IntervalWidth(_BaseProbaForecastingErrorMetric):
+    """Interval width for interval predictions, sometimes also known as sharpness.
+
+    Parameters
+    ----------
+    multioutput : string "uniform_average" or "raw_values" determines how\
+        multioutput results will be treated.
+
+    score_average : bool, optional, default = True
+        specifies whether scores for each quantile should be averaged.
+    """
+
+    _tags = {
+        "scitype:y_pred": "pred_interval",
+        "lower_is_better": True,
+    }
+
+    def __init__(self, multioutput="uniform_average", score_average=True):
+        self.score_average = score_average
+        self.multioutput = multioutput
+        super().__init__(score_average=score_average, multioutput=multioutput)
+
+    def _evaluate_by_index(self, y_true, y_pred, multioutput, **kwargs):
+        """Logic for finding the metric evaluated at each index.
+
+        y_true : pd.Series, pd.DataFrame or np.array of shape (fh,) or \
+            (fh, n_outputs) where fh is the forecasting horizon
+            Ground truth (correct) target values.
+
+        y_pred : pd.Series, pd.DataFrame or np.array of shape (fh,) or  \
+            (fh, n_outputs)  where fh is the forecasting horizon
+            Forecasted values.
+
+        multioutput : string "uniform_average" or "raw_values" determines how \
+            multioutput results will be treated.
+        """
+        lower = y_pred.iloc[:, y_pred.columns.get_level_values(2) == "lower"].to_numpy()
+        upper = y_pred.iloc[:, y_pred.columns.get_level_values(2) == "upper"].to_numpy()
+
+        if not isinstance(y_true, np.ndarray):
+            y_true_np = y_true.to_numpy()
+        else:
+            y_true_np = y_true
+        if y_true_np.ndim == 1:
+            y_true_np = y_true.reshape(-1, 1)
+
+        scores = np.unique(np.round(y_pred.columns.get_level_values(1), 7))
+        vars = np.unique(y_pred.columns.get_level_values(0))
+
+        metric_array = upper - lower
+
+        out_df = pd.DataFrame(
+            metric_array, columns=pd.MultiIndex.from_product([vars, scores])
+        )
+
+        return out_df
+
+    @classmethod
+    def get_test_params(self):
+        """Retrieve test parameters."""
+        params1 = {}
+        return [params1]
+
+
 class ConstraintViolation(_BaseProbaForecastingErrorMetric):
     """Percentage of interval constraint violations for interval predictions.
 
@@ -882,7 +949,7 @@ class CRPS(_BaseDistrForecastingMetric):
     multivariate : bool, optional, default=False
         if True, behaves as multivariate CRPS (sum of scores)
         CRPS is computed for entire row, results one score per row
-        if False, is univariate log-loss, per variable
+        if False, returns univariate CRPS, per variable
         CRPS is computed per variable marginal, results in many scores per row
     """  # noqa: E501
 
@@ -900,3 +967,73 @@ class CRPS(_BaseDistrForecastingMetric):
         params1 = {}
         params2 = {"multivariate": True}
         return [params1, params2]
+
+
+class AUCalibration(_BaseDistrForecastingMetric):
+    r"""Area under the calibration curve for distributional predictions.
+
+    Computes the unsigned area between the calibration curve and the diagonal.
+
+    The calibration curve is the cumulative curve of the sample of
+    predictive cumulative distibution functions evaluated at the true values.
+
+    Mathematically, let :math:`d_1, \dots, d_N` be the predictive distributions,
+    let :math:`y_1, \dots, y_N` be the true values, and let :math:`F_i` be the
+    cumulative distribution function of :math:`d_i`.
+
+    Define the calibration sample as :math:`c_i := F_i(y_i)`, for
+    :math:`i = 1, \dots, N`. For perfect predictions, the sample of :math:`c_i` will be
+    uniformly distributed on [0, 1], and i.i.d. from that uniform distribution.
+
+    Let :math:`c_{(i)}` be the :math:`i`-th order statistic of the sample of
+    :math:`c_i`, i.e., the :math:`i`-th smallest value in the sample.
+
+    The (unsigned) area under the calibration curve - or, more precisely,
+    between the diagonal and the calibration curve - is defined as
+
+    .. math:: \frac{1}{N} \sum_{i=1}^N \left| c_{(i)} - \frac{i}{N} \right|.
+
+    * ``evaluate`` returns the unsigned area between the calibration curve
+      and the diagonal, i.e., the above quantity.
+    * ``evaluate_by_index`` returns, for the :math:`i`-th test sample, the value
+      :math:`\left| c_i - \frac{r_i}{N} \right|`,
+      where :math:`r_i` is the rank of :math:`c_i`
+      in the sample of :math:`c_i`. In case of ties, tied ranks are averaged.
+    * ``multivariate`` controls averaging over variables.
+
+    Parameters
+    ----------
+    multioutput : {'raw_values', 'uniform_average'} or array-like of shape \
+            (n_outputs,), default='uniform_average'
+        Defines whether and how to aggregate metric for across variables.
+        If 'uniform_average' (default), errors are mean-averaged across variables.
+        If array-like, errors are weighted averaged across variables, values as weights.
+        If 'raw_values', does not average errors across variables, columns are retained.
+    multivariate : bool, optional, default=False
+        if True, behaves as multivariate metric (sum of scores)
+        The metric is computed for entire row, results one score per row
+        if False, returns univariate metric, per variable
+        The metric is computed per variable marginal, results in many scores per row
+    """  # noqa: E501
+
+    def __init__(self, multioutput="uniform_average", multivariate=False):
+        self.multivariate = multivariate
+        super().__init__(multioutput=multioutput)
+
+    def _evaluate_by_index(self, y_true, y_pred, multioutput, **kwargs):
+        cdfs = y_pred.cdf(y_true)
+        # using the average in case of ranks is fine
+        # because the absolute sums in the metric average out
+        cdfs_ranked = cdfs.rank(axis=0, method="average", pct=True)
+        n = cdfs.shape[0]
+        diagonal = np.arange(1, n + 1).reshape(-1, 1) / n
+
+        res = (cdfs_ranked - diagonal).abs()
+        return res
+
+    @classmethod
+    def get_test_params(cls, parameter_set="default"):
+        """Retrieve test parameters."""
+        params1 = {}
+        params2 = {"multivariate": True}
+        return [params1, params2]
diff --git a/sktime/proba/mixture.py b/sktime/proba/mixture.py
index 80b1dce48..1a8c75ea8 100644
--- a/sktime/proba/mixture.py
+++ b/sktime/proba/mixture.py
@@ -139,7 +139,7 @@ class Mixture(_HeterogenousMetaEstimator, BaseDistribution):
         n_df = len(df_list)
         df_weighted = [df * w for df, w in zip(df_list, weights)]
         df_concat = pd.concat(df_weighted, axis=1, keys=range(n_df))
-        df_res = df_concat.groupby(level=-1, axis=1).sum()
+        df_res = df_concat.T.groupby(level=-1).sum().T
         return df_res
 
     def pdf(self, x):
diff --git a/sktime/registry/_lookup.py b/sktime/registry/_lookup.py
index 1adbcf7b6..97f0aa2a3 100644
--- a/sktime/registry/_lookup.py
+++ b/sktime/registry/_lookup.py
@@ -136,16 +136,16 @@ def all_estimators(
     --------
     >>> from sktime.registry import all_estimators
     >>> # return a complete list of estimators as pd.Dataframe
-    >>> all_estimators(as_dataframe=True)
+    >>> all_estimators(as_dataframe=True)  # doctest: +SKIP
     >>> # return all forecasters by filtering for estimator type
-    >>> all_estimators("forecaster")
+    >>> all_estimators("forecaster")  # doctest: +SKIP
     >>> # return all forecasters which handle missing data in the input by tag filtering
-    >>> all_estimators("forecaster", filter_tags={"handles-missing-data": True})
+    >>> all_estimators("forecaster", filter_tags={"handles-missing-data": True})  # doctest: +SKIP
 
     References
     ----------
     Modified version from scikit-learn's ``all_estimators()``.
-    """
+    """  # noqa: E501
     MODULES_TO_IGNORE = (
         "tests",
         "setup",
diff --git a/sktime/regression/base.py b/sktime/regression/base.py
index 6b92a9b43..1197ba3d5 100644
--- a/sktime/regression/base.py
+++ b/sktime/regression/base.py
@@ -315,7 +315,11 @@ class BaseRegressor(BasePanelMixin):
 
         Returns
         -------
-        float, R-squared score of predict(X) vs y
+        float (default) or 1D np.array of float
+            R-squared score of predict(X) vs y
+            float if multioutput="uniform_average" or "variance_weighted,
+            or y is univariate;
+            1D np.array if multioutput="raw_values" and y is multivariate
         """
         from sklearn.metrics import r2_score
 
diff --git a/sktime/regression/deep_learning/cnn.py b/sktime/regression/deep_learning/cnn.py
index 70eb345b5..070767206 100644
--- a/sktime/regression/deep_learning/cnn.py
+++ b/sktime/regression/deep_learning/cnn.py
@@ -17,7 +17,6 @@ class CNNRegressor(BaseDeepRegressor):
 
     Parameters
     ----------
-    should inherited fields be listed here?
     n_epochs       : int, default = 2000
         the number of epochs to train the model
     batch_size      : int, default = 16
@@ -28,20 +27,29 @@ class CNNRegressor(BaseDeepRegressor):
         size of the average pooling windows
     n_conv_layers   : int, default = 2
         the number of convolutional plus average pooling layers
-    filter_sizes    : array of shape (n_conv_layers) default = [6, 12]
-    random_state    : int or None, default=None
-        Seed for random number generation.
+    callbacks       : list of keras.callbacks, default = None
     verbose         : boolean, default = False
         whether to output extra information
     loss            : string, default="mean_squared_error"
         fit parameter for the keras model
-    activation      : keras.activations or string, default ``linear``
-        function to use in the output layer.
-    optimizer       : keras.optimizers or string, default ``None``.
-        when ``None``, internally uses ``keras.optimizers.Adam(0.01)``
-    use_bias        : bool, default=True
-        whether to use bias in the output layer.
     metrics         : list of strings, default=["accuracy"],
+    random_state    : int or None, default=None
+        Seed for random number generation.
+    activation      : string or a tf callable, default="softmax"
+        Activation function used in the output linear layer.
+        List of available activation functions:
+        https://keras.io/api/layers/activations/
+    use_bias        : boolean, default = True
+        whether the layer uses a bias vector.
+    optimizer       : keras.optimizers object, default = Adam(lr=0.01)
+        specify the optimizer and the learning rate to be used.
+    filter_sizes    : array of shape (n_conv_layers) default = [6, 12]
+    padding : string, default = "auto"
+        Controls padding logic for the convolutional layers,
+        i.e. whether ``'valid'`` and ``'same'`` are passed to the ``Conv1D`` layer.
+        - "auto": as per original implementation, ``"same"`` is passed if
+          ``input_shape[0] < 60`` in the input layer, and ``"valid"`` otherwise.
+        - "valid", "same", and other values are passed directly to ``Conv1D``
 
     References
     ----------
@@ -90,6 +98,8 @@ class CNNRegressor(BaseDeepRegressor):
         activation="linear",
         use_bias=True,
         optimizer=None,
+        filter_sizes=None,
+        padding="auto",
     ):
         _check_dl_dependencies(severity="error")
         super().__init__()
@@ -107,11 +117,16 @@ class CNNRegressor(BaseDeepRegressor):
         self.use_bias = use_bias
         self.optimizer = optimizer
         self.history = None
+        self.filter_sizes = filter_sizes
+        self.padding = padding
+
         self._network = CNNNetwork(
             kernel_size=self.kernel_size,
             avg_pool_size=self.avg_pool_size,
             n_conv_layers=self.n_conv_layers,
+            filter_sizes=self.filter_sizes,
             activation=self.activation,
+            padding=self.padding,
             random_state=self.random_state,
         )
 
diff --git a/sktime/transformations/panel/channel_selection.py b/sktime/transformations/panel/channel_selection.py
index 82bd8ba08..89461a05e 100644
--- a/sktime/transformations/panel/channel_selection.py
+++ b/sktime/transformations/panel/channel_selection.py
@@ -33,7 +33,12 @@ def _detect_knee_point(values, indices):
     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))
+
+    line_vec_abs = np.sqrt(np.sum(line_vec**2))
+    if line_vec_abs == 0:  # ensuring normalization is correct in case of zero vector
+        line_vec_abs = 1
+    line_vec_norm = line_vec / line_vec_abs
+
     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)
diff --git a/sktime/transformations/panel/reduce.py b/sktime/transformations/panel/reduce.py
index a5cb28b10..ce99ae420 100644
--- a/sktime/transformations/panel/reduce.py
+++ b/sktime/transformations/panel/reduce.py
@@ -154,7 +154,7 @@ class TimeBinner(BaseTransformer):
         transformed version of X
         """
         idx = pd.cut(X.iloc[0, 0].index, bins=self.idx, include_lowest=True)
-        Xt = df_map(X)(lambda x: x.groupby(idx).apply(self._aggfunc))
+        Xt = df_map(X)(lambda x: x.groupby(idx, observed=False).apply(self._aggfunc))
         Xt = convert_to(Xt, to_type="numpyflat", as_scitype="Panel")
         return Xt
 
diff --git a/sktime/transformations/series/binning.py b/sktime/transformations/series/binning.py
index 0d74bc00f..44ab9f37f 100644
--- a/sktime/transformations/series/binning.py
+++ b/sktime/transformations/series/binning.py
@@ -117,7 +117,7 @@ class TimeBinAggregate(BaseTransformer):
         """
         bins = self.bins
         idx_cut = pd.cut(X.index, bins=self._bins, include_lowest=True)
-        Xt = X.groupby(idx_cut).apply(self._aggfunc)
+        Xt = X.groupby(idx_cut, observed=False).apply(self._aggfunc)
 
         if self.return_index == "range":
             Xt = Xt.reset_index(drop=True)
diff --git a/sktime/transformations/series/bollinger.py b/sktime/transformations/series/bollinger.py
new file mode 100644
index 000000000..9c91d4521
--- /dev/null
+++ b/sktime/transformations/series/bollinger.py
@@ -0,0 +1,164 @@
+#!/usr/bin/env python3 -u
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+"""Class to apply bollinger bounds to a time series."""
+__author__ = ["ishanpai"]
+__all__ = ["Bollinger"]
+
+import pandas as pd
+
+from sktime.datatypes._utilities import get_cutoff, update_data
+from sktime.transformations.base import BaseTransformer
+
+
+class Bollinger(BaseTransformer):
+    """Apply Bollinger bounds to a timeseries.
+
+    The transformation works for univariate and multivariate timeseries.
+
+    Parameters
+    ----------
+    window : int
+        The window over which to compute the moving average and the standard deviation.
+
+    k: float, default = 1
+        Multiplier to determine how many stds the upper and lower bounds are from the
+        moving average.
+
+    memory : str, optional, default = "all"
+        how much of previously seen X to remember, for exact reconstruction of inverse
+        "all" : estimator remembers all X, inverse is correct for all indices seen
+        "latest" : estimator only remembers latest X necessary for future reconstruction
+            inverses at any time stamps after fit are correct, but not past time stamps
+        "none" : estimator does not remember any X, inverse is direct cumsum
+
+    Examples
+    --------
+    >>> from sktime.transformations.series.bollinger import Bollinger
+    >>> from sktime.datasets import load_airline
+    >>> y = load_airline()
+    >>> transformer = Bollinger(window=12, k=1)
+    >>> y_transform = transformer.fit_transform(y)
+    """
+
+    _tags = {
+        # packaging info
+        # --------------
+        "authors": ["ishanpai"],
+        # estimator type
+        # --------------
+        "scitype:transform-input": "Series",
+        # what is the scitype of X: Series, or Panel
+        "scitype:transform-output": "Series",
+        # what scitype is returned: Primitives, Series, Panel
+        "scitype:instancewise": False,  # is this an instance-wise transform?
+        "X_inner_mtype": ["pd.DataFrame", "pd.Series"],
+        # which mtypes do _fit/_predict support for X?
+        "y_inner_mtype": "None",  # which mtypes do _fit/_predict support for y?
+        "fit_is_empty": False,
+        "transform-returns-same-time-index": True,
+        "univariate-only": False,
+        "capability:inverse_transform": False,
+        "remember_data": True,
+    }
+
+    def __init__(self, window, k=1, memory="all"):
+        if window <= 1:
+            raise ValueError(f"window must be greater than 1, passed {window}")
+        if k <= 0:
+            raise ValueError(f"k must be positive, passed {k}")
+        self.window = window
+        self.k = k
+        self.memory = memory
+
+        self._X = None
+        super().__init__()
+
+    def _fit(self, X, y=None):
+        """Fit transformer to X and y.
+
+        private _fit containing the core logic, called from fit
+
+        Parameters
+        ----------
+        X : pd.Series or pd.DataFrame
+            Data to fit transform to
+        y : ignored argument for interface compatibility
+            Additional data, e.g., labels for transformation
+
+        Returns
+        -------
+        self: a fitted instance of the estimator
+        """
+        memory = self.memory
+
+        # remember X or part of X
+        if memory == "all":
+            self._X = X
+        elif memory == "latest":
+            n_memory = min(len(X), self.window)
+            self._X = X.iloc[-n_memory:]
+
+        self._freq = get_cutoff(X, return_index=True)
+        return self
+
+    def _transform(self, X, y=None):
+        """Transform X and return a transformed version.
+
+        private _transform containing the core logic, called from transform
+
+        Parameters
+        ----------
+        X : pd.Series or pd.DataFrame
+            Data to be transformed
+        y : ignored argument for interface compatibility
+            Additional data, e.g., labels for transformation
+
+        Returns
+        -------
+        Xt : pd.Series or pd.DataFrame, same type as X
+            transformed version of X
+        """
+        X_orig_index = X.index
+
+        X = update_data(X=self._X, X_new=X)
+
+        Xt = _bollinger_transform(X, self.window, self.k)
+
+        Xt = Xt.loc[X_orig_index]
+
+        return Xt
+
+    @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 transformers.
+
+        Returns
+        -------
+        params : dict or list of dict, default = {}
+            Parameters to create testing instances of the class
+            Each dict are parameters to construct an "interesting" test instance, i.e.,
+            ``MyClass(**params)`` or ``MyClass(**params[i])`` creates a valid test
+            instance.
+            ``create_test_instance`` uses the first (or only) dictionary in ``params``
+        """
+        return [{"window": 12, "k": 1}, {"window": 2, "k": 1.2}]
+
+
+def _bollinger_transform(df: pd.Series, window: int, k: float) -> pd.DataFrame:
+    df_ma = df.rolling(window=window).mean()
+    df_std = df.rolling(window=window).std()
+    df_upper = df_ma + k * df_std
+    df_lower = df_ma - k * df_std
+
+    df_transformed = pd.concat(
+        [df_ma, df_upper, df_lower], keys=("moving_average", "upper", "lower"), axis=1
+    )
+
+    return df_transformed
diff --git a/sktime/transformations/series/peak.py b/sktime/transformations/series/peak.py
index 469080582..dc468e7ec 100644
--- a/sktime/transformations/series/peak.py
+++ b/sktime/transformations/series/peak.py
@@ -294,7 +294,9 @@ class PeakTimeFeature(BaseTransformer):
         df = self._extract_peaktime_features(calendar_features, datetime_freq)
 
         if self.keep_original_columns:
-            Xt = pd.concat([X, df], axis=1, copy=True)
+            to_concat = [X, df]
+            to_concat = [df for df in to_concat if not df.empty]
+            Xt = pd.concat(to_concat, axis=1, copy=True)
         else:
             Xt = df
 
diff --git a/sktime/transformations/series/vmd/_vmdpy.py b/sktime/transformations/series/vmd/_vmdpy.py
index 86d21f42e..4ca54fc62 100644
--- a/sktime/transformations/series/vmd/_vmdpy.py
+++ b/sktime/transformations/series/vmd/_vmdpy.py
@@ -147,11 +147,8 @@ def VMD(f, alpha, tau, K, DC, init, tol):
 
         # update first omega if not held at 0
         if not DC:
-            o_pl_enum = np.dot(
-                freqs[T // 2 : T], abs(u_hat_plus[n + 1, T // 2 : T, k]) ** 2
-            )
-            o_pl_denom = np.sum(abs(u_hat_plus[n + 1, T // 2 : T, k]) ** 2)
-            omega_plus[n + 1, k] = o_pl_enum / o_pl_denom
+            wts = abs(u_hat_plus[n + 1, T // 2 : T, k]) ** 2
+            omega_plus[n + 1, k] = _safe_average(freqs[T // 2 : T], weights=wts)
 
         # update of any other mode
         for k in np.arange(1, K):
@@ -162,11 +159,8 @@ def VMD(f, alpha, tau, K, DC, init, tol):
             u_hat_plus_denominator = 1.0 + Alpha[k] * (freqs - omega_plus[n, k]) ** 2
             u_hat_plus[n + 1, :, k] = u_hat_plus_enumerator / u_hat_plus_denominator
             # center frequencies
-            o_pl_enum = np.dot(
-                freqs[T // 2 : T], abs(u_hat_plus[n + 1, T // 2 : T, k]) ** 2
-            )
-            o_pl_denom = np.sum(abs(u_hat_plus[n + 1, T // 2 : T, k]) ** 2)
-            omega_plus[n + 1, k] = o_pl_enum / o_pl_denom
+            wts = abs(u_hat_plus[n + 1, T // 2 : T, k]) ** 2
+            omega_plus[n + 1, k] = _safe_average(freqs[T // 2 : T], weights=wts)
 
         # Dual ascent
         lambda_update = tau * (np.sum(u_hat_plus[n + 1, :, :], axis=1) - f_hat_plus)
@@ -211,3 +205,29 @@ def VMD(f, alpha, tau, K, DC, init, tol):
         u_hat[:, k] = np.fft.fftshift(np.fft.fft(u[k, :]))
 
     return u, u_hat, omega
+
+
+def _safe_average(x, weights):
+    """Compute the weighted average of x, with safe handling of zero weights.
+
+    If not all weights are zero, the function returns the weighted arithmetic mean of x,
+    equivalent to ``np.average(x, weights=weights)``.
+    If all weights are zero, the function returns the unweighted arithmetic mean of x.
+
+    Parameters
+    ----------
+    x : 1D array_like
+        the data to be averaged
+    weights : 1D array_like
+        the weights for the average
+
+    Returns
+    -------
+    average : float
+        the weighted average of x
+    """
+    wtsum = np.sum(weights)
+    if wtsum == 0:
+        return np.mean(x)
+    else:
+        return np.dot(x, weights) / wtsum
diff --git a/sktime/utils/git_diff.py b/sktime/utils/git_diff.py
index f5cf6ef72..0d53af9ca 100644
--- a/sktime/utils/git_diff.py
+++ b/sktime/utils/git_diff.py
@@ -126,7 +126,6 @@ def get_changed_lines(file_path, only_indented=True):
         return []
 
 
-@lru_cache
 def get_packages_with_changed_specs():
     """Get packages with changed or added specs.
 
@@ -134,6 +133,20 @@ def get_packages_with_changed_specs():
     -------
     list of str : names of packages with changed or added specs
     """
+    return list(_get_packages_with_changed_specs())
+
+
+@lru_cache
+def _get_packages_with_changed_specs():
+    """Get packages with changed or added specs.
+
+    Private version of get_packages_with_changed_specs,
+    to avoid side effects on the list return.
+
+    Returns
+    -------
+    tuple of str : names of packages with changed or added specs
+    """
     from packaging.requirements import Requirement
 
     changed_lines = get_changed_lines("pyproject.toml")
@@ -161,6 +174,6 @@ def get_packages_with_changed_specs():
         packages.append(pkg)
 
     # make unique
-    packages = list(set(packages))
+    packages = tuple(set(packages))
 
     return packages


Test patch:
diff --git a/docs/source/developer_guide/testing_framework.rst b/docs/source/developer_guide/testing_framework.rst
index 40ebc982e..7841b26f5 100644
--- a/docs/source/developer_guide/testing_framework.rst
+++ b/docs/source/developer_guide/testing_framework.rst
@@ -68,7 +68,8 @@ See below for more details, or the `pytest documentation on the topic`_.
 The ``sktime`` plug-in for ``pytest`` generates the tuples of fixture values for this.
 In the above example, we loop over the following fixtures lists:
 
-* ``estimator_instance`` over estimator instances, obtained from all ``sktime`` estimators via ``create_test_instances_and_names``
+* ``estimator_instance`` over estimator instances, obtained from all ``sktime`` estimators via ``create_test_instances_and_names``, which
+  constructs instances from parameter settings in estimator classes' ``get_test_params``.
 
 * ``scenario`` objects, which encodes data inputs and method call sequences to ``estimator_instance`` (explained in further detail below).
 
diff --git a/sktime/classification/dictionary_based/tests/test_boss.py b/sktime/classification/dictionary_based/tests/test_boss.py
index 1bba876ec..f803785b1 100644
--- a/sktime/classification/dictionary_based/tests/test_boss.py
+++ b/sktime/classification/dictionary_based/tests/test_boss.py
@@ -25,7 +25,7 @@ def dataset():
 @pytest.mark.parametrize(
     "new_class,expected_dtype",
     [
-        ({"1": "Class1", "2": "Class2"}, object),
+        ({"1": "Class1", "2": "Class2"}, "same"),
         ({"1": 1, "2": 2}, int),
         ({"1": 1.0, "2": 2.0}, float),
         ({"1": True, "2": False}, bool),
@@ -39,6 +39,9 @@ def test_individual_boss_classes(dataset, new_class, expected_dtype):
     # change class
     y_train = np.array([new_class[y] for y in y_train])
 
+    if expected_dtype == "same":
+        expected_dtype = y_train.dtype
+
     # train iboss and predict X_test
     iboss = IndividualBOSS()
     iboss.fit(X_train, y_train)
diff --git a/sktime/classification/tests/test_all_classifiers.py b/sktime/classification/tests/test_all_classifiers.py
index 173edc08a..cd16cdfb5 100644
--- a/sktime/classification/tests/test_all_classifiers.py
+++ b/sktime/classification/tests/test_all_classifiers.py
@@ -120,20 +120,17 @@ class TestAllClassifiers(ClassifierFixtureGenerator, QuickTester):
         # we only use the first estimator instance for testing
         classname = estimator_class.__name__
 
-        # retrieve expected predict_proba output, and skip test if not available
-        if classname in unit_test_proba.keys():
-            expected_probas = unit_test_proba[classname]
-        else:
-            # skip test if no expected probas are registered
-            return None
-
         # if numba is not installed, some estimators may still try to construct
         # numba dependenct estimators in results_comparison
         # if that is the case, we skip the test
+        if classname in unit_test_proba.keys():
+            parameter_set = "results_comparison"
+        else:
+            parameter_set = "default"
         try:
             # we only use the first estimator instance for testing
             estimator_instance = estimator_class.create_test_instance(
-                parameter_set="results_comparison"
+                parameter_set=parameter_set
             )
         except ModuleNotFoundError as e:
             if not _check_soft_dependencies("numba", severity="none"):
@@ -152,10 +149,18 @@ class TestAllClassifiers(ClassifierFixtureGenerator, QuickTester):
 
         # train classifier and predict probas
         estimator_instance.fit(X_train, y_train)
+
+        y_pred = estimator_instance.predict(X_test.iloc[indices])
+        assert y_pred.dtype == y_train.dtype
+        assert set(y_train).issuperset(set(y_pred))
+
         y_proba = estimator_instance.predict_proba(X_test.iloc[indices])
 
-        # assert probabilities are the same
-        _assert_array_almost_equal(y_proba, expected_probas, decimal=2)
+        # retrieve expected predict_proba output, and skip test if not available
+        if classname in unit_test_proba.keys():
+            expected_probas = unit_test_proba[classname]
+            # assert probabilities are the same
+            _assert_array_almost_equal(y_proba, expected_probas, decimal=2)
 
     def test_classifier_on_basic_motions(self, estimator_class):
         """Test classifier on basic motions data."""
diff --git a/sktime/datatypes/_adapter/tests/test_polars.py b/sktime/datatypes/_adapter/tests/test_polars.py
new file mode 100644
index 000000000..ade78dc97
--- /dev/null
+++ b/sktime/datatypes/_adapter/tests/test_polars.py
@@ -0,0 +1,98 @@
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+"""Converters between polars and pandas, with multiindex convention."""
+
+from copy import deepcopy
+
+import pandas as pd
+import pytest
+
+from sktime.datatypes._adapter.polars import (
+    convert_pandas_to_polars,
+    convert_polars_to_pandas,
+)
+from sktime.utils.validation._dependencies import _check_soft_dependencies
+
+# simple pd.DataFrame fixture
+pd_fixture_simple = pd.DataFrame({"foo": [2, 3, 4], "bar": [3, 4, 5]})
+
+# multiindex pd.DataFrame fixture
+pd_fixture_multiindex = pd.DataFrame(
+    {"a": [1, 2, 3], "b": [1, 2, 3], "foo": [2, 3, 4], "bar": [3, 4, 5]}
+)
+
+pd_fixture_multiindex = pd_fixture_multiindex.set_index(["a", "b"], append=True)
+pd_fixture_multiindex.index.names = ["a", None, "foo"]
+pd_fixture_multiindex.columns = ["a", "foo"]
+
+PANDAS_FIXTURES = [pd_fixture_simple, pd_fixture_multiindex]
+
+
+@pytest.mark.skipif(
+    not _check_soft_dependencies("polars", severity="none"),
+    reason="skip test if required soft dependency for polars not available",
+)
+def test_convert_polars_to_pandas_multiindex():
+    """Tests conversion from polars to pandas is correct for MultiIndex."""
+    from polars import from_pandas
+
+    pd_fixture = pd.DataFrame(
+        {"__index__a": [1, 2, 3], "__index__1": [1, 2, 3], "a": [2, 3, 4]}
+    )
+    pd_fixture_orig = deepcopy(pd_fixture)
+    polars_fixture = from_pandas(pd_fixture)
+
+    result = convert_polars_to_pandas(polars_fixture)
+
+    # tests absence of side effects
+    assert pd_fixture.equals(pd_fixture_orig)
+
+    # test expected output format
+    assert result.index.names == ["a", None]
+    assert result.columns == ["a"]
+    assert (result.values == pd_fixture[["a"]].values).all()
+
+
+@pytest.mark.skipif(
+    not _check_soft_dependencies("polars", severity="none"),
+    reason="skip test if required soft dependency for polars not available",
+)
+def test_convert_pd_multiindex_to_polars():
+    pd_fixture = pd_fixture_multiindex.copy()
+    pd_fixture_orig = deepcopy(pd_fixture)
+    result = convert_pandas_to_polars(pd_fixture)
+
+    # tests absence of side effects
+    assert pd_fixture.equals(pd_fixture_orig)
+
+    # test expected output format
+    expected_columns = ["__index__a", "__index__1", "__index__foo", "a", "foo"]
+    assert result.columns == expected_columns
+    result_values = result.to_pandas()[["a", "foo"]].values
+    pd_fixture_values = pd_fixture[["a", "foo"]].values
+    assert (result_values == pd_fixture_values).all()
+
+
+@pytest.mark.skipif(
+    not _check_soft_dependencies("polars", severity="none"),
+    reason="skip test if required soft dependency for polars not available",
+)
+@pytest.mark.parametrize("pd_fixture", PANDAS_FIXTURES)
+def test_convert_pd_polars_inverse(pd_fixture):
+    """Tests conversions from pandas from/to polars are inverses."""
+    polars_result = convert_pandas_to_polars(pd_fixture)
+    pd_result = convert_polars_to_pandas(polars_result)
+
+    assert pd_result.equals(pd_fixture)
+
+
+@pytest.mark.skipif(
+    not _check_soft_dependencies("polars", severity="none"),
+    reason="skip test if required soft dependency for polars not available",
+)
+@pytest.mark.parametrize("pd_fixture", PANDAS_FIXTURES)
+def test_convert_pd_polars_inverse_lazy(pd_fixture):
+    """Tests conversions from pandas from/to polars are inverses."""
+    polars_result = convert_pandas_to_polars(pd_fixture, lazy=True)
+    pd_result = convert_polars_to_pandas(polars_result)
+
+    assert pd_result.equals(pd_fixture)
diff --git a/sktime/datatypes/tests/test_utils.py b/sktime/datatypes/tests/test_utils.py
index 60784e7fa..c7913e3fe 100644
--- a/sktime/datatypes/tests/test_utils.py
+++ b/sktime/datatypes/tests/test_utils.py
@@ -410,7 +410,7 @@ def test_get_slice_expected_result():
 def test_retain_series_freq_on_update():
     """Tests that the frequency of a series is retained after updating it"""
     from sktime.datasets import load_airline
-    from sktime.forecasting.model_selection import temporal_train_test_split
+    from sktime.split import temporal_train_test_split
 
     y = load_airline()
 
diff --git a/sktime/forecasting/compose/tests/test_forecastx.py b/sktime/forecasting/compose/tests/test_forecastx.py
index 80d308d19..aec4eccbd 100644
--- a/sktime/forecasting/compose/tests/test_forecastx.py
+++ b/sktime/forecasting/compose/tests/test_forecastx.py
@@ -391,3 +391,41 @@ def test_use_of_passed_unknown_X(predict_behaviour_option: str) -> None:
             mock_predict.assert_called_once()
         elif predict_behaviour_option == "use_actuals":
             mock_predict.assert_not_called()
+
+
+@pytest.mark.skipif(
+    not _check_estimator_deps(ARIMA, severity="none"),
+    reason="skip test if required soft dependency is not available",
+)
+@pytest.mark.parametrize("cols_to_forecast", [["GNPDEFL", "GNP"], ["ARMED", "POP"]])
+def test_forecaster_X_exogeneous(cols_to_forecast):
+    """Test that ForecastX forecaster_X uses exogenous data as told by parameter."""
+    from sktime.forecasting.compose import ForecastX
+    from sktime.split import temporal_train_test_split
+
+    y, X = load_longley()
+
+    fh = [1, 2, 3, 4]
+    y_train, _, X_train, X_test = temporal_train_test_split(y, X, test_size=max(fh))
+
+    forecaster = ARIMA()
+    pipeline1 = ForecastX(
+        forecaster.clone(),
+        forecaster_X=forecaster.clone(),
+        columns=cols_to_forecast,
+        forecaster_X_exogeneous="complement",
+    )
+
+    pipeline1.fit(y_train, X=X_train, fh=fh)
+    y_pred1 = pipeline1.predict(X=X_test.drop(columns=cols_to_forecast))
+
+    pipeline2 = ForecastX(
+        forecaster.clone(),
+        forecaster_X=forecaster.clone(),
+        columns=cols_to_forecast,
+        forecaster_X_exogeneous="None",
+    )
+
+    pipeline2.fit(y_train, X=X_train, fh=fh)
+    y_pred2 = pipeline2.predict(X=X_test.drop(columns=cols_to_forecast))
+    np.testing.assert_array_equal(y_pred1.index, y_pred2.index)
diff --git a/sktime/performance_metrics/forecasting/probabilistic/tests/test_probabilistic_metrics.py b/sktime/performance_metrics/forecasting/probabilistic/tests/test_probabilistic_metrics.py
index 2c683a688..b5e450d51 100644
--- a/sktime/performance_metrics/forecasting/probabilistic/tests/test_probabilistic_metrics.py
+++ b/sktime/performance_metrics/forecasting/probabilistic/tests/test_probabilistic_metrics.py
@@ -8,6 +8,7 @@ import pytest
 from sktime.performance_metrics.forecasting.probabilistic import (
     ConstraintViolation,
     EmpiricalCoverage,
+    IntervalWidth,
     PinballLoss,
 )
 from sktime.split import temporal_train_test_split
@@ -20,8 +21,9 @@ quantile_metrics = [
 ]
 
 interval_metrics = [
-    EmpiricalCoverage,
     ConstraintViolation,
+    EmpiricalCoverage,
+    IntervalWidth,
 ]
 
 all_metrics = interval_metrics + quantile_metrics
diff --git a/sktime/regression/tests/test_all_regressors.py b/sktime/regression/tests/test_all_regressors.py
index a499fa65e..366edca4e 100644
--- a/sktime/regression/tests/test_all_regressors.py
+++ b/sktime/regression/tests/test_all_regressors.py
@@ -13,6 +13,7 @@ from sktime.utils._testing.panel import make_regression_problem
 from sktime.utils._testing.scenarios_classification import (
     ClassifierFitPredictMultivariate,
 )
+from sktime.utils.validation import is_float
 
 
 class RegressorFixtureGenerator(BaseFixtureGenerator):
@@ -79,7 +80,7 @@ class TestAllRegressors(RegressorFixtureGenerator, QuickTester):
         y_pred = scenario.run(estimator_instance, method_sequence=["fit", "predict"])
         # check score
         score = estimator_instance.score(X_new, y_pred)
-        assert np.issubdtype(score.dtype, np.floating)
+        assert is_float(score)
 
         # check predict
         assert isinstance(y_pred, np.ndarray)
@@ -100,7 +101,7 @@ class TestAllRegressors(RegressorFixtureGenerator, QuickTester):
         y_pred = estimator_instance.predict(X)
         # check score
         score = estimator_instance.score(X, y_mult)
-        assert np.issubdtype(score.dtype, np.floating)
+        assert is_float(score)
 
         assert isinstance(y_pred, pd.DataFrame)
         assert y_pred.shape == y_mult.shape
diff --git a/sktime/tests/_config.py b/sktime/tests/_config.py
index c2160a9cc..0127b05b6 100644
--- a/sktime/tests/_config.py
+++ b/sktime/tests/_config.py
@@ -156,6 +156,7 @@ EXCLUDED_TESTS = {
         "test_persistence_via_pickle",
         "test_save_estimators_to_file",
         "test_multioutput",  # see 6201
+        "test_classifier_on_unit_test_data",  # see 6201
     ],
     "SimpleRNNRegressor": [
         "test_fit_idempotent",
@@ -228,6 +229,7 @@ EXCLUDED_TESTS = {
     "LTSFDLinearForecaster": ["test_predict_time_index_in_sample_full"],
     "LTSFNLinearForecaster": ["test_predict_time_index_in_sample_full"],
     "HFTransformersForecaster": ["test_predict_time_index_in_sample_full"],
+    "PyKANForecaster": ["test_predict_time_index_in_sample_full"],
     "WEASEL": ["test_multiprocessing_idempotent"],  # see 5658
     # StatsForecastMSTL is failing in probabistic forecasts, see #5703, #5920
     "StatsForecastMSTL": ["test_pred_int_tag"],
diff --git a/sktime/transformations/series/tests/test_bollinger.py b/sktime/transformations/series/tests/test_bollinger.py
new file mode 100644
index 000000000..f341e3623
--- /dev/null
+++ b/sktime/transformations/series/tests/test_bollinger.py
@@ -0,0 +1,32 @@
+"""Tests for Bollinger."""
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+
+__author__ = ["ishanpai"]
+__all__ = []
+
+import numpy as np
+import pytest
+
+from sktime.datasets import load_airline
+from sktime.transformations.series.bollinger import Bollinger
+
+
+@pytest.mark.parametrize("window", [2, 12])
+@pytest.mark.parametrize("k", [1, 5])
+def test_bollinger_against_raw_implementation(window, k):
+    y = load_airline()
+    t = Bollinger(window=window, k=k)
+    y_t = t.fit_transform(y)
+
+    ma = y.rolling(window=window).mean()
+    std = y.rolling(window=window).std()
+
+    np.testing.assert_array_equal(y_t["moving_average"].values, ma.values)
+    np.testing.assert_array_equal(y_t["upper"].values, (ma + k * std).values)
+    np.testing.assert_array_equal(y_t["lower"].values, (ma - k * std).values)
+
+
+@pytest.mark.parametrize(("window", "k"), [(1, 1), (12, 0), (1, 0)])
+def test_bollinger_input_error(window, k):
+    with pytest.raises(ValueError):
+        Bollinger(window=window, k=k)