Engineering Notes · Internals

Inside nltools

A neuroimaging analysis toolbox built on one idea: classes are thin facades — all real work lives in pure functions. Here's how the four data classes, the functional core, and the CPU/GPU algorithm substrate fit together.

0Data classes
0Algorithm families
0Compute backends
0Organizing rule

01Architectural shape

nltools is a functional core, imperative shell. Three layers, one dependency direction: the shell delegates down into the core and the algorithm substrate — logic never flows back up into a class. Hover any layer to inspect what lives there:

Layer 1 · imperative shell
Data-class facades
BrainData Adjacency DesignMatrix BrainCollection
validate · delegate · assign — each class is a facade over a submodule package
Layer 2 · functional core
Pure functions
stats utils cross_validation mask
containers in, containers out — no hidden state, no class reach-back
Layer 3 · algorithm substrate
Heavy numerics
alignment inference ridge backends
SRM · hyperalignment · permutation/bootstrap · GPU ridge — CPU/GPU behind one Backend
imperative shell functional core algorithm substrate
The dependency rule. A facade may validate arguments and call one core/algorithm function, then stow the result. If a method starts branching on model type or orchestrating multiple phases, that logic belongs in a pure function, not the class.

02The four facades

Each data class is a facade over a package of submodules — io, modeling, plotting, and so on. The class holds state and delegates; the submodules hold the work. Hover a card to light up its submodules:

BrainData

Voxelwise brain images + analysis.

ioanalysismodelingpredictionbootstrapneighborhoodscacheplottingviewervalidation

Adjacency

Similarity / distance matrices.

iomodelingstatsspatialplotting

DesignMatrix

GLM design construction.

appendtransformsregressorsdiagnosticsioplotting

BrainCollection

Parallel iterator of BrainData.

coreexecutioninferenceiopipeline
Same surface, one level up. BrainCollection mirrors BrainData's API but runs it across subjects in parallel — so you never write a for-loop over subjects, and first-level results concatenate without picking a contrast inside the loop.

03One vocabulary

All four facades share one kwarg vocabulary (v0.6.0). Learn it once; it means the same thing on every class. Internal algorithm layers may keep legacy names — the facade translates at the boundary.

ConceptCanonical kwargNotes
Algorithm / variant choicemethodnot algorithm/scheme/kind/estimator
Spatial scalespatial_scale'whole_brain' · 'roi' · 'searchlight'
Distance / similarity metricmetrickept separate from method
Central tendencysummary'mean' · 'median' — never metric
Cross-validation speccvint folds · 'loo' · 'logo' (+ groups=) · sklearn splitter — never 'loso'/'loro'
Subject-level parallelismn_jobsdefault -1
GPU / CPU selectiondeviceorthogonal to n_jobs — explicit 'gpu' runs or raises
Backend (ridge/alignment internals)parallelNone · 'cpu' · 'gpu' — inference uses device
Progress indicatorprogress_barverbose reserved for log-level
Permutation countn_permutedistinct from…
Bootstrap sample countn_samples…the bootstrap count
Tail of testtail2/'two' default · 1/'one' = the test's positive direction
Threshold pairlower, upper, binarizeconvenience threshold where bidirectional
Diagonal flaginclude_diagnot ignore_diagonal — inverted sense
Radius (mm)radius_mmunits in the name
Four deliberate exceptions. fit(model='glm'|'ridge') selects an estimator class, not a variant. Ridge(n_iter=) matches sklearn's random-search name. compute_contrasts(statistic=) selects an output map (t/z/p/β), not an algorithm. GLM contrast p-maps are one-sided (the nilearn/SPM directional-contrast convention) and take no tail= knob — the one deliberate exception to the two-tailed default. Each is documented, not an oversight.

04Execution model

BrainCollection is memory-efficient by default. After a parallel op, workers write each subject's result to a visible disk cache and return a path-backed collection — peak RAM stays at roughly n_workers × 1 subject. The cache= knob controls this. Try it:

Fit bundles

fit(model='glm') writes one self-contained HDF5 bundle per subject. Residuals are always included, and the mask is embedded as bytes — so a bundle survives mv, cp, and cross-machine transfer, and any later contrast (incl. t/z/p) is available without re-fitting:

# {step_dir}/sub-XXXX_fit.h5
├── /betas       (n_regressors, n_voxels)
├── /residuals   (n_obs, n_voxels)   — always saved
├── /X           (n_obs, n_regressors)
├── /mask        (embedded NIfTI bytes — portable)
└── attrs: affine · regressor_names · nltools_version
         · bundle_schema_version · step_id · parent_step_id
Reductions stay small. mean, ttest, concat, isc stream over path-backed inputs and return a small in-memory BrainData (or dict) — they never path-back their own output.

05The algorithm substrate

Three families of heavy numerics live under nltools/algorithms/, each with its own parallel story — all reachable from the facades through the shared vocabulary.

alignment

SRM, hyperalignment, and searchlight/ROI LocalAlignment across subjects.

σ

inference

Permutation & bootstrap testing with deterministic, cross-backend RNG.

ridge

GPU-accelerated ridge/banded ridge — SVD reuse, batching, per-target alphas.

Ridge: six tricks behind the speed

SVD reuse
Generator
2-D batching
Y_in_cpu
Per-target α
Resolution matrix

Solve once, use forever

Decompose X once; every alpha is then just shrinkage = s / (s² + α) — arithmetic, no re-inversion. A 1000-alpha grid costs one SVD.

Process and forget

_decompose_ridge is a generator: compute one alpha batch, yield, del. Only one batch is ever in RAM.

Divide and conquer

Batch over both targets (voxels) and alphas, so a 60 GB-naive problem runs in chunks of a few hundred MB.

Smart shuttle

Y_in_cpu=True (default): Y stays in RAM; only the current target batch visits the GPU. ~10% slower, prevents OOM entirely.

Per-voxel α, bulk cost

Per-target alphas need only n_unique_alphas SVDs (~10), not one per voxel — group voxels by their selected alpha and solve once each.

Separate X from Y

Precompute (XᵀX + αI)⁻¹ Xᵀ (X-dependent, expensive); applying it to any target is a cheap matmul, vectorized over alphas and targets.

One backend, three devices

The algorithm layer selects compute with parallel=. A single class Backend hides NumPy vs PyTorch (CUDA / Apple MPS / CPU) — pick one:

06A workflow, end-to-end

A first-level → group GLM shows how path-backed collections flow through the layers. Hit play to step through:

idle
① construct
BrainCollection.from_bids(root)
lazy, path-backed collection
② fit
bc.fit(model='glm')
per-subject sub-XXXX_fit.h5 bundles
③ contrasts
.compute_contrasts('A - B')
per-subject contrast NIfTIs (path-backed)
④ group
.ttest()
BrainData {mean, t, z, p} — in memory
Note what's absent: no contrast is picked inside a loop, and nothing accumulates in RAM. Because residuals live in every bundle, you can compute a different contrast later without re-fitting. The group reduction streams and lands a small map.

07The facade rule

Facade methods are intentionally boring. A method may validate user arguments, delegate to one core/algorithm function, and assign the result. The moment it branches on model type or orchestrates phases, that logic moves into a pure function.

good facade method

def distance(self, *, metric="correlation",
             spatial_scale="whole_brain"):
    validate_metric(metric)
    result = compute_distance(     # one core call
        self.data, metric=metric,
        spatial_scale=spatial_scale,
    )
    return Adjacency(result, matrix_type="distance")

validate · delegate · assign.

bad facade method

def distance(self, **kwargs):
    if kwargs["metric"] == "correlation":
        d = 1 - np.corrcoef(self.data)
    elif kwargs["metric"] == "euclidean":
        # … 30 more lines of branching + math …
    self._cache = _assemble(...)
    return d

**kwargs, math + branching in the class — belongs in stats.

08Why this works

🧪

Testable in isolation

Core functions take plain arrays/containers. Construct inputs, call them — no class, no global state.

💾

Memory-efficient by default

Path-backed collections keep peak RAM at n_workers × 1 subject — 100-subject studies on a laptop.

One codebase, CPU + GPU

A single Backend hides NumPy vs PyTorch (CUDA / MPS / CPU). Domain code never imports torch.

🎲

Reproducible inference

Per-permutation seeds are pre-generated, so results are identical across sequential, CPU-parallel, and GPU runs.

♻︎

Refactor-safe vocabulary

One canonical kwarg set across four facades. Renames are mechanical; the surface is predictable.

🎯

Intentional scope

Facades stay thin on purpose. Complexity is spent in the core, where it can be tested and reused.

The takeaway. Thin facades + pure functions + a backend protocol is a small set of primitives. Most of nltools' "design" is just these rules applied consistently — and the rest of the codebase falls out of them.