diff --git a/aeon/transformations/collection/interval_based/_interval_features.py b/aeon/transformations/collection/interval_based/_interval_features.py new file mode 100644 index 0000000000..5111e7f8ca --- /dev/null +++ b/aeon/transformations/collection/interval_based/_interval_features.py @@ -0,0 +1,110 @@ +"""Shared helpers for extracting transformer features from interval slices. + +``RandomIntervals`` and ``SupervisedIntervals`` both extract features from single +channel slices of a collection that the top-level ``fit`` has already validated. +Calling a transformer feature's public ``fit``/``fit_transform``/``transform`` on +every slice repeats that validation thousands of times per forest fit, so for aeon +collection transformers these helpers call the private methods instead, which +assume input already in the ``numpy3D`` inner type. +""" + +__maintainer__ = [] +__all__ = ["_fit_feature", "_fit_transform_feature", "_transform_feature"] + +import numpy as np + +from aeon.transformations.collection.base import BaseCollectionTransformer + + +def _fit_feature(feature, X, y=None, expand_fallback=True): + """Fit a transformer feature on a single channel interval slice. + + Parameters + ---------- + feature : BaseTransformer + The transformer feature to fit. + X : 2D np.ndarray of shape (n_cases, interval_length) + A single channel interval slice, taken from input the top-level ``fit`` + has already validated. + y : 1D np.ndarray or None, default=None + Class labels, passed on to the feature. + expand_fallback : bool, default=True + Whether a feature that is not a collection transformer receives the + slice expanded to ``numpy3D`` along with ``y``, or the 2D slice on its + own. See the note below. + + Returns + ------- + feature : BaseTransformer + The fitted feature. + + Notes + ----- + ``expand_fallback`` exists only to preserve the two callers' pre-existing + behaviour for features that are not aeon collection transformers: + ``RandomIntervals`` passes such a feature the expanded slice and ``y``, + ``SupervisedIntervals`` passes the 2D slice and no ``y``. The collection + transformer path, which is the one this module exists to speed up, is the + same for both. + """ + if isinstance(feature, BaseCollectionTransformer): + return feature._fit(np.expand_dims(X, axis=1), y) + if expand_fallback: + return feature.fit(np.expand_dims(X, axis=1), y) + return feature.fit(X) + + +def _fit_transform_feature(feature, X, y=None, expand_fallback=True): + """Fit and transform a transformer feature on a single channel interval slice. + + Parameters + ---------- + feature : BaseTransformer + The transformer feature to fit and apply. + X : 2D np.ndarray of shape (n_cases, interval_length) + A single channel interval slice, taken from input the top-level ``fit`` + has already validated. + y : 1D np.ndarray or None, default=None + Class labels, passed on to the feature. + expand_fallback : bool, default=True + Whether a feature that is not a collection transformer receives the + slice expanded to ``numpy3D`` along with ``y``, or the 2D slice on its + own. See the note in ``_fit_feature``. + + Returns + ------- + Xt : np.ndarray + The extracted features, in whatever shape the feature returns. + """ + if isinstance(feature, BaseCollectionTransformer): + return feature._fit_transform(np.expand_dims(X, axis=1), y) + if expand_fallback: + return feature.fit_transform(np.expand_dims(X, axis=1), y) + return feature.fit_transform(X) + + +def _transform_feature(feature, X, expand_fallback=True): + """Transform a single channel interval slice with a fitted transformer feature. + + Parameters + ---------- + feature : BaseTransformer + The fitted transformer feature to apply. + X : 2D np.ndarray of shape (n_cases, interval_length) + A single channel interval slice, taken from input the top-level + ``transform`` has already validated. + expand_fallback : bool, default=True + Whether a feature that is not a collection transformer receives the + slice expanded to ``numpy3D``, or the 2D slice. See the note in + ``_fit_feature``. + + Returns + ------- + Xt : np.ndarray + The extracted features, in whatever shape the feature returns. + """ + if isinstance(feature, BaseCollectionTransformer): + return feature._transform(np.expand_dims(X, axis=1)) + if expand_fallback: + return feature.transform(np.expand_dims(X, axis=1)) + return feature.transform(X) diff --git a/aeon/transformations/collection/interval_based/_random_intervals.py b/aeon/transformations/collection/interval_based/_random_intervals.py index dcfe883deb..d4d3f0100c 100644 --- a/aeon/transformations/collection/interval_based/_random_intervals.py +++ b/aeon/transformations/collection/interval_based/_random_intervals.py @@ -13,6 +13,11 @@ from aeon.base._base import _clone_estimator from aeon.transformations.base import BaseTransformer from aeon.transformations.collection.base import BaseCollectionTransformer +from aeon.transformations.collection.interval_based._interval_features import ( + _fit_feature, + _fit_transform_feature, + _transform_feature, +) from aeon.utils._parallel import _run_jobs from aeon.utils.numba.stats import ( row_mean, @@ -361,30 +366,25 @@ def _generate_interval(self, X, y, seed, transform): for feature in self._features: if isinstance(feature, BaseTransformer): + # X was validated by the top-level fit, so a slice of it is + # already in the numpy3D inner type once expanded; the helpers + # skip the redundant per-slice checks for collection + # transformers. + interval = X[:, dim, interval_start:interval_end:dilation] if transform: feature = _clone_estimator( feature, seed, ) - t = feature.fit_transform( - np.expand_dims( - X[:, dim, interval_start:interval_end:dilation], axis=1 - ), - y, - ) + t = _fit_transform_feature(feature, interval, y) if t.ndim == 3 and t.shape[1] == 1: t = t.reshape((t.shape[0], t.shape[2])) Xt_parts.append(t) else: - feature.fit( - np.expand_dims( - X[:, dim, interval_start:interval_end:dilation], axis=1 - ), - y, - ) + _fit_feature(feature, interval, y) elif transform: t = np.asarray( feature(X[:, dim, interval_start:interval_end:dilation]) @@ -413,8 +413,9 @@ def _transform_interval(self, X, idx, keep_transform): return np.zeros((X.shape[0], 1)) if isinstance(feature, BaseTransformer): - Xt = feature.transform( - np.expand_dims(X[:, dim, interval_start:interval_end:dilation], axis=1) + # See _generate_interval: private path skips redundant per-slice checks. + Xt = _transform_feature( + feature, X[:, dim, interval_start:interval_end:dilation] ) if Xt.ndim == 3: diff --git a/aeon/transformations/collection/interval_based/_supervised_intervals.py b/aeon/transformations/collection/interval_based/_supervised_intervals.py index f4ba2c4c40..3d00f374f1 100644 --- a/aeon/transformations/collection/interval_based/_supervised_intervals.py +++ b/aeon/transformations/collection/interval_based/_supervised_intervals.py @@ -17,6 +17,10 @@ from aeon.base._base import _clone_estimator from aeon.transformations.base import BaseTransformer from aeon.transformations.collection.base import BaseCollectionTransformer +from aeon.transformations.collection.interval_based._interval_features import ( + _fit_transform_feature, + _transform_feature, +) from aeon.utils.numba.general import z_normalise_series_3d from aeon.utils.numba.stats import ( fisher_score, @@ -411,7 +415,9 @@ def _transform_intervals(self, X, idx): start, end, dim, feature = self.intervals_[idx] if isinstance(feature, BaseTransformer): - return feature.transform(X[:, dim, start:end]).flatten() + return _transform_feature( + feature, X[:, dim, start:end], expand_fallback=False + ).flatten() else: return feature(X[:, dim, start:end]) @@ -445,8 +451,12 @@ def _supervised_search( sub_interval_1 = X[:, div_point:] if feature_is_transformer: - interval_feature_0 = feature.fit_transform(sub_interval_0).flatten() - interval_feature_1 = feature.fit_transform(sub_interval_1).flatten() + interval_feature_0 = _fit_transform_feature( + feature, sub_interval_0, expand_fallback=False + ).flatten() + interval_feature_1 = _fit_transform_feature( + feature, sub_interval_1, expand_fallback=False + ).flatten() else: interval_feature_0 = feature(sub_interval_0) interval_feature_1 = feature(sub_interval_1) @@ -463,8 +473,8 @@ def _supervised_search( if keep_transform: if self.normalise_for_search: if feature_is_transformer: - interval_feature_to_use = feature.transform( - X_ori[:, ini_idx:end] + interval_feature_to_use = _transform_feature( + feature, X_ori[:, ini_idx:end], expand_fallback=False ).flatten() else: interval_feature_to_use = feature(X_ori[:, ini_idx:end]) @@ -490,8 +500,8 @@ def _supervised_search( if keep_transform: if self.normalise_for_search: if feature_is_transformer: - interval_feature_to_use = feature.transform( - X_ori[:, ini_idx:end] + interval_feature_to_use = _transform_feature( + feature, X_ori[:, ini_idx:end], expand_fallback=False ).flatten() else: interval_feature_to_use = feature(X_ori[:, ini_idx:end])