Skip to content

Quickstart

Open in molab

Run this tutorial

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

nltools keeps things simple by making use of a few key concepts that can be flexibly combined to perform a wide variety of analyses.

Everything on this page runs in your browser: a real Python interpreter, nothing installed. Press Run to recompute a cell; the first Run downloads Python and nltools, and each cell needs the ones above it to have run first.

Basics

Working with neuroimaging data

Start by choosing the grid every image lives on. set_brainspace sets it for the session, and 3 mm is what the example dataset below uses: 71,020 voxels against 238,955 at the 2 mm default, small enough to keep this page in a tab.

0.6.0.dev1
BrainSpaceConfig(template='default', resolution=3mm)
  mask: 3mm-MNI152-2009fsl-mask.nii.gz
  brain: 3mm-MNI152-2009fsl-brain.nii.gz
  plot: 3mm-MNI152-2009fsl-T1.nii.gz

Editor (session: quickstart)Run
import nltools
from nltools import set_brainspace

print(nltools.__version__)
print(set_brainspace(resolution=3))
OutputClear

load_haxby_example simulates a whole experiment and downloads nothing but the MNI template: the eight object conditions of the Haxby task in a randomized block design, each condition driving an 8 mm sphere where that category responds in the real data. It hands back one BrainData per run, images by voxels with one row per TR, and the DesignMatrix that generated it. The fetchers in nltools.datasets load your own data.

nltools.data.braindata.BrainData(data=(72, 71020), resolution=3.0mm, space=mni, mask=3mm-MNI152-2009fsl-mask.nii.gz)
shape: (9, 2)
┌──────────────┬───────┐
│ condition    ┆ count │
│ ---          ┆ ---   │
│ str          ┆ u32   │
╞══════════════╪═══════╡
│ bottle       ┆ 6     │
│ cat          ┆ 6     │
│ chair        ┆ 6     │
│ face         ┆ 6     │
│ house        ┆ 6     │
│ rest         ┆ 24    │
│ scissors     ┆ 6     │
│ scrambledpix ┆ 6     │
│ shoe         ┆ 6     │
└──────────────┴───────┘

Editor (session: quickstart)Run
from nltools.datasets import load_haxby_example

brains, design_matrices = load_haxby_example()
data, design = brains[0], design_matrices[0]

print(data)
print(data.Y["condition"].value_counts().sort("condition"))
OutputClear

.Y carries the condition of every TR, so indexing pulls one condition out and mean() averages it; smooth first, because six face TRs against one subject's noise make a speckled map. Face blocks minus rest peaks in the right fusiform, and threshold="99.5%" keeps the strongest half percent of voxels — the blue is the patches the other categories drive, which the rest TRs carry a little of. iplot() draws it in an interactive niivue viewer: drag the sliders to rewindow the map, scroll a panel to move through slices.

Interactive viewer (needs a live kernel)

Editor (session: quickstart)Run
smoothed = data.smooth(fwhm=6)
faces = smoothed[smoothed.Y["condition"] == "face"].mean()
baseline = smoothed[smoothed.Y["condition"] == "rest"].mean()

(faces - baseline).iplot(threshold="99.5%")
OutputClear

More in Working with BrainData, which covers loading, indexing, masks and ROIs, plotting and saving images.

Working with an experimental design

A DesignMatrix is a dataframe that knows it describes a timeseries: it carries a sampling frequency, and convolve applies a hemodynamic response to each task regressor, renaming it <column>_c0. The example's design arrives convolved: eight condition regressors and an intercept over 72 TRs:

DesignMatrix(sampling_freq=0.4, shape=(72, 9))
  convolved (8): ['bottle_c0', 'cat_c0', 'chair_c0', 'face_c0', 'house_c0', 'scissors_c0', 'scrambledpix_c0', 'shoe_c0']
  confounds (1): ['.nl_poly_0']

Editor (session: quickstart)Run
print(design)
OutputClear

2026-09-14T01:06:56.478964 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ bottle_c0 cat_c0 chair_c0 face_c0 house_c0 scissors_c0 scrambledpix_c0 shoe_c0 .nl_poly_0 Regressors Time (TRs)

Editor (session: quickstart)Run
design.plot()
OutputClear

More in Working with DesignMatrix, which covers confounds, polynomial terms, multi-run designs and events files.

Working with similarities & distances

Adjacency holds a square matrix over a set of nodes: a correlation matrix, a distance matrix, a network. BrainData.distance makes one out of images, and it is worth doing inside a region, since over 71,020 voxels two patterns mostly differ by noise. create_sphere draws regions in MNI millimetres and apply_mask keeps the voxels inside them — here the example's eight response spheres. Correlation distance ignores how strongly a pattern responds overall and compares only its shape across those voxels:

nltools.data.braindata.BrainData(data=(8, 616), resolution=3.0mm, space=mni, mask=None)
nltools.data.adjacency.Adjacency(shape=(8, 8), Y=(0, 0), is_symmetric=True, matrix_type=distance)

Editor (session: quickstart)Run
from nltools import concatenate
from nltools.data import Adjacency
from nltools.mask import create_sphere

ventral_stream = {
    "face": [40, -50, -20],  # right fusiform face area
    "cat": [-40, -50, -20],  # left fusiform
    "bottle": [46, -78, -6],  # right lateral occipital
    "scissors": [-46, -78, -6],  # left lateral occipital
    "shoe": [36, -66, -16],  # right posterior fusiform
    "chair": [-36, -66, -16],  # left posterior fusiform
    "house": [-26, -44, -10],  # left parahippocampal place area
    "scrambledpix": [0, -88, 2],  # early visual cortex
}
ventral_temporal = create_sphere(
    list(ventral_stream.values()), radius=8, mask=data.mask
)
patterns = concatenate(
    [data[data.Y["condition"] == name].mean() for name in ventral_stream]
).apply_mask(ventral_temporal)
neural = Adjacency(
    patterns.distance(metric="correlation"), labels=list(ventral_stream)
)

print(patterns)
print(neural)
OutputClear

2026-09-14T01:06:57.094983 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ face cat bottle scissors shoe chair house scrambledpix face cat bottle scissors shoe chair house scrambledpix 0.0 0.2 0.4 0.6 0.8 1.0 1.2

Editor (session: quickstart)Run
neural.plot()
OutputClear

Eight conditions make 28 distinct pairs, and that is all an Adjacency stores; the plot fills the diagonal back in with the zero a pattern has with itself. The dark cells are the within-category pairs — face with cat, the four man-made objects with each other — while houses and scrambled pictures have no close partner.

More in Working with Adjacency, which covers thresholds, Fisher z, stacking subjects, regression and graphs.

Common analysis workflows

Those three objects are all you need. Every permutation and bootstrap test below uses 200 resamples instead of the default 5,000, so each cell takes a second.

Mapping neural responses

Fitting a GLM is fit(model="glm", X=design), and compute_contrasts asks it a question — here, how much more each voxel responds to faces than to houses:

2026-09-14T01:06:57.758631 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -3.1 -1.5 0 1.5 3.1 faces - houses, one subject

Editor (session: quickstart)Run
data.fit(model="glm", X=design)

data.compute_contrasts("face_c0 - house_c0").plot(
    title="faces - houses, one subject"
)
OutputClear

One subject is not a result: the map a paper reports is a test across subjects. n_runs=5 returns five fresh draws of the experiment, each with its own block order and noise, to stand in for five subjects; stack their contrast maps with concatenate, ttest gives the voxelwise one-sample test, and threshold zeroes every voxel whose p-value misses a cutoff:

2026-09-14T01:07:00.502849 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -57 -28 0 28 57 group t, p < 0.001

Editor (session: quickstart)Run
from nltools.algorithms import threshold

subjects, subject_designs = load_haxby_example(n_runs=5)
group = concatenate(
    [
        subject.fit(model="glm", X=subject_design).compute_contrasts(
            "face_c0 - house_c0"
        )
        for subject, subject_design in zip(subjects, subject_designs)
    ]
)
group_t = group.ttest()

threshold(group_t["t"], group_t["p"], thr=0.001).plot(title="group t, p < 0.001")
OutputClear

ttest returns the mean, t, z and p maps as separate images.

Predicting neural responses

An encoding model turns the same equation around: features go in, and the model is judged on data it never saw. The features here are every convolved condition regressor at twelve delays — 96 columns over 72 TRs, so least squares has no unique solution and the ridge penalty is what makes the fit possible at all. Two runs, one to train on and one held out, both z-scored because ridge fits no intercept and the data sit on a baseline of 100. ridge_cv picks the penalty by cross-validation, one per voxel, and the fit lands on .model: a weight map per feature in betas, the chosen penalty in alpha, and the variance explained on the training run in r2:

nltools.data.braindata.BrainData(data=(96, 71020), resolution=3.0mm, space=mni, mask=3mm-MNI152-2009fsl-mask.nii.gz)
penalty per voxel: [   1.   10.  100. 1000.]
best training r2: 0.82

Editor (session: quickstart)Run
import numpy as np

def delayed(design):
    """The eight convolved regressors at twelve delays, side by side."""
    names = [name for name in design.columns if name.endswith("_c0")]
    regressors = np.column_stack([np.asarray(design[name]) for name in names])
    shifted = [np.pad(regressors, ((lag, 0), (0, 0))) for lag in range(12)]
    return np.column_stack([block[: len(regressors)] for block in shifted])

runs, run_designs = load_haxby_example(n_runs=2)
train = runs[0].standardize(method="zscore")
held_out = runs[1].standardize(method="zscore")
train.fit(
    model="ridge",
    X=delayed(run_designs[0]),
    ridge_alpha=[1, 10, 100, 1000],
    ridge_cv=5,
    random_state=0,
)

print(train.model.betas)
print(f"penalty per voxel: {np.unique(train.model.alpha.data)}")
print(f"best training r2: {train.model.r2.data.max():.2f}")
OutputClear

Applying those weights to the held-out run's features predicts its timecourse. Correlating that prediction with what the run actually did, voxel by voxel, gives a performance map: not how much a voxel responds, but how well the model accounts for it:

2026-09-14T01:07:02.700419 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -0.64 -0.32 0 0.32 0.64 Held-out run, r per voxel

Editor (session: quickstart)Run
from nltools.data import BrainData

def voxel_correlation(observed, predicted):
    """Correlate two arrays over the same voxels, one voxel at a time."""
    observed = (observed - observed.mean(axis=0)) / observed.std(axis=0)
    predicted = (predicted - predicted.mean(axis=0)) / predicted.std(axis=0)
    return (observed * predicted).mean(axis=0)

encoding_scores = voxel_correlation(
    held_out.data, train.predict(X=delayed(run_designs[1])).data
)

BrainData(encoding_scores[None, :], mask=held_out.mask).plot(
    title="Held-out run, r per voxel"
)
OutputClear

Analyzing neural patterns

Representational similarity analysis compares geometries instead of voxels: how far apart the conditions are in the brain, against how far apart a model says they should be. The neural side is the distance matrix from the basics section, and the model side puts two conditions far apart when their categories differ. Both sides are an Adjacency, and similarity correlates them, Spearman by default, with a permutation test that shuffles rows and columns together:

rho = 0.75, p = 0.010 (200 permutations)

Editor (session: quickstart)Run
categories = np.array(["animate"] * 2 + ["object"] * 4 + ["scene", "control"])
model_rdm = Adjacency(
    (categories[:, None] != categories[None, :]).astype(float),
    matrix_type="distance",
    labels=neural.labels,
)
rsa = neural.similarity(model_rdm, n_permute=200, random_state=0, n_jobs=1)

print(f"rho = {rsa['correlation']:.2f}, p = {rsa['p']:.3f} (200 permutations)")
OutputClear

2026-09-14T01:07:03.016029 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ face cat bottle scissors shoe chair house scrambledpix Category model face cat bottle scissors shoe chair house scrambledpix Correlation distance 0.6 0.8 1.0 1.2 0.0 0.2 0.4 0.6 0.8 1.0

Editor (session: quickstart)Run
neural.plot_stacked(
    model_rdm, upper_title="Correlation distance", lower_title="Category model"
)
OutputClear

plot_stacked puts the two matrices in one square, the measured geometry above the diagonal and the model below it.

Decoding asks the same region the opposite question: given the pattern, which condition was it? predict cross-validates a classifier over images and reports the accuracy per fold. Twelve face and house TRs, three folds:

accuracy 0.92, per fold [0.75 1.   1.  ]

Editor (session: quickstart)Run
decoded = (
    data[data.Y["condition"].is_in(["face", "house"])]
    .apply_mask(ventral_temporal)
    .predict(y="condition", estimator="linear_svc", cv=3)
)

print(f"accuracy {decoded.mean_score:.2f}, per fold {decoded.scores}")
OutputClear

2026-09-14T01:07:03.746669 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -0.016 -0.008 0 0.008 0.016 Classifier weights, faces vs houses

Editor (session: quickstart)Run
decoded.weight_map.plot(title="Classifier weights, faces vs houses")
OutputClear

The weight map is the pattern the classifier leaned on, refit on all twelve TRs. Decoding wants a region: twelve labelled TRs against 71,020 voxels leave a whole-brain classifier anywhere between chance and this, and masking to ventral temporal cortex first is how the real Haxby data are analyzed too.

Analyzing intersubject similarity

Intersubject correlation asks how much of a response is shared: with everyone watching the same thing, what two subjects' timecourses have in common is what the stimulus drove. extract_roi averages each subject's timecourse inside every parcel of a 50-region atlas, isc takes the median correlation over pairs of subjects one parcel at a time and bootstraps subjects for the p-value, and roi_to_brain paints it back onto the brain. Each simulated subject saw the blocks in a different order, so line the TRs up by condition first:

best parcel: ISC 0.79, 6 of 50 parcels at p < 0.05 (200 bootstraps)

Editor (session: quickstart)Run
from nltools.algorithms import isc
from nltools.mask import expand_mask, roi_to_brain
from nltools.datasets import fetch_resource

parcellation = BrainData(
    fetch_resource("masks/default/3mm-MNI152-2009fsl-k50.nii.gz")
)

def time_locked(subject):
    """Reorder one subject's TRs into the sequence every subject shares."""
    order = subject.Y.with_row_index().sort(["condition", "index"])["index"]
    return subject[order.to_numpy()]

parcel_timeseries = np.stack(
    [time_locked(subject).extract_roi(parcellation).T for subject in subjects],
    axis=1,
)
isc_result = isc(parcel_timeseries, n_samples=200, random_state=0, n_jobs=1)

print(
    f"best parcel: ISC {isc_result['isc'].max():.2f}, "
    f"{(isc_result['p'] < 0.05).sum()} of 50 parcels at p < 0.05 (200 bootstraps)"
)
OutputClear

2026-09-14T01:07:07.494817 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -0.79 -0.39 0 0.39 0.79 Intersubject correlation, per parcel

Editor (session: quickstart)Run
roi_to_brain(isc_result["isc"], expand_mask(parcellation)).plot(
    title="Intersubject correlation, per parcel"
)
OutputClear

Intersubject RSA runs the RSA above with people as the items: a matrix of how similar each pair of subjects' responses are, against a matrix of how far apart their behavioural scores are. Neither side is a finding here — each subject's ventral-temporal response is blended from its own face and house profiles, and the face-recognition score is that blend, rescaled:

rho = -0.86, p = 0.025 (200 permutations)

Editor (session: quickstart)Run
blend = np.linspace(0, 1, len(subjects))

def blended_response(subject, weight):
    """One subject's ventral-temporal response, blended from faces toward houses."""
    rest = subject[subject.Y["condition"] == "rest"].mean()
    to_faces = subject[subject.Y["condition"] == "face"].mean() - rest
    to_houses = subject[subject.Y["condition"] == "house"].mean() - rest
    blended = to_faces * (1 - weight) + to_houses * weight
    return blended.apply_mask(ventral_temporal).data

profiles = [blended_response(s, w) for s, w in zip(subjects, blend)]
neural_similarity = Adjacency(
    np.corrcoef(profiles),
    matrix_type="similarity",
    labels=[f"s{n + 1}" for n in range(len(subjects))],
)
scores = 100 - 40 * blend
behaviour = Adjacency(
    np.abs(scores[:, None] - scores[None, :]), matrix_type="distance"
)
isrsa = neural_similarity.similarity(
    behaviour, n_permute=200, random_state=0, n_jobs=1
)

print(f"rho = {isrsa['correlation']:.2f}, p = {isrsa['p']:.3f} (200 permutations)")
OutputClear

2026-09-14T01:07:08.828896 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ s1 s2 s3 s4 s5 Face-recognition score distance s1 s2 s3 s4 s5 Response similarity 0.2 0.3 0.4 0.5 10 15 20 25 30 35 40

Editor (session: quickstart)Run
neural_similarity.plot_stacked(
    behaviour,
    upper_title="Response similarity",
    lower_title="Face-recognition score distance",
)
OutputClear

The correlation is negative because one side is a similarity and the other a distance: subjects who respond alike are the ones whose scores are close.

Aligning neural responses

Two people watching the same film share the response, not the anatomy it sits in: the same information can live in different voxels, and functional alignment finds the transformation between them. Here the second subject is the first one's ventral-temporal data with its voxels shuffled, nothing lost, only moved:

before: r = 0.07
after:  r = 1.00

Editor (session: quickstart)Run
target = time_locked(subjects[0]).apply_mask(ventral_temporal)
shuffle = np.random.default_rng(0).permutation(target.shape[1])
scrambled = BrainData(target.data[:, shuffle], mask=ventral_temporal)
aligned = scrambled.align(target, method="procrustes")["transformed"]

print(f"before: r = {voxel_correlation(target.data, scrambled.data).mean():.2f}")
print(f"after:  r = {voxel_correlation(target.data, aligned.data).mean():.2f}")
OutputClear

2026-09-14T01:07:09.645378 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ 0 10 20 30 voxel 0 20 40 60 TR subject 1 (target) 0 10 20 30 voxel subject 2, voxels shuffled 0 10 20 30 voxel subject 2, after procrustes

Editor (session: quickstart)Run
import matplotlib.pyplot as plt

_panels = {
    "subject 1 (target)": target,
    "subject 2, voxels shuffled": scrambled,
    "subject 2, after procrustes": aligned,
}
_fig, _axes = plt.subplots(1, 3, figsize=(9, 3), sharey=True)
for _ax, (_label, _image) in zip(_axes, _panels.items()):
    _ax.imshow(_image.data[:, :40], aspect="auto", cmap="RdBu_r")
    _ax.set(title=_label, xlabel="voxel")
_axes[0].set_ylabel("TR")
_fig.tight_layout()
OutputClear

Shuffling voxels is an orthogonal transformation, exactly what procrustes solves for, so the recovery is exact: an idealized version of the real problem. BrainData.align works one pair at a time; nltools.algorithms.align takes a list of subjects and learns the shared response model they all map into.

Keep learning

The Reference documents every namespace, and the tutorials work through the same objects on real data.

  • DartBrains — the fundamentals of fMRI analysis, from preprocessing to group statistics.
  • Naturalistic Data — movies, games and other naturalistic designs, where the intersubject methods above come from.
  • Getting help — the Discourse forum, where questions and their answers stay findable.