Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 43 additions & 5 deletions aeon/classification/convolution_based/_mr_hydra.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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.
Expand Down Expand Up @@ -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
--------
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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}
34 changes: 22 additions & 12 deletions aeon/classification/convolution_based/_rocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -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
Expand Down
53 changes: 53 additions & 0 deletions aeon/classification/convolution_based/tests/test_mr_hydra.py
Original file line number Diff line number Diff line change
@@ -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()
36 changes: 36 additions & 0 deletions aeon/classification/convolution_based/tests/test_rocket.py
Original file line number Diff line number Diff line change
@@ -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()
10 changes: 8 additions & 2 deletions aeon/classification/shapelet_based/_rdst.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down
32 changes: 32 additions & 0 deletions aeon/classification/shapelet_based/tests/test_rdst.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""RDST tests."""

from unittest.mock import patch

import numpy as np
from sklearn.ensemble import RandomForestClassifier

Expand Down Expand Up @@ -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()
38 changes: 38 additions & 0 deletions aeon/utils/validation/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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

@aadya940 aadya940 Sep 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure how relevant this is in this context but it is not necessary that LAPACK will overflow for more than 2**31 - 1 elements, it is only the case in the default LP64 scipy build. However, now SciPy supports ILP64 builds which can accomodate far large matrices (upto 64 bit integer indices), see these release notes, if we add these checks it will mostly work okay in the default SciPy but will restrict user who specifically use ILP64 SciPy builds.

Therefore, the more robust way would be to query scipy:

import scipy

try:
    config = scipy.show_config(mode='dicts')
    is_ilp64 = config['Build Dependencies']['blas']['cython_blas_ilp64']
    print(f"Is SciPy ILP64? {is_ilp64}")
except KeyError:
    print("Is SciPy ILP64? False (Default LP64 profile active)")

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."
)
Loading
Loading