Skip to content

feat(decoding): add SearchLight decoder#334

Open
FelipeCybis wants to merge 28 commits into
mainfrom
worktree-feat-searchlight-decoding
Open

feat(decoding): add SearchLight decoder#334
FelipeCybis wants to merge 28 commits into
mainfrom
worktree-feat-searchlight-decoding

Conversation

@FelipeCybis

@FelipeCybis FelipeCybis commented Jul 20, 2026

Copy link
Copy Markdown
Member

Summary

  • Added a new confusius.decoding module with SearchLight, 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.
  • API reference page, a user-guide stub, and a worked example that decodes locomotion speed from a single fUSI plane and compares the searchlight map against a GLM fit on identically preprocessed data.
  • Test suite (brute-force reference, sparse-mask ordering, planted-signal recovery, radius-in-coordinate-units, serial/parallel parity, and input-validation guards) plus changelog and nav entries.

How SearchLight works

For every voxel selected by process_mask, fit gathers all mask voxels lying within radius, clones the estimator onto that neighborhood, cross-validates it against the target over the time (sample) axis, and stores the mean score. radius is 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 — a StratifiedKFold is 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 of process_mask, NaN outside it.

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
FelipeCybis marked this pull request as ready for review July 20, 2026 01:20
@FelipeCybis
FelipeCybis marked this pull request as draft July 20, 2026 01:24
@FelipeCybis FelipeCybis changed the title feat(decoding): add SearchLight searchlight decoder feat(decoding): add SearchLight decoder Jul 20, 2026
- 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

codecov Bot commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@github-actions

Copy link
Copy Markdown
Contributor

📖 Doc preview: https://confusius.tools/pr-preview/pr-334/

@FelipeCybis FelipeCybis added the enhancement New feature or request label Jul 20, 2026
@FelipeCybis FelipeCybis self-assigned this Jul 20, 2026
@FelipeCybis

Copy link
Copy Markdown
Member Author

@sdiebolt this should be good to be reviewed!

@FelipeCybis
FelipeCybis marked this pull request as ready for review July 20, 2026 13:46
@FelipeCybis
FelipeCybis requested a review from sdiebolt July 20, 2026 13:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant