Repository: alan-turing-institute/sktime
Pull request #3415 (2022-09-12 14:14:23)

[GOV] Update core dev list
#### What does this implement/fix? Explain your changes.
Update core dev list according to [governance ](https://www.sktime.org/en/stable/get_involved/governance.html#end-of-tenure)

> Core developers that have not contributed to the project in the past one-year-period will automatically become inactive and give up their rights and responsibilities. 

Thanks for all contributions made by you :) You can come back and become active again at any point in time.

fyi: @SveaMeyer13 , @jasonlines @james-large @abostrom



Patch:
diff --git a/docs/source/about/team.rst b/docs/source/about/team.rst
index a6eda376f..f64d1298f 100644
--- a/docs/source/about/team.rst
+++ b/docs/source/about/team.rst
@@ -42,8 +42,6 @@ Core Developers
 
    * - Name
      - GitHub ID
-   * - Aaron Bostrom
-     - :user:`abostrom`
    * - Anthony Bagnall
      - :user:`TonyBagnall`
    * - Chris Holder
@@ -54,12 +52,8 @@ Core Developers
      - :user:`fkiraly`
    * - Freddy A Boulton
      - :user:`freddyaboulton`
-   * - George Oastler
-     - :user:`goastler`
    * - Guzal Bulatova
      - :user:`GuzalBulatova`
-   * - James Large
-     - :user:`james-large`
    * - Leonidas Tsaprounis
      - :user:`ltsaprounis`
    * - Lovkush Agarwal
@@ -87,10 +81,16 @@ Former Core Developers (inactive)
 
    * - Name
      - GitHub ID
+   * - Aaron Bostrom
+     - :user:`abostrom`
    * - Ayushmaan Seth
      - :user:`ayushmaanseth`
+   * - George Oastler
+     - :user:`goastler`
    * - Hongyi Yang
      - :user:`hyang1996`
+   * - James Large
+     - :user:`james-large`
    * - Jason Lines
      - :user:`jasonlines`
    * - Mathew Smith
diff --git a/sktime/annotation/stray.py b/sktime/annotation/stray.py
new file mode 100644
index 000000000..5d5d83d14
--- /dev/null
+++ b/sktime/annotation/stray.py
@@ -0,0 +1,266 @@
+# -*- coding: utf-8 -*-
+"""Tests for STRAY (Search TRace AnomalY) outlier estimator."""
+
+import warnings
+from typing import Dict
+
+import numpy as np
+import numpy.typing as npt
+from sklearn.neighbors import NearestNeighbors
+
+from sktime.transformations.base import BaseTransformer
+
+__author__ = ["KatieBuc"]
+__all__ = ["STRAY"]
+
+
+class STRAY(BaseTransformer):
+    """STRAY: robust anomaly detection in data streams with concept drift.
+
+    This is based on STRAY (Search TRace AnomalY) _[1], which is a modification
+    of HDoutliers _[2]. HDoutliers is a powerful algorithm for the detection of
+    anomalous observations in a dataset, which has (among other advantages) the
+    ability to detect clusters of outliers in multi-dimensional data without
+    requiring a model of the typical behavior of the system. However, it suffers
+    from some limitations that affect its accuracy. STRAY is an extension of
+    HDoutliers that uses extreme value theory for the anomolous threshold
+    calculation, to deal with data streams that exhibit non-stationary behavior.
+
+    Parameters
+    ----------
+    alpha : float, optional (default=0.01)
+        Threshold for determining the cutoff for outliers. Observations are
+        considered outliers if they fall in the (1 - alpha) tail of
+        the distribution of the nearest-neighbor distances between exemplars.
+    k : int, optional (default=10)
+        Number of neighbours considered.
+    knn_algorithm : str {"auto", "ball_tree", "kd_tree", "brute"}, optional
+        (default="brute")
+        Algorithm used to compute the nearest neighbors, from
+        sklearn.neighbors.NearestNeighbors
+    p : float, optional (default=0.5)
+        Proportion of possible candidates for outliers. This defines the starting point
+        for the bottom up searching algorithm.
+    size_threshold : int, optional (default=50)
+        Sample size to calculate an emperical threshold.
+    outlier_tail : str {"min", "max"}, optional (default="max")
+        Direction of the outlier tail.
+
+    Attributes
+    ----------
+    score_ : pd.Series
+        Outlier score of each data point in X.
+    y_ : pd.Series
+        Outlier boolean flag for each data point in X.
+
+    References
+    ----------
+    .. [1] Talagala, Priyanga Dilini, Rob J. Hyndman, and Kate Smith-Miles.
+    "Anomaly detection in high-dimensional data." Journal of Computational
+    and Graphical Statistics 30.2 (2021): 360-374.
+    .. [2] Wilkinson, Leland. "Visualizing big data outliers through
+    distributed aggregation." IEEE transactions on visualization and
+    computer graphics 24.1 (2017): 256-266.
+
+    Examples
+    --------
+    >>> from sktime.annotation.stray import STRAY
+    >>> from sktime.datasets import load_airline
+    >>> from sklearn.preprocessing import MinMaxScaler
+    >>> import numpy as np
+    >>> X = load_airline().to_frame().to_numpy()
+    >>> scaler = MinMaxScaler()
+    >>> X = scaler.fit_transform(X)
+    >>> model = STRAY(k=3)
+    >>> y = model.fit_transform(X)
+    >>> y[:5]
+    array([False, False, False, False, False])
+    """
+
+    _tags = {
+        "handles-missing-data": True,
+        "X_inner_mtype": "np.ndarray",
+        "fit_is_empty": False,
+        "skip-inverse-transform": True,
+    }
+
+    def __init__(
+        self,
+        alpha: float = 0.01,
+        k: int = 10,
+        knn_algorithm: str = "brute",
+        p: float = 0.5,
+        size_threshold: int = 50,
+        outlier_tail: str = "max",
+    ):
+        self.alpha = alpha
+        self.k = k
+        self.knn_algorithm = knn_algorithm
+        self.p = p
+        self.size_threshold = size_threshold
+        self.outlier_tail = outlier_tail
+        super(STRAY, self).__init__()
+
+    def _find_threshold(self, outlier_score: npt.ArrayLike, n: int) -> npt.ArrayLike:
+        """Find Outlier Threshold.
+
+        Parameters
+        ----------
+        outlier_score : np.ArrayLike
+            The outlier scores determined by k nearast neighbours distance
+        n : int
+            The number of rows remaining in X when NA's are removed.
+
+        Returns
+        -------
+        array of indices of the observations determined to be outliers.
+        """
+        if self.outlier_tail == "min":
+            outlier_score = -outlier_score
+
+        order = np.argsort(outlier_score)
+        gaps = np.append(0, np.diff(outlier_score[order]))
+        n4 = int(max(min(self.size_threshold, np.floor(n / 4)), 2))
+
+        J = np.array(range(2, n4 + 1))
+        start = int(max(np.floor(n * (1 - self.p)), 1))
+
+        ghat = [
+            0.0 if i < start else sum((J / (n4 - 1)) * gaps[i - J + 1])
+            for i in range(n)
+        ]
+
+        log_alpha = np.log(1 / self.alpha)
+        bound = np.Inf
+
+        for i in range(start, n):
+            if gaps[i] > log_alpha * ghat[i]:
+                bound = outlier_score[order][i - 1]
+                break
+
+        return np.where(outlier_score > bound)[0]
+
+    def _find_outliers_kNN(self, X: npt.ArrayLike, n: int) -> Dict:
+        """Find outliers using kNN distance with maximum gap.
+
+        Parameters
+        ----------
+        X : np.ArrayLike
+            Data for anomaly detection (time series).
+        n : int
+            The number of rows remaining in X when NA's are removed.
+
+        Returns
+        -------
+        dict of index of outliers and the outlier scores
+        """
+        if len(X.shape) == 1:
+            nbrs = NearestNeighbors(n_neighbors=self.k + 1).fit(X.reshape(-1, 1))
+            distances, _ = nbrs.kneighbors(X.reshape(-1, 1))
+        else:
+            nbrs = NearestNeighbors(
+                n_neighbors=n if self.k >= n else self.k + 1,
+                algorithm=self.knn_algorithm,
+            ).fit(X)
+            distances, _ = nbrs.kneighbors(X)
+
+        if self.k == 1:
+            d = distances[:, 1]
+        else:
+            diff = np.apply_along_axis(np.diff, 1, distances)
+            d = distances[range(n), np.apply_along_axis(np.argmax, 1, diff) + 1]
+
+        out_index = self._find_threshold(d, n)
+        return {"idx_outliers": out_index, "out_scores": d}
+
+    def _find_outliers(self, X):
+        """Detect Anomalies in High Dimensional Data.
+
+        Parameters
+        ----------
+        X : np.ArrayLike
+            Data for anomaly detection (time series).
+
+        Returns
+        -------
+        dict of anomalies and their corresponding scores
+        """
+        r = np.shape(X)[0]
+        idx_dropna = np.array([i for i in range(r) if not np.isnan(X[i]).any()])
+        X_dropna = X[
+            idx_dropna,
+        ]
+
+        n = np.shape(X_dropna)[0]
+        outliers = self._find_outliers_kNN(X_dropna, n)
+
+        # adjusted back to length r, for missing data
+        slice_ = [True if i in outliers["idx_outliers"] else False for i in range(n)]
+        idx_outliers = idx_dropna[slice_]  # index values from 1:r
+        outlier_bool = np.array([1 if i in idx_outliers else 0 for i in range(r)])
+
+        list_scores = outliers["out_scores"].tolist()
+        outlier_scores = np.array(
+            [list_scores.pop(0) if i in idx_dropna else np.nan for i in range(r)]
+        )
+
+        return {
+            "outlier_scores": outlier_scores,
+            "outlier_bool": outlier_bool,
+        }
+
+    def _fit(self, X: npt.ArrayLike, y: npt.ArrayLike = None):
+        """Find outliers using STRAY (Search TRace AnomalY).
+
+        Parameters
+        ----------
+        X : np.ArrayLike
+            Data for anomaly detection (time series).
+        y : pd.Series, optional
+            Not used for this unsupervsed method.
+
+        Returns
+        -------
+        self :
+            Reference to self.
+        """
+        # remember X for transform
+        self._X = X
+
+        info_dict = self._find_outliers(X)
+        self.score_ = info_dict["outlier_scores"]
+        self.y_ = info_dict["outlier_bool"]
+
+        return self
+
+    def _transform(self, X: npt.ArrayLike, y: npt.ArrayLike = None) -> npt.ArrayLike:
+        """Return anomaly detection.
+
+        Parameters
+        ----------
+        X : np.ArrayLike
+            Data for anomaly detection (time series).
+
+        Returns
+        -------
+        y_ : np.ArrayLike
+            Anomaly detection, boolean.
+        """
+        # fit again if data is different to fit, but don't store anything
+        if not np.allclose(X, self._X, equal_nan=True):
+            new_obj = STRAY(
+                alpha=self.alpha,
+                k=self.k,
+                knn_algorithm=self.knn_algorithm,
+                p=self.p,
+                size_threshold=self.size_threshold,
+                outlier_tail=self.outlier_tail,
+            ).fit(X)
+            warnings.warn(
+                "Warning: Input data X differs from that given to fit(). "
+                "Refitting with new input data, not storing updated public class "
+                "attributes. For this, explicitly use fit(X) or fit_transform(X)."
+            )
+            return new_obj.y_.astype(bool)
+
+        return self.y_.astype(bool)
diff --git a/sktime/base/_base.py b/sktime/base/_base.py
index 09fb7eead..14f2ca9cd 100644
--- a/sktime/base/_base.py
+++ b/sktime/base/_base.py
@@ -816,7 +816,7 @@ class BaseEstimator(BaseObject):
         """
         if not self.is_fitted:
             raise NotFittedError(
-                f"parameter estimator of type {type(self).__name__} has not been "
+                f"estimator of type {type(self).__name__} has not been "
                 "fitted yet, please call fit on data before get_fitted_params"
             )
 
@@ -831,9 +831,11 @@ class BaseEstimator(BaseObject):
                 return x
 
         for c in c_dict.keys():
-            c_f_params = c_dict[c].get_fitted_params()
-            c_f_params = {f"{sh(c)}__{k}": c_f_params[k] for k in c_f_params.keys()}
-            fitted_params.update(c_f_params)
+            comp = c_dict[c]
+            if comp._is_fitted:
+                c_f_params = c_dict[c].get_fitted_params()
+                c_f_params = {f"{sh(c)}__{k}": c_f_params[k] for k in c_f_params.keys()}
+                fitted_params.update(c_f_params)
 
         fitted_params.update(self._get_fitted_params())
 
diff --git a/sktime/classification/_delegate.py b/sktime/classification/_delegate.py
index cef272bd0..e5873c526 100644
--- a/sktime/classification/_delegate.py
+++ b/sktime/classification/_delegate.py
@@ -122,3 +122,18 @@ class _DelegatedClassifier(BaseClassifier):
         """
         estimator = self._get_delegate()
         return estimator.predict_proba(X=X)
+
+    def _get_fitted_params(self):
+        """Get fitted parameters.
+
+        private _get_fitted_params, called from get_fitted_params
+
+        State required:
+            Requires state to be "fitted".
+
+        Returns
+        -------
+        fitted_params : dict
+        """
+        estimator = self._get_delegate()
+        return estimator.get_fitted_params()
diff --git a/sktime/datatypes/__init__.py b/sktime/datatypes/__init__.py
index 67b1c981e..8e5e22e48 100644
--- a/sktime/datatypes/__init__.py
+++ b/sktime/datatypes/__init__.py
@@ -13,6 +13,7 @@ from sktime.datatypes._check import (
 from sktime.datatypes._convert import convert, convert_to
 from sktime.datatypes._examples import get_examples
 from sktime.datatypes._registry import (
+    ALL_TIME_SERIES_MTYPES,
     MTYPE_LIST_HIERARCHICAL,
     MTYPE_LIST_PANEL,
     MTYPE_LIST_PROBA,
@@ -28,6 +29,7 @@ from sktime.datatypes._utilities import get_cutoff, update_data
 from sktime.datatypes._vectorize import VectorizedDF
 
 __all__ = [
+    "ALL_TIME_SERIES_MTYPES",
     "check_is_mtype",
     "check_is_scitype",
     "check_raise",
diff --git a/sktime/datatypes/_check.py b/sktime/datatypes/_check.py
index 2e2d9fb8d..d26d49958 100644
--- a/sktime/datatypes/_check.py
+++ b/sktime/datatypes/_check.py
@@ -32,7 +32,7 @@ from sktime.datatypes._alignment import check_dict_Alignment
 from sktime.datatypes._hierarchical import check_dict_Hierarchical
 from sktime.datatypes._panel import check_dict_Panel
 from sktime.datatypes._proba import check_dict_Proba
-from sktime.datatypes._registry import SCITYPE_LIST, mtype_to_scitype
+from sktime.datatypes._registry import AMBIGUOUS_MTYPES, SCITYPE_LIST, mtype_to_scitype
 from sktime.datatypes._series import check_dict_Series
 from sktime.datatypes._table import check_dict_Table
 
@@ -46,10 +46,6 @@ check_dict.update(check_dict_Table)
 check_dict.update(check_dict_Proba)
 
 
-# mtypes to exclude
-AMBIGUOUS_MTYPES = ["numpyflat", "alignment_loc"]
-
-
 def _check_scitype_valid(scitype: str = None):
     """Check validity of scitype."""
     valid_scitypes = list(set([x[1] for x in check_dict.keys()]))
diff --git a/sktime/datatypes/_registry.py b/sktime/datatypes/_registry.py
index bdf475873..910173d11 100644
--- a/sktime/datatypes/_registry.py
+++ b/sktime/datatypes/_registry.py
@@ -53,6 +53,15 @@ MTYPE_REGISTER += MTYPE_REGISTER_ALIGNMENT
 MTYPE_REGISTER += MTYPE_REGISTER_TABLE
 MTYPE_REGISTER += MTYPE_REGISTER_PROBA
 
+# mtypes to exclude in checking since they are ambiguous and rare
+AMBIGUOUS_MTYPES = ["numpyflat", "alignment_loc"]
+
+# all time series mtypes excluding ambiguous ones
+ALL_TIME_SERIES_MTYPES = (
+    list(MTYPE_LIST_PANEL) + list(MTYPE_LIST_SERIES) + list(MTYPE_LIST_HIERARCHICAL)
+)
+ALL_TIME_SERIES_MTYPES = list(set(ALL_TIME_SERIES_MTYPES).difference(AMBIGUOUS_MTYPES))
+
 
 __all__ = [
     "MTYPE_REGISTER",
diff --git a/sktime/forecasting/base/_base.py b/sktime/forecasting/base/_base.py
index b001290d7..7f24a0673 100644
--- a/sktime/forecasting/base/_base.py
+++ b/sktime/forecasting/base/_base.py
@@ -1137,14 +1137,40 @@ class BaseForecaster(BaseEstimator):
     def get_fitted_params(self):
         """Get fitted parameters.
 
+        Overrides BaseEstimator default in case of vectorization.
+
         State required:
             Requires state to be "fitted".
 
         Returns
         -------
-        fitted_params : dict
+        fitted_params : dict of fitted parameters, keys are str names of parameters
+            parameters of components are indexed as [componentname]__[paramname]
         """
-        raise NotImplementedError("abstract method")
+        # if self is not vectorized, run the default get_fitted_params
+        if not getattr(self, "_is_vectorized", False):
+            return super(BaseForecaster, self).get_fitted_params()
+
+        # otherwise, we delegate to the instances' get_fitted_params
+        # instances' parameters are returned at dataframe-slice-like keys
+        fitted_params = {}
+
+        # forecasters contains a pd.DataFrame with the individual forecasters
+        forecasters = self.forecasters_
+
+        # return forecasters in the "forecasters" param
+        fitted_params["forecasters"] = forecasters
+
+        # populate fitted_params with forecasters and their parameters
+        for ix, col in zip(forecasters.index, forecasters.columns):
+            fcst = forecasters.loc[ix, col]
+            fcst_key = f"forecasters.loc[{ix},{col}]"
+            fitted_params[fcst_key] = fcst
+            fcst_params = fcst.get_fitted_params()
+            for key, val in fcst_params.items():
+                fitted_params[f"{fcst_key}__{key}"] = val
+
+        return fitted_params
 
     def _check_X_y(self, X=None, y=None):
         """Check and coerce X/y for fit/predict/update functions.
diff --git a/sktime/forecasting/base/_delegate.py b/sktime/forecasting/base/_delegate.py
index 18fa8f270..96431aab8 100644
--- a/sktime/forecasting/base/_delegate.py
+++ b/sktime/forecasting/base/_delegate.py
@@ -285,3 +285,18 @@ class _DelegatedForecaster(BaseForecaster):
         """
         estimator = self._get_delegate()
         return estimator.predict_proba(fh=fh, X=X, marginal=marginal)
+
+    def _get_fitted_params(self):
+        """Get fitted parameters.
+
+        private _get_fitted_params, called from get_fitted_params
+
+        State required:
+            Requires state to be "fitted".
+
+        Returns
+        -------
+        fitted_params : dict
+        """
+        estimator = self._get_delegate()
+        return estimator.get_fitted_params()
diff --git a/sktime/forecasting/compose/_multiplexer.py b/sktime/forecasting/compose/_multiplexer.py
index f4dc47452..0a4bde71e 100644
--- a/sktime/forecasting/compose/_multiplexer.py
+++ b/sktime/forecasting/compose/_multiplexer.py
@@ -4,6 +4,7 @@
 """Implements forecaster for selecting among different model classes."""
 
 from sktime.base import _HeterogenousMetaEstimator
+from sktime.datatypes import ALL_TIME_SERIES_MTYPES
 from sktime.forecasting.base._base import BaseForecaster
 from sktime.forecasting.base._delegate import _DelegatedForecaster
 
@@ -83,7 +84,8 @@ class MultiplexForecaster(_DelegatedForecaster, _HeterogenousMetaEstimator):
         "requires-fh-in-fit": False,
         "handles-missing-data": False,
         "scitype:y": "both",
-        "y_inner_mtype": ["pd.DataFrame", "pd.Series"],
+        "y_inner_mtype": ALL_TIME_SERIES_MTYPES,
+        "X_inner_mtype": ALL_TIME_SERIES_MTYPES,
         "fit_is_empty": False,
     }
 
@@ -108,8 +110,12 @@ class MultiplexForecaster(_DelegatedForecaster, _HeterogenousMetaEstimator):
             clone_ests=False,
         )
         self._set_forecaster()
+
         self.clone_tags(self.forecaster_)
         self.set_tags(**{"fit_is_empty": False})
+        # this ensures that we convert in the inner estimator, not in the multiplexer
+        self.set_tags(**{"y_inner_mtype": ALL_TIME_SERIES_MTYPES})
+        self.set_tags(**{"X_inner_mtype": ALL_TIME_SERIES_MTYPES})
 
     @property
     def _forecasters(self):
diff --git a/sktime/forecasting/compose/_pipeline.py b/sktime/forecasting/compose/_pipeline.py
index 38f6a90f3..302286ad9 100644
--- a/sktime/forecasting/compose/_pipeline.py
+++ b/sktime/forecasting/compose/_pipeline.py
@@ -1129,8 +1129,8 @@ class ForecastX(BaseForecaster):
     """
 
     _tags = {
-        "X_inner_mtype": "pd.DataFrame",
-        "y_inner_mtype": "pd.DataFrame",
+        "X_inner_mtype": SUPPORTED_MTYPES,
+        "y_inner_mtype": SUPPORTED_MTYPES,
         "X-y-must-have-same-index": False,
         "fit_is_empty": False,
         "ignores-exogeneous-X": False,
diff --git a/sktime/forecasting/stream/_update.py b/sktime/forecasting/stream/_update.py
index 7ebec92ea..7a885b280 100644
--- a/sktime/forecasting/stream/_update.py
+++ b/sktime/forecasting/stream/_update.py
@@ -6,9 +6,17 @@ __author__ = ["fkiraly"]
 
 import pandas as pd
 
+from sktime.datatypes import ALL_TIME_SERIES_MTYPES
 from sktime.datatypes._utilities import get_window
 from sktime.forecasting.base._delegate import _DelegatedForecaster
 
+# prepare tags to clone - exceptions are TAGS_TO_KEEP
+TAGS_TO_KEEP = ["fit_is_empty", "X_inner_mtype", "y_inner_mtype"]
+# fit must be executed to fit the wrapped estimator and remember the cutoff
+# mtype tags are set so X/y is passed through, conversions happen in wrapped estimator
+TAGS_TO_CLONE = _DelegatedForecaster().get_tags().keys()
+TAGS_TO_CLONE = list(set(TAGS_TO_CLONE).difference(TAGS_TO_KEEP))
+
 
 class UpdateRefitsEvery(_DelegatedForecaster):
     """Refits periodically when update is called.
@@ -39,7 +47,12 @@ class UpdateRefitsEvery(_DelegatedForecaster):
 
     _delegate_name = "forecaster_"
 
-    _tags = {"fit_is_empty": False, "requires-fh-in-fit": False}
+    _tags = {
+        "fit_is_empty": False,
+        "requires-fh-in-fit": False,
+        "y_inner_mtype": ALL_TIME_SERIES_MTYPES,
+        "X_inner_mtype": ALL_TIME_SERIES_MTYPES,
+    }
 
     def __init__(
         self, forecaster, refit_interval=0, refit_window_size=None, refit_window_lag=0
@@ -53,10 +66,7 @@ class UpdateRefitsEvery(_DelegatedForecaster):
 
         super(UpdateRefitsEvery, self).__init__()
 
-        self.clone_tags(forecaster)
-
-        # fit must be executed to fit the wrapped estimator and remember the cutoff
-        self.set_tags(fit_is_empty=False)
+        self.clone_tags(forecaster, TAGS_TO_CLONE)
 
     def _fit(self, y, X=None, fh=None):
         """Fit forecaster to training data.
@@ -219,7 +229,12 @@ class UpdateEvery(_DelegatedForecaster):
 
     _delegate_name = "forecaster_"
 
-    _tags = {"fit_is_empty": False, "requires-fh-in-fit": False}
+    _tags = {
+        "fit_is_empty": False,
+        "requires-fh-in-fit": False,
+        "y_inner_mtype": ALL_TIME_SERIES_MTYPES,
+        "X_inner_mtype": ALL_TIME_SERIES_MTYPES,
+    }
 
     def __init__(self, forecaster, update_interval=None):
         self.forecaster = forecaster
@@ -229,10 +244,7 @@ class UpdateEvery(_DelegatedForecaster):
 
         super(UpdateEvery, self).__init__()
 
-        self.clone_tags(forecaster)
-
-        # fit must be executed to fit the wrapped estimator and remember the cutoff
-        self.set_tags(fit_is_empty=False)
+        self.clone_tags(forecaster, TAGS_TO_KEEP)
 
     def _fit(self, y, X=None, fh=None):
         """Fit forecaster to training data.
@@ -379,7 +391,12 @@ class DontUpdate(_DelegatedForecaster):
 
     _delegate_name = "forecaster_"
 
-    _tags = {"fit_is_empty": False, "requires-fh-in-fit": False}
+    _tags = {
+        "fit_is_empty": False,
+        "requires-fh-in-fit": False,
+        "y_inner_mtype": ALL_TIME_SERIES_MTYPES,
+        "X_inner_mtype": ALL_TIME_SERIES_MTYPES,
+    }
 
     def __init__(self, forecaster):
         self.forecaster = forecaster
@@ -387,7 +404,7 @@ class DontUpdate(_DelegatedForecaster):
 
         super(DontUpdate, self).__init__()
 
-        self.clone_tags(forecaster)
+        self.clone_tags(forecaster, TAGS_TO_CLONE)
 
     def _update(self, y, X=None, update_params=True):
         """Update time series to incremental training data.
diff --git a/sktime/regression/_delegate.py b/sktime/regression/_delegate.py
index 655fbeedc..1260ab08c 100644
--- a/sktime/regression/_delegate.py
+++ b/sktime/regression/_delegate.py
@@ -91,3 +91,18 @@ class _DelegatedRegressor(BaseRegressor):
         """
         estimator = self._get_delegate()
         return estimator.predict(X=X)
+
+    def _get_fitted_params(self):
+        """Get fitted parameters.
+
+        private _get_fitted_params, called from get_fitted_params
+
+        State required:
+            Requires state to be "fitted".
+
+        Returns
+        -------
+        fitted_params : dict
+        """
+        estimator = self._get_delegate()
+        return estimator.get_fitted_params()
diff --git a/sktime/transformations/_delegate.py b/sktime/transformations/_delegate.py
index c8055ba1c..1507b5ed8 100644
--- a/sktime/transformations/_delegate.py
+++ b/sktime/transformations/_delegate.py
@@ -166,3 +166,18 @@ class _DelegatedTransformer(BaseTransformer):
         estimator = self._get_delegate()
         estimator.update(y=y, X=X, update_params=update_params)
         return self
+
+    def _get_fitted_params(self):
+        """Get fitted parameters.
+
+        private _get_fitted_params, called from get_fitted_params
+
+        State required:
+            Requires state to be "fitted".
+
+        Returns
+        -------
+        fitted_params : dict
+        """
+        estimator = self._get_delegate()
+        return estimator.get_fitted_params()
diff --git a/sktime/transformations/base.py b/sktime/transformations/base.py
index 7e706ee5b..53b583fe4 100644
--- a/sktime/transformations/base.py
+++ b/sktime/transformations/base.py
@@ -407,6 +407,7 @@ class BaseTransformer(BaseEstimator):
         # checks and conversions complete, pass to inner fit
         #####################################################
         vectorization_needed = isinstance(X_inner, VectorizedDF)
+        self._is_vectorized = vectorization_needed
         # we call the ordinary _fit if no looping/vectorization needed
         if not vectorization_needed:
             self._fit(X=X_inner, y=y_inner)
@@ -678,6 +679,44 @@ class BaseTransformer(BaseEstimator):
 
         return self
 
+    def get_fitted_params(self):
+        """Get fitted parameters.
+
+        Overrides BaseEstimator default in case of vectorization.
+
+        State required:
+            Requires state to be "fitted".
+
+        Returns
+        -------
+        fitted_params : dict of fitted parameters, keys are str names of parameters
+            parameters of components are indexed as [componentname]__[paramname]
+        """
+        # if self is not vectorized, run the default get_fitted_params
+        if not getattr(self, "_is_vectorized", False):
+            return super(BaseTransformer, self).get_fitted_params()
+
+        # otherwise, we delegate to the instances' get_fitted_params
+        # instances' parameters are returned at dataframe-slice-like keys
+        fitted_params = {}
+
+        # transformers contains a pd.DataFrame with the individual transformers
+        transformers = self.transformers_
+
+        # return forecasters in the "forecasters" param
+        fitted_params["transformers"] = transformers
+
+        # populate fitted_params with ftransformers and their parameters
+        for ix, col in zip(transformers.index, transformers.columns):
+            fcst = transformers.loc[ix, col]
+            fcst_key = f"transformers.loc[{ix},{col}]"
+            fitted_params[fcst_key] = fcst
+            fcst_params = fcst.get_fitted_params()
+            for key, val in fcst_params.items():
+                fitted_params[f"{fcst_key}__{key}"] = val
+
+        return fitted_params
+
     def _check_X_y(self, X=None, y=None, return_metadata=False):
         """Check and coerce X/y for fit/transform functions.
 
diff --git a/sktime/transformations/compose.py b/sktime/transformations/compose.py
index cca2d9ea5..1a3107056 100644
--- a/sktime/transformations/compose.py
+++ b/sktime/transformations/compose.py
@@ -9,6 +9,7 @@ from sklearn import clone
 from sklearn.utils.metaestimators import if_delegate_has_method
 
 from sktime.base import _HeterogenousMetaEstimator
+from sktime.datatypes import ALL_TIME_SERIES_MTYPES
 from sktime.transformations._delegate import _DelegatedTransformer
 from sktime.transformations.base import BaseTransformer
 from sktime.utils.multiindex import flatten_multiindex
@@ -906,6 +907,7 @@ class MultiplexTransformer(_DelegatedTransformer, _HeterogenousMetaEstimator):
     _tags = {
         "fit_is_empty": False,
         "univariate-only": False,
+        "X_inner_mtype": ALL_TIME_SERIES_MTYPES,
     }
 
     _delegate_name = "transformer_"
@@ -928,6 +930,8 @@ class MultiplexTransformer(_DelegatedTransformer, _HeterogenousMetaEstimator):
         self._set_transformer()
         self.clone_tags(self.transformer_)
         self.set_tags(**{"fit_is_empty": False})
+        # this ensures that we convert in the inner estimator, not in the multiplexer
+        self.set_tags(**{"X_inner_mtype": ALL_TIME_SERIES_MTYPES})
 
     @property
     def _transformers(self):


Test patch:
diff --git a/sktime/annotation/tests/test_stray.py b/sktime/annotation/tests/test_stray.py
new file mode 100644
index 000000000..bcb6127ce
--- /dev/null
+++ b/sktime/annotation/tests/test_stray.py
@@ -0,0 +1,289 @@
+# -*- coding: utf-8 -*-
+"""Tests for STRAY outlier estimator."""
+
+__author__ = ["KatieBuc"]
+
+import numpy as np
+from sklearn.preprocessing import MinMaxScaler, RobustScaler
+
+from sktime.annotation.stray import STRAY
+
+
+def test_default_1D():
+    """Test with default parameters and 1D input array."""
+    X = np.array(
+        [
+            -7.207066,
+            -5.722571,
+            -4.915559,
+            -8.345698,
+            -5.570875,
+            -5.493944,
+            -6.574740,
+            -6.546632,
+            -6.564452,
+            -6.890038,
+            -6.477193,
+            -6.998386,
+            -6.776254,
+            -5.935541,
+            -5.040506,
+            0.000000,
+            5.889715,
+            5.488990,
+            5.088805,
+            5.162828,
+            8.415835,
+            6.134088,
+            5.509314,
+            5.559452,
+            6.459589,
+            5.306280,
+            4.551795,
+            6.574756,
+            4.976344,
+            5.984862,
+            5.064051,
+            7.102298,
+        ]
+    )
+
+    y_expected = np.array(
+        [
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            True,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+        ]
+    )
+
+    scaler = MinMaxScaler()
+    X = scaler.fit_transform(X.reshape(-1, 1))
+    model = STRAY()
+    y_actual = model.fit_transform(X)
+    assert np.allclose(y_actual, y_expected)
+
+
+def test_default_2D():
+    """Test with default parameters and 2D input array."""
+    X = np.array(
+        [
+            [-1.207, -0.776, -0.694],
+            [0.277, 0.064, -1.448],
+            [1.084, 0.959, 0.575],
+            [-2.346, -0.11, -1.024],
+            [0.429, -0.511, -0.015],
+            [0.506, -0.911, -0.936],
+            [-0.575, -0.837, 1.102],
+            [-0.547, 2.416, -0.476],
+            [-0.564, 0.134, -0.709],
+            [-0.89, -0.491, -0.501],
+            [-0.477, -0.441, -1.629],
+            [-0.998, 0.46, -1.168],
+            [10.0, 12.0, 10.0],
+            [3.0, 7.0, 10.0],
+        ]
+    )
+
+    y_expected = np.array(
+        [
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            False,
+            True,
+            True,
+        ]
+    )
+
+    scaler = MinMaxScaler()
+    X = scaler.fit_transform(X)
+    model = STRAY()
+    y_actual = model.fit_transform(X)
+    assert np.allclose(y_actual, y_expected)
+
+
+def test_1D_score_with_na():
+    """Test score with 1D input array with missing values."""
+    X = np.array(
+        [
+            np.nan,
+            -5.72257076,
+            -4.91555882,
+            -8.3456977,
+            -5.57087531,
+            0.0,
+            6.50605589,
+            5.42526004,
+            5.45336814,
+            5.435548,
+            5.10996217,
+        ]
+    )
+
+    y_scores_expected = np.array(
+        [
+            np.nan,
+            0.17662069,
+            0.23095851,
+            0.17662069,
+            0.18683466,
+            0.33097498,
+            0.07087969,
+            0.02122967,
+            0.02312225,
+            0.02192238,
+            0.02122967,
+        ]
+    )
+
+    scaler = MinMaxScaler()
+    X = scaler.fit_transform(X.reshape(-1, 1))
+    model = STRAY(k=3)
+    fitted_model = model.fit(X)
+    y_scores_actual = fitted_model.score_
+    assert np.allclose(y_scores_actual, y_scores_expected, equal_nan=True)
+
+
+def test_1D_bool_with_na():
+    """Test anomaly detection with 1D input array with missing values."""
+    X = np.array(
+        [
+            np.nan,
+            -5.72257076,
+            -4.91555882,
+            -8.3456977,
+            -5.57087531,
+            0.0,
+            6.50605589,
+            5.42526004,
+            5.45336814,
+            5.435548,
+            5.10996217,
+        ]
+    )
+
+    y_expected = np.array([0, 0, 1, 0, 1, 1, 0, 0, 0, 0, 0])
+
+    scaler = MinMaxScaler()
+    X = scaler.fit_transform(X.reshape(-1, 1))
+    model = STRAY(k=3)
+    fitted_model = model.fit(X)
+    y_actual = fitted_model.y_
+    assert np.allclose(y_actual, y_expected)
+
+
+def test_2D_score_with_na():
+    """Test score with 2D input array with missing values."""
+    X = np.array(
+        [
+            [-1.20706575, -0.57473996],
+            [0.27742924, -0.54663186],
+            [1.08444118, np.nan],
+            [-2.3456977, -0.89003783],
+            [0.42912469, -0.4771927],
+            [0.50605589, -0.99838644],
+        ]
+    )
+
+    y_scores_expected = np.array(
+        [0.43612712, 0.43612712, np.nan, 0.69004259, 0.51240831, 0.85697804]
+    )
+
+    scaler = MinMaxScaler()
+    X = scaler.fit_transform(X)
+    model = STRAY(k=2, size_threshold=4)
+    fitted_model = model.fit(X)
+    y_scores_actual = fitted_model.score_
+    assert np.allclose(y_scores_actual, y_scores_expected, equal_nan=True)
+
+
+def test_2D_bool_with_na():
+    """Test anomaly detection with 2D input array with missing values."""
+    X = np.array(
+        [
+            [-1.20706575, -0.57473996],
+            [0.27742924, -0.54663186],
+            [1.08444118, np.nan],
+            [-2.3456977, -0.89003783],
+            [0.42912469, -0.4771927],
+            [0.50605589, -0.99838644],
+        ]
+    )
+
+    y_expected = np.array([0, 0, 0, 1, 1, 1])
+
+    scaler = MinMaxScaler()
+    X = scaler.fit_transform(X)
+    model = STRAY(k=2, size_threshold=4)
+    fitted_model = model.fit(X)
+    y_actual = fitted_model.y_
+    assert np.allclose(y_actual, y_expected)
+
+
+def test_2D_score_with_standardize():
+    """Test score with 2D input array and median/IQR normalization."""
+    X = np.array(
+        [
+            [-1.20706575, -0.57473996],
+            [0.27742924, -0.54663186],
+            [1.08444118, -0.5644520],
+            [-2.3456977, -0.89003783],
+            [0.42912469, -0.4771927],
+            [0.50605589, -0.99838644],
+        ]
+    )
+
+    y_scores_expected = np.array(
+        [
+            1.1274565,
+            0.6139288,
+            0.5982989,
+            1.4866554,
+            0.5982989,
+            1.7245212,
+        ]
+    )
+
+    scaler = RobustScaler()
+    X = scaler.fit_transform(X)
+    model = STRAY(k=2, size_threshold=4)
+    fitted_model = model.fit(X)
+    y_scores_actual = fitted_model.score_
+    assert np.allclose(y_scores_actual, y_scores_expected)