Lagged first-level GLM analysis of fUSI data with a continuous regressor¶
This example reproduces the lagged-GLM analysis reported by Cybis Pereira et al. 2026, which uses functional ultrasound imaging of a freely moving rat exploring an open field with its body position tracked from video. Unlike the block-design task in the First-level GLM analysis of fUSI data example, the regressor of interest is a continuous variable measured throughout the recording, the animal's locomotion speed, and we ask at every voxel how much of the power Doppler time course tracks it at different lags.
Rather than convolving the speed regressor with a hemodynamic response function, the analysis shifts it across a range of temporal lags and fits one model per lag, much like a voxel-wise cross-correlation between speed and the vascular signal. The notebook goes through:
- Fetch and load one open-field recording and its motion track.
- Build an animal speed regressor from the animal's pose estimation.
- Encode speed as a per-volume parametric modulator and extract CompCor noise regressors.
- Fit a
FirstLevelModelat a range of temporal lags between speed and signal. - Threshold the resulting maps and visualize the response at each lag.
Fetch the recording¶
fetch_cybis_pereira_2026 downloads
the fUSI-BIDS dataset. We take subject rat75, session 20220524, acquisition
slice32 of the openfield task, which gives one power Doppler recording together
with its motion track.
from pathlib import Path
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import xarray as xr
import confusius as cf
# Adapt background color to the current Matplotlib style.
bg_color = mpl.colors.to_hex(mpl.rcParams["figure.facecolor"])
is_dark_theme = sum(mpl.colors.to_rgb(bg_color)) / 3 < 0.5
# Keep notebook output compact for large DataArray displays. The coordinates section is
# left expanded on purpose; `display_expand_data` alone does not cover the attributes.
xr.set_options(display_expand_data=False, display_expand_attrs=False)
subject = "rat75"
session = "20220524"
acq = "slice32"
bids_root = cf.datasets.fetch_cybis_pereira_2026(
datasets="rawdata",
subjects=subject,
sessions=session,
acqs=acq,
)
pwd_path = (
Path(bids_root)
/ f"sub-{subject}"
/ f"ses-{session}"
/ "fusi"
/ f"sub-{subject}_ses-{session}_task-openfield_acq-{acq}_pwd.nii.gz"
)
motion_path = (
Path(bids_root)
/ f"sub-{subject}"
/ f"ses-{session}"
/ "motion"
/ f"sub-{subject}_ses-{session}_task-openfield_tracksys-DLC_acq-{acq}_motion.tsv"
)
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
Load the recording¶
cf.load reads the power Doppler NIfTI into an xarray.DataArray. We call
.compute() to pull it fully into memory, since it is reused across many model fits
below.
- 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 animal speed regressor from the pose estimation¶
The motion .tsv holds the body position (body_x, body_y) tracked with
DeepLabCut at 50 fps. We take the frame-to-frame
Euclidean displacement, scale it by the frame rate to obtain speed, smooth it with a 1
s centered rolling mean, and resample it onto the fUSI volume times.
fps = 50
motion_df = pd.read_csv(motion_path, sep="\t")
squared_diff = motion_df.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")
fig, ax = plt.subplots(figsize=(7, 3), facecolor=bg_color)
ax.plot(speed.time, speed, color="#d93a54")
ax.set_xlabel("Time (s)")
_ = ax.set_ylabel("Animal speed (cm/s)")
Encode speed as a parametric modulator¶
We build an events table with one entry per fUSI volume, each carrying the speed at
that volume as its modulation, which turns speed into a single continuous regressor.
events = pd.DataFrame(
{
"onset": data.time,
"duration": [data.time.volume_acquisition_duration] * len(data),
"modulation": speed,
"trial_type": ["speed"] * len(data),
}
)
Model physiological noise with CompCor¶
compute_compcor_confounds extracts the
three leading principal components of the highest-variance voxels (the top 5%) to add
as nuisance regressors (temporal CompCor).
Build the model and design matrix¶
We instantiate the FirstLevelModel with
a light 0.3 mm Gaussian spatial smoothing (the default AR(1) noise model handles
temporal autocorrelation) and build the design matrix with
make_first_level_design_matrix: the
speed regressor, the CompCor confounds, a "cosine" drift basis that high-pass
filters drifts below 0.01 Hz, and a constant.
glm = cf.glm.FirstLevelModel(smoothing_fwhm=0.3)
design_matrix = cf.glm.make_first_level_design_matrix(
data.time.values,
events=events,
drift_model="cosine",
low_cutoff=0.01,
confounds=confounds,
)
Fit the GLM across temporal lags¶
The vascular response follows a change in speed with a delay. Instead of fixing that
delay with an HRF, we sweep it: for each temporal lag we shift only the speed
regressor, leaving the confounds, drift, and data window fixed. We fit the model, and
compute the "speed" contrast. This yields one z-map per lag, like a
cross-correlation between speed and the signal of each voxel. Holding the window fixed
across lags keeps the maps comparable, and a positive lag means the signal responds
after the change in speed. run_lagged_glm returns the per-lag maps, which we stack
along a lag dimension whose coordinate is the delay in seconds (one volume per lag).
The original study sweeps a wider window (about -2 to 10 s); here we use a short
positive range to keep the example quick.
def run_lagged_glm(
data: xr.DataArray,
glm: cf.glm.FirstLevelModel,
design_matrix: pd.DataFrame,
lags: range,
) -> list[xr.DataArray]:
"""Fit the GLM at a range of lags, shifting only the speed regressor."""
max_lag = max(lags)
n = len(design_matrix)
# Fixed data window and time-locked nuisance regressors, shared by every lag.
data_window = data[max_lag:]
fixed_design = design_matrix.iloc[max_lag:]
z_scores = []
for lag in lags:
design = fixed_design.copy()
# Shift only the regressor of interest: speed at volume t - lag predicts the
# signal at volume t.
design["speed"] = design_matrix["speed"].iloc[max_lag - lag : n - lag].values
glm.fit([data_window], design_matrices=[design])
z_scores.append(glm.compute_contrast("speed"))
return z_scores
lags = range(9)
z_scores = run_lagged_glm(data, glm, design_matrix, lags=lags)
z_score = xr.concat(z_scores, dim="lag").assign_coords(
lag=data.fusi.spacing["time"] * np.asarray(lags)
)
z_score.lag.attrs["units"] = data.time.units
Threshold and display the maps¶
apply_statistical_threshold applies a
Bonferroni correction at alpha=0.001 followed by a 30-voxel cluster-extent
threshold, zeroing the voxels that do not survive. We plot the thresholded z-map at
each lag over the mean power Doppler image (in dB).
thresholded_zscore, threshold = cf.stats.apply_statistical_threshold(
z_score,
alpha=0.001,
method="bonferroni",
cluster_threshold=30,
)
cmap = "berlin" if is_dark_theme else None
_ = thresholded_zscore.fusi.plot.stat_map(
bg_volume=data.mean("time").fusi.scale.db().expand_dims(lag=z_score.lag),
slice_mode="lag",
nrows=3,
cmap=cmap,
threshold=threshold,
bg_color=bg_color,
fontsize=20,
)
Total running time: 28.5 s



