Searchlight decoding of a continuous variable¶
This example maps which parts of a fUSI recording carry information about how fast a rat is moving with a searchlight: a small cross-validated model run over the local neighborhood of every voxel. Unlike a GLM, which asks voxel by voxel whether one voxel's signal tracks the regressor, the searchlight asks whether the local pattern around each voxel can predict it, picking up information carried jointly by neighboring voxels rather than by any one alone.
We follow the experimental setting and dataset of Cybis Pereira et al. 2026, decoding locomotion speed from a single sagittal plane, and compare the searchlight map against a GLM fit on the same data. Both analyses receive the same preprocessing steps, so the only thing that differs between them is univariate versus multivariate, which is the comparison we actually want to make.
Load the recording and the tracking data¶
from functools import partial
from pathlib import Path
import colorcet as cc
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import xarray as xr
from sklearn.linear_model import RidgeCV
import confusius as cf
# Adapt background color to the current Matplotlib style.
bg_color = mpl.colors.to_hex(mpl.rcParams["figure.facecolor"])
xr.set_options(display_expand_data=False)
subject = "rat75"
session = "20220524"
acq = "slice32"
bids_root = cf.datasets.fetch_cybis_pereira_2026(
datasets="rawdata",
subjects=subject,
sessions=session,
acqs=acq,
)
session_dir = Path(bids_root) / f"sub-{subject}" / f"ses-{session}"
stem = f"sub-{subject}_ses-{session}_task-openfield"
pwd_path = session_dir / "fusi" / f"{stem}_acq-{acq}_pwd.nii.gz"
motion_path = session_dir / "motion" / f"{stem}_tracksys-DLC_acq-{acq}_motion.tsv"
data = cf.load(pwd_path).compute()
data
If you use this dataset in your work, please cite the following source: Cybis Pereira, F., Castedo, S. H., Meur-Diebolt, S. L., Ialy-Radio, N., Bhattacharya, S., Ferrier, J., Osmanski, B. F., Cocco, S., Monasson, R., Pezet, S., & Tanter, M. (2026). A vascular code for speed in the spatial navigation system. Cell Reports, 45(1). https://doi.org/10.1016/j.celrep.2025.116791
- time: 1440
- z: 1
- y: 112
- x: 128
- 192.7 197.2 226.9 229.9 278.7 278.7 ... 61.31 60.61 64.25 65.22 55.23
array([[[[ 192.71379271, 197.1830354 , 226.89889524, ..., 135.64715222, 117.71528254, 111.25769231], [ 269.26946815, 218.92078634, 218.29339147, ..., 153.14631565, 135.36809639, 122.19806324], [ 469.46957151, 696.08320236, 741.61495988, ..., 116.9231638 , 116.41497862, 112.41130004], ..., [ 72.70813918, 67.33206102, 67.73276301, ..., 100.03939077, 95.61359 , 101.95938025], [ 76.24533181, 68.25491978, 73.33637818, ..., 79.11982739, 89.33515235, 90.30539562], [ 77.10922277, 85.88419607, 80.21954388, ..., 70.46910931, 78.98406757, 83.14898008]]], [[[ 102.03228554, 154.92695187, 208.98264674, ..., 76.35214763, 70.93058492, 92.42799846], [ 128.40719979, 179.70018173, 266.95304282, ..., 79.35330346, 70.45420204, 82.7213379 ], [ 197.83338619, 292.06213106, 453.36528013, ..., ... [ 55.2049498 , 55.06794489, 61.32079278, ..., 68.94497935, 72.57032896, 67.73033591], [ 65.66832994, 62.9659692 , 64.82018124, ..., 66.59579324, 58.19578412, 47.85324591]]], [[[ 57.94911664, 55.762271 , 60.17339521, ..., 133.26805649, 109.66913578, 81.76589334], [ 63.21644599, 66.81321546, 80.94457473, ..., 106.51902626, 90.90147175, 75.77422113], [ 85.8301978 , 92.77473431, 91.98078457, ..., 84.56545788, 87.36666125, 74.77339123], ..., [ 59.61792382, 57.51713315, 49.45440719, ..., 85.92554608, 74.22148882, 88.33563124], [ 55.38937908, 53.42711824, 56.3874491 , ..., 65.18981048, 79.52728571, 72.33484683], [ 53.38009269, 51.48751045, 64.40242875, ..., 64.25058854, 65.21577368, 55.2309584 ]]]], shape=(1440, 1, 112, 128)) - time(time)float640.5 1.0 1.5 ... 719.0 719.5 720.0
- units :
- s
- volume_acquisition_reference :
- start
- volume_acquisition_duration :
- 0.5000249999999369
array([5.000000e-01, 1.000025e+00, 1.500025e+00, ..., 7.190184e+02, 7.195184e+02, 7.200184e+02], shape=(1440,)) - z(z)float640.0
- units :
- mm
- voxdim :
- 0.3999999761581421
array([0.])
- y(y)float645.0 5.099 5.197 ... 15.84 15.94
- units :
- mm
- voxdim :
- 0.09855999797582626
array([ 5. , 5.09856, 5.19712, 5.29568, 5.39424, 5.4928 , 5.59136, 5.68992, 5.78848, 5.88704, 5.9856 , 6.08416, 6.18272, 6.28128, 6.37984, 6.4784 , 6.57696, 6.67552, 6.77408, 6.87264, 6.9712 , 7.06976, 7.16832, 7.26688, 7.36544, 7.464 , 7.56256, 7.66112, 7.75968, 7.85824, 7.9568 , 8.05536, 8.15392, 8.25248, 8.35104, 8.4496 , 8.54816, 8.64672, 8.74528, 8.84384, 8.9424 , 9.04096, 9.13952, 9.23808, 9.33664, 9.4352 , 9.53376, 9.63232, 9.73088, 9.82944, 9.928 , 10.02656, 10.12512, 10.22368, 10.32224, 10.4208 , 10.51936, 10.61792, 10.71648, 10.81504, 10.9136 , 11.01216, 11.11072, 11.20928, 11.30784, 11.4064 , 11.50496, 11.60352, 11.70208, 11.80064, 11.8992 , 11.99776, 12.09632, 12.19488, 12.29344, 12.392 , 12.49056, 12.58912, 12.68768, 12.78624, 12.8848 , 12.98336, 13.08192, 13.18048, 13.27904, 13.3776 , 13.47616, 13.57472, 13.67328, 13.77184, 13.8704 , 13.96896, 14.06752, 14.16608, 14.26464, 14.3632 , 14.46176, 14.56032, 14.65888, 14.75744, 14.856 , 14.95456, 15.05312, 15.15168, 15.25024, 15.3488 , 15.44736, 15.54592, 15.64448, 15.74304, 15.8416 , 15.94016]) - x(x)float64-6.985 -6.875 ... 6.875 6.985
- units :
- mm
- voxdim :
- 0.10999999940395355
array([-6.985, -6.875, -6.765, -6.655, -6.545, -6.435, -6.325, -6.215, -6.105, -5.995, -5.885, -5.775, -5.665, -5.555, -5.445, -5.335, -5.225, -5.115, -5.005, -4.895, -4.785, -4.675, -4.565, -4.455, -4.345, -4.235, -4.125, -4.015, -3.905, -3.795, -3.685, -3.575, -3.465, -3.355, -3.245, -3.135, -3.025, -2.915, -2.805, -2.695, -2.585, -2.475, -2.365, -2.255, -2.145, -2.035, -1.925, -1.815, -1.705, -1.595, -1.485, -1.375, -1.265, -1.155, -1.045, -0.935, -0.825, -0.715, -0.605, -0.495, -0.385, -0.275, -0.165, -0.055, 0.055, 0.165, 0.275, 0.385, 0.495, 0.605, 0.715, 0.825, 0.935, 1.045, 1.155, 1.265, 1.375, 1.485, 1.595, 1.705, 1.815, 1.925, 2.035, 2.145, 2.255, 2.365, 2.475, 2.585, 2.695, 2.805, 2.915, 3.025, 3.135, 3.245, 3.355, 3.465, 3.575, 3.685, 3.795, 3.905, 4.015, 4.125, 4.235, 4.345, 4.455, 4.565, 4.675, 4.785, 4.895, 5.005, 5.115, 5.225, 5.335, 5.445, 5.555, 5.665, 5.775, 5.885, 5.995, 6.105, 6.215, 6.325, 6.435, 6.545, 6.655, 6.765, 6.875, 6.985])
- qform_code :
- 1
- manufacturer :
- Iconeus
- manufacturers_model_name :
- Iconeus One
- probe_type :
- linear
- probe_model :
- IcoPrime light
- probe_central_frequency :
- 15.625
- probe_number_of_elements :
- 128
- probe_pitch :
- 0.11
- probe_radius_of_curvature :
- 0
- ProbeElevationAperture :
- 1.5
- ProbeElevationFocus :
- 8
- pulse_repetition_frequency :
- 3200.0
- plane_wave_angles :
- [-7.0, -5.0, -3.0, -1.0, 1.0, 3.0, 5.0, 7.0]
- IsHadamard :
- True
- UltrafastSamplingFrequency :
- 400.0
- probe_voltage :
- 15
- clutter_filter_window_duration :
- 200.0
- clutter_filters :
- ['svd:remove_first_60_components']
- power_doppler_integration_duration :
- 200.0
- affines :
- {'physical_to_qform': array([[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., 0., 0., 1.]])}
Correct for motion¶
The rat moves freely, so a real analysis should first correct volume-to-volume brain motion. We skip it here to keep the example fast to build, at the cost of a little statistical robustness in the maps below.
Recommended in a real analysis
Register every volume to a reference frame with
register_volumewise before
building the regressors, then continue with the corrected data. Removing
motion-driven variance sharpens the speed maps and their statistics. See the
Motion correction of a single
recording example for the full
workflow and diagnostics.
Build the speed regressor¶
The animal is tracked with DeepLabCut at 50 frames per second. We take the frame-to-frame displacement of the body marker, smooth it with a one second centered rolling mean, and resample it onto the fUSI volume acquisition times.
fps = 50
motion_df = pd.read_csv(motion_path, sep="\t")
squared_diff = motion_df[["body_x", "body_y"]].diff() ** 2
speed_df = fps * (squared_diff["body_x"] + squared_diff["body_y"]) ** 0.5
speed_df[0] = 0
speed = (
xr.DataArray(
speed_df,
dims=["time"],
coords={"time": 1 / fps * np.arange(len(speed_df))},
name="speed",
)
.rolling(time=fps, min_periods=1, center=True)
.mean()
)
speed = speed.interp(time=data.time, method="linear").ffill("time")
What the searchlight should actually predict¶
The speed signal above is an instantaneous behavioral variable, and the power Doppler signal contains a proxy of the neural signal encoding the speed signal through the neurovascular coupling. With the neurovascular coupling inherent delay, asking a decoder to predict the instantaneous trace from that signal sets it an impossible target. We therefore decode an hemodynamically convolved speed regressor.
We build it with the modified Claron 2021 HRF, a rodent fUSI response function, and
read it straight off a first-level design matrix. Building the design matrix here
serves double duty: its speed column is the searchlight target, and the whole matrix
is what we hand the GLM later, so the two analyses are guaranteed to see the same
regressor.
modified_claron2021 = partial(cf.glm.claron2021_hrf, beta=6.7)
events = pd.DataFrame(
{
"onset": data.time.values,
"duration": data.time.volume_acquisition_duration,
"modulation": speed.values,
"trial_type": "speed",
}
)
confounds = cf.signal.compute_compcor_confounds(
data,
variance_threshold=0.05,
n_components=3,
)
design_matrix = cf.glm.make_first_level_design_matrix(
data.time.values,
events=events,
hrf_model=modified_claron2021,
drift_model="cosine",
low_cutoff=0.01,
confounds=confounds,
)
The speed column of the design matrix is the raw speed trace convolved with the HRF.
We read it out as the searchlight target.
Preprocess the data exactly as the GLM does¶
The point of this example is to compare a univariate analysis with a multivariate one, which only works if everything else is held equal. So the decoder gets the same preprocessing the GLM applies.
Spatial smoothing. FirstLevelModel(smoothing_fwhm=0.3) smooths each run with
smooth_volume before fitting. We apply the same
call with the same value, rather than leaving the searchlight to work on unsmoothed
data while the GLM enjoys the noise reduction.
Drift and confound removal. Power Doppler carries slow drift and nuisance signal
that have nothing to do with locomotion. The GLM handles them with cosine drift and
CompCor regressors in its design; a
decoder has no design matrix, so we remove the same regressors from the data up front
with clean, reusing the design matrix's own nuisance
columns.
The target is cleaned with the same regressors as the data, so both sides have the same nuisance structure removed. This mirrors what the GLM does implicitly when it fits the speed regressor and the nuisance regressors jointly.
smoothing_fwhm = 0.3
confounds_np = design_matrix.drop(columns="speed").to_numpy()
confounds = xr.DataArray(
confounds_np, dims=["time", "confound"], coords={"time": data.time}
)
cleaned = cf.signal.clean(
cf.spatial.smooth_volume(data, smoothing_fwhm),
standardize_method="zscore",
confounds=confounds,
)
target = cf.signal.clean(
xr.DataArray(
speed_regressor, dims=["time"], coords={"time": data.time}, name="speed"
),
standardize_method="zscore",
confounds=confounds,
)
Run the searchlight¶
Running searchlight through the entire field of view is costly because this means fitting a few tens of thousands of models. Here we will create a mask to only run the searchlight algorithm in the top half of the field of view. A real region-of-interest mask, such as an intensity-thresholded brain mask, or an anatomical brain region mask, would be of good use here just the same.
Two details matter for fUSI data:
radiusis in the units of the data's spatial coordinates, not in voxel indices. Each neighborhood is the set of voxels withinradiusmillimeters of the center.- Consecutive fUSI volumes are strongly autocorrelated. Cross-validating with shuffled
folds would put near-duplicate volumes in both the training and test sets and
inflate the scores.
SearchLighttherefore builds contiguous temporal folds by default. Each fold also needs to be long enough to contain both quiet and active periods, since the animal moves in bursts, which is why we keep the fold count low.
The estimator is a RidgeCV: ridge regression that
selects its own penalty from a grid. Neighboring fUSI voxels are highly correlated,
and the right amount of regularization varies across the plane. By default RidgeCV
picks the penalty by leave-one-out generalized cross-validation, which does put
temporally adjacent volumes in its train and test sets, unlike the contiguous folds we
use for the outer searchlight cross-validation. That is not a problem: the penalty
search runs entirely inside each outer training fold and never sees the outer test
fold, so it only affects which penalty is chosen.
estimator = RidgeCV(alphas=np.logspace(0, 10, 5))
mask = xr.ones_like(cleaned.isel(time=0), dtype=bool).where(cleaned.y < 10, False)
searchlight = cf.decoding.SearchLight(
estimator=estimator, mask=mask, radius=0.6, cv=3, n_jobs=-1
)
searchlight.fit(cleaned, target.values)
searchlight.scores_
- z: 1
- y: 112
- x: 128
- -0.04186 -0.04583 -0.05186 -0.0598 -0.06268 ... nan nan nan nan nan
array([[[-0.04185839, -0.04582578, -0.05186282, ..., -0.15304316, -0.18103184, -0.16589569], [-0.04157416, -0.04360269, -0.04272866, ..., -0.14518918, -0.49228095, -0.58586543], [-0.03736185, -0.03926947, -0.03986719, ..., -0.25195268, -0.31287728, -0.43389237], ..., [ nan, nan, nan, ..., nan, nan, nan], [ nan, nan, nan, ..., nan, nan, nan], [ nan, nan, nan, ..., nan, nan, nan]]], shape=(1, 112, 128)) - z(z)float640.0
- units :
- mm
- voxdim :
- 0.3999999761581421
array([0.])
- y(y)float645.0 5.099 5.197 ... 15.84 15.94
- units :
- mm
- voxdim :
- 0.09855999797582626
array([ 5. , 5.09856, 5.19712, 5.29568, 5.39424, 5.4928 , 5.59136, 5.68992, 5.78848, 5.88704, 5.9856 , 6.08416, 6.18272, 6.28128, 6.37984, 6.4784 , 6.57696, 6.67552, 6.77408, 6.87264, 6.9712 , 7.06976, 7.16832, 7.26688, 7.36544, 7.464 , 7.56256, 7.66112, 7.75968, 7.85824, 7.9568 , 8.05536, 8.15392, 8.25248, 8.35104, 8.4496 , 8.54816, 8.64672, 8.74528, 8.84384, 8.9424 , 9.04096, 9.13952, 9.23808, 9.33664, 9.4352 , 9.53376, 9.63232, 9.73088, 9.82944, 9.928 , 10.02656, 10.12512, 10.22368, 10.32224, 10.4208 , 10.51936, 10.61792, 10.71648, 10.81504, 10.9136 , 11.01216, 11.11072, 11.20928, 11.30784, 11.4064 , 11.50496, 11.60352, 11.70208, 11.80064, 11.8992 , 11.99776, 12.09632, 12.19488, 12.29344, 12.392 , 12.49056, 12.58912, 12.68768, 12.78624, 12.8848 , 12.98336, 13.08192, 13.18048, 13.27904, 13.3776 , 13.47616, 13.57472, 13.67328, 13.77184, 13.8704 , 13.96896, 14.06752, 14.16608, 14.26464, 14.3632 , 14.46176, 14.56032, 14.65888, 14.75744, 14.856 , 14.95456, 15.05312, 15.15168, 15.25024, 15.3488 , 15.44736, 15.54592, 15.64448, 15.74304, 15.8416 , 15.94016]) - x(x)float64-6.985 -6.875 ... 6.875 6.985
- units :
- mm
- voxdim :
- 0.10999999940395355
array([-6.985, -6.875, -6.765, -6.655, -6.545, -6.435, -6.325, -6.215, -6.105, -5.995, -5.885, -5.775, -5.665, -5.555, -5.445, -5.335, -5.225, -5.115, -5.005, -4.895, -4.785, -4.675, -4.565, -4.455, -4.345, -4.235, -4.125, -4.015, -3.905, -3.795, -3.685, -3.575, -3.465, -3.355, -3.245, -3.135, -3.025, -2.915, -2.805, -2.695, -2.585, -2.475, -2.365, -2.255, -2.145, -2.035, -1.925, -1.815, -1.705, -1.595, -1.485, -1.375, -1.265, -1.155, -1.045, -0.935, -0.825, -0.715, -0.605, -0.495, -0.385, -0.275, -0.165, -0.055, 0.055, 0.165, 0.275, 0.385, 0.495, 0.605, 0.715, 0.825, 0.935, 1.045, 1.155, 1.265, 1.375, 1.485, 1.595, 1.705, 1.815, 1.925, 2.035, 2.145, 2.255, 2.365, 2.475, 2.585, 2.695, 2.805, 2.915, 3.025, 3.135, 3.245, 3.355, 3.465, 3.575, 3.685, 3.795, 3.905, 4.015, 4.125, 4.235, 4.345, 4.455, 4.565, 4.675, 4.785, 4.895, 5.005, 5.115, 5.225, 5.335, 5.445, 5.555, 5.665, 5.775, 5.885, 5.995, 6.105, 6.215, 6.325, 6.435, 6.545, 6.655, 6.765, 6.875, 6.985])
- qform_code :
- 1
- manufacturer :
- Iconeus
- manufacturers_model_name :
- Iconeus One
- probe_type :
- linear
- probe_model :
- IcoPrime light
- probe_central_frequency :
- 15.625
- probe_number_of_elements :
- 128
- probe_pitch :
- 0.11
- probe_radius_of_curvature :
- 0
- ProbeElevationAperture :
- 1.5
- ProbeElevationFocus :
- 8
- pulse_repetition_frequency :
- 3200.0
- plane_wave_angles :
- [-7.0, -5.0, -3.0, -1.0, 1.0, 3.0, 5.0, 7.0]
- IsHadamard :
- True
- UltrafastSamplingFrequency :
- 400.0
- probe_voltage :
- 15
- clutter_filter_window_duration :
- 200.0
- clutter_filters :
- ['svd:remove_first_60_components']
- power_doppler_integration_duration :
- 200.0
- affines :
- {'physical_to_qform': array([[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., 0., 0., 1.]])}
- units :
- R²
- long_name :
- Searchlight CV score
Compare against a GLM¶
We now fit a GLM with the design matrix built earlier, so it tests the same HRF-convolved speed regressor against the same drift model, on data smoothed with the same kernel.
glm = cf.glm.FirstLevelModel(smoothing_fwhm=smoothing_fwhm)
glm.fit(data, design_matrices=design_matrix)
z_scores = glm.compute_contrast("speed")
Compare the two maps¶
The searchlight reports a cross-validated coefficient of determination, so values at or below zero mean the local neighborhood predicts the speed regressor no better than the fold mean; we clip the color scale at zero. The GLM reports a z-score for the speed contrast. Both maps cover the whole plane.
is_dark_theme = sum(mpl.colors.to_rgb(bg_color)) / 3 < 0.5
searchlight_cmap = "inferno" if is_dark_theme else cc.cm.CET_L17
glm_cmap = "berlin" if is_dark_theme else "coolwarm"
background_data = data.mean("time").fusi.scale.db()
fig, axes = plt.subplots(1, 2, figsize=(10, 3), constrained_layout=True)
background_data.fusi.plot.volume(axes=axes[0], show_colorbar=False, bg_color=bg_color)
searchlight.scores_.plot(
ax=axes[0],
cmap=searchlight_cmap,
vmin=0,
cbar_kwargs={"label": "Cross-validated $R^2$"},
)
axes[0].set_title("Searchlight decoding of speed")
background_data.fusi.plot.volume(axes=axes[1], show_colorbar=False, bg_color=bg_color)
z_scores.where(mask).plot(
ax=axes[1], cmap=glm_cmap, center=0, cbar_kwargs={"label": "z-score"}
)
axes[1].set_title("GLM, same regressor")
for ax in axes:
ax.set_aspect("equal")
ax.invert_yaxis()
We quantify the spatial agreement between the two maps by the Dice overlap of their top 5 percent of voxels.
top_scores = searchlight.scores_.squeeze(drop=True)
top_z = z_scores.squeeze(drop=True)
selected_scores = top_scores >= top_scores.quantile(0.95)
selected_z = top_z >= top_z.quantile(0.95)
dice = float(
2
* (selected_scores & selected_z).sum()
/ (selected_scores.sum() + selected_z.sum())
)
print(f"Top-5% overlap, Dice = {dice:.3f}")
The overlap is partial: the two maps answer related but different questions. The GLM is univariate and asks, at each voxel, whether that voxel's signal tracks the speed regressor. The searchlight is multivariate and cross-validated, and asks whether the local pattern around each voxel predicts the regressor in held-out blocks of time.
Total running time: 88.3 s

