Skip to content

Decomposition

Open in molab

Run this tutorial

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

Decomposition looks for structure with no labels at all: factor a dataset into a small number of components and see what they turn out to be. It is the tool for "what is in this data" rather than "does this data predict that".

BrainData.decompose wraps scikit-learn's decompositions, so the choice of algorithm is one keyword. Here it runs on the pain dataset — 28 subjects at three intensities — and the components turn out to track pain.

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
from joblib import Memory

from nltools.datasets import fetch_pain
from nltools import concatenate

memory = Memory(".tutorial-cache", verbose=0)

data = memory.cache(fetch_pain)()
data
nltools.data.braindata.BrainData(data=(84, 238955), resolution=2.0mm, space=mni, mask=2mm-MNI152-2009fsl-mask.nii.gz)

Center within subject

Every subject contributes three images, and subjects differ from each other far more than intensities differ within a subject. Left alone, the first components would describe who each image belongs to. Subtracting each subject's own mean removes that, leaving the part of each image that is about the intensity.

centered = concatenate(
    [
        data[data.X["SubjectID"] == subject].standardize()
        for subject in data.X["SubjectID"].unique(maintain_order=True)
    ]
)
centered
nltools.data.braindata.BrainData(data=(84, 238955), resolution=2.0mm, space=mni, mask=2mm-MNI152-2009fsl-mask.nii.gz)

Factor the data

method selects the algorithm: 'pca', 'ica', 'nnmf', 'fa', 'dictionary' or 'kernelpca'. axis decides which way round the factorization runs. axis='images' treats voxels as observations and images as features, so each component is a brain map and each image gets a loading on it. axis='voxels' does the opposite, which is what you want for resting-state networks over a timeseries.

The result carries decomposition_object (the fitted scikit-learn estimator, with its parameters and explained variance), components (a BrainData of one map per component), and weights (an images-by-components array).

N_COMPONENTS = 5

factors = centered.decompose(method="fa", axis="images", n_components=N_COMPONENTS)

print(sorted(factors))
print(f"components: {factors['components'].shape}")
print(f"weights:    {factors['weights'].shape}")
['components', 'decomposition_object', 'weights']
components: (5, 238955)
weights:    (84, 5)

The weight matrix says how much each image loads on each component, and the component maps say which voxels each one is made of.

weight_figure, weight_axis = plt.subplots(figsize=(5, 8))
sns.heatmap(factors["weights"], ax=weight_axis, center=0, cmap="RdBu_r")
weight_axis.set_xlabel("component")
weight_axis.set_ylabel("image")
weight_axis.set_title("Image loadings")
weight_figure.tight_layout()

# Bound rather than left as the cell's last expression, so the page gets the
# figures and not the list of Figure objects that `plot` returns.
component_figures = factors["components"].plot(limit=N_COMPONENTS)
2026-09-13T00:41:23.775543 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ 0 1 2 3 4 component 0 2 4 6 8 10 12 14 16 18 20 22 24 26 28 30 32 34 36 38 40 42 44 46 48 50 52 54 56 58 60 62 64 66 68 70 72 74 76 78 80 82 image Image loadings −0.10 −0.05 0.00 0.05 0.10 0.15
2026-09-13T00:41:23.909812 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -5 -2.5 -0.31 0.31 2.5 5 image 0
2026-09-13T00:41:24.073480 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -5.4 -2.7 -0.25 0.25 2.7 5.4 image 1
2026-09-13T00:41:24.236012 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -4.5 -2.2 -0.23 0.23 2.2 4.5 image 2
2026-09-13T00:41:24.399727 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -5.1 -2.6 -0.19 0.19 2.6 5.1 image 3
2026-09-13T00:41:24.562005 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -6.7 -3.3 -0.25 0.25 3.3 6.7 image 4

Do any components track pain?

Nothing about the decomposition knew the intensities, so this is a real test. Put the loadings beside each image's intensity and average within level.

loadings = pd.DataFrame(
    factors["weights"],
    columns=[str(index) for index in range(N_COMPONENTS)],
)
loadings["intensity"] = centered.X["PainIntensity"].to_list()

long_loadings = loadings.melt(
    id_vars="intensity", var_name="component", value_name="weight"
)

grid = sns.catplot(
    data=long_loadings,
    x="intensity",
    y="weight",
    hue="component",
    order=["low", "medium", "high"],
    kind="point",
    aspect=1.5,
)
grid.set_axis_labels("pain intensity", "component loading")
grid.figure
2026-09-13T00:41:24.955711 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ low medium high pain intensity −0.075 −0.050 −0.025 0.000 0.025 0.050 0.075 0.100 component loading component 0 1 2 3 4
level = centered.X["PainLevel"].to_numpy()
for component in range(N_COMPONENTS):
    correlation = np.corrcoef(loadings[str(component)].to_numpy(), level)[0, 1]
    print(f"component {component}: r with intensity {correlation:+.2f}")
component 0: r with intensity +0.83
component 1: r with intensity -0.06
component 2: r with intensity -0.00
component 3: r with intensity +0.13
component 4: r with intensity -0.12

One component rises monotonically across the three levels and correlates strongly with intensity; the rest are flat. Nothing in the factorization was told that the intensities existed, so a component that orders them recovered real structure. Its map, the first in the panel above, is the one worth inspecting.

Inspecting components interactively

component_viewer puts the pieces of one component side by side — its brain map at an adjustable threshold, its loading across images, and the power spectrum of that loading — with sliders for the component index and the threshold. It needs ipywidgets and a live kernel, so it is shown here rather than run:

from nltools.plotting import component_viewer

component_viewer(factors, tr=2.0)

Recap

Step Call
Remove between-subject differences data[...].standardize() per subject, then concatenate
Factor the data data.decompose(method=, axis=, n_components=)
Component brain maps result["components"] — a BrainData
Image-by-component loadings result["weights"]
The fitted estimator result["decomposition_object"]
Interactive inspection component_viewer(result, tr=)

Next steps