From 1757bba2cca480011c6ae37b1f963d2665a65803 Mon Sep 17 00:00:00 2001 From: Tony Bagnall Date: Wed, 22 Jul 2026 17:54:00 +0100 Subject: [PATCH 1/2] _fit_transform --- .../interval_based/_random_intervals.py | 35 +++++++++++------- .../interval_based/_supervised_intervals.py | 36 +++++++++++++++---- 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/aeon/transformations/collection/interval_based/_random_intervals.py b/aeon/transformations/collection/interval_based/_random_intervals.py index 9605e48eeb..fb2c267491 100644 --- a/aeon/transformations/collection/interval_based/_random_intervals.py +++ b/aeon/transformations/collection/interval_based/_random_intervals.py @@ -363,30 +363,33 @@ def _generate_interval(self, X, y, seed, transform): for feature in self._features: if isinstance(feature, BaseTransformer): + # The interval slice is already validated (at the top-level fit) + # and in the numpy3D inner type, so collection transformers use + # the private path to skip redundant per-slice input checks. + interval = np.expand_dims( + X[:, dim, interval_start:interval_end:dilation], axis=1 + ) + is_collection = isinstance(feature, BaseCollectionTransformer) 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 = ( + feature._fit_transform(interval, y) + if is_collection + else feature.fit_transform(interval, y) ) if t.ndim == 3 and t.shape[1] == 1: t = t.reshape((t.shape[0], t.shape[2])) Xt_parts.append(t) + elif is_collection: + feature._fit(interval, y) else: - feature.fit( - np.expand_dims( - X[:, dim, interval_start:interval_end:dilation], axis=1 - ), - y, - ) + feature.fit(interval, y) elif transform: t = np.asarray( feature(X[:, dim, interval_start:interval_end:dilation]) @@ -415,8 +418,14 @@ 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) + interval = np.expand_dims( + X[:, dim, interval_start:interval_end:dilation], axis=1 + ) + # See _generate_interval: private path skips redundant per-slice checks. + Xt = ( + feature._transform(interval) + if isinstance(feature, BaseCollectionTransformer) + else feature.transform(interval) ) 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..d275bc8f21 100644 --- a/aeon/transformations/collection/interval_based/_supervised_intervals.py +++ b/aeon/transformations/collection/interval_based/_supervised_intervals.py @@ -411,10 +411,28 @@ 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 self._feature_transform(feature, X[:, dim, start:end]).flatten() else: return feature(X[:, dim, start:end]) + def _feature_fit_transform(self, feature, X, y=None): + """fit_transform a single-channel 2D interval slice. + + ``X`` is a ``(n_cases, n_timepoints)`` slice of a single channel. For aeon + collection transformers the top-level input has already been validated, so + the slice is expanded to the ``numpy3D`` inner type and passed to the + private ``_fit_transform``, skipping the redundant per-slice input checks. + """ + if isinstance(feature, BaseCollectionTransformer): + return feature._fit_transform(np.expand_dims(X, axis=1), y) + return feature.fit_transform(X) + + def _feature_transform(self, feature, X): + """Transform a single-channel 2D interval slice. See _feature_fit_transform.""" + if isinstance(feature, BaseCollectionTransformer): + return feature._transform(np.expand_dims(X, axis=1)) + return feature.transform(X) + def _supervised_search( self, X, @@ -445,8 +463,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 = self._feature_fit_transform( + feature, sub_interval_0 + ).flatten() + interval_feature_1 = self._feature_fit_transform( + feature, sub_interval_1 + ).flatten() else: interval_feature_0 = feature(sub_interval_0) interval_feature_1 = feature(sub_interval_1) @@ -463,8 +485,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 = self._feature_transform( + feature, X_ori[:, ini_idx:end] ).flatten() else: interval_feature_to_use = feature(X_ori[:, ini_idx:end]) @@ -490,8 +512,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 = self._feature_transform( + feature, X_ori[:, ini_idx:end] ).flatten() else: interval_feature_to_use = feature(X_ori[:, ini_idx:end]) From f56714c3453e31e92227e091fbad907534da2384 Mon Sep 17 00:00:00 2001 From: Tony Bagnall Date: Mon, 31 Aug 2026 19:26:47 +0100 Subject: [PATCH 2/2] restructure --- .../interval_based/_interval_features.py | 110 ++++++++++++++++++ .../interval_based/_random_intervals.py | 36 +++--- .../interval_based/_supervised_intervals.py | 42 +++---- 3 files changed, 139 insertions(+), 49 deletions(-) create mode 100644 aeon/transformations/collection/interval_based/_interval_features.py 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 f262f47967..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,33 +366,25 @@ def _generate_interval(self, X, y, seed, transform): for feature in self._features: if isinstance(feature, BaseTransformer): - # The interval slice is already validated (at the top-level fit) - # and in the numpy3D inner type, so collection transformers use - # the private path to skip redundant per-slice input checks. - interval = np.expand_dims( - X[:, dim, interval_start:interval_end:dilation], axis=1 - ) - is_collection = isinstance(feature, BaseCollectionTransformer) + # 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(interval, y) - if is_collection - else feature.fit_transform(interval, 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) - elif is_collection: - feature._fit(interval, y) else: - feature.fit(interval, y) + _fit_feature(feature, interval, y) elif transform: t = np.asarray( feature(X[:, dim, interval_start:interval_end:dilation]) @@ -416,14 +413,9 @@ def _transform_interval(self, X, idx, keep_transform): return np.zeros((X.shape[0], 1)) if isinstance(feature, BaseTransformer): - interval = np.expand_dims( - X[:, dim, interval_start:interval_end:dilation], axis=1 - ) # See _generate_interval: private path skips redundant per-slice checks. - Xt = ( - feature._transform(interval) - if isinstance(feature, BaseCollectionTransformer) - else feature.transform(interval) + 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 d275bc8f21..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,28 +415,12 @@ def _transform_intervals(self, X, idx): start, end, dim, feature = self.intervals_[idx] if isinstance(feature, BaseTransformer): - return self._feature_transform(feature, X[:, dim, start:end]).flatten() + return _transform_feature( + feature, X[:, dim, start:end], expand_fallback=False + ).flatten() else: return feature(X[:, dim, start:end]) - def _feature_fit_transform(self, feature, X, y=None): - """fit_transform a single-channel 2D interval slice. - - ``X`` is a ``(n_cases, n_timepoints)`` slice of a single channel. For aeon - collection transformers the top-level input has already been validated, so - the slice is expanded to the ``numpy3D`` inner type and passed to the - private ``_fit_transform``, skipping the redundant per-slice input checks. - """ - if isinstance(feature, BaseCollectionTransformer): - return feature._fit_transform(np.expand_dims(X, axis=1), y) - return feature.fit_transform(X) - - def _feature_transform(self, feature, X): - """Transform a single-channel 2D interval slice. See _feature_fit_transform.""" - if isinstance(feature, BaseCollectionTransformer): - return feature._transform(np.expand_dims(X, axis=1)) - return feature.transform(X) - def _supervised_search( self, X, @@ -463,11 +451,11 @@ def _supervised_search( sub_interval_1 = X[:, div_point:] if feature_is_transformer: - interval_feature_0 = self._feature_fit_transform( - feature, sub_interval_0 + interval_feature_0 = _fit_transform_feature( + feature, sub_interval_0, expand_fallback=False ).flatten() - interval_feature_1 = self._feature_fit_transform( - feature, sub_interval_1 + interval_feature_1 = _fit_transform_feature( + feature, sub_interval_1, expand_fallback=False ).flatten() else: interval_feature_0 = feature(sub_interval_0) @@ -485,8 +473,8 @@ def _supervised_search( if keep_transform: if self.normalise_for_search: if feature_is_transformer: - interval_feature_to_use = self._feature_transform( - feature, 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]) @@ -512,8 +500,8 @@ def _supervised_search( if keep_transform: if self.normalise_for_search: if feature_is_transformer: - interval_feature_to_use = self._feature_transform( - feature, 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])