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.
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:
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:
Voxelwise brain images + analysis.
Similarity / distance matrices.
GLM design construction.
Parallel iterator of BrainData.
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.
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.
| Concept | Canonical kwarg | Notes |
|---|---|---|
| Algorithm / variant choice | method | not algorithm/scheme/kind/estimator |
| Spatial scale | spatial_scale | 'whole_brain' · 'roi' · 'searchlight' |
| Distance / similarity metric | metric | kept separate from method |
| Central tendency | summary | 'mean' · 'median' — never metric |
| Cross-validation spec | cv | int folds · 'loo' · 'logo' (+ groups=) · sklearn splitter — never 'loso'/'loro' |
| Subject-level parallelism | n_jobs | default -1 |
| GPU / CPU selection | device | orthogonal to n_jobs — explicit 'gpu' runs or raises |
| Backend (ridge/alignment internals) | parallel | None · 'cpu' · 'gpu' — inference uses device |
| Progress indicator | progress_bar | verbose reserved for log-level |
| Permutation count | n_permute | distinct from… |
| Bootstrap sample count | n_samples | …the bootstrap count |
| Tail of test | tail | 2/'two' default · 1/'one' = the test's positive direction |
| Threshold pair | lower, upper, binarize | convenience threshold where bidirectional |
| Diagonal flag | include_diag | not ignore_diagonal — inverted sense |
| Radius (mm) | radius_mm | units in the name |
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.
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(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
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.
Three families of heavy numerics live under nltools/algorithms/, each with its
own parallel story — all reachable from the facades through the shared vocabulary.
SRM, hyperalignment, and searchlight/ROI LocalAlignment across subjects.
Permutation & bootstrap testing with deterministic, cross-backend RNG.
GPU-accelerated ridge/banded ridge — SVD reuse, batching, per-target alphas.
Decompose X once; every alpha is then just shrinkage = s / (s² + α) — arithmetic, no re-inversion. A 1000-alpha grid costs one SVD.
_decompose_ridge is a generator: compute one alpha batch, yield, del. Only one batch is ever in RAM.
Batch over both targets (voxels) and alphas, so a 60 GB-naive problem runs in chunks of a few hundred MB.
Y_in_cpu=True (default): Y stays in RAM; only the current target batch visits the GPU. ~10% slower, prevents OOM entirely.
Per-target alphas need only n_unique_alphas SVDs (~10), not one per voxel — group voxels by their selected alpha and solve once each.
Precompute (XᵀX + αI)⁻¹ Xᵀ (X-dependent, expensive); applying it to any target is a cheap matmul, vectorized over alphas and targets.
The algorithm layer selects compute with parallel=. A single class Backend hides NumPy vs PyTorch (CUDA / Apple MPS / CPU) — pick one:
A first-level → group GLM shows how path-backed collections flow through the layers. Hit play to step through:
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.
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.
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.
Core functions take plain arrays/containers. Construct inputs, call them — no class, no global state.
Path-backed collections keep peak RAM at n_workers × 1 subject — 100-subject studies on a laptop.
A single Backend hides NumPy vs PyTorch (CUDA / MPS / CPU). Domain code never imports torch.
Per-permutation seeds are pre-generated, so results are identical across sequential, CPU-parallel, and GPU runs.
One canonical kwarg set across four facades. Renames are mechanical; the surface is predictable.
Facades stay thin on purpose. Complexity is spent in the core, where it can be tested and reused.