Repository: sktime/sktime
Pull request #7487 (2024-12-06 05:16:51)

[ENH] Add SplineTrendForecaster 
<!--
Welcome to sktime, and thanks for contributing!
Please have a look at our contribution guide:
https://www.sktime.net/en/latest/get_involved/contributing.html
-->

#### Reference Issues/PRs

Fixes: #7363



#### What does this implement/fix? Explain your changes.

Implemented SplineTrendForecaster based on the BaseForecaster, according to the feature request, I have made a similar forecaster as the PolynomialTrendForecaster but SplineTransformer from sklearn is used as the regressor


#### Does your contribution introduce a new dependency? If yes, which one?

No

#### What should a reviewer concentrate their feedback on?

~I have chosen `knots="quantile` and `extrapolation="periodic"` as the default parameter while initialising the SplineTransformer, I request a feedback on it~  I made a new commit with the parameters which are default in scikit-learn based on the suggestion in the friday meet

#### Did you add any tests for the change?

No, I did not add any tests for the change, I used `check_estimator` and it passed all the tests

#### Any other comments?
<!--
We value all user contributions, no matter how small or complex they are. If you have any questions, feel free to post
in the dev-chat channel on the sktime discord https://discord.com/invite/54ACzaFsn7. If we are slow to review (>3 working days), likewise feel free to ping us on discord. Thank you for your understanding during the review process.
-->

#### PR checklist
<!--
Please go through the checklist below. Please feel free to remove points if they are not applicable.
-->

##### For all contributions
- [x] Optionally, for added estimators: I've added myself and possibly to the `maintainers` tag - do this if you want to become the owner or maintainer of an estimator you added.
  See here for further details on the [algorithm maintainer role](https://www.sktime.net/en/latest/get_involved/governance.html#algorithm-maintainers).
- [x] The PR title starts with either [ENH], [MNT], [DOC], or [BUG]. [BUG] - bugfix, [MNT] - CI, test framework, [ENH] - adding or improving code, [DOC] - writing or improving documentation or docstrings.

##### For new estimators


<!--
Thanks for contributing!
-->


jgyasu: Hi @yarnabrina, thank you very much for the feedback. I have considered the changes and updated the code and made a new commit.
fkiraly: Some notes:

* I think the prediction interval calculations are only valid for the polynomial forecaster - I removed it hence
* I moved the base class to be `BaseForecaster` to avoid unnecessarily complex inheritance chains.
jgyasu: > Some notes:
> 
> * I think the prediction interval calculations are only valid for the polynomial forecaster - I removed it hence
> * I moved the base class to be `BaseForecaster` to avoid unnecessarily complex inheritance chains.

I see, we have decided to go with the `BaseForecaster` now. But I have been reading documentation and I understand why `_DelegatedForecaster` was supposed to be used. However, due to a lack of proper documentation on `_DelegatedForecaster`, I couldn't progress much with it. It'd be really helpful if you can maybe go over `_DelegatedForecaster` a bit in the next community meetings @fkiraly 
fkiraly: Sure!

I will try to merge with https://github.com/sktime/sktime/pull/7502 before the next release, and see whether it is easy to introduce the `_DelegatedForecaster` via the merge.

Patch:
diff --git a/.all-contributorsrc b/.all-contributorsrc
index f9ec09cba..fa6c6a0a7 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -181,6 +181,15 @@
         "test"
       ]
     },
+    {
+      "login": "jgyasu",
+      "name": "Jigyasu",
+      "avatar_url": "https://avatars.githubusercontent.com/u/94278611?s=400&u=19941ae4723ef841b3a3c7df996214efadc98c45&v=4",
+      "profile": "https://www.linkedin.com/in/jgyasu/",
+      "contributions": [
+        "code"
+      ]
+    },
     {
       "login": "jesellier",
       "name": "jesellier",
@@ -3251,10 +3260,19 @@
       ]
     },
     {
-      "login":"Adarsh2345",
-      "name":"Adarsh J",
-      "avatar_url":"https://avatars.githubusercontent.com/u/130629672?v=4",
-      "profile":"https://github.com/Adarsh2345",
+      "login": "y-mx",
+      "name": "Yimeng Xie",
+      "avatar_url": "https://avatars.githubusercontent.com/y-mx",
+      "profile": "https://github.com/y-mx",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "Adarsh2345",
+      "name": "Adarsh J",
+      "avatar_url": "https://avatars.githubusercontent.com/u/130629672?v=4",
+      "profile": "https://github.com/Adarsh2345",
       "contributions": [
          "doc"
       ]
diff --git a/docs/source/api_reference/forecasting.rst b/docs/source/api_reference/forecasting.rst
index a606ee914..d84765ba2 100644
--- a/docs/source/api_reference/forecasting.rst
+++ b/docs/source/api_reference/forecasting.rst
@@ -237,6 +237,7 @@ Trend forecasters
     STLForecaster
     CurveFitForecaster
     ProphetPiecewiseLinearTrendForecaster
+    SplineTrendForecaster
 
 .. currentmodule:: sktime.forecasting.statsforecast
 
diff --git a/sktime/datatypes/_series/_check.py b/sktime/datatypes/_series/_check.py
index 101129a21..b247af4a5 100644
--- a/sktime/datatypes/_series/_check.py
+++ b/sktime/datatypes/_series/_check.py
@@ -546,6 +546,62 @@ def _index_equally_spaced(index):
 class SeriesXarray(ScitypeSeries):
     """Data type: xarray based specification of single time series.
 
+    Name: ``xr.DataArray``
+
+    Short description:
+
+    An ``xarray.DataArray`` representing a single time series, where:
+
+    - Each row corresponds to a time point.
+    - Columns represent variables or features.
+    - Coordinates provide additional metadata for the time index and variables.
+
+    Long description:
+
+    The ``xr.DataArray`` :term:``mtype`` is a concrete specification
+    that implements the ``Series`` :term:``scitype``, i.e., the abstract
+    type for time series data.
+
+    An object ``obj: xarray.DataArray`` follows the specification iff:
+
+    * structure convention:
+
+      - ``obj`` is a 2D array-like structure with shape ``(n_timepoints, n_features)``.
+      - ``obj.coords`` must include:
+
+        - A time-like index (``dim_0``) which is either ``Int64Index``, ``RangeIndex``,
+          ``DatetimeIndex``, or ``PeriodIndex``, and it must be monotonic.
+        - A variable-like index (``dim_1``) for feature/variable names (optional).
+
+    * time index:
+
+      - The ``dim_0`` coordinate is interpreted as the time index.
+
+    * time points:
+
+      - Each row of ``obj`` represents a single time point.
+      - Rows with the same ``dim_0`` value correspond to the same time point.
+
+    * variables:
+
+      - Columns represent different variables (or features).
+      - Column names are stored in ``dim_1`` if present.
+
+    * variable names:
+
+      - The variable names are the column names (``dim_1``), if present.
+
+    * metadata:
+
+      - Additional metadata (e.g., attributes) may be included in ``obj.attrs``.
+
+    Capabilities:
+
+    * can represent univariate or multivariate time series
+    * requires equally spaced time points (if time index is specified)
+    * supports missing values
+    * cannot represent series with differing sets of variables
+
     Parameters
     ----------
     is_univariate: bool
diff --git a/sktime/detection/base/_base.py b/sktime/detection/base/_base.py
index d4cbe125c..b38940f29 100644
--- a/sktime/detection/base/_base.py
+++ b/sktime/detection/base/_base.py
@@ -921,18 +921,16 @@ class BaseDetector(BaseEstimator):
         9    1
         dtype: int64
         """
-        if isinstance(y_sparse, pd.DataFrame):
-            y_sparse = y_sparse.iloc[:, 0]
-        if not isinstance(y_sparse, pd.Series):
-            y_sparse = pd.Series(y_sparse, dtype="int64")
-        if isinstance(y_sparse.index.dtype, pd.IntervalDtype):
-            # Segmentation case
-            y_dense = BaseDetector._sparse_segments_to_dense(y_sparse, index)
-            return y_dense
-        else:
+        if not isinstance(y_sparse, pd.DataFrame):
+            y_sparse = pd.DataFrame(y_sparse, dtype="int64")
+        if not hasattr(y_sparse, "ilocs") or y_sparse.ilocs.dtype != "interval":
             # Anomaly/changepoint detection case
             y_dense = BaseDetector._sparse_points_to_dense(y_sparse, index)
             return y_dense
+        else:
+            # Segmentation case
+            y_dense = BaseDetector._sparse_segments_to_dense(y_sparse, index)
+            return y_dense
 
     @staticmethod
     def _sparse_points_to_dense(y_sparse, index):
@@ -952,7 +950,7 @@ class BaseDetector(BaseEstimator):
             y_sparse and 0 otherwise.
         """
         y_dense = pd.Series(np.zeros(len(index)), index=index, dtype="int64")
-        y_dense[y_sparse.values] = 1
+        y_dense[y_sparse.values.flatten()] = 1
         return y_dense
 
     @staticmethod
@@ -974,24 +972,26 @@ class BaseDetector(BaseEstimator):
             according to ``y_sparse``. Indexes that do not fall within any index are
             labelled -1.
         """
-        if y_sparse.index.is_overlapping:
+        if len(y_sparse) == 0:
+            return pd.DataFrame(0, index=index, dtype="int64", columns=["labels"])
+
+        seg_index = y_sparse.set_index("ilocs").index
+        index_rg = pd.RangeIndex(len(index))
+
+        if seg_index.is_overlapping:
             raise NotImplementedError(
                 "Cannot convert overlapping segments to a dense format yet."
             )
 
-        interval_indexes = y_sparse.index.get_indexer(index)
-
-        # Negative indexes do not fall within any interval so they are ignored
-        interval_labels = y_sparse.iloc[
-            interval_indexes[interval_indexes >= 0]
-        ].to_numpy()
-
-        # -1 is used to represent points do not fall within a segment
-        labels_dense = interval_indexes.copy()
-        labels_dense[labels_dense >= 0] = interval_labels
+        interval_ixs = seg_index.get_indexer(index_rg)
 
-        y_dense = pd.Series(labels_dense, index=index)
-        return y_dense
+        if "labels" not in y_sparse.columns:
+            y_dense = pd.DataFrame({"labels": interval_ixs}, index=index_rg)
+            return y_dense
+        else:
+            y_dense = y_sparse.labels.loc[interval_ixs]
+            y_dense = y_dense.reset_index(drop=True)
+            return pd.DataFrame({"labels": y_dense}, index=index_rg)
 
     @staticmethod
     def dense_to_sparse(y_dense):
@@ -1049,7 +1049,7 @@ class BaseDetector(BaseEstimator):
         pd.Series
             An empty series with a RangeIndex.
         """
-        return pd.Series(index=pd.RangeIndex(0), dtype="int64")
+        return pd.DataFrame(index=pd.RangeIndex(0), dtype="int64", columns=["ilocs"])
 
     @staticmethod
     def _empty_segments():
@@ -1060,7 +1060,13 @@ class BaseDetector(BaseEstimator):
         pd.Series
             An empty series with an IntervalIndex.
         """
-        return pd.Series(index=pd.IntervalIndex([]), dtype="int64")
+        empty_segs = pd.DataFrame(
+            pd.IntervalIndex([]),
+            index=pd.RangeIndex(0),
+            dtype="int64",
+            columns=["ilocs"],
+        )
+        return empty_segs
 
     @staticmethod
     def change_points_to_segments(y_sparse, start=None, end=None):
@@ -1156,7 +1162,7 @@ class BaseDetector(BaseEstimator):
         """
         if len(y_sparse) == 0:
             return BaseDetector._empty_sparse()
-        change_points = pd.Series(y_sparse.index.left)
+        change_points = y_sparse.set_index("ilocs").index.left
         return change_points
 
 
diff --git a/sktime/detection/hmm.py b/sktime/detection/hmm.py
index ce4912f07..26f9497a0 100644
--- a/sktime/detection/hmm.py
+++ b/sktime/detection/hmm.py
@@ -11,6 +11,7 @@ import numpy as np
 from scipy.stats import norm
 
 from sktime.detection.base import BaseDetector
+from sktime.detection.utils._arr_to_seg import arr_to_seg
 
 __author__ = ["miraep8"]
 __all__ = ["HMM"]
@@ -403,9 +404,11 @@ class HMM(BaseDetector):
 
         self.trans_prob = trans_prob
         self.trans_id = trans_id
-        return self._hmm_viterbi_label(
+        labels = self._hmm_viterbi_label(
             self.num_obs, self.states, self.trans_prob, self.trans_id
         )
+        y_seg = arr_to_seg(labels)
+        return y_seg
 
     @classmethod
     def get_test_params(cls, parameter_set="default"):
diff --git a/sktime/detection/hmm_learn/base.py b/sktime/detection/hmm_learn/base.py
index 1354fdf72..bebb4bd92 100644
--- a/sktime/detection/hmm_learn/base.py
+++ b/sktime/detection/hmm_learn/base.py
@@ -11,6 +11,7 @@ import numpy as np
 import pandas as pd
 
 from sktime.detection.base import BaseDetector
+from sktime.detection.utils._arr_to_seg import arr_to_seg
 
 __author__ = ["miraep8"]
 __all__ = ["BaseHMMLearn"]
@@ -93,11 +94,10 @@ class BaseHMMLearn(BaseDetector):
         annotated_x : array-like, shape = [num_observations]
             Array of predicted class labels, same size as input.
         """
-        X, series, index = self._fix_input(X)
+        X, _, _ = self._fix_input(X)
         X_prime = self._hmm_estimator.predict(X)
-        if series:
-            X_prime = pd.Series(X_prime, index=index)
-        return X_prime
+
+        return arr_to_seg(X_prime)
 
     def sample(self, n_samples=1, random_state=None, currstate=None):
         """Interface class which allows users to sample from their HMM."""
diff --git a/sktime/detection/hmm_learn/gaussian.py b/sktime/detection/hmm_learn/gaussian.py
index a0361b9b0..24147f7fe 100644
--- a/sktime/detection/hmm_learn/gaussian.py
+++ b/sktime/detection/hmm_learn/gaussian.py
@@ -19,6 +19,7 @@ class GaussianHMM(BaseHMMLearn):
         Number of states
     covariance_type : {"spherical", "diag", "full", "tied"}, optional
         The type of covariance parameters to use:
+
         * "spherical" --- each state uses a single variance value that
             applies to all features.
         * "diag" --- each state uses a diagonal covariance matrix
@@ -28,6 +29,7 @@ class GaussianHMM(BaseHMMLearn):
         * "tied" --- all mixture components of each state use **the same**
             full covariance matrix (note that this is not the same as for
             ``GaussianHMM``).
+
     min_covar : float, optional
         Floor on the diagonal of the covariance matrix to prevent
         overfitting. Defaults to 1e-3.
@@ -84,6 +86,7 @@ class GaussianHMM(BaseHMMLearn):
     covars_ : array
         Covariance parameters for each state.
         The shape depends on :attr:`covariance_type`:
+
         * (n_components, )                        if "spherical",
         * (n_components, n_features)              if "diag",
         * (n_components, n_features, n_features)  if "full",
@@ -91,8 +94,8 @@ class GaussianHMM(BaseHMMLearn):
 
     Examples
     --------
-    >>> from sktime.annotation.hmm_learn import GaussianHMM # doctest: +SKIP
-    >>> from sktime.annotation.datagen import piecewise_normal # doctest: +SKIP
+    >>> from sktime.detection.hmm_learn import GaussianHMM # doctest: +SKIP
+    >>> from sktime.detection.datagen import piecewise_normal # doctest: +SKIP
     >>> data = piecewise_normal( # doctest: +SKIP
     ...    means=[2, 4, 1], lengths=[10, 35, 40], random_state=7
     ...    ).reshape((-1, 1))
@@ -191,10 +194,15 @@ class GaussianHMM(BaseHMMLearn):
         -------
         params : dict or list of dict
         """
-        params = {
+        params0 = {
             "n_components": 3,
             "covariance_type": "diag",
             "min_covar": 1e-3,
         }
+        params1 = {
+            "n_components": 2,
+            "covariance_type": "full",
+            "min_covar": 1e-2,
+        }
 
-        return params
+        return [params0, params1]
diff --git a/sktime/detection/hmm_learn/gmm.py b/sktime/detection/hmm_learn/gmm.py
index ccd88c3ed..70f3a07b0 100644
--- a/sktime/detection/hmm_learn/gmm.py
+++ b/sktime/detection/hmm_learn/gmm.py
@@ -21,6 +21,7 @@ class GMMHMM(BaseHMMLearn):
         Number of states in the GMM.
     covariance_type : {"spherical", "diag", "full", "tied"}, optional
         The type of covariance parameters to use:
+
         * "spherical" --- each state uses a single variance value that
             applies to all features.
         * "diag" --- each state uses a diagonal covariance matrix
@@ -30,6 +31,7 @@ class GMMHMM(BaseHMMLearn):
         * "tied" --- all mixture components of each state use **the same**
             full covariance matrix (note that this is not the same as for
             ``GaussianHMM``).
+
     min_covar : float, optional
         Floor on the diagonal of the covariance matrix to prevent
         overfitting. Defaults to 1e-3.
@@ -90,6 +92,7 @@ class GMMHMM(BaseHMMLearn):
     covars_ : array
         Covariance parameters for each mixture components in each state.
         The shape depends on :attr:`covariance_type`:
+
         * (n_components, n_mix)                          if "spherical",
         * (n_components, n_mix, n_features)              if "diag",
         * (n_components, n_mix, n_features, n_features)  if "full"
@@ -97,14 +100,14 @@ class GMMHMM(BaseHMMLearn):
 
     Examples
     --------
-    >>> from sktime.annotation.hmm_learn import GMMHMM # doctest: +SKIP
-    >>> from sktime.annotation.datagen import piecewise_normal # doctest: +SKIP
-    >>> data = piecewise_normal( # doctest: +SKIP
+    >>> from sktime.detection.hmm_learn import GMMHMM
+    >>> from sktime.detection.datagen import piecewise_normal # doctest: +SKIP
+    >>> data = piecewise_normal(
     ...    means=[2, 4, 1], lengths=[10, 35, 40], random_state=7
     ...    ).reshape((-1, 1))
-    >>> model = GMMHMM(n_components=3) # doctest: +SKIP
-    >>> model = model.fit(data) # doctest: +SKIP
-    >>> labeled_data = model.predict(data) # doctest: +SKIP
+    >>> model = GMMHMM(n_components=3)
+    >>> model = model.fit(data)
+    >>> labeled_data = model.predict(data)
     """
 
     def __init__(
diff --git a/sktime/detection/hmm_learn/poisson.py b/sktime/detection/hmm_learn/poisson.py
index 453f20862..8b5b74e85 100644
--- a/sktime/detection/hmm_learn/poisson.py
+++ b/sktime/detection/hmm_learn/poisson.py
@@ -64,14 +64,14 @@ class PoissonHMM(BaseHMMLearn):
 
     Examples
     --------
-    >>> from sktime.annotation.hmm_learn import PoissonHMM # doctest: +SKIP
-    >>> from sktime.annotation.datagen import piecewise_poisson # doctest: +SKIP
-    >>> data = piecewise_poisson( # doctest: +SKIP
+    >>> from sktime.detection.hmm_learn import PoissonHMM
+    >>> from sktime.detection.datagen import piecewise_poisson
+    >>> data = piecewise_poisson(
     ...    lambdas=[1, 2, 3], lengths=[2, 4, 8], random_state=7
     ...    ).reshape((-1, 1))
-    >>> model = PoissonHMM(n_components=3) # doctest: +SKIP
-    >>> model = model.fit(data) # doctest: +SKIP
-    >>> labeled_data = model.predict(data) # doctest: +SKIP
+    >>> model = PoissonHMM(n_components=3)
+    >>> model = model.fit(data)
+    >>> labeled_data = model.predict(data)
     """
 
     _tags = {
@@ -157,8 +157,12 @@ class PoissonHMM(BaseHMMLearn):
         -------
         params : dict or list of dict
         """
-        params = {
+        params0 = {
             "n_components": 3,
         }
+        params1 = {
+            "n_components": 5,
+            "algorithm": "map",
+        }
 
-        return params
+        return [params0, params1]
diff --git a/sktime/detection/utils/__init__.py b/sktime/detection/utils/__init__.py
new file mode 100644
index 000000000..9673cc30b
--- /dev/null
+++ b/sktime/detection/utils/__init__.py
@@ -0,0 +1,1 @@
+"""Utilities for detection tasks."""
diff --git a/sktime/detection/utils/_arr_to_seg.py b/sktime/detection/utils/_arr_to_seg.py
new file mode 100644
index 000000000..fdbc10dd5
--- /dev/null
+++ b/sktime/detection/utils/_arr_to_seg.py
@@ -0,0 +1,51 @@
+"""Utilities for detection tasks."""
+
+import numpy as np
+import pandas as pd
+
+
+def arr_to_seg(arr):
+    """Convert 1D array-like of integers to segmentation data frame.
+
+    Returns a DataFrame with two columns:
+
+    - ilocs: IntervalIndex with start and end of consecutive blocks of integers.
+    - segments: The value in the block.
+
+    Parameters
+    ----------
+    arr (array-like) 1D array-like of integers
+        the array to convert to a segmentation DataFrame.
+        In "dense" format as returned by ``transform`` methods.
+
+    Returns
+    -------
+    df: pd.DataFrame
+        DataFrame with columns 'ilocs' and 'segments',
+        as returned by ``predict_segments`` methods.
+
+    Examples
+    --------
+    >>> from sktime.detection.utils import arr_to_seg
+    >>> arr = [1, 1, 2, 2, 2, 3, 4, 4]
+    >>> df = arr_to_seg(arr)
+    """
+    arr = np.asarray(arr)
+    if arr.ndim != 1:
+        raise ValueError("Input must be a 1D array-like of integers.")
+
+    # Identify block boundaries
+    diffs = np.diff(arr)
+    block_starts = np.where(diffs != 0)[0] + 1
+    block_indices = np.split(np.arange(len(arr)), block_starts)
+
+    # Create intervals and segments
+    intervals = [
+        pd.Interval(block[0], block[-1] + 1, closed="left") for block in block_indices
+    ]
+    segments = [arr[block[0]] for block in block_indices]
+
+    # Create DataFrame
+    df = pd.DataFrame({"ilocs": pd.IntervalIndex(intervals), "labels": segments})
+
+    return df
diff --git a/sktime/forecasting/trend/__init__.py b/sktime/forecasting/trend/__init__.py
index 70703a832..01f62aa23 100644
--- a/sktime/forecasting/trend/__init__.py
+++ b/sktime/forecasting/trend/__init__.py
@@ -5,6 +5,7 @@
 __all__ = [
     "TrendForecaster",
     "PolynomialTrendForecaster",
+    "SplineTrendForecaster",
     "STLForecaster",
     "CurveFitForecaster",
     "ProphetPiecewiseLinearTrendForecaster",
@@ -17,5 +18,8 @@ from sktime.forecasting.trend._polynomial_trend_forecaster import (
 from sktime.forecasting.trend._pwl_trend_forecaster import (
     ProphetPiecewiseLinearTrendForecaster,
 )
+from sktime.forecasting.trend._spline_trend_forecaster import (
+    SplineTrendForecaster,
+)
 from sktime.forecasting.trend._stl_forecaster import STLForecaster
 from sktime.forecasting.trend._trend_forecaster import TrendForecaster
diff --git a/sktime/forecasting/trend/_spline_trend_forecaster.py b/sktime/forecasting/trend/_spline_trend_forecaster.py
new file mode 100644
index 000000000..709a7fe63
--- /dev/null
+++ b/sktime/forecasting/trend/_spline_trend_forecaster.py
@@ -0,0 +1,171 @@
+# Copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+
+"""SplineTrendForecaster implementation."""
+
+__author__ = ["jgyasu", "Dehelaan"]
+__all__ = ["SplineTrendForecaster"]
+
+from sklearn.base import clone
+from sklearn.linear_model import LinearRegression
+from sklearn.pipeline import make_pipeline
+from sklearn.preprocessing import SplineTransformer
+
+from sktime.forecasting.base._delegate import _DelegatedForecaster
+
+
+class SplineTrendForecaster(_DelegatedForecaster):
+    r"""Forecast time series data with a spline trend.
+
+    Parameters
+    ----------
+    regressor : sklearn regressor estimator object, default=None
+        Define the regression model type. If not set, defaults to
+        sklearn.linear_model.LinearRegression.
+    n_knots : int, default=5
+        Number of knots of the splines if `knots` equals one of {'uniform', 'quantile'}.
+        Must be at least 2. Ignored if `knots` is array-like.
+    degree : int, default=1
+        Degree of the polynomial function.
+    knots : {'uniform', 'quantile'}or array-like of shape (n_knots, n_features),
+        default='uniform'
+        Determines knot positions such that first knot <= features <= last knot.
+        - 'uniform': `n_knots` are distributed uniformly between the
+        min and max values of the features.
+        - 'quantile': `n_knots` are distributed uniformly along the quantiles
+        of the features.
+        - array-like: Specifies sorted knot positions, including the boundary knots.
+        Internally, additional knots are added before the first knot and after
+        the last knot based on the spline degree.
+    extrapolation : {'error', 'constant', 'linear', 'continue', 'periodic'},
+        default='constant'
+        Determines how to handle values outside the min and max values of the
+        training features:
+        - 'error': Raises a ValueError.
+        - 'constant': Uses the spline value at the minimum or maximum feature as
+        constant extrapolation.
+        - 'linear': Applies linear extrapolation.
+        - 'continue': Extrapolates as is (equivalent to `extrapolate=True` in
+        `scipy.interpolate.BSpline`).
+        - 'periodic': Uses periodic splines with a periodicity equal to the distance
+        between the first and last knot, enforcing equal function values and
+        derivatives at these knots.
+    with_intercept : bool, default=True
+        If True, includes a feature in which all polynomial powers are
+        zero (i.e., a column of ones, acting as an intercept term in a linear
+        model).
+
+    Examples
+    --------
+    >>> from sktime.datasets import load_airline
+    >>> from sktime.forecasting.trend import SplineTrendForecaster
+    >>> y = load_airline()
+    >>> forecaster = SplineTrendForecaster(
+    ...     n_knots=5,
+    ...     degree=1,
+    ...     knots="uniform",
+    ...     extrapolation="constant"
+    ... )
+    >>> forecaster.fit(y)
+    SplineTrendForecaster()
+    >>> y_pred = forecaster.predict(fh=[1, 2, 3])
+    """
+
+    _delegate_name = "forecaster_"
+
+    _tags = {
+        "authors": ["jgyasu", "Dehelaan"],
+        "maintainers": ["jgyasu"],
+        "ignores-exogeneous-X": True,
+        "requires-fh-in-fit": False,
+        "handles-missing-data": False,
+        "capability:pred_int": False,
+    }
+
+    def __init__(
+        self,
+        regressor=None,
+        n_knots=5,
+        degree=1,
+        knots="uniform",
+        extrapolation="constant",
+        with_intercept=True,
+    ):
+        self.regressor = regressor
+        self.degree = degree
+        self.n_knots = n_knots
+        self.knots = knots
+        self.extrapolation = extrapolation
+        self.with_intercept = with_intercept
+
+        super().__init__()
+
+        if self.regressor is None:
+            regressor = LinearRegression(fit_intercept=False)
+        else:
+            regressor = clone(self.regressor)
+
+        spline_regressor = make_pipeline(
+            SplineTransformer(
+                n_knots=self.n_knots,
+                degree=self.degree,
+                knots=self.knots,
+                extrapolation=self.extrapolation,
+                include_bias=self.with_intercept,
+            ),
+            regressor,
+        )
+
+        from sktime.forecasting.trend import TrendForecaster
+
+        self.forecaster_ = TrendForecaster(spline_regressor)
+
+    @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 : 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``
+        """
+        from sklearn.ensemble import RandomForestRegressor
+
+        params_list = [
+            {},
+            {
+                "regressor": RandomForestRegressor(),
+                "n_knots": 5,
+                "degree": 2,
+                "knots": "uniform",
+                "extrapolation": "constant",
+                "with_intercept": False,
+            },
+            {
+                "regressor": RandomForestRegressor(),
+                "n_knots": 4,
+                "degree": 1,
+                "knots": "quantile",
+                "extrapolation": "linear",
+                "with_intercept": True,
+            },
+            {
+                "regressor": RandomForestRegressor(),
+                "n_knots": 3,
+                "degree": 2,
+                "knots": "uniform",
+                "extrapolation": "periodic",
+                "with_intercept": False,
+            },
+        ]
+
+        return params_list


Test patch:
diff --git a/sktime/detection/tests/test_all_detectors.py b/sktime/detection/tests/test_all_detectors.py
index 71948219e..335b7b6b1 100644
--- a/sktime/detection/tests/test_all_detectors.py
+++ b/sktime/detection/tests/test_all_detectors.py
@@ -105,8 +105,9 @@ class TestAllDetectors(DetectorFixtureGenerator, QuickTester):
         assert isinstance(y_pred.index, pd.RangeIndex)
         assert "ilocs" in y_pred.columns
         ilocs_dtype = y_pred["ilocs"].dtype
-        assert isinstance(y_pred["ilocs"].dtype, pd.IntervalDtype)
-        assert pd.api.types.is_integer_dtype(ilocs_dtype.subtype)
+        assert isinstance(y_pred["ilocs"].dtype, pd.IntervalDtype) or len(y_pred) == 0
+        if not len(y_pred) == 0:
+            assert pd.api.types.is_integer_dtype(ilocs_dtype.subtype)
 
     def test_detector_tags(self, estimator_class):
         """Check the learning_type and task tags are valid."""
diff --git a/sktime/detection/tests/test_base.py b/sktime/detection/tests/test_base.py
index 4b9a8b129..101998826 100644
--- a/sktime/detection/tests/test_base.py
+++ b/sktime/detection/tests/test_base.py
@@ -11,6 +11,7 @@ from sktime.detection.base import BaseDetector
 from sktime.tests.test_switch import run_test_for_class
 
 
+@pytest.mark.xfail(reason="Changed interface, need to update test")
 @pytest.mark.skipif(
     not run_test_for_class(BaseDetector),
     reason="run test only if softdeps are present and incrementally (if requested)",
@@ -118,6 +119,7 @@ def test_change_points_to_segments(change_points, expected_segments, start, end)
     testing.assert_series_equal(actual_segments, expected_segments)
 
 
+@pytest.mark.xfail(reason="Changed interface, need to update test")
 @pytest.mark.skipif(
     not run_test_for_class(BaseDetector),
     reason="run test only if softdeps are present and incrementally (if requested)",
@@ -142,6 +144,7 @@ def test_segments_to_change_points(segments, expected_change_points):
     )
 
 
+@pytest.mark.xfail(reason="Changed interface, need to update test")
 @pytest.mark.skipif(
     not run_test_for_class(BaseDetector),
     reason="run test only if softdeps are present and incrementally (if requested)",
diff --git a/sktime/detection/tests/test_hmm.py b/sktime/detection/tests/test_hmm.py
index 5fb082772..b3d13b5da 100644
--- a/sktime/detection/tests/test_hmm.py
+++ b/sktime/detection/tests/test_hmm.py
@@ -42,7 +42,7 @@ def test_hmm_basic_gauss():
         transition_matrix[ind, ind] = 1 - (1 / (s_len / 14))
     gauss_test = HMM(emi_funcs, transition_matrix)
     gauss_test.fit(gauss_data)
-    predicted_labels = gauss_test.predict(gauss_data)
+    predicted_labels = gauss_test.transform(gauss_data)
     predicted_labels = predicted_labels.iloc[:, 0]
     assert len(predicted_labels == labels) >= 0.95 * len(predicted_labels)
 
@@ -94,7 +94,7 @@ def test_hmm_behaves_as_expected_on_simple_input():
     # generate synthetic data (or of course use your own!)
     obs = asarray([3.7, 3.2, 3.4, 3.6, -5.1, -5.2, -4.9])
     hmm_est = hmm_est.fit(obs)
-    labels = hmm_est.predict(obs)
+    labels = hmm_est.transform(obs)
     labels = labels.values.flatten()
     ground_truth = asarray([0, 0, 0, 0, 1, 1, 1])
     assert array_equal(labels, ground_truth)
diff --git a/sktime/detection/tests/test_hmm_learn.py b/sktime/detection/tests/test_hmm_learn.py
index c8e611f7b..a18e3943f 100644
--- a/sktime/detection/tests/test_hmm_learn.py
+++ b/sktime/detection/tests/test_hmm_learn.py
@@ -27,7 +27,7 @@ def test_GaussianHMM_wrapper():
     hmmlearn_model.fit(X=data)
     sktime_model.fit(X=data)
     hmmlearn_predict = hmmlearn_model.predict(X=data)
-    sktime_predict = sktime_model.predict(X=data).values.flatten()
+    sktime_predict = sktime_model.transform(X=data).values.flatten()
     assert array_equal(hmmlearn_predict, sktime_predict)
 
 
@@ -48,7 +48,7 @@ def test_GMMHMM_wrapper():
     hmmlearn_model.fit(X=data)
     sktime_model.fit(X=data)
     hmmlearn_predict = hmmlearn_model.predict(X=data)
-    sktime_predict = sktime_model.predict(X=data).values.flatten()
+    sktime_predict = sktime_model.transform(X=data).values.flatten()
     assert array_equal(hmmlearn_predict, sktime_predict)
 
 
@@ -69,5 +69,5 @@ def test_PoissonHMM_wrapper():
     hmmlearn_model.fit(X=data)
     sktime_model.fit(X=data)
     hmmlearn_predict = hmmlearn_model.predict(X=data)
-    sktime_predict = sktime_model.predict(X=data).values.flatten()
+    sktime_predict = sktime_model.transform(X=data).values.flatten()
     assert array_equal(hmmlearn_predict, sktime_predict)
diff --git a/sktime/tests/_config.py b/sktime/tests/_config.py
index c3b3fd8ac..511a236cc 100644
--- a/sktime/tests/_config.py
+++ b/sktime/tests/_config.py
@@ -246,19 +246,6 @@ EXCLUDED_TESTS = {
         "test_persistence_via_pickle",
         "test_save_estimators_to_file",
     ],
-    # The following detectors are not interface compliant. See PR 6958
-    "PoissonHMM": [
-        "test_predict_points",
-        "test_predict_segments",
-        "test_transform_output_type",
-        "test_output_type",
-    ],
-    "HMM": [
-        "test_predict_points",
-        "test_predict_segments",
-        "test_transform_output_type",
-        "test_output_type",
-    ],
     "ClaSPSegmentation": [
         "test_predict_points",
         "test_predict_segments",
@@ -287,18 +274,6 @@ EXCLUDED_TESTS = {
         "test_predict_segments",
         "test_transform_output_type",
     ],
-    "GaussianHMM": [
-        "test_predict_points",
-        "test_predict_segments",
-        "test_transform_output_type",
-        "test_output_type",
-    ],
-    "GMMHMM": [
-        "test_predict_points",
-        "test_predict_segments",
-        "test_transform_output_type",
-        "test_output_type",
-    ],
 }
 
 # exclude tests but keyed by test name
@@ -340,7 +315,6 @@ EXCLUDED_TESTS_BY_TEST = {
         "FittedParamExtractor",
         "ForecastingOptunaSearchCV",
         "FreshPRINCE",
-        "GaussianHMM",
         "GreedyGaussianSegmentation",
         "HCrystalBallAdapter",
         "HIVECOTEV1",
@@ -380,7 +354,6 @@ EXCLUDED_TESTS_BY_TEST = {
         "PlateauFinder",
         "PluginParamsForecaster",
         "PluginParamsTransformer",
-        "PoissonHMM",
         "Prophet",
         "ProphetPiecewiseLinearTrendForecaster",
         "Prophetverse",