feat(decoding): add SearchLight decoder#334
Open
FelipeCybis wants to merge 28 commits into
Open
Conversation
Adds decoding to the exported names, the lazy submodule set, and the TYPE_CHECKING imports so cf.decoding resolves like other subpackages.
The all-True mask made boolean indexing a no-op on both sides, so a feature-ordering mismatch between extract_with_mask and _masked_coordinates could not be observed.
The h5py guard claimed joblib workers cannot pickle h5py datasets, which is false here: fit materialises features before anything reaches joblib. The real reason is that fitting pulls the whole masked time series into RAM. The degenerate-neighbourhood warning also fires on legitimately sparse masks, so the remedy now names both causes. Hoist the duplicated median and document the warning in `fit`.
Add a keyword-only `show_progress` parameter to `SearchLight`, defaulting to `False`. The searchlight previously wrote a joblib progress bar to stdout on every fit with no way to silence it, which is noise when the estimator is nested inside a scikit-learn `Pipeline` or `GridSearchCV`, and which corrupted the class docstring example's output.
The narrative described a different analysis than the code ran. Name the log1p target in the figure, intro, GLM section and closing text; soften the claim of reproducing Cybis Pereira et al. 2026 to following its setting and dataset, with the transform marked as an expository choice. Add a section stating the three ways the GLM and the searchlight are given different information (denoising, HRF convolution, lag selection), describe RidgeCV correctly, and pass a non-shuffled inner KFold so alpha selection respects the same temporal-contiguity principle the example argues for. Report the top-5-percent Dice overlap inline, name the right-lateral region where the two maps disagree, credit the per-voxel lag maximum for part of the GLM z-score inflation, and drop the unsupported "dorsal structures" phrasing in favour of coordinates.
The explicit non-shuffled KFold gave up RidgeCV's closed-form generalised cross-validation path, costing 6x gallery build time (64s -> 373s) for a 0.005 change in Dice. Restore the default and note in the prose why the leave-one-out penalty search cannot inflate the reported scores.
…ocess_mask `joblib_progress` patches `joblib.parallel.BatchCompletionCallBack` process-wide, so the nested `Parallel` inside every `cross_val_score` call advanced the bar too, and `n_batches = effective_n_jobs(n_jobs)` gave the bar only `n_jobs` steps. The only user-visible output was wrong, so `show_progress` is removed rather than patched. A correct bar needs a design that does not collide with nested `Parallel`. Also drops `__sklearn_is_fitted__`, which was dead: the default `check_is_fitted` already finds `scores_` by scanning for trailing-underscore attributes. And an empty `process_mask` now warns instead of silently returning an all-NaN map.
`test_radius_is_in_coordinate_units` only asserted that the 0.25 and 1.05 radius maps differ, which also holds when coordinates are index-like, so it could not fail for the reason it existed. It now pins neighbourhood size through the public API: a promoted single-voxel warning proves the radius reaches past the centre, and a signal planted in one z plane must leave the other plane bit-identical at 0.25 but not at 1.05. Verified by sabotage. `test_classifier_selects_stratified_folds` never touched the splitter, so it is renamed to `test_classifier_selects_accuracy_scorer` to match what it checks. Adds coverage for a splitter object passed as `cv`, for `groups` with `LeaveOneGroupOut`, and for the empty `process_mask` warning.
A 0.3 mm radius at 0.1 mm spacing reaches 3 voxels in each direction, so the neighbourhood is roughly seven voxels across, not three. Also removes the changelog link to pull request 325, which does not exist.
The searchlight decoded raw instantaneous speed from unfiltered data while the GLM it was compared against received cleaned data and an HRF-convolved regressor. That asymmetry produced a nearly blank map, previously papered over with a log1p transform on the target. Both analyses now share the same cleaned data and the same HRF-convolved speed regressor, so the comparison isolates univariate versus multivariate: - Decode the `speed` column of a design matrix built with the modified Claron 2021 HRF, rather than the raw instantaneous trace. - Clean the data with a cosine high-pass at 0.01 Hz and three CompCor components, matching the GLM design. - Clean the regressor the same way. This is the step that matters most: with the nuisance basis removed from the data but not the target, the decoder is asked to predict variance deleted from its inputs and the peak neighbourhood scores 0.03 instead of 0.25. - Replace the nine-lag GLM sweep and its per-voxel maximum with a single fit on the same design matrix the target came from. - Restore the mask construction the example needed but had lost. - Add a shifted-target null control, since cleaning before cross-validating is the one step that could leak across fold boundaries. It comes out flat. Peak cross-validated R^2 is 0.296 (95th percentile 0.213), the null run reaches 0.006, and the top-5% Dice overlap with the GLM map is 0.680.
The searchlight was still handed unsmoothed data while the GLM smoothed internally at 0.3 mm, so the pipelines remained mismatched in a way that favoured the GLM. Both now share the same smoothing kernel, applied with the same `smooth_volume` call the GLM uses. Dropping CompCor is the larger change. Its components are built from the highest-variance voxels, and locomotion drives widespread haemodynamic change, so the components described the task rather than the noise and removing them removed the signal being decoded. Both analyses now go without, keeping them matched. Peak cross-validated R^2 rises from 0.296 to 0.408 (95th percentile 0.324). Null controls at four circular shifts stay between -0.013 and +0.047. Also record the fold-count sensitivity in the prose, since the peak falls from 0.416 at three folds to 0.315 at eight: bursty locomotion leaves short test blocks with little target variance to predict. Top-5% Dice against the GLM falls from 0.680 to 0.359. Dropping CompCor changes both maps, and a 1.0 mm radius blurs the searchlight relative to the voxelwise GLM. The closing discussion now states the partial overlap plainly rather than claiming the maps agree.
When `mask` is not provided, every voxel of the input data is used as a feature voxel. The default mask is built from the input's spatial geometry, so the masked and unmasked paths share the same downstream code, mirroring `_BaseFUSIDecomposer._prepare_data`. `estimator` is now the first keyword-only parameter, being the only required one.
Display a rich progress bar while scoring searchlight centres, matching the look of register_volumewise. The bar is driven directly rather than through joblib_progress, which patches Parallel.print_progress process-wide: SearchLight calls cross_val_score inside each batch, and that builds its own Parallel, so every inner fold would advance the outer bar. Driving it here also lets the total be the true centre count instead of the worker count. Results stream back with return_as="generator", which preserves batch order, so concatenating them still maps scores onto the right centres. Centres are now split into several batches per worker so the bar moves during long runs. Also drops a stray verbose=10 left on the inner cross_val_score.
Apply code-review findings on the new (unreleased) SearchLight decoder: - Raise on non-finite features and on a negative radius, instead of silently producing NaN score maps or fitting on zero features. - Correct the cv docstrings: the "contiguous folds" guarantee holds only for the KFold (regressor) path; a classifier gets StratifiedKFold, which interleaves time and so does not by itself prevent autocorrelation leakage. Steer classification toward grouped/blocked CV. - Strengthen three shape-only tests to pin actual scores, and add tests for the two new guards. - Drop the redundant progress-context bookkeeping in _run_searchlight. - Example: mask non-finite voxels before fitting and diff only the tracked columns so a non-numeric column cannot crash it. - Rename the private helpers to verb phrases and switch docstrings to American English.
FelipeCybis
marked this pull request as ready for review
July 20, 2026 01:20
FelipeCybis
marked this pull request as draft
July 20, 2026 01:24
- SearchLight.scores_ now keeps the input's attributes, notably any `affines`, so the score map stays in the same physical space. - `units` is set from the scorer: the scoring string when given, or `accuracy` / `R²` for the classifier / regressor default; a callable scorer leaves `units` unset instead of inheriting the input's.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Contributor
|
📖 Doc preview: https://confusius.tools/pr-preview/pr-334/ |
Member
Author
|
@sdiebolt this should be good to be reviewed! |
FelipeCybis
marked this pull request as ready for review
July 20, 2026 13:46
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
confusius.decodingmodule withSearchLight, an xarray-native searchlight decoder that maps where a cross-validated scikit-learn estimator can predict a target from each voxel's local neighborhood, keeping fUSI metadata and geometry throughout.How SearchLight works
For every voxel selected by
process_mask,fitgathers allmaskvoxels lying withinradius, clones the estimator onto that neighborhood, cross-validates it against the target over thetime(sample) axis, and stores the mean score.radiusis measured in physical coordinate units rather than voxel indices, so anisotropic fUSI voxels give isotropic neighborhoods. The neighborhoods are found once with a KDTree; scoring is parallelized over batches of centers with joblib. Folds are contiguous by default (KFold(shuffle=False)for regressors) so that strongly autocorrelated neighboring volumes are not split across the train/test boundary — aStratifiedKFoldis used for classifiers, and grouped/blocked CV is recommended when class blocks are themselves autocorrelated. The output,scores_, is a DataArray in the spatial geometry ofprocess_mask, NaN outside it.