Repository: aeon-toolkit/aeon
Pull request #457 (2023-05-26 12:13:05)

[BUG] Tslearn clusterers update
<!--
Thanks for contributing a pull request! Please ensure you have taken a look at our contribution guide: https://github.com/aeon-toolkit/aeon/blob/main/CONTRIBUTING.md

Feel free to delete sections of this template if they do not apply to your PR, avoid submitting a blank template or empty sections.
-->

#### Reference Issues/PRs

<!--
Example: Fixes #1234. See also #3456.

Please use keywords (e.g., Fixes) to create link to the issues or pull requests you resolved, so that they will automatically be closed when your pull request is merged. See https://github.com/blog/1506-closing-issues-via-pull-requests
-->

#### What does this implement/fix? Explain your changes.

The tslearn clusterers want the data to be passed to them in the format (n_instances, n_timepoints, n_channels). As
we use the format (n_instances, n_channels, n_timepoints) this meant for multivariate time series these clusterers
were not working. This fix address this by adding a reshape to ensure the correct format.

<!--
A clear and concise description of what you have implemented.
-->

#### Does your contribution introduce a new dependency? If yes, which one?

<!--
If your contribution does add a dependency, we may suggest adding it as an optional/soft dependency to keep external dependencies of the core aeon package to a minimum.
-->

#### What should a reviewer concentrate their feedback on?

<!--
This section is particularly useful if you have a pull request that is still in development. You can guide the reviews to focus on the parts that are ready for their comments. We suggest using bullets (indicated by * or -) and filled checkboxes [x] here
-->

#### Did you add any tests for the change?

I updated the tests and additionally made them faster.

<!--
This section is useful if you have added a test in addition to the existing ones. This will ensure that further changes to these files won't introduce the same kind of bug. It is considered good practice to add tests with newly added code to enforce the fact that the code actually works. This will reduce the chance of introducing logical bugs.
-->

#### Any other comments?

<!--
Please be aware that we are a loose team of volunteers so patience is necessary; assistance handling other issues is very welcome. We value all user contributions, no matter how minor they are. If we are slow to review, either the pull request needs some benchmarking, tinkering, convincing, etc. or more likely the reviewers are simply busy. In either case, we ask for your understanding during the review process.
-->

### PR checklist

<!--
Please go through the checklist below. Please feel free to remove points if they are not applicable.
-->

##### For all contributions
- [x] I've added myself to the [list of contributors](https://github.com/aeon-toolkit/aeon/blob/main/.all-contributorsrc).
- [x] Optionally, I've updated aeon's [CODEOWNERS](https://github.com/aeon-toolkit/aeon/blob/main/CODEOWNERS) to receive notifications about future changes to these files.
- [x] The PR title starts with either [ENH], [MNT], [DOC], or [BUG] indicating whether the PR topic is related to enhancement, maintenance, documentation, or bug.
- [x] For new estimators, I've added the estimator to the online documentation.


<!--
Thanks for contributing!
-->



Patch:
diff --git a/CODEOWNERS b/CODEOWNERS
index 01e4541b..02535633 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -51,7 +51,7 @@ aeon/networks/mlp.py @hadifawaz1999
 aeon/networks/inception.py @hadifawaz1999
 
 aeon/transformations/panel/dictionary_based/_paa.py @patrickzib @MatthewMiddlehurst
-aeon/transformations/panel/dictionary_based/_sax.py @patrickzib @MatthewMiddlehurst
+aeon/transformations/panel/dictionary_based/_sax.py @patrickzib @MatthewMiddlehurst @hadifawaz1999
 aeon/transformations/panel/dictionary_based/_sfa.py @patrickzib @MatthewMiddlehurst
 aeon/transformations/panel/dictionary_based/_sfa_fast.py @patrickzib @MatthewMiddlehurst
 aeon/transformations/panel/catch22.py @MatthewMiddlehurst
diff --git a/aeon/classification/distance_based/_elastic_ensemble.py b/aeon/classification/distance_based/_elastic_ensemble.py
index 71eb60aa..84456f7e 100644
--- a/aeon/classification/distance_based/_elastic_ensemble.py
+++ b/aeon/classification/distance_based/_elastic_ensemble.py
@@ -5,7 +5,7 @@ An ensemble of elastic nearest neighbour classifiers.
 """
 
 __author__ = ["jasonlines", "TonyBagnall"]
-__all__ = ["ElasticEnsemble", "series_slope_derivative"]
+__all__ = ["ElasticEnsemble"]
 
 import time
 from itertools import product
@@ -89,8 +89,11 @@ class ElasticEnsemble(BaseClassifier):
     """
 
     _tags = {
+        "capability:multivariate": True,
+        "capability:unequal_length": True,
         "capability:multithreading": True,
         "algorithm_type": "distance",
+        "X_inner_mtype": ["np-list", "numpy3D"],
     }
 
     def __init__(
@@ -123,9 +126,11 @@ class ElasticEnsemble(BaseClassifier):
 
         Parameters
         ----------
-        X : array-like of shape = [n_instances, n_channels, series_length]
+        X : np.ndarray of shape = (n_cases, n_channels, n_timepoints)
+            or list of [n_cases] np.ndarray shape (n_channels, n_timepoints_i)
             The training input samples.
-        y : array-like, shape = [n_instances] The class labels.
+
+        y : array-like, shape = (n_cases) The class labels.
 
         Returns
         -------
@@ -149,7 +154,11 @@ class ElasticEnsemble(BaseClassifier):
         if self._distance_measures.__contains__(
             "ddtw"
         ) or self._distance_measures.__contains__("wddtw"):
-            der_X = series_slope_derivative(X)
+            der_X = []  # use list to allow for unequal length
+            for x in X:
+                der_X.append(series_slope_derivative(x))
+            if isinstance(X, np.ndarray):
+                der_X = np.array(der_X)
         else:
             der_X = None
 
@@ -324,18 +333,22 @@ class ElasticEnsemble(BaseClassifier):
 
         Parameters
         ----------
-        X : 3D np.array of shape = [n_instances, 1, series_length]
-            The data to make predictions for.
+        X : 3D np.array of shape = (n_cases, n_channels, n_timepoints)
+            or list of [n_cases] numpy arrays size n_channels, n_timepoints_i)
 
         Returns
         -------
-        y : array-like, shape = [n_instances, n_classes_]
+        y : array-like, shape = (n_cases, n_classes_)
             Predicted probabilities using the ordering in classes_.
         """
         if self._distance_measures.__contains__(
             "ddtw"
         ) or self._distance_measures.__contains__("wddtw"):
-            der_X = series_slope_derivative(X)
+            der_X = []  # use list to allow for unequal length
+            for x in X:
+                der_X.append(series_slope_derivative(x))
+            if isinstance(X, np.ndarray):
+                der_X = np.array(der_X)
         else:
             der_X = None
 
@@ -366,12 +379,13 @@ class ElasticEnsemble(BaseClassifier):
 
         Parameters
         ----------
-        X : 3D np.array of shape = [n_instances, 1, series_length]
-            The data to make predictions for.
+        X : np.ndarray of shape = (n_cases, n_channels, n_timepoints)
+            or list of [n_cases] np.ndarray shape (n_channels, n_timepoints_i)
+            The training input samples.
 
         Returns
         -------
-        y : array-like, shape = [n_instances]
+        y : array-like, shape = (n_cases) The class labels.
             Predicted class labels.
         """
         probas = self._predict_proba(X)
@@ -392,7 +406,7 @@ class ElasticEnsemble(BaseClassifier):
         }
 
     @staticmethod
-    def _get_100_param_options(distance_measure, train_x=None, data_dim_to_use=0):
+    def _get_100_param_options(distance_measure, train_x=None):
         def get_inclusive(min_val, max_val, num_vals):
             inc = (max_val - min_val) / (num_vals - 1)
             return np.arange(min_val, max_val + inc / 2, inc)
@@ -402,6 +416,8 @@ class ElasticEnsemble(BaseClassifier):
         elif distance_measure == "wdtw" or distance_measure == "wddtw":
             return {"distance_params": [{"g": x / 100} for x in range(0, 100)]}
         elif distance_measure == "lcss":
+            if train_x is None:
+                raise ValueError(f"Error need train data for {distance_measure}")
             train_std = np.std(train_x)
             epsilons = get_inclusive(train_std * 0.2, train_std, 10)
             deltas = get_inclusive(0, 0.25, 10)
@@ -412,6 +428,8 @@ class ElasticEnsemble(BaseClassifier):
                 ]
             }
         elif distance_measure == "erp":
+            if train_x is None:
+                raise ValueError(f"Error need train data for {distance_measure}")
             train_std = np.std(train_x)
             band_sizes = get_inclusive(0, 0.25, 10)
             g_vals = get_inclusive(train_std * 0.2, train_std, 10)
diff --git a/aeon/classification/distance_based/_time_series_neighbors.py b/aeon/classification/distance_based/_time_series_neighbors.py
index 4290bb53..e2f5a72e 100644
--- a/aeon/classification/distance_based/_time_series_neighbors.py
+++ b/aeon/classification/distance_based/_time_series_neighbors.py
@@ -102,7 +102,7 @@ class KNeighborsTimeSeriesClassifier(BaseClassifier):
         Parameters
         ----------
         X : 3D np.ndarray of shape = (n_cases, n_channels, n_timepoints) or list of
-        shape[n_cases] of 2D arrays shape (n_channels,n_timepoints_i)
+        shape [n_cases] of 2D arrays shape (n_channels,n_timepoints_i)
                 If the series are all equal length, a numpy3D will be passed. If
                 unequal, a list of 2D numpy arrays is passed, which may have
                 different lengths.
diff --git a/aeon/clustering/k_shapes.py b/aeon/clustering/k_shapes.py
index 32f3da6a..724460e0 100644
--- a/aeon/clustering/k_shapes.py
+++ b/aeon/clustering/k_shapes.py
@@ -86,9 +86,9 @@ class TimeSeriesKShapes(BaseClusterer):
 
         Parameters
         ----------
-        X : np.ndarray (2d or 3d array of shape (n_instances, series_length) or shape
-            (n_instances, n_dimensions, series_length))
-            Training time series instances to cluster.
+        X: np.ndarray, of shape (n_instances, n_channels, n_timepoints) or
+                (n_instances, n_timepoints)
+            A collection of time series instances.
         y: ignored, exists for API consistency reasons.
 
         Returns
@@ -98,19 +98,19 @@ class TimeSeriesKShapes(BaseClusterer):
         """
         from tslearn.clustering import KShape
 
-        if self._tslearn_k_shapes is None:
-            self._tslearn_k_shapes = KShape(
-                # n_clusters=self.n_clusters,
-                n_clusters=3,
-                max_iter=self.max_iter,
-                tol=self.tol,
-                random_state=self.random_state,
-                n_init=self.n_init,
-                verbose=self.verbose,
-                init=self.init_algorithm,
-            )
-
-        self._tslearn_k_shapes.fit(X)
+        self._tslearn_k_shapes = KShape(
+            n_clusters=self.n_clusters,
+            max_iter=self.max_iter,
+            tol=self.tol,
+            random_state=self.random_state,
+            n_init=self.n_init,
+            verbose=self.verbose,
+            init=self.init_algorithm,
+        )
+
+        _X = X.swapaxes(1, 2)
+
+        self._tslearn_k_shapes.fit(_X)
         self._cluster_centers = self._tslearn_k_shapes.cluster_centers_
         self.labels_ = self._tslearn_k_shapes.labels_
         self.inertia_ = self._tslearn_k_shapes.inertia_
@@ -121,9 +121,9 @@ class TimeSeriesKShapes(BaseClusterer):
 
         Parameters
         ----------
-        X : np.ndarray (2d or 3d array of shape (n_instances, series_length) or shape
-            (n_instances, n_dimensions, series_length))
-            Time series instances to predict their cluster indexes.
+        X: np.ndarray, of shape (n_instances, n_channels, n_timepoints) or
+                (n_instances, n_timepoints)
+            A collection of time series instances.
         y: ignored, exists for API consistency reasons.
 
         Returns
@@ -131,7 +131,8 @@ class TimeSeriesKShapes(BaseClusterer):
         np.ndarray (1d array of shape (n_instances,))
             Index of the cluster each time series in X belongs to.
         """
-        return self._tslearn_k_shapes.predict(X)
+        _X = X.swapaxes(1, 2)
+        return self._tslearn_k_shapes.predict(_X)
 
     @classmethod
     def get_test_params(cls, parameter_set="default"):
diff --git a/aeon/clustering/kernel_k_means.py b/aeon/clustering/kernel_k_means.py
index ad76efa4..f6eb5924 100644
--- a/aeon/clustering/kernel_k_means.py
+++ b/aeon/clustering/kernel_k_means.py
@@ -107,9 +107,9 @@ class TimeSeriesKernelKMeans(BaseClusterer):
 
         Parameters
         ----------
-        X : np.ndarray (2d or 3d array of shape (n_instances, series_length) or shape
-            (n_instances, n_dimensions, series_length))
-            Training time series instances to cluster.
+        X: np.ndarray, of shape (n_instances, n_channels, n_timepoints) or
+                (n_instances, n_timepoints)
+            A collection of time series instances.
         y: ignored, exists for API consistency reasons.
 
         Returns
@@ -123,19 +123,20 @@ class TimeSeriesKernelKMeans(BaseClusterer):
         if self.verbose is True:
             verbose = 1
 
-        if self._tslearn_kernel_k_means is None:
-            self._tslearn_kernel_k_means = TsLearnKernelKMeans(
-                n_clusters=self.n_clusters,
-                kernel=self.kernel,
-                max_iter=self.max_iter,
-                tol=self.tol,
-                n_init=self.n_init,
-                kernel_params=self.kernel_params,
-                n_jobs=self.n_jobs,
-                verbose=verbose,
-                random_state=self.random_state,
-            )
-        self._tslearn_kernel_k_means.fit(X)
+        self._tslearn_kernel_k_means = TsLearnKernelKMeans(
+            n_clusters=self.n_clusters,
+            kernel=self.kernel,
+            max_iter=self.max_iter,
+            tol=self.tol,
+            n_init=self.n_init,
+            kernel_params=self.kernel_params,
+            n_jobs=self.n_jobs,
+            verbose=verbose,
+            random_state=self.random_state,
+        )
+
+        _X = X.swapaxes(1, 2)
+        self._tslearn_kernel_k_means.fit(_X)
         self.labels_ = self._tslearn_kernel_k_means.labels_
         self.inertia_ = self._tslearn_kernel_k_means.inertia_
         self.n_iter_ = self._tslearn_kernel_k_means.n_iter_
@@ -145,9 +146,9 @@ class TimeSeriesKernelKMeans(BaseClusterer):
 
         Parameters
         ----------
-        X : np.ndarray (2d or 3d array of shape (n_instances, series_length) or shape
-            (n_instances, n_dimensions, series_length))
-            Time series instances to predict their cluster indexes.
+        X: np.ndarray, of shape (n_instances, n_channels, n_timepoints) or
+                (n_instances, n_timepoints)
+            A collection of time series instances.
         y: ignored, exists for API consistency reasons.
 
         Returns
@@ -155,7 +156,8 @@ class TimeSeriesKernelKMeans(BaseClusterer):
         np.ndarray (1d array of shape (n_instances,))
             Index of the cluster each time series in X belongs to.
         """
-        return self._tslearn_kernel_k_means.predict(X)
+        _X = X.swapaxes(1, 2)
+        return self._tslearn_kernel_k_means.predict(_X)
 
     @classmethod
     def get_test_params(cls, parameter_set="default") -> Dict:
diff --git a/aeon/regression/distance_based/_time_series_neighbors.py b/aeon/regression/distance_based/_time_series_neighbors.py
index 362e9336..b5687dce 100644
--- a/aeon/regression/distance_based/_time_series_neighbors.py
+++ b/aeon/regression/distance_based/_time_series_neighbors.py
@@ -50,8 +50,8 @@ class KNeighborsTimeSeriesRegressor(BaseRegressor):
     --------
     >>> from aeon.datasets import load_unit_test
     >>> from aeon.regression.distance_based import KNeighborsTimeSeriesRegressor
-    >>> X_train, y_train = load_unit_test(return_X_y=True, split="train")
-    >>> X_test, y_test = load_unit_test(return_X_y=True, split="test")
+    >>> X_train, y_train = load_unit_test(split="train")
+    >>> X_test, y_test = load_unit_test(split="test")
     >>> regressor = KNeighborsTimeSeriesRegressor(distance="euclidean")
     >>> regressor.fit(X_train, y_train)
     KNeighborsTimeSeriesRegressor(...)
@@ -60,7 +60,9 @@ class KNeighborsTimeSeriesRegressor(BaseRegressor):
 
     _tags = {
         "capability:multivariate": True,
-        "X_inner_mtype": ["numpy3D"],
+        "capability:unequal_length": True,
+        "X_inner_mtype": ["np-list", "numpy3D"],
+        "algorithm_type": "distance",
     }
 
     def __init__(
@@ -92,9 +94,12 @@ class KNeighborsTimeSeriesRegressor(BaseRegressor):
 
         Parameters
         ----------
-        X : 3D np.array of shape = [n_instances, n_dimensions, series_length]
-            The training data.
-        y : array-like, shape = [n_instances]
+        X : 3D np.ndarray of shape = (n_cases, n_channels, n_timepoints) or list of
+        shape[n_cases] of 2D arrays shape (n_channels,n_timepoints_i)
+                If the series are all equal length, a numpy3D will be passed. If
+                unequal, a list of 2D numpy arrays is passed, which may have
+                different lengths.
+        y : array-like, shape = (n_cases)
             The class labels.
         """
         if isinstance(self.distance, str):
@@ -109,18 +114,21 @@ class KNeighborsTimeSeriesRegressor(BaseRegressor):
 
         Parameters
         ----------
-        X : 3D np.array of shape = [n_instances, n_dimensions, series_length]
-            The training data.
+        X : 3D np.ndarray of shape = (n_cases, n_channels, n_timepoints) or list of
+        shape[n_cases] of 2D arrays shape (n_channels,n_timepoints_i)
+                If the series are all equal length, a numpy3D will be passed. If
+                unequal, a list of 2D numpy arrays is passed, which may have
+                different lengths.
 
         Returns
         -------
-        y : array of shape [n_instances]
-            Target values for each data sample.
+        y : array of shape (n_cases)
+            Class labels for each data sample.
         """
         self.check_is_fitted()
 
-        preds = np.zeros(X.shape[0])
-        for i in range(X.shape[0]):
+        preds = np.empty(len(X))
+        for i in range(len(X)):
             idx, weights = self._kneighbors(X[i])
             preds[i] = np.average(self.y_[idx], weights=weights)
 
@@ -133,8 +141,11 @@ class KNeighborsTimeSeriesRegressor(BaseRegressor):
 
         Parameters
         ----------
-        X : 3D np.array of shape = [n_instances, n_dimensions, series_length]
-            The training data.
+        X : 3D np.ndarray of shape = (n_cases, n_channels, n_timepoints) or list of
+        shape[n_cases] of 2D arrays shape (n_channels,n_timepoints_i)
+                If the series are all equal length, a numpy3D will be passed. If
+                unequal, a list of 2D numpy arrays is passed, which may have
+                different lengths.
 
         Returns
         -------
@@ -146,7 +157,7 @@ class KNeighborsTimeSeriesRegressor(BaseRegressor):
         distances = np.array(
             [
                 self.metric_(X, self.X_[j], **self._distance_params)
-                for j in range(self.X_.shape[0])
+                for j in range(len(self.X_))
             ]
         )
 
diff --git a/aeon/transformations/panel/dictionary_based/_sax.py b/aeon/transformations/panel/dictionary_based/_sax.py
index fcc3f1b7..073bda8c 100644
--- a/aeon/transformations/panel/dictionary_based/_sax.py
+++ b/aeon/transformations/panel/dictionary_based/_sax.py
@@ -1,22 +1,15 @@
 # -*- coding: utf-8 -*-
 """Symbolic Aggregate approXimation (SAX) transformer."""
 
-import sys
-
 import numpy as np
-import pandas as pd
 import scipy.stats
+from numba import njit, prange
 
 from aeon.transformations.base import BaseTransformer
 from aeon.transformations.panel.dictionary_based import PAA
 
-#    TO DO: verify this returned pandas is consistent with aeon
-#    definition. Timestamps?
-
-# from numba import types
-# from numba.experimental import jitclass
-
-__author__ = "MatthewMiddlehurst"
+__author__ = ["MatthewMiddlehurst", "hadifawaz1999"]
+__all__ = ["SAX", "_invert_sax_symbols"]
 
 
 class SAX(BaseTransformer):
@@ -26,176 +19,211 @@ class SAX(BaseTransformer):
     Jessica Lin, Eamonn Keogh, Li Wei and Stefano Lonardi,
     "Experiencing SAX: a novel symbolic representation of time series"
     Data Mining and Knowledge Discovery, 15(2):107-144
-    Overview: for each series:
-        run a sliding window across the series
-        for each window
-            shorten the series with PAA (Piecewise Approximate Aggregation)
-            discretise the shortened series into fixed bins
-            form a word from these discrete values
-    by default SAX produces a single word per series (window_size=0).
-    SAX returns a pandas data frame where column 0 is the histogram (sparse
-    pd.series)
-    of each series.
 
     Parameters
     ----------
-    word_length:         int, length of word to shorten window to (using
-    PAA) (default 8)
-    alphabet_size:       int, number of values to discretise each value
-    to (default to 4)
-    window_size:         int, size of window for sliding. Input series
-    length for whole series transform (default to 12)
-    remove_repeat_words: boolean, whether to use numerosity reduction (
-    default False)
-    save_words:          boolean, whether to use numerosity reduction (
-    default False)
-
-    return_pandas_data_series:          boolean, default = True
-        set to true to return Pandas Series as a result of transform.
-        setting to true reduces speed significantly but is required for
-        automatic test.
-
-    Attributes
+    n_segments : int, default = 8,
+        number of segments for the PAA, each segment is represented
+        by a symbol
+    alphabet_size : int, default = 4,
+        size of the alphabet to be used to create the bag of words
+    distribution : str, default = "Gaussian",
+        options={"Gaussian"}
+        the distribution function to use when generating the
+        alphabet. Currently only Gaussian is supported.
+    distribution_params : dict, default = None,
+        the parameters of the used distribution, if the used
+        distribution is "Gaussian" and this parameter is None
+        then the default setup is {"scale" : 1.0}
+    znormalized : bool, default = True,
+        this parameter is set to True when the input time series
+        are assume to be be z-normalized, i.e. the mean of each
+        time series should be 0 and the standard deviation should be
+        equal to 1. If this parameter is set to False, the z-normalization
+        is applied before the transformation.
+
+    Notes
+    -----
+    This implementation is based on the one done by tslearn [1]
+
+    References
     ----------
-    words:      history = []
+    .. [1] https://github.com/tslearn-team/tslearn/blob/fa40028/tslearn/
+       piecewise/piecewise.py#L261-L501
+
+    Examples
+    --------
+    >>> from aeon.transformations.panel.dictionary_based import SAX
+    >>> from aeon.datasets import load_unit_test
+    >>> X_train, y_train = load_unit_test(split="train")
+    >>> X_test, y_test = load_unit_test(split="test")
+    >>> sax = SAX(n_segments=10, alphabet_size=8)
+    >>> X_train = sax.fit_transform(X_train)
+    >>> X_test = sax.fit_transform(X_test)
     """
 
     _tags = {
-        "univariate-only": True,
-        "fit_is_empty": True,
-        "scitype:transform-input": "Series",
-        # what is the scitype of X: Series, or Panel
         "scitype:transform-output": "Series",
-        # what scitype is returned: Primitives, Series, Panel
-        "scitype:instancewise": True,  # is this an instance-wise transform?
-        "X_inner_mtype": "numpy3D",  # which mtypes do _fit/_predict support for X?
-        "y_inner_mtype": "None",  # which mtypes do _fit/_predict require for y?
+        "scitype:instancewise": True,
+        "X_inner_mtype": "numpy3D",
+        "y_inner_mtype": "None",
+        "capability:multivariate": True,
     }
 
     def __init__(
         self,
-        word_length=8,
+        n_segments=8,
         alphabet_size=4,
-        window_size=12,
-        remove_repeat_words=False,
-        save_words=False,
-        return_pandas_data_series=True,
+        distribution="Gaussian",
+        distribution_params=None,
+        znormalized=True,
     ):
-        self.word_length = word_length
+        self.n_segments = n_segments
         self.alphabet_size = alphabet_size
-        self.window_size = window_size
-        self.remove_repeat_words = remove_repeat_words
-        self.save_words = save_words
-        self.return_pandas_data_series = return_pandas_data_series
-        self.words = []
+        self.distribution = distribution
+
+        self.distribution_params = distribution_params
+        self.znormalized = znormalized
+
+        if self.distribution == "Gaussian":
+            self.distribution_params_ = (
+                dict(scale=1.0)
+                if self.distribution_params is None
+                else self.distribution_params
+            )
+
+        else:
+            raise NotImplementedError(self.distribution, "still not added")
 
-        super(SAX, self).__init__(_output_convert="off")
+        self.breakpoints, self.breakpoints_mid = self._generate_breakpoints(
+            alphabet_size=self.alphabet_size,
+            distribution=self.distribution,
+            distribution_params=self.distribution_params_,
+        )
+
+        super(SAX, self).__init__()
+
+    def _get_paa(self, X):
+        """Transform the input time series to PAA segments.
+
+        Parameters
+        ----------
+        X : np.ndarray of shape = (n_instances, n_channels, series_length)
+            The input time series
+
+        Returns
+        -------
+        X_paa : np.ndarray of shape = (n_instances, n_channels, n_segments)
+            The output of the PAA transformation
+        """
+        if not self.znormalized:
+            X = scipy.stats.zscore(X, axis=-1)
+
+        paa = PAA(n_intervals=self.n_segments)
+        X_paa = paa.fit_transform(X=X)
+
+        return X_paa
 
-    # todo: looks like this just loops over series instances
-    # so should be refactored to work on Series directly
     def _transform(self, X, y=None):
-        """Transform data.
+        """Transform the input time series to SAX symbols.
+
+        This function will transform the input time series into a bag of
+        symbols. These symbols are represented as integer values pointing
+        to the indices of the breakpoint in the alphabet for each
+        segment produced by PAA.
 
         Parameters
         ----------
-        X : nested pandas DataFrame of shape [n_instances, 1]
-            Nested dataframe with univariate time-series in cells.
+        X : np.ndarray of shape = (n_instances, n_channels, series_length)
+            The input time series
+        y : np.ndarray of shape = (n_instances,), default = None
+            The labels are not used
 
         Returns
         -------
-        dims: Pandas data frame with first dimension in column zero
+        sax_symbols : np.ndarray of shape = (n_instances, n_channels, n_segments)
+            The output of the SAX transformation
         """
-        X = X.squeeze(1)
+        X_paa = self._get_paa(X=X)
+        sax_symbols = self._get_sax_symbols(X_paa=X_paa)
+        return sax_symbols
 
-        if self.alphabet_size < 2 or self.alphabet_size > 4:
-            raise RuntimeError("Alphabet size must be an integer between 2 and 4")
-        if self.word_length < 1 or self.word_length > 16:
-            raise RuntimeError("Word length must be an integer between 1 and 16")
+    def _get_sax_symbols(self, X_paa):
+        """Produce the SAX transformation.
 
-        breakpoints = self._generate_breakpoints()
-        n_instances, series_length = X.shape
+        Parameters
+        ----------
+        X_paa : np.ndarray of shape = (n_instances, n_channels, n_segments)
+            The output of the PAA transformation
 
-        bags = pd.DataFrame()
-        dim = []
+        Returns
+        -------
+        sax_symbols : np.ndarray of shape = (n_instances, n_channels, n_segments)
+            The output of the SAX transformation using np.digitize
+        """
+        sax_symbols = np.digitize(x=X_paa, bins=self.breakpoints)
+        return sax_symbols
 
-        for i in range(n_instances):
-            bag = {}
-            lastWord = -1
+    def inverse_sax(self, X, original_length, y=None):
+        """Produce the inverse SAX transformation.
 
-            words = []
+        Parameters
+        ----------
+        X : np.ndarray of shape = (n_instances, n_channels, n_segments)
+            The output of the SAX transformation
+        y : np.ndarray of shape = (n_instances,), default = None
+            The labels are not used
 
-            num_windows_per_inst = series_length - self.window_size + 1
-            split = np.array(
-                X[
-                    i,
-                    np.arange(self.window_size)[None, :]
-                    + np.arange(num_windows_per_inst)[:, None],
-                ]
+        Returns
+        -------
+        sax_inverse : np.ndarray(n_instances, n_channels, series_length)
+            The inverse of sax transform
+        """
+        sax_inverse = _invert_sax_symbols(
+            sax_symbols=X,
+            series_length=original_length,
+            breakpoints_mid=self.breakpoints_mid,
+        )
+
+        return sax_inverse
+
+    def _generate_breakpoints(
+        self, alphabet_size, distribution="Gaussian", distribution_params=None
+    ):
+        """Generate the breakpoints following a probability distribution.
+
+        Parameters
+        ----------
+        alphabet_size : int
+            The size of the alphabet, the number of breakpints is alphabet_size -1
+        distribution : str, default = "Gaussian"
+            The name of the distribution to follow when generating the breakpoints
+        distribution_params : dict, default = None,
+            The parameters of the chosen distribution, if the distribution is
+            chosen as Gaussian and this is left default to None then the params
+            used are "scale=1.0"
+
+        Returns
+        -------
+        breakpoints : np.ndarray of shape = (alphabet_size-1,)
+            The breakpoints to be used to generate the SAX symbols
+        breakpoints_mid : np.ndarray of shape = (alphabet_size,)
+            The middle breakpoints for each breakpoint interval used to produce
+            the inverse of SAX transformation
+        """
+        if distribution == "Gaussian":
+            breakpoints = scipy.stats.norm.ppf(
+                np.arange(1, alphabet_size, dtype=np.float64) / alphabet_size,
+                scale=distribution_params["scale"],
             )
 
-            split = scipy.stats.zscore(split, axis=1)
-
-            paa = PAA(n_intervals=self.word_length)
-            data = pd.DataFrame()
-            data[0] = [pd.Series(x, dtype=np.float32) for x in split]
-            patterns = paa.fit_transform(data)
-            patterns = np.asarray([a.values for a in patterns.iloc[:, 0]])
-
-            for n in range(patterns.shape[0]):
-                pattern = patterns[n, :]
-                word = self._create_word(pattern, breakpoints)
-                words.append(word)
-                lastWord = self._add_to_bag(bag, word, lastWord)
-
-            if self.save_words:
-                self.words.append(words)
-
-            dim.append(pd.Series(bag) if self.return_pandas_data_series else bag)
-
-        bags[0] = dim
-
-        return bags
-
-    def _create_word(self, pattern, breakpoints):
-        word = 0
-        for i in range(self.word_length):
-            for bp in range(self.alphabet_size):
-                if pattern[i] <= breakpoints[bp]:
-                    word = (word << 2) | bp
-                    break
-
-        return word
-
-    def _add_to_bag(self, bag, word, last_word):
-        if self.remove_repeat_words and word == last_word:
-            return False
-        bag[word] = bag.get(word, 0) + 1
-        return True
-
-    def _generate_breakpoints(self):
-        # Pre-made gaussian curve breakpoints from UEA TSC codebase
-        return {
-            2: [0, sys.float_info.max],
-            3: [-0.43, 0.43, sys.float_info.max],
-            4: [-0.67, 0, 0.67, sys.float_info.max],
-            5: [-0.84, -0.25, 0.25, 0.84, sys.float_info.max],
-            6: [-0.97, -0.43, 0, 0.43, 0.97, sys.float_info.max],
-            7: [-1.07, -0.57, -0.18, 0.18, 0.57, 1.07, sys.float_info.max],
-            8: [-1.15, -0.67, -0.32, 0, 0.32, 0.67, 1.15, sys.float_info.max],
-            9: [-1.22, -0.76, -0.43, -0.14, 0.14, 0.43, 0.76, 1.22, sys.float_info.max],
-            10: [
-                -1.28,
-                -0.84,
-                -0.52,
-                -0.25,
-                0.0,
-                0.25,
-                0.52,
-                0.84,
-                1.28,
-                sys.float_info.max,
-            ],
-        }[self.alphabet_size]
+            breakpoints_mid = scipy.stats.norm.ppf(
+                np.arange(1, 2 * alphabet_size, 2, dtype=np.float64)
+                / (2 * self.alphabet_size),
+                scale=distribution_params["scale"],
+            )
+
+        return breakpoints, breakpoints_mid
 
     @classmethod
     def get_test_params(cls, parameter_set="default"):
@@ -216,6 +244,43 @@ class SAX(BaseTransformer):
             `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
             `create_test_instance` uses the first (or only) dictionary in `params`
         """
-        # small word length, window size for testing
-        params = {"word_length": 2, "window_size": 4}
+        params = {"n_segments": 10, "alphabet_size": 8}
         return params
+
+
+@njit(parallel=True, fastmath=True)
+def _invert_sax_symbols(sax_symbols, series_length, breakpoints_mid):
+    """Reconstruct the original time series using a Gaussian estimation.
+
+    In other words, try to inverse the SAX transformation.
+
+    Parameters
+    ----------
+    sax_symbols : np.ndarray(n_instances, n_channels, n_segments)
+        The sax output transformation
+    series_length : int
+        The original time series length
+    breakpoints_mid : np.ndarray(alphabet_size)
+        The Gaussian estimation of the value for each breakpoint interval
+
+    Returns
+    -------
+    sax_inverse : np.ndarray(n_instances, n_channels, series_length)
+        The inverse of sax transform
+    """
+    n_samples, n_channels, sax_length = sax_symbols.shape
+
+    segment_length = int(series_length / sax_length)
+    sax_inverse = np.zeros((n_samples, n_channels, series_length))
+
+    for i in prange(n_samples):
+        for c in prange(n_channels):
+            for _current_sax_index in prange(sax_length):
+                start_index = _current_sax_index * segment_length
+                stop_index = start_index + segment_length
+
+                sax_inverse[i, :, start_index:stop_index] = breakpoints_mid[
+                    sax_symbols[i, c, _current_sax_index]
+                ]
+
+    return sax_inverse
diff --git a/aeon/transformations/panel/summarize/__init__.py b/aeon/transformations/panel/summarize/__init__.py
index eee0714c..eaeae6a8 100644
--- a/aeon/transformations/panel/summarize/__init__.py
+++ b/aeon/transformations/panel/summarize/__init__.py
@@ -8,6 +8,7 @@ __all__ = [
     "PlateauFinder",
     "RandomIntervalFeatureExtractor",
     "FittedParamExtractor",
+    "series_slope_derivative",
 ]
 
 from ._extract import (
@@ -15,4 +16,5 @@ from ._extract import (
     FittedParamExtractor,
     PlateauFinder,
     RandomIntervalFeatureExtractor,
+    series_slope_derivative,
 )
diff --git a/aeon/transformations/panel/summarize/_extract.py b/aeon/transformations/panel/summarize/_extract.py
index fd9cb4f1..989510f6 100644
--- a/aeon/transformations/panel/summarize/_extract.py
+++ b/aeon/transformations/panel/summarize/_extract.py
@@ -28,13 +28,29 @@ def _der(x: np.ndarray):
 def series_slope_derivative(X: np.ndarray) -> np.ndarray:
     """Find the slope derivative of collection of time series.
 
+    takes any shape numpy array and finds the derivative of the last axis, padding
+    the first and last values so that the length stays the same
+
     Parameters
     ----------
-    X: np.ndarray shape (n_time_series, n_channels, series_length)
+    X: np.ndarray
 
     Returns
     -------
-    np.ndarray shape (n_time_series, n_channels, series_length)
+    np.ndarray same shape as X
+
+    Example
+    -------
+    >>> from aeon.transformations.panel.summarize import series_slope_derivative
+    >>> x=np.array([1, 2, 3, 4, 5, 4, 3, 2, 1])
+    >>> series_slope_derivative(x)
+    array([ 1. ,  1. ,  1. ,  1. ,  0.5, -1. , -1. , -1. , -1. ])
+    >>> x=np.array([[1, 2, 3, 4, 5], [5, 4, 3, 2, 1]])
+    >>> series_slope_derivative(x)
+    array([[ 1.,  1.,  1.,  1.,  1.],
+           [-1., -1., -1., -1., -1.]])
+    >>> x=np.random.random((10,3,50)) # 10 time series, 3 channels, length 50
+    >>> x2 = series_slope_derivative(x)
     """
     return np.apply_along_axis(_der, axis=-1, arr=X)
 


Test patch:
diff --git a/aeon/classification/distance_based/tests/test_elastic_ensemble.py b/aeon/classification/distance_based/tests/test_elastic_ensemble.py
new file mode 100644
index 00000000..06f43732
--- /dev/null
+++ b/aeon/classification/distance_based/tests/test_elastic_ensemble.py
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+"""Tests for ElasticEnsemble."""
+import numpy as np
+import pytest
+
+from aeon.classification.distance_based import ElasticEnsemble
+
+DISTANCE = [
+    "lcss",
+    "dtw",
+    "wdtw",
+    "erp",
+    "msm",
+]
+PARAS = {
+    "dtw": {"window"},
+    "wdtw": {"g"},
+    "lcss": {"epsilon", "window"},
+    "erp": {"g", "window"},
+    "msm": {"c"},
+}
+DATA = np.random.random((10, 1, 50))
+
+
+@pytest.mark.parametrize("dist", DISTANCE)
+@pytest.mark.parametrize("data", [None, DATA])
+def test_get_100_param_options(dist, data):
+    """Test the method to get 100 options per distance function.
+    1. Test 100 returned.
+    2. Test on specified range.
+    """
+    if (
+        dist == "erp" or dist == "lcss"
+    ) and data is None:  # raise exception, LCSS needs
+        # train data
+        with pytest.raises(ValueError):
+            ElasticEnsemble._get_100_param_options(dist, data)
+    else:
+        paras = ElasticEnsemble._get_100_param_options(dist, data)
+        para_values = paras["distance_params"]
+        # Check correct number of para combos
+        assert len(para_values) == 100
+        # Check all provided parameters are valid for this distance
+        expected_paras = PARAS[dist]
+        for p in para_values:
+            for ep in expected_paras:
+                assert ep in p
diff --git a/aeon/clustering/tests/test_k_shapes.py b/aeon/clustering/tests/test_k_shapes.py
index 24e87bcf..ea40e912 100644
--- a/aeon/clustering/tests/test_k_shapes.py
+++ b/aeon/clustering/tests/test_k_shapes.py
@@ -7,97 +7,14 @@ from aeon.clustering.k_shapes import TimeSeriesKShapes
 from aeon.datasets import load_basic_motions
 from aeon.utils.validation._dependencies import _check_estimator_deps
 
-expected_results = [
-    0,
-    1,
-    0,
-    1,
-    1,
-    0,
-    0,
-    1,
-    0,
-    1,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    1,
-    2,
-    2,
-    2,
-    2,
-    1,
-    2,
-]
+expected_results = [2, 2, 2, 0, 0]
 
-inertia = 0.550860917533926
+inertia = 0.5645477840468736
 
-expected_iters = 4
+expected_iters = 2
 
-expected_labels = [
-    0,
-    1,
-    2,
-    0,
-    1,
-    0,
-    2,
-    0,
-    1,
-    0,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    0,
-    2,
-    0,
-    2,
-    0,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-    2,
-]
-
-expected_score = 0.550860917533926
+expected_labels = [0, 2, 1, 1, 1]
+expected_score = 0.5645477840468736
 
 
 @pytest.mark.skipif(
@@ -106,20 +23,21 @@ expected_score = 0.550860917533926
 )
 def test_kshapes():
     """Test implementation of Kshapes."""
+    max_train = 5
+
     X_train, y_train = load_basic_motions(split="train")
     X_test, y_test = load_basic_motions(split="test")
 
     kshapes = TimeSeriesKShapes(random_state=1, n_clusters=3)
-    kshapes.fit(X_train)
-    test_shape_result = kshapes.predict(X_test)
-    score = kshapes.score(X_test)
-    proba = kshapes.predict_proba(X_test)
-
+    kshapes.fit(X_train[0:max_train])
+    test_shape_result = kshapes.predict(X_test[0:max_train])
+    score = kshapes.score(X_test[0:max_train])
+    proba = kshapes.predict_proba(X_test[0:max_train])
     assert np.array_equal(test_shape_result, expected_results)
     np.testing.assert_almost_equal(score, expected_score)
     assert kshapes.n_iter_ == expected_iters
     assert np.array_equal(kshapes.labels_, expected_labels)
-    assert proba.shape == (40, 3)
+    assert proba.shape == (max_train, 3)
 
     for val in proba:
         assert np.count_nonzero(val == 1.0) == 1
diff --git a/aeon/clustering/tests/test_kernel_k_means.py b/aeon/clustering/tests/test_kernel_k_means.py
index 2f8893ec..0b6a5665 100644
--- a/aeon/clustering/tests/test_kernel_k_means.py
+++ b/aeon/clustering/tests/test_kernel_k_means.py
@@ -7,95 +7,13 @@ from aeon.clustering.kernel_k_means import TimeSeriesKernelKMeans
 from aeon.datasets import load_basic_motions
 from aeon.utils.validation._dependencies import _check_estimator_deps
 
-expected_results = [
-    0,
-    1,
-    0,
-    0,
-    0,
-    0,
-    1,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-    0,
-]
+expected_labels = [0, 2, 1, 2, 0]
 
-expected_score = 73.99999999999983
+expected_score = 4.0
 
 expected_iters = 2
 
-expected_labels = [
-    1,
-    0,
-    0,
-    1,
-    2,
-    1,
-    0,
-    2,
-    2,
-    1,
-    1,
-    1,
-    0,
-    0,
-    1,
-    0,
-    2,
-    0,
-    0,
-    1,
-    1,
-    2,
-    0,
-    0,
-    1,
-    2,
-    2,
-    1,
-    0,
-    2,
-    1,
-    2,
-    1,
-    0,
-    1,
-    1,
-    2,
-    0,
-    0,
-    2,
-]
+expected_results = [0, 0, 0, 0, 0]
 
 
 @pytest.mark.skipif(
@@ -104,20 +22,22 @@ expected_labels = [
 )
 def test_kernel_k_means():
     """Test implementation of kernel k means."""
+    max_train = 5
+
     X_train, y_train = load_basic_motions(split="train")
     X_test, y_test = load_basic_motions(split="test")
 
     kernel_kmeans = TimeSeriesKernelKMeans(random_state=1, n_clusters=3)
-    kernel_kmeans.fit(X_train)
-    test_shape_result = kernel_kmeans.predict(X_test)
-    score = kernel_kmeans.score(X_test)
-    proba = kernel_kmeans.predict_proba(X_test)
+    kernel_kmeans.fit(X_train[0:max_train])
+    test_shape_result = kernel_kmeans.predict(X_test[0:max_train])
+    score = kernel_kmeans.score(X_test[0:max_train])
+    proba = kernel_kmeans.predict_proba(X_test[0:max_train])
 
     assert np.array_equal(test_shape_result, expected_results)
     np.testing.assert_almost_equal(score, expected_score)
     assert kernel_kmeans.n_iter_ == expected_iters
     assert np.array_equal(kernel_kmeans.labels_, expected_labels)
-    assert proba.shape == (40, 3)
+    assert proba.shape == (max_train, 3)
 
     for val in proba:
         assert np.count_nonzero(val == 1.0) == 1
diff --git a/aeon/transformations/panel/dictionary_based/tests/test_sax.py b/aeon/transformations/panel/dictionary_based/tests/test_sax.py
new file mode 100644
index 00000000..25ac34ae
--- /dev/null
+++ b/aeon/transformations/panel/dictionary_based/tests/test_sax.py
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+import numpy as np
+import pytest
+
+from aeon.transformations.panel.dictionary_based import SAX
+
+
+@pytest.mark.parametrize("n_segments", [8])
+@pytest.mark.parametrize("alphabet_size", [4])
+def test_equal_length_univariate_sax(n_segments, alphabet_size):
+    X = np.random.normal(size=(10, 1, 100))
+
+    # normalize input
+    stds = np.std(X, axis=2, keepdims=True)
+    stds[stds == 0] = 1.0
+    X = (X - np.mean(X, axis=2, keepdims=True)) / stds
+
+    sax = SAX(n_segments=n_segments, alphabet_size=alphabet_size)
+
+    X_sax = sax.fit_transform(X=X)
+    X_sax_inv = sax.inverse_sax(X=X_sax, original_length=100)
+
+    assert X_sax.shape[-1] == n_segments
+    assert X_sax_inv.shape[-1] == X.shape[-1]
+    assert len(sax.breakpoints) == 3
+    assert len(sax.breakpoints_mid) == 4
+
+
+@pytest.mark.parametrize("n_segments", [8])
+@pytest.mark.parametrize("alphabet_size", [4])
+def test_equal_length_multivariate_sax(n_segments, alphabet_size):
+    X = np.random.normal(size=(10, 3, 100))
+
+    # normalize input
+    stds = np.std(X, axis=2, keepdims=True)
+    stds[stds == 0] = 1.0
+    X = (X - np.mean(X, axis=2, keepdims=True)) / stds
+
+    sax = SAX(n_segments=n_segments, alphabet_size=alphabet_size)
+
+    X_sax = sax.fit_transform(X=X)
+    X_sax_inv = sax.inverse_sax(X=X_sax, original_length=100)
+
+    assert X_sax.shape[-1] == n_segments
+    assert X_sax_inv.shape[-1] == X.shape[-1]
+    assert len(sax.breakpoints) == 3
+    assert len(sax.breakpoints_mid) == 4