Repository: sktime/sktime
Pull request #7062 (2024-08-30 20:51:56)

[MNT] [Dependabot](deps): Update dask requirement from <2024.8.2 to <2024.8.3
Updates the requirements on [dask](https://github.com/dask/dask) to permit the latest version.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/dask/dask/releases">dask's releases</a>.</em></p>
<blockquote>
<h2>2024.8.2</h2>
<h2>Changes</h2>
<ul>
<li>Release 2024.8.2 <a href="https://github.com/jrbourbeau"><code>@​jrbourbeau</code></a> (<a href="https://redirect.github.com/dask/dask/issues/11359">#11359</a>)</li>
<li>Add changelor entries for shuffle, vindex and blockwise_reshape <a href="https://github.com/phofl"><code>@​phofl</code></a> (<a href="https://redirect.github.com/dask/dask/issues/11350">#11350</a>)</li>
<li>Ensure persisted collections are released without GC <a href="https://github.com/fjetter"><code>@​fjetter</code></a> (<a href="https://redirect.github.com/dask/dask/issues/11348">#11348</a>)</li>
<li>Update zoom link for dask meeting <a href="https://github.com/scharlottej13"><code>@​scharlottej13</code></a> (<a href="https://redirect.github.com/dask/dask/issues/11357">#11357</a>)</li>
<li>Add more docstring examples for <code>normalize\_chunks</code> <a href="https://github.com/Illviljan"><code>@​Illviljan</code></a> (<a href="https://redirect.github.com/dask/dask/issues/11271">#11271</a>)</li>
<li>Choose automatically between tasks-based and p2p rechunking <a href="https://github.com/hendrikmakait"><code>@​hendrikmakait</code></a> (<a href="https://redirect.github.com/dask/dask/issues/11337">#11337</a>)</li>
<li>Implement blockwise reshape <a href="https://github.com/phofl"><code>@​phofl</code></a> (<a href="https://redirect.github.com/dask/dask/issues/11328">#11328</a>)</li>
<li>Make rechunking in shuffle more intelligent to distribute unevenly if necessary <a href="https://github.com/phofl"><code>@​phofl</code></a> (<a href="https://redirect.github.com/dask/dask/issues/11326">#11326</a>)</li>
<li>Increase visibility of GPU CI updates <a href="https://github.com/charlesbluca"><code>@​charlesbluca</code></a> (<a href="https://redirect.github.com/dask/dask/issues/11345">#11345</a>)</li>
<li>Update <code>numpy</code> and <code>pyarrow</code> versions in install docs <a href="https://github.com/jrbourbeau"><code>@​jrbourbeau</code></a> (<a href="https://redirect.github.com/dask/dask/issues/11340">#11340</a>)</li>
<li>Fixup dask and distributed dependencies <a href="https://github.com/phofl"><code>@​phofl</code></a> (<a href="https://redirect.github.com/dask/dask/issues/11338">#11338</a>)</li>
<li>Bump <code>numpy&gt;=1.24</code> and <code>pyarrow&gt;=14.0.1</code> minimum versions <a href="https://github.com/jrbourbeau"><code>@​jrbourbeau</code></a> (<a href="https://redirect.github.com/dask/dask/issues/11331">#11331</a>)</li>
<li>Add <code>crick</code> back to Python 3.11+ CI builds <a href="https://github.com/jrbourbeau"><code>@​jrbourbeau</code></a> (<a href="https://redirect.github.com/dask/dask/issues/11335">#11335</a>)</li>
<li>Preserve chunksizes in vindex <a href="https://github.com/phofl"><code>@​phofl</code></a> (<a href="https://redirect.github.com/dask/dask/issues/11330">#11330</a>)</li>
<li>Fix <code>dask.array.fft</code> mismatch with Numpy's interface (add support for <code>norm</code> argument) <a href="https://github.com/joanrue"><code>@​joanrue</code></a> (<a href="https://redirect.github.com/dask/dask/issues/10665">#10665</a>)</li>
<li>Pass additional parameters to <code>rechunk_p2p</code> <a href="https://github.com/hendrikmakait"><code>@​hendrikmakait</code></a> (<a href="https://redirect.github.com/dask/dask/issues/11319">#11319</a>)</li>
<li>Fix docstring formatting for map_overlap <a href="https://github.com/Tao-VanJS"><code>@​Tao-VanJS</code></a> (<a href="https://redirect.github.com/dask/dask/issues/11332">#11332</a>)</li>
<li>Fix NumPy overflowing for prod on 2.0 <a href="https://github.com/phofl"><code>@​phofl</code></a> (<a href="https://redirect.github.com/dask/dask/issues/11327">#11327</a>)</li>
<li>tensordot: ensure <code>axes</code> are positive / add tests for negative axes <a href="https://github.com/joanrue"><code>@​joanrue</code></a> (<a href="https://redirect.github.com/dask/dask/issues/10812">#10812</a>)</li>
<li>Fix map_overlap with new_axis <a href="https://github.com/dstansby"><code>@​dstansby</code></a> (<a href="https://redirect.github.com/dask/dask/issues/11128">#11128</a>)</li>
</ul>
<p>See the <a href="https://docs.dask.org/en/stable/changelog.html">Changelog</a> for more information.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/dask/dask/commit/80b0737c9ad5848fa779efb8f841b9fa35c8ff36"><code>80b0737</code></a> Release 2024.8.2 (<a href="https://redirect.github.com/dask/dask/issues/11359">#11359</a>)</li>
<li><a href="https://github.com/dask/dask/commit/b7d026612fc1275ff7e822a1b466f73eaa5ab553"><code>b7d0266</code></a> Add changelor entries for shuffle, vindex and blockwise_reshape (<a href="https://redirect.github.com/dask/dask/issues/11350">#11350</a>)</li>
<li><a href="https://github.com/dask/dask/commit/3003db5b84070b1afc197c7c70c76c5c4c2bc821"><code>3003db5</code></a> Ensure persisted collections are released without GC (<a href="https://redirect.github.com/dask/dask/issues/11348">#11348</a>)</li>
<li><a href="https://github.com/dask/dask/commit/a918b4199762db60503577241778e6247f32303e"><code>a918b41</code></a> Update zoom link for dask meeting (<a href="https://redirect.github.com/dask/dask/issues/11357">#11357</a>)</li>
<li><a href="https://github.com/dask/dask/commit/9bbfc7a9cf80e5025fa27d30c5b10e573bb853af"><code>9bbfc7a</code></a> Add more docstring examples for <code>normalize_chunks</code> (<a href="https://redirect.github.com/dask/dask/issues/11271">#11271</a>)</li>
<li><a href="https://github.com/dask/dask/commit/6093009ccbd32311423219662525ea84b182d4be"><code>6093009</code></a> Choose automatically between tasks-based and p2p rechunking (<a href="https://redirect.github.com/dask/dask/issues/11337">#11337</a>)</li>
<li><a href="https://github.com/dask/dask/commit/72a14822139eedd140d928aa3b465706a04403f1"><code>72a1482</code></a> Implement blockwise reshaping API for arrays (<a href="https://redirect.github.com/dask/dask/issues/11328">#11328</a>)</li>
<li><a href="https://github.com/dask/dask/commit/f8171f12f93b4bb47ef04945e20a6d6903e3b664"><code>f8171f1</code></a> Make rechunking in shuffle more intelligent to distribute unevenly if necessa...</li>
<li><a href="https://github.com/dask/dask/commit/2fbe18b29378f281f87341253c743f98f8e9ac72"><code>2fbe18b</code></a> Increase visibility of GPU CI updates (<a href="https://redirect.github.com/dask/dask/issues/11345">#11345</a>)</li>
<li><a href="https://github.com/dask/dask/commit/984f24abdea8fdb1a6644270baa9df0d75dc3f1f"><code>984f24a</code></a> Update <code>numpy</code> and <code>pyarrow</code> versions in install docs (<a href="https://redirect.github.com/dask/dask/issues/11340">#11340</a>)</li>
<li>Additional commits viewable in <a href="https://github.com/dask/dask/compare/0.2.0...2024.8.2">compare view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>


Patch:
diff --git a/.all-contributorsrc b/.all-contributorsrc
index f5b5f043c..186246ef9 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -2645,6 +2645,16 @@
         "maintenance"
       ]
     },
+    {
+      "login": "SaiRevanth25",
+      "name": "Sai Revanth Gowravajhala",
+      "avatar_url": "https://avatars.githubusercontent.com/SaiRevanth25",
+      "profile": "https://github.com/SaiRevanth25",
+      "contributions": [
+        "code",
+        "bug"
+      ]
+    },
     {
       "login": "XinyuWuu",
       "name": "Xinyu Wu",
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..64be04056 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 = [
@@ -81,7 +81,7 @@ all_extras = [
   'autots<0.7,>=0.6.1',
   "cloudpickle",
   "dash!=2.9.0",
-  "dask<2024.8.2; extra == 'dataframe'",
+  "dask<2024.8.3; extra == 'dataframe'",
   "dtaidistance<2.4",
   "dtw-python",
   'esig==0.9.7; python_version < "3.10"',
@@ -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"',
@@ -125,7 +125,7 @@ all_extras_pandas2 = [
   'autots<0.7,>=0.6.1',
   "cloudpickle",
   "dash!=2.9.0",
-  "dask<2024.8.2; extra == 'dataframe'",
+  "dask<2024.8.3; extra == 'dataframe'",
   "dtaidistance<2.4",
   "dtw-python",
   'esig==0.9.7; python_version < "3.10"',
@@ -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)