diff --git a/aeon/testing/testing_config.py b/aeon/testing/testing_config.py index 2838a856d1..c8ed71ff15 100644 --- a/aeon/testing/testing_config.py +++ b/aeon/testing/testing_config.py @@ -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"], diff --git a/aeon/transformations/base.py b/aeon/transformations/base.py index f0ae37d008..277559f615 100644 --- a/aeon/transformations/base.py +++ b/aeon/transformations/base.py @@ -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 = { @@ -24,8 +25,6 @@ class BaseTransformer(BaseAeonEstimator): @abstractmethod def __init__(self): - self._estimator_type = "transformer" - super().__init__() @abstractmethod diff --git a/aeon/transformations/collection/__init__.py b/aeon/transformations/collection/__init__.py index 11ccc604b0..a970b5e536 100644 --- a/aeon/transformations/collection/__init__.py +++ b/aeon/transformations/collection/__init__.py @@ -1,8 +1,9 @@ """Collection transformations.""" __all__ = [ - # base class and series wrapper + # base class and series broadcaster "BaseCollectionTransformer", + "SeriesToCollectionBroadcaster", # transformers "AutocorrelationFunctionTransformer", "ARCoefficientTransformer", @@ -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 diff --git a/aeon/transformations/collection/_broadcaster.py b/aeon/transformations/collection/_series_broadcaster.py similarity index 95% rename from aeon/transformations/collection/_broadcaster.py rename to aeon/transformations/collection/_series_broadcaster.py index 742e24d2c1..f2e65c824e 100644 --- a/aeon/transformations/collection/_broadcaster.py +++ b/aeon/transformations/collection/_series_broadcaster.py @@ -38,6 +38,7 @@ 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"], } @@ -45,15 +46,15 @@ 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): @@ -100,7 +101,6 @@ 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): @@ -108,6 +108,7 @@ def _transform(self, X, y=None): 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: @@ -134,7 +135,7 @@ 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): @@ -142,6 +143,11 @@ def _inverse_transform(self, X, y=None): 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 diff --git a/aeon/transformations/collection/tests/test_broadcaster.py b/aeon/transformations/collection/tests/test_broadcaster.py index d8e2cc6be4..3508924679 100644 --- a/aeon/transformations/collection/tests/test_broadcaster.py +++ b/aeon/transformations/collection/tests/test_broadcaster.py @@ -1,7 +1,5 @@ """Tests for SeriesToCollectionBroadcaster transformer.""" -__maintainer__ = ["baraline"] - import pytest from numpy.testing import assert_array_almost_equal @@ -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(): @@ -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) @@ -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) diff --git a/aeon/transformations/series/__init__.py b/aeon/transformations/series/__init__.py index d97d45c7ad..d6bb864747 100644 --- a/aeon/transformations/series/__init__.py +++ b/aeon/transformations/series/__init__.py @@ -3,6 +3,7 @@ __all__ = [ "AutoCorrelationSeriesTransformer", "BaseSeriesTransformer", + "CollectionToSeriesWrapper", "ClaSPTransformer", "DFTSeriesTransformer", "Dobin", @@ -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 diff --git a/aeon/transformations/series/_collection_wrapper.py b/aeon/transformations/series/_collection_wrapper.py new file mode 100644 index 0000000000..af6f9d981b --- /dev/null +++ b/aeon/transformations/series/_collection_wrapper.py @@ -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()} diff --git a/aeon/transformations/series/tests/test_wrapper.py b/aeon/transformations/series/tests/test_wrapper.py new file mode 100644 index 0000000000..a8529857b3 --- /dev/null +++ b/aeon/transformations/series/tests/test_wrapper.py @@ -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]