Skip to content

[ENH] Make transformers inherit TransformerMixin and add CollectionToSeriesWrapper #2812

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions aeon/testing/testing_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@
"MatrixProfile": ["check_fit_deterministic", "check_persistence_via_pickle"],
"LeftSTAMPi": ["check_series_anomaly_detector_output"],
"SignatureClassifier": ["check_classifier_against_expected_results"],
"SeriesToCollectionBroadcaster": ["check_transform_inverse_transform_equivalent"],
"CollectionToSeriesWrapper": ["check_transform_inverse_transform_equivalent"],
# missed in legacy testing, changes state in predict/transform
"FLUSSSegmenter": ["check_non_state_changing_method"],
"ClaSPSegmenter": ["check_non_state_changing_method"],
Expand Down
5 changes: 2 additions & 3 deletions aeon/transformations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@

import numpy as np
import pandas as pd
from sklearn.base import TransformerMixin

from aeon.base import BaseAeonEstimator


class BaseTransformer(BaseAeonEstimator):
class BaseTransformer(TransformerMixin, BaseAeonEstimator):
"""Transformer base class."""

_tags = {
Expand All @@ -24,8 +25,6 @@ class BaseTransformer(BaseAeonEstimator):

@abstractmethod
def __init__(self):
self._estimator_type = "transformer"

super().__init__()

@abstractmethod
Expand Down
6 changes: 5 additions & 1 deletion aeon/transformations/collection/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""Collection transformations."""

__all__ = [
# base class and series wrapper
# base class and series broadcaster
"BaseCollectionTransformer",
"SeriesToCollectionBroadcaster",
# transformers
"AutocorrelationFunctionTransformer",
"ARCoefficientTransformer",
Expand Down Expand Up @@ -34,6 +35,9 @@
from aeon.transformations.collection._reduce import Tabularizer
from aeon.transformations.collection._rescale import Centerer, MinMaxScaler, Normalizer
from aeon.transformations.collection._resize import Resizer
from aeon.transformations.collection._series_broadcaster import (
SeriesToCollectionBroadcaster,
)
from aeon.transformations.collection._slope import SlopeTransformer
from aeon.transformations.collection._truncate import Truncator
from aeon.transformations.collection.base import BaseCollectionTransformer
Original file line number Diff line number Diff line change
Expand Up @@ -38,22 +38,23 @@ class SeriesToCollectionBroadcaster(BaseCollectionTransformer):
"input_data_type": "Collection",
"output_data_type": "Collection",
"capability:unequal_length": True,
"capability:inverse_transform": True,
"X_inner_type": ["numpy3D", "np-list"],
}

def __init__(
self,
transformer: BaseSeriesTransformer,
) -> None:
# Setting tags before __init__() causes them to be overwritten. Hence we make
# a copy before init from the series transformer, then copy the tags of the
# BaseSeriesTransformer to this BaseCollectionTransformer
self.transformer = transformer

super().__init__()

# Setting tags before __init__() causes them to be overwritten.
tags_to_keep = SeriesToCollectionBroadcaster._tags
tags_to_add = transformer.get_tags()
for key in tags_to_keep:
tags_to_add.pop(key, None)
super().__init__()
self.set_tags(**tags_to_add)

def _fit(self, X, y=None):
Expand Down Expand Up @@ -100,14 +101,14 @@ def _transform(self, X, y=None):

"""
n_cases = get_n_cases(X)
"""If fit is empty is true only single transform is used."""
Xt = []
if self.get_tag("fit_is_empty"):
for i in range(n_cases):
Xt.append(self.transformer._transform(X[i]))
else:
for i in range(n_cases):
Xt.append(self.single_transformers_[i]._transform(X[i]))

# Need to make it a valid collection
for i in range(n_cases):
if isinstance(Xt[i], np.ndarray) and Xt[i].ndim == 1:
Expand All @@ -134,14 +135,19 @@ def _inverse_transform(self, X, y=None):
The transformed collection of time series, either a 3D numpy or a
list of 2D numpy.
"""
n_cases = len(X)
n_cases = get_n_cases(X)
Xt = []
if self.get_tag("fit_is_empty"):
for i in range(n_cases):
Xt.append(self.transformer._inverse_transform(X[i]))
else:
for i in range(n_cases):
Xt.append(self.single_transformers_[i]._inverse_transform(X[i]))

# Need to make it a valid collection
for i in range(n_cases):
if isinstance(Xt[i], np.ndarray) and Xt[i].ndim == 1:
Xt[i] = Xt[i].reshape(1, -1)
return Xt

@classmethod
Expand Down
17 changes: 9 additions & 8 deletions aeon/transformations/collection/tests/test_broadcaster.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""Tests for SeriesToCollectionBroadcaster transformer."""

__maintainer__ = ["baraline"]

import pytest
from numpy.testing import assert_array_almost_equal

Expand All @@ -14,7 +12,9 @@
MockSeriesTransformerNoFit,
MockUnivariateSeriesTransformer,
)
from aeon.transformations.collection._broadcaster import SeriesToCollectionBroadcaster
from aeon.transformations.collection._series_broadcaster import (
SeriesToCollectionBroadcaster,
)


def test_broadcaster_tag_inheritance():
Expand All @@ -41,10 +41,9 @@ def test_broadcaster_tag_inheritance():
assert post_constructor_tags[key] == mock_tags[key]


df = [make_example_3d_numpy, make_example_3d_numpy_list]


@pytest.mark.parametrize("data_gen", df)
@pytest.mark.parametrize(
"data_gen", [make_example_3d_numpy, make_example_3d_numpy_list]
)
def test_broadcaster_methods_univariate(data_gen):
"""Test the broadcaster fit, transform and inverse transform method."""
X, y = data_gen(n_channels=1)
Expand All @@ -64,7 +63,9 @@ def test_broadcaster_methods_univariate(data_gen):
assert_array_almost_equal(X[i], X2[i])


@pytest.mark.parametrize("data_gen", df)
@pytest.mark.parametrize(
"data_gen", [make_example_3d_numpy, make_example_3d_numpy_list]
)
def test_broadcaster_methods_multivariate(data_gen):
"""Test the broadcaster fit, transform and inverse transform method."""
X, y = data_gen(n_channels=3)
Expand Down
2 changes: 2 additions & 0 deletions aeon/transformations/series/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
__all__ = [
"AutoCorrelationSeriesTransformer",
"BaseSeriesTransformer",
"CollectionToSeriesWrapper",
"ClaSPTransformer",
"DFTSeriesTransformer",
"Dobin",
Expand Down Expand Up @@ -32,6 +33,7 @@
from aeon.transformations.series._bkfilter import BKFilter
from aeon.transformations.series._boxcox import BoxCoxTransformer
from aeon.transformations.series._clasp import ClaSPTransformer
from aeon.transformations.series._collection_wrapper import CollectionToSeriesWrapper
from aeon.transformations.series._dft import DFTSeriesTransformer
from aeon.transformations.series._diff import DifferenceTransformer
from aeon.transformations.series._dobin import Dobin
Expand Down
103 changes: 103 additions & 0 deletions aeon/transformations/series/_collection_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Class to wrap a collection transformer for single series."""

__maintainer__ = ["MatthewMiddlehurst"]
__all__ = ["CollectionToSeriesWrapper"]


from aeon.transformations.collection.base import BaseCollectionTransformer
from aeon.transformations.series.base import BaseSeriesTransformer


class CollectionToSeriesWrapper(BaseSeriesTransformer):
"""Wrap a ``BaseCollectionTransformer`` to run on single series datatypes.

Parameters
----------
transformer : BaseCollectionTransformer
The collection transformer to wrap.

Examples
--------
>>> from aeon.transformations.collection._resize import Resizer
>>> import numpy as np
>>> X = np.random.rand(1, 10)
>>> transformer = Resizer(length=5)
>>> wrapper = CollectionToSeriesWrapper(transformer)
>>> X_t = wrapper.fit_transform(X)
"""

# These tags are not set from the collection transformer.
_tags = {
"input_data_type": "Series",
"output_data_type": "Series",
"capability:inverse_transform": True,
"X_inner_type": "np.ndarray",
}

def __init__(
self,
transformer: BaseCollectionTransformer,
) -> None:
self.transformer = transformer

super().__init__(axis=1)

# Setting tags before __init__() causes them to be overwritten.
tags_to_keep = CollectionToSeriesWrapper._tags
tags_to_add = transformer.get_tags()
for key in tags_to_keep:
tags_to_add.pop(key, None)
for key in ["capability:unequal_length", "removes_unequal_length"]:
tags_to_add.pop(key, None)
self.set_tags(**tags_to_add)

def _fit(self, X, y=None):
X = X.reshape(1, X.shape[0], X.shape[1])
self.collection_transformer_ = self.transformer.clone()
self.collection_transformer_.fit(X, y)

def _transform(self, X, y=None):
X = X.reshape(1, X.shape[0], X.shape[1])

t = self.transformer
if not self.get_tag("fit_is_empty"):
t = self.collection_transformer_

return t.transform(X, y)

def _fit_transform(self, X, y=None):
X = X.reshape(1, X.shape[0], X.shape[1])
self.collection_transformer_ = self.transformer.clone()
return self.collection_transformer_.fit_transform(X, y)

def _inverse_transform(self, X, y=None):
X = X.reshape(1, X.shape[0], X.shape[1])

t = self.transformer
if not self.get_tag("fit_is_empty"):
t = self.collection_transformer_

return t.inverse_transform(X, y)

@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.
"""
from aeon.testing.mock_estimators._mock_collection_transformers import (
MockCollectionTransformer,
)

return {"transformer": MockCollectionTransformer()}
24 changes: 24 additions & 0 deletions aeon/transformations/series/tests/test_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Tests for SeriesToCollectionBroadcaster transformer."""

from aeon.testing.mock_estimators import MockCollectionTransformer
from aeon.transformations.series import CollectionToSeriesWrapper


def test_broadcaster_tag_inheritance():
"""Test the ability to inherit tags from the BaseCollectionTransformer.

The broadcaster should always keep some tags related to single series
"""
trans = MockCollectionTransformer()
class_tags = CollectionToSeriesWrapper._tags

bc = CollectionToSeriesWrapper(trans)

post_constructor_tags = bc.get_tags()
mock_tags = trans.get_tags()
# constructor_tags should match class_tags or, if not present, tags in transformer
for key in post_constructor_tags:
if key in class_tags:
assert post_constructor_tags[key] == class_tags[key]
elif key in mock_tags:
assert post_constructor_tags[key] == mock_tags[key]