Repository: sktime/sktime
Pull request #7061 (2024-08-30 18:17:49)

[MNT] Try to reduce load for the runners
See also https://github.com/jdb78/pytorch-forecasting/issues/1632


I tried to reduce the load for the runner by reducing the batch size in the PyTorch forecasting models and reduced the model size of the TFT.


In my opinion, this PR is successful if in multiple runs, the CI is not crashing because of HEAP errors and crashed runners... 

benHeid: I tried to reduce it further. Since in the last run only one run failed... 

Patch:
diff --git a/README.md b/README.md
index ab88c119a..14d9a1ae3 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
 
 > A unified interface for machine learning with time series
 
-:rocket: **Version 0.32.3 out now!** [Check out the release notes here](https://www.sktime.net/en/latest/changelog.html).
+:rocket: **Version 0.32.4 out now!** [Check out the release notes here](https://www.sktime.net/en/latest/changelog.html).
 
 sktime is a library for time series analysis in Python. It provides a unified interface for multiple time series learning tasks. Currently, this includes time series classification, regression, clustering, annotation, and forecasting. It comes with [time series algorithms](https://www.sktime.net/en/stable/estimator_overview.html) and [scikit-learn] compatible tools to build, tune and validate time series models.
 
diff --git a/docs/source/api_reference/transformations.rst b/docs/source/api_reference/transformations.rst
index 42373d540..69b7a5c42 100644
--- a/docs/source/api_reference/transformations.rst
+++ b/docs/source/api_reference/transformations.rst
@@ -46,8 +46,8 @@ Transformations are categorized as follows:
 Composition
 -----------
 
-Pipeline building
-~~~~~~~~~~~~~~~~~
+Pipeline building - Structural
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 .. currentmodule:: sktime.transformations.compose
 
@@ -58,24 +58,24 @@ Pipeline building
     TransformerPipeline
     FeatureUnion
     ColumnEnsembleTransformer
-    ColumnwiseTransformer
     FitInTransform
-    MultiplexTransformer
-    OptionalPassthrough
     InvertTransform
-    Id
     YtoX
     IxToX
-    TransformByLevel
-    TransformIf
 
-.. currentmodule:: sktime.transformations.panel.compose
+Pipeline building - Broadcasting and apply-map
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. currentmodule:: sktime.transformations.compose
 
 .. autosummary::
     :toctree: auto_generated/
     :template: class.rst
 
-    ColumnTransformer
+    ColumnwiseTransformer
+    TransformByLevel
+
+.. currentmodule:: sktime.transformations.panel.compose
 
 .. currentmodule:: sktime.transformations.series.func_transform
 
@@ -85,6 +85,19 @@ Pipeline building
 
     FunctionTransformer
 
+Pipeline building - AutoML, switches and multiplexing
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. currentmodule:: sktime.transformations.compose
+
+.. autosummary::
+    :toctree: auto_generated/
+    :template: class.rst
+
+    MultiplexTransformer
+    OptionalPassthrough
+    TransformIf
+    Id
 
 Sklearn and pandas adapters
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -203,6 +216,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/changelog.rst b/docs/source/changelog.rst
index 6ca0f54b9..47fbde099 100644
--- a/docs/source/changelog.rst
+++ b/docs/source/changelog.rst
@@ -19,6 +19,84 @@ For upcoming changes and next releases, see our `milestones <https://github.com/
 For our long-term plan, see our :ref:`roadmap`.
 
 
+Version 0.32.4 - 2024-09-06
+---------------------------
+
+Small feature and fix release for:
+
+* colab compatibility, hotfix for ``polars`` dependency incompatibility
+* compatibility with ``skchange`` 2nd party integration
+* improvements to the anomalies, changepoints, segmentation framework
+* documentation update related to upcoming ``sktime`` elections
+
+Core interface changes
+~~~~~~~~~~~~~~~~~~~~~~
+
+* A scitype adaptation framework is introduced, allowing use of an estimator of one type in
+  a slot for another, via type coercion. The coercion framework covers ``sklearn``, ``skpro``, and ``sktime`` estimators
+  currently, and is extensible. A user and developer shorthand for such coercion is provided
+  as the ``registry.coerce_scitype`` function, which dispatches to individual coercion mechanisms.
+* Time series clusterers that produce a cluster assignment can be coerced to transformations,
+  enabling their use in any pipeline slot for transformations.
+* Anomaly and changepoint detectors can now be pipelined with transformations, resulting in a detector.
+  Dunder concatenation ``transformer * detector`` will default to this.
+* Anomaly and changepoint detectors can be coerced to transformations, enabling their use in any
+  pipeline slot for transformations.
+
+Enhancements
+~~~~~~~~~~~~
+
+BaseObject and base framework
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+* [ENH] decouple registry from base modules, scitype specific data records for documentation of estimator types  (:pr:`6998`) :user:`fkiraly`
+* [ENH] scitype coercion and checking utility (:pr:`6969`) :user:`fkiraly`
+
+Time series anomalies, changepoints, segmentation
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+* [ENH] Add padded f1 score for evaluating change point detection algorithms (:pr:`7034`) :user:`Alex-JG3`
+* [ENH] improvements to ``BaseSeriesAnnotator`` base class for anomaly, changepoint, segments (:pr:`7073`) :user:`fkiraly`
+* [ENH] pipeline for anomaly, changepoint detectors and segmenters (:pr:`7071`) :user:`fkiraly`
+* [ENH] coercion to use time series anomaly, changepoint detectors as transformers (:pr:`7072`) :user:`fkiraly`
+
+Time series clustering
+^^^^^^^^^^^^^^^^^^^^^^
+
+* [ENH] enable use of clusterers as transformations, enable ``TransformSelectForecaster`` use of clusterers for group selection (:pr:`7068`) :user:`fkiraly`
+
+Transformations
+^^^^^^^^^^^^^^^
+
+* [ENH] Hurst exponent feature extraction transformer (:pr:`7065`) :user:`phoeenniixx`
+
+Documentation
+~~~~~~~~~~~~~
+
+* [DOC] Add SVG version of the sktime logo with no text  (:pr:`7024`) :user:`wirrywoo`
+* [DOC] Improve documentation for ``TinyTimeMixer`` (:pr:`7009`) :user:`geetu040`
+* [DOC] fix broken links in continous integration docs (:pr:`7059`) :user:`Saptarshi-Bandopadhyay`
+* [DOC] split list of transformation pipeline components into subcategories (:pr:`7075`) :user:`fkiraly`
+
+Fixes
+~~~~~
+
+* [BUG] fix ``sktime`` crash with older ``polars`` versions (:pr:`7057`) :user:`fkiraly`
+* [BUG] Fix ``ForecastX.update`` when the ``forecaster_X_exogeneous`` is set to ``"complement"`` (:pr:`7041`) :user:`fnhirwa`
+* [BUG] fix ``ExpandingCutoffSplitter`` for case where ``fh`` is not continuous (:pr:`7053`) :user:`ninedigits`
+
+Contributors
+~~~~~~~~~~~~
+
+:user:`Alex-JG3`,
+:user:`fkiraly`,
+:user:`fnhirwa`,
+:user:`geetu040`,
+:user:`ninedigits`,
+:user:`phoeenniixx`,
+:user:`Saptarshi-Bandopadhyay`,
+:user:`wirrywoo`
+
 Version 0.32.3 - 2024-08-27
 ---------------------------
 
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/docs/source/get_involved/governance.rst b/docs/source/get_involved/governance.rst
index 9993e3748..760696f1a 100644
--- a/docs/source/get_involved/governance.rst
+++ b/docs/source/get_involved/governance.rst
@@ -336,56 +336,25 @@ Rights and responsibilities
    * - Project management
      - Funding, collaborations with external organisations, community infrastructure (chat server, GitHub repositories, continuous integration accounts, social media accounts)
 
-Eligibility
-^^^^^^^^^^^
-
-Only core developers are eligible for appointment as CC members.
-Non-core-developers can be nominated, but this must be accompanied
-by a nomination for core developer, and a core developer appointment vote
-concurrent with the 5 day discussion period (see below).
-
 .. _appointment-3:
 
-Appointment
-^^^^^^^^^^^
-
-Appointment to the CC is by nomination by a core developer and a vote by
-all core developers. A nomination will result in discussion which stays open
-for 5 days excluding weekends and then a vote by core developers which will
-stay open for 5 days excluding weekends. Approval of appointment requires:
-
-* a 2/3 majority of all cast votes, and
-* a simple majority approval of all the current CC members.
-
-The core developer vote takes place in private communication channels,
-visible to, and only to, core developers, and is anonymous.
-The CC members' vote takes place in private communication channels,
-visible to, and only to, CC members, and is anonymous.
-
-In case the CC members' vote is tied,
-the CC member with the shortest continuous tenure has a tie breaking privilege.
-
-The tie breaking is by construction visible to CC members,
-does not require the tie breaking CC member to have voted in the anonymous vote,
-and need not follow their anonymous vote if they have voted.
-
-End of tenure
-^^^^^^^^^^^^^
+Eligibility and appointment
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-CC members can resign voluntarily at any point in time, by informing the CC in writing.
+The community council is elected by contributors to sktime in regular elections.
 
-CC members who do not actively engage with their CC member role responsibilities are
-expected to resign voluntarily.
+The elections process is detailed in the `community council elections repository <https://github.com/sktime/elections/>`__.
 
-Tenure also ends automatically when a CC member's tenure as core developer ends,
-e.g., through resignation or inactivity.
+The repository linked contains information about the election process, including the election schedule, the election rules, and past election results.
 
 Communications
 ^^^^^^^^^^^^^^
 
 The CC has regular public meetings that the full community is welcome to attend.
+Meetings take place on the social channels of the project, currently on Discord.
 
-For more details about our meetings, please go to our `community-council repository <https://github.com/sktime/community-council/>`__.
+For more details about our meetings and minutes of previous meetings,
+please go to our `community-council repository <https://github.com/sktime/community-org/tree/main/community_council/previous_meetings/>`__.
 
 To contact the CC directly, please send an email to sktime.toolbox@gmail.com.
 
diff --git a/pyproject.toml b/pyproject.toml
index 514d510be..2bfaa22f1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [project]
 name = "sktime"
-version = "0.32.3"
+version = "0.32.4"
 description = "A unified framework for machine learning with time series"
 readme = "README.md"
 keywords = [
@@ -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/__init__.py b/sktime/__init__.py
index c8b6b28aa..ec1aa33e1 100644
--- a/sktime/__init__.py
+++ b/sktime/__init__.py
@@ -1,6 +1,6 @@
 """sktime."""
 
-__version__ = "0.32.3"
+__version__ = "0.32.4"
 
 __all__ = ["show_versions"]
 
diff --git a/sktime/annotation/__init__.py b/sktime/annotation/__init__.py
index c87ec57b3..c39a804f9 100644
--- a/sktime/annotation/__init__.py
+++ b/sktime/annotation/__init__.py
@@ -1,1 +1,1 @@
-"""Implements time series annotation."""
+"""Time series anomaly, changepoint detection, segmentation."""
diff --git a/sktime/annotation/base/_base.py b/sktime/annotation/base/_base.py
index 140394141..e5faa183d 100644
--- a/sktime/annotation/base/_base.py
+++ b/sktime/annotation/base/_base.py
@@ -71,16 +71,55 @@ class BaseSeriesAnnotator(BaseEstimator):
     }  # for unit test cases
 
     def __init__(self):
-        self.task = self.get_class_tag("task")
-        self.learning_type = self.get_class_tag("learning_type")
-
         self._is_fitted = False
 
         self._X = None
         self._Y = None
 
+        task = self.get_tag("task")
+        learning_type = self.get_tag("learning_type")
+
         super().__init__()
 
+        # hacky workaround to ensure task and learning_type are set
+        # TODO 0.34.0: remove the self.task and self.learning_type attributes
+        # if possible, check downwards compatibility
+        self.set_tags(**{"task": task, "learning_type": learning_type})
+        self.task = task
+        self.learning_type = learning_type
+
+    def __rmul__(self, other):
+        """Magic * method, return (left) concatenated AnnotatorPipeline.
+
+        Implemented for ``other`` being a transformer, otherwise returns
+        ``NotImplemented``.
+
+        Parameters
+        ----------
+        other: ``sktime`` transformer, must inherit from BaseTransformer
+            otherwise, ``NotImplemented`` is returned
+
+        Returns
+        -------
+        AnnotatorPipeline object,
+            concatenation of ``other`` (first) with ``self`` (last).
+            not nested, contains only non-AnnotatorPipeline ``sktime`` steps
+        """
+        from sktime.annotation.compose import AnnotatorPipeline
+        from sktime.transformations.base import BaseTransformer
+        from sktime.transformations.series.adapt import TabularToSeriesAdaptor
+        from sktime.utils.sklearn import is_sklearn_transformer
+
+        # we wrap self in a pipeline, and concatenate with the other
+        #   the TransformedTargetForecaster does the rest, e.g., dispatch on other
+        if isinstance(other, BaseTransformer):
+            self_as_pipeline = AnnotatorPipeline(steps=[self])
+            return other * self_as_pipeline
+        elif is_sklearn_transformer(other):
+            return TabularToSeriesAdaptor(other) * self
+        else:
+            return NotImplemented
+
     def fit(self, X, Y=None):
         """Fit to training data.
 
@@ -155,11 +194,7 @@ class BaseSeriesAnnotator(BaseEstimator):
             Annotations for sequence X. The returned annotations will be in the dense
             format.
         """
-        if self.task == "anomaly_detection" or self.task == "change_point_detection":
-            Y = self.predict_points(X)
-        elif self.task == "segmentation":
-            Y = self.predict_segments(X)
-
+        Y = self.predict(X)
         return self.sparse_to_dense(Y, X.index)
 
     def predict_scores(self, X):
@@ -378,18 +413,15 @@ class BaseSeriesAnnotator(BaseEstimator):
             A series with an index of intervals. Each interval is the range of a
             segment and the corresponding value is the label of the segment.
         """
-        if self.task == "anomaly_detection":
-            raise RuntimeError(
-                "Anomaly detection annotators should not be used for segmentation."
-            )
         self.check_is_fitted()
         X = check_series(X)
 
-        if self.task == "change_point_detection":
+        task = self.get_tag("task")
+        if task in ["anomaly_detection", "change_point_detection"]:
             return self.change_points_to_segments(
                 self.predict_points(X), start=X.index.min(), end=X.index.max()
             )
-        elif self.task == "segmentation":
+        elif task == "segmentation":
             return self._predict_segments(X)
 
     def predict_points(self, X):
@@ -408,9 +440,10 @@ class BaseSeriesAnnotator(BaseEstimator):
         self.check_is_fitted()
         X = check_series(X)
 
-        if self.task == "anomaly_detection" or self.task == "change_point_detection":
+        task = self.get_tag("task")
+        if task in ["anomaly_detection", "change_point_detection"]:
             return self._predict_points(X)
-        elif self.task == "segmentation":
+        elif task == "segmentation":
             return self.segments_to_change_points(self.predict_segments(X))
 
     def _predict_segments(self, X):
@@ -427,7 +460,7 @@ class BaseSeriesAnnotator(BaseEstimator):
             A series with an index of intervals. Each interval is the range of a
             segment and the corresponding value is the label of the segment.
         """
-        raise NotImplementedError("abstract method")
+        return self._predict(X)
 
     def _predict_points(self, X):
         """Predict changepoints/anomalies on test/deployment data.
@@ -442,7 +475,7 @@ class BaseSeriesAnnotator(BaseEstimator):
         Y : pd.Series
             A series whose values are the changepoints/anomalies in X.
         """
-        raise NotImplementedError("abstract method")
+        return self._predict(X)
 
     @staticmethod
     def sparse_to_dense(y_sparse, index):
diff --git a/sktime/annotation/compose/__init__.py b/sktime/annotation/compose/__init__.py
new file mode 100644
index 000000000..9a23397e2
--- /dev/null
+++ b/sktime/annotation/compose/__init__.py
@@ -0,0 +1,8 @@
+"""Composition for outlier, changepoint, segmentation estimators."""
+
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+
+from sktime.annotation.compose._as_transform import AnnotatorAsTransformer
+from sktime.annotation.compose._pipeline import AnnotatorPipeline
+
+__all__ = ["AnnotatorAsTransformer", "AnnotatorPipeline"]
diff --git a/sktime/annotation/compose/_as_transform.py b/sktime/annotation/compose/_as_transform.py
new file mode 100644
index 000000000..1d107ca31
--- /dev/null
+++ b/sktime/annotation/compose/_as_transform.py
@@ -0,0 +1,152 @@
+"""Using an annotation estimator as transformation."""
+
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+from sktime.datatypes import MTYPE_LIST_SERIES
+from sktime.transformations.base import BaseTransformer
+
+__author__ = ["fkiraly"]
+__all__ = ["AnnotatorAsTransformer"]
+
+
+MTYPE_LIST_FOR_ANNOTATORS = MTYPE_LIST_SERIES
+# override until annotators only support pd.Series
+MTYPE_LIST_FOR_ANNOTATORS = ["pd.Series"]
+
+
+class AnnotatorAsTransformer(BaseTransformer):
+    """Use an anomaly, changepoint detector, segmentation estimator as a transformer.
+
+    This adapter is used in coercions, when passing an annotator to a transformer slot.
+
+    The transformation is series-to-primitives, transforming a time series
+    into its cluster assignment.
+
+    The adapter dispatches ``BaseTransformer.transform`` to
+    ``BaseSeriesAnnotator.transform``.
+
+    Parameters
+    ----------
+    estimator : sktime annotator, i.e., estimator inheriting from BaseSeriesAnnotator
+        this is a "blueprint" clusterer, state does not change when ``fit`` is called
+
+    Attributes
+    ----------
+    estimator_ : sktime annotator, clone of ``estimator``
+        this clone is fitted in the pipeline when ``fit`` is called
+
+    Examples
+    --------
+    >>> from sktime.annotation.compose import AnnotatorAsTransformer
+    >>> from sktime.annotation.stray import STRAY
+    >>> from sktime.utils._testing import _make_hierarchical
+    >>> X = _make_hierarchical()
+    >>> t = AnnotatorAsTransformer(STRAY())
+    >>> t.fit(X)
+    AnnotatorAsTransformer(...)
+    >>> Xt = t.transform(X)
+    """
+
+    _tags = {
+        # packaging info
+        # --------------
+        "authors": ["fkiraly"],
+        # estimator type
+        # --------------
+        "scitype:transform-input": "Series",
+        "scitype:transform-output": "Series",
+        "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_FOR_ANNOTATORS,
+        "y_inner_mtype": MTYPE_LIST_FOR_ANNOTATORS,
+        "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, estimator):
+        self.estimator = estimator
+        self.estimator_ = estimator.clone()
+        super().__init__()
+
+        requires_y = estimator.get_tag("learning_type", "unsupervised") == "supervised"
+
+        # forward tag information
+        tags_to_set = {"requires_y": requires_y}
+        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.estimator_.fit(X=X, Y=y)
+        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.estimator_.transform(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.annotation.lof import SubLOF
+
+        params1 = {"estimator": SubLOF.create_test_instance()}
+        params2 = {"estimator": SubLOF.create_test_instance()}
+
+        return [params1, params2]
diff --git a/sktime/annotation/compose/_pipeline.py b/sktime/annotation/compose/_pipeline.py
new file mode 100644
index 000000000..0fa3c43f9
--- /dev/null
+++ b/sktime/annotation/compose/_pipeline.py
@@ -0,0 +1,394 @@
+"""Pipeline for time series annotators."""
+
+from sktime.annotation.base import BaseSeriesAnnotator
+from sktime.base import _HeterogenousMetaEstimator
+from sktime.registry import scitype
+
+
+class AnnotatorPipeline(_HeterogenousMetaEstimator, BaseSeriesAnnotator):
+    """Pipeline for time series anomaly, changepoint detection, segmentation.
+
+    Parameters
+    ----------
+    steps : list of sktime transformers and annotation estimators, or
+        list of tuples (str, estimator) of ``sktime`` transformers or annotators.
+        The list must contain exactly one forecaster.
+        These are "blueprint" transformers resp forecasters,
+        annotator/transformer states do not change when ``fit`` is called.
+
+    Attributes
+    ----------
+    steps_ : list of tuples (str, estimator) of ``sktime`` transformers or annotators
+        clones of estimators in ``steps`` which are fitted in the pipeline
+        is always in (str, estimator) format, even if ``steps`` is just a list
+        strings not passed in ``steps`` are replaced by unique generated strings
+        i-th transformer in ``steps_`` is clone of i-th in ``steps``
+    estimator_ : estimator, reference to the first non-transformer in ``steps_``
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> import pandas as pd
+    >>> from sktime.annotation.lof import SubLOF
+    >>> from sktime.transformations.series.detrend import Detrender
+    >>>
+    >>> n = 100
+    >>> x = pd.Series(np.linspace(0, 5, n) + np.random.normal(0, 0.1, size=n))
+    >>> x.at[50] = 100
+    >>>
+    >>> pipeline = Detrender() * SubLOF(n_neighbors=5, window_size=5, novelty=True)
+    >>> pipeline.fit(x)
+    >>> y_hat = pipeline.transform(x)
+    """
+
+    # Change the `task` and `learning_type` as needed
+    _tags = {"learning_type": "unsupervised"}
+
+    # for default get_params/set_params from _HeterogenousMetaEstimator
+    # _steps_attr points to the attribute of self
+    # which contains the heterogeneous set of estimators
+    # this must be an iterable of (name: str, estimator, ...) tuples for the default
+    _steps_attr = "_steps"
+    # if the estimator is fittable, _HeterogenousMetaEstimator also
+    # provides an override for get_fitted_params for params from the fitted estimators
+    # the fitted estimators should be in a different attribute, _steps_fitted_attr
+    # this must be an iterable of (name: str, estimator, ...) tuples for the default
+    _steps_fitted_attr = "steps_"
+
+    def __init__(self, steps):
+        self.steps = steps
+        self.steps_ = self._check_steps(steps, allow_postproc=False)
+
+        tags_to_clone = ["learning_type", "task"]
+        # we do not clone X-y-must-have-same-index, since transformers can
+        #   create indices, and that behaviour is not tag-inspectable
+        self.clone_tags(self.estimator_, tags_to_clone)
+
+        # init must be called at the end so task is properly set
+        super().__init__()
+
+    def __rmul__(self, other):
+        """Magic * method, return (left) concatenated AnnotatorPipeline.
+
+        Implemented for ``other`` being a transformer, otherwise returns
+        ``NotImplemented``.
+
+        Parameters
+        ----------
+        other: ``sktime`` transformer, must inherit from BaseTransformer
+            otherwise, ``NotImplemented`` is returned
+
+        Returns
+        -------
+        TransformedTargetForecaster object,
+            concatenation of ``other`` (first) with ``self`` (last).
+            not nested, contains only non-AnnotatorPipeline ``sktime`` steps
+        """
+        from sktime.transformations.base import BaseTransformer
+        from sktime.transformations.compose import TransformerPipeline
+
+        _, ests = zip(*self.steps_)
+        names = tuple(self._get_estimator_names(self.steps))
+        if isinstance(other, TransformerPipeline):
+            _, trafos_o = zip(*other.steps_)
+            names_o = tuple(other._get_estimator_names(other.steps))
+            new_names = names_o + names
+            new_ests = trafos_o + ests
+        elif isinstance(other, BaseTransformer):
+            new_names = (type(other).__name__,) + names
+            new_ests = (other,) + ests
+        elif self._is_name_and_est(other, BaseTransformer):
+            other_name = other[0]
+            other_trafo = other[1]
+            new_names = (other_name,) + names
+            new_ests = (other_trafo,) + ests
+        else:
+            return NotImplemented
+
+        # if all the names are equal to class names, we eat them away
+        if all(type(x[1]).__name__ == x[0] for x in zip(new_names, new_ests)):
+            return AnnotatorPipeline(steps=list(new_ests))
+        else:
+            return AnnotatorPipeline(steps=list(zip(new_names, new_ests)))
+
+    @property
+    def estimator_(self):
+        """Return reference to the annotator in the pipeline.
+
+        Valid after _fit.
+        """
+        return self.steps_[-1][1]
+
+    def _get_pipeline_scitypes(self, estimators):
+        """Get list of scityes (str) from names/estimator list."""
+        return [scitype(x[1], raise_on_unknown=False) for x in estimators]
+
+    def _get_first_annotator_index(self, estimators):
+        """Get the index of the first forecaster in the list."""
+        sts = self._get_pipeline_scitypes(estimators)
+        for i, s in enumerate(sts):
+            if s != "transformer":
+                return i
+        return -1
+
+    def _check_steps(self, estimators, allow_postproc=False):
+        """Check Steps.
+
+        Parameters
+        ----------
+        estimators : list of estimators, or list of (name, estimator) pairs
+        allow_postproc : bool, optional, default=False
+            whether transformers after the annotator are allowed
+
+        Returns
+        -------
+        step : list of (name, estimator) pairs, estimators are cloned (not references)
+            if estimators was a list of (str, estimator) tuples, then just cloned
+            if was a list of estimators, then str are generated via _get_estimator_names
+
+        Raises
+        ------
+        TypeError if names in ``estimators`` are not unique
+        TypeError if estimators in ``estimators`` are not all forecaster or transformer
+        TypeError if there is not exactly one forecaster in ``estimators``
+        TypeError if not allow_postproc and forecaster is not last estimator
+        """
+        self_name = type(self).__name__
+        if not isinstance(estimators, list):
+            msg = (
+                f"steps in {self_name} must be list of estimators, "
+                f"or (string, estimator) pairs, "
+                f"the two can be mixed; but, found steps of type {type(estimators)}"
+            )
+            raise TypeError(msg)
+
+        estimator_tuples = self._get_estimator_tuples(estimators, clone_ests=True)
+        names, estimators = zip(*estimator_tuples)
+
+        # validate names
+        self._check_names(names)
+
+        ann_ind = self._get_first_annotator_index(estimator_tuples)
+
+        if not allow_postproc and ann_ind != len(estimators) - 1:
+            TypeError(
+                f"in {self_name}, last estimator must be a time series annotator, "
+                f"but found a transformer"
+            )
+
+        # Shallow copy
+        return estimator_tuples
+
+    def _iter_transformers(self, reverse=False, an_idx=-1):
+        # exclude final forecaster
+        steps = self.steps_[:an_idx]
+
+        if reverse:
+            steps = reversed(steps)
+
+        for idx, (name, transformer) in enumerate(steps):
+            yield idx, name, transformer
+
+    def __len__(self):
+        """Return the length of the Pipeline."""
+        return len(self.steps)
+
+    @property
+    def named_steps(self):
+        """Map the steps to a dictionary."""
+        return dict(self._steps)
+
+    @property
+    def _steps(self):
+        return self._get_estimator_tuples(self.steps, clone_ests=False)
+
+    @_steps.setter
+    def _steps(self, value):
+        self.steps = value
+
+    def _components(self, base_class=None):
+        """Return references to all state changing BaseObject type attributes.
+
+        This *excludes* the blue-print-like components passed in the __init__.
+
+        Caution: this method returns *references* and not *copies*.
+            Writing to the reference will change the respective attribute of self.
+
+        Parameters
+        ----------
+        base_class : class, optional, default=None, must be subclass of BaseObject
+            if None, behaves the same as ``base_class=BaseObject``
+            if not None, return dict collects descendants of ``base_class``
+
+        Returns
+        -------
+        dict with key = attribute name, value = reference to attribute
+        dict contains all attributes of ``self`` that inherit from ``base_class``, and:
+            whose names do not contain the string "__", e.g., hidden attributes
+            are not class attributes, and are not hyper-parameters (``__init__`` args)
+        """
+        import inspect
+
+        from sktime.base import BaseObject
+
+        if base_class is None:
+            base_class = BaseObject
+        if base_class is not None and not inspect.isclass(base_class):
+            raise TypeError(f"base_class must be a class, but found {type(base_class)}")
+        # if base_class is not None and not issubclass(base_class, BaseObject):
+        #     raise TypeError("base_class must be a subclass of BaseObject")
+
+        fitted_estimator_tuples = self.steps_
+
+        comp_dict = {name: comp for (name, comp) in fitted_estimator_tuples}
+        return comp_dict
+
+    def _fit(self, X, Y=None):
+        """Fit to training data.
+
+        core logic
+
+        Parameters
+        ----------
+        X : pd.DataFrame
+            training data to fit model to, time series
+        Y : pd.Series, optional
+            ground truth annotations for training if annotator is supervised
+
+        Returns
+        -------
+        self : returns a reference to self
+
+        State change
+        ------------
+        creates fitted model (attributes ending in "_")
+        """
+        # skip transformers if X is ignored
+        # condition 1 for ignoring X: X is None and required in fit of 1st transformer
+        first_trafo = self.steps_[0][1]
+        cond1 = len(self.steps_) > 1 and first_trafo.get_tag("requires_X")
+        cond1 = cond1 and X is None
+
+        # X ignored = condition 1
+        skip_trafos = cond1
+        self.skip_trafos_ = skip_trafos
+
+        # If X is ignored, just ignore the transformers and pass through to forecaster
+        if not skip_trafos:
+            # transform X
+            for step_idx, name, transformer in self._iter_transformers():
+                t = transformer.clone()
+                X = t.fit_transform(X=X, y=Y)
+                self.steps_[step_idx] = (name, t)
+
+        # fit annotator
+        name, annotator = self.steps_[-1]
+        f = annotator.clone()
+        f.fit(X=X, Y=Y)
+        self.steps_[-1] = (name, f)
+
+        return self
+
+    def _transform(self, X=None, y=None):
+        # If X is not given or ignored, just passthrough the data without transformation
+        if not self.skip_trafos_:
+            for _, _, transformer in self._iter_transformers():
+                X = transformer.transform(X=X, y=y)
+        return X
+
+    def _predict(self, X):
+        """Predict on test/deployment data.
+
+        Parameters
+        ----------
+        X : pd.DataFrame
+            Data to annotate, time series.
+
+        Returns
+        -------
+        Y : pd.Series
+            A series with an index of intervals. Each interval is the range of a
+            segment and the corresponding value is the label of the segment.
+        """
+        X = self._transform(X=X)
+        return self.estimator_.predict(X)
+
+    def _predict_segments(self, X):
+        """Predict segments on test/deployment data.
+
+        Parameters
+        ----------
+        X : pd.DataFrame
+            Data to annotate, time series.
+
+        Returns
+        -------
+        Y : pd.Series
+            A series with an index of intervals. Each interval is the range of a
+            segment and the corresponding value is the label of the segment.
+        """
+        X = self._transform(X=X)
+        return self.estimator_.predict_segments(X)
+
+    def _predict_points(self, X):
+        """Predict changepoints/anomalies on test/deployment data.
+
+        Parameters
+        ----------
+        X : pd.DataFrame
+            Data to annotate, time series.
+
+        Returns
+        -------
+        Y : pd.Series
+            A series whose values are the changepoints/anomalies in X.
+        """
+        X = self._transform(X=X)
+        return self.estimator_.predict_points(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.
+            There are currently no reserved values for annotators.
+
+        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`
+        """
+        import datetime
+
+        from sklearn.preprocessing import StandardScaler
+
+        from sktime.annotation.lof import SubLOF
+        from sktime.transformations.series.adapt import TabularToSeriesAdaptor
+        from sktime.transformations.series.detrend import Detrender
+        from sktime.transformations.series.exponent import ExponentTransformer
+
+        lof = SubLOF(
+            n_neighbors=5, window_size=datetime.timedelta(days=25), novelty=True
+        )
+        STEPS1 = [
+            ("transformer", TabularToSeriesAdaptor(StandardScaler())),
+            ("anomaly", lof),
+        ]
+        params1 = {"steps": STEPS1}
+
+        STEPS2 = [
+            ("transformer", ExponentTransformer()),
+            ("anomaly", lof),
+        ]
+        params2 = {"steps": STEPS2}
+
+        params3 = {"steps": [Detrender(), lof]}
+
+        return [params1, params2, params3]
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/pytorchforecasting.py b/sktime/forecasting/pytorchforecasting.py
index c1c2ec9a5..7925676c7 100644
--- a/sktime/forecasting/pytorchforecasting.py
+++ b/sktime/forecasting/pytorchforecasting.py
@@ -66,7 +66,7 @@ class PytorchForecastingTFT(_PytorchForecastingAdapter):
     >>> model = PytorchForecastingTFT(
     ...     trainer_params={
     ...         "max_epochs": 5,  # for quick test
-    ...         "limit_train_batches": 10,  # for quick test
+    ...         "limit_train_batches": 2,  # for quick test
     ...     },
     ... )
     >>> # fit and predict
@@ -204,7 +204,7 @@ class PytorchForecastingTFT(_PytorchForecastingAdapter):
                 {
                     "trainer_params": {
                         "max_epochs": 1,  # for quick test
-                        "limit_train_batches": 10,  # for quick test
+                        "limit_train_batches": 2,  # for quick test
                         "enable_checkpointing": False,
                         "logger": False,
                     },
@@ -212,27 +212,29 @@ class PytorchForecastingTFT(_PytorchForecastingAdapter):
                         "max_encoder_length": 3,
                     },
                     "model_params": {
-                        "hidden_size": 8,
+                        "hidden_size": 4,
                         "lstm_layers": 1,
                         "log_interval": -1,
                     },
+                    "train_to_dataloader_params": {"batch_size": 2},
                     "random_log_path": True,  # fix multiprocess file access error in CI
                 },
                 {
                     "trainer_params": {
                         "max_epochs": 1,  # for quick test
-                        "limit_train_batches": 10,  # for quick test
+                        "limit_train_batches": 2,  # for quick test
                         "enable_checkpointing": False,
                         "logger": False,
                     },
                     "model_params": {
-                        "hidden_size": 8,
+                        "hidden_size": 4,
                         "lstm_layers": 1,
                         "dropout": 0.1,
                         "optimizer": "Adam",
                         # avoid jdb78/pytorch-forecasting#1571 bug in the CI
                         "log_interval": -1,
                     },
+                    "train_to_dataloader_params": {"batch_size": 2},
                     "dataset_params": {
                         "max_encoder_length": 3,
                     },
@@ -255,30 +257,31 @@ class PytorchForecastingTFT(_PytorchForecastingAdapter):
                 {
                     "trainer_params": {
                         "max_epochs": 1,  # for quick test
-                        "limit_train_batches": 10,  # for quick test
+                        "limit_train_batches": 2,  # for quick test
                         "enable_checkpointing": False,
                         "logger": False,
                     },
                     "model_params": {
                         "log_interval": -1,
-                        "hidden_size": 8,
+                        "hidden_size": 4,
                         "lstm_layers": 1,
                     },
                     "dataset_params": {
                         "max_encoder_length": 3,
                     },
+                    "train_to_dataloader_params": {"batch_size": 2},
                     "random_log_path": True,  # fix multiprocess file access error in CI
                 },
                 {
                     "trainer_params": {
                         "callbacks": [early_stop_callback],
                         "max_epochs": 1,  # for quick test
-                        "limit_train_batches": 10,  # for quick test
+                        "limit_train_batches": 2,  # for quick test
                         "enable_checkpointing": False,
                         "logger": False,
                     },
                     "model_params": {
-                        "hidden_size": 8,
+                        "hidden_size": 4,
                         "lstm_layers": 1,
                         "dropout": 0.1,
                         # "loss": QuantileLoss(),
@@ -291,6 +294,7 @@ class PytorchForecastingTFT(_PytorchForecastingAdapter):
                     "dataset_params": {
                         "max_encoder_length": 3,
                     },
+                    "train_to_dataloader_params": {"batch_size": 2},
                     "random_log_path": True,  # fix multiprocess file access error in CI
                 },
             ]
@@ -482,13 +486,14 @@ class PytorchForecastingNBeats(_PytorchForecastingAdapter):
                 {
                     "trainer_params": {
                         "max_epochs": 1,  # for quick test
-                        "limit_train_batches": 10,  # for quick test
+                        "limit_train_batches": 2,  # for quick test
                         "enable_checkpointing": False,
                         "logger": False,
                     },
                     "dataset_params": {
                         "max_encoder_length": 3,
                     },
+                    "train_to_dataloader_params": {"batch_size": 2},
                     "model_params": {
                         "num_blocks": [1, 1],
                         "num_block_layers": [1, 1],
@@ -500,7 +505,7 @@ class PytorchForecastingNBeats(_PytorchForecastingAdapter):
                 {
                     "trainer_params": {
                         "max_epochs": 1,  # for quick test
-                        "limit_train_batches": 10,  # for quick test
+                        "limit_train_batches": 2,  # for quick test
                         "enable_checkpointing": False,
                         "logger": False,
                     },
@@ -514,6 +519,7 @@ class PytorchForecastingNBeats(_PytorchForecastingAdapter):
                     "dataset_params": {
                         "max_encoder_length": 3,
                     },
+                    "train_to_dataloader_params": {"batch_size": 2},
                     "random_log_path": True,  # fix multiprocess file access error in CI
                 },
             ]
@@ -531,7 +537,7 @@ class PytorchForecastingNBeats(_PytorchForecastingAdapter):
                 {
                     "trainer_params": {
                         "max_epochs": 1,  # for quick test
-                        "limit_train_batches": 10,  # for quick test
+                        "limit_train_batches": 2,  # for quick test
                         "enable_checkpointing": False,
                         "logger": False,
                     },
@@ -544,13 +550,14 @@ class PytorchForecastingNBeats(_PytorchForecastingAdapter):
                     "dataset_params": {
                         "max_encoder_length": 3,
                     },
+                    "train_to_dataloader_params": {"batch_size": 2},
                     "random_log_path": True,  # fix multiprocess file access error in CI
                 },
                 {
                     "trainer_params": {
                         "callbacks": [early_stop_callback],
                         "max_epochs": 1,  # for quick test
-                        "limit_train_batches": 10,  # for quick test
+                        "limit_train_batches": 2,  # for quick test
                         "enable_checkpointing": False,
                         "logger": False,
                     },
@@ -565,6 +572,7 @@ class PytorchForecastingNBeats(_PytorchForecastingAdapter):
                     "dataset_params": {
                         "max_encoder_length": 3,
                     },
+                    "train_to_dataloader_params": {"batch_size": 2},
                     "random_log_path": True,  # fix multiprocess file access error in CI
                 },
             ]
@@ -770,39 +778,38 @@ class PytorchForecastingDeepAR(_PytorchForecastingAdapter):
                 {
                     "trainer_params": {
                         "max_epochs": 1,  # for quick test
-                        "limit_train_batches": 10,  # for quick test
-                        "enable_checkpointing": False,
+                        "limit_train_batches": 2,  # for quick test
                         "logger": False,
                     },
                     "model_params": {
                         "cell_type": "GRU",
                         "rnn_layers": 1,
-                        "hidden_size": 3,
-                        "enable_checkpointing": False,
+                        "hidden_size": 2,
                         "log_interval": -1,
                     },
                     "dataset_params": {
                         "max_encoder_length": 3,
                     },
+                    "train_to_dataloader_params": {"batch_size": 2},
                     "random_log_path": True,  # fix multiprocess file access error in CI
                     "deterministic": True,  # to pass test_score
                 },
                 {
                     "trainer_params": {
                         "max_epochs": 1,  # for quick test
-                        "limit_train_batches": 10,  # for quick test
-                        "enable_checkpointing": False,
+                        "limit_train_batches": 2,  # for quick test
                         "logger": False,
                     },
                     "model_params": {
-                        "cell_type": "GRU",
-                        "rnn_layers": 2,
-                        "hidden_size": 3,
+                        "cell_type": "LSTM",
+                        "rnn_layers": 1,
+                        "hidden_size": 2,
                         "log_interval": -1,
                     },
                     "dataset_params": {
                         "max_encoder_length": 3,
                     },
+                    "train_to_dataloader_params": {"batch_size": 2},
                     "random_log_path": True,  # fix multiprocess file access error in CI
                     "deterministic": True,  # to pass test_score
                 },
@@ -821,16 +828,19 @@ class PytorchForecastingDeepAR(_PytorchForecastingAdapter):
                 {
                     "trainer_params": {
                         "max_epochs": 1,  # for quick test
-                        "limit_train_batches": 10,  # for quick test
-                        "enable_checkpointing": False,
+                        "limit_train_batches": 2,  # for quick test
                         "logger": False,
                     },
                     "model_params": {
+                        "cell_type": "GRU",
+                        "rnn_layers": 1,
+                        "hidden_size": 2,
                         "log_interval": -1,
                     },
                     "dataset_params": {
                         "max_encoder_length": 3,
                     },
+                    "train_to_dataloader_params": {"batch_size": 2},
                     "random_log_path": True,  # fix multiprocess file access error in CI
                     "deterministic": True,  # to pass test_score
                 },
@@ -838,18 +848,19 @@ class PytorchForecastingDeepAR(_PytorchForecastingAdapter):
                     "trainer_params": {
                         "callbacks": [early_stop_callback],
                         "max_epochs": 1,  # for quick test
-                        "limit_train_batches": 10,  # for quick test
-                        "enable_checkpointing": False,
+                        "limit_train_batches": 2,  # for quick test
                         "logger": False,
                     },
                     "model_params": {
-                        "cell_type": "GRU",
-                        "rnn_layers": 3,
+                        "cell_type": "LSTM",
+                        "rnn_layers": 1,
                         "log_interval": -1,
+                        "hidden_size": 2,
                     },
                     "dataset_params": {
                         "max_encoder_length": 3,
                     },
+                    "train_to_dataloader_params": {"batch_size": 2},
                     "random_log_path": True,  # fix multiprocess file access error in CI
                     "deterministic": True,  # to pass test_score
                 },
@@ -1053,15 +1064,16 @@ class PytorchForecastingNHiTS(_PytorchForecastingAdapter):
                 {
                     "trainer_params": {
                         "max_epochs": 1,  # for quick test
-                        "limit_train_batches": 10,  # for quick test
+                        "limit_train_batches": 2,  # for quick test
                         "enable_checkpointing": False,
                         "logger": False,
                     },
                     "dataset_params": {
                         "max_encoder_length": 3,
                     },
+                    "train_to_dataloader_params": {"batch_size": 2},
                     "model_params": {
-                        "hidden_size": 8,
+                        "hidden_size": 4,
                         "n_blocks": [1, 1],
                         "n_layers": 1,
                         "log_interval": -1,
@@ -1071,14 +1083,14 @@ class PytorchForecastingNHiTS(_PytorchForecastingAdapter):
                 {
                     "trainer_params": {
                         "max_epochs": 1,  # for quick test
-                        "limit_train_batches": 10,  # for quick test
+                        "limit_train_batches": 2,  # for quick test
                         "enable_checkpointing": False,
                         "logger": False,
                     },
                     "model_params": {
                         "interpolation_mode": "nearest",
                         "activation": "Tanh",
-                        "hidden_size": 8,
+                        "hidden_size": 4,
                         "n_blocks": [1, 1],
                         "n_layers": 1,
                         "log_interval": -1,
@@ -1086,6 +1098,7 @@ class PytorchForecastingNHiTS(_PytorchForecastingAdapter):
                     "dataset_params": {
                         "max_encoder_length": 3,
                     },
+                    "train_to_dataloader_params": {"batch_size": 2},
                     "random_log_path": True,  # fix multiprocess file access error in CI
                 },
             ]
@@ -1103,12 +1116,12 @@ class PytorchForecastingNHiTS(_PytorchForecastingAdapter):
                 {
                     "trainer_params": {
                         "max_epochs": 1,  # for quick test
-                        "limit_train_batches": 10,  # for quick test
+                        "limit_train_batches": 2,  # for quick test
                         "enable_checkpointing": False,
                         "logger": False,
                     },
                     "model_params": {
-                        "hidden_size": 8,
+                        "hidden_size": 4,
                         "n_blocks": [1, 1],
                         "n_layers": 1,
                         "log_interval": -1,
@@ -1116,20 +1129,21 @@ class PytorchForecastingNHiTS(_PytorchForecastingAdapter):
                     "dataset_params": {
                         "max_encoder_length": 3,
                     },
+                    "train_to_dataloader_params": {"batch_size": 2},
                     "random_log_path": True,  # fix multiprocess file access error in CI
                 },
                 {
                     "trainer_params": {
                         "callbacks": [early_stop_callback],
                         "max_epochs": 1,  # for quick test
-                        "limit_train_batches": 10,  # for quick test
+                        "limit_train_batches": 2,  # for quick test
                         "enable_checkpointing": False,
                         "logger": False,
                     },
                     "model_params": {
                         "interpolation_mode": "nearest",
                         "activation": "Tanh",
-                        "hidden_size": 8,
+                        "hidden_size": 4,
                         "n_blocks": [1, 1],
                         "n_layers": 1,
                         "log_interval": -1,
@@ -1137,6 +1151,7 @@ class PytorchForecastingNHiTS(_PytorchForecastingAdapter):
                     "dataset_params": {
                         "max_encoder_length": 3,
                     },
+                    "train_to_dataloader_params": {"batch_size": 2},
                     "random_log_path": True,  # fix multiprocess file access error in CI
                 },
             ]
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..dfbf2fa71 100644
--- a/sktime/registry/_scitype_coercion.py
+++ b/sktime/registry/_scitype_coercion.py
@@ -16,6 +16,26 @@ _coerce_register[("transformer_tabular", "transformer")] = (
 )
 
 
+def _coerce_series_annotator_to_transformer(obj):
+    from sktime.annotation.compose._as_transform import AnnotatorAsTransformer
+
+    return AnnotatorAsTransformer(obj)
+
+
+_coerce_register[("series-annotator", "transformer")] = (
+    _coerce_series_annotator_to_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 +57,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 +81,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 +102,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 +144,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..741616f0c 100644
--- a/sktime/split/expandingcutoff.py
+++ b/sktime/split/expandingcutoff.py
@@ -54,16 +54,20 @@ class ExpandingCutoffSplitter(BaseSplitter):
     functions as an iloc indexer. When an int index is paired with a positive int
     cutoff, the cutoff serves as a loc indexer. If the int cutoff is negative, it
     functions as an iloc indexer.
+
+    For example for ``cutoff = 10``, ``step_length = 1`` and ``fh = [1, 2, 3, 4, 5, 6]``
+    here is a representation of the folds:
+
     ```
                           c
-                          |
     |---------------------|----fh----|------|
     | * * * * * * * * * * x x x x x x - - - |
     | * * * * * * * * * * * x x x x x x - - |
     | * * * * * * * * * * * * x x x x x x - |
     | * * * * * * * * * * * * * x x x x x x |
+
     ```
-    
+
     ``c`` = cutoff date or index.
 
     ``*`` = training fold.
@@ -121,10 +125,9 @@ class ExpandingCutoffSplitter(BaseSplitter):
         """
         if fh is None:
             fh = self._fh
-        offset = fh.to_numpy().max() + 1
         for cutoff in self.get_cutoffs(y):
             train_window = np.arange(0, cutoff + 1, step=1)
-            test_window = np.arange(cutoff + 1, cutoff + offset, step=1)
+            test_window = cutoff + fh
             yield train_window, test_window
 
     def _validate_y(self, y):
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]
diff --git a/sktime/utils/validation/annotation.py b/sktime/utils/validation/annotation.py
index bafad89c0..dc271e1a4 100644
--- a/sktime/utils/validation/annotation.py
+++ b/sktime/utils/validation/annotation.py
@@ -50,7 +50,7 @@ def check_task(task):
         Annotation task.
     """
     valid_tasks = ["segmentation", "change_point_detection", "anomaly_detection"]
-    if task not in valid_tasks:
+    if task != "None" and task not in valid_tasks:
         raise ValueError(f"`task` must be in: {valid_tasks}, but found: {task}.")
 
 


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
diff --git a/sktime/split/tests/test_expandingcutoff.py b/sktime/split/tests/test_expandingcutoff.py
index 9d0261281..4e320dd9e 100644
--- a/sktime/split/tests/test_expandingcutoff.py
+++ b/sktime/split/tests/test_expandingcutoff.py
@@ -6,7 +6,7 @@ import pandas as pd
 import pytest
 
 from sktime.forecasting.base import ForecastingHorizon
-from sktime.split import ExpandingCutoffSplitter
+from sktime.split import ExpandingCutoffSplitter, ExpandingWindowSplitter
 from sktime.split.tests.test_split import _check_cv
 from sktime.tests.test_switch import run_test_for_class
 from sktime.utils._testing.hierarchical import _make_hierarchical
@@ -256,3 +256,31 @@ def test_expanding_cutoff_docstring_examples():
         == _make_splits_listlike(splits2)
         == _make_splits_listlike(splits3)
     )
+
+
+@pytest.mark.skipif(
+    not run_test_for_class(ExpandingCutoffSplitter),
+    reason="run test only if softdeps are present and incrementally (if requested)",
+)
+def test_expandingcutoff_fh():
+    """Test different forecast horizon configurations"""
+    # Datetime cutoff
+    y = _make_series(n_timepoints=10, index_type="period", random_state=42)
+    fh_configs = ([1], [1, 2, 3], [4], [2, 4])
+    for _fh in fh_configs:
+        fh = ForecastingHorizon(_fh)
+        index_num = 3
+        cutoff = y.index[index_num]
+
+        # Behavior of cutoff and window splitter should be identical in this scenario
+        cv1 = ExpandingCutoffSplitter(cutoff=cutoff, fh=fh, step_length=1)
+        cv2 = ExpandingWindowSplitter(initial_window=index_num, fh=fh, step_length=1)
+
+        splits1 = list(cv1.split(y))
+        splits2 = list(cv2.split(y))
+
+        for (split_train_a, split_test_a), (split_train_b, split_test_b) in zip(
+            splits1, splits2
+        ):
+            np.testing.assert_array_equal(split_train_a, split_train_b)
+            np.testing.assert_array_equal(split_test_a, split_test_b)