Repository: sktime/sktime
Pull request #7059 (2024-08-30 16:02:11)

[DOC] fix broken links in continous integration docs
This PR fixes all the broken links in continuos integration docs.


Patch:
diff --git a/docs/source/api_reference/transformations.rst b/docs/source/api_reference/transformations.rst
index 42373d540..ae2b93435 100644
--- a/docs/source/api_reference/transformations.rst
+++ b/docs/source/api_reference/transformations.rst
@@ -203,6 +203,17 @@ Dictionary-based features
 
     SFA
 
+Auto-correlation-based features
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. currentmodule:: sktime.transformations.series.hurst
+
+.. autosummary::
+    :toctree: auto_generated/
+    :template: class.rst
+
+    HurstExponentTransformer
+
 Moment-based features
 ~~~~~~~~~~~~~~~~~~~~~
 
diff --git a/docs/source/developer_guide/continuous_integration.rst b/docs/source/developer_guide/continuous_integration.rst
index 923ca0c91..e01bca714 100644
--- a/docs/source/developer_guide/continuous_integration.rst
+++ b/docs/source/developer_guide/continuous_integration.rst
@@ -10,7 +10,7 @@ This page gives a summary of:
 
 If you are a contributor or developer, ensure that you have set
 up your developer environment, and installed a
-`development version :doc:</installation>`
+:doc:`development version </installation>`
 of ``sktime``.
 
 ``sktime`` use continuous integration (CI) services on GitHub to automatically check
@@ -36,7 +36,7 @@ We recommend:
 Prerequisite: local python environment
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-Local testing requires a `development version :doc:</installation>`
+Local testing requires a :doc:`development version </installation>`
 of ``sktime``, follow the link for detail instructions.
 
 In your environment, ensure you have an editable development version of sktime with developer dependencies.
@@ -53,7 +53,7 @@ To install, if not already installed:
 .. note::
 
    For trouble shooting on different operating systems, please see our detailed
-   `installation instructions :doc:</installation>`.
+   :doc:`installation instructions </installation>`.
 
 Code quality checks
 ~~~~~~~~~~~~~~~~~~~
@@ -81,7 +81,7 @@ For contributions that are localized to estimators or objects, the ``check_estim
 utility can be used.
 
 For this, follow the instructions in the
-`estimator development guide :doc:</developer_guide/add_estimators>`
+:doc:`estimator development guide </developer_guide/add_estimators>`
 
 Full test suite runs via ``pytest``
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/pyproject.toml b/pyproject.toml
index 514d510be..d86915bba 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -96,7 +96,7 @@ all_extras = [
   'numba<0.61,>=0.53',
   'optuna<3.7',
   'pmdarima!=1.8.1,<3.0.0,>=1.8; python_version < "3.12"',
-  "polars[pandas]",
+  "polars[pandas]>=0.20,<2.0",
   'prophet>=1.1; python_version < "3.12"',
   "pycatch22<0.4.6",
   'pyod>=0.8; python_version < "3.11"',
@@ -140,7 +140,7 @@ all_extras_pandas2 = [
   'numba<0.61,>=0.53',
   'optuna<3.7',
   'pmdarima!=1.8.1,<3.0.0,>=1.8; python_version < "3.12"',
-  "polars[pandas]",
+  "polars[pandas]>=0.20,<2.0",
   'prophet>=1.1; python_version < "3.12"',
   "pycatch22<0.4.6",
   'pyod>=0.8; python_version < "3.11"',
diff --git a/sktime/clustering/compose/__init__.py b/sktime/clustering/compose/__init__.py
index d302377c6..7b7114946 100644
--- a/sktime/clustering/compose/__init__.py
+++ b/sktime/clustering/compose/__init__.py
@@ -3,8 +3,13 @@
 # copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
 
 __author__ = ["fkiraly"]
-__all__ = ["ClustererPipeline", "SklearnClustererPipeline"]
+__all__ = [
+    "ClustererAsTransformer",
+    "ClustererPipeline",
+    "SklearnClustererPipeline",
+]
 
+from sktime.clustering.compose._as_transform import ClustererAsTransformer
 from sktime.clustering.compose._pipeline import (
     ClustererPipeline,
     SklearnClustererPipeline,
diff --git a/sktime/clustering/compose/_as_transform.py b/sktime/clustering/compose/_as_transform.py
new file mode 100644
index 000000000..23d341a0c
--- /dev/null
+++ b/sktime/clustering/compose/_as_transform.py
@@ -0,0 +1,179 @@
+"""Using a clusterer as transformation."""
+
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+from sktime.datatypes import MTYPE_LIST_PANEL
+from sktime.transformations.base import BaseTransformer
+
+__author__ = ["fkiraly"]
+__all__ = ["ClustererAsTransformer"]
+
+
+class ClustererAsTransformer(BaseTransformer):
+    """Use a clusterer as a transformer.
+
+    This adapter is used in coercions, when passing a clusterer to a transformer slot.
+
+    The transformation is series-to-primitives, transforming a time series
+    into its cluster assignment.
+
+    The adapter dispatches ``BaseTransformer.transform`` to ``BaseClusterer.predict``,
+    and requires a clusterer that is able to make cluster assignments via ``predict``,
+    see the ``capability:predict`` tag for clusterers.
+
+    Parameters
+    ----------
+    clusterer : sktime clusterer, i.e., estimator inheriting from BaseClusterer
+        this is a "blueprint" clusterer, state does not change when ``fit`` is called
+
+    Attributes
+    ----------
+    clusterer_ : sktime clusterer, clone of clusterer in `clusterer`
+        this clone is fitted in the pipeline when `fit` is called
+
+    Examples
+    --------
+    >>> from sktime.clustering.dbscan import TimeSeriesDBSCAN
+    >>> from sktime.datasets import load_unit_test
+    >>> from sktime.clustering.compose import ClustererAsTransformer
+    >>> X, _ = load_unit_test(split="train")
+    >>> cluster_assign_trafo = ClustererAsTransformer(TimeSeriesDBSCAN())
+    >>> cluster_assign_trafo.fit(X)
+    ClustererAsTransformer(...)
+    >>> cluster_assignment = cluster_assign_trafo.transform(X)
+    """
+
+    _tags = {
+        # packaging info
+        # --------------
+        "authors": ["fkiraly"],
+        # estimator type
+        # --------------
+        "scitype:transform-input": "Series",
+        "scitype:transform-output": "Primitives",
+        "scitype:transform-labels": "None",
+        "scitype:instancewise": False,  # is this an instance-wise transform?
+        "capability:inverse_transform": False,  # can the transformer inverse transform?
+        "univariate-only": False,  # can the transformer handle multivariate X?
+        "X_inner_mtype": MTYPE_LIST_PANEL,
+        "y_inner_mtype": "None",  # which mtypes do _fit/_predict support for y?
+        "requires_y": False,  # does y need to be passed in fit?
+        "fit_is_empty": False,  # is fit empty and can be skipped? Yes = True
+        "X-y-must-have-same-index": False,  # can estimator handle different X/y index?
+        "transform-returns-same-time-index": True,
+        "skip-inverse-transform": True,  # is inverse-transform skipped when called?
+        "capability:unequal_length": True,
+        "capability:unequal_length:removes": False,
+        "handles-missing-data": True,
+        "capability:missing_values:removes": True,
+    }
+
+    def __init__(self, clusterer):
+        self.clusterer = clusterer
+        self.clusterer_ = clusterer.clone()
+        super().__init__()
+
+        can_transform = clusterer.get_tag("capability:predict", False)
+        if not can_transform:
+            raise ValueError(
+                f"To use clusterer {type(clusterer).__name__} as a transformation, "
+                "it must support cluster assignment via predict, but it does not. "
+                'See the tag "capability:predict" for cluster assignment capability.'
+            )
+
+        multivariate = clusterer.get_tag("capability:multivariate", False)
+        missing = clusterer.get_tag("capability:missing_values", False)
+        unequal = clusterer.get_tag("capability:unequal_length", False)
+
+        # forward tag information
+        tags_to_set = {
+            "univariate-only": not multivariate,
+            "handles-missing-data": missing,
+            "capability:unequal_length": unequal,
+        }
+        self.set_tags(**tags_to_set)
+
+    def _fit(self, X, y=None):
+        """Fit transformer to X and y.
+
+        private _fit containing the core logic, called from fit
+
+        Parameters
+        ----------
+        X : pd.DataFrame
+            if self.get_tag("univariate-only")==True:
+                guaranteed to have a single column
+            if self.get_tag("univariate-only")==False: no restrictions apply
+        y : None, present only for interface compatibility
+
+        Returns
+        -------
+        self: reference to self
+        """
+        self.clusterer_.fit(X)
+        return self
+
+    def _transform(self, X, y=None):
+        """Transform X and return a transformed version.
+
+        private _transform containing core logic, called from transform
+
+        Parameters
+        ----------
+        X : pd.DataFrame
+            if self.get_tag("univariate-only")==True:
+                guaranteed to have a single column
+            if self.get_tag("univariate-only")==False: no restrictions apply
+        y : None, present only for interface compatibility
+
+        Returns
+        -------
+        transformed version of X
+        """
+        return self.clusterer_.predict(X)
+
+    @classmethod
+    def get_test_params(cls, parameter_set="default"):
+        """Return testing parameter settings for the estimator.
+
+        Parameters
+        ----------
+        parameter_set : str, default="default"
+            Name of the set of test parameters to return, for use in tests. If no
+            special parameters are defined for a value, will return `"default"` set.
+            For clusterers, a "default" set of parameters should be provided for
+            general testing, and a "results_comparison" set for comparing against
+            previously recorded results if the general set does not produce suitable
+            probabilities to compare against.
+
+        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`.
+        """
+        # imports
+        from sktime.clustering.dbscan import TimeSeriesDBSCAN
+        from sktime.clustering.k_means import TimeSeriesKMeansTslearn
+        from sktime.utils.dependencies import _check_estimator_deps
+
+        params = []
+
+        # construct without names
+        c = TimeSeriesDBSCAN.create_test_instance()
+
+        params1 = {"clusterer": c}
+        params = params + [params1]
+
+        if _check_estimator_deps(TimeSeriesKMeansTslearn, severity="none"):
+            c = TimeSeriesKMeansTslearn.create_test_instance()
+
+            params2 = {"clusterer": c}
+        else:
+            tsd_params2 = TimeSeriesDBSCAN.get_test_params()[1]
+            params2 = {"clusterer": TimeSeriesDBSCAN(**tsd_params2)}
+
+        params = params + [params2]
+
+        return params
diff --git a/sktime/datatypes/_series/_examples.py b/sktime/datatypes/_series/_examples.py
index d1441e304..0074bc79b 100644
--- a/sktime/datatypes/_series/_examples.py
+++ b/sktime/datatypes/_series/_examples.py
@@ -75,7 +75,7 @@ if _check_soft_dependencies("dask", severity="none"):
     example_dict[("dask_series", "Series", 0)] = df_dask
     example_dict_lossy[("dask_series", "Series", 0)] = False
 
-if _check_soft_dependencies("polars", severity="none"):
+if _check_soft_dependencies("polars>=0.20", severity="none"):
     from polars import DataFrame
 
     pl_df = DataFrame({"__index__0": [0, 1, 2, 3], "a": [1, 4, 0.5, -3]}, strict=False)
@@ -136,7 +136,7 @@ if _check_soft_dependencies("dask", severity="none"):
     example_dict_lossy[("dask_series", "Series", 1)] = False
 
 
-if _check_soft_dependencies("polars", severity="none"):
+if _check_soft_dependencies("polars>=0.20", severity="none"):
     from polars import DataFrame
 
     pl_df = DataFrame(
@@ -202,7 +202,7 @@ if _check_soft_dependencies("dask", severity="none"):
     example_dict_lossy[("dask_series", "Series", 2)] = False
 
 
-if _check_soft_dependencies("polars", severity="none"):
+if _check_soft_dependencies("polars>=0.20", severity="none"):
     from polars import DataFrame
 
     pl_df = DataFrame(
@@ -262,7 +262,7 @@ if _check_soft_dependencies("xarray", severity="none"):
     example_dict_lossy[("xr.DataArray", "Series", 3)] = False
 
 
-if _check_soft_dependencies("polars", severity="none"):
+if _check_soft_dependencies("polars>=0.20", severity="none"):
     from polars import DataFrame
 
     pl_df = DataFrame({"__index__0": [0, 1, 2, 3], "a": [1, 4, 0.5, 3]}, strict=False)
diff --git a/sktime/forecasting/compose/_transform_select_forecaster.py b/sktime/forecasting/compose/_transform_select_forecaster.py
index bf7d2435f..72a0004d3 100644
--- a/sktime/forecasting/compose/_transform_select_forecaster.py
+++ b/sktime/forecasting/compose/_transform_select_forecaster.py
@@ -5,11 +5,7 @@ __author__ = ["shlok191"]
 
 from sktime.base._meta import _HeterogenousMetaEstimator
 from sktime.forecasting.base import BaseForecaster
-from sktime.forecasting.croston import Croston
-from sktime.forecasting.naive import NaiveForecaster
-from sktime.forecasting.trend import PolynomialTrendForecaster
-from sktime.transformations.base import BaseTransformer
-from sktime.transformations.series.adi_cv import ADICVTransformer
+from sktime.registry import coerce_scitype
 
 
 class TransformSelectForecaster(BaseForecaster, _HeterogenousMetaEstimator):
@@ -38,6 +34,8 @@ class TransformSelectForecaster(BaseForecaster, _HeterogenousMetaEstimator):
         A fallback forecaster that will be used if the category generated by
         the transformer does not match any of the given forecasters.
 
+    pooling : str, optional, default = "local", one of {"local", "global"}
+
     Raises
     ------
     AssertionError: If a valid transformer (an instance of BaseTransformer)
@@ -105,26 +103,27 @@ class TransformSelectForecaster(BaseForecaster, _HeterogenousMetaEstimator):
         transformer=None,
         fallback_forecaster=None,
     ):
-        # saving arguments to object storage
-        if transformer is not None:
-            self.transformer = transformer
-
-        else:
-            self.transformer = ADICVTransformer(features=["class"])
-
+        self.transformer = transformer
         self.forecasters = forecasters
         self.fallback_forecaster = fallback_forecaster
 
-        self.transformer_ = self.transformer.clone()
-
         super().__init__()
 
-        # validating passed arguments
-        assert isinstance(self.transformer_, BaseTransformer)
+        # saving arguments to object storage
+        if transformer is not None:
+            transformer_ = transformer
+        else:
+            from sktime.transformations.series.adi_cv import ADICVTransformer
+
+            transformer_ = ADICVTransformer(features=["class"])
+
+        self.transformer_ = coerce_scitype(transformer_, "transformer")
 
         for forecaster in forecasters.values():
             assert isinstance(forecaster, BaseForecaster)
 
+        self.forecasters_ = {k: f.clone() for k, f in forecasters.items()}
+
         # All checks OK!
 
         # Assigning all capabilities on the basis of the capabilities
@@ -246,7 +245,7 @@ class TransformSelectForecaster(BaseForecaster, _HeterogenousMetaEstimator):
                 self.chosen_forecaster_ = self.fallback_forecaster.clone()
 
         else:
-            self.chosen_forecaster_ = self.forecasters[self.category_].clone()
+            self.chosen_forecaster_ = self.forecasters_[self.category_].clone()
 
         # fitting the forecaster!
         self.chosen_forecaster_.fit(y=y, X=X, fh=fh)
@@ -344,6 +343,12 @@ class TransformSelectForecaster(BaseForecaster, _HeterogenousMetaEstimator):
             `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
             `create_test_instance` uses the first (or only) dictionary in `params`
         """
+        from sktime.clustering.dbscan import TimeSeriesDBSCAN
+        from sktime.forecasting.croston import Croston
+        from sktime.forecasting.naive import NaiveForecaster
+        from sktime.forecasting.trend import PolynomialTrendForecaster
+        from sktime.transformations.series.adi_cv import ADICVTransformer
+
         param1 = {
             "forecasters": {
                 "smooth": NaiveForecaster(),
@@ -362,7 +367,14 @@ class TransformSelectForecaster(BaseForecaster, _HeterogenousMetaEstimator):
             "fallback_forecaster": Croston(),
         }
 
-        params = [param1, param2]
+        # use with clusterer
+        param3 = {
+            "forecasters": {},
+            "transformer": TimeSeriesDBSCAN.create_test_instance(),
+            "fallback_forecaster": Croston(),
+        }
+
+        params = [param1, param2, param3]
         return params
 
     @property
diff --git a/sktime/forecasting/ttm.py b/sktime/forecasting/ttm.py
index f86784f98..3b327de36 100644
--- a/sktime/forecasting/ttm.py
+++ b/sktime/forecasting/ttm.py
@@ -38,7 +38,12 @@ class TinyTimeMixerForecaster(_BaseGlobalForecaster):
     Parameters
     ----------
     model_path : str, default="ibm/TTM"
-        Path of the model to use for forecasting.
+        Path to the Huggingface model to use for forecasting.
+        This can be either:
+        - The name of a Huggingface repository (e.g., "ibm/TTM")
+        - A local path to a folder containing model files in a format supported
+          by transformers. In this case, ensure that the directory contains all
+          necessary files (e.g., configuration, tokenizer, and model weights).
     revision: str, default="main"
         Revision of the model to use:
         - "main": For loading model with context_length of 512
diff --git a/sktime/performance_metrics/annotation/metrics.py b/sktime/performance_metrics/annotation/metrics.py
index cd77fc4ae..c2e972bd0 100644
--- a/sktime/performance_metrics/annotation/metrics.py
+++ b/sktime/performance_metrics/annotation/metrics.py
@@ -6,6 +6,7 @@ points and quantify the error.
 
 import numpy as np
 import numpy.typing as npt
+import pandas as pd
 from scipy.spatial.distance import directed_hausdorff
 from sklearn.utils import check_array
 
@@ -91,3 +92,56 @@ def prediction_ratio(
     true_change_points = check_array(true_change_points, ensure_2d=False)
     pred_change_points = check_array(pred_change_points, ensure_2d=False)
     return pred_change_points.size / true_change_points.size
+
+
+def padded_f1(true_change_points, pred_change_points, pad):
+    """Calculate padded F1 score for change point detection.
+
+    Parameters
+    ----------
+    true_change_points: pd.Series
+        True change point positions. Can be integers, floats or datetimes.
+    precicted_change_points: pd.Series
+        Precicted change point positions. Can be integers, floats or datetimes.
+    pad: int, float, timdelta
+        Used to pad the true change points. If a predicted change point falls within
+        the range of the padded change then then change point has been correctly
+        identified.
+
+    Returns
+    -------
+    float
+        Padded f1 score
+
+    References
+    ----------
+    .. [1] Gerrit J. J. van den Burg and Christopher K. I. Williams, An Evaluation of
+           Change Point Detection Algorithms, 2022, https://arxiv.org/abs/2003.06222
+    """
+    true_change_points = pd.Series(true_change_points)
+    pred_change_points = pd.Series(pred_change_points)
+
+    boundary_left = true_change_points - pad
+    boundary_right = true_change_points + pad
+    true_cp_intervals = pd.IntervalIndex.from_arrays(boundary_left, boundary_right)
+
+    false_positives = 0
+    tp_and_fn = pd.Series(False, index=true_cp_intervals)
+
+    for cp in pred_change_points:
+        boolean_mask = tp_and_fn.index.contains(cp)
+        if not boolean_mask.any():
+            false_positives += 1
+        else:
+            tp_and_fn = tp_and_fn | boolean_mask
+
+    true_positives = tp_and_fn.sum()
+    false_negatives = (~tp_and_fn).sum()
+
+    # Avoid division by zero to mimic sklearn behaviour
+    denom = 2 * true_positives + false_positives + false_negatives
+    if denom == 0:
+        return 0.0
+
+    padded_f1 = 2 * true_positives / denom
+    return padded_f1
diff --git a/sktime/registry/_scitype_coercion.py b/sktime/registry/_scitype_coercion.py
index 91e59a9bc..0b2362322 100644
--- a/sktime/registry/_scitype_coercion.py
+++ b/sktime/registry/_scitype_coercion.py
@@ -16,6 +16,15 @@ _coerce_register[("transformer_tabular", "transformer")] = (
 )
 
 
+def _coerce_clusterer_to_transformer(obj):
+    from sktime.clustering.compose import ClustererAsTransformer
+
+    return ClustererAsTransformer(obj)
+
+
+_coerce_register[("clusterer", "transformer")] = _coerce_clusterer_to_transformer
+
+
 def coerce_scitype(
     obj,
     to_scitype,
@@ -37,12 +46,21 @@ def coerce_scitype(
     Parameters
     ----------
     obj : class or object inheriting from sktime BaseObject
+        object to check or coerce
+
     to_scitype : str
-        scitype to coerce the object to
-    from_scitype : str, optional, default = None
+        scitype to coerce the object ``obj`` to
+
+    from_scitype : str or list of str, optional, default = None
         scitype of ``obj`` that is assumed for the coercion, before coercion.
+
+        * If None, no scitype for ``obj`` is assumed.
+        * if str, the scitype of ``obj`` is assumed to be this scitype.
+        * if list of str, the scitype of ``obj`` is assumed to be one of these scitypes.
+
         If ``raise_on_mismatch``, non-None value will cause an exception
-        if the detected scitype does not match the expected scitype.
+        if the detected scitype does not match the expected scitype(s).
+
     clone_obj : bool, optional, default = True
         if True, a clone of ``obj`` is used inside coercion composites.
         if False, the original object is passed unmodified.
@@ -52,6 +70,7 @@ def coerce_scitype(
     raise_on_mismatch : bool, optional, default = False
         if True, raises an error if the detected scitype does not match the
         expected scitype, see ``from_scitype``.
+        If False, the object is returned unmodified.
     msg : str, optional, default = "Error in object scitype check."
         Start of error message returned with the exception, if
         an error is raised due to ``raise_on_mismatch``.
@@ -72,20 +91,36 @@ def coerce_scitype(
     from sktime.registry._scitype import scitype
     from sktime.utils.sklearn import is_sklearn_estimator, sklearn_scitype
 
-    if from_scitype is None or raise_on_mismatch:
+    if isinstance(from_scitype, str):
+        from_scitype = [from_scitype]
+    if isinstance(from_scitype, list) and len(from_scitype) == 0:
+        from_scitype = None
+    # from_scitype is now a list or None
+
+    # we need to detect the scitype if it is not provided as an assumption
+    need_detect = from_scitype is None or len(from_scitype) >= 2 or raise_on_mismatch
+    if need_detect:
         if is_sklearn_estimator(obj):
             detected_scitype = f"{sklearn_scitype(obj)}_tabular"
         else:
             detected_scitype = scitype(
                 obj, force_single_scitype=True, raise_on_unknown=raise_on_unknown
             )
-        if raise_on_mismatch and detected_scitype != from_scitype:
+
+    # case 1: detected scitype is not assumed scitype
+    if from_scitype is not None and detected_scitype not in from_scitype:
+        # if raise_on_mismatch, raise an error
+        if raise_on_mismatch:
             raise TypeError(
                 f"{msg} Expected object scitype {from_scitype}, "
                 f"but found {detected_scitype}."
             )
+        # otherwise, return the object unmodified
         else:
-            from_scitype = detected_scitype
+            return obj
+
+    if from_scitype is None or len(from_scitype) >= 2:
+        from_scitype = detected_scitype
 
     if clone_obj:
         if is_sklearn_estimator(obj) or not hasattr(obj, "clone"):
@@ -98,6 +133,9 @@ def coerce_scitype(
     if (from_scitype, to_scitype) not in _coerce_register:
         return obj
 
+    if isinstance(from_scitype, list) and detected_scitype not in from_scitype:
+        return obj
+
     # now we know that we have a coercion function in the register
     coerce_func = _coerce_register[(from_scitype, to_scitype)]
 
diff --git a/sktime/split/expandingcutoff.py b/sktime/split/expandingcutoff.py
index c31c704c8..d9a01c225 100644
--- a/sktime/split/expandingcutoff.py
+++ b/sktime/split/expandingcutoff.py
@@ -63,7 +63,7 @@ class ExpandingCutoffSplitter(BaseSplitter):
     | * * * * * * * * * * * * x x x x x x - |
     | * * * * * * * * * * * * * x x x x x x |
     ```
-    
+
     ``c`` = cutoff date or index.
 
     ``*`` = training fold.
diff --git a/sktime/transformations/compose/_pipeline.py b/sktime/transformations/compose/_pipeline.py
index 5a2ed8a6c..e95a4de3e 100644
--- a/sktime/transformations/compose/_pipeline.py
+++ b/sktime/transformations/compose/_pipeline.py
@@ -228,7 +228,12 @@ class TransformerPipeline(_HeterogenousMetaEstimator, BaseTransformer):
         from sktime.registry import coerce_scitype
         from sktime.regression.compose import SklearnRegressorPipeline
 
-        other = coerce_scitype(other, "transformer")
+        # we only coerce transformations on the right, otherwise we would end up
+        # coercing forecasters, clusterers, etc into transformer pipeline
+        # in a case where a forecaster or clusterer pipeline is intended
+        other = coerce_scitype(
+            other, "transformer", from_scitype=["transformer", "transformer_tabular"]
+        )
 
         # if sklearn classifier, use sklearn classifier pipeline
         if is_sklearn_classifier(other):
diff --git a/sktime/transformations/series/hurst.py b/sktime/transformations/series/hurst.py
new file mode 100644
index 000000000..fa1b84c78
--- /dev/null
+++ b/sktime/transformations/series/hurst.py
@@ -0,0 +1,275 @@
+# Copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+"""Hurst Exponent Transformer for time series analysis."""
+
+from typing import Optional, Union
+
+import numpy as np
+import pandas as pd
+from scipy import stats
+
+from sktime.transformations.base import BaseTransformer
+
+
+class HurstExponentTransformer(BaseTransformer):
+    """Transformer for calculating the Hurst exponent of a time series.
+
+    This transformer calculates the Hurst exponent, which is used to evaluate
+    the auto-correlation properties of time series, particularly the degree of
+    long-range dependence.
+
+    Parameters
+    ----------
+    lags : Optional[Union[List[int], range]], default=None
+        The lags to use for calculation. If None, uses a range based on min_lag
+        and max_lag.
+    method : str, default='rs'
+        The method to use for Hurst exponent calculation. Either 'rs' (rescaled range)
+        or 'dfa' (detrended fluctuation analysis).
+    min_lag : int, default=2
+        The minimum lag to use if lags is None.
+    max_lag : int, default=100
+        The maximum lag to use if lags is None.
+    fit_trend : str, default='c'
+        The trend component to include in the calculation.
+    confidence_level : float, default=0.95
+        The confidence level for the confidence interval calculation.
+
+    Attributes
+    ----------
+    hurst_estimate_ : float
+        The estimated Hurst exponent.
+    confidence_interval_ : tuple
+        The confidence interval for the Hurst exponent estimate.
+    """
+
+    _tags = {
+        "scitype:transform-input": "Series",
+        "scitype:transform-output": "Primitives",
+        "scitype:instancewise": True,
+        "scitype:transform-labels": "None",
+        "X_inner_mtype": "pd.Series",
+        "y_inner_mtype": "None",
+        "univariate-only": True,
+        "requires_y": False,
+        "fit_is_empty": False,
+        "capability:inverse_transform": False,
+        "capability:unequal_length": True,
+        "handles-missing-data": False,
+        "authors": ["phoeenniixx"],
+    }
+
+    def __init__(
+        self,
+        lags: Optional[Union[list[int], range]] = None,
+        method: str = "rs",
+        min_lag: int = 2,
+        max_lag: int = 100,
+        fit_trend: str = "c",
+        confidence_level: float = 0.95,
+    ):
+        self.lags = lags
+        self.method = method
+        self.min_lag = min_lag
+        self.max_lag = max_lag
+        self.fit_trend = fit_trend
+        self.confidence_level = confidence_level
+
+        super().__init__()
+
+    def _fit(self, X: pd.Series, y=None):
+        """Fit transformer to X and y.
+
+        Parameters
+        ----------
+        X : pd.Series
+            The time series data to fit.
+        y : None
+            Ignored.
+
+        Returns
+        -------
+        self : object
+            Returns self.
+        """
+        if len(X) < 4:  # Minimum length to calculate meaningful statistics
+            raise ValueError(
+                f"Time series too short. Length: {len(X)}, minimum required: 4"
+            )
+
+        self._effective_lags = self._get_effective_lags(len(X))
+
+        try:
+            self.hurst_estimate_ = self._hurst_exponent(X)
+        except ValueError as e:
+            raise ValueError(f"Failed to calculate Hurst exponent: {str(e)}") from e
+        return self
+
+    def _get_effective_lags(self, series_length: int) -> Union[list[int], range]:
+        """Get effective lag range based on series length and initial parameters."""
+        if self.lags is not None:
+            return [lag for lag in self.lags if 2 <= lag <= series_length // 2]
+
+        min_lag = max(2, self.min_lag)
+        max_lag = min(series_length // 2, self.max_lag)
+
+        if min_lag >= max_lag:
+            min_lag = 2
+            max_lag = max(4, series_length // 2)
+
+        return range(min_lag, max_lag + 1)
+
+    def _transform(self, X: pd.Series, y=None):
+        """Transform X, return DataFrame with Hurst exponent and confidence interval.
+
+        Parameters
+        ----------
+        X : pd.Series
+            The time series data to transform.
+        y : None
+            Ignored.
+
+        Returns
+        -------
+        X_transformed : pd.DataFrame
+            A single-row DataFrame containing the Hurst exponent
+            and confidence interval.
+        """
+        return pd.DataFrame(
+            {
+                "hurst_exponent": [self.hurst_estimate_],
+                "confidence_interval_lower": [self.confidence_interval_[0]],
+                "confidence_interval_upper": [self.confidence_interval_[1]],
+            }
+        )
+
+    def _hurst_exponent(self, ts: pd.Series) -> float:
+        """Calculate Hurst exponent for a single time series."""
+        if self.method == "rs":
+            return self._rs_method(ts)
+        elif self.method == "dfa":
+            return self._dfa_method(ts)
+        else:
+            raise ValueError("Invalid method. Choose 'rs' or 'dfa'.")
+
+    def _rs_method(self, ts: pd.Series) -> float:
+        """Rescaled range (R/S) method for Hurst exponent calculation."""
+        tau = [self._calculate_rs(ts, lag) for lag in self._effective_lags]
+        tau = [t for t in tau if np.isfinite(t)]  # Remove any NaN or infinite values
+        if not tau:
+            raise ValueError("Unable to calculate valid R/S values")
+        return self._fit_hurst(self._effective_lags[: len(tau)], tau)
+
+    def _dfa_method(self, ts: pd.Series) -> float:
+        """Detrended Fluctuation Analysis method for Hurst exponent calculation."""
+        tau = [self._calculate_dfa(ts, lag) for lag in self._effective_lags]
+        tau = [t for t in tau if np.isfinite(t)]  # Remove any NaN or infinite values
+        if not tau:
+            raise ValueError("Unable to calculate valid DFA values")
+        return self._fit_hurst(self._effective_lags[: len(tau)], tau)
+
+    def _calculate_rs(self, ts: pd.Series, lag: int) -> float:
+        """Calculate rescaled range for a given lag."""
+        rolling_mean = ts.rolling(window=lag).mean()
+        dev_from_mean = ts - rolling_mean
+        range_ts = (
+            dev_from_mean.rolling(window=lag).max()
+            - dev_from_mean.rolling(window=lag).min()
+        )
+        std_ts = ts.rolling(window=lag).std()
+        rs = range_ts / std_ts
+        return np.nanmean(rs)
+
+    def _calculate_dfa(self, ts: pd.Series, lag: int) -> float:
+        """Calculate DFA fluctuation for a given lag."""
+        y = ts.cumsum() - ts.mean()
+        y_len = len(y)
+        n_segments = y_len // lag
+        fluctuations = []
+        for i in range(n_segments):
+            segment = y[i * lag : (i + 1) * lag]
+            x = np.arange(lag)
+            coef = np.polyfit(x, segment, 1)
+            trend = np.polyval(coef, x)
+            fluctuation = np.sqrt(np.mean((segment - trend) ** 2))
+            fluctuations.append(fluctuation)
+        return np.mean(fluctuations)
+
+    def _fit_hurst(self, lags: Union[list[int], range], tau: list[float]) -> float:
+        """Fit Hurst exponent from log-log plot."""
+        log_lags = np.log(lags)
+        log_tau = np.log(tau)
+
+        slope, intercept, r_value, p_value, std_err = stats.linregress(
+            log_lags, log_tau
+        )
+
+        self._calculate_confidence_interval(slope, std_err, len(lags))
+
+        return slope
+
+    def _calculate_confidence_interval(self, slope, std_err, n):
+        """Calculate confidence interval for the Hurst exponent."""
+        df = n - 2
+        t_value = stats.t.ppf((1 + self.confidence_level) / 2, df)
+        margin_of_error = t_value * std_err
+
+        self.confidence_interval_ = (slope - margin_of_error, slope + margin_of_error)
+
+    def plot_log_log(self, ts: pd.Series):
+        """Plot the log-log graph used in Hurst exponent calculation."""
+        from sktime.utils.dependencies._dependencies import _check_soft_dependencies
+
+        try:
+            _check_soft_dependencies("matplotlib", severity="warning")
+            import matplotlib.pyplot as plt
+        except ImportError:
+            # If matplotlib is not installed, we'll just return without plotting
+            return
+
+        lags = self.lags or range(self.min_lag, min(self.max_lag, len(ts) // 2))
+        if self.method == "rs":
+            tau = [self._calculate_rs(ts, lag) for lag in lags]
+            y_label = "log(R/S)"
+        else:  # DFA method
+            tau = [self._calculate_dfa(ts, lag) for lag in lags]
+            y_label = "log(F)"
+
+        log_lags = np.log(lags)
+        log_tau = np.log(tau)
+
+        plt.figure(figsize=(10, 6))
+        plt.scatter(log_lags, log_tau, alpha=0.5)
+        plt.plot(
+            log_lags,
+            self.hurst_estimate_ * log_lags
+            + np.mean(log_tau - self.hurst_estimate_ * log_lags),
+            color="r",
+            label=f"Hurst = {self.hurst_estimate_:.3f}",
+        )
+        plt.xlabel("log(lag)")
+        plt.ylabel(y_label)
+        plt.title(
+            f"Log-Log Plot for Hurst Exponent Estimation ({self.method.upper()} method)"
+        )
+        plt.legend()
+        plt.grid(True, which="both", ls="-", alpha=0.2)
+        plt.show()
+
+    @classmethod
+    def get_test_params(cls, parameter_set="default"):
+        """Return testing parameter settings for the estimator.
+
+        Parameters
+        ----------
+        parameter_set : str, default="default"
+            Name of the set of test parameters to return, for use in tests. If no
+            special parameters are defined for a value, will return `"default"` set.
+
+        Returns
+        -------
+        params : list of dict
+            Parameters to create testing instances of the class
+        """
+        params1 = {"method": "rs", "min_lag": 2, "max_lag": 20}
+        params2 = {"method": "dfa", "min_lag": 2, "max_lag": 20}
+        return [params1, params2]


Test patch:
diff --git a/sktime/performance_metrics/tests/test_annotation_metrics.py b/sktime/performance_metrics/tests/test_annotation_metrics.py
new file mode 100644
index 000000000..ff2841444
--- /dev/null
+++ b/sktime/performance_metrics/tests/test_annotation_metrics.py
@@ -0,0 +1,73 @@
+"""Tests for classes in _classes module."""
+
+import pandas as pd
+import pytest
+from sklearn.metrics import f1_score
+
+from sktime.performance_metrics.annotation import metrics
+
+
+def test_padded_f1_with_sklearn():
+    """Compare padded f1 with sklearn's f1 score.
+
+    f1 score and padded f1 score should be the same when there is one change point per
+    interval.
+    """
+    true_change_points = pd.Series([10, 20])
+    predicted_change_points = pd.Series([10])
+
+    padded_f1 = metrics.padded_f1(true_change_points, predicted_change_points, pad=1)
+    sklearn_f1 = f1_score([0.0, 1.0, 1.0], [0.0, 1.0, 0.0])
+    assert padded_f1 == sklearn_f1
+
+
+@pytest.mark.parametrize(
+    "true_cps,pred_cps,pad",
+    [
+        ([5, 15], [5, 14], 2),
+        (
+            pd.Series(["2024-01-01", "2024-06-01"], dtype="datetime64[ns]"),
+            pd.Series(["2023-12-01", "2024-06-15"], dtype="datetime64[ns]"),
+            pd.tseries.offsets.DateOffset(months=2),
+        ),
+        ([-0.5, 1.6, -5], [-0.3, 1.4, -5], 0.3),
+        pytest.param([5, 15], [5, 18], 2, marks=pytest.mark.xfail),
+    ],
+)
+def test_padded_f1_for_perfect_score(true_cps, pred_cps, pad):
+    """Ensure padded F1 score returns 1.0 when all change points are detected."""
+    padded_f1 = metrics.padded_f1(pd.Series(true_cps), pd.Series(pred_cps), pad)
+    assert padded_f1 == 1.0
+
+
+@pytest.mark.parametrize(
+    "true_cps,pred_cps,threshold",
+    [
+        ([5, 15], [1, 10, 20], 2),
+        (
+            pd.Series(["2024-01-01", "2024-06-01"], dtype="datetime64[ns]"),
+            pd.Series(["2023-06-01", "2024-04-01"]),
+            pd.tseries.offsets.DateOffset(months=1),
+        ),
+        ([-0.5, 1.6], [0.0, 1.0], 0.3),
+    ],
+)
+def test_padded_f1_for_worst_score(true_cps, pred_cps, threshold):
+    """Ensure padded F1 score returns 0.0 when no of the change points are detected."""
+    padded_f1 = metrics.padded_f1(pd.Series(true_cps), pd.Series(pred_cps), threshold)
+    assert padded_f1 == 0.0
+
+
+@pytest.mark.parametrize(
+    "true_cps,pred_cps,thresh,expected_value",
+    [
+        ([5, 20], [5, 10], 2, 0.5),
+        ([5, 12, 13], [5, 10], 1, 0.4),
+        ([4, 10], [5, 12], 1, 0.5),
+        ([4, 10], [5, 12], 2, 1.0),
+    ],
+)
+def test_padded_f1_against_known_score(true_cps, pred_cps, thresh, expected_value):
+    """Test padded f1 against pre-calculated tests where we know the expected value"""
+    padded_f1 = metrics.padded_f1(pd.Series(true_cps), pd.Series(pred_cps), thresh)
+    assert padded_f1 == expected_value