From f5d06a3bc2791ae7578bd8c52163330b5a00f38d Mon Sep 17 00:00:00 2001 From: aashoday Date: Sat, 22 Aug 2026 11:08:40 +0530 Subject: [PATCH 1/6] [BUG] Guard default RidgeClassifierCV against 32-bit LAPACK SVD overflow Several classifiers (RDSTClassifier, RocketClassifier, MultiRocketHydraClassifier) fit a default RidgeClassifierCV on transformed feature matrices that can exceed the 32-bit LAPACK indexing limit for SVD, raising an unhelpful scipy error on large datasets. - Add check_lapack_svd_safe() to aeon.utils.validation, which raises an informative ValueError before fitting when the default estimator is used and the matrix would overflow. - Wire this check into RDSTClassifier and RocketClassifier ahead of their default RidgeClassifierCV fit. - Expose an estimator parameter on MultiRocketHydraClassifier and apply the same check, matching the pattern already used by RDSTClassifier and RocketClassifier. - The check is skipped entirely when a custom estimator is supplied, so behaviour for existing custom-estimator users is unchanged. --- .../convolution_based/_mr_hydra.py | 48 +++++++++++++++++-- .../convolution_based/_rocket.py | 34 ++++++++----- aeon/classification/shapelet_based/_rdst.py | 10 +++- aeon/utils/validation/__init__.py | 38 +++++++++++++++ 4 files changed, 111 insertions(+), 19 deletions(-) diff --git a/aeon/classification/convolution_based/_mr_hydra.py b/aeon/classification/convolution_based/_mr_hydra.py index c9c90a034d..93cdb4f6fe 100644 --- a/aeon/classification/convolution_based/_mr_hydra.py +++ b/aeon/classification/convolution_based/_mr_hydra.py @@ -8,11 +8,12 @@ from sklearn.linear_model import RidgeClassifierCV from sklearn.preprocessing import StandardScaler +from aeon.base._base import _clone_estimator from aeon.classification import BaseClassifier from aeon.classification.convolution_based._hydra import _SparseScaler from aeon.transformations.collection.convolution_based import MultiRocket from aeon.transformations.collection.convolution_based._hydra import HydraTransformer -from aeon.utils.validation import check_n_jobs +from aeon.utils.validation import check_lapack_svd_safe, check_n_jobs class MultiRocketHydraClassifier(BaseClassifier): @@ -30,6 +31,9 @@ class MultiRocketHydraClassifier(BaseClassifier): Number of kernels per group for the Hydra transform. n_groups : int, default=64 Number of groups per dilation for the Hydra transform. + estimator : sklearn compatible classifier or None, default=None + The estimator used. If None, a RidgeClassifierCV(alphas=np.logspace(-3, 3, 10)) + is used. class_weight{None, “balanced”}, dict or list of dicts, default=None From sklearn documentation: If None, all classes are assigned equal weights. @@ -57,6 +61,8 @@ class MultiRocketHydraClassifier(BaseClassifier): Number of classes. Extracted from the data. classes_ : ndarray of shape (n_classes_) Holds the label for each class. + estimator_ : sklearn classifier + The fitted estimator. See Also -------- @@ -93,12 +99,14 @@ def __init__( self, n_kernels: int = 8, n_groups: int = 64, + estimator=None, class_weight=None, n_jobs: int = 1, random_state=None, ): self.n_kernels = n_kernels self.n_groups = n_groups + self.estimator = estimator self.class_weight = class_weight self.n_jobs = n_jobs self.random_state = random_state @@ -130,10 +138,24 @@ def _fit(self, X, y): Xt = np.concatenate((Xt_hydra, Xt_multirocket), axis=1) - self.classifier = RidgeClassifierCV( - alphas=np.logspace(-3, 3, 10), class_weight=self.class_weight + _using_default_estimator = self.estimator is None + + self.estimator_ = _clone_estimator( + ( + RidgeClassifierCV( + alphas=np.logspace(-3, 3, 10), class_weight=self.class_weight + ) + if _using_default_estimator + else self.estimator + ), + self.random_state, ) - self.classifier.fit(Xt, y) + + if _using_default_estimator: + n_samples, n_features = Xt.shape + check_lapack_svd_safe(n_samples, n_features, "MultiRocketHydraClassifier") + + self.estimator_.fit(Xt, y) return self @@ -146,4 +168,20 @@ def _predict(self, X) -> np.ndarray: Xt = np.concatenate((Xt_hydra, Xt_multirocket), axis=1) - return self.classifier.predict(Xt) + return self.estimator_.predict(Xt) + + @classmethod + def _get_test_params(cls, parameter_set: str = "default") -> dict: + """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. + + Returns + ------- + params : dict + Parameters to create testing instances of the class. + """ + return {"n_kernels": 2, "n_groups": 2} diff --git a/aeon/classification/convolution_based/_rocket.py b/aeon/classification/convolution_based/_rocket.py index 8ff7cc4be4..51f13527ce 100644 --- a/aeon/classification/convolution_based/_rocket.py +++ b/aeon/classification/convolution_based/_rocket.py @@ -8,13 +8,12 @@ import numpy as np from sklearn.linear_model import RidgeClassifierCV -from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler from aeon.base._base import _clone_estimator from aeon.classification import BaseClassifier from aeon.transformations.collection.convolution_based import Rocket -from aeon.utils.validation import check_n_jobs +from aeon.utils.validation import check_lapack_svd_safe, check_n_jobs class RocketClassifier(BaseClassifier): @@ -139,23 +138,28 @@ def _fit(self, X, y): random_state=self.random_state, ) self._scaler = StandardScaler(with_mean=False) + + _using_default_estimator = self.estimator is None + self.estimator_ = _clone_estimator( ( RidgeClassifierCV( alphas=np.logspace(-3, 3, 10), class_weight=self.class_weight ) - if self.estimator is None + if _using_default_estimator else self.estimator ), self.random_state, ) - self.pipeline_ = make_pipeline( - self._transformer, - self._scaler, - self.estimator_, - ) - self.pipeline_.fit(X, y) + X_t = self._transformer.fit_transform(X) + X_t = self._scaler.fit_transform(X_t) + + if _using_default_estimator: + n_samples, n_features = X_t.shape + check_lapack_svd_safe(n_samples, n_features, "RocketClassifier") + + self.estimator_.fit(X_t, y) return self @@ -172,7 +176,10 @@ def _predict(self, X) -> np.ndarray: y : array-like, shape = (n_cases,) Predicted class labels. """ - return self.pipeline_.predict(X) + X_t = self._transformer.transform(X) + X_t = self._scaler.transform(X_t) + + return self.estimator_.predict(X_t) def _predict_proba(self, X) -> np.ndarray: """Predicts labels probabilities for sequences in X. @@ -187,12 +194,15 @@ def _predict_proba(self, X) -> np.ndarray: y : array-like, shape = (n_cases, n_classes_) Predicted probabilities using the ordering in classes_. """ + X_t = self._transformer.transform(X) + X_t = self._scaler.transform(X_t) + m = getattr(self.estimator_, "predict_proba", None) if callable(m): - return self.pipeline_.predict_proba(X) + return self.estimator_.predict_proba(X) else: dists = np.zeros((len(X), self.n_classes_)) - preds = self.pipeline_.predict(X) + preds = self.estimator_.predict(X) for i in range(0, len(X)): dists[i, np.where(self.classes_ == preds[i])] = 1 return dists diff --git a/aeon/classification/shapelet_based/_rdst.py b/aeon/classification/shapelet_based/_rdst.py index ebff3b56c2..9faded6db4 100644 --- a/aeon/classification/shapelet_based/_rdst.py +++ b/aeon/classification/shapelet_based/_rdst.py @@ -18,7 +18,7 @@ from aeon.transformations.collection.shapelet_based import ( RandomDilatedShapeletTransform, ) -from aeon.utils.validation import check_n_jobs +from aeon.utils.validation import check_lapack_svd_safe, check_n_jobs class RDSTClassifier(BaseClassifier): @@ -201,7 +201,9 @@ def _fit(self, X, y): random_state=self.random_state, ) - if self.estimator is None: + _using_default_estimator = self.estimator is None + + if _using_default_estimator: self.estimator_ = make_pipeline( StandardScaler(with_mean=True), RidgeClassifierCV( @@ -221,6 +223,10 @@ def _fit(self, X, y): if self.save_transformed_data: self.transformed_data_ = X_t + if _using_default_estimator: + n_samples, n_features = X_t.shape + check_lapack_svd_safe(n_samples, n_features, "RDSTClassifier") + self.estimator_.fit(X_t, y) return self diff --git a/aeon/utils/validation/__init__.py b/aeon/utils/validation/__init__.py index 5ab60b19bf..bcac6e12f6 100644 --- a/aeon/utils/validation/__init__.py +++ b/aeon/utils/validation/__init__.py @@ -1,11 +1,14 @@ """Validation and checking functions for time series.""" __all__ = [ + "check_lapack_svd_safe", "check_n_jobs", ] import os +import numpy as np + def check_n_jobs(n_jobs: int) -> int: """Check `n_jobs` parameter according to the scikit-learn convention. @@ -34,3 +37,38 @@ def check_n_jobs(n_jobs: int) -> int: return max(1, os.cpu_count() + 1 + n_jobs) else: return n_jobs + + +def check_lapack_svd_safe(n_samples: int, n_features: int, estimator_name: str) -> None: + """Raise an informative error if a matrix is too large for LAPACK SVD. + + Matrices with more than ``2**31 - 1`` elements may overflow 32-bit integer + indexing used internally by LAPACK during SVD-based operations. + + Parameters + ---------- + n_samples : int + Number of rows in the matrix. + n_features : int + Number of columns in the matrix. + estimator_name : str + Name of the calling estimator, used in the error message. + + Raises + ------ + ValueError + If ``n_samples * n_features`` exceeds the 32-bit LAPACK indexing limit. + """ + n_elements = int(n_samples) * int(n_features) + limit = np.iinfo(np.int32).max + + if n_elements > limit: + raise ValueError( + f"{estimator_name} cannot process this data because the " + f"transformed feature matrix has {n_samples} samples and " + f"{n_features} features ({n_elements} elements), exceeding " + f"the {limit} element limit for 32-bit LAPACK indexing during " + "SVD-based operations. This limitation is independent of " + "available RAM. Use an estimator or solver that does not rely " + "on the affected SVD-based operation." + ) From 7a8cec48c6be2e40b4fba899d504c99ae0e9ef16 Mon Sep 17 00:00:00 2001 From: aashoday Date: Sat, 22 Aug 2026 11:27:10 +0530 Subject: [PATCH 2/6] [BUG] Add tests for LAPACK SVD overflow check - test_check_lapack_svd_safe.py: boundary tests for check_lapack_svd_safe (under/at/over the 32-bit limit) and a check that the error message includes the computed element count. - test_rdst.py, test_rocket.py, test_mr_hydra.py: for each classifier, confirm check_lapack_svd_safe is called once when the default estimator is used, and never called when a custom estimator is supplied. --- .../convolution_based/tests/test_mr_hydra.py | 43 +++++++++++++++++++ .../convolution_based/tests/test_rocket.py | 36 ++++++++++++++++ .../shapelet_based/tests/test_rdst.py | 32 ++++++++++++++ .../tests/test_check_lapack_svd_safe.py | 42 ++++++++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 aeon/classification/convolution_based/tests/test_mr_hydra.py create mode 100644 aeon/classification/convolution_based/tests/test_rocket.py create mode 100644 aeon/utils/validation/tests/test_check_lapack_svd_safe.py diff --git a/aeon/classification/convolution_based/tests/test_mr_hydra.py b/aeon/classification/convolution_based/tests/test_mr_hydra.py new file mode 100644 index 0000000000..67d5300113 --- /dev/null +++ b/aeon/classification/convolution_based/tests/test_mr_hydra.py @@ -0,0 +1,43 @@ +"""MultiRocketHydra classifier tests.""" + +from unittest.mock import patch + +from sklearn.linear_model import RidgeClassifier + +from aeon.classification.convolution_based import MultiRocketHydraClassifier +from aeon.testing.data_generation import make_example_3d_numpy + + +def test_mrhydra_calls_lapack_check_with_default_estimator(): + """Check LAPACK safety when using the default estimator. + + MultiRocketHydra should call check_lapack_svd_safe when estimator is None. + """ + X, y = make_example_3d_numpy(n_cases=10, n_channels=1, n_timepoints=12) + clf = MultiRocketHydraClassifier(n_kernels=2, n_groups=2) + + with patch( + "aeon.classification.convolution_based._mr_hydra.check_lapack_svd_safe" + ) as mock_check: + clf.fit(X, y) + + mock_check.assert_called_once() + args, _ = mock_check.call_args + assert args[2] == "MultiRocketHydraClassifier" + + +def test_mrhydra_skips_lapack_check_with_custom_estimator(): + """Skip the LAPACK safety check with a custom estimator. + + MultiRocketHydra should not call check_lapack_svd_safe when a custom + estimator is supplied. + """ + X, y = make_example_3d_numpy(n_cases=10, n_channels=1, n_timepoints=12) + clf = MultiRocketHydraClassifier(n_kernels=2, estimator=RidgeClassifier()) + + with patch( + "aeon.classification.convolution_based._mr_hydra.check_lapack_svd_safe" + ) as mock_check: + clf.fit(X, y) + + mock_check.assert_not_called() diff --git a/aeon/classification/convolution_based/tests/test_rocket.py b/aeon/classification/convolution_based/tests/test_rocket.py new file mode 100644 index 0000000000..7d33e5ab20 --- /dev/null +++ b/aeon/classification/convolution_based/tests/test_rocket.py @@ -0,0 +1,36 @@ +"""Rocket classifier tests.""" + +from unittest.mock import patch + +from sklearn.linear_model import RidgeClassifier + +from aeon.classification.convolution_based import RocketClassifier +from aeon.testing.data_generation import make_example_3d_numpy + + +def test_rocket_calls_lapack_check_with_default_estimator(): + """RocketClassifier should call check_lapack_svd_safe when estimator is None.""" + X, y = make_example_3d_numpy(n_cases=10, n_channels=1, n_timepoints=12) + clf = RocketClassifier(n_kernels=20) + + with patch( + "aeon.classification.convolution_based._rocket.check_lapack_svd_safe" + ) as mock_check: + clf.fit(X, y) + + mock_check.assert_called_once() + args, _ = mock_check.call_args + assert args[2] == "RocketClassifier" + + +def test_rocket_skips_lapack_check_with_custom_estimator(): + """RocketClassifier should not call check_lapack_svd_safe with custom estimator.""" + X, y = make_example_3d_numpy(n_cases=10, n_channels=1, n_timepoints=12) + clf = RocketClassifier(n_kernels=20, estimator=RidgeClassifier()) + + with patch( + "aeon.classification.convolution_based._rocket.check_lapack_svd_safe" + ) as mock_check: + clf.fit(X, y) + + mock_check.assert_not_called() diff --git a/aeon/classification/shapelet_based/tests/test_rdst.py b/aeon/classification/shapelet_based/tests/test_rdst.py index b1e050b313..1ddd7c32b7 100644 --- a/aeon/classification/shapelet_based/tests/test_rdst.py +++ b/aeon/classification/shapelet_based/tests/test_rdst.py @@ -1,5 +1,7 @@ """RDST tests.""" +from unittest.mock import patch + import numpy as np from sklearn.ensemble import RandomForestClassifier @@ -31,3 +33,33 @@ def test_rdst_estimator_attribute_lifecycle(): assert hasattr(model, "estimator_") assert not hasattr(model, "_estimator") + + +def test_rdst_calls_lapack_check_with_default_estimator(): + """RDSTClassifier should call check_lapack_svd_safe when estimator is None.""" + X, y = make_example_3d_numpy(n_cases=10, n_channels=1, n_timepoints=12) + clf = RDSTClassifier(max_shapelets=5) + + with patch( + "aeon.classification.shapelet_based._rdst.check_lapack_svd_safe" + ) as mock_check: + clf.fit(X, y) + + mock_check.assert_called_once() + args, _ = mock_check.call_args + assert args[2] == "RDSTClassifier" + + +def test_rdst_skips_lapack_check_with_custom_estimator(): + """RDSTClassifier should not call check_lapack_svd_safe with a custom estimator.""" + from sklearn.linear_model import RidgeClassifier + + X, y = make_example_3d_numpy(n_cases=10, n_channels=1, n_timepoints=12) + clf = RDSTClassifier(max_shapelets=5, estimator=RidgeClassifier()) + + with patch( + "aeon.classification.shapelet_based._rdst.check_lapack_svd_safe" + ) as mock_check: + clf.fit(X, y) + + mock_check.assert_not_called() diff --git a/aeon/utils/validation/tests/test_check_lapack_svd_safe.py b/aeon/utils/validation/tests/test_check_lapack_svd_safe.py new file mode 100644 index 0000000000..9208bea1a5 --- /dev/null +++ b/aeon/utils/validation/tests/test_check_lapack_svd_safe.py @@ -0,0 +1,42 @@ +"""Tests for check_lapack_svd_safe.""" + +import numpy as np +import pytest + +from aeon.utils.validation import check_lapack_svd_safe + + +def test_check_lapack_svd_safe_under_limit(): + """Matrices under the 32-bit LAPACK element limit should not raise.""" + limit = np.iinfo(np.int32).max + n_samples = 1000 + n_features = limit // n_samples - 1 + + check_lapack_svd_safe(n_samples, n_features, "TestEstimator") + + +def test_check_lapack_svd_safe_at_limit(): + """Exactly at the limit should not raise.""" + limit = np.iinfo(np.int32).max + n_samples = 1 + n_features = limit + + check_lapack_svd_safe(n_samples, n_features, "TestEstimator") + + +def test_check_lapack_svd_safe_over_limit(): + """Matrices exceeding the limit should raise an informative ValueError.""" + n_samples = 112_186 + n_features = 30_000 + + with pytest.raises(ValueError, match="TestEstimator"): + check_lapack_svd_safe(n_samples, n_features, "TestEstimator") + + +def test_check_lapack_svd_safe_message_mentions_element_count(): + """The raised error should surface sample/feature/element counts.""" + n_samples = 200_000 + n_features = 20_000 + + with pytest.raises(ValueError, match=str(n_samples * n_features)): + check_lapack_svd_safe(n_samples, n_features, "TestEstimator") From 5996be38eeaae9f777bebd3722cc341cb04dad8c Mon Sep 17 00:00:00 2001 From: aashoday Date: Sat, 22 Aug 2026 18:21:49 +0530 Subject: [PATCH 3/6] [DOC] Add check_lapack_svd_safe to API reference (#3737) --- docs/api_reference/utils.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api_reference/utils.md b/docs/api_reference/utils.md index c5de14aac6..4a4dbf4ace 100644 --- a/docs/api_reference/utils.md +++ b/docs/api_reference/utils.md @@ -176,6 +176,7 @@ is_date_offset is_timedelta_or_date_offset check_n_jobs + check_lapack_svd_safe check_window_length get_n_cases get_type From 7e8cadf86050907d98672a66aab8ade431d7bf47 Mon Sep 17 00:00:00 2001 From: aashoday Date: Sat, 22 Aug 2026 18:58:27 +0530 Subject: [PATCH 4/6] [BUG] Fix RocketClassifier predict_proba passing raw 3D X to estimator_ instead of transformed X_t (#3737) --- aeon/classification/convolution_based/_rocket.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aeon/classification/convolution_based/_rocket.py b/aeon/classification/convolution_based/_rocket.py index 51f13527ce..3fdb7380ee 100644 --- a/aeon/classification/convolution_based/_rocket.py +++ b/aeon/classification/convolution_based/_rocket.py @@ -199,10 +199,10 @@ def _predict_proba(self, X) -> np.ndarray: m = getattr(self.estimator_, "predict_proba", None) if callable(m): - return self.estimator_.predict_proba(X) + return self.estimator_.predict_proba(X_t) else: dists = np.zeros((len(X), self.n_classes_)) - preds = self.estimator_.predict(X) + preds = self.estimator_.predict(X_t) for i in range(0, len(X)): dists[i, np.where(self.classes_ == preds[i])] = 1 return dists From 6eac085c894d536dd4f27811136c65b2b73030bc Mon Sep 17 00:00:00 2001 From: aashoday Date: Sat, 22 Aug 2026 18:58:29 +0530 Subject: [PATCH 5/6] [BUG] Skip MultiRocketHydraClassifier LAPACK tests when torch is unavailable (#3737) --- .../convolution_based/tests/test_mr_hydra.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/aeon/classification/convolution_based/tests/test_mr_hydra.py b/aeon/classification/convolution_based/tests/test_mr_hydra.py index 67d5300113..f74af766b6 100644 --- a/aeon/classification/convolution_based/tests/test_mr_hydra.py +++ b/aeon/classification/convolution_based/tests/test_mr_hydra.py @@ -2,12 +2,18 @@ from unittest.mock import patch +import pytest from sklearn.linear_model import RidgeClassifier from aeon.classification.convolution_based import MultiRocketHydraClassifier from aeon.testing.data_generation import make_example_3d_numpy +from aeon.utils.validation._dependencies import _check_soft_dependencies +@pytest.mark.skipif( + not _check_soft_dependencies("torch", severity="none"), + reason="skip test if required soft dependency not available", +) def test_mrhydra_calls_lapack_check_with_default_estimator(): """Check LAPACK safety when using the default estimator. @@ -26,6 +32,10 @@ def test_mrhydra_calls_lapack_check_with_default_estimator(): assert args[2] == "MultiRocketHydraClassifier" +@pytest.mark.skipif( + not _check_soft_dependencies("torch", severity="none"), + reason="skip test if required soft dependency not available", +) def test_mrhydra_skips_lapack_check_with_custom_estimator(): """Skip the LAPACK safety check with a custom estimator. From 3382635a3f355445fb67b943b813e13ad2f00757 Mon Sep 17 00:00:00 2001 From: aashoday Date: Sat, 22 Aug 2026 19:14:31 +0530 Subject: [PATCH 6/6] trigger CI re-run