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..3fdb7380ee 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_t) else: dists = np.zeros((len(X), self.n_classes_)) - preds = self.pipeline_.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 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..f74af766b6 --- /dev/null +++ b/aeon/classification/convolution_based/tests/test_mr_hydra.py @@ -0,0 +1,53 @@ +"""MultiRocketHydra classifier tests.""" + +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. + + 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" + + +@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. + + 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/_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/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/__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." + ) 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") diff --git a/docs/api_reference/utils.md b/docs/api_reference/utils.md index 2c6ad93ec4..05bedc7f56 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