Repository: aeon-toolkit/aeon
Pull request #2141 (2024-10-03 16:34:00)

[MNT]Use Numpy Vectorization in ContinuosIntervalTree fit method #1345

#### Reference Issues/PRs

 #1345 

#### What does this implement/fix? Explain your changes.

Using numpy.unique to replace the original code as suggested in  #1345 

#### Does your contribution introduce a new dependency? If yes, which one?

No

#### Any other comments?

Thing goes well, and thank aeon to let me know the pipeline about a repo

### PR checklist

<!--
Please go through the checklist below. Please feel free to remove points if they are not applicable.
-->

##### For all contributions
- [x] I've added myself to the [list of contributors](https://github.com/aeon-toolkit/aeon/blob/main/.all-contributorsrc). Alternatively, you can use the [@all-contributors](https://allcontributors.org/docs/en/bot/usage) bot to do this for you.
- [x] The PR title starts with either [ENH], [MNT], [DOC], [BUG], [REF], [DEP] or [GOV] indicating whether the PR topic is related to enhancement, maintenance, documentation, bugs, refactoring, deprecation or governance.

##### For new estimators and functions
- [ ] I've added the estimator to the online [API documentation](https://www.aeon-toolkit.org/en/latest/api_reference.html).
- [ ] (OPTIONAL) I've added myself as a `__maintainer__` at the top of relevant files and want to be contacted regarding its maintenance. Unmaintained files may be removed. This is for the full file, and you should not add yourself if you are just making minor changes or do not want to help maintain its contents.

##### For developers with write access
- [ ] (OPTIONAL) I've updated aeon's [CODEOWNERS](https://github.com/aeon-toolkit/aeon/blob/main/CODEOWNERS) to receive notifications about future changes to these files.


<!--
Thanks for contributing!
-->


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

I did not find any labels to add based on the title. Please add the [ENH], [MNT], [BUG], [DOC], [REF], [DEP] and/or [GOV] tags to your pull requests titles. For now you can add the labels manually.
I have added the following labels to this PR based on the changes made: **[ $\\color{#BCAE15}{\\textsf{classification}}$ ]**. Feel free to change these if they do not properly represent the PR.

The [Checks](https://github.com/aeon-toolkit/aeon/pull/2141/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
    
MatthewMiddlehurst: Could you please fill out the PR template and give an informative title, thanks.
LinGinQiu: @all-contributors please add @LinGinQiu  for MNT#1345
allcontributors[bot]: @LinGinQiu 

I couldn't determine any contributions to add, did you specify any contributions?
        Please make sure to use [valid contribution names](https://allcontributors.org/docs/en/emoji-key).

Patch:
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/classification/sklearn/_continuous_interval_tree.py b/aeon/classification/sklearn/_continuous_interval_tree.py
index 0236afe9..05abcb73 100644
--- a/aeon/classification/sklearn/_continuous_interval_tree.py
+++ b/aeon/classification/sklearn/_continuous_interval_tree.py
@@ -403,9 +403,7 @@ class ContinuousIntervalTree(ClassifierMixin, BaseEstimator):
 
         thresholds = np.linspace(np.min(X, axis=0), np.max(X, axis=0), self.thresholds)
 
-        distribution = np.zeros(self.n_classes_)
-        for i in range(len(y)):
-            distribution[y[i]] += 1
+        _, distribution = np.unique(y, return_counts=True)
 
         entropy = _entropy(distribution, distribution.sum())
 
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


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],
+    ],
 }