Skip to content

Design Matrices

Open in molab

Run this tutorial

This page is rendered from the marimo notebook docs/tutorials/data-operations/05_design_matrix.py. Click the badge to run it in the cloud (free, no install), or locally: download 05_design_matrix.py and run uvx marimo edit --sandbox 05_design_matrix.py. The outputs below were produced when this page was built.

DesignMatrix is a dataframe that knows it describes a timeseries. It carries a sampling frequency, tracks which of its columns are convolved task regressors and which are confounds, and offers the operations a GLM needs: HRF convolution, drift terms, run-wise concatenation, and collinearity diagnostics.

The layout is the usual machine-learning one, observations by features: TRs by conditions plus nuisance regressors for a first-level analysis, or participants by conditions for a second-level one.

Build one by hand

A toy design for one participant: 22 TRs at 1.5 s, four stimulus conditions, each on for 2 TRs with a TR of rest between events. sampling_freq is in hertz, so it is 1 / TR.

import numpy as np

from nltools.data import DesignMatrix

TR = 1.5

dm = DesignMatrix(
    np.array(
        [
            [0, 0, 0, 0],
            [0, 0, 0, 0],
            [1, 0, 0, 0],
            [1, 0, 0, 0],
            [0, 0, 0, 0],
            [0, 1, 0, 0],
            [0, 1, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 1, 0],
            [0, 0, 1, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 1],
            [0, 0, 0, 1],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
            [0, 0, 0, 0],
        ]
    ),
    columns=["face_A", "face_B", "house_A", "house_B"],
    sampling_freq=1.0 / TR,
)
dm.head()
DesignMatrix(sampling_freq=0.6666666666666666, shape=(5, 4))

Printing it shows what the object knows about itself: sampling frequency, shape, and — once there are any — which columns are convolved and which are confounds.

print(dm)
DesignMatrix(sampling_freq=0.6666666666666666, shape=(22, 4))

.plot() draws the SPM/FSL-style heatmap, TRs down and regressors across:

dm.plot()
2026-09-13T00:39:18.692296 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ face_A face_B house_A house_B Regressors Time (TRs)

Drift and baseline regressors

Legendre polynomials

add_poly adds orthogonal Legendre polynomials on the interval -1 to 1, the same convention other packages use. Order 2 with include_lower=True gives an intercept, a linear trend and a quadratic trend.

dm_with_nuisance = dm.add_poly(2, include_lower=True)
dm_with_nuisance.plot()
2026-09-13T00:39:18.738091 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ face_A face_B house_A house_B .nl_poly_0 .nl_poly_1 .nl_poly_2 Regressors Time (TRs)

Three columns appeared, and the object now lists them as confounds:

print(dm_with_nuisance)
DesignMatrix(sampling_freq=0.6666666666666666, shape=(22, 7))
  confounds (3): ['.nl_poly_0', '.nl_poly_1', '.nl_poly_2']

Discrete cosine basis

add_dct_basis is the other standard choice: a bank of cosine filters acting as a high-pass filter. duration sets the cutoff period in seconds and defaults to 180 s; 20 s suits this very short toy run.

dm.add_dct_basis(duration=20).plot()
2026-09-13T00:39:18.795687 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ face_A face_B house_A house_B .nl_cosine_0 .nl_cosine_1 .nl_cosine_2 .nl_cosine_3 Regressors Time (TRs)

Convolution

convolve applies a hemodynamic response function to the task regressors and skips the confound columns, so drift terms are left alone. kernel= defaults to "glover" and takes any of nilearn's HRF models ("glover_time", "glover_dispersion", "spm", "spm_time", "spm_dispersion"); pass a 1-D array for your own kernel, or a 2-D array to convolve with several at once. Convolved columns get a _c0 suffix (_c1, _c2, … for further kernels) so you can reference them by name.

convolved = dm_with_nuisance.convolve()
print(convolved)
convolved.plot()
DesignMatrix(sampling_freq=0.6666666666666666, shape=(22, 7))
  convolved (4): ['face_A_c0', 'face_B_c0', 'house_A_c0', 'house_B_c0']
  confounds (3): ['.nl_poly_0', '.nl_poly_1', '.nl_poly_2']
2026-09-13T00:39:18.854581 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ .nl_poly_0 .nl_poly_1 .nl_poly_2 face_A_c0 face_B_c0 house_A_c0 house_B_c0 Regressors Time (TRs)

DesignMatrix also upsamples, downsamples and z-scores; the DesignMatrix reference has the full list.

Read a design from files

From an onsets file

Pass a path to a 2- or 3-column onsets file and DesignMatrix builds the regressors for you. run_length is the number of TRs and TR the repetition time. The constructor convolves by default; hrf_model=None gives raw boxcars instead, which is what you want when you plan to build interaction terms first. nltools ships an example file where each event lasts 10 s.

import os

from nltools.datasets import get_resource_path

RUN_TR = 2.0
onsets_file = os.path.join(get_resource_path(), "onsets_example.csv")

onsets_dm = DesignMatrix(
    onsets_file, run_length=160, TR=RUN_TR, hrf_model=None
).add_poly(1)
onsets_dm.plot()
2026-09-13T00:39:18.947893 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ BillyRiggins BuddyGarrity CoachTaylor GrandmaSaracen JasonStreet JulieTaylor LandryClarke LylaGarrity MattSaracen SmashWilliams TamiTaylor TimRiggins TyraCollette .nl_poly_0 .nl_poly_1 Regressors Time (TRs)

From any table

Anything polars or pandas can read becomes a DesignMatrix — you only have to supply the sampling frequency. Here is a motion-realignment file from a preprocessing pipeline. .plot() forwards keyword arguments to seaborn, so vmin/vmax rescale the color range to the size of these regressors.

import pandas as pd

covariates_file = os.path.join(get_resource_path(), "covariates_example.csv")
covariates = DesignMatrix(pd.read_csv(covariates_file), sampling_freq=1.0 / RUN_TR)
covariates.plot(vmin=-1, vmax=1)
2026-09-13T00:39:19.145203 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ ra1 ra2 ra3 ra4 ra5 ra6 rasq1 rasq2 rasq3 rasq4 rasq5 rasq6 radiff1 radiff2 radiff3 radiff4 radiff5 radiff6 radiffsq1 radiffsq2 radiffsq3 radiffsq4 radiffsq5 radiffsq6 spike1 Regressors Time (TRs)

Combining runs

Stacking vertically

An experiment with several runs needs one design per run, stacked. Run differences have to be absorbed by run-specific baselines, so append keeps polynomial columns separate per run automatically and renames them to say which run they came from.

two_runs = dm_with_nuisance.append(dm_with_nuisance, axis=0)
print(two_runs.columns)
two_runs.plot()
['face_A', 'face_B', 'house_A', 'house_B', '.nl_r0_poly_0', '.nl_r0_poly_1', '.nl_r0_poly_2', '.nl_r1_poly_0', '.nl_r1_poly_1', '.nl_r1_poly_2']
2026-09-13T00:39:19.357235 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ face_A face_B house_A house_B .nl_r0_poly_0 .nl_r0_poly_1 .nl_r0_poly_2 .nl_r1_poly_0 .nl_r1_poly_1 .nl_r1_poly_2 Regressors Time (TRs)

Keeping other columns separate

Task regressors are stacked, so one coefficient is estimated across runs. To estimate a column per run instead, name it in unique_cols. A leading or trailing * is a wildcard, so "house*" separates both house conditions.

split_houses = dm_with_nuisance.append(
    dm_with_nuisance, axis=0, unique_cols=["house*"]
)
print(split_houses.columns)
split_houses.plot()
['face_A', 'face_B', '.nl_r0_house_A', '.nl_r0_house_B', '.nl_r0_poly_0', '.nl_r0_poly_1', '.nl_r0_poly_2', '.nl_r1_house_A', '.nl_r1_house_B', '.nl_r1_poly_0', '.nl_r1_poly_1', '.nl_r1_poly_2']
2026-09-13T00:39:19.445818 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ face_A face_B .nl_r0_house_A .nl_r0_house_B .nl_r0_poly_0 .nl_r0_poly_1 .nl_r0_poly_2 .nl_r1_house_A .nl_r1_house_B .nl_r1_poly_0 .nl_r1_poly_1 .nl_r1_poly_2 Regressors Time (TRs)

A realistic multi-run design

Putting it together: for each run, read the onsets, read the confounds, add that run's drift terms, join the two side by side, then stack the runs while keeping the confounds run-specific. Four runs of the same example files stand in for a real experiment here.

add_dct_basis(include_constant=False) is deliberate — add_poly(1) already contributed an intercept, and asking for a second one would be redundant.

all_runs = DesignMatrix(sampling_freq=1.0 / RUN_TR)

for _run in range(4):
    # 1. Task regressors, HRF-convolved by the constructor
    _task = DesignMatrix(onsets_file, run_length=160, TR=RUN_TR)

    # 2. Confounds for this run, with drift and high-pass terms
    _confounds = DesignMatrix(
        pd.read_csv(covariates_file), sampling_freq=1.0 / RUN_TR
    ).fillna(0)
    _confounds = _confounds.add_poly(1).add_dct_basis(include_constant=False)

    # 3. Join them side by side, then stack onto the master design
    _full = _task.append(_confounds, axis=1)
    all_runs = all_runs.append(_full, axis=0, unique_cols=list(_confounds.columns))

print(all_runs)
all_runs.plot(vmin=-1, vmax=1)
DesignMatrix(sampling_freq=0.5, shape=(640, 133))
  convolved (13): ['BillyRiggins_c0', 'BuddyGarrity_c0', 'CoachTaylor_c0', 'GrandmaSaracen_c0', 'JasonStreet_c0', 'JulieTaylor_c0', 'LandryClarke_c0', 'LylaGarrity_c0', 'MattSaracen_c0', 'SmashWilliams_c0', 'TamiTaylor_c0', 'TimRiggins_c0', 'TyraCollette_c0']
  confounds (20): ['.nl_r0_poly_0', '.nl_r0_poly_1', '.nl_r0_cosine_1', '.nl_r0_cosine_2', '.nl_r0_cosine_3', '.nl_r1_poly_0', '.nl_r1_poly_1', '.nl_r1_cosine_1', '.nl_r1_cosine_2', '.nl_r1_cosine_3', '.nl_r2_poly_0', '.nl_r2_poly_1', '.nl_r2_cosine_1', '.nl_r2_cosine_2', '.nl_r2_cosine_3', '.nl_r3_poly_0', '.nl_r3_poly_1', '.nl_r3_cosine_1', '.nl_r3_cosine_2', '.nl_r3_cosine_3']
2026-09-13T00:39:20.189639 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ BillyRiggins_c0 BuddyGarrity_c0 CoachTaylor_c0 GrandmaSaracen_c0 JasonStreet_c0 JulieTaylor_c0 LandryClarke_c0 LylaGarrity_c0 MattSaracen_c0 SmashWilliams_c0 TamiTaylor_c0 TimRiggins_c0 TyraCollette_c0 .nl_r0_ra1 .nl_r0_ra2 .nl_r0_ra3 .nl_r0_ra4 .nl_r0_ra5 .nl_r0_ra6 .nl_r0_rasq1 .nl_r0_rasq2 .nl_r0_rasq3 .nl_r0_rasq4 .nl_r0_rasq5 .nl_r0_rasq6 .nl_r0_radiff1 .nl_r0_radiff2 .nl_r0_radiff3 .nl_r0_radiff4 .nl_r0_radiff5 .nl_r0_radiff6 .nl_r0_radiffsq1 .nl_r0_radiffsq2 .nl_r0_radiffsq3 .nl_r0_radiffsq4 .nl_r0_radiffsq5 .nl_r0_radiffsq6 .nl_r0_spike1 .nl_r0_poly_0 .nl_r0_poly_1 .nl_r0_cosine_1 .nl_r0_cosine_2 .nl_r0_cosine_3 .nl_r1_ra1 .nl_r1_ra2 .nl_r1_ra3 .nl_r1_ra4 .nl_r1_ra5 .nl_r1_ra6 .nl_r1_rasq1 .nl_r1_rasq2 .nl_r1_rasq3 .nl_r1_rasq4 .nl_r1_rasq5 .nl_r1_rasq6 .nl_r1_radiff1 .nl_r1_radiff2 .nl_r1_radiff3 .nl_r1_radiff4 .nl_r1_radiff5 .nl_r1_radiff6 .nl_r1_radiffsq1 .nl_r1_radiffsq2 .nl_r1_radiffsq3 .nl_r1_radiffsq4 .nl_r1_radiffsq5 .nl_r1_radiffsq6 .nl_r1_spike1 .nl_r1_poly_0 .nl_r1_poly_1 .nl_r1_cosine_1 .nl_r1_cosine_2 .nl_r1_cosine_3 .nl_r2_ra1 .nl_r2_ra2 .nl_r2_ra3 .nl_r2_ra4 .nl_r2_ra5 .nl_r2_ra6 .nl_r2_rasq1 .nl_r2_rasq2 .nl_r2_rasq3 .nl_r2_rasq4 .nl_r2_rasq5 .nl_r2_rasq6 .nl_r2_radiff1 .nl_r2_radiff2 .nl_r2_radiff3 .nl_r2_radiff4 .nl_r2_radiff5 .nl_r2_radiff6 .nl_r2_radiffsq1 .nl_r2_radiffsq2 .nl_r2_radiffsq3 .nl_r2_radiffsq4 .nl_r2_radiffsq5 .nl_r2_radiffsq6 .nl_r2_spike1 .nl_r2_poly_0 .nl_r2_poly_1 .nl_r2_cosine_1 .nl_r2_cosine_2 .nl_r2_cosine_3 .nl_r3_ra1 .nl_r3_ra2 .nl_r3_ra3 .nl_r3_ra4 .nl_r3_ra5 .nl_r3_ra6 .nl_r3_rasq1 .nl_r3_rasq2 .nl_r3_rasq3 .nl_r3_rasq4 .nl_r3_rasq5 .nl_r3_rasq6 .nl_r3_radiff1 .nl_r3_radiff2 .nl_r3_radiff3 .nl_r3_radiff4 .nl_r3_radiff5 .nl_r3_radiff6 .nl_r3_radiffsq1 .nl_r3_radiffsq2 .nl_r3_radiffsq3 .nl_r3_radiffsq4 .nl_r3_radiffsq5 .nl_r3_radiffsq6 .nl_r3_spike1 .nl_r3_poly_0 .nl_r3_poly_1 .nl_r3_cosine_1 .nl_r3_cosine_2 .nl_r3_cosine_3 Regressors Time (TRs)

Reading the heatmap left to right: conditions of interest, stacked across all four runs; then each run's own confounds; then each run's drift and baseline terms.

Diagnostics

A design with two columns that say nearly the same thing cannot be estimated stably. clean drops any column correlated at or above thresh (0.95 by default) with a column before it.

cleaned = all_runs.clean()
print(f"{all_runs.shape[1]} columns -> {cleaned.shape[1]} after cleaning")
cleaned.plot(vmin=-1, vmax=1)
133 columns -> 129 after cleaning
2026-09-13T00:39:25.216043 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ BillyRiggins_c0 BuddyGarrity_c0 CoachTaylor_c0 GrandmaSaracen_c0 JasonStreet_c0 JulieTaylor_c0 LandryClarke_c0 LylaGarrity_c0 MattSaracen_c0 SmashWilliams_c0 TamiTaylor_c0 TimRiggins_c0 TyraCollette_c0 .nl_r0_ra1 .nl_r0_ra2 .nl_r0_ra3 .nl_r0_ra4 .nl_r0_ra5 .nl_r0_ra6 .nl_r0_rasq1 .nl_r0_rasq2 .nl_r0_rasq3 .nl_r0_rasq4 .nl_r0_rasq5 .nl_r0_rasq6 .nl_r0_radiff1 .nl_r0_radiff2 .nl_r0_radiff3 .nl_r0_radiff4 .nl_r0_radiff5 .nl_r0_radiff6 .nl_r0_radiffsq1 .nl_r0_radiffsq2 .nl_r0_radiffsq3 .nl_r0_radiffsq4 .nl_r0_radiffsq5 .nl_r0_radiffsq6 .nl_r0_spike1 .nl_r0_poly_0 .nl_r0_poly_1 .nl_r0_cosine_2 .nl_r0_cosine_3 .nl_r1_ra1 .nl_r1_ra2 .nl_r1_ra3 .nl_r1_ra4 .nl_r1_ra5 .nl_r1_ra6 .nl_r1_rasq1 .nl_r1_rasq2 .nl_r1_rasq3 .nl_r1_rasq4 .nl_r1_rasq5 .nl_r1_rasq6 .nl_r1_radiff1 .nl_r1_radiff2 .nl_r1_radiff3 .nl_r1_radiff4 .nl_r1_radiff5 .nl_r1_radiff6 .nl_r1_radiffsq1 .nl_r1_radiffsq2 .nl_r1_radiffsq3 .nl_r1_radiffsq4 .nl_r1_radiffsq5 .nl_r1_radiffsq6 .nl_r1_spike1 .nl_r1_poly_0 .nl_r1_poly_1 .nl_r1_cosine_2 .nl_r1_cosine_3 .nl_r2_ra1 .nl_r2_ra2 .nl_r2_ra3 .nl_r2_ra4 .nl_r2_ra5 .nl_r2_ra6 .nl_r2_rasq1 .nl_r2_rasq2 .nl_r2_rasq3 .nl_r2_rasq4 .nl_r2_rasq5 .nl_r2_rasq6 .nl_r2_radiff1 .nl_r2_radiff2 .nl_r2_radiff3 .nl_r2_radiff4 .nl_r2_radiff5 .nl_r2_radiff6 .nl_r2_radiffsq1 .nl_r2_radiffsq2 .nl_r2_radiffsq3 .nl_r2_radiffsq4 .nl_r2_radiffsq5 .nl_r2_radiffsq6 .nl_r2_spike1 .nl_r2_poly_0 .nl_r2_poly_1 .nl_r2_cosine_2 .nl_r2_cosine_3 .nl_r3_ra1 .nl_r3_ra2 .nl_r3_ra3 .nl_r3_ra4 .nl_r3_ra5 .nl_r3_ra6 .nl_r3_rasq1 .nl_r3_rasq2 .nl_r3_rasq3 .nl_r3_rasq4 .nl_r3_rasq5 .nl_r3_rasq6 .nl_r3_radiff1 .nl_r3_radiff2 .nl_r3_radiff3 .nl_r3_radiff4 .nl_r3_radiff5 .nl_r3_radiff6 .nl_r3_radiffsq1 .nl_r3_radiffsq2 .nl_r3_radiffsq3 .nl_r3_radiffsq4 .nl_r3_radiffsq5 .nl_r3_radiffsq6 .nl_r3_spike1 .nl_r3_poly_0 .nl_r3_poly_1 .nl_r3_cosine_2 .nl_r3_cosine_3 Regressors Time (TRs)

The dropped columns are polynomials and cosine filters that duplicate one another. In practice pick one family or the other; both were used above to show the diagnostic working.

vif() is the companion check: it reports each regressor's variance inflation factor, and values at or above 5 are the classic warning sign.

Estimating the model

A finished design becomes the X of a BrainData object holding the EPI data for those runs, and the regression is one call. This is not run here because the notebook has no four-run dataset attached; the GLM tutorial works through a real one.

from nltools.data import BrainData

brains = BrainData(["run_1.nii.gz", "run_2.nii.gz", "run_3.nii.gz", "run_4.nii.gz"])
brains.X = cleaned

results = brains.regress()

That produces a beta, t, and p image per column of the design matrix.