Repository: sktime/sktime
Pull request #5742 (2024-01-15 13:52:37)

[ENH] New PAA and SAX transformer implementations
<!--
Welcome to sktime, and thanks for contributing!
Please have a look at our contribution guide:
https://www.sktime.net/en/latest/get_involved/contributing.html
-->

#### 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.
If no issue exists, you can open one here: https://github.com/sktime/sktime/issues
-->
See also #5732.

#### What does this implement/fix? Explain your changes.
<!--
A clear and concise description of what you have implemented.
-->
New implementations for PAA and SAX. The old ones are in transformations/panel/dictionary_based. The new code is more efficient and concise. If anything is unclear in the new code, I'm happy to add a comment/make it more clear!

#### Does your contribution introduce a new dependency? If yes, which one?

<!--
Only relevant if you changed pyproject.toml.
We try to minimize dependencies in the core dependency set. There
are also further specific instructions to follow for soft dependencies.
See here for handling dependencies in sktime: https://www.sktime.net/en/latest/developer_guide/dependencies.html
-->
No.

#### 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 -->
This is my first time contributing. Though I did follow the template, some elements might be incompliant with the required format.

#### Did you add any tests for the change?

<!-- 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.
-->
Yes.

#### 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/sktime/sktime/blob/main/CONTRIBUTORS.md) with any new badges I've earned :-)
  How to: add yourself to the [all-contributors file](https://github.com/sktime/sktime/blob/main/.all-contributorsrc) in the `sktime` root directory (not the `CONTRIBUTORS.md`). Common badges: `code` - fixing a bug, or adding code logic. `doc` - writing or improving documentation or docstrings. `bug` - reporting or diagnosing a bug (get this plus `code` if you also fixed the bug in the PR).`maintenance` - CI, test framework, release.
  See here for [full badge reference](https://allcontributors.org/docs/en/emoji-key)
- [ ] Optionally, I've added myself and possibly others to the [CODEOWNERS](https://github.com/sktime/sktime/blob/main/CODEOWNERS) file - do this if you want to become the owner or maintainer of an estimator you added.
  See here for further details on the [algorithm maintainer role](https://www.sktime.net/en/latest/get_involved/governance.html#algorithm-maintainers).
- [x] The PR title starts with either [ENH], [MNT], [DOC], or [BUG]. [BUG] - bugfix, [MNT] - CI, test framework, [ENH] - adding or improving code, [DOC] - writing or improving documentation or docstrings.

##### For new estimators
- [x] I've added the estimator to the API reference - in `docs/source/api_reference/taskname.rst`, follow the pattern.
- [x] I've added one or more illustrative usage examples to the docstring, in a pydocstyle compliant `Examples` section.


<!--
Thanks for contributing!
-->


fkiraly: Failures are due to no blank after `>>>` in doctest, it should be `>>> line1 = stuff`.
Easy fix, I didn't do it because you might be moving the estimators around, to avoid merge conflicts.
steenrotsman: Fixed. I changed the names to `PAA2` and `SAX2`, but I'd vote for deprecating the old classes. As for the section they should go in, I'm not sure. The returned types look like other time series (though typically a time series is defined as a sequence of real numbers, while the sequences returned by SAX can only contain natural numbers, often represented as characters). However, their use might be closer to primitives, as they were originally used to give lower bounds to the Euclidean distance.
I'm fine with either.
fkiraly: > Fixed. I changed the names to `PAA2` and `SAX2`, but I'd vote for deprecating the old classes.

Ok - we need to do that step-wise though, and ensure we do not break code downstream. I suggest opening a separate issue to discuss this and track.
One could do it this way:

* at the end, we rename `PAA` to `PAAlegacy` and `PAA2` to `PAA`, same for `SAX`
* for this, we need to come up with a good deprecation mechanism
    * the first step would be a warnig message in the constructor of `PAA` / `SAX` in every case where we expect reakage, e.g., the output conversion. It also needs to include migration instuctions - "stay if" and "switch if".
    * the "stay" condition is probably for all the same parameters, the "switch" codition is when non-joint parameters are used?
* probably we should also comment in the current docstring - separate PR though.

>As for the section they should go in, I'm not sure. The returned types look like other time series. 

We cosider sequences as a sub-type of time series (index-less), so yes, it should be series-to-series.
The section you selected looks fine, I suggest to move the older `PAX` and `SAA` to here.

> However, their use might be closer to primitives, as they were originally used to give lower bounds to the Euclidean distance.

Yes, although that's a typing question.

You *could* make the transformer polymorphic, by doing a dynamic `set_tag` in the `__init__` depending on a parameter. This would however assume that the number of primitive features for all time series instances in a panel are the same, and I'm not sure whether that is the case.
fkiraly: Silly question, from me out of interest since you seem familiar with the literature: how is `PAA` different from `TimeBinner`? Is it through the additional logic which selects breakpoints?

I.e., would it be the same as a parameter estimator, say, `BreakPointSelector`, and then you pipeline it with `TimeBinner`?
fkiraly: doctests are now failing, since it expects an output print below. You can use doctest skip, as this would not reduce coverage, or an ellipsis statement like `np.array(...)` to make your life easier if you want.
steenrotsman: > assume that the number of primitive features for all time series instances in a panel are the same

This is the case when using the `frames` and `word_size` attributes, but not when using the `frame_size` attribute.

> how is `PAA` different from `TimeBinner`?

Using `frames`, which is the original definition, each frame is exactly the same size and time points can be part of multiple frames. The effects of using `frame_size` can be achieved with `TimeBinner` as well, but `TimeBinner` can set other `aggfunc`s and divide the series into an arbitrary number of frames of arbitrary sizes. With a proper `aggfunc`, `TimeBinner` can probably also emulate `SAX`. Implementation-wise, `TimeBinner` returns primitives, so always a `DataFrame`, while `PAA` returns the input type.

> doctests are now failing

Weird, I think I followed the same format as in NumPy's own docstring... I'll skip them as the same outputs are tested with pytest already.

fkiraly: >> assume that the number of primitive features for all time series instances in a panel are the same

> This is the case when using the `frames` and `word_size` attributes, but not when using the `frame_size` attribute.

Then it does not satisfy the "series-to-primitives" contract, which in `transform` must transform n time series (however represented) into a 2D array-like of size n times k, for some k.

> Implementation-wise, `TimeBinner` returns primitives, so always a `DataFrame`, while `PAA` returns the input type.

Ok, then it is probably closer to `TimeBinAggregate`?
steenrotsman: > can you kindly make sure the API docs are consistent and up-to-date

I'm not sure if I understand correctly. Should I edit the docstrings for the old classes to indicate that they might be deprecated in the future?

Patch:
diff --git a/.all-contributorsrc b/.all-contributorsrc
index 89b417e16..a679950aa 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -2527,6 +2527,16 @@
         "maintenance"
       ]
     },
+    {
+      "login": "steenrotsman",
+      "name": "Stijn J. Rotman",
+      "avatar_url": "https://avatars.githubusercontent.com/u/78110080?s=400&v=4",
+      "profile": "https://github.com/steenrotsman",
+      "contributions": [
+        "code",
+        "doc"
+      ]
+    },
     {
       "login": "tvdboom",
       "name": "Mavs",
diff --git a/docs/source/api_reference/transformations.rst b/docs/source/api_reference/transformations.rst
index 28028133a..2cf54685c 100644
--- a/docs/source/api_reference/transformations.rst
+++ b/docs/source/api_reference/transformations.rst
@@ -188,9 +188,7 @@ Dictionary-based features
     :toctree: auto_generated/
     :template: class.rst
 
-    PAA
     SFA
-    SAX
 
 Moment-based features
 ~~~~~~~~~~~~~~~~~~~~~
@@ -447,6 +445,31 @@ Binning, sampling and segmentation
 
     DilationMappingTransformer
 
+.. currentmodule:: sktime.transformations.series.paa
+
+.. autosummary::
+    :toctree: auto_generated/
+    :template: class.rst
+
+    PAA2
+
+.. currentmodule:: sktime.transformations.series.sax
+
+.. autosummary::
+    :toctree: auto_generated/
+    :template: class.rst
+
+    SAX2
+
+.. currentmodule:: sktime.transformations.panel.dictionary_based
+
+.. autosummary::
+    :toctree: auto_generated/
+    :template: class.rst
+
+    PAA
+    SAX
+
 Missing value treatment
 ~~~~~~~~~~~~~~~~~~~~~~~~
 
diff --git a/pyproject.toml b/pyproject.toml
index 51e45612d..71eccc866 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -56,7 +56,7 @@ dependencies = [
   "numpy<1.27,>=1.21",  # required for framework layer and base class logic
   "packaging",  # for estimator specific dependency parsing
   "pandas<2.2.0,>=1.1",  # pandas is the main in-memory data container
-  "scikit-base<0.7.0",  # base module for sklearn compatible base API
+  "scikit-base<0.8.0",  # base module for sklearn compatible base API
   "scikit-learn<1.4.0,>=0.24",  # required for estimators and framework layer
   "scipy<2.0.0,>=1.2",  # required for estimators and framework layer
 ]
@@ -200,7 +200,7 @@ regression = [
 transformations = [
   'esig<0.10,>=0.9.7; python_version < "3.11"',
   "filterpy<1.5,>=1.4.5",
-  "holidays>=0.29,<0.41",
+  "holidays>=0.29,<0.42",
   "mne<1.6,>=1.5",
   'numba<0.59,>=0.53; python_version < "3.12"',
   "pycatch22>=0.4,<0.4.4",
diff --git a/sktime/transformations/series/paa.py b/sktime/transformations/series/paa.py
new file mode 100644
index 000000000..a492b2fdb
--- /dev/null
+++ b/sktime/transformations/series/paa.py
@@ -0,0 +1,149 @@
+"""Piecewise Aggregate Approximation Transformer."""
+__author__ = ["steenrotsman"]
+import numpy as np
+
+from sktime.transformations.base import BaseTransformer
+
+
+class PAA2(BaseTransformer):
+    """Piecewise Aggregate Approximation Transformer (PAA).
+
+    PAA [1]_ is a dimensionality reduction technique that divides a time series
+    into frames and takes their mean. This implementation offers two variants:
+     1) the original, which takes the desired number of frames and can set the
+     frame size to a fraction to support cases where the time series cannot be
+     divided into the frames equally.
+     2) a variant that takes the desired frame size and can decrease the frame
+     size of the last frame to support cases where the time series is not
+     evenly divisible into frames.
+
+    Parameters
+    ----------
+    frames : int, optional (default=8, greater equal 1 if frame_size=0)
+        length of transformed time series. Ignored if `frame_size` is set.
+    frame_size : int, optional (default=0, greater equal 0)
+        length of the frames over which the mean is taken. Overrides `frames` if > 0.
+
+    References
+    ----------
+    .. [1] Keogh, E., Chakrabarti, K., Pazzani, M., and Mehrotra, S.
+        Dimensionality Reduction for Fast Similarity Search
+        in Large Time Series Databases.
+        Knowledge and Information Systems 3, 263–286 (2001).
+        https://doi.org/10.1007/PL00011669
+
+    Examples
+    --------
+    >>> from numpy import arange
+    >>> from sktime.transformations.series.paa import PAA2
+
+    >>> X = arange(10)
+    >>> paa = PAA2(frames=3)
+    >>> paa.fit_transform(X)  # doctest: +SKIP
+    array([1.2, 4.5, 7.8])
+    >>> paa = PAA2(frame_size=3)  # doctest: +SKIP
+    array([1, 4, 7, 9])
+    """
+
+    _tags = {
+        "scitype:transform-input": "Series",
+        "scitype:transform-output": "Series",
+        "scitype:instancewise": True,
+        "scitype:transform-labels": "None",
+        "X_inner_mtype": "np.ndarray",
+        "y_inner_mtype": "None",
+        "univariate-only": True,
+        "requires_y": False,
+        "fit_is_empty": True,
+        "capability:inverse_transform": False,
+        "handles-missing-data": False,
+    }
+
+    def __init__(self, frames=8, frame_size=0):
+        self.frames = frames
+        self.frame_size = frame_size
+
+        super().__init__()
+
+        self._check_params()
+
+    def _transform(self, X, y=None):
+        """Transform X and return a transformed version.
+
+        private _transform containing core logic, called from transform
+
+        Parameters
+        ----------
+        X : Series of mtype np.ndarray
+            data to be transformed
+        y : None
+            unused
+
+        Returns
+        -------
+        X_transformed : Series of mtype np.ndarray
+            transformed version of X
+        """
+        if self.frame_size:
+            return self._transform_frame_size(X)
+        return self._transform_frames(X)
+
+    def _transform_frames(self, X):
+        """Original PAA definition.
+
+        First performs validity check and handles trivial cases. If self.frames evenly
+        divides X, calculation is easy. Else, values in X are weighed into the frames.
+        Last case corrects and adapts https://vigne.sh/posts/piecewise-aggregate-approx/
+        """
+        if self.frames > X.shape[0]:
+            raise ValueError(
+                "Series length cannot be shorter than the desired number of frames."
+            )
+        elif not X.shape[0] % self.frames:
+            return X.reshape(self.frames, -1).mean(axis=1).T
+
+        indices = np.arange(X.shape[0] * self.frames) // self.frames
+        return X[indices.reshape(self.frames, -1)].sum(axis=1) / X.shape[0]
+
+    def _transform_frame_size(self, X):
+        if self.frame_size > X.shape[0]:
+            raise ValueError(
+                "Series length cannot be shorter than the desired frame size."
+            )
+        elif last_frame_length := (X.shape[0] % self.frame_size):
+            last_frame_mean = np.mean(X[-last_frame_length:])
+            last_frame_fill = [last_frame_mean] * (self.frame_size - last_frame_length)
+            X = np.append(X, last_frame_fill)
+
+        return np.mean(X.reshape(-1, self.frame_size), axis=1).T
+
+    @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.
+            There are currently no reserved values for transformers.
+
+        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`
+        """
+        params = {"frames": 4}
+        return params
+
+    def _check_params(self):
+        for attribute in ["frames", "frame_size"]:
+            if not isinstance(getattr(self, attribute), int):
+                t = type(getattr(self, attribute)).__name__
+                raise TypeError(f"{attribute} must be of type int. Found {t}.")
+
+        if self.frames < 1 and not self.frame_size:
+            raise ValueError("frames must be at least 1.")
diff --git a/sktime/transformations/series/sax.py b/sktime/transformations/series/sax.py
new file mode 100644
index 000000000..6a2176f1f
--- /dev/null
+++ b/sktime/transformations/series/sax.py
@@ -0,0 +1,141 @@
+"""Symbolic Aggregate Approximation Transformer."""
+__author__ = ["steenrotsman"]
+import numpy as np
+from scipy.stats import norm, zscore
+
+from sktime.transformations.base import BaseTransformer
+from sktime.transformations.series.paa import PAA2
+
+
+class SAX2(BaseTransformer):
+    """Symbolic Aggregate approXimation Transformer (SAX).
+
+    SAX [2]_ is a dimensionality reduction technique that z-normalises a time
+    series, applies Piecewise Aggregate Approximation (PAA) [1]_, and bins the
+    mean of each PAA frame to a discrete value, resulting in a SAX word.
+
+    This implementation offers two variants:
+     1) the original, which takes the desired number of frames and can set the
+     frame size to a fraction to support cases where the time series cannot be
+     divided into the frames equally.
+     2) a variant that takes the desired frame size and can decrease the frame
+     size of the last frame to support cases where the time series is not
+     evenly divisible into frames.
+
+    Parameters
+    ----------
+    word_size : int, optional (default=8, greater equal 1 if frame_size=0)
+        length of transformed time series. Ignored if `frame_size` is set.
+    alphabet_size : int, optional (default=5, greater equal 2)
+        number of discrete values transformed time series is binned to.
+    frame_size : int, optional (default=0, greater equal 0)
+        length of the frames over which the mean is taken. Overrides `frames` if > 0.
+
+    References
+    ----------
+    .. [1] Keogh, E., Chakrabarti, K., Pazzani, M., and Mehrotra, S.
+        Dimensionality Reduction for Fast Similarity Search
+        in Large Time Series Databases.
+        Knowledge and Information Systems 3, 263–286 (2001).
+        https://doi.org/10.1007/PL00011669
+    .. [2] Lin, J., Keogh, E., Wei, L., and Lonardi, S.
+        Experiencing SAX: A Novel Symbolic Representation of Time Series.
+        Data Mining and Knowledge Discovery 15, 107–144 (2007).
+        https://doi.org/10.1007/s10618-007-0064-z
+
+    Examples
+    --------
+    >>> from numpy import arange
+    >>> from sktime.transformations.series.sax import SAX2
+
+    >>> X = arange(10)
+    >>> sax = SAX2(word_size=3, alphabet_size=5)
+    >>> sax.fit_transform(X)  # doctest: +SKIP
+    array([0, 2, 4])
+    >>> sax = SAX2(frame_size=2, alphabet_size=5)  # doctest: +SKIP
+    array([0, 1, 2, 3, 4])
+    """
+
+    _tags = {
+        "scitype:transform-input": "Series",
+        "scitype:transform-output": "Series",
+        "scitype:instancewise": True,
+        "scitype:transform-labels": "None",
+        "X_inner_mtype": "np.ndarray",
+        "y_inner_mtype": "None",
+        "univariate-only": True,
+        "requires_y": False,
+        "fit_is_empty": True,
+        "capability:inverse_transform": False,
+        "handles-missing-data": False,
+    }
+
+    def __init__(self, word_size=8, alphabet_size=5, frame_size=0):
+        self.word_size = word_size
+        self.alphabet_size = alphabet_size
+        self.frame_size = frame_size
+
+        super().__init__()
+
+        self._check_params()
+
+    def _transform(self, X, y=None):
+        """Transform X and return a transformed version.
+
+        private _transform containing core logic, called from transform
+
+        Parameters
+        ----------
+        X : Series of mtype np.ndarray
+            data to be transformed
+        y : None
+            unused
+
+        Returns
+        -------
+        X_transformed : Series of mtype np.ndarray
+            transformed version of X
+        """
+        X_transformed = zscore(X)
+        paa = PAA2(self.word_size, self.frame_size)
+        X_transformed = paa.fit_transform(X_transformed)
+        X_transformed = np.digitize(X_transformed, self._get_breakpoints())
+        return X_transformed
+
+    def _get_breakpoints(self):
+        return norm.ppf(np.arange(1, self.alphabet_size) / self.alphabet_size, loc=0)
+
+    @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.
+            There are currently no reserved values for transformers.
+
+        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`
+        """
+        params = {"word_size": 4, "alphabet_size": 5}
+        return params
+
+    def _check_params(self):
+        for attribute in ["word_size", "alphabet_size", "frame_size"]:
+            if not isinstance(getattr(self, attribute), int):
+                t = type(getattr(self, attribute)).__name__
+                raise TypeError(f"{attribute} must be of type int. Found {t}.")
+
+        if self.word_size < 1 and not self.frame_size:
+            raise ValueError("word_size must be at least 1.")
+        if self.alphabet_size < 2:
+            raise ValueError("alphabet_size must be at least 2.")
+        if self.frame_size < 0:
+            raise ValueError("frame_size must be at least 0.")


Test patch:
diff --git a/sktime/transformations/series/tests/test_paa.py b/sktime/transformations/series/tests/test_paa.py
new file mode 100644
index 000000000..4acf1b13d
--- /dev/null
+++ b/sktime/transformations/series/tests/test_paa.py
@@ -0,0 +1,44 @@
+"""PAA transformer test code."""
+import numpy as np
+import pytest
+
+from sktime.transformations.series.paa import PAA2
+
+
+# Check that exception is raised for bad frames values.
+# input types - string, float, negative int, negative float, empty dict.
+# correct input is an integer of 1 or more, but less than or equal to series length.
+@pytest.mark.parametrize("bad_frames", ["str", 1.2, -1.2, -1, {}, 0, 11])
+def test_bad_input_args(bad_frames):
+    """Test that exception is raised for bad num levels."""
+    X = np.arange(10).T
+
+    if not isinstance(bad_frames, int):
+        for attribute in ["frames", "frame_size"]:
+            with pytest.raises(TypeError):
+                PAA2(**{attribute: bad_frames}).fit(X).transform(X)
+    else:
+        with pytest.raises(ValueError):
+            PAA2(bad_frames).fit(X).transform(X)
+
+
+@pytest.mark.parametrize(
+    "frames,frame_size,expected",
+    [
+        (1, 0, [4.5]),
+        (2, 0, [2, 7]),
+        (3, 0, [1.2, 4.5, 7.8]),
+        (10, 0, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]),
+        (0, 1, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]),
+        (0, 2, [0.5, 2.5, 4.5, 6.5, 8.5]),
+        (0, 3, [1, 4, 7, 9]),
+        (0, 10, [4.5]),
+    ],
+)
+def test_output_of_transformer(frames, frame_size, expected):
+    """Test that the transformer has changed the data correctly."""
+    X = np.arange(10).T
+    paa = PAA2(frames, frame_size)
+    res = paa.fit_transform(X)
+    want = np.array(expected, dtype=np.float64).T
+    np.testing.assert_array_equal(res, want)
diff --git a/sktime/transformations/series/tests/test_sax.py b/sktime/transformations/series/tests/test_sax.py
new file mode 100644
index 000000000..ca122c932
--- /dev/null
+++ b/sktime/transformations/series/tests/test_sax.py
@@ -0,0 +1,48 @@
+"""SAX transformer test code."""
+import numpy as np
+import pytest
+
+from sktime.transformations.series.sax import SAX2
+
+
+# Check that exception is raised for bad size values.
+# input types - string, float, negative float, empty dict.
+# correct input is an integer, see below for value restrictions.
+@pytest.mark.parametrize("bad_size", ["str", 1.2, -1.2, {}])
+def test_bad_input_types(bad_size):
+    """Test that exception is raised for bad sizes."""
+    for attribute in ["word_size", "alphabet_size", "frame_size"]:
+        with pytest.raises(TypeError):
+            SAX2(**{attribute: bad_size})
+
+
+@pytest.mark.parametrize(
+    "attribute,bad_size",
+    [
+        ("word_size", 0),
+        ("alphabet_size", 1),
+        ("frame_size", -1),
+    ],
+)
+def test_bad_input_values(attribute, bad_size):
+    """Test that word_size is at least 1 (if frame_size is not set)."""
+    with pytest.raises(ValueError):
+        SAX2(**{attribute: bad_size})
+
+
+@pytest.mark.parametrize(
+    "word_size,alphabet_size,frame_size,expected",
+    [
+        (2, 5, 0, [0, 4]),
+        (3, 5, 0, [0, 2, 4]),
+        (0, 5, 2, [0, 1, 2, 3, 4]),
+        (0, 5, 3, [0, 2, 4, 4]),
+    ],
+)
+def test_output_of_transformer(word_size, alphabet_size, frame_size, expected):
+    """Test that the transformer has changed the data correctly."""
+    X = np.arange(10).T
+    sax = SAX2(word_size, alphabet_size, frame_size)
+    res = sax.fit_transform(X)
+    want = np.array(expected, dtype=np.float64).T
+    np.testing.assert_array_equal(res, want)