Repository: aeon-toolkit/aeon
Pull request #415 (2023-05-11 13:03:17)

[ENH] Facilitate multivariate and unequal length with ElasticEnsemble classifier
#### Reference Issues/PRs
see #413
depends on #412 
#### What does this implement/fix? Explain your changes.
Facilitates unequal length and multivariate series for the ElasticEnsemble classifier

Once KNN can handle unequal/multivariate (see #412), its easy to adapt EE. Also added test for private function



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/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/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