From 4619eadc0c082af399f6c59b65a4beca7fe0d66b Mon Sep 17 00:00:00 2001 From: MatthewMiddlehurst Date: Sun, 10 Aug 2025 20:38:39 +0100 Subject: [PATCH 1/7] banish update and inverse transform --- aeon/transformations/base.py | 90 +++++++++++++- .../collection/_broadcaster.py | 3 +- aeon/transformations/collection/base.py | 94 +-------------- .../collection/compose/_identity.py | 4 +- .../collection/dictionary_based/_borf.py | 1 - aeon/transformations/series/_boxcox.py | 4 +- aeon/transformations/series/_log.py | 4 +- aeon/transformations/series/_scaled_logit.py | 4 +- aeon/transformations/series/base.py | 111 ++---------------- .../series/compose/_identity.py | 4 +- 10 files changed, 119 insertions(+), 200 deletions(-) diff --git a/aeon/transformations/base.py b/aeon/transformations/base.py index f0ae37d008..fe2c8a5199 100644 --- a/aeon/transformations/base.py +++ b/aeon/transformations/base.py @@ -3,12 +3,15 @@ __maintainer__ = ["MatthewMiddlehurst", "TonyBagnall"] __all__ = ["BaseTransformer"] -from abc import abstractmethod +from abc import ABC, abstractmethod +from typing import final import numpy as np import pandas as pd from aeon.base import BaseAeonEstimator +from aeon.transformations.collection import BaseCollectionTransformer +from aeon.transformations.series import BaseSeriesTransformer class BaseTransformer(BaseAeonEstimator): @@ -112,3 +115,88 @@ def _check_y(self, y, n_cases=None): f"Mismatch in number of cases. Number in X = {n_cases} nos in y = " f"{n_labels}" ) + + +class InverseTransformerMixin(ABC): + """Mixin for transformers that support inverse transformation.""" + + _tags = { + "capability:inverse_transform": True, + } + + @final + def inverse_transform(self, X, y=None, axis=1): + """Inverse transform X and return an inverse transformed version. + + Currently it is assumed that only transformers with tags + "input_data_type"="Series", "output_data_type"="Series", + can have an inverse_transform. + + State required: + Requires state to be "fitted". + + Accesses in self: + _is_fitted : must be True + fitted model attributes (ending in "_") : accessed by _inverse_transform + + Parameters + ---------- + X : Series or Collection, any supported type + Data to fit transform to, of python type as follows: + Series: 2D np.ndarray shape (n_channels, n_timepoints) + Collection: 3D np.ndarray shape (n_cases, n_channels, n_timepoints) + or list of 2D np.ndarray, case i has shape (n_channels, n_timepoints_i) + y : Series, default=None + Additional data, e.g., labels for transformation. + axis : int, default = 1 + Axis of time in the input series. + If ``axis == 0``, it is assumed each column is a time series and each row is + a time point. i.e. the shape of the data is ``(n_timepoints, + n_channels)``. + ``axis == 1`` indicates the time series are in rows, i.e. the shape of + the data is ``(n_channels, n_timepoints)`.``axis is None`` indicates + that the axis of X is the same as ``self.axis``. + + Only relevant for ``aeon.transformations.series`` transformers. + + Returns + ------- + inverse transformed version of X + of the same type as X + """ + # check whether is fitted + self._check_is_fitted() + + # input check and conversion for X/y + if isinstance(self, BaseCollectionTransformer): + X_inner = self._preprocess_collection(X, store_metadata=False) + Xt = self._inverse_transform(X=X_inner, y=y) + return Xt + elif isinstance(self, BaseSeriesTransformer): + self._check_is_fitted() + X = self._preprocess_series(X, axis=axis, store_metadata=False) + Xt = self._inverse_transform(X=X, y=y) + return self._postprocess_series(Xt, axis=axis) + + @abstractmethod + def _inverse_transform(self, X, y=None): + """Inverse transform X and return an inverse transformed version. + + private _inverse_transform containing core logic, called from inverse_transform. + + Parameters + ---------- + X : Series or Collection, any supported type + Data to fit transform to, of python type as follows: + Series: 2D np.ndarray shape (n_channels, n_timepoints) + Collection: 3D np.ndarray shape (n_cases, n_channels, n_timepoints) + or list of 2D np.ndarray, case i has shape (n_channels, n_timepoints_i) + y : Series, default=None + Additional data, e.g., labels for transformation. + + Returns + ------- + inverse transformed version of X + of the same type as X. + """ + ... diff --git a/aeon/transformations/collection/_broadcaster.py b/aeon/transformations/collection/_broadcaster.py index 742e24d2c1..c422fdc6d8 100644 --- a/aeon/transformations/collection/_broadcaster.py +++ b/aeon/transformations/collection/_broadcaster.py @@ -5,12 +5,13 @@ import numpy as np +from aeon.transformations.base import InverseTransformerMixin from aeon.transformations.collection.base import BaseCollectionTransformer from aeon.transformations.series.base import BaseSeriesTransformer from aeon.utils.validation import get_n_cases -class SeriesToCollectionBroadcaster(BaseCollectionTransformer): +class SeriesToCollectionBroadcaster(BaseCollectionTransformer, InverseTransformerMixin): """Broadcast a ``BaseSeriesTransformer`` over a collection of time series. Uses the ``BaseSeriesTransformer`` passed in the constructor. If the diff --git a/aeon/transformations/collection/base.py b/aeon/transformations/collection/base.py index 4463f4a4ee..f382683217 100644 --- a/aeon/transformations/collection/base.py +++ b/aeon/transformations/collection/base.py @@ -211,83 +211,21 @@ def fit_transform(self, X, y=None): self.is_fitted = True return Xt - @final - def inverse_transform(self, X, y=None): - """Inverse transform X and return an inverse transformed version. - - Currently it is assumed that only transformers with tags - "input_data_type"="Series", "output_data_type"="Series", - can have an inverse_transform. - - State required: - Requires state to be "fitted". - - Accesses in self: - _is_fitted : must be True - fitted model attributes (ending in "_") : accessed by _inverse_transform - - Parameters - ---------- - X : np.ndarray or list - Data to fit transform to, of valid collection type. Input data, - any number of channels, equal length series of shape ``( - n_cases, n_channels, n_timepoints)`` or list of numpy arrays (number - of channels, series length) of shape ``[n_cases]``, 2D np.array - ``(n_channels, n_timepoints_i)``, where ``n_timepoints_i`` is length of - series ``i``. Other types are allowed and converted into one of the above. - - Different estimators have different capabilities to handle different - types of input. If ``self.get_tag("capability:multivariate")`` is False, - they cannot handle multivariate series. If ``self.get_tag( - "capability:unequal_length")`` is False, they cannot handle unequal - length input. In both situations, a ``ValueError`` is raised if X has a - characteristic that the estimator does not have the capability to handle. - y : np.ndarray, default=None - 1D np.array of float or str, of shape ``(n_cases)`` - class labels - (ground truth) for fitting indices corresponding to instance indices in X. - If None, no labels are used in fitting. - - Returns - ------- - inverse transformed version of X - of the same type as X - """ - if not self.get_tag("capability:inverse_transform"): - raise NotImplementedError( - f"{type(self)} does not implement inverse_transform" - ) - - # check whether is fitted - self._check_is_fitted() - - # input check and conversion for X/y - X_inner = self._preprocess_collection(X, store_metadata=False) - y_inner = y - - Xt = self._inverse_transform(X=X_inner, y=y_inner) - - return Xt - def _fit(self, X, y=None): """Fit transformer to X and y. private _fit containing the core logic, called from fit + private _transform containing the core logic, called from transform + Parameters ---------- X : Input data Data to fit transform to, of valid collection type. y : Target variable, default=None Additional data, e.g., labels for transformation - - Returns - ------- - self: a fitted instance of the estimator - - See extension_templates/transformer.py for implementation details. """ - # default fit is "no fitting happens" - return self + ... @abstractmethod def _transform(self, X, y=None): @@ -315,6 +253,9 @@ def _fit_transform(self, X, y=None): private _fit_transform containing the core logic, called from fit_transform. + Non-optimized default implementation; override when a better + method is possible for a given algorithm. + Parameters ---------- X : Input data @@ -326,29 +267,6 @@ def _fit_transform(self, X, y=None): ------- transformed version of X. """ - # Non-optimized default implementation; override when a better - # method is possible for a given algorithm. if not self.get_tag("fit_is_empty"): self._fit(X, y) return self._transform(X, y) - - def _inverse_transform(self, X, y=None): - """Inverse transform X and return an inverse transformed version. - - private _inverse_transform containing core logic, called from inverse_transform. - - Parameters - ---------- - X : Input data - Data to fit transform to, of valid collection type. - y : Target variable, default=None - Additional data, e.g., labels for transformation - - Returns - ------- - inverse transformed version of X - of the same type as X. - """ - raise NotImplementedError( - f"{self.__class__.__name__} does not support inverse_transform" - ) diff --git a/aeon/transformations/collection/compose/_identity.py b/aeon/transformations/collection/compose/_identity.py index a359255242..e197fd2fa6 100644 --- a/aeon/transformations/collection/compose/_identity.py +++ b/aeon/transformations/collection/compose/_identity.py @@ -1,16 +1,16 @@ """Identity transformer.""" +from aeon.transformations.base import InverseTransformerMixin from aeon.transformations.collection import BaseCollectionTransformer from aeon.utils.data_types import COLLECTIONS_DATA_TYPES -class CollectionId(BaseCollectionTransformer): +class CollectionId(BaseCollectionTransformer, InverseTransformerMixin): """Identity transformer, returns data unchanged in transform/inverse_transform.""" _tags = { "X_inner_type": COLLECTIONS_DATA_TYPES, "fit_is_empty": True, - "capability:inverse_transform": True, "capability:multivariate": True, "capability:unequal_length": True, "capability:missing_values": True, diff --git a/aeon/transformations/collection/dictionary_based/_borf.py b/aeon/transformations/collection/dictionary_based/_borf.py index 8b67d084c7..029a9c5780 100644 --- a/aeon/transformations/collection/dictionary_based/_borf.py +++ b/aeon/transformations/collection/dictionary_based/_borf.py @@ -100,7 +100,6 @@ class BORF(BaseCollectionTransformer): _tags = { "X_inner_type": "numpy3D", - "capability:inverse_transform": False, "capability:missing_values": True, "capability:multivariate": True, "capability:multithreading": True, diff --git a/aeon/transformations/series/_boxcox.py b/aeon/transformations/series/_boxcox.py index 37f0f14550..01fa1195cc 100644 --- a/aeon/transformations/series/_boxcox.py +++ b/aeon/transformations/series/_boxcox.py @@ -8,6 +8,7 @@ from scipy.special import boxcox, inv_boxcox from scipy.stats import boxcox_llf, distributions, variation +from aeon.transformations.base import InverseTransformerMixin from aeon.transformations.series.base import BaseSeriesTransformer from aeon.utils.validation import is_int @@ -39,7 +40,7 @@ def _calc_uniform_order_statistic_medians(n): return v -class BoxCoxTransformer(BaseSeriesTransformer): +class BoxCoxTransformer(BaseSeriesTransformer, InverseTransformerMixin): r"""Box-Cox power transform. Box-Cox transformation is a power transformation that is used to @@ -107,7 +108,6 @@ class BoxCoxTransformer(BaseSeriesTransformer): "X_inner_type": "np.ndarray", "fit_is_empty": False, "capability:multivariate": False, - "capability:inverse_transform": True, } def __init__(self, bounds=None, method="mle", sp=None): diff --git a/aeon/transformations/series/_log.py b/aeon/transformations/series/_log.py index 45d72664f5..b82f104430 100644 --- a/aeon/transformations/series/_log.py +++ b/aeon/transformations/series/_log.py @@ -5,10 +5,11 @@ import numpy as np +from aeon.transformations.base import InverseTransformerMixin from aeon.transformations.series.base import BaseSeriesTransformer -class LogTransformer(BaseSeriesTransformer): +class LogTransformer(BaseSeriesTransformer, InverseTransformerMixin): """Natural logarithm transformation. The Natural logarithm transformation can be used to make the data more normally @@ -41,7 +42,6 @@ class LogTransformer(BaseSeriesTransformer): "X_inner_type": "np.ndarray", "fit_is_empty": True, "capability:multivariate": True, - "capability:inverse_transform": True, } def __init__(self, offset=0, scale=1): diff --git a/aeon/transformations/series/_scaled_logit.py b/aeon/transformations/series/_scaled_logit.py index e400385f35..e94e659e89 100644 --- a/aeon/transformations/series/_scaled_logit.py +++ b/aeon/transformations/series/_scaled_logit.py @@ -8,10 +8,11 @@ import numpy as np +from aeon.transformations.base import InverseTransformerMixin from aeon.transformations.series.base import BaseSeriesTransformer -class ScaledLogitSeriesTransformer(BaseSeriesTransformer): +class ScaledLogitSeriesTransformer(BaseSeriesTransformer, InverseTransformerMixin): r"""Scaled logit transform or Log transform. If both lower_bound and upper_bound are not None, a scaled logit transform is @@ -59,7 +60,6 @@ class ScaledLogitSeriesTransformer(BaseSeriesTransformer): "X_inner_type": "np.ndarray", "fit_is_empty": True, "capability:multivariate": True, - "capability:inverse_transform": True, } def __init__(self, lower_bound=None, upper_bound=None): diff --git a/aeon/transformations/series/base.py b/aeon/transformations/series/base.py index 3afa1011bc..86fe68684a 100644 --- a/aeon/transformations/series/base.py +++ b/aeon/transformations/series/base.py @@ -111,9 +111,8 @@ def transform(self, X, y=None, axis=1): if y is not None: self._check_y(y) - # #2768 - # if not fit_empty: - # self._check_shape(X) + if not fit_empty: + self._check_shape(X) Xt = self._transform(X, y) return self._postprocess_series(Xt, axis=axis) @@ -165,86 +164,22 @@ def fit_transform(self, X, y=None, axis=1): self.is_fitted = True return self._postprocess_series(Xt, axis=axis) - @final - def inverse_transform(self, X, y=None, axis=1): - """Inverse transform X and return an inverse transformed version. - - State required: - Requires state to be "fitted". - - Parameters - ---------- - X : Input data - Data to fit transform to, of valid collection type. - y : Target variable, default=None - Additional data, e.g., labels for transformation - axis : int, default = 1 - Axis of time in the input series. - If ``axis == 0``, it is assumed each column is a time series and each row is - a time point. i.e. the shape of the data is ``(n_timepoints, - n_channels)``. - ``axis == 1`` indicates the time series are in rows, i.e. the shape of - the data is ``(n_channels, n_timepoints)`.``axis is None`` indicates - that the axis of X is the same as ``self.axis``. - - Returns - ------- - inverse transformed version of X - of the same type as X - """ - if not self.get_tag("capability:inverse_transform"): - raise NotImplementedError( - f"{type(self)} does not implement inverse_transform" - ) - - # check whether is fitted - self._check_is_fitted() - X = self._preprocess_series(X, axis=axis, store_metadata=False) - Xt = self._inverse_transform(X=X, y=y) - return self._postprocess_series(Xt, axis=axis) - - @final - def update(self, X, y=None, update_params=True, axis=1): - """Update transformer with X, optionally y. - - Parameters - ---------- - X : data to update of valid series type. - y : Target variable, default=None - Additional data, e.g., labels for transformation - update_params : bool, default=True - whether the model is updated. Yes if true, if false, simply skips call. - argument exists for compatibility with forecasting module. - axis : int, default=None - axis along which to update. If None, uses self.axis. - - Returns - ------- - self : a fitted instance of the estimator - """ - # check whether is fitted - self._check_is_fitted() - X = self._preprocess_series(X, axis, False) - return self._update(X=X, y=y, update_params=update_params) - def _fit(self, X, y=None): """Fit transformer to X and y. private _fit containing the core logic, called from fit + A default implementation for the fit_is_empty tag. Other transformers + should override this. + Parameters ---------- X : Input data Data to fit transform to, of valid collection type. y : Target variable, default=None Additional data, e.g., labels for transformation - - Returns - ------- - self: a fitted instance of the estimator """ - # default fit is "no fitting happens" - return self + ... @abstractmethod def _transform(self, X, y=None): @@ -263,6 +198,7 @@ def _transform(self, X, y=None): ------- transformed version of X """ + ... def _fit_transform(self, X, y=None): """Fit to data, then transform it. @@ -271,6 +207,9 @@ def _fit_transform(self, X, y=None): private _fit_transform containing the core logic, called from fit_transform. + Non-optimised default implementation; override when a better + method is possible for a given algorithm. + Parameters ---------- X : Input data @@ -282,36 +221,10 @@ def _fit_transform(self, X, y=None): ------- transformed version of X. """ - # Non-optimized default implementation; override when a better - # method is possible for a given algorithm. - self._fit(X, y) + if not self.get_tag("fit_is_empty"): + self._fit(X, y) return self._transform(X, y) - def _inverse_transform(self, X, y=None): - """Inverse transform X and return an inverse transformed version. - - private _inverse_transform containing core logic, called from inverse_transform. - - Parameters - ---------- - X : Input data - Time series to fit transform to, of valid collection type. - y : Target variable, default=None - Additional data, e.g., labels for transformation - - Returns - ------- - inverse transformed version of X - of the same type as X. - """ - raise NotImplementedError( - f"{self.__class__.__name__} does not support inverse_transform" - ) - - def _update(self, X, y=None, update_params=True): - # standard behaviour: no update takes place, new data is ignored - return self - def _postprocess_series(self, Xt, axis): """Postprocess data Xt to revert to original shape. diff --git a/aeon/transformations/series/compose/_identity.py b/aeon/transformations/series/compose/_identity.py index d9135fae36..c67d200c19 100644 --- a/aeon/transformations/series/compose/_identity.py +++ b/aeon/transformations/series/compose/_identity.py @@ -1,16 +1,16 @@ """Identity transformer.""" +from aeon.transformations.base import InverseTransformerMixin from aeon.transformations.series import BaseSeriesTransformer from aeon.utils.data_types import VALID_SERIES_INNER_TYPES -class SeriesId(BaseSeriesTransformer): +class SeriesId(BaseSeriesTransformer, InverseTransformerMixin): """Identity transformer, returns data unchanged in transform/inverse_transform.""" _tags = { "X_inner_type": VALID_SERIES_INNER_TYPES, "fit_is_empty": True, - "capability:inverse_transform": True, "capability:multivariate": True, "capability:missing_values": True, } From 889f566424feff4ac753f8bc099cd2a9fcfdc032 Mon Sep 17 00:00:00 2001 From: MatthewMiddlehurst Date: Sun, 10 Aug 2025 20:39:47 +0100 Subject: [PATCH 2/7] revert --- aeon/transformations/series/base.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/aeon/transformations/series/base.py b/aeon/transformations/series/base.py index 86fe68684a..f89404d1f9 100644 --- a/aeon/transformations/series/base.py +++ b/aeon/transformations/series/base.py @@ -111,8 +111,9 @@ def transform(self, X, y=None, axis=1): if y is not None: self._check_y(y) - if not fit_empty: - self._check_shape(X) + # #2768 + # if not fit_empty: + # self._check_shape(X) Xt = self._transform(X, y) return self._postprocess_series(Xt, axis=axis) From 798a424111025610643be599c17df5335af24870 Mon Sep 17 00:00:00 2001 From: MatthewMiddlehurst Date: Wed, 20 Aug 2025 23:19:50 +0100 Subject: [PATCH 3/7] revert --- aeon/transformations/collection/base.py | 16 +++++---- aeon/transformations/series/base.py | 47 +++++++++++++++++++------ 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/aeon/transformations/collection/base.py b/aeon/transformations/collection/base.py index 6eb9779b19..921e70e377 100644 --- a/aeon/transformations/collection/base.py +++ b/aeon/transformations/collection/base.py @@ -216,16 +216,21 @@ def _fit(self, X, y=None): private _fit containing the core logic, called from fit - private _transform containing the core logic, called from transform - Parameters ---------- X : Input data Data to fit transform to, of valid collection type. y : Target variable, default=None Additional data, e.g., labels for transformation + + Returns + ------- + self: a fitted instance of the estimator + + See extension_templates/transformer.py for implementation details. """ - ... + # default fit is "no fitting happens" + return self @abstractmethod def _transform(self, X, y=None): @@ -253,9 +258,6 @@ def _fit_transform(self, X, y=None): private _fit_transform containing the core logic, called from fit_transform. - Non-optimized default implementation; override when a better - method is possible for a given algorithm. - Parameters ---------- X : Input data @@ -267,6 +269,8 @@ def _fit_transform(self, X, y=None): ------- transformed version of X. """ + # Non-optimized default implementation; override when a better + # method is possible for a given algorithm. if not self.get_tag("fit_is_empty"): self._fit(X, y) return self._transform(X, y) diff --git a/aeon/transformations/series/base.py b/aeon/transformations/series/base.py index f89404d1f9..299b502e79 100644 --- a/aeon/transformations/series/base.py +++ b/aeon/transformations/series/base.py @@ -165,22 +165,48 @@ def fit_transform(self, X, y=None, axis=1): self.is_fitted = True return self._postprocess_series(Xt, axis=axis) + @final + def update(self, X, y=None, update_params=True, axis=1): + """Update transformer with X, optionally y. + + Parameters + ---------- + X : data to update of valid series type. + y : Target variable, default=None + Additional data, e.g., labels for transformation + update_params : bool, default=True + whether the model is updated. Yes if true, if false, simply skips call. + argument exists for compatibility with forecasting module. + axis : int, default=None + axis along which to update. If None, uses self.axis. + + Returns + ------- + self : a fitted instance of the estimator + """ + # check whether is fitted + self._check_is_fitted() + X = self._preprocess_series(X, axis, False) + return self._update(X=X, y=y, update_params=update_params) + def _fit(self, X, y=None): """Fit transformer to X and y. private _fit containing the core logic, called from fit - A default implementation for the fit_is_empty tag. Other transformers - should override this. - Parameters ---------- X : Input data Data to fit transform to, of valid collection type. y : Target variable, default=None Additional data, e.g., labels for transformation + + Returns + ------- + self: a fitted instance of the estimator """ - ... + # default fit is "no fitting happens" + return self @abstractmethod def _transform(self, X, y=None): @@ -199,7 +225,6 @@ def _transform(self, X, y=None): ------- transformed version of X """ - ... def _fit_transform(self, X, y=None): """Fit to data, then transform it. @@ -208,9 +233,6 @@ def _fit_transform(self, X, y=None): private _fit_transform containing the core logic, called from fit_transform. - Non-optimised default implementation; override when a better - method is possible for a given algorithm. - Parameters ---------- X : Input data @@ -222,10 +244,15 @@ def _fit_transform(self, X, y=None): ------- transformed version of X. """ - if not self.get_tag("fit_is_empty"): - self._fit(X, y) + # Non-optimized default implementation; override when a better + # method is possible for a given algorithm. + self._fit(X, y) return self._transform(X, y) + def _update(self, X, y=None, update_params=True): + # standard behaviour: no update takes place, new data is ignored + return self + def _postprocess_series(self, Xt, axis): """Postprocess data Xt to revert to original shape. From d93b4b31a4b7139ad06c82bd813657e901f8ae71 Mon Sep 17 00:00:00 2001 From: MatthewMiddlehurst Date: Tue, 26 Aug 2025 18:04:35 +0100 Subject: [PATCH 4/7] import --- aeon/transformations/base.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aeon/transformations/base.py b/aeon/transformations/base.py index fe2c8a5199..71087fca2f 100644 --- a/aeon/transformations/base.py +++ b/aeon/transformations/base.py @@ -1,7 +1,7 @@ """Base class for transformers.""" __maintainer__ = ["MatthewMiddlehurst", "TonyBagnall"] -__all__ = ["BaseTransformer"] +__all__ = ["BaseTransformer", "InverseTransformerMixin"] from abc import ABC, abstractmethod from typing import final @@ -10,8 +10,8 @@ import pandas as pd from aeon.base import BaseAeonEstimator -from aeon.transformations.collection import BaseCollectionTransformer -from aeon.transformations.series import BaseSeriesTransformer +from aeon.transformations.collection.base import BaseCollectionTransformer +from aeon.transformations.series.base import BaseSeriesTransformer class BaseTransformer(BaseAeonEstimator): From 5c4dc94379445380147c3cd49bd0eba4626ca2cf Mon Sep 17 00:00:00 2001 From: MatthewMiddlehurst Date: Sun, 26 Oct 2025 19:49:24 +0000 Subject: [PATCH 5/7] two classes --- aeon/transformations/__init__.py | 3 +- aeon/transformations/base.py | 19 +--- aeon/transformations/collection/__init__.py | 6 +- .../collection/_broadcaster.py | 10 +- aeon/transformations/collection/base.py | 62 ++++++++++++- .../collection/compose/_identity.py | 4 +- aeon/transformations/series/__init__.py | 10 +- aeon/transformations/series/_boxcox.py | 8 +- aeon/transformations/series/_log.py | 8 +- aeon/transformations/series/_scaled_logit.py | 10 +- aeon/transformations/series/base.py | 93 ++++++++++++++++++- .../series/compose/_identity.py | 4 +- 12 files changed, 195 insertions(+), 42 deletions(-) diff --git a/aeon/transformations/__init__.py b/aeon/transformations/__init__.py index 23421c062b..eae067f9cb 100644 --- a/aeon/transformations/__init__.py +++ b/aeon/transformations/__init__.py @@ -2,6 +2,7 @@ __all__ = [ "BaseTransformer", + "InverseTransformerMixin", ] -from aeon.transformations.base import BaseTransformer +from aeon.transformations.base import BaseTransformer, InverseTransformerMixin diff --git a/aeon/transformations/base.py b/aeon/transformations/base.py index 71087fca2f..9b55affebc 100644 --- a/aeon/transformations/base.py +++ b/aeon/transformations/base.py @@ -4,14 +4,11 @@ __all__ = ["BaseTransformer", "InverseTransformerMixin"] from abc import ABC, abstractmethod -from typing import final import numpy as np import pandas as pd from aeon.base import BaseAeonEstimator -from aeon.transformations.collection.base import BaseCollectionTransformer -from aeon.transformations.series.base import BaseSeriesTransformer class BaseTransformer(BaseAeonEstimator): @@ -124,7 +121,7 @@ class InverseTransformerMixin(ABC): "capability:inverse_transform": True, } - @final + @abstractmethod def inverse_transform(self, X, y=None, axis=1): """Inverse transform X and return an inverse transformed version. @@ -164,19 +161,7 @@ def inverse_transform(self, X, y=None, axis=1): inverse transformed version of X of the same type as X """ - # check whether is fitted - self._check_is_fitted() - - # input check and conversion for X/y - if isinstance(self, BaseCollectionTransformer): - X_inner = self._preprocess_collection(X, store_metadata=False) - Xt = self._inverse_transform(X=X_inner, y=y) - return Xt - elif isinstance(self, BaseSeriesTransformer): - self._check_is_fitted() - X = self._preprocess_series(X, axis=axis, store_metadata=False) - Xt = self._inverse_transform(X=X, y=y) - return self._postprocess_series(Xt, axis=axis) + ... @abstractmethod def _inverse_transform(self, X, y=None): diff --git a/aeon/transformations/collection/__init__.py b/aeon/transformations/collection/__init__.py index 19dddc6e99..306fb46f49 100644 --- a/aeon/transformations/collection/__init__.py +++ b/aeon/transformations/collection/__init__.py @@ -3,6 +3,7 @@ __all__ = [ # base class and series wrapper "BaseCollectionTransformer", + "CollectionInverseTransformerMixin", # transformers "AutocorrelationFunctionTransformer", "ARCoefficientTransformer", @@ -32,4 +33,7 @@ from aeon.transformations.collection._reduce import Tabularizer from aeon.transformations.collection._rescale import Centerer, MinMaxScaler, Normalizer from aeon.transformations.collection._slope import SlopeTransformer -from aeon.transformations.collection.base import BaseCollectionTransformer +from aeon.transformations.collection.base import ( + BaseCollectionTransformer, + CollectionInverseTransformerMixin, +) diff --git a/aeon/transformations/collection/_broadcaster.py b/aeon/transformations/collection/_broadcaster.py index 5df90a0c72..bf818ac125 100644 --- a/aeon/transformations/collection/_broadcaster.py +++ b/aeon/transformations/collection/_broadcaster.py @@ -5,13 +5,17 @@ import numpy as np -from aeon.transformations.base import InverseTransformerMixin -from aeon.transformations.collection.base import BaseCollectionTransformer +from aeon.transformations.collection.base import ( + BaseCollectionTransformer, + CollectionInverseTransformerMixin, +) from aeon.transformations.series.base import BaseSeriesTransformer from aeon.utils.validation.collection import get_n_cases -class SeriesToCollectionBroadcaster(BaseCollectionTransformer, InverseTransformerMixin): +class SeriesToCollectionBroadcaster( + BaseCollectionTransformer, CollectionInverseTransformerMixin +): """Broadcast a ``BaseSeriesTransformer`` over a collection of time series. Uses the ``BaseSeriesTransformer`` passed in the constructor. If the diff --git a/aeon/transformations/collection/base.py b/aeon/transformations/collection/base.py index 37ffb69d1e..55df22b0a2 100644 --- a/aeon/transformations/collection/base.py +++ b/aeon/transformations/collection/base.py @@ -20,15 +20,13 @@ class name: BaseCollectionTransformer """ __maintainer__ = ["MatthewMiddlehurst"] -__all__ = [ - "BaseCollectionTransformer", -] +__all__ = ["BaseCollectionTransformer", "CollectionInverseTransformerMixin"] from abc import abstractmethod from typing import final from aeon.base import BaseCollectionEstimator -from aeon.transformations.base import BaseTransformer +from aeon.transformations.base import BaseTransformer, InverseTransformerMixin from aeon.utils.validation.collection import get_n_cases @@ -265,3 +263,59 @@ def _fit_transform(self, X, y=None): if not self.get_tag("fit_is_empty"): self._fit(X, y) return self._transform(X, y) + + +class CollectionInverseTransformerMixin(InverseTransformerMixin): + """Mixin for transformers that support inverse transformation.""" + + _tags = { + "capability:inverse_transform": True, + } + + @final + def inverse_transform(self, X, y=None): + """Inverse transform X and return an inverse transformed version. + + Currently it is assumed that only transformers with tags + "input_data_type"="Series", "output_data_type"="Series", + can have an inverse_transform. + + State required: + Requires state to be "fitted". + + Accesses in self: + _is_fitted : must be True + fitted model attributes (ending in "_") : accessed by _inverse_transform + + Parameters + ---------- + X : Series or Collection, any supported type + Data to fit transform to, of python type as follows: + Series: 2D np.ndarray shape (n_channels, n_timepoints) + Collection: 3D np.ndarray shape (n_cases, n_channels, n_timepoints) + or list of 2D np.ndarray, case i has shape (n_channels, n_timepoints_i) + y : Series, default=None + Additional data, e.g., labels for transformation. + axis : int, default = 1 + Axis of time in the input series. + If ``axis == 0``, it is assumed each column is a time series and each row is + a time point. i.e. the shape of the data is ``(n_timepoints, + n_channels)``. + ``axis == 1`` indicates the time series are in rows, i.e. the shape of + the data is ``(n_channels, n_timepoints)`.``axis is None`` indicates + that the axis of X is the same as ``self.axis``. + + Only relevant for ``aeon.transformations.series`` transformers. + + Returns + ------- + inverse transformed version of X + of the same type as X + """ + # check whether is fitted + self._check_is_fitted() + + # input check and conversion for X/y + X_inner = self._preprocess_collection(X, store_metadata=False) + Xt = self._inverse_transform(X=X_inner, y=y) + return Xt diff --git a/aeon/transformations/collection/compose/_identity.py b/aeon/transformations/collection/compose/_identity.py index e197fd2fa6..521417b771 100644 --- a/aeon/transformations/collection/compose/_identity.py +++ b/aeon/transformations/collection/compose/_identity.py @@ -1,11 +1,11 @@ """Identity transformer.""" -from aeon.transformations.base import InverseTransformerMixin from aeon.transformations.collection import BaseCollectionTransformer +from aeon.transformations.collection.base import CollectionInverseTransformerMixin from aeon.utils.data_types import COLLECTIONS_DATA_TYPES -class CollectionId(BaseCollectionTransformer, InverseTransformerMixin): +class CollectionId(BaseCollectionTransformer, CollectionInverseTransformerMixin): """Identity transformer, returns data unchanged in transform/inverse_transform.""" _tags = { diff --git a/aeon/transformations/series/__init__.py b/aeon/transformations/series/__init__.py index 96d2c78958..c491479f36 100644 --- a/aeon/transformations/series/__init__.py +++ b/aeon/transformations/series/__init__.py @@ -1,8 +1,11 @@ """Series transformations.""" __all__ = [ - "AutoCorrelationSeriesTransformer", + # base class "BaseSeriesTransformer", + "SeriesInverseTransformerMixin", + # transformers + "AutoCorrelationSeriesTransformer", "ClaSPTransformer", "Dobin", "MatrixProfileTransformer", @@ -38,4 +41,7 @@ from aeon.transformations.series._pla import PLASeriesTransformer from aeon.transformations.series._scaled_logit import ScaledLogitSeriesTransformer from aeon.transformations.series._warping import WarpingSeriesTransformer -from aeon.transformations.series.base import BaseSeriesTransformer +from aeon.transformations.series.base import ( + BaseSeriesTransformer, + SeriesInverseTransformerMixin, +) diff --git a/aeon/transformations/series/_boxcox.py b/aeon/transformations/series/_boxcox.py index 70d9ad636a..adc13afcde 100644 --- a/aeon/transformations/series/_boxcox.py +++ b/aeon/transformations/series/_boxcox.py @@ -8,8 +8,10 @@ from scipy.special import boxcox, inv_boxcox from scipy.stats import boxcox_llf, distributions, variation -from aeon.transformations.base import InverseTransformerMixin -from aeon.transformations.series.base import BaseSeriesTransformer +from aeon.transformations.series.base import ( + BaseSeriesTransformer, + SeriesInverseTransformerMixin, +) # copy-pasted from scipy 1.7.3 since it moved in 1.8.0 and broke this estimator @@ -39,7 +41,7 @@ def _calc_uniform_order_statistic_medians(n): return v -class BoxCoxTransformer(BaseSeriesTransformer, InverseTransformerMixin): +class BoxCoxTransformer(BaseSeriesTransformer, SeriesInverseTransformerMixin): r"""Box-Cox power transform. Box-Cox transformation is a power transformation that is used to diff --git a/aeon/transformations/series/_log.py b/aeon/transformations/series/_log.py index b82f104430..fd345ccdf7 100644 --- a/aeon/transformations/series/_log.py +++ b/aeon/transformations/series/_log.py @@ -5,11 +5,13 @@ import numpy as np -from aeon.transformations.base import InverseTransformerMixin -from aeon.transformations.series.base import BaseSeriesTransformer +from aeon.transformations.series.base import ( + BaseSeriesTransformer, + SeriesInverseTransformerMixin, +) -class LogTransformer(BaseSeriesTransformer, InverseTransformerMixin): +class LogTransformer(BaseSeriesTransformer, SeriesInverseTransformerMixin): """Natural logarithm transformation. The Natural logarithm transformation can be used to make the data more normally diff --git a/aeon/transformations/series/_scaled_logit.py b/aeon/transformations/series/_scaled_logit.py index e94e659e89..2d6351bbbf 100644 --- a/aeon/transformations/series/_scaled_logit.py +++ b/aeon/transformations/series/_scaled_logit.py @@ -8,11 +8,15 @@ import numpy as np -from aeon.transformations.base import InverseTransformerMixin -from aeon.transformations.series.base import BaseSeriesTransformer +from aeon.transformations.series.base import ( + BaseSeriesTransformer, + SeriesInverseTransformerMixin, +) -class ScaledLogitSeriesTransformer(BaseSeriesTransformer, InverseTransformerMixin): +class ScaledLogitSeriesTransformer( + BaseSeriesTransformer, SeriesInverseTransformerMixin +): r"""Scaled logit transform or Log transform. If both lower_bound and upper_bound are not None, a scaled logit transform is diff --git a/aeon/transformations/series/base.py b/aeon/transformations/series/base.py index a21cee172e..022bd3eca9 100644 --- a/aeon/transformations/series/base.py +++ b/aeon/transformations/series/base.py @@ -11,8 +11,10 @@ class name: BaseSeriesTransformer from abc import abstractmethod from typing import final +from deprecated.sphinx import deprecated + from aeon.base import BaseSeriesEstimator -from aeon.transformations.base import BaseTransformer +from aeon.transformations.base import BaseTransformer, InverseTransformerMixin class BaseSeriesTransformer(BaseSeriesEstimator, BaseTransformer): @@ -262,3 +264,92 @@ def _postprocess_series(self, Xt, axis): return Xt else: return Xt.T + + # TODO: Remove in v1.4.0 + @deprecated( + version="1.3.0", + reason="update is deprecated for transformers and will be removed in v1.4.0.", + category=FutureWarning, + ) + @final + def update(self, X, y=None, update_params=True, axis=1): + """Update transformer with X, optionally y. + + Parameters + ---------- + X : data to update of valid series type. + y : Target variable, default=None + Additional data, e.g., labels for transformation + update_params : bool, default=True + whether the model is updated. Yes if true, if false, simply skips call. + argument exists for compatibility with forecasting module. + axis : int, default=None + axis along which to update. If None, uses self.axis. + + Returns + ------- + self : a fitted instance of the estimator + """ + # check whether is fitted + self._check_is_fitted() + X = self._preprocess_series(X, axis, False) + return self._update(X=X, y=y, update_params=update_params) + + def _update(self, X, y=None, update_params=True): + # standard behaviour: no update takes place, new data is ignored + return self + + +class SeriesInverseTransformerMixin(InverseTransformerMixin): + """Mixin for transformers that support inverse transformation.""" + + _tags = { + "capability:inverse_transform": True, + } + + @final + def inverse_transform(self, X, y=None, axis=1): + """Inverse transform X and return an inverse transformed version. + + Currently it is assumed that only transformers with tags + "input_data_type"="Series", "output_data_type"="Series", + can have an inverse_transform. + + State required: + Requires state to be "fitted". + + Accesses in self: + _is_fitted : must be True + fitted model attributes (ending in "_") : accessed by _inverse_transform + + Parameters + ---------- + X : Series or Collection, any supported type + Data to fit transform to, of python type as follows: + Series: 2D np.ndarray shape (n_channels, n_timepoints) + Collection: 3D np.ndarray shape (n_cases, n_channels, n_timepoints) + or list of 2D np.ndarray, case i has shape (n_channels, n_timepoints_i) + y : Series, default=None + Additional data, e.g., labels for transformation. + axis : int, default = 1 + Axis of time in the input series. + If ``axis == 0``, it is assumed each column is a time series and each row is + a time point. i.e. the shape of the data is ``(n_timepoints, + n_channels)``. + ``axis == 1`` indicates the time series are in rows, i.e. the shape of + the data is ``(n_channels, n_timepoints)`.``axis is None`` indicates + that the axis of X is the same as ``self.axis``. + + Only relevant for ``aeon.transformations.series`` transformers. + + Returns + ------- + inverse transformed version of X + of the same type as X + """ + # check whether is fitted + self._check_is_fitted() + + X = self._preprocess_series(X, axis=axis, store_metadata=False) + Xt = self._inverse_transform(X=X, y=y) + return self._postprocess_series(Xt, axis=axis) diff --git a/aeon/transformations/series/compose/_identity.py b/aeon/transformations/series/compose/_identity.py index c67d200c19..a026cc7816 100644 --- a/aeon/transformations/series/compose/_identity.py +++ b/aeon/transformations/series/compose/_identity.py @@ -1,11 +1,11 @@ """Identity transformer.""" -from aeon.transformations.base import InverseTransformerMixin from aeon.transformations.series import BaseSeriesTransformer +from aeon.transformations.series.base import SeriesInverseTransformerMixin from aeon.utils.data_types import VALID_SERIES_INNER_TYPES -class SeriesId(BaseSeriesTransformer, InverseTransformerMixin): +class SeriesId(BaseSeriesTransformer, SeriesInverseTransformerMixin): """Identity transformer, returns data unchanged in transform/inverse_transform.""" _tags = { From f552b12c8bc10d818fac390b219cc54510c9558b Mon Sep 17 00:00:00 2001 From: MatthewMiddlehurst Date: Sun, 26 Oct 2025 20:45:59 +0000 Subject: [PATCH 6/7] tests --- .../_yield_transformation_checks.py | 17 ++++++++++++++++- .../collection/tests/test_base.py | 7 +++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/aeon/testing/estimator_checking/_yield_transformation_checks.py b/aeon/testing/estimator_checking/_yield_transformation_checks.py index f01d2b5370..178ec5e167 100644 --- a/aeon/testing/estimator_checking/_yield_transformation_checks.py +++ b/aeon/testing/estimator_checking/_yield_transformation_checks.py @@ -10,8 +10,12 @@ from aeon.testing.testing_data import FULL_TEST_DATA_DICT from aeon.testing.utils.deep_equals import deep_equals from aeon.testing.utils.estimator_checks import _run_estimator_method +from aeon.transformations.collection import CollectionInverseTransformerMixin from aeon.transformations.collection.channel_selection.base import BaseChannelSelector -from aeon.transformations.series import BaseSeriesTransformer +from aeon.transformations.series import ( + BaseSeriesTransformer, + SeriesInverseTransformerMixin, +) from aeon.utils.data_types import COLLECTIONS_DATA_TYPES, VALID_SERIES_INNER_TYPES @@ -82,8 +86,19 @@ def check_transformer_overrides_and_tags(estimator_class): else: # must be a list assert any([t in valid_unequal_types for t in X_inner_type]) + inherits_inverse = ( + issubclass(estimator_class, SeriesInverseTransformerMixin) + if issubclass(estimator_class, BaseSeriesTransformer) + else issubclass(estimator_class, CollectionInverseTransformerMixin) + ) if estimator_class.get_class_tag("capability:inverse_transform"): + assert inherits_inverse + assert "inverse_transform" not in estimator_class.__dict__ assert "_inverse_transform" in estimator_class.__dict__ + else: + assert not inherits_inverse + assert "inverse_transform" not in estimator_class.__dict__ + assert "_inverse_transform" not in estimator_class.__dict__ def check_transformer_output(estimator, datatype): diff --git a/aeon/transformations/collection/tests/test_base.py b/aeon/transformations/collection/tests/test_base.py index c4aac11407..602a99902a 100644 --- a/aeon/transformations/collection/tests/test_base.py +++ b/aeon/transformations/collection/tests/test_base.py @@ -11,7 +11,10 @@ make_example_3d_numpy_list, make_example_pandas_series, ) -from aeon.transformations.collection import BaseCollectionTransformer +from aeon.transformations.collection import ( + BaseCollectionTransformer, + CollectionInverseTransformerMixin, +) @pytest.mark.parametrize( @@ -55,7 +58,7 @@ def test_raise_inverse_transform(): d.inverse_transform(x) -class _Dummy(BaseCollectionTransformer): +class _Dummy(BaseCollectionTransformer, CollectionInverseTransformerMixin): """Dummy transformer for testing. Converts a numpy array to a list of numpy arrays. From 5687d8236cf32fe7a7825fae2a5a33ce5f243461 Mon Sep 17 00:00:00 2001 From: MatthewMiddlehurst Date: Sun, 26 Oct 2025 21:27:36 +0000 Subject: [PATCH 7/7] swap inherit order and remove unneeded test --- aeon/transformations/collection/_broadcaster.py | 2 +- .../transformations/collection/compose/_identity.py | 2 +- aeon/transformations/collection/tests/test_base.py | 13 +------------ aeon/transformations/series/_boxcox.py | 2 +- aeon/transformations/series/_log.py | 2 +- aeon/transformations/series/_scaled_logit.py | 2 +- aeon/transformations/series/compose/_identity.py | 2 +- 7 files changed, 7 insertions(+), 18 deletions(-) diff --git a/aeon/transformations/collection/_broadcaster.py b/aeon/transformations/collection/_broadcaster.py index bf818ac125..9a4c36faa0 100644 --- a/aeon/transformations/collection/_broadcaster.py +++ b/aeon/transformations/collection/_broadcaster.py @@ -14,7 +14,7 @@ class SeriesToCollectionBroadcaster( - BaseCollectionTransformer, CollectionInverseTransformerMixin + CollectionInverseTransformerMixin, BaseCollectionTransformer ): """Broadcast a ``BaseSeriesTransformer`` over a collection of time series. diff --git a/aeon/transformations/collection/compose/_identity.py b/aeon/transformations/collection/compose/_identity.py index 521417b771..12f5c4a3aa 100644 --- a/aeon/transformations/collection/compose/_identity.py +++ b/aeon/transformations/collection/compose/_identity.py @@ -5,7 +5,7 @@ from aeon.utils.data_types import COLLECTIONS_DATA_TYPES -class CollectionId(BaseCollectionTransformer, CollectionInverseTransformerMixin): +class CollectionId(CollectionInverseTransformerMixin, BaseCollectionTransformer): """Identity transformer, returns data unchanged in transform/inverse_transform.""" _tags = { diff --git a/aeon/transformations/collection/tests/test_base.py b/aeon/transformations/collection/tests/test_base.py index 602a99902a..ada1cf2325 100644 --- a/aeon/transformations/collection/tests/test_base.py +++ b/aeon/transformations/collection/tests/test_base.py @@ -47,18 +47,7 @@ def test_collection_transformer_invalid_input(dtype): t.fit_transform(y) -def test_raise_inverse_transform(): - """Test that inverse transform raises NotImplementedError.""" - d = _Dummy() - x, _ = make_example_3d_numpy() - d.fit(x) - with pytest.raises( - NotImplementedError, match="does not implement " "inverse_transform" - ): - d.inverse_transform(x) - - -class _Dummy(BaseCollectionTransformer, CollectionInverseTransformerMixin): +class _Dummy(CollectionInverseTransformerMixin, BaseCollectionTransformer): """Dummy transformer for testing. Converts a numpy array to a list of numpy arrays. diff --git a/aeon/transformations/series/_boxcox.py b/aeon/transformations/series/_boxcox.py index adc13afcde..9f9a926c6b 100644 --- a/aeon/transformations/series/_boxcox.py +++ b/aeon/transformations/series/_boxcox.py @@ -41,7 +41,7 @@ def _calc_uniform_order_statistic_medians(n): return v -class BoxCoxTransformer(BaseSeriesTransformer, SeriesInverseTransformerMixin): +class BoxCoxTransformer(SeriesInverseTransformerMixin, BaseSeriesTransformer): r"""Box-Cox power transform. Box-Cox transformation is a power transformation that is used to diff --git a/aeon/transformations/series/_log.py b/aeon/transformations/series/_log.py index fd345ccdf7..d2e0cabead 100644 --- a/aeon/transformations/series/_log.py +++ b/aeon/transformations/series/_log.py @@ -11,7 +11,7 @@ ) -class LogTransformer(BaseSeriesTransformer, SeriesInverseTransformerMixin): +class LogTransformer(SeriesInverseTransformerMixin, BaseSeriesTransformer): """Natural logarithm transformation. The Natural logarithm transformation can be used to make the data more normally diff --git a/aeon/transformations/series/_scaled_logit.py b/aeon/transformations/series/_scaled_logit.py index 2d6351bbbf..69e8f2127d 100644 --- a/aeon/transformations/series/_scaled_logit.py +++ b/aeon/transformations/series/_scaled_logit.py @@ -15,7 +15,7 @@ class ScaledLogitSeriesTransformer( - BaseSeriesTransformer, SeriesInverseTransformerMixin + SeriesInverseTransformerMixin, BaseSeriesTransformer ): r"""Scaled logit transform or Log transform. diff --git a/aeon/transformations/series/compose/_identity.py b/aeon/transformations/series/compose/_identity.py index a026cc7816..b186b6fb70 100644 --- a/aeon/transformations/series/compose/_identity.py +++ b/aeon/transformations/series/compose/_identity.py @@ -5,7 +5,7 @@ from aeon.utils.data_types import VALID_SERIES_INNER_TYPES -class SeriesId(BaseSeriesTransformer, SeriesInverseTransformerMixin): +class SeriesId(SeriesInverseTransformerMixin, BaseSeriesTransformer): """Identity transformer, returns data unchanged in transform/inverse_transform.""" _tags = {