Repository: aeon-toolkit/aeon
Pull request #2120 (2024-09-30 14:40:32)

[ENH] Remove `BaseEstimator` methods
Removes `clone_tags`, `set_params`, `get_param_defaults`, `get_param_names`, and `_get_init_signature`. `set_params` is an overload of the `sklearn` base, which seems to only add a call to `reset` for some reason. None of the other methods have usages in the package itself, except for `get_param_defaults` in a test. IMO there is not much value in retaining these for users.

This is mostly an effort to remove method bloat from the base class (and subsequently every `aeon` estimator). One of my gripes with the API is that there are a massive amount of inherited methods which I don't believe people use (i.e. https://www.aeon-toolkit.org/en/stable/api_reference/auto_generated/aeon.classification.convolution_based.Arsenal.html#aeon.classification.convolution_based.Arsenal). These are not the only removal candidates, I just dont want to do too much at once.

Adds a `random_state` param to `clone` and a `keep_attributes` param to `reset`.

aeon-actions-bot[bot]: 
## Thank you for contributing to `aeon`

I have added the following labels to this PR based on the title: **[ $\\color{#FEF1BE}{\\textsf{enhancement}}$ ]**.


The [Checks](https://github.com/aeon-toolkit/aeon/pull/2120/checks) tab will show the status of our automated tests. You can click on individual test runs in the tab or "Details" in the panel below to see more information if there is a failure.

If our `pre-commit` code quality check fails, any trivial fixes will automatically be pushed to your PR unless it is a draft.

Don't hesitate to ask questions on the `aeon` [Slack](https://join.slack.com/t/aeon-toolkit/shared_invite/zt-22vwvut29-HDpCu~7VBUozyfL_8j3dLA) channel if you have any.

### PR CI actions

These checkboxes will add labels to enable/disable CI functionality for this PR. This may not take effect immediately, and a new commit may be required to run the new configuration.

- [ ] Run `pre-commit` checks for all files
- [ ] Run `mypy` typecheck tests
- [ ] Run all `pytest` tests and configurations
- [ ] Run all notebook example tests
- [ ] Run numba-disabled `codecov` tests
- [ ] Stop automatic `pre-commit` fixes (always disabled for drafts)
- [ ] Disable numba cache loading
- [ ] Push an empty commit to re-run CI checks
    
TonyBagnall: I completely agree, but could you do a full run of CI and codecov just to check?

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index fc4affda..265e81c2 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -29,7 +29,7 @@ repos:
         args: [ "--create", "--python-folders", "aeon" ]
 
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.6.2
+    rev: v0.6.8
     hooks:
       - id: ruff
         args: [ "--fix"]
diff --git a/aeon/base/_base.py b/aeon/base/_base.py
index 8b6c4327..5f3ac75b 100644
--- a/aeon/base/_base.py
+++ b/aeon/base/_base.py
@@ -8,8 +8,6 @@ Interface specifications below.
 Parameter inspection and setter methods
     inspect parameter values      - get_params()
     setting parameter values      - set_params(**params)
-    list of parameter names       - get_param_names()
-    dict of parameter defaults    - get_param_defaults()
     fitted parameter inspection - get_fitted_params()
 
 Tag inspection and setter methods
@@ -39,7 +37,6 @@ __maintainer__ = ["MatthewMiddlehurst", "TonyBagnall"]
 __all__ = ["BaseEstimator"]
 
 import inspect
-from collections import defaultdict
 from copy import deepcopy
 
 from sklearn import clone
@@ -52,12 +49,12 @@ class BaseEstimator(_BaseEstimator):
     """Base class for defining estimators in aeon."""
 
     def __init__(self):
-        self._is_fitted = False
-        self._tags_dynamic = dict()
+        self._is_fitted = False  # flag to indicate if fit has been called
+        self._tags_dynamic = dict()  # storage for dynamic tags
 
         super().__init__()
 
-    def reset(self):
+    def reset(self, keep=None):
         """Reset the object to a clean post-init state.
 
         Equivalent to sklearn.clone but overwrites self.
@@ -82,8 +79,17 @@ class BaseEstimator(_BaseEstimator):
         cls_attrs = [attr for attr in dir(type(self))]
         self_attrs = set(attrs).difference(cls_attrs)
 
-        # keep a test flag if it exists
-        self_attrs.discard("_unit_test_flag")
+        # keep specific attributes if set
+        if keep is not None:
+            if isinstance(keep, str):
+                keep = [keep]
+            elif not isinstance(keep, list):
+                raise TypeError(
+                    "keep must be a string or list of strings containing attributes "
+                    "to keep after the reset."
+                )
+            for attr in keep:
+                self_attrs.discard(attr)
 
         for attr in self_attrs:
             delattr(self, attr)
@@ -93,7 +99,7 @@ class BaseEstimator(_BaseEstimator):
 
         return self
 
-    def clone(self):
+    def clone(self, random_state=None):
         """
         Obtain a clone of the object with same hyper-parameters.
 
@@ -105,123 +111,12 @@ class BaseEstimator(_BaseEstimator):
         -------
         instance of ``type(self)``, clone of self (see above)
         """
-        return clone(self)
-
-    @classmethod
-    def _get_init_signature(cls):
-        """Get init sigature of cls, for use in parameter inspection.
+        estimator = clone(self)
 
-        Returns
-        -------
-        list of inspect Parameter objects (including defaults)
+        if random_state is not None:
+            _set_random_states(estimator, random_state)
 
-        Raises
-        ------
-        RuntimeError if cls has varargs in ``__init__``
-        """
-        # fetch the constructor or the original constructor before
-        # deprecation wrapping if any
-        init = getattr(cls.__init__, "deprecated_original", cls.__init__)
-        if init is object.__init__:
-            # No explicit constructor to introspect
-            return []
-
-        # introspect the constructor arguments to find the model parameters
-        # to represent
-        init_signature = inspect.signature(init)
-
-        # Consider the constructor parameters excluding 'self'
-        parameters = [
-            p
-            for p in init_signature.parameters.values()
-            if p.name != "self" and p.kind != p.VAR_KEYWORD
-        ]
-        for p in parameters:
-            if p.kind == p.VAR_POSITIONAL:
-                raise RuntimeError(
-                    "scikit-learn compatible estimators should always "
-                    "specify their parameters in the signature"
-                    " of their __init__ (no varargs)."
-                    " %s with constructor %s doesn't "
-                    " follow this convention." % (cls, init_signature)
-                )
-        return parameters
-
-    @classmethod
-    def get_param_names(cls):
-        """
-        Get parameter names for the object.
-
-        Returns
-        -------
-        param_names: list of str, alphabetically sorted list of parameter names of cls
-        """
-        parameters = cls._get_init_signature()
-        param_names = sorted([p.name for p in parameters])
-        return param_names
-
-    @classmethod
-    def get_param_defaults(cls):
-        """
-        Get parameter defaults for the object.
-
-        Returns
-        -------
-        default_dict: dict with str keys
-            keys are all parameters of cls that have a default defined in __init__
-            values are the defaults, as defined in __init__.
-        """
-        parameters = cls._get_init_signature()
-        default_dict = {
-            x.name: x.default for x in parameters if x.default != inspect._empty
-        }
-        return default_dict
-
-    def set_params(self, **params):
-        """
-        Set the parameters of this object.
-
-        The method works on simple estimators as well as on nested objects.
-        The latter have parameters of the form ``<component>__<parameter>`` so that it's
-        possible to update each component of a nested object.
-
-        Parameters
-        ----------
-        **params : dict
-            BaseEstimator parameters
-
-        Returns
-        -------
-        self : reference to self (after parameters have been set)
-        """
-        if not params:
-            # Simple optimization to gain speed (inspect is slow)
-            return self
-        valid_params = self.get_params(deep=True)
-
-        nested_params = defaultdict(dict)  # grouped by prefix
-        for key, value in params.items():
-            key, delim, sub_key = key.partition("__")
-            if key not in valid_params:
-                raise ValueError(
-                    "Invalid parameter %s for object %s. "
-                    "Check the list of available parameters "
-                    "with `object.get_params().keys()`." % (key, self)
-                )
-
-            if delim:
-                nested_params[key][sub_key] = value
-            else:
-                setattr(self, key, value)
-                valid_params[key] = value
-
-        self.reset()
-
-        # recurse in components
-        for key, sub_params in nested_params.items():
-            valid_params[key].set_params(**sub_params)
-
-        return self
+        return estimator
 
     @classmethod
     def get_class_tags(cls):
@@ -402,45 +297,6 @@ class BaseEstimator(_BaseEstimator):
 
         return self
 
-    def clone_tags(self, estimator, tag_names=None):
-        """
-        Clone/mirror tags from another estimator as dynamic override.
-
-        Parameters
-        ----------
-        estimator : object
-            Estimator inheriting from :class:BaseEstimator.
-        tag_names : str or list of str, default = None
-            Names of tags to clone. If None then all tags in estimator are used
-            as `tag_names`.
-
-        Returns
-        -------
-        Self :
-            Reference to self.
-
-        Notes
-        -----
-        Changes object state by setting tag values in tag_set from estimator as
-        dynamic tags in self.
-        """
-        tags_est = deepcopy(estimator.get_tags())
-
-        # if tag_set is not passed, default is all tags in estimator
-        if tag_names is None:
-            tag_names = tags_est.keys()
-        else:
-            # if tag_set is passed, intersect keys with tags in estimator
-            if not isinstance(tag_names, list):
-                tag_names = [tag_names]
-            tag_names = [key for key in tag_names if key in tags_est.keys()]
-
-        update_dict = {key: tags_est[key] for key in tag_names}
-
-        self.set_tags(**update_dict)
-
-        return self
-
     @classmethod
     def get_test_params(cls, parameter_set="default"):
         """
@@ -782,8 +638,7 @@ class BaseEstimator(_BaseEstimator):
             Dictionary of fitted parameters, paramname : paramvalue
             keys-value pairs include:
 
-            * always: all fitted parameters of this object, as via ``get_param_names``
-              values are fitted parameter value for that key, of this object
+            * always: all fitted parameters of this object
             * if ``deep=True``, also contains keys/value pairs of component parameters
               parameters of components are indexed as ``[componentname]__[paramname]``
               all parameters of ``componentname`` appear as ``paramname`` with its value
diff --git a/aeon/base/estimator/interval_based/base_interval_forest.py b/aeon/base/estimator/interval_based/base_interval_forest.py
index a93f7030..07c23c53 100644
--- a/aeon/base/estimator/interval_based/base_interval_forest.py
+++ b/aeon/base/estimator/interval_based/base_interval_forest.py
@@ -218,7 +218,7 @@ class BaseIntervalForest(metaclass=ABCMeta):
     transformer_feature_skip = ["transform_features_", "_transform_features"]
 
     def _fit(self, X, y):
-        if getattr(self, "_unit_test_flag", False):
+        if getattr(self, "__unit_test_flag", False):
             self._transformed_data = self._fit_forest(X, y, save_transformed_data=True)
         else:
             self._fit_forest(X, y)
diff --git a/aeon/distances/__init__.py b/aeon/distances/__init__.py
index e72df235..dec1dba2 100644
--- a/aeon/distances/__init__.py
+++ b/aeon/distances/__init__.py
@@ -71,6 +71,9 @@ __all__ = [
     "sax_mindist",
     "sfa_mindist",
     "dft_sfa_mindist",
+    "shift_scale_invariant_distance",
+    "shift_scale_invariant_pairwise_distance",
+    "shift_scale_invariant_best_shift",
 ]
 
 
@@ -143,6 +146,11 @@ from aeon.distances._shape_dtw import (
     shape_dtw_distance,
     shape_dtw_pairwise_distance,
 )
+from aeon.distances._shift_scale_invariant import (
+    shift_scale_invariant_best_shift,
+    shift_scale_invariant_distance,
+    shift_scale_invariant_pairwise_distance,
+)
 from aeon.distances._squared import squared_distance, squared_pairwise_distance
 from aeon.distances._twe import (
     twe_alignment_path,
diff --git a/aeon/distances/_distance.py b/aeon/distances/_distance.py
index de50f236..b8944598 100644
--- a/aeon/distances/_distance.py
+++ b/aeon/distances/_distance.py
@@ -58,6 +58,10 @@ from aeon.distances._shape_dtw import (
     shape_dtw_distance,
     shape_dtw_pairwise_distance,
 )
+from aeon.distances._shift_scale_invariant import (
+    shift_scale_invariant_distance,
+    shift_scale_invariant_pairwise_distance,
+)
 from aeon.distances._squared import squared_distance, squared_pairwise_distance
 from aeon.distances._twe import (
     twe_alignment_path,
@@ -97,6 +101,7 @@ class DistanceKwargs(TypedDict, total=False):
     warp_penalty: float
     standardize: bool
     m: int
+    max_shift: Optional[int]
 
 
 DistanceFunction = Callable[[np.ndarray, np.ndarray, Any], float]
@@ -249,6 +254,8 @@ def distance(
         )
     elif metric == "sbd":
         return sbd_distance(x, y, kwargs.get("standardize", True))
+    elif metric == "shift_scale":
+        return shift_scale_invariant_distance(x, y, kwargs.get("max_shift", None))
     else:
         if isinstance(metric, Callable):
             return metric(x, y, **kwargs)
@@ -259,6 +266,7 @@ def pairwise_distance(
     x: np.ndarray,
     y: Optional[np.ndarray] = None,
     metric: Union[str, DistanceFunction, None] = None,
+    symmetric: bool = True,
     **kwargs: Unpack[DistanceKwargs],
 ) -> np.ndarray:
     """Compute the pairwise distance matrix between two time series.
@@ -276,6 +284,13 @@ def pairwise_distance(
         A list of valid distance metrics can be found in the documentation for
         :func:`aeon.distances.get_distance_function` or by calling  the function
         :func:`aeon.distances.get_distance_function_names`.
+    symmetric : bool, default=True
+        If True and a function is provided as the "metric" paramter, then it will
+        compute a symmetric distance matrix where d(x, y) = d(y, x). Only the lower
+        triangle is calculated, and the upper triangle is ignored. If False and a
+        function is provided as the "metric" parameter, then it will compute an
+        asymmetric distance matrix, and the entire matrix (including both upper and
+        lower triangles) is returned.
     kwargs : Any
         Extra arguments for metric. Refer to each metric documentation for a list of
         possible arguments.
@@ -419,8 +434,14 @@ def pairwise_distance(
         )
     elif metric == "sbd":
         return sbd_pairwise_distance(x, y, kwargs.get("standardize", True))
+    elif metric == "shift_scale":
+        return shift_scale_invariant_pairwise_distance(
+            x, y, kwargs.get("max_shift", None)
+        )
     else:
         if isinstance(metric, Callable):
+            if y is None and not symmetric:
+                return _custom_func_pairwise(x, x, metric, **kwargs)
             return _custom_func_pairwise(x, y, metric, **kwargs)
         raise ValueError("Metric must be one of the supported strings or a callable")
 
@@ -804,6 +825,7 @@ def get_distance_function(metric: Union[str, DistanceFunction]) -> DistanceFunct
     'manhattan'     distances.manhattan_distance
     'minkowski'     distances.minkowski_distance
     'sbd'           distances.sbd_distance
+    'shift_scale'   distances.shift_scale_invariant_distance
     =============== ========================================
 
     Parameters
@@ -861,6 +883,7 @@ def get_pairwise_distance_function(
     'manhattan'     distances.manhattan_pairwise_distance
     'minkowski'     distances.minkowski_pairwise_distance
     'sbd'           distances.sbd_pairwise_distance
+    'shift_scale'   distances.shift_scale_invariant_pairwise_distance
     =============== ========================================
 
     Parameters
@@ -919,7 +942,7 @@ def get_alignment_path_function(metric: str) -> AlignmentPathFunction:
     Parameters
     ----------
     metric : str or Callable
-        The metric string to resolve to a alignment path function.
+        The metric string to resolve to an alignment path function.
 
     Returns
     -------
@@ -1114,6 +1137,11 @@ DISTANCES = [
         "distance": sbd_distance,
         "pairwise_distance": sbd_pairwise_distance,
     },
+    {
+        "name": "shift_scale",
+        "distance": shift_scale_invariant_distance,
+        "pairwise_distance": shift_scale_invariant_pairwise_distance,
+    },
 ]
 
 DISTANCES_DICT = {d["name"]: d for d in DISTANCES}
diff --git a/aeon/distances/_shift_scale_invariant.py b/aeon/distances/_shift_scale_invariant.py
new file mode 100644
index 00000000..4c99182c
--- /dev/null
+++ b/aeon/distances/_shift_scale_invariant.py
@@ -0,0 +1,321 @@
+"""Shift-invariant distance."""
+
+from typing import Optional, Union
+
+import numpy as np
+from numba import njit
+from numba.typed import List as NumbaList
+
+from aeon.distances._utils import _convert_to_list, _is_multivariate
+
+
+@njit(cache=True, fastmath=True)
+def shift_scale_invariant_distance(
+    x: np.ndarray, y: np.ndarray, max_shift: Optional[int] = None
+) -> float:
+    r"""Compute the shift and scale invariant distance [1]_ between two time series.
+
+    The shift and scale invariant distance is designed to compare two time series while
+    being robust to both shifts in time (translation) and changes in scale. The method
+    uses an optimisation process to find the best shift (``q``) and scale factor
+    (``α``) that minimises the distance between the two time series.
+
+    Given two time series, the second time series is shifted by a maximum of
+    ``max_shift`` time points to the left or right, and for each shift, a scaling
+    factor is computed that minimises the L2 norm between the scaled and shifted
+    version of the second time series and the first time series.
+
+    The optimal scale factor ``α`` is computed for a fixed shift ``q`` using a
+    closed-form solution derived from minimising the distance. The algorithm starts by
+    aligning the peaks of the two time series, and then adjusts the shift locally to
+    find the optimal ``q``. The shift and scale that yield the smallest distance are
+    returned as the final distance.
+
+    Formally the shift and scale invariant distance is defined as:
+
+    .. math::
+        \hat{d}(a, b) = \min_{\alpha, q} \frac{L_2(a, \alpha b_{(q)})}{L_2(a, a)}
+
+    where alpha is defined as:
+
+    .. math::
+        \alpha = \frac{L_2(a, b_{(q)})^2}{L_2(b_{(q)},b_{(q)})^2}
+
+
+    Parameters
+    ----------
+    x : np.ndarray
+        First time series, either univariate, shape ``(n_timepoints,)``, or
+        multivariate, shape ``(n_channels, n_timepoints)``.
+    y : np.ndarray
+        Second time series, either univariate, shape ``(n_timepoints,)``, or
+        multivariate, shape ``(n_channels, n_timepoints)``.
+    max_shift : int or None, default=None
+        Maximum shift allowed in the alignment path. If None, then max_shift is set
+        to min(x.shape[-1], y.shape[-1]).
+
+    Returns
+    -------
+    float
+        Shift-scale invariant distance between x and y, minimum value 0.
+
+    Raises
+    ------
+    ValueError
+        If x and y are not 1D or 2D arrays.
+
+    References
+    ----------
+    .. [1] J. Yang and J. Leskovec. Patterns of temporal variation in online media. In
+    Proc. of the fourth ACM international conf. on Web search and data mining, page
+    177. ACM, 2011.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> from aeon.distances import shift_scale_invariant_distance
+    >>> x = np.array([1., 2., 3., 4., 5., 6., 7., 8., 9., 10.])
+    >>> y = np.array([11., 12., 13., 14., 15., 16., 17., 18., 19., 20.])
+    >>> univ_dist = shift_scale_invariant_distance(x, y)
+
+    >>> x = np.array([[1., 2., 3., 4.], [6., 7., 8., 9.], [0., 1., 0., 2.]])
+    >>> y = np.array([[11., 12., 13., 14.], [3., 22., 5., 4.], [12., 3., 4., 5.]])
+    >>> multi_dist = shift_scale_invariant_distance(x, y)
+    """
+    if max_shift is None:
+        max_shift = min(x.shape[-1], y.shape[-1])
+
+    if x.ndim == 1 and y.ndim == 1:
+        dist, _ = _univariate_shift_scale_invariant_distance(x, y, max_shift)
+        return dist
+    if x.ndim == 2 and y.ndim == 2:
+        if x.shape[0] == 1 and y.shape[0] == 1:
+            dist, _ = _univariate_shift_scale_invariant_distance(
+                x[0, :], y[0, :], max_shift
+            )
+            return dist
+        else:
+            n_channels = min(x.shape[0], y.shape[0])
+            distance = 0.0
+            for i in range(n_channels):
+                dist, _ = _univariate_shift_scale_invariant_distance(
+                    x[i], y[i], max_shift
+                )
+                distance += dist
+
+            return distance
+    raise ValueError("x and y must be 1D or 2D")
+
+
+@njit(cache=True, fastmath=True)
+def _scale_d(x: np.ndarray, y: np.ndarray) -> float:
+    denominator = np.dot(y, y)
+
+    # Prevent divide by zero
+    if denominator == 0:
+        return float(np.finfo(np.float64).max)
+
+    alpha = np.dot(x, y) / denominator
+
+    norm_x = np.linalg.norm(x)
+    # Prevent divide by zero
+    if norm_x == 0:
+        return float(np.finfo(np.float64).max)
+
+    dist = np.linalg.norm(x - alpha * y) / norm_x
+
+    return dist
+
+
+@njit(cache=True, fastmath=True)
+def _univariate_shift_scale_invariant_distance(
+    x: np.ndarray, y: np.ndarray, max_shift: int
+) -> tuple[float, np.ndarray]:
+    if np.array_equal(x, y):
+        return 0, y
+    min_dist = _scale_d(x, y)
+    best_shifted_y = y
+
+    for sh in range(-max_shift, max_shift + 1):
+        if sh == 0:
+            shifted_y = y
+        elif sh < 0:
+            # Shift left
+            shifted_y = np.append(y[-sh:], np.zeros(-sh))
+        else:
+            # Shift right
+            shifted_y = np.append(np.zeros(sh), y[:-sh])
+
+        dist = _scale_d(x, shifted_y)
+
+        if dist < min_dist:
+            min_dist = dist
+            best_shifted_y = shifted_y
+
+    return min_dist, best_shifted_y
+
+
+def shift_scale_invariant_pairwise_distance(
+    X: Union[np.ndarray, list[np.ndarray]],
+    y: Optional[Union[np.ndarray, list[np.ndarray]]] = None,
+    max_shift: Optional[int] = None,
+) -> np.ndarray:
+    r"""Compute the shift-scale invariant pairwise distance between time series.
+
+    By default, this takes a collection of :math:`n` time series :math:`X` and returns a
+    matrix
+    :math:`D` where :math:`D_{i,j}` is the shift-scale distance between the
+    :math:`i^{th}` and the :math:`j^{th}` series in :math:`X`. If :math:`X` is 2
+    dimensional, it is assumed to be a collection of univariate series with shape
+    ``(n_cases, n_timepoints)``. If it is 3 dimensional, it is assumed to be shape
+    ``(n_cases, n_channels, n_timepoints)``.
+
+    This function has an optional argument, :math:`y`, to allow calculation of the
+    distance matrix between :math:`X` and one or more series stored in :math:`y`. If
+    :math:`y` is 1 dimensional, we assume it is a single univariate series and the
+    distance matrix returned is shape ``(n_cases,1)``. If it is 2D, we assume it
+    is a collection of univariate series with shape ``(m_cases, m_timepoints)``
+    and the distance ``(n_cases,m_cases)``. If it is 3 dimensional,
+    it is assumed to be shape ``(m_cases, m_channels, m_timepoints)``.
+
+    Parameters
+    ----------
+    X : np.ndarray or List of np.ndarray
+        A collection of time series instances  of shape ``(n_cases, n_timepoints)``
+        or ``(n_cases, n_channels, n_timepoints)``.
+    y : np.ndarray or List of np.ndarray or None, default=None
+        A single series or a collection of time series of shape ``(m_timepoints,)`` or
+        ``(m_cases, m_timepoints)`` or ``(m_cases, m_channels, m_timepoints)``.
+        If None, then the dtw pairwise distance between the instances of X is
+        calculated.
+    max_shift : int or None, default=None
+        Maximum shift allowed in the alignment path. If None, then max_shift is set
+        to min(X.shape[-1], y.shape[-1]) or if y is None, max_shift is set to
+        X.shape[-1].
+
+    Returns
+    -------
+    np.ndarray
+        Shift-scale invariant  pairwise matrix between the instances of X of shape
+        ``(n_cases, n_cases)`` or between X and y of shape ``(n_cases,
+        n_cases)``.
+
+    Raises
+    ------
+    ValueError
+        If X is not 2D or 3D array and if y is not 1D, 2D or 3D arrays when passing y.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> from aeon.distances import shift_scale_invariant_pairwise_distance
+    >>> # Distance between each time series in a collection of time series
+    >>> X = np.array([[[1., 2., 3.]],[[4., 5., 6.]], [[7., 8., 9.]]])
+    >>> pw_self = shift_scale_invariant_pairwise_distance(X)
+
+    >>> # Distance between two collections of time series
+    >>> X = np.array([[[1., 2., 3.]],[[4., 5., 6.]], [[7., 8., 9.]]])
+    >>> y = np.array([[[11., 12., 13.]],[[14., 15., 16.]], [[17., 18., 19.]]])
+    >>> pw = shift_scale_invariant_pairwise_distance(X, y)
+
+    >>> X = np.array([[[1., 2., 3.]],[[4., 5., 6.]], [[7., 8., 9.]]])
+    >>> y_univariate = np.array([11., 12., 13.])
+    >>> single_pw =shift_scale_invariant_pairwise_distance(X, y_univariate)
+    """
+    if max_shift is None:
+        if y is None:
+            max_shift = X.shape[-1]
+        else:
+            max_shift = min(X.shape[-1], y.shape[-1])
+    multivariate_conversion = _is_multivariate(X, y)
+    _X, _ = _convert_to_list(X, "", multivariate_conversion)
+
+    if y is None:
+        return _shift_invariant_pairwise_distance(_X, _X, max_shift)
+
+    _y, _ = _convert_to_list(y, "y", multivariate_conversion)
+    return _shift_invariant_pairwise_distance(_X, _y, max_shift)
+
+
+def shift_scale_invariant_best_shift(
+    x: np.ndarray, y: np.ndarray, max_shift: int = None
+) -> tuple[float, np.ndarray]:
+    """Compute the best shift for the shift-invariant distance.
+
+    This function computes the best shift for the shift-invariant distance between
+    two time series. The best shift value returned is the shift of y that minimises the
+    distance between x and y.
+
+    Parameters
+    ----------
+    x : np.ndarray
+        First time series, either univariate, shape ``(n_timepoints,)``, or
+        multivariate, shape ``(n_channels, n_timepoints)``.
+    y : np.ndarray
+        Second time series, either univariate, shape ``(n_timepoints,)``, or
+        multivariate, shape ``(n_channels, n_timepoints)``.
+    max_shift : int or None, default=None
+        Maximum shift allowed in the alignment path. If None, then max_shift is set
+        to min(x.shape[1], y.shape[1]).
+
+    Returns
+    -------
+    float
+        Shift-scale invariant distance between x and y, minimum value 0.
+    np.ndarray
+        Shifts of y that minimise the distance between x and y.
+
+    Raises
+    ------
+    ValueError
+        If x and y are not 1D or 2D arrays.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> from aeon.distances import shift_scale_invariant_best_shift
+    >>> x = np.array([1., 2., 3., 4., 5., 6., 7., 8., 9., 10.])
+    >>> y = np.array([11., 12., 13., 14., 15., 16., 17., 18., 19., 20.])
+    >>> univ_dist, univ_shift = shift_scale_invariant_best_shift(x, y)
+
+    >>> x = np.array([[1., 2., 3., 4.], [6., 7., 8., 9.], [0., 1., 0., 2.]])
+    >>> y = np.array([[11., 12., 13., 14.],[7., 8., 9., 20.],[1., 3., 4., 5.]])
+    >>> multi_dist, multi_shift = shift_scale_invariant_best_shift(x, y)
+    """
+    if max_shift is None:
+        max_shift = min(x.shape[-1], y.shape[-1])
+    if x.ndim == 1 and y.ndim == 1:
+        return _univariate_shift_scale_invariant_distance(x, y, max_shift)
+    if x.ndim == 2 and y.ndim == 2:
+        if x.shape[0] == 1 and y.shape[0] == 1:
+            return _univariate_shift_scale_invariant_distance(
+                x[0, :], y[0, :], max_shift
+            )
+        else:
+            n_channels = min(x.shape[0], y.shape[0])
+            distance = 0.0
+            best_shift = np.zeros((n_channels, y.shape[1]))
+            for i in range(n_channels):
+                dist, curr_shift = _univariate_shift_scale_invariant_distance(
+                    x[i], y[i], max_shift
+                )
+                best_shift[i] = curr_shift
+                distance += dist
+
+            return distance, best_shift
+
+    raise ValueError("x and y must be 1D or 2D")
+
+
+@njit(cache=True, fastmath=True)
+def _shift_invariant_pairwise_distance(
+    x: NumbaList[np.ndarray], y: NumbaList[np.ndarray], max_shift: int
+) -> np.ndarray:
+    n_cases = len(x)
+    m_cases = len(y)
+    distances = np.zeros((n_cases, m_cases))
+
+    for i in range(n_cases):
+        for j in range(m_cases):
+            distances[i, j] = shift_scale_invariant_distance(x[i], y[j], max_shift)
+    return distances
diff --git a/aeon/transformations/base.py b/aeon/transformations/base.py
index a4e8d693..39006321 100644
--- a/aeon/transformations/base.py
+++ b/aeon/transformations/base.py
@@ -1,22 +1,14 @@
 """Base class for transformers."""
 
-__maintainer__ = ["TonyBagnall"]
+__maintainer__ = ["MatthewMiddlehurst", "TonyBagnall"]
 __all__ = ["BaseTransformer"]
 
+from abc import ABC, abstractmethod
 
 from aeon.base import BaseEstimator
-from aeon.utils.validation._dependencies import _check_estimator_deps
 
 
-def _coerce_to_list(obj):
-    """Return [obj] if obj is not a list, otherwise obj."""
-    if not isinstance(obj, list):
-        return [obj]
-    else:
-        return obj
-
-
-class BaseTransformer(BaseEstimator):
+class BaseTransformer(BaseEstimator, ABC):
     """Transformer base class."""
 
     # default tag values - these typically make the "safest" assumption
@@ -42,29 +34,10 @@ class BaseTransformer(BaseEstimator):
         "remember_data": False,  # whether all data seen is remembered as self._X
     }
 
-    # allowed types for transformers - Series and Collections
-    ALLOWED_INPUT_TYPES = [
-        "pd.Series",
-        "pd.DataFrame",
-        "np.ndarray",
-        "nested_univ",
-        "numpy3D",
-        # "numpy2D",
-        "pd-multiindex",
-        # "pd-wide",
-        # "pd-long",
-        "df-list",
-        "np-list",
-        "pd_multiindex_hier",
-    ]
-
-    def __init__(self, _output_convert="auto"):
-        self._converter_store_X = dict()  # storage dictionary for in/output conversion
-        self._output_convert = _output_convert
-
+    def __init__(self):
         super().__init__()
-        _check_estimator_deps(self)
 
+    @abstractmethod
     def fit(self, X, y=None):
         """Fit transformer to X, optionally to y.
 
@@ -88,12 +61,9 @@ class BaseTransformer(BaseEstimator):
         -------
         self : a fitted instance of the estimator
         """
-        self._fit(X=X, y=y)
-        # this should happen last: fitted state is set to True
-        self._is_fitted = True
-
-        return self
+        ...
 
+    @abstractmethod
     def transform(self, X, y=None):
         """Transform X and return a transformed version.
 
@@ -113,12 +83,9 @@ class BaseTransformer(BaseEstimator):
         y : Series, default=None
             Additional data, e.g., labels for transformation.
         """
-        # check whether is fitted
-        self.check_is_fitted()
-
-        Xt = self._transform(X=X, y=y)
-        return Xt
+        ...
 
+    @abstractmethod
     def fit_transform(self, X, y=None):
         """Fit to data, then transform it.
 
@@ -134,209 +101,4 @@ class BaseTransformer(BaseEstimator):
         y : Series, default=None
             Additional data, e.g., labels for transformation.
         """
-        Xt = self._fit_transform(X=X, y=y)
-        return Xt
-
-    def inverse_transform(self, X, y=None):
-        """Inverse transform X and return an inverse transformed version.
-
-        State required:
-            Requires state to be "fitted".
-
-        Accesses in self:
-        _is_fitted : must be True
-
-        Parameters
-        ----------
-        X : Series or Collection, any supported type
-            Data to fit transform to, of python type as follows:
-                Series: 2D np.ndarray shape (n_channels, n_timepoints)
-                Collection: 3D np.ndarray shape (n_cases, n_channels, n_timepoints)
-                or list of 2D np.ndarray, case i has shape (n_channels, n_timepoints_i)
-        y : Series, default=None
-            Additional data, e.g., labels for transformation.
-
-        Returns
-        -------
-        inverse transformed version of X
-            of the same type as X,
-        """
-        if self.get_tag("skip-inverse-transform"):
-            return X
-
-        if not self.get_tag("capability:inverse_transform"):
-            raise NotImplementedError(
-                f"{type(self)} does not implement inverse_transform"
-            )
-
-        # check whether is fitted
-        self.check_is_fitted()
-        Xt = self._inverse_transform(X=X, y=y)
-        return Xt
-
-    def update(self, X, y=None, update_params=True):
-        """Update transformer with X, optionally y.
-
-        State required:
-            Requires state to be "fitted".
-
-        Accesses in self:
-        _is_fitted : must be True
-
-        Parameters
-        ----------
-        X : Series or Collection, any supported type
-            Data to fit transform to, of python type as follows:
-                Series: 2D np.ndarray shape (n_channels, n_timepoints)
-                Collection: 3D np.ndarray shape (n_cases, n_channels, n_timepoints)
-                or list of 2D np.ndarray, case i has shape (n_channels, n_timepoints_i)
-        y : Series, default=None
-            Additional data, e.g., labels for transformation.
-
-        Returns
-        -------
-        self : a fitted instance of the estimator
-        """
-        # check whether is fitted
-        self.check_is_fitted()
-
-        # if requires_y is set, y is required in fit and update
-        if self.get_tag("requires_y") and y is None:
-            raise ValueError(f"{self.__class__.__name__} requires `y` in `update`.")
-
-        self._update(X=X, y=y)
-
-        return self
-
-    def get_fitted_params(self, deep=True):
-        """Get fitted parameters.
-
-        State required:
-            Requires state to be "fitted".
-
-        Parameters
-        ----------
-        deep : bool, default=True
-            Whether to return fitted parameters of components.
-
-            * If True, will return a dict of parameter name : value for this object,
-              including fitted parameters of fittable components
-              (= BaseEstimator-valued parameters).
-            * If False, will return a dict of parameter name : value for this object,
-              but not include fitted parameters of components.
-
-        Returns
-        -------
-        fitted_params : dict with str-valued keys
-            Dictionary of fitted parameters, paramname : paramvalue
-            keys-value pairs include:
-        """
-        return super().get_fitted_params(deep=deep)
-
-    def _fit(self, X, y=None):
-        """Fit transformer to X and y.
-
-        private _fit containing the core logic, called from fit
-
-        Parameters
-        ----------
-        X: data structure of type X_inner_type
-            if X_inner_type is list, _fit must support all types in it
-            Data to fit transform to
-        y : Series, default=None
-            Additional data, e.g., labels for transformation.
-
-        Returns
-        -------
-        self: a fitted instance of the estimator
-        """
-        # default fit is "no fitting happens"
-        return self
-
-    def _transform(self, X, y=None):
-        """Transform X and return a transformed version.
-
-        private _transform containing the core logic, called from transform
-
-        Parameters
-        ----------
-        X: data structure of type X_inner_type
-            if X_inner_type is list, _fit must support all types in it
-            Data to fit transform to
-        y : Series, default=None
-            Additional data, e.g., labels for transformation.
-
-        Returns
-        -------
-        transformed version of X
-        """
-        raise NotImplementedError("abstract method")
-
-    def _fit_transform(self, X, y=None):
-        """Fit to data, then transform it.
-
-        Fits the transformer to X and y and returns a transformed version of X.
-
-        private _fit_transform containing the core logic, called from fit_transform
-
-        Parameters
-        ----------
-        X: data structure of type X_inner_type
-            if X_inner_type is list, _fit_transform must support all types in it
-            Data to fit transform to
-        y : Series, default=None
-            Additional data, e.g., labels for transformation.
-
-        Returns
-        -------
-        self: a fitted instance of the estimator
-        """
-        # Non-optimized default implementation; override when a better
-        # method is possible for a given algorithm.
-        self._fit(X, y)
-        return self._transform(X, y)
-
-    def _inverse_transform(self, X, y=None):
-        """Inverse transform X and return an inverse transformed version.
-
-        private _inverse_transform containing core logic, called from inverse_transform
-
-        Parameters
-        ----------
-        X: data structure of type X_inner_type
-            if X_inner_type is list, _inverse_transform must support all types in it
-            Data to be transformed
-        y : Series, default=None
-            Additional data, e.g., labels for transformation.
-
-
-        Returns
-        -------
-        inverse transformed version of X
-            of the same type as X, and conforming to type format specifications
-
-        See extension_templates/transformer.py for implementation details.
-        """
-        raise NotImplementedError("abstract method")
-
-    def _update(self, X, y=None):
-        """Update transformer with X and y.
-
-        private _update containing the core logic, called from update
-
-        Parameters
-        ----------
-        X: data structure of type X_inner_type
-            if X_inner_type is list, _update must support all types in it
-            Data to update transformer with
-        y : Series, default=None
-            Additional data, e.g., labels for tarnsformation
-
-        Returns
-        -------
-        self: a fitted instance of the estimator
-
-        See extension_templates/transformer.py for implementation details.
-        """
-        # standard behaviour: no update takes place, new data is ignored
-        return self
+        ...
diff --git a/examples/datasets/datasets.ipynb b/examples/datasets/datasets.ipynb
index dfad5eac..fd349c37 100644
--- a/examples/datasets/datasets.ipynb
+++ b/examples/datasets/datasets.ipynb
@@ -5,9 +5,9 @@
    "source": [
     "# Storing and Loading data in `aeon`\n",
     "\n",
-    "Getting data into the correct data structure is fundamental. This notebook\n",
-    "describes the datastructures used in `aeon` and links to more complex use cases.\n",
-    "models two types of abstract data. Single series and collections of series.\n",
+    "Getting data into the correct data structure is fundamental. This notebook describes\n",
+    "the data structures used in `aeon` and links to more complex use cases. `aeon` models\n",
+    "abstract data types: single series and collections of series.\n",
     "\n",
     "A single time series can be univariate (each observation is a single value) or\n",
     "multivariate (each observation is a vector). We say that the length of the vector\n",
@@ -15,22 +15,43 @@
     "The length of the series is called  the number of timepoints, or `n_timepoints` in\n",
     "code. We generally store a single series\n",
     "in a 2D numpy array with shape ``(n_channels, n_timepoints)``. Series estimators\n",
-    "should work with a univariate series stored as a 1D numpy array, but will internally convert to 2D.\n",
+    "should work with a univariate series stored as a 1D numpy array, but will internally convert to 2D."
+   ],
+   "metadata": {
+    "collapsed": false
+   }
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "(1, 100)\n",
+      "(3, 200)\n",
+      "(10, 1, 50)\n",
+      "(5, 26, 100)\n"
+     ]
+    }
+   ],
+   "source": [
+    "import numpy as np\n",
     "\n",
-    "A collection consists of a group of time series, ideally assumed to be independent of\n",
-    " each other. Each is also called a case or instance, and a collection contains a\n",
-    " number of cases, denoted ``n_cases``. A  collection of equal length time series is\n",
-    " internally stored in a 3D numpy array of shape ``(n_cases, n_channels, n_timepoints)\n",
-    " ``. Collection estimators will work with a univariate collection of shape ``\n",
-    " (n_cases, n_timepoints)`` which internally is converted to ``(n_cases, 1,\n",
-    " n_timepoints)``.  Like ``scikit-learn``, we refer to a\n",
-    " collection of cases as ``X``. Supervised learners (e.g. classifiers and regressors)\n",
-    " require a target variable for training."
+    "X = np.random.random((1, 100))  # Univariate series length 100\n",
+    "print(X.shape)\n",
+    "X = np.random.random((3, 200))  # three channel multivariate series length 200\n",
+    "print(X.shape)\n",
+    "X = np.random.random((10, 1, 50))  # Collection of 10 univariate series of length 50\n",
+    "print(X.shape)\n",
+    "X = np.random.random((5, 26, 100))  # Collection of 5 multivariate time series with 26\n",
+    "# channels, length 100\n",
+    "print(X.shape)"
    ],
    "metadata": {
     "collapsed": false
-   },
-   "outputs": []
+   }
   },
   {
    "cell_type": "markdown",
@@ -40,10 +61,72 @@
     "We get asked this a lot. Packages like tensorflow assume  ``(n_cases, n_timepoints,\n",
     "n_channels)`` rather than ``(n_cases, n_channels, n_timepoints)``. tl;dr: its a\n",
     "decision we made early on because many estimators iterate over channels, and we are\n",
-    "not changing it now. Its simple to reshape.\n",
+    "not changing it now. It is simple to reshape.\n"
+   ],
+   "metadata": {
+    "collapsed": false
+   }
+  },
+  {
+   "cell_type": "markdown",
+   "source": [
+    "Supervised collection algorithms (e.g. classifiers , regressors and some collection\n",
+    "transformersd) require a target variable for training. In keeping with\n",
+    "`scikit-learn`, we denote the target `y`. For classification, `y` should be a numpy\n",
+    "array of strings or integers. For regression, `y` should be a numpy array of integers\n",
+    " or floats. The `DummyClassifier` used below simply predicts the majority class in\n",
+    " the train data. The `DummyRegressor` predicts the mean of the training target variable."
+   ],
+   "metadata": {
+    "collapsed": false
+   }
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "['run' 'run' 'run' 'run' 'run']\n",
+      "[1 1 1 1 1]\n",
+      "[0.6 0.6 0.6 0.6 0.6]\n",
+      "[0.8899 0.8899 0.8899 0.8899 0.8899]\n"
+     ]
+    }
+   ],
+   "source": [
+    "import numpy as np\n",
+    "\n",
+    "from aeon.classification import DummyClassifier\n",
+    "from aeon.regression import DummyRegressor\n",
+    "\n",
+    "X = np.random.random((5, 1, 10))  # Collection of five series\n",
+    "y1 = np.array([\"run\", \"walk\", \"run\", \"run\", \"walk\"])\n",
+    "y2 = np.array([1, 0, 1, 1, 0])\n",
+    "y3 = np.array([0.5, 1.11, 0.545, -0.3675, 2.662])\n",
+    "cls = DummyClassifier()\n",
+    "reg = DummyRegressor()\n",
+    "cls.fit(X, y1)\n",
+    "print(cls.predict(X))\n",
+    "cls.fit(X, y2)\n",
+    "print(cls.predict(X))\n",
+    "reg.fit(X, y2)\n",
+    "print(reg.predict(X))\n",
+    "reg.fit(X, y3)\n",
+    "print(reg.predict(X))"
+   ],
+   "metadata": {
+    "collapsed": false
+   }
+  },
+  {
+   "cell_type": "markdown",
+   "source": [
     "\n",
     "If your collection of series are unequal length, we store them in a list of 2D numpy\n",
-    "arrays. See the [unequal length collections](provided_unequal.ipynb). aeon does not\n",
+    "arrays. See the [unequal length collections](data_unequal.ipynb). aeon does not\n",
     "currently support single series with unequal length channels.\n",
     "\n",
     "`aeon` ships with a range of datasets used in examples and testing. The [provided\n",
@@ -56,11 +139,18 @@
     "You can load data directly from the [Time Series Machine Learning\n",
     "archive](https://timeseriesclassification.com/) and the\n",
     "[Monash time series forecasting](https://forecastingdata.org/)\n",
-    "sites. More details in the [load from web notebook](load_data_from_web.ipynb).\n"
+    "sites. More details in the [load from web notebook](load_data_from_web.ipynb)."
    ],
    "metadata": {
     "collapsed": false
    }
+  },
+  {
+   "cell_type": "markdown",
+   "source": [],
+   "metadata": {
+    "collapsed": false
+   }
   }
  ],
  "metadata": {


Test patch:
diff --git a/aeon/base/estimator/interval_based/tests/test_base_interval_forest.py b/aeon/base/estimator/interval_based/tests/test_base_interval_forest.py
index 63b5b4e9..fd0f20f8 100644
--- a/aeon/base/estimator/interval_based/tests/test_base_interval_forest.py
+++ b/aeon/base/estimator/interval_based/tests/test_base_interval_forest.py
@@ -100,7 +100,7 @@ def test_interval_forest_n_intervals(n_intervals, n_intervals_len):
         series_transformers=[None, FunctionTransformer(np.log1p)],
         random_state=0,
     )
-    est._unit_test_flag = True
+    est.__unit_test_flag = True
     est.fit(X, y)
     est.predict_proba(X)
 
@@ -144,7 +144,7 @@ def test_interval_forest_attribute_subsample(features, output_len):
         replace_nan=0,
         random_state=0,
     )
-    est._unit_test_flag = True
+    est.__unit_test_flag = True
     est.fit(X, y)
     est.predict_proba(X)
 
@@ -184,7 +184,7 @@ def test_interval_forest_series_transformer(series_transformer):
         series_transformers=series_transformer,
         random_state=0,
     )
-    est._unit_test_flag = True
+    est.__unit_test_flag = True
     est.fit(X, y)
     est.predict_proba(X)
 
diff --git a/aeon/distances/tests/test_distances.py b/aeon/distances/tests/test_distances.py
index 30c6f190..3cf2533b 100644
--- a/aeon/distances/tests/test_distances.py
+++ b/aeon/distances/tests/test_distances.py
@@ -12,7 +12,10 @@ from aeon.distances._distance import (
     _custom_func_pairwise,
     _resolve_key_from_distance,
 )
-from aeon.distances.tests.test_utils import SINGLE_POINT_NOT_SUPPORTED_DISTANCES
+from aeon.distances.tests.test_utils import (
+    SINGLE_POINT_NOT_SUPPORTED_DISTANCES,
+    UNEQUAL_LENGTH_NOT_SUPPORTED_DISTANCES,
+)
 from aeon.testing.data_generation._legacy import make_series
 from aeon.testing.expected_results.expected_distance_results import (
     _expected_distance_results,
@@ -93,32 +96,33 @@ def test_distances(dist):
     )
 
     # ================== Test unequal length ==================
-    # Test univariate unequal length of shape (n_timepoints,)
-    _validate_distance_result(
-        make_series(5, return_numpy=True, random_state=1),
-        make_series(10, return_numpy=True, random_state=2),
-        dist["name"],
-        dist["distance"],
-        _expected_distance_results[dist["name"]][3],
-    )
+    if dist["name"] not in UNEQUAL_LENGTH_NOT_SUPPORTED_DISTANCES:
+        # Test univariate unequal length of shape (n_timepoints,)
+        _validate_distance_result(
+            make_series(5, return_numpy=True, random_state=1),
+            make_series(10, return_numpy=True, random_state=2),
+            dist["name"],
+            dist["distance"],
+            _expected_distance_results[dist["name"]][3],
+        )
 
-    # Test univariate unequal length of shape (1, n_timepoints)
-    _validate_distance_result(
-        make_series(5, 1, return_numpy=True, random_state=1),
-        make_series(10, 1, return_numpy=True, random_state=2),
-        dist["name"],
-        dist["distance"],
-        _expected_distance_results[dist["name"]][3],
-    )
+        # Test univariate unequal length of shape (1, n_timepoints)
+        _validate_distance_result(
+            make_series(5, 1, return_numpy=True, random_state=1),
+            make_series(10, 1, return_numpy=True, random_state=2),
+            dist["name"],
+            dist["distance"],
+            _expected_distance_results[dist["name"]][3],
+        )
 
-    # Test multivariate unequal length of shape (n_channels, n_timepoints)
-    _validate_distance_result(
-        make_series(5, 10, return_numpy=True, random_state=1),
-        make_series(10, 10, return_numpy=True, random_state=2),
-        dist["name"],
-        dist["distance"],
-        _expected_distance_results[dist["name"]][4],
-    )
+        # Test multivariate unequal length of shape (n_channels, n_timepoints)
+        _validate_distance_result(
+            make_series(5, 10, return_numpy=True, random_state=1),
+            make_series(10, 10, return_numpy=True, random_state=2),
+            dist["name"],
+            dist["distance"],
+            _expected_distance_results[dist["name"]][4],
+        )
 
     # ============== Test single point series ==============
     if dist["name"] not in SINGLE_POINT_NOT_SUPPORTED_DISTANCES:
diff --git a/aeon/distances/tests/test_miscellaneous_distances.py b/aeon/distances/tests/test_miscellaneous_distances.py
new file mode 100644
index 00000000..319622f3
--- /dev/null
+++ b/aeon/distances/tests/test_miscellaneous_distances.py
@@ -0,0 +1,37 @@
+"""Test the miscellaneous distance functions."""
+
+import numpy as np
+from numpy.ma.testutils import assert_almost_equal
+
+from aeon.distances import (
+    shift_scale_invariant_best_shift,
+    shift_scale_invariant_distance,
+)
+from aeon.testing.data_generation import make_example_2d_numpy_series
+
+
+def test_shift_scale_invariant_distance():
+    """Test the shift-scale invariant distance shift function."""
+    univ_x = make_example_2d_numpy_series(n_channels=1, n_timepoints=10, random_state=1)
+    univ_y = make_example_2d_numpy_series(n_channels=1, n_timepoints=10, random_state=2)
+
+    multi_x = make_example_2d_numpy_series(
+        n_channels=3, n_timepoints=10, random_state=1
+    )
+    multi_y = make_example_2d_numpy_series(
+        n_channels=3, n_timepoints=10, random_state=2
+    )
+
+    univariate_dist = shift_scale_invariant_distance(univ_x, univ_y)
+    multivariate_dist = shift_scale_invariant_distance(multi_x, multi_y)
+
+    univariate_shift = shift_scale_invariant_best_shift(univ_x, univ_y)
+    multivariate_shift = shift_scale_invariant_best_shift(multi_x, multi_y)
+
+    assert_almost_equal(univariate_dist, univariate_shift[0])
+    assert_almost_equal(multivariate_dist, multivariate_shift[0])
+
+    assert isinstance(univariate_shift[1], np.ndarray)
+    assert univariate_shift[1].shape == (10,)
+    assert isinstance(multivariate_shift[1], np.ndarray)
+    assert multivariate_shift[1].shape == (3, 10)
diff --git a/aeon/distances/tests/test_numba_distance_parameters.py b/aeon/distances/tests/test_numba_distance_parameters.py
index 9c420b33..4c607a45 100644
--- a/aeon/distances/tests/test_numba_distance_parameters.py
+++ b/aeon/distances/tests/test_numba_distance_parameters.py
@@ -33,6 +33,10 @@ def _test_distance_params(
     x_multi = make_series(10, 10, return_numpy=True, random_state=1)
     y_multi = make_series(10, 10, return_numpy=True, random_state=2)
 
+    if distance_str == "shift_scale":
+        # Shift it to test the max_shift parameter works
+        y_univ = np.roll(x_univ, 4)
+
     # Shape dtw needs parameters to be generated with the x and y so function used
     if distance_str == "shape_dtw":
         param_list.append(_generate_shape_dtw_params)
@@ -103,6 +107,7 @@ DIST_PARAMS = {
     "minkowski": [{"p": 1.0}, {"p": 2.0}],
     "sbd": [{"standardize": False}],
     "shape_dtw": BASIC_BOUNDING_PARAMS + [{"reach": 4}],
+    "shift_scale": [{"max_shift": 1}, {"max_shift": None}],
 }
 
 
diff --git a/aeon/distances/tests/test_pairwise.py b/aeon/distances/tests/test_pairwise.py
index a0405de9..f0e00a85 100644
--- a/aeon/distances/tests/test_pairwise.py
+++ b/aeon/distances/tests/test_pairwise.py
@@ -7,6 +7,7 @@ from numpy.testing import assert_almost_equal
 from aeon.distances import pairwise_distance as compute_pairwise_distance
 from aeon.distances._distance import DISTANCES
 from aeon.distances.tests.test_utils import (
+    ASYMMETRIC_DISTANCES,
     SINGLE_POINT_NOT_SUPPORTED_DISTANCES,
     _make_3d_series,
 )
@@ -35,14 +36,20 @@ def _validate_pairwise_result(
     distance: Distance function.
     pairwise_distance: Pairwise distance function.
     """
+    symmetric = not (name in ASYMMETRIC_DISTANCES)
     pairwise_result = pairwise_distance(x)
 
     expected_size = (len(x), len(x))
 
     assert isinstance(pairwise_result, np.ndarray)
     assert pairwise_result.shape == expected_size
-    assert_almost_equal(pairwise_result, compute_pairwise_distance(x, metric=name))
-    assert_almost_equal(pairwise_result, compute_pairwise_distance(x, metric=distance))
+    assert_almost_equal(
+        pairwise_result, compute_pairwise_distance(x, metric=name, symmetric=symmetric)
+    )
+    assert_almost_equal(
+        pairwise_result,
+        compute_pairwise_distance(x, metric=distance, symmetric=symmetric),
+    )
 
     if isinstance(x, np.ndarray):
         x = _make_3d_series(x)
@@ -146,6 +153,7 @@ def _validate_single_to_multiple_result(
     single_to_multiple_distance: Single to multiple distance function.
     run_inverse: Boolean that reruns the test with x and y swapped in position
     """
+    symmetric = not (name in ASYMMETRIC_DISTANCES)
     single_to_multiple_result = single_to_multiple_distance(x, y)
 
     expected_size = len(y)
@@ -169,10 +177,12 @@ def _validate_single_to_multiple_result(
     else:
         assert single_to_multiple_result.shape[1] == expected_size
     assert_almost_equal(
-        single_to_multiple_result, compute_pairwise_distance(x, y, metric=name)
+        single_to_multiple_result,
+        compute_pairwise_distance(x, y, metric=name, symmetric=symmetric),
     )
     assert_almost_equal(
-        single_to_multiple_result, compute_pairwise_distance(x, y, metric=distance)
+        single_to_multiple_result,
+        compute_pairwise_distance(x, y, metric=distance, symmetric=symmetric),
     )
 
     if len(x_shape) < len(y_shape):
@@ -189,7 +199,10 @@ def _validate_single_to_multiple_result(
         if curr_x.ndim == 1:
             curr_x = curr_x.reshape(1, -1)
 
-        dist = distance(curr_x, y)
+        if symmetric:
+            dist = distance(curr_x, y)
+        else:
+            dist = distance(y, curr_x)
         assert_almost_equal(dist, curr)
 
 
@@ -276,13 +289,14 @@ def test_multiple_to_multiple_distances(dist):
     """Test multiple to multiple distances."""
     # ================== Test equal length ==================
     # Test passing two singular univariate time series of shape (n_timepoints,)
-    _validate_multiple_to_multiple_result(
-        make_series(5, return_numpy=True, random_state=1),
-        make_series(5, return_numpy=True, random_state=2),
-        dist["name"],
-        dist["distance"],
-        dist["pairwise_distance"],
-    )
+    if dist["name"] != "scale_shift":
+        _validate_multiple_to_multiple_result(
+            make_series(5, return_numpy=True, random_state=1),
+            make_series(5, return_numpy=True, random_state=2),
+            dist["name"],
+            dist["distance"],
+            dist["pairwise_distance"],
+        )
 
     # Test passing two collections of univariate time series of shape
     # (n_cases, n_timepoints)
diff --git a/aeon/distances/tests/test_sklearn_compatibility.py b/aeon/distances/tests/test_sklearn_compatibility.py
index 0faa7b82..8752b7d7 100644
--- a/aeon/distances/tests/test_sklearn_compatibility.py
+++ b/aeon/distances/tests/test_sklearn_compatibility.py
@@ -17,7 +17,9 @@ from aeon.testing.data_generation import make_example_3d_numpy
 @pytest.mark.parametrize("dist", DISTANCES)
 def test_function_transformer(dist):
     """Test all distances work with FunctionTransformer in a pipeline."""
-    X = make_example_3d_numpy(n_cases=5, n_channels=1, n_timepoints=10, return_y=False)
+    X = make_example_3d_numpy(
+        n_cases=5, n_channels=1, n_timepoints=10, return_y=False, random_state=1
+    )
     distance = dist["distance"]
     pairwise = dist["pairwise_distance"]
     ft = FunctionTransformer(pairwise)
diff --git a/aeon/distances/tests/test_utils.py b/aeon/distances/tests/test_utils.py
index b8a5bf17..f8228840 100644
--- a/aeon/distances/tests/test_utils.py
+++ b/aeon/distances/tests/test_utils.py
@@ -16,6 +16,10 @@ from aeon.testing.data_generation._legacy import make_series
 
 SINGLE_POINT_NOT_SUPPORTED_DISTANCES = ["ddtw", "wddtw", "edr"]
 
+UNEQUAL_LENGTH_NOT_SUPPORTED_DISTANCES = ["shift_scale"]
+
+ASYMMETRIC_DISTANCES = ["shift_scale"]
+
 
 def test_incorrect_input():
     """Test util function incorrect input."""
diff --git a/aeon/testing/estimator_checking/_yield_estimator_checks.py b/aeon/testing/estimator_checking/_yield_estimator_checks.py
index 4097b659..b377ade2 100644
--- a/aeon/testing/estimator_checking/_yield_estimator_checks.py
+++ b/aeon/testing/estimator_checking/_yield_estimator_checks.py
@@ -391,7 +391,7 @@ def check_set_params_sklearn(estimator_class):
         # we construct the full parameter set for params
         # params may only have parameters that are deviating from defaults
         # in order to set non-default parameters back to defaults
-        params_full = estimator_class.get_param_defaults()
+        params_full = estimator.get_params(deep=False)
         params_full.update(params)
 
         msg = f"set_params of {estimator_class.__name__} does not return self"
diff --git a/aeon/testing/expected_results/expected_distance_results.py b/aeon/testing/expected_results/expected_distance_results.py
index e2ba2319..509d74f9 100644
--- a/aeon/testing/expected_results/expected_distance_results.py
+++ b/aeon/testing/expected_results/expected_distance_results.py
@@ -106,6 +106,11 @@ _expected_distance_results = {
         0.5967483689427071,
         0.6010116433765647,
     ],
+    "shift_scale": [
+        0.0,
+        0.4996725642773175,
+        4.558052881149513,
+    ],
 }
 
 _expected_distance_results_params = {
@@ -165,4 +170,8 @@ _expected_distance_results_params = {
         [20.36273099843225, 187.37458448152938],
         [20.36273099843225, 187.37458448152938],
     ],
+    "shift_scale": [
+        [0.34832898059464934, 4.558052881149513],
+        [0.34832898059464934, 4.558052881149513],
+    ],
 }