diff --git a/aeon/transformations/collection/feature_based/_catch22.py b/aeon/transformations/collection/feature_based/_catch22.py index b29bee8c44..fde03fb275 100644 --- a/aeon/transformations/collection/feature_based/_catch22.py +++ b/aeon/transformations/collection/feature_based/_catch22.py @@ -1904,6 +1904,21 @@ def _quantile(X, quant): _local_simple_mean3_stderr = Catch22._FC_LocalSimple_mean3_stderr +@njit(fastmath=True, cache=True) +def _zeros_promoted(series, n): + """Allocate a length-n zero array typed to match catch22's dtype rule. + + Applies the same dtype-promotion rule used internally by + z_normalise_series_with_mean / _as_normalised_float (float32 + input stays float32, every other dtype -- including integer input -- + promotes to float64). Used both for typed placeholders whose real + values are assigned later, and for output buffers that must keep the + input's precision instead of silently upcasting to float64. + """ + dtype = (series[:0] / 1).dtype + return np.zeros(n, dtype=dtype) + + @njit(fastmath=True, cache=True) def _transform_case_numba( X, f_idx, keep, outlier_norm, fft_case, ac_case, ac_tw, ac_nfft, stds @@ -1920,7 +1935,7 @@ def _transform_case_numba( requested (numba's np.std can round differently from numpy's). """ n_feats = len(f_idx) - c22 = np.zeros(n_feats * len(X)) + c22 = _zeros_promoted(X[0], n_feats * len(X)) f_count = -1 for i in range(len(X)): @@ -1934,7 +1949,10 @@ def _transform_case_numba( # typed placeholders; real values are assigned before first use. These # must be dtype-fixed (not aliases of series) so the kernel also # compiles for integer input arrays. - outlier_series = np.empty(0, np.float64) + # / 1 applies the same dtype promotion z_normalise_series_with_mean + # uses internally (float32 stays float32, everything else becomes + # float64), so this placeholder always matches the real value's dtype. + outlier_series = _zeros_promoted(series, 0) have_outlier = False fft = ac_tw[:0] have_fft = False diff --git a/aeon/transformations/collection/feature_based/tests/test_catch22.py b/aeon/transformations/collection/feature_based/tests/test_catch22.py index 63348ace1d..48c0c5954a 100644 --- a/aeon/transformations/collection/feature_based/tests/test_catch22.py +++ b/aeon/transformations/collection/feature_based/tests/test_catch22.py @@ -7,6 +7,7 @@ from numpy import testing from aeon.datasets import load_basic_motions +from aeon.testing.data_generation import make_example_3d_numpy from aeon.transformations.collection.feature_based import Catch22 from aeon.utils.validation._dependencies import _check_soft_dependencies @@ -1521,3 +1522,26 @@ def track_n(s): assert abs(np.std(passed_series["p"]) - 1.0) < 1e-10 assert abs(np.mean(passed_series["n"])) < 1e-10 assert abs(np.std(passed_series["n"]) - 1.0) < 1e-10 + + +def test_catch22_float32_output_dtype(): + """Test Catch22 does not raise on float32 input with outlier features. + + Regression test: the outlier_series placeholder in _transform_case_numba was + hardcoded to float64, which numba's nopython mode could not unify with a + float32 z-normalised series produced for features 13/14 (outlier_include / + outlier_include_n), raising a TypingError for float32 input. + """ + X = make_example_3d_numpy( + n_cases=6, n_channels=1, n_timepoints=40, random_state=0, return_y=False + ).astype(np.float32) + + # outlier_norm=True exercises the fixed code path (features 13/14) + c22 = Catch22(outlier_norm=True, replace_nans=True) + result = c22.fit_transform(X) + assert result.shape == (6, 22) + + # outlier_norm=False takes the other branch -- should still work + c22_no_norm = Catch22(outlier_norm=False, replace_nans=True) + result_no_norm = c22_no_norm.fit_transform(X) + assert result_no_norm.shape == (6, 22)