Effect of standardization on decoding performance

Different voxels have different baselines and variances, so the standardize parameter of nilearn maskers rescales each voxel’s time series before it reaches the estimator:

  • "zscore_sample" shifts the signal to zero mean and scales it to unit variance,

  • "psc" scales it to percent signal change relative to its mean,

  • None leaves the signal as acquired.

This choice matters because estimators such as SVMs and other regularized linear models are sensitive to feature scale. The scikit-learn section on standardization explains why a high-variance feature can dominate the objective function.

Note that the defaults differ across estimators: NiftiMasker leaves the data untouched (standardize=None), while Decoder standardizes by default (standardize="zscore_sample"). The code below runs the same decoding pipeline with each setting and compares the scores.

Aside from standardize, the data processing and Decoder options mirror those of the Different classifiers in decoding the Haxby dataset example, so the two can be read side by side.

Load the Haxby data

import numpy as np
import pandas as pd

from nilearn import datasets
from nilearn.image import index_img

# by default 2nd subject data will be fetched
haxby_dataset = datasets.fetch_haxby()

fmri_filename = haxby_dataset.func[0]
mask_filename = haxby_dataset.mask_vt[0]

labels = pd.read_csv(haxby_dataset.session_target[0], sep=" ")
stimuli = labels["labels"]

# remove resting-state runs from the data and the target
task_mask = stimuli != "rest"
run_labels = labels["chunks"][task_mask]

fmri_niimgs = index_img(fmri_filename, task_mask)
classification_target = stimuli[task_mask]
[fetch_haxby] Dataset directory found: /home/runner/work/nilearn/nilearn/nilearn_data/haxby2001

Why do the defaults differ?

NiftiMasker is a low-level building block: it only extracts voxels from the mask and leaves any rescaling decision to the user, so it defaults to standardize=None. Decoder is an end-to-end estimator whose regularized classifier benefits from rescaled features, so it applies standardize="zscore_sample" out of the box. Rather than hard-coding them, the code below reads both defaults from the signatures:

import inspect

from nilearn.decoding import Decoder
from nilearn.maskers import NiftiMasker

print(
    "NiftiMasker default:",
    inspect.signature(NiftiMasker).parameters["standardize"].default,
)
print(
    "Decoder default:",
    inspect.signature(Decoder).parameters["standardize"].default,
)
NiftiMasker default: None
Decoder default: zscore_sample

Decoding with each standardization setting

from sklearn.model_selection import LeaveOneGroupOut

cv = LeaveOneGroupOut()

scores = {}
for standardize in (None, "zscore_sample", "psc"):
    # everything but "standardize" is held fixed
    decoder = Decoder(
        estimator="svc",
        mask=mask_filename,
        cv=cv,
        standardize=standardize,
        screening_percentile=100,
        n_jobs=-1,
        verbose=1,
    )
    decoder.fit(
        fmri_niimgs,
        classification_target,
        groups=run_labels,
    )
    # Decoder stores one cross-validated roc_auc score per category
    scores[standardize] = np.mean(
        list(decoder.cv_scores_.values()), axis=0
    ).mean()
[Decoder.fit] The decoding model will be trained on 464 features.
[Parallel(n_jobs=-1)]: Using backend LokyBackend with 4 concurrent workers.
[Parallel(n_jobs=-1)]: Done  33 tasks      | elapsed:    4.0s
[Parallel(n_jobs=-1)]: Done  96 out of  96 | elapsed:    7.2s finished
[Decoder.fit] The decoding model will be trained on 464 features.
[Parallel(n_jobs=-1)]: Using backend LokyBackend with 4 concurrent workers.
[Parallel(n_jobs=-1)]: Done  33 tasks      | elapsed:  1.2min
[Parallel(n_jobs=-1)]: Done  96 out of  96 | elapsed:  3.0min finished
[Decoder.fit] The decoding model will be trained on 464 features.
[Parallel(n_jobs=-1)]: Using backend LokyBackend with 4 concurrent workers.
[Parallel(n_jobs=-1)]: Done  33 tasks      | elapsed:  1.0min
[Parallel(n_jobs=-1)]: Done  96 out of  96 | elapsed:  2.9min finished

Compare the scores

import matplotlib.pyplot as plt

from nilearn.plotting import show

setting_labels = {
    None: "standardize=None",
    "zscore_sample": 'standardize="zscore_sample"',
    "psc": 'standardize="psc"',
}

plt.figure(figsize=(7, 4))
plt.bar(
    [setting_labels[s] for s in scores],
    list(scores.values()),
    color=["C0", "C1", "C2"],
)
plt.ylim(0.5, 1.0)
plt.ylabel("cross-validated ROC AUC")
plt.title("Effect of standardization on decoding accuracy")
plt.xticks(rotation=10, ha="right")
plt.tight_layout()

show()
Effect of standardization on decoding accuracy

What does this tell us?

The setting has a real effect: on this dataset the unstandardized pipeline scores highest, followed by "zscore_sample" and then "psc". This is one dataset and one classifier, so the ranking should not be generalized to other data. What does generalize is that the choice matters — and that "zscore_sample" remains a good default even though it does not translate into higher accuracy here: it puts every voxel on a common zero-mean, unit-variance scale, so no voxel can dominate the estimator purely because of its raw amplitude.

Total running time of the script: (6 minutes 6.736 seconds)

Estimated memory usage: 1049 MB

Gallery generated by Sphinx-Gallery