Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[ENH] Speed up Arsenal classifier - #3655

Open
TonyBagnall wants to merge 30 commits into
mainfrom
ajb/arsenal
Open

[ENH] Speed up Arsenal classifier#3655
TonyBagnall wants to merge 30 commits into
mainfrom
ajb/arsenal

Conversation

@TonyBagnall

@TonyBagnall TonyBagnall commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

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

  • Normalize ROCKET input once per Arsenal operation.
  • Add efficient OOB-index selection using boolean masks.
  • Add generalized parallel job handling, including a direct path for n_jobs=1. Note this copies the function from [ENH] Speed up rotation forest #3631
  • Reduce vote-aggregation overhead by working with class indices.
  • Fuse training/OOB estimate generation into the fit pass.
  • Exception-safe ROCKET thread restoration.
  • Vectorised one-hot probability fallbacks for the ROCKET-family and Hydra classifiers.
  • fixed a minor bug: arsenal was weighting by error instead of by accuracy on one unused path, but it made little difference

Validation

  • Arsenal and parallel utility tests: 9 passed
  • Ruff checks and formatting passed
  • git diff --check passed

@aeon-actions-bot aeon-actions-bot Bot added classification Classification package enhancement New feature, improvement request or other non-bug code enhancement transformations Transformations package labels Jul 16, 2026
@aeon-actions-bot

Copy link
Copy Markdown
Contributor

Thank you for contributing to aeon

I have added the following labels to this PR based on the title: [ enhancement ].
I have added the following labels to this PR based on the changes made: [ classification, transformations ]. Feel free to change these if they do not properly represent the PR.

The Checks tab will show the status of our automated tests. You can click on individual test runs in the tab or "Details" in the panel below to see more information if there is a failure.

If our pre-commit code quality check fails, please run pre-commit locally and push the fixes to your PR branch.

Don't hesitate to ask questions on the aeon Discord channel if you have any.

PR CI actions

These checkboxes will add labels to enable or disable CI functionality for this PR. This may not take effect immediately, and a new commit may be required to run the new configuration.

  • Run pre-commit checks for all files
  • Run mypy typecheck tests
  • Run all pytest tests and configurations
  • Run all notebook example tests
  • Run numba-disabled codecov tests
  • Disable numba cache loading
  • Regenerate expected results for testing
  • Push an empty commit to re-run CI checks

TonyBagnall and others added 14 commits July 16, 2026 17:54
_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]>
@TonyBagnall TonyBagnall changed the title [ENH] Arsenal are champions [ENH] Speed up Arsenal classifier Jul 22, 2026

@MatthewMiddlehurst MatthewMiddlehurst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Comment thread aeon/classification/convolution_based/_hydra.py Outdated
Comment thread aeon/classification/convolution_based/tests/test_rocket_classifier.py Outdated
@TonyBagnall

Copy link
Copy Markdown
Contributor Author

went a little too far here, was meant to stick to arsenal

@TonyBagnall

Copy link
Copy Markdown
Contributor Author

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

TonyBagnall and others added 2 commits August 31, 2026 18:47
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]>
@TonyBagnall

Copy link
Copy Markdown
Contributor Author

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.

the normalisation fix is to avoid renormalising every ensemble member.
so they all handle normalisation differently: Rocket normalises by default, (normalise=True). MultiRocket defaults to False and MiniRocket has no normalise option.

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.
Arsenal afc = Arsenal.fit(rawX) <- will normalise
afc.estimators_.predict(rawX) <- wont normalise

Granted, its a edge case, and there is no reason for it to touch the others, was overreaching

TonyBagnall and others added 3 commits September 1, 2026 17:45
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]>
@TonyBagnall

Copy link
Copy Markdown
Contributor Author
image speed up 10%

@TonyBagnall

Copy link
Copy Markdown
Contributor Author

actually makes arsenal a tiny bit better to published results
image

# 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

minor (optional) but could also do this in Arsenal to avoid doing it for each ensemble member

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

scikit-learn/scikit-learn#34942

got a fix I'm testing, if it works could go in here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Comment on lines +322 to +323
_apply_kernels = njit(parallel=True, cache=True)(_apply_kernels_impl)
_apply_kernels_serial = njit(nogil=True, cache=True)(_apply_kernels_impl)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

classification Classification package enhancement New feature, improvement request or other non-bug code enhancement transformations Transformations package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[ENH] Dont use Parallel when n_jobs is 1

2 participants