Repository: sktime/sktime
Pull request #6455 (2024-05-20 15:26:12)

[ENH] Polars conversion utilities
Adds conversion functions from polars to pandas and vice versa, with relevant tests for the functions.
#5423 


Patch:
diff --git a/.all-contributorsrc b/.all-contributorsrc
index 630f97689..c3bb48d3d 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"
       ]
     },
     {
@@ -2812,6 +2813,16 @@
       "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/pyproject.toml b/pyproject.toml
index ae8a7e8fb..cbb0ffd6f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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 = [
@@ -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/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/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 fbab9f8df..68a072958 100644
--- a/sktime/performance_metrics/forecasting/probabilistic/__init__.py
+++ b/sktime/performance_metrics/forecasting/probabilistic/__init__.py
@@ -20,10 +20,12 @@ __all__ = [
     "IntervalWidth",
     "LogLoss",
     "SquaredDistrLoss",
+    "AUCalibration",
 ]
 
 from sktime.performance_metrics.forecasting.probabilistic._classes import (
     CRPS,
+    AUCalibration,
     ConstraintViolation,
     EmpiricalCoverage,
     IntervalWidth,
diff --git a/sktime/performance_metrics/forecasting/probabilistic/_classes.py b/sktime/performance_metrics/forecasting/probabilistic/_classes.py
index cfee5b76f..95342e610 100644
--- a/sktime/performance_metrics/forecasting/probabilistic/_classes.py
+++ b/sktime/performance_metrics/forecasting/probabilistic/_classes.py
@@ -946,7 +946,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
 
@@ -964,3 +964,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/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/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


Test patch:
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/tests/_config.py b/sktime/tests/_config.py
index c2160a9cc..aca49b5e2 100644
--- a/sktime/tests/_config.py
+++ b/sktime/tests/_config.py
@@ -228,6 +228,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)