Repository: aeon-toolkit/aeon
Pull request #2461 (2024-12-16 09:51:43)

[BUG, ENH] SFA fix: Std-Normalization, as used in BOSS and WEASEL models, is potentially harmful for lower bounding
This fixes an issue with std normalization in SFA, as is commonly used for BOSS and WEASEL models.

For Lower Bounding computations this however adds a potential violation of the lower bounding distance. Thus, I remove it, if whole series matching is applied.

This PR further adds:
- a module `SFAWhole`, which simplifies usage of SFA, for lower bounding in whole series, by correctly setting default parameters and only exposing needed parameters for lower bounding.
- parallel computation of the DFT using `scipy.fft.rfft(X, axis=1, workers=-1)`
- option to use sampling for learning BINs when using whole series.

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


I would have added the following labels to this PR based on the changes made: **[ $\\color{#5209C9}{\\textsf{distances}}$, $\\color{#41A8F6}{\\textsf{transformations}}$ ]**, however some package labels are already present.

The [Checks](https://github.com/aeon-toolkit/aeon/pull/2461/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
    
patrickzib: > Fast review on "looks" and not algorithm correctness which I'll trust you with !

Thank you, and happy new year :) 
baraline: Quick question before approval, are you supposed to get the same output from SFA vs SFA fast ? If so did you test it ?
patrickzib: > Quick question before approval, are you supposed to get the same output from SFA vs SFA fast ? If so did you test it ?

Good question. For the same parameterization is should be. I have extended the test case in `test_symbolic_mindist.py.test_sfa_mindist` to check this. 



Patch:
diff --git a/aeon/distances/mindist/_dft_sfa.py b/aeon/distances/mindist/_dft_sfa.py
index deb20141..5f6e8562 100644
--- a/aeon/distances/mindist/_dft_sfa.py
+++ b/aeon/distances/mindist/_dft_sfa.py
@@ -44,22 +44,17 @@ def mindist_dft_sfa_distance(
     --------
     >>> import numpy as np
     >>> from aeon.distances import mindist_dft_sfa_distance
-    >>> from aeon.transformations.collection.dictionary_based import SFAFast
+    >>> from aeon.transformations.collection.dictionary_based import SFAWhole
     >>> 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]])
-    >>> transform = SFAFast(
+    >>> transform = SFAWhole(
     ...    word_length=8,
     ...    alphabet_size=8,
-    ...    window_size=x.shape[-1],
     ...    norm=True,
-    ...    lower_bounding_distances=True   # This must be set!
     ... )
-    >>> transform.fit(x)
-    SFAFast(...)
-    >>> x_sfa = transform.transform_words(x).squeeze()
-    >>> y_sfa = transform.transform_words(y).squeeze()
-    >>> x_dft = transform.transform_mft(x).squeeze()
-    >>> dist = mindist_dft_sfa_distance(x_dft, y_sfa, transform.breakpoints)
+    >>> x_sfa, _ = transform.fit_transform(x)
+    >>> _, y_dft = transform.transform(y)
+    >>> dist = mindist_dft_sfa_distance(y_dft, x_sfa, transform.breakpoints)
     """
     if x_dft.ndim == 1 and y_sfa.ndim == 1:
         return _univariate_dft_sfa_distance(x_dft, y_sfa, breakpoints)
diff --git a/aeon/distances/mindist/_sfa.py b/aeon/distances/mindist/_sfa.py
index 601b83f1..e9c6cf86 100644
--- a/aeon/distances/mindist/_sfa.py
+++ b/aeon/distances/mindist/_sfa.py
@@ -44,20 +44,16 @@ def mindist_sfa_distance(
     --------
     >>> import numpy as np
     >>> from aeon.distances import mindist_sfa_distance
-    >>> from aeon.transformations.collection.dictionary_based import SFAFast
+    >>> from aeon.transformations.collection.dictionary_based import SFAWhole
     >>> 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]])
-    >>> transform = SFAFast(
+    >>> transform = SFAWhole(
     ...    word_length=8,
     ...    alphabet_size=8,
-    ...    window_size=x.shape[-1],
-    ...    norm=True,
-    ...    lower_bounding_distances=True   # This must be set!
+    ...    norm=True
     ... )
-    >>> transform.fit(x)
-    SFAFast(...)
-    >>> x_sfa = transform.transform_words(x).squeeze()
-    >>> y_sfa = transform.transform_words(y).squeeze()
+    >>> x_sfa, _ = transform.fit_transform(x)
+    >>> y_sfa, _ = transform.transform(y)
     >>> dist = mindist_sfa_distance(x_sfa, y_sfa, transform.breakpoints)
     """
     if x.ndim == 1 and y.ndim == 1:
diff --git a/aeon/transformations/collection/dictionary_based/__init__.py b/aeon/transformations/collection/dictionary_based/__init__.py
index 5f75cc96..f69f96a7 100644
--- a/aeon/transformations/collection/dictionary_based/__init__.py
+++ b/aeon/transformations/collection/dictionary_based/__init__.py
@@ -1,9 +1,10 @@
 """Transformers."""
 
-__all__ = ["PAA", "SFA", "SFAFast", "SAX", "BORF"]
+__all__ = ["PAA", "SFA", "SFAFast", "SFAWhole", "SAX", "BORF"]
 
 from aeon.transformations.collection.dictionary_based._borf import BORF
 from aeon.transformations.collection.dictionary_based._paa import PAA
 from aeon.transformations.collection.dictionary_based._sax import SAX
 from aeon.transformations.collection.dictionary_based._sfa import SFA
 from aeon.transformations.collection.dictionary_based._sfa_fast import SFAFast
+from aeon.transformations.collection.dictionary_based._sfa_whole import SFAWhole
diff --git a/aeon/transformations/collection/dictionary_based/_sfa.py b/aeon/transformations/collection/dictionary_based/_sfa.py
index bb399b7f..c6429816 100644
--- a/aeon/transformations/collection/dictionary_based/_sfa.py
+++ b/aeon/transformations/collection/dictionary_based/_sfa.py
@@ -443,7 +443,7 @@ class SFA(BaseCollectionTransformer):
                 self.letter_bits,
             )
 
-        return words
+        return words, dfts
 
     def transform_words(self, X):
         """Return the words generated for each series.
@@ -463,8 +463,8 @@ class SFA(BaseCollectionTransformer):
             delayed(self._transform_words_case)(X[i, :]) for i in range(X.shape[0])
         )
 
-        words = zip(*transform)
-        return np.array(list(words))
+        words = list(zip(*transform))  # words and dfts
+        return np.array(words[0]).squeeze(), np.array(words[1]).squeeze()
 
     def get_words(self):
         """Return the words generated for each series.
diff --git a/aeon/transformations/collection/dictionary_based/_sfa_fast.py b/aeon/transformations/collection/dictionary_based/_sfa_fast.py
index 1166bc87..0244d735 100644
--- a/aeon/transformations/collection/dictionary_based/_sfa_fast.py
+++ b/aeon/transformations/collection/dictionary_based/_sfa_fast.py
@@ -12,6 +12,7 @@ import sys
 from warnings import simplefilter
 
 import numpy as np
+import scipy.fft
 from numba import (
     NumbaPendingDeprecationWarning,
     NumbaTypeSafetyWarning,
@@ -28,6 +29,7 @@ from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor
 from sklearn.utils import check_random_state
 
 from aeon.transformations.collection import BaseCollectionTransformer
+from aeon.utils.numba.general import AEON_NUMBA_STD_THRESHOLD
 
 # The binning methods to use: equi-depth, equi-width, information gain or kmeans
 binning_methods = {
@@ -106,6 +108,8 @@ class SFAFast(BaseCollectionTransformer):
        Whether to use numerosity reduction.
     lower_bounding_distances : boolean, default = None
        If set to True, the FFT is normed to allow for ED lower bounding.
+    sampling_factor : float, default = None
+       If set to a value <1.0, this percentage of samples are used to learn MCB bins.
     return_sparse :  boolean, default=True
         If set to true, a scipy sparse matrix will be returned as BOP model.
         If set to false a dense array will be returned as BOP model. Sparse
@@ -154,6 +158,7 @@ class SFAFast(BaseCollectionTransformer):
         feature_selection="none",
         max_feature_count=256,
         p_threshold=0.05,
+        sampling_factor=None,
         random_state=None,
         return_sparse=True,
         n_jobs=1,
@@ -171,11 +176,7 @@ class SFAFast(BaseCollectionTransformer):
         self.lower_bounding = lower_bounding
         self.lower_bounding_distances = lower_bounding_distances
 
-        self.inverse_sqrt_win_size = (
-            1.0 / math.sqrt(window_size)
-            if (not lower_bounding or lower_bounding_distances)
-            else 1.0
-        )
+        self.inverse_sqrt_win_size = self.initialize_inverse_sqrt_win_size()
 
         self.remove_repeat_words = remove_repeat_words
 
@@ -195,6 +196,7 @@ class SFAFast(BaseCollectionTransformer):
 
         self.dilation = dilation
         self.first_difference = first_difference
+        self.sampling_factor = sampling_factor
 
         # Feature selection part
         self.feature_selection = feature_selection
@@ -210,7 +212,7 @@ class SFAFast(BaseCollectionTransformer):
         self.random_state = random_state
         super().__init__()
 
-    def _fit_transform(self, X, y=None):
+    def _fit_transform(self, X, y=None, return_bag_of_words=True):
         """Fit to data, then transform it."""
         if self.alphabet_size < 2:
             raise ValueError("Alphabet size must be an integer greater than 2")
@@ -231,6 +233,10 @@ class SFAFast(BaseCollectionTransformer):
         if self.binning_method not in binning_methods:
             raise TypeError("binning_method must be one of: ", binning_methods)
 
+        # If window_size was not set, assume whole series matching
+        if self.window_size is None:
+            self.window_size = X.shape[-1]
+
         offset = 2 if self.norm else 0
         self.word_length_actual = min(self.window_size - offset, self.word_length)
 
@@ -240,6 +246,14 @@ class SFAFast(BaseCollectionTransformer):
                 "the time series length and window-length."
             )
 
+        if self.sampling_factor and self.sampling_factor > 1.0:
+            raise ValueError(
+                "Please set the sampling_factor to a value smaller than or equal to "
+                "1 (equals 100%)."
+            )
+
+        self.inverse_sqrt_win_size = self.initialize_inverse_sqrt_win_size()
+
         self.dft_length = (
             self.window_size - offset
             if (self.anova or self.variance) is True
@@ -254,6 +268,16 @@ class SFAFast(BaseCollectionTransformer):
         # self.word_bits = self.word_length_actual * self.letter_bits
         X = X.squeeze(1)
 
+        # subsample the samples
+        if self.sampling_factor:
+            rng = check_random_state(self.random_state)
+            sampled_indices = rng.choice(
+                X.shape[0],
+                size=min(np.int32(X.shape[0] * self.sampling_factor), X.shape[0]),
+                replace=False,
+            )
+            X = X[sampled_indices]
+
         if self.dilation >= 1 or self.first_difference:
             X2, self.X_index = _dilation(X, self.dilation, self.first_difference)
         else:
@@ -262,32 +286,35 @@ class SFAFast(BaseCollectionTransformer):
         self.n_cases, self.n_timepoints = X2.shape
         self.breakpoints = self._binning(X2, y)
 
-        words, dfts = _transform_case(
-            X2,
-            self.window_size,
-            self.dft_length,
-            self.word_length_actual,
-            self.norm,
-            self.remove_repeat_words,
-            self.support,
-            self.anova,
-            self.variance,
-            self.breakpoints,
-            self.letter_bits,
-            self.bigrams,
-            self.skip_grams,
-            self.inverse_sqrt_win_size,
-            self.lower_bounding or self.lower_bounding_distances,
-        )
+        # fitting: learns the feature selection strategy, too
+        if return_bag_of_words:
+            words, dfts = _transform_case(
+                X2,
+                self.window_size,
+                self.dft_length,
+                self.word_length_actual,
+                self.norm,
+                self.remove_repeat_words,
+                self.support,
+                self.anova,
+                self.variance,
+                self.breakpoints,
+                self.letter_bits,
+                self.bigrams,
+                self.skip_grams,
+                self.inverse_sqrt_win_size,
+                self.lower_bounding or self.lower_bounding_distances,
+            )
 
-        if self.remove_repeat_words:
-            words = remove_repeating_words(words)
+            if self.remove_repeat_words:
+                words = remove_repeating_words(words)
 
-        if self.save_words:
-            self.words = words
+            if self.save_words:
+                self.words = words
 
-        # fitting: learns the feature selection strategy, too
-        return self.transform_to_bag(words, self.word_length_actual, y)
+            return self.transform_to_bag(words, self.word_length_actual, y)
+        else:
+            return None
 
     def _fit(self, X, y=None):
         """Calculate word breakpoints using MCB or IGB.
@@ -302,7 +329,7 @@ class SFAFast(BaseCollectionTransformer):
         self: object
         """
         # with parallel_backend("loky", inner_max_num_threads=n_jobs):
-        self._fit_transform(X, y)
+        self._fit_transform(X, y, return_bag_of_words=False)
         return self
 
     def _transform(self, X, y=None):
@@ -365,6 +392,16 @@ class SFAFast(BaseCollectionTransformer):
             bags = csr_matrix(bags, dtype=np.uint32)
         return bags
 
+    def initialize_inverse_sqrt_win_size(self):
+        """Initialize the inverse square root constant."""
+        if self.window_size:
+            return (
+                1.0 / math.sqrt(self.window_size)
+                if (not self.lower_bounding or self.lower_bounding_distances)
+                else 1.0
+            )
+        return 1
+
     def transform_mft(self, X):
         """Transform data using the Fourier transform.
 
@@ -662,7 +699,7 @@ class SFAFast(BaseCollectionTransformer):
         )
 
     def transform_words(self, X):
-        """Return the words generated for each series.
+        """Return the words and dft coefficients generated for each series.
 
         Parameters
         ----------
@@ -675,7 +712,7 @@ class SFAFast(BaseCollectionTransformer):
         if X.ndim == 3:
             X = X.squeeze(1)
 
-        return _transform_words_case(
+        words, dfts = _transform_words_case(
             X,
             self.window_size,
             self.dft_length,
@@ -690,6 +727,8 @@ class SFAFast(BaseCollectionTransformer):
             self.breakpoints,
         )
 
+        return words.squeeze(), dfts.squeeze()
+
     @classmethod
     def _get_test_params(cls, parameter_set="default"):
         """Return testing parameter settings for the estimator.
@@ -763,30 +802,41 @@ def _binning_dft(
 ):
     num_windows_per_inst = math.ceil(n_timepoints / window_size)
 
-    # Splits individual time series into windows and returns the DFT for each
-    data = np.zeros((len(X), num_windows_per_inst, window_size))
-    for i in prange(len(X)):
-        for j in range(num_windows_per_inst - 1):
-            data[i, j] = X[i, window_size * j : window_size * (j + 1)]
+    # Windowing
+    if num_windows_per_inst > 1:
+        # Splits individual time series into windows and returns the DFT for each
+        data = np.zeros((len(X), num_windows_per_inst, window_size))
+        for i in prange(len(X)):
+            for j in range(num_windows_per_inst - 1):
+                data[i, j] = X[i, window_size * j : window_size * (j + 1)]
+
+            start = n_timepoints - window_size
+            data[i, -1] = X[i, start:n_timepoints]
+
+        dft = np.zeros((len(X), num_windows_per_inst, dft_length))
+        for i in prange(len(X)):
+            return_val = _fast_fourier_transform(
+                data[i], norm, dft_length, inverse_sqrt_win_size, True
+            )
+            dft[i] = return_val
 
-        start = n_timepoints - window_size
-        data[i, -1] = X[i, start:n_timepoints]
+        if lower_bounding:
+            dft[:, :, 1::2] = dft[:, :, 1::2] * -1  # lower bounding
 
-    dft = np.zeros((len(X), num_windows_per_inst, dft_length))
-    for i in prange(len(X)):
-        return_val = _fast_fourier_transform(
-            data[i], norm, dft_length, inverse_sqrt_win_size
-        )
-        dft[i] = return_val
+        return dft.reshape(dft.shape[0] * dft.shape[1], dft_length)
 
-    if lower_bounding:
-        dft[:, :, 1::2] = dft[:, :, 1::2] * -1  # lower bounding
+    # No Windowing (Whole Series)
+    else:
+        dft = _fast_fourier_transform(X, norm, dft_length, inverse_sqrt_win_size, False)
 
-    return dft.reshape(dft.shape[0] * dft.shape[1], dft_length)
+        if lower_bounding:
+            dft[:, 1::2] = dft[:, 1::2] * -1  # lower bounding
+
+        return dft
 
 
 @njit(fastmath=True, cache=True)
-def _fast_fourier_transform(X, norm, dft_length, inverse_sqrt_win_size):
+def _fast_fourier_transform(X, norm, dft_length, inverse_sqrt_win_size, norm_std=True):
     """Perform a discrete fourier transform using the fast fourier transform.
 
     if self.norm is True, then the first term of the DFT is ignored
@@ -807,21 +857,24 @@ def _fast_fourier_transform(X, norm, dft_length, inverse_sqrt_win_size):
     length = start + dft_length
     dft = np.zeros((len(X), length))  # , dtype=np.float64
 
-    stds = np.zeros(len(X))
-    for i in range(len(stds)):
-        stds[i] = np.std(X[i])
-    # stds = np.std(X, axis=1)  # not available in numba
-    stds = np.where(stds < 1e-8, 1, stds)
-
     with objmode(X_ffts="complex128[:,:]"):
-        X_ffts = np.fft.rfft(X, axis=1)  # complex128
+        X_ffts = scipy.fft.rfft(X, axis=1, workers=-1).astype(np.complex128)
+
     reals = np.real(X_ffts)  # float64[]
     imags = np.imag(X_ffts)  # float64[]
     dft[:, 0::2] = reals[:, 0 : length // 2]
     dft[:, 1::2] = imags[:, 0 : length // 2]
-    dft /= stds.reshape(-1, 1)
     dft *= inverse_sqrt_win_size
 
+    # apply z-normalization
+    if norm_std:
+        stds = np.zeros(len(X))
+        for i in range(len(stds)):
+            stds[i] = np.std(X[i])
+        # stds = np.std(X, axis=1)  # not available in numba
+        stds = np.where(stds < AEON_NUMBA_STD_THRESHOLD, 1, stds)
+        dft /= stds.reshape(-1, 1)
+
     return dft[:, start:]
 
 
@@ -895,7 +948,7 @@ def _calc_incremental_mean_std(series, end, window_size):
     r_window_length = 1.0 / window_size
     mean = series_sum * r_window_length
     buf = math.sqrt(max(square_sum * r_window_length - mean * mean, 0.0))
-    stds[0] = buf if buf > 1e-8 else 1
+    stds[0] = buf if buf > AEON_NUMBA_STD_THRESHOLD else 1
 
     for w in range(1, end):
         series_sum += series[w + window_size - 1] - series[w - 1]
@@ -905,7 +958,7 @@ def _calc_incremental_mean_std(series, end, window_size):
             - series[w - 1] * series[w - 1]
         )
         buf = math.sqrt(max(square_sum * r_window_length - mean * mean, 0.0))
-        stds[w] = buf if buf > 1e-8 else 1
+        stds[w] = buf if buf > AEON_NUMBA_STD_THRESHOLD else 1
 
     return stds
 
@@ -1009,8 +1062,11 @@ def _mft(
     transformed = np.zeros((X.shape[0], end, length))
 
     # 1. First run using DFT
-    with objmode(X_ffts="complex128[:,:]"):
-        X_ffts = np.fft.rfft(X[:, :window_size], axis=1)  # complex128
+    with objmode(X_ffts="complex128[:, :]"):
+        X_ffts = scipy.fft.rfft(X[:, :window_size], axis=1, workers=-1).astype(
+            np.complex128
+        )
+
     reals = np.real(X_ffts)  # float64[]
     imags = np.imag(X_ffts)  # float64[]
     transformed[:, 0, 0::2] = reals[:, 0 : length // 2]
@@ -1039,18 +1095,30 @@ def _mft(
     if lower_bounding:
         transformed2[:, :, 1::2] = transformed2[:, :, 1::2] * -1
 
-    # compute STDs
-    stds = np.zeros((X.shape[0], end))
-    for a in range(X.shape[0]):
-        stds[a] = _calc_incremental_mean_std(X[a], end, window_size)
+    # STD-normalization only applied for subsequences
+    if end > 1:
+        # compute STDs
+        stds = np.zeros((X.shape[0], end))
+        for a in range(X.shape[0]):
+            stds[a] = _calc_incremental_mean_std(X[a], end, window_size)
+
+        # divide all by stds and use only the best indices
+        if anova or variance:
+            return transformed2[:, :, mask] / stds.reshape(
+                stds.shape[0], stds.shape[1], 1
+            )
+        else:
+            return (transformed2 / stds.reshape(stds.shape[0], stds.shape[1], 1))[
+                :, :, start_offset:
+            ]
 
-    # divide all by stds and use only the best indices
-    if anova or variance:
-        return transformed2[:, :, mask] / stds.reshape(stds.shape[0], stds.shape[1], 1)
+    # Whole-Series
     else:
-        return (transformed2 / stds.reshape(stds.shape[0], stds.shape[1], 1))[
-            :, :, start_offset:
-        ]
+        # Do not norm
+        if anova or variance:
+            return transformed2[:, :, mask]
+        else:
+            return transformed2[:, :, start_offset:]
 
 
 def _dilation(X, d, first_difference):
@@ -1248,4 +1316,4 @@ def _transform_words_case(
                         words[x, window, i] = bp
                         break
 
-    return words
+    return words, dfts
diff --git a/aeon/transformations/collection/dictionary_based/_sfa_whole.py b/aeon/transformations/collection/dictionary_based/_sfa_whole.py
new file mode 100644
index 00000000..2e9f3df8
--- /dev/null
+++ b/aeon/transformations/collection/dictionary_based/_sfa_whole.py
@@ -0,0 +1,154 @@
+"""Symbolic Fourier Approximation (SFA) Transformer.
+
+Configurable SFA transform for discretising time series into words.
+
+"""
+
+__maintainer__ = []
+__all__ = ["SFAWhole"]
+
+from aeon.transformations.collection.dictionary_based import SFAFast
+
+
+class SFAWhole(SFAFast):
+    """Symbolic Fourier Approximation (SFA) Transformer.
+
+    A whole series transform for SFA which holds the lower bounding lemma, see [1].
+
+    It is implemented as a wrapper for the SFA-Fast transformer, the latter implements
+    subsequence-based SFA extraction.
+
+    This wrapper reduces non-needed parameters, and sets some usefull defaults for
+    lower bounding.
+
+    Parameters
+    ----------
+    word_length : int, default = 8
+        Length of word to shorten window to (using DFT).
+    alphabet_size : int, default = 4
+        Number of values to discretise each value to.
+    norm : boolean, default = False
+        Mean normalise words by dropping first fourier coefficient.
+    binning_method : str, default="equi-depth"
+        The binning method used to derive the breakpoints. One of {"equi-depth",
+        "equi-width", "information-gain", "information-gain-mae", "kmeans", "quantile"},
+    variance : boolean, default = False
+        If True, the Fourier coefficient selection is done via the largest variance.
+        If False, the first Fourier coefficients are selected. Only applicable if
+        labels are given.
+    sampling_factor : float, default = None
+       If set to a value <1.0, this percentage of samples are used to learn MCB bins.
+    n_jobs : int, default = 1
+        The number of jobs to run in parallel for both `transform`.
+        ``-1`` means using all processors.
+
+    Attributes
+    ----------
+    breakpoints: = []
+    num_insts = 0
+    num_atts = 0
+
+
+    References
+    ----------
+    .. [1] Schäfer, Patrick, and Mikael Högqvist. "SFA: a symbolic fourier approximation
+    and  index for similarity search in high dimensional datasets." Proceedings of the
+    15th international conference on extending database technology. 2012.
+    """
+
+    _tags = {
+        "requires_y": False,  # SFA is unsupervised for equi-depth and equi-width bins
+        "capability:multithreading": True,
+        "algorithm_type": "dictionary",
+    }
+
+    def __init__(
+        self,
+        word_length=8,
+        alphabet_size=4,
+        norm=True,
+        binning_method="equi-depth",
+        variance=True,
+        sampling_factor=None,
+        random_state=None,
+        n_jobs=1,
+    ):
+        super().__init__(
+            word_length=word_length,
+            alphabet_size=alphabet_size,
+            norm=norm,
+            binning_method=binning_method,
+            variance=variance,
+            sampling_factor=sampling_factor,
+            random_state=random_state,
+            n_jobs=n_jobs,
+            # Default values for other parameters
+            lower_bounding_distances=True,
+            feature_selection="none",
+            anova=False,
+            save_words=False,
+            lower_bounding=False,
+            bigrams=False,
+            skip_grams=False,
+            remove_repeat_words=False,
+            return_sparse=False,
+            window_size=None,  # set in fit
+        )
+
+    def _fit_transform(self, X, y=None):
+        super()._fit_transform(X, y, return_bag_of_words=False)
+        return self.transform_words(X)
+
+    def _fit(self, X, y=None):
+        """Calculate word breakpoints.
+
+        Parameters
+        ----------
+        X : 3d numpy array, input time series.
+        y : array_like, target values (optional, ignored).
+
+        Returns
+        -------
+        self: object
+        """
+        super()._fit_transform(X, y, return_bag_of_words=False)
+        return self
+
+    def _transform(self, X, y=None):
+        """Transform data into SFA words.
+
+        Parameters
+        ----------
+        X : 3d numpy array, input time series.
+
+        Returns
+        -------
+        List of words containing SFA words
+        """
+        return self.transform_words(X)
+
+    @classmethod
+    def _get_test_params(cls, parameter_set="default"):
+        """Return testing parameter settings for the estimator.
+
+        Parameters
+        ----------
+        parameter_set : str, default="default"
+            Name of the set of test parameters to return, for use in tests. If no
+            special parameters are defined for a value, will return `"default"` set.
+
+        Returns
+        -------
+        params : dict or list of dict, default = {}
+            Parameters to create testing instances of the class
+            Each dict are parameters to construct an "interesting" test instance, i.e.,
+            `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
+            `create_test_instance` uses the first (or only) dictionary in `params`
+        """
+        # small window size for testing
+        params = {
+            "word_length": 4,
+            "alphabet_size": 4,
+            "variance": False,
+        }
+        return params


Test patch:
diff --git a/aeon/distances/tests/test_symbolic_mindist.py b/aeon/distances/tests/test_symbolic_mindist.py
index 197a9dbb..2d81e04d 100644
--- a/aeon/distances/tests/test_symbolic_mindist.py
+++ b/aeon/distances/tests/test_symbolic_mindist.py
@@ -8,7 +8,7 @@ from aeon.distances.mindist._dft_sfa import mindist_dft_sfa_distance
 from aeon.distances.mindist._paa_sax import mindist_paa_sax_distance
 from aeon.distances.mindist._sax import mindist_sax_distance
 from aeon.distances.mindist._sfa import mindist_sfa_distance
-from aeon.transformations.collection.dictionary_based import SAX, SFA, SFAFast
+from aeon.transformations.collection.dictionary_based import SAX, SFA, SFAFast, SFAWhole
 
 
 def test_sax_mindist():
@@ -69,7 +69,7 @@ def test_sfa_mindist():
         window_size=n,
         binning_method=histogram_type,
         norm=True,
-        variance=True,
+        variance=False,  # True gives a tighter lower bound
         lower_bounding_distances=True,  # This must be set!
     )
 
@@ -81,14 +81,24 @@ def test_sfa_mindist():
         norm=True,
         lower_bounding_distances=True,  # This must be set!
     )
-    transforms = [sfa_old, sfa_fast]
 
-    for sfa in transforms:
-        sfa.fit(X_train)
-        X_train_words = sfa.transform_words(X_train).squeeze()
-        Y_train_words = sfa.transform_words(X_test).squeeze()
+    sfa_whole = SFAWhole(
+        word_length=n_segments,
+        alphabet_size=alphabet_size,
+        binning_method=histogram_type,
+        variance=False,  # True gives a tighter lower bound
+        norm=True,
+    )
 
-        SFA_train_dfts = sfa.transform_mft(X_train).squeeze()
+    transforms = [sfa_fast, sfa_old, sfa_whole]
+    dists = np.zeros(
+        (min(X_train.shape[0], X_test.shape[0]), len(transforms)), dtype=np.float32
+    )
+
+    for j, sfa in enumerate(transforms):
+        sfa.fit(X_train)
+        X_train_words, X_train_dfts = sfa.transform_words(X_train)
+        X_test_words, _ = sfa.transform_words(X_test)
 
         for i in range(min(X_train.shape[0], X_test.shape[0])):
             X = X_train[i].reshape(1, -1)
@@ -96,12 +106,14 @@ def test_sfa_mindist():
 
             # SFA Min-Distance
             mindist_sfa = mindist_sfa_distance(
-                X_train_words[i], Y_train_words[i], sfa.breakpoints
+                X_train_words[i], X_test_words[i], sfa.breakpoints
             )
 
+            dists[i, j] = mindist_sfa
+
             # DFT-SFA Min-Distance
             mindist_dft_sfa = mindist_dft_sfa_distance(
-                SFA_train_dfts[i], Y_train_words[i], sfa.breakpoints
+                X_train_dfts[i], X_test_words[i], sfa.breakpoints
             )
 
             # Euclidean Distance
@@ -110,3 +122,51 @@ def test_sfa_mindist():
             assert mindist_sfa <= ed
             assert mindist_dft_sfa >= mindist_sfa  # a tighter lower bound
             assert mindist_dft_sfa <= ed
+
+    for i in range(min(X_train.shape[0], X_test.shape[0])):
+        assert np.allclose(*dists[i])
+
+
+def test_sfa_whole_mindist():
+    """Test the SFA Min-Distance function."""
+    n_segments = 16
+    alphabet_size = 8
+
+    X_train, _ = load_unit_test("TRAIN")
+    X_test, _ = load_unit_test("TEST")
+
+    X_train = zscore(X_train.squeeze(), axis=1)
+    X_test = zscore(X_test.squeeze(), axis=1)
+
+    histogram_type = "equi-width"
+
+    sfa = SFAWhole(
+        word_length=n_segments,
+        alphabet_size=alphabet_size,
+        binning_method=histogram_type,
+        norm=True,
+    )
+
+    X_train_words, X_train_dfts = sfa.fit_transform(X_train)
+    X_test_words, _ = sfa.transform(X_test)
+
+    for i in range(min(X_train.shape[0], X_test.shape[0])):
+        X = X_train[i].reshape(1, -1)
+        Y = X_test[i].reshape(1, -1)
+
+        # SFA Min-Distance
+        mindist_sfa = mindist_sfa_distance(
+            X_train_words[i], X_test_words[i], sfa.breakpoints
+        )
+
+        # DFT-SFA Min-Distance
+        mindist_dft_sfa = mindist_dft_sfa_distance(
+            X_train_dfts[i], X_test_words[i], sfa.breakpoints
+        )
+
+        # Euclidean Distance
+        ed = np.linalg.norm(X[0] - Y[0])
+
+        assert mindist_sfa <= ed
+        assert mindist_dft_sfa >= mindist_sfa  # a tighter lower bound
+        assert mindist_dft_sfa <= ed