Skip to content

Encoding Models

Open in molab

Run this tutorial

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

What it answers. How much of each voxel's response can a stimulus feature space explain — on held-out data? An encoding model is the inverse of decoding: instead of predicting the stimulus from the brain, you predict the brain from features of the stimulus, and score each voxel by its cross-validated R².

For the theory, see the encoding-model material in naturalistic-data. This tutorial is about running one in nltools.

How it works. Compared with the GLM, an encoding model flips the question and the machinery:

  • GLM assumes a canonical HRF, uses a few categorical regressors, and asks which voxels respond (β / t / p).
  • Encoding uses many features (often hundreds), lets the data estimate the response shape, and asks how well features predict each voxel (R² on data the model never saw).

Two ideas make it work: a FIR (finite impulse response) feature bank — lagged copies of the stimulus, so ridge learns the per-voxel HRF instead of assuming one — and ridge regularization with per-voxel α, since hundreds of features would make ordinary least squares overfit. We compare an optimistic in-sample fit against an honest held-out one.

import numpy as np
from joblib import Memory

from nltools.data import BrainData
from nltools import concatenate

# Memoize the (slow) multi-run load to disk (.cache/ is git-ignored).
memory = Memory(".tutorial-cache", verbose=0)

How to do it

We use the classic Miyawaki 2008 dataset — one subject viewing 10×10 binary contrast figures while we record from visual cortex. The stimulus is naturally 100-dimensional, so encoding is the right tool. The data ships in subject-native space (anisotropic voxels), so we pass mask= to skip MNI resampling and plot on the bundled anatomical with slice views (glass-brain/MNI views would misalign).

from nilearn.datasets import fetch_miyawaki2008

DATASET = fetch_miyawaki2008(verbose=0)

@memory.cache
def load_runs(n_runs: int):
    """Concatenate the first n_runs of BOLD and stack the matching stimulus."""
    runs = [BrainData(DATASET.func[i], mask=DATASET.mask) for i in range(n_runs)]
    bold = concatenate(runs)
    stim = np.vstack(
        [
            np.loadtxt(DATASET.label[i], delimiter=",", dtype=int)
            for i in range(n_runs)
        ]
    ).astype(float)
    stim[stim < 0] = 0.0  # rest frames → no-patch
    return bold, stim

bold, stim = load_runs(8)
print(f"bold: {bold.shape}  (TRs, voxels)   stim: {stim.shape}  (TRs, 100 patches)")
bold: (1040, 5438)  (TRs, voxels)   stim: (1040, 100)  (TRs, 100 patches)

Each on-frame is a 10×10 binary contrast figure — these 100 patches are our features:

import matplotlib.pyplot as plt

# Each stimulus is held on screen for several TRs, so show the first TR of
# four *different* stimuli rather than four copies of the same one.
on_frames = (stim.sum(axis=1) > 0).nonzero()[0]
first_seen = sorted(np.unique(stim[on_frames], axis=0, return_index=True)[1])
stim_fig, stim_axes = plt.subplots(1, 4, figsize=(9, 2.6))
for stim_ax, frame in zip(stim_axes, on_frames[first_seen[:4]]):
    stim_ax.imshow(stim[frame].reshape(10, 10), cmap="gray", vmin=0, vmax=1)
    stim_ax.set_title(f"TR {frame}")
    stim_ax.axis("off")
_ = stim_fig.suptitle("Stimulus frames (10×10 binary contrast)", y=1.05)
2026-09-13T00:32:08.290296 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ TR 10 TR 22 TR 34 TR 46 Stimulus frames (10×10 binary contrast)

Build the feature matrix (FIR lag bank)

For each of the 100 patches we add copies at lags of 1, 2, and 3 TRs, giving 100 × 3 = 300 features. The weights ridge learns across those lags are an estimated impulse response per voxel — no canonical HRF assumed. Going from 100 to 300 features for ~1000 samples is exactly why we need ridge.

def lag_features(X, lags):
    """Stack lagged copies of X horizontally; pad early TRs with zeros."""
    n_tr, n_patch = X.shape
    out = np.zeros((n_tr, n_patch * len(lags)))
    for j, lag in enumerate(lags):
        out[lag:, j * n_patch : (j + 1) * n_patch] = X[: n_tr - lag]
    return out

X_fir = lag_features(stim, [1, 2, 3])
print(f"X_fir: {X_fir.shape}  (3 lags × 100 patches = 300 features)")
X_fir: (1040, 300)  (3 lags × 100 patches = 300 features)

Fit ridge: in-sample vs. held-out

Standard encoding preprocessing: z-score each voxel explicitly, since fit never preprocesses the response and ridge fits no intercept. A fixed-α fit with no CV gives ridge_r2 — an in-sample R², which is optimistically biased.

# `fit` never preprocesses the response, so standardize explicitly: ridge
# fits no intercept, and a shared alpha should regularize voxels comparably.
# The standardized data gets its own name so every later cell that needs it
# depends on it by name rather than on `bold` having been mutated.
bold_z = bold.standardize(method="zscore")
bold_z.fit(model="ridge", X=X_fir, ridge_alpha=1.0)
in_sample = bold_z.ridge_r2.data.ravel()
print(f"in-sample R²  — mean {in_sample.mean():.3f}  max {in_sample.max():.3f}")
in-sample R²  — mean 0.141  max 0.572

The honest version holds out the last run entirely and fits on the other seven. A sequence of candidate alphas plus a ridge_cv sweeps the grid and picks the best α per voxel (ridge_per_target_alpha=True, the default) — high-SNR visual voxels want little regularization, noisier voxels want more. Scoring the fitted model on the untouched run gives a genuinely out-of-sample R². Both blocks are standardized on their own statistics — explicitly, since fit does no preprocessing — because ridge fits no intercept and a new run carries its own offset.

from sklearn.model_selection import KFold

ALPHAS = np.logspace(-1, 4, 20)
n_test = bold_z.shape[0] // 8  # last of the 8 concatenated runs
train, test = slice(0, -n_test), slice(-n_test, None)

trained = (
    bold_z[train]
    .standardize(method="zscore")
    .fit(
        model="ridge",
        X=X_fir[train],
        ridge_alpha=ALPHAS,
        ridge_cv=KFold(n_splits=5, shuffle=True, random_state=0),
        inplace=False,
    )
)
held_out = bold_z[test].standardize(method="zscore")
held_out_r2 = trained.model_.score(X_fir[test], held_out.data)
print(
    f"held-out R²  — median {np.median(held_out_r2):.3f}  "
    f"max {held_out_r2.max():.3f}"
)
print(
    f"voxels with held-out R² > 0.10: "
    f"{(held_out_r2 > 0.10).sum()} / {held_out_r2.size}"
)
held-out R²  — median -0.089  max 0.641
voxels with held-out R² > 0.10: 387 / 5438
held_out_map = bold_z.ridge_r2.copy()
# Most voxels do not track the stimulus at all, so their held-out R² is
# negative. Floor the map at zero and let the threshold hide the rest —
# a diverging map here would be a wall of colour with no signal in it.
held_out_map.data = np.clip(held_out_r2, 0, None).reshape(1, -1)
held_out_map.plot(
    method="slices",
    view="z",
    cut_coords=[[-12, -6, 0, 6, 12]],
    bg_img=DATASET.background,
    title="Ridge held-out R² (per-voxel α, FIR lags 1–3; R² > 0.05)",
    cmap="viridis",
    threshold=0.05,
)
2026-09-13T00:32:13.429228 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R z=-6 L R z=0 L R z=6 L R z=12 0 0.05 0.16 0.32 0.48 0.64 L R z=-12 Ridge held-out R² (per-voxel α, FIR lags 1–3; R² > 0.05)

Held-out R² is smaller than in-sample (a new run is harder) and concentrates in the visual cortex that actually tracks the stimulus. Finally, the spread of selected α confirms why per-voxel regularization matters — voxels disagree:

best_alpha = np.asarray(trained.model_.alpha_).ravel()
# One bin per grid value, edges at the midpoints between neighbouring alphas.
log_grid = np.log10(ALPHAS)
step = log_grid[1] - log_grid[0]
edges = np.concatenate([log_grid - step / 2, [log_grid[-1] + step / 2]])
alpha_fig, alpha_ax = plt.subplots(figsize=(7, 3))
alpha_ax.hist(np.log10(best_alpha), bins=edges, color="steelblue")
alpha_ax.set_xlabel(r"$\log_{10}(\alpha)$ selected per voxel")
alpha_ax.set_ylabel("voxel count")
_ = alpha_ax.set_title("Per-voxel ridge α — voxels disagree on regularization")
2026-09-13T00:32:13.613996 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ −1 0 1 2 3 4 l o g ( )   s e l e c t e d   p e r   v o x e l 1 0 α 0 200 400 600 800 1000 1200 voxel count Per-voxel ridge α — voxels disagree on regularization

Recap

Stage What it does Key API
Load runs Concatenate BOLD + stimulus across runs concatenate([...])
Features FIR lag bank (learn the HRF, don't assume it) lag_features(stim, [1, 2, 3])
In-sample fit Fixed-α ridge → optimistic R² bold_z.fit(model="ridge", X=, ridge_alpha=1.0)
Honest fit Per-voxel α via CV, scored on a held-out run bold_z[train].fit(model="ridge", X=, ridge_alpha=ALPHAS, ridge_cv=KFold(5), inplace=False)
Inspect Held-out R² map + selected α per voxel trained.model_.score(X_test, Y_test), trained.model_.alpha_

Next steps