Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

BrainCollection Basics

A BrainCollection is a parallel, memory-efficient iterator of BrainData — one BrainData per subject (or run). It saves you from writing for-loops over subjects: its API mirrors BrainData, but every per-subject operation (smooth, standardize, fit, ...) runs across all subjects in parallel, and group operations (mean, ttest, ...) reduce over subjects to a single BrainData map.

By default it is lazy and path-backed: subjects are loaded on demand and the results of parallel ops are streamed to a visible disk cache, so peak memory stays at roughly n_workers × 1 subject no matter how many subjects you have.

From a stack of images to a collection

To keep things self-contained we reuse the pain dataset from the BrainData tutorial: fetch_pain() returns a single BrainData of 84 images — 28 subjects × 3 stimulus-intensity conditions (low / medium / high) — with a metadata table in .X.

import polars as pl

from nltools.datasets import fetch_pain

stacked = fetch_pain()
stacked.X.select(["SubjectID", "PainIntensity", "Age", "Sex"]).head()
Loading...

That stacked BrainData mixes all 28 subjects together. To analyze per subject we split it into one BrainData per subject (each holding that subject’s 3 condition maps) and wrap the list in a BrainCollection.

This explicit constructor takes a list[BrainData | path], a shared mask, and a per-subject metadata table. For real datasets on disk you’d usually skip the manual grouping and use a classmethod constructor instead:

from nltools.data import BrainCollection

# One BrainData per subject (each = that subject's low/medium/high maps)
subject_ids = sorted(stacked.X["SubjectID"].unique().to_list())
per_subject = [
    stacked[[i for i, s in enumerate(stacked.X["SubjectID"]) if s == sid]]
    for sid in subject_ids
]

# One metadata row per subject (simple-typed columns only)
subject_meta = pl.DataFrame(
    [
        {
            "subject": f"sub-{sid:02d}",
            **stacked.X.filter(pl.col("SubjectID") == sid).row(0, named=True),
        }
        for sid in subject_ids
    ]
).select(["subject", "Age", "Sex"])

bc = BrainCollection(
    per_subject,
    mask=stacked.mask,
    metadata=subject_meta,
    cache_dir=None,  # ephemeral tempdir, auto-cleaned at exit
)
bc
BrainCollection(n_subjects=28, loaded=28/28)

The repr shows n_subjects and how many are currently loaded in memory (loaded=28/28 here, since we built it from in-memory BrainData; a collection built from paths starts cold at loaded=0/28).

Properties

BrainCollection exposes lightweight properties that don’t materialize the data.

# (n_subjects, images-per-subject, n_voxels). The middle dim is None when
# subjects have differing image counts.
bc.shape
(28, 3, 238955)
print(f"n_subjects: {bc.n_subjects}")
print(f"n_voxels:   {bc.n_voxels}")
print(f"loaded:     {sum(bc.is_loaded)}/{bc.n_subjects}")
print(f"memory:     {bc.memory_estimate()}")
# memory_estimate() reports the full in-RAM footprint if every subject were loaded
n_subjects: 28
n_voxels:   238955
loaded:     28/28
memory:     BrainCollection(n_subjects=28, per_item=3×238955, estimated_total≈76.6 MB)
# Per-subject metadata as a polars DataFrame
bc.metadata.head()
Loading...

Indexing and iteration

Indexing follows an intuitive rule: an integer or subject label returns a single BrainData; a slice, list, boolean mask, or polars expression returns a smaller BrainCollection (sharing the same cache root).

# Integer → the BrainData for that subject (3 condition maps)
bc[0]
nltools.data.braindata.BrainData(data=(3, 238955), resolution=2.0mm, space=mni, mask=2mm-MNI152-2009fsl-mask.nii.gz)
# Subject label → BrainData, looked up via metadata['subject']
bc["sub-05"].shape
(3, 238955)
# Slice → a sub-collection
bc[:5]
BrainCollection(n_subjects=5, loaded=5/5)
# polars expression on metadata → filtered sub-collection
bc[pl.col("Sex") == "Female"]
BrainCollection(n_subjects=10, loaded=10/10)
# Iterate to get each subject's BrainData
per_subject_means = [bd.mean().data.mean() for bd in bc]
print(
    f"grand mean of {len(per_subject_means)} subject means: "
    f"{sum(per_subject_means) / len(per_subject_means):.3f}"
)
grand mean of 28 subject means: 0.073

Per-subject operations (parallel)

Every per-subject method mirrors the same method on BrainData, but runs across all subjects in parallel and returns a new BrainCollection. They all accept n_jobs, progress_bar, and cache.

Here we smooth every subject at 6 mm FWHM (the default n_jobs=-1 uses every core).

smoothed = bc.smooth(fwhm=6)
smoothed
BrainCollection(n_subjects=28, loaded=28/28)

map applies an arbitrary BrainData → BrainData function per subject, and apply calls a named BrainData method on every subject — the escape hatches when there’s no dedicated wrapper. Here we reduce each subject to just their high-pain map (the 3rd image), giving a single-map-per-subject collection.

high_pain = bc.map(lambda bd: bd[2])  # 3rd condition = "high"
high_pain.shape  # (28, n_voxels) — one map per subject
(28, 238955, 238955)

The memory model: path-backed by default

The collection built above lives in memory because we constructed it from in-memory BrainData. Constructors like from_bids instead return a lazy collection whose subjects are on disk, and parallel ops write their results through to a visible cache (./.nltools_cache/{run_id}/) rather than accumulating everything in RAM.

The cache= knob on every parallel op controls this:

cache=Behavior
'auto' (default)Follow the source: loaded → in-memory, path-backed → write through
TrueForce a disk write (path-backed result)
FalseForce an in-memory result

bc.load() / bc.unload() move subjects between disk and RAM; bc.steps() lists the cache sub-directories produced so far; bc.cleanup() removes the whole cache root for this collection and its derived clones. Let’s force one op to disk and inspect the trail.

cached = bc.smooth(fwhm=6, cache=True)
print(f"result loaded in RAM? {any(cached.is_loaded)}")
print("cache steps:")
for step in cached.steps():
    print(f"  {step.name}")
result loaded in RAM? False
cache steps:
  20260902T004002_000000000_8601a858_smooth_fwhm-6
# A path-backed collection materializes on demand; load() pulls it into RAM
cached.load()
print(f"after load(): {sum(cached.is_loaded)}/{cached.n_subjects} in RAM")
after load(): 28/28 in RAM

Group reductions (collection → BrainData)

Reductions collapse the subject axis and return a single in-memory BrainData (or a dict of them). They stream from disk when the source is path-backed, so they stay memory-light.

mean() over our full collection averages each condition across subjects, giving a (3, n_voxels) map — one mean image per condition. We plot the mean high-pain map.

group_mean = bc.mean()  # (3, n_voxels): mean low / medium / high across subjects
group_mean[2].plot(title="Mean high-pain activation (n=28)")
<Figure size 950x350 with 6 Axes>

One-sample group t-test

With one map per subject (our high_pain collection), ttest() runs a voxel-wise one-sample t-test across subjects — the classic second-level analysis. It returns a dict of BrainData maps (mean, t, z, p), mirroring BrainData.ttest().

group = high_pain.ttest()
print("returned maps:", list(group.keys()))
group["t"].plot(title="High-pain one-sample t (n=28)")
returned maps: ['mean', 't', 'z', 'p']
<Figure size 950x350 with 6 Axes>
# Threshold the t-map for display (|z| > 2)
group["z"].threshold(lower=-2, upper=2, binarize=False).plot(
    title="High-pain z, thresholded |z| > 2"
)
<Figure size 950x350 with 6 Axes>

Where to go next

This tour covered the data structure itself — construction, indexing, per-subject parallel ops, the path-backed cache, and group reductions. The full analysis workflows that BrainCollection powers live in the Workflows tutorials:

When you’re done with a real (disk-backed) collection, call bc.cleanup() to remove its cache root.