diff --git a/aeon/transformations/collection/dictionary_based/_sax.py b/aeon/transformations/collection/dictionary_based/_sax.py index 0bced4be37..6a85d54e7a 100644 --- a/aeon/transformations/collection/dictionary_based/_sax.py +++ b/aeon/transformations/collection/dictionary_based/_sax.py @@ -3,6 +3,8 @@ __maintainer__ = [] __all__ = ["SAX", "_invert_sax_symbols"] +import warnings + import numpy as np import scipy.stats from numba import get_num_threads, njit, prange, set_num_threads @@ -42,10 +44,9 @@ class SAX(BaseCollectionTransformer): 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, - Whether the input is already z-normalized. If False, each complete - series is normalized before PAA when ``window_size=None``. When - windowing is enabled, each extracted window is normalized independently + znormalize : bool, default = True, + Whether to z-normalize each series before PAA when ``window_size=None``. + When windowing is enabled, each extracted window is normalized independently before PAA, as required by the sliding-window SAX formulation. window_size : int, default = None, The size of the sliding window to use when transforming the time series, @@ -57,6 +58,10 @@ class SAX(BaseCollectionTransformer): only used when the window_size parameter is not None. n_jobs : int, default = 1, The number of jobs to run in parallel for both `fit` and `transform`. + znormalized : bool, default="deprecated" + Old identifier for ``znormalize``. + + Deprecated and will be removed in v1.7.0. Notes ----- @@ -85,6 +90,7 @@ class SAX(BaseCollectionTransformer): "algorithm_type": "dictionary", } + # TODO remove 'znormalized' in v1.7.0 def __init__( self, n_segments: int = 8, @@ -92,10 +98,11 @@ def __init__( alphabet: list = None, distribution: str = "Gaussian", distribution_params: dict = None, - znormalized: bool = True, + znormalize: bool = True, window_size: int = None, stride: int = 1, n_jobs: int = 1, + znormalized: bool = "deprecated", ): self.n_segments = n_segments @@ -110,11 +117,21 @@ def __init__( self.distribution = distribution self.n_jobs = n_jobs self.distribution_params = distribution_params - self.znormalized = znormalized - self.window_size = window_size self.stride = stride + self.znormalize = znormalize + self.znormalized = znormalized + + if znormalized != "deprecated": + warnings.warn( + "The 'znormalized' parameter of SAX is deprecated and will be " + "removed in v1.7.0. Please use 'znormalize' instead.", + FutureWarning, + stacklevel=2, + ) + self.znormalize = not znormalized + if self.distribution == "Gaussian": self.distribution_params_ = ( dict(scale=1.0) @@ -181,7 +198,7 @@ def _get_paa(self, X): X_paa : np.ndarray of shape = (n_cases, n_channels, n_segments) The output of the PAA transformation """ - if not self.znormalized: + if self.znormalize: X = self._z_normalize(X) paa = PAA(n_segments=self.n_segments, n_jobs=self.n_jobs) @@ -235,7 +252,7 @@ def _transform(self, X, y=None): self.window_size, ) - if self.znormalized: + if not self.znormalize: X_windows_normalized = X_windows_3d self._window_means_ = None self._window_stds_ = None @@ -333,12 +350,12 @@ def inverse_sax( window_means : np.ndarray, optional Per-window means with shape (n_cases, n_channels, n_windows, 1). Required to restore the - original scale when ``znormalized=False`` unless the statistics + original scale when ``znormalize=True`` unless the statistics were stored by the most recent call to ``transform``. window_stds : np.ndarray, optional Per-window standard deviations with shape (n_cases, n_channels, n_windows, 1). Required to restore the - original scale when ``znormalized=False`` unless the statistics + original scale when ``znormalize=True`` unless the statistics were stored by the most recent call to ``transform``. Returns @@ -411,7 +428,7 @@ def inverse_sax( "covered by the SAX windows" ) - if self.znormalized: + if not self.znormalize: if window_means is None: window_means = np.zeros( ( @@ -442,7 +459,7 @@ def inverse_sax( raise ValueError( "window_means and window_stds are required to " "denormalize windowed SAX output when " - "znormalized=False" + "znormalize=True" ) window_means = np.asarray( @@ -483,7 +500,7 @@ def inverse_sax( breakpoints_mid=self.breakpoints_mid, window_means=window_means, window_stds=window_stds, - denormalize=not self.znormalized, + denormalize=self.znormalize, ) raise ValueError( diff --git a/aeon/transformations/collection/dictionary_based/tests/test_sax.py b/aeon/transformations/collection/dictionary_based/tests/test_sax.py index db5d3a6ecf..f83e961614 100644 --- a/aeon/transformations/collection/dictionary_based/tests/test_sax.py +++ b/aeon/transformations/collection/dictionary_based/tests/test_sax.py @@ -24,7 +24,7 @@ def test_sax_standard_output_and_inverse_shapes(shape, n_segments, alphabet_size sax = SAX( n_segments=n_segments, alphabet_size=alphabet_size, - znormalized=True, + znormalize=False, n_jobs=2, ) @@ -59,7 +59,7 @@ def test_sax_known_symbols_and_inverse_values(): n_segments=4, alphabet_size=4, alphabet=alphabet, - znormalized=True, + znormalize=False, ) X_sax = sax.fit_transform(X) @@ -100,7 +100,7 @@ def test_sax_custom_alphabet_is_preserved(alphabet): n_segments=4, alphabet_size=4, alphabet=alphabet, - znormalized=True, + znormalize=False, ) X_sax = sax.fit_transform(X) @@ -119,7 +119,7 @@ def test_sax_custom_alphabet_round_trip(): n_segments=4, alphabet_size=4, alphabet=["low", "mid-low", "mid-high", "high"], - znormalized=True, + znormalize=False, ) X_sax = sax.fit_transform(X) @@ -139,7 +139,7 @@ def test_sax_inverse_rejects_duplicate_alphabet(): n_segments=4, alphabet_size=4, alphabet=["a", "a", "b", "c"], - znormalized=True, + znormalize=False, ) X_sax = np.array([[["a", "a", "b", "c"]]]) @@ -153,7 +153,7 @@ def test_sax_inverse_rejects_unknown_symbol(): n_segments=4, alphabet_size=4, alphabet=["a", "b", "c", "d"], - znormalized=True, + znormalize=False, ) X_sax = np.array([[["a", "b", "unknown", "d"]]]) @@ -161,7 +161,7 @@ def test_sax_inverse_rejects_unknown_symbol(): sax.inverse_sax(X_sax, original_length=8) -def test_sax_znormalized_false_matches_manual_normalization(): +def test_sax_znormalize_true_matches_manual_normalization(): """Test that internal normalization matches manual z-normalization.""" rng = np.random.RandomState(0) X = rng.normal(loc=5, scale=2, size=(5, 2, 40)) @@ -170,26 +170,26 @@ def test_sax_znormalized_false_matches_manual_normalization(): words_internal = SAX( n_segments=8, alphabet_size=4, - znormalized=False, + znormalize=True, ).fit_transform(X) words_manual = SAX( n_segments=8, alphabet_size=4, - znormalized=True, + znormalize=False, ).fit_transform(X_normalized) np.testing.assert_array_equal(words_internal, words_manual) -def test_sax_znormalized_false_handles_constant_series(): +def test_sax_znormalize_true_handles_constant_series(): """Test that internal normalization safely handles zero variance.""" X = np.full((2, 1, 16), 5.0) X_sax = SAX( n_segments=4, alphabet_size=4, - znormalized=False, + znormalize=True, ).fit_transform(X) # A normalized constant series is zero. np.digitize with right=False @@ -224,7 +224,7 @@ def test_windowed_sax_normalizes_each_window_independently(): n_segments=2, alphabet_size=4, alphabet=["a", "b", "c", "d"], - znormalized=False, + znormalize=True, window_size=4, stride=4, ) @@ -260,7 +260,7 @@ def test_windowed_sax_matches_manual_per_window_normalization(): words_internal = SAX( n_segments=2, alphabet_size=4, - znormalized=False, + znormalize=True, window_size=window_size, stride=stride, ).fit_transform(X) @@ -272,7 +272,7 @@ def test_windowed_sax_matches_manual_per_window_normalization(): words_manual = SAX( n_segments=2, alphabet_size=4, - znormalized=True, + znormalize=False, ).fit_transform(X_manual) words_manual = words_manual.reshape(1, 2, 1, 2).transpose(0, 2, 1, 3) @@ -302,7 +302,7 @@ def test_windowed_sax_differs_from_whole_series_normalization(): words_per_window = SAX( n_segments=2, alphabet_size=4, - znormalized=False, + znormalize=True, window_size=4, stride=4, ).fit_transform(X) @@ -311,7 +311,7 @@ def test_windowed_sax_differs_from_whole_series_normalization(): words_whole_normalized = SAX( n_segments=2, alphabet_size=4, - znormalized=True, + znormalize=False, window_size=4, stride=4, ).fit_transform(X_whole_normalized) @@ -337,7 +337,7 @@ def test_windowed_sax_output_shape(window_size, stride, expected_n_windows): sax = SAX( n_segments=4, alphabet_size=4, - znormalized=True, + znormalize=False, window_size=window_size, stride=stride, ) @@ -367,7 +367,7 @@ def test_windowed_sax_known_words_non_overlapping(): n_segments=2, alphabet_size=4, alphabet=["a", "b", "c", "d"], - znormalized=False, + znormalize=True, window_size=4, stride=4, ) @@ -389,7 +389,7 @@ def test_windowed_sax_known_words_without_internal_normalization(): n_segments=2, alphabet_size=4, alphabet=["a", "b", "c", "d"], - znormalized=True, + znormalize=False, window_size=4, stride=4, ) @@ -408,7 +408,7 @@ def test_windowed_inverse_known_non_overlapping_result(): n_segments=2, alphabet_size=4, alphabet=["a", "b", "c", "d"], - znormalized=True, + znormalize=False, window_size=4, stride=4, ) @@ -446,7 +446,7 @@ def test_windowed_inverse_averages_overlaps(): n_segments=2, alphabet_size=4, alphabet=["a", "b", "c", "d"], - znormalized=True, + znormalize=False, window_size=4, stride=2, ) @@ -480,7 +480,7 @@ def test_windowed_inverse_infers_covered_length(): n_segments=2, alphabet_size=4, alphabet=["a", "b", "c", "d"], - znormalized=True, + znormalize=False, window_size=4, stride=2, ) @@ -502,7 +502,7 @@ def test_windowed_sax_multivariate_custom_alphabet(): n_segments=4, alphabet_size=5, alphabet=alphabet, - znormalized=True, + znormalize=False, window_size=12, stride=6, ) @@ -523,7 +523,7 @@ def test_windowed_inverse_denormalizes_before_overlap_average(): n_segments=2, alphabet_size=4, alphabet=["a", "b", "c", "d"], - znormalized=False, + znormalize=True, window_size=4, stride=2, ) @@ -574,7 +574,7 @@ def test_windowed_inverse_uses_stats_from_latest_transform(): sax = SAX( n_segments=2, alphabet_size=4, - znormalized=False, + znormalize=True, window_size=4, stride=4, ) @@ -593,7 +593,7 @@ def test_windowed_inverse_requires_stats_for_denormalization(): sax = SAX( n_segments=2, alphabet_size=4, - znormalized=False, + znormalize=True, window_size=4, stride=2, ) @@ -610,7 +610,7 @@ def test_windowed_inverse_rejects_invalid_stats_shape(parameter_name): sax = SAX( n_segments=2, alphabet_size=4, - znormalized=False, + znormalize=True, window_size=4, stride=2, ) @@ -651,7 +651,7 @@ def test_invalid_window_parameters_raise(window_size, stride, error_type, match) sax = SAX( n_segments=4, alphabet_size=4, - znormalized=True, + znormalize=False, window_size=window_size, stride=stride, ) @@ -666,7 +666,7 @@ def test_non_integer_window_size_raises(): sax = SAX( n_segments=4, alphabet_size=4, - znormalized=True, + znormalize=False, window_size=8.5, stride=1, ) @@ -677,7 +677,7 @@ def test_non_integer_window_size_raises(): def test_standard_inverse_requires_original_length(): """Test that standard inverse SAX requires a target length.""" - sax = SAX(n_segments=4, alphabet_size=4, znormalized=True) + sax = SAX(n_segments=4, alphabet_size=4, znormalize=False) X_sax = np.zeros((1, 1, 4), dtype=np.intp) with pytest.raises(ValueError, match="original_length"): @@ -686,7 +686,7 @@ def test_standard_inverse_requires_original_length(): def test_inverse_rejects_invalid_number_of_dimensions(): """Test that inverse SAX rejects arrays that are not 3D or 4D.""" - sax = SAX(n_segments=4, alphabet_size=4, znormalized=True) + sax = SAX(n_segments=4, alphabet_size=4, znormalize=False) with pytest.raises(ValueError, match="shape"): sax.inverse_sax(np.zeros((1, 4), dtype=np.intp), original_length=8) @@ -698,7 +698,7 @@ def test_windowed_inverse_rejects_too_short_original_length(): sax = SAX( n_segments=2, alphabet_size=4, - znormalized=True, + znormalize=False, window_size=4, stride=2, ) @@ -711,7 +711,7 @@ def test_windowed_inverse_rejects_too_short_original_length(): def test_unsupported_distribution_raises(): """Test that unsupported distributions are rejected.""" with pytest.raises(NotImplementedError): - SAX(distribution="bogus", znormalized=True) + SAX(distribution="bogus", znormalize=False) def test_alphabet_length_must_match_alphabet_size(): @@ -720,13 +720,28 @@ def test_alphabet_length_must_match_alphabet_size(): SAX( alphabet_size=4, alphabet=["a", "b", "c"], - znormalized=True, + znormalize=False, ) -def test_sax_default_znormalized_is_true(): - """Test that SAX treats input as pre-normalized by default.""" - assert SAX().znormalized is True +def test_sax_default_znormalize_is_true(): + """Test that SAX performs z-normalization by default.""" + sax = SAX() + assert sax.znormalize is True + assert sax.znormalized == "deprecated" + + +def test_sax_znormalized_deprecated(): + """Test that using znormalized emits a FutureWarning and maps to znormalize.""" + with pytest.warns(FutureWarning, match="znormalized"): + sax_true = SAX(znormalized=True) + assert sax_true.znormalize is False + assert sax_true.znormalized is True + + with pytest.warns(FutureWarning, match="znormalized"): + sax_false = SAX(znormalized=False) + assert sax_false.znormalize is True + assert sax_false.znormalized is False def test_sax_get_test_params():