[ENH] Speed up Arsenal classifier - #3655
Conversation
Thank you for contributing to
|
_apply_kernels built the output as float32 and then returned _X.astype(np.float32), which in numba always copies; return the array directly. Kernel results are written as two scalars rather than a tuple-to-slice assignment. Bit-identical outputs verified over univariate, multivariate and unnormalised configurations; timing is unchanged (the win is one full output-matrix allocation per transform). Co-Authored-By: Claude Fable 5 <[email protected]>
Cast X to float32 before _apply_kernels: the kernel weights and output features are already float32, so float64 input only added per-element promotion in the hot loop. Matches the original ROCKET reference implementation, which is float32 throughout. Measured 1.11-1.12x on univariate transform (interleaved A/B, min of 5), neutral on multivariate; outputs shift by at most 1.9e-05 across univariate, multivariate and unnormalised reference configurations (tolerance-level change agreed for this case, not bit-identical). All rocket-family tests pass unchanged. Co-Authored-By: Claude Fable 5 <[email protected]>
# Conflicts: # aeon/transformations/collection/convolution_based/_rocket.py
…tests Nothing passed keep_transformed_data=True after the train-estimate path was fused into _fit_ensemble_estimator, so the parameter, the Xt accumulation and the list-wrapped Xt/idx indirection in _train_probas_for_estimator are removed. Outputs verified identical to the pre-cleanup branch, which is itself equivalent to main within 3e-08 (the float32 rocket tolerance). Tests: the OOB-indices docstring now matches what is asserted, fit_predict coverage checks valid probability rows rather than only a shape, and a new test asserts n_jobs=1 and n_jobs=2 produce identical weights, predictions and train estimates. Co-Authored-By: Claude Fable 5 <[email protected]>
RidgeClassifierCV with the default scoring=None sets best_score_ to the negative LOO mean squared error, so ensemble members were weighted by |LOO error|: all weights were negative and worse members received larger absolute weight, inverting the CV-accuracy weighting described in the docstring and the HC2 paper. Passing scoring=accuracy at both sites makes best_score_ the LOO CV accuracy; this also switches alpha selection to accuracy, a deliberate choice. Measured impact at default parameters is negligible (identical test and OOB accuracy to 4 d.p. on unit_test, italy_power and basic_motions over 3 seeds) because member LOO errors cluster within ~4%, but small heterogeneous ensembles such as the results_comparison test config shift visibly. Expected classifier results for Arsenal regenerated with the writer recipe; the old stored values contained the -0.0 probabilities characteristic of the negative weights. A new test guards that weights lie in [0, 1]. Co-Authored-By: Claude Fable 5 <[email protected]>
- class_weight docstring no longer offers balanced_subsample, which RidgeClassifierCV does not accept and which raised at fit; it now documents dict/balanced/None, and a test checks the parameter reaches every member ridge. - _fit_ensemble_estimator returns the member weight, removing the steps[2][1] re-digging; the scaler no longer receives a meaningless y and uses one fit_transform pass. - Contract fitting clamps each batch to the remaining budget so contract_max_n_estimators is never exceeded when n_jobs > 1; the contract test asserts the cap. - An all-in-bag bootstrap now reports weight 0.0 (no evidence) instead of a fake perfect accuracy; the value was and is unused in aggregation because the OOB index set is empty. - predict_proba no longer rounds to 8 decimals; the rounding lost information and could manufacture exact ties for the random tie-breaker. Stored expected results are unaffected at 4 decimals (verified by regeneration). Co-Authored-By: Claude Fable 5 <[email protected]>
test_arsenal was five near-identical fit-and-check blocks; it becomes one test parametrised over the three rocket transformers and one/four channels, with named configuration reused in the assertions, plus a separate invalid-input test. Docstrings now state exactly what is asserted (the class-vote and OOB helper tests no longer claim memory or vectorisation properties they do not test), implementation-detail names are dropped, seeds are fixed throughout, and spellings follow British English. Co-Authored-By: Claude Fable 5 <[email protected]>
Set in _fit and never read anywhere; the base class already enforces equal-length transform input, so the attribute implied a length check that does not exist. The fit-time length is now a local used only to cap kernel dilation. Co-Authored-By: Claude Fable 5 <[email protected]>
_transform_kernels(X) now means the same thing on Rocket, MiniRocket and MultiRocket: apply the fitted kernels, the caller owns any normalisation. MiniRocket applies no normalisation so it delegates to _transform; MultiRocket extracts the kernels-only part of _transform exactly as Rocket does. Arsenal drops both of its per-transformer branches and calls the contract uniformly in fit and predict. Bit-identical outputs verified for the minirocket and multirocket Arsenal paths (predict_proba and fit_predict_proba); a new parametrised test asserts _transform_kernels equals transform for all three transformers when no normalisation is configured. Co-Authored-By: Claude Fable 5 <[email protected]>
The batch method was never exercised (the transformer calls forward directly): a new test asserts both of its branches return exactly the single-pass result. A single-group test covers the divisor == 1 path in forward, which every existing test skipped by using eight or more groups, and pins the feature-count invariant 2 * n_kernels * n_groups * num_dilations. Co-Authored-By: Claude Fable 5 <[email protected]>
Arsenal fits and predicts its members in joblib threads, and each
member transform entered a numba parallel=True region. Numba's default
workqueue threading layer terminates the process on concurrent entry
("Concurrent access has been detected"), which surfaced on CI as
crashed pytest-xdist workers in the Arsenal n_jobs tests; reproduced
deterministically with NUMBA_THREADING_LAYER=workqueue.
Each rocket transform kernel is now compiled twice from one body:
the existing parallel dispatcher for standalone n_jobs > 1 use, and a
serial nogil dispatcher (prange degrades to range) used when
n_jobs == 1. Serial kernels never enter the threading layer, so
ensemble members transform genuinely concurrently. The n_jobs > 1
parallel path additionally holds a shared lock around the global
thread-count swap and launch.
The workqueue repro survives 20 Arsenal n_jobs=2 fits where the old
code aborted immediately. Serial timing equals the previous
parallel-at-one-thread path within noise. MiniRocket and MultiRocket
ensemble outputs are bit-identical; Rocket outputs shift by at most
2e-06 because fastmath compiles the serial and parallel pipelines
with different reassociation, an order below the accepted float32
tolerance. All 82 rocket-family and Arsenal tests pass.
Co-Authored-By: Claude Fable 5 <[email protected]>
The dual serial/parallel kernel compilations introduced for thread safety could produce different results because fastmath lets the two compilations reassociate float32 arithmetic differently; the estimator multithreading check caught MultiRocket giving different transform results at n_jobs=1 versus n_jobs>1. Per transformer, chosen by measurement: - Rocket: the outer dispatchers are compiled without fastmath (measured free); the per-kernel helper functions keep fastmath because they are separately compiled shared units used by both dispatchers, so results are identical by construction. Full performance retained (interleaved A/B equal to the previous fastmath build). - MultiRocket: both compilations drop fastmath (~5 percent, within noise, of transform time) since its arithmetic is inline in the compiled bodies. - MiniRocket: fastmath is worth 1.4x on its transform, so it reverts to a single parallel compilation used for every n_jobs value under the shared numba lock; identity across thread counts holds because the same machine code runs, and the lock keeps ensemble threading safe. Verified: transform outputs identical for n_jobs=1 vs 2 across all three transformers over multiple shapes and seeds, the estimator multithreading checks pass, all 82 rocket-family and Arsenal tests pass, and the workqueue crash repro still survives. Co-Authored-By: Claude Fable 5 <[email protected]>
There was a problem hiding this comment.
Are all of the rocket/minirocket/multirocket changes related to threading? bit confused on what is meant by normalisation why the structure has changed rather than just calling _transform if we want to skip validation.
If we normalise the series in every one and I just missed that then ok.
Have not gone through the full Arsenal code yet but do not see anything massively off. Do you have some benchmark results similar to TDE and RotF? Especially nice to see since expected results changed here.
|
went a little too far here, was meant to stick to arsenal |
|
move the fallback predict proba up, not sure why arsenal had an over ride method there, removed changes to non arsenal classifiers and removed the normalisation for multi-rocket, which has an argument rather than always normalise |
One conflict, in aeon/utils/_parallel.py: main added the same `_run_jobs` helper this branch extracted, byte-for-byte identical. Kept this branch's version, which additionally defines `_NUMBA_PARALLEL_LOCK` — the rocket transforms import it to serialise numba parallel launches across joblib threads, so it has to stay. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01GbGfqBvct8ebLoev3adD95
Addresses Matthew's review point that the one-hot predict_proba rewrite was not different from the default and did not belong in this PR. The branch had vectorised BaseClassifier._predict_proba and then added byte-identical copies of that body to HydraClassifier and MultiRocketHydraClassifier, plus the same block inline in the Rocket, MiniRocket and MultiRocket classifiers: the same four lines in six places. None of it is used by Arsenal, which overrides _predict_proba itself, so all of it is reverted to main. Arsenal keeps its own internal use of np.searchsorted for mapping member predictions to class indices when aggregating votes, which is unrelated. Co-Authored-By: Claude Opus 5 <[email protected]>
the normalisation fix is to avoid renormalising every ensemble member. We could just normalise once then set Rocket(normalise=False), and its simpler, but each estimator is a pipeline in estimators_. If we set it to false but there is a risk then of calling individual estimators without normalisation. Granted, its a edge case, and there is no reason for it to touch the others, was overreaching |
Timing the branch against main showed the transform changes were a net loss for two of the three rocket variants. Paired runs, alternating order, fresh process and numba cache per measurement, n_kernels=2000, n_estimators=10: rocket n_jobs=1 +10.0% fit +11.2% predict +10.1% total rocket n_jobs=4 + 9.5% fit +12.1% predict +10.3% total minirocket n_jobs=4 -11.1% fit -119% predict -19.7% total multirocket n_jobs=1 -39.5% fit -44.6% predict -41.5% total MultiRocket's loss is the fastmath removal, which 13f878d described as "~5 percent, within noise"; it is not. MiniRocket's loss was not the numba lock: restoring the dual serial/parallel compilation it used to have did not recover it, though that experiment did show the two compilations are bit-identical over 216 n_jobs pairings, so the invariance concern that removed them was unfounded. Both files now match main exactly. Rocket keeps its changes, being the only variant with a measured win and the one Arsenal uses by default. Arsenal now picks the transform entry point via _transform_with: Rocket is the only rocket transform that normalises by default, so only it needs the kernels-only path once the ensemble has normalised. MiniRocket never normalises and MultiRocket defaults to normalise=False, so _transform is already kernels-only for them. Co-Authored-By: Claude Opus 5 <[email protected]>
…to ajb/arsenal
| # the output features are float32, so float64 input only adds | ||
| # per-element promotion in the hot loop. asarray avoids a copy if X | ||
| # is already float32. | ||
| X = np.asarray(X, dtype=np.float32) |
There was a problem hiding this comment.
minor (optional) but could also do this in Arsenal to avoid doing it for each ensemble member
There was a problem hiding this comment.
good point, done: Arsenal casts to float32 once after normalising, so each member's np.asarray no longer copies. Only for Rocket, and outputs are unchanged.
| alphas=np.logspace(-3, 3, 10), class_weight=self.class_weight | ||
| alphas=np.logspace(-3, 3, 10), | ||
| class_weight=self.class_weight, | ||
| scoring="accuracy", |
There was a problem hiding this comment.
For the scoring change, I think this introduces a bug that causes binary problems to go unweighted. This was picked up by AI so not 100%, but it is an sklean issue with the default cv method.
I am inclined to believe as the expected results for our dummy problem do not look weighed by the multiclass one does. If verified I think this version is better than the old one, but should probably create an issue.
There was a problem hiding this comment.
scikit-learn/scikit-learn#34942
got a fix I'm testing, if it works could go in here
There was a problem hiding this comment.
mitigated as follows:
For binary problems, this change works out the leave-one-out accuracy from the predictions RidgeClassifierCV already stores, taking the predicted class from their sign. It then refits at the best alpha, which costs one extra ridge fit per member. Multiclass is untouched and bit-identical.
I ran the lot and it makes Arsenal better but not HC2.
The expected results for univariate Arsenal and HIVECOTEV2 have been regenerated
| _apply_kernels = njit(parallel=True, cache=True)(_apply_kernels_impl) | ||
| _apply_kernels_serial = njit(nogil=True, cache=True)(_apply_kernels_impl) |
There was a problem hiding this comment.
Tested this and the cache name is _apply_kernels_impl, not the names here, which means whatever you run first will be cached first and then loaded in the future regardless of changing n_threads input.
There was a problem hiding this comment.
I've dropped the split: there's a single parallel=True kernel again, as on main, and all calls go through the shared lock. That still stops ensemble threads entering numba's threading layer at the same time.


This PR improves Arsenal fit and prediction performance, best to hope for is 20% speed up, but every little helps.
part of #3616
closes #3371
For context, Arsenal is now slower than TDE, about twice as fast as STC and still 10x faster than DrCIF, the new bottleneck.
Changes
Validation