Repository: sktime/sktime
Pull request #7075 (2024-09-04 16:17:08)

[DOC] split list of transformation pipeline components into subcategories
This PR splits the long list of transformation pipeline components, in the API reference, into sensible subcategories, for better user experience.


Patch:
diff --git a/docs/source/api_reference/transformations.rst b/docs/source/api_reference/transformations.rst
index ae2b93435..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
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~
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/registry/_scitype_coercion.py b/sktime/registry/_scitype_coercion.py
index 0b2362322..dfbf2fa71 100644
--- a/sktime/registry/_scitype_coercion.py
+++ b/sktime/registry/_scitype_coercion.py
@@ -16,6 +16,17 @@ _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
 
diff --git a/sktime/split/expandingcutoff.py b/sktime/split/expandingcutoff.py
index d9a01c225..741616f0c 100644
--- a/sktime/split/expandingcutoff.py
+++ b/sktime/split/expandingcutoff.py
@@ -54,14 +54,18 @@ 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.
@@ -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/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/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)