Group-level cluster permutation testing with formula contrasts#

Run a cluster-based permutation test on evoked data from several subjects, specifying the contrast with a Wilkinson (R-style) formula. By the end you will have run a paired t-test across subjects and inspected the cluster permutation results.

You will:

  • load evoked data from multiple subjects

  • build a long-format dataframe with one row per subject and condition

  • run mne.stats.cluster_test() with a Wilkinson-notation formula

  • inspect the cluster permutation results

# Author: Carina Forster <carinaforster0611@gmail.com>
#
# License: BSD-3-Clause
# Copyright the MNE-Python contributors.

Load the required packages#

from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

import mne

Load evoked data for multiple subjects#

We use the P3b data from the freely available ERP CORE dataset: a visual oddball task contrasting rare target stimuli with frequent non-target stimuli. Each subject has an evoked response for both conditions; five subjects are included here.

# path to the P3 dataset
path_to_p3 = mne.datasets.misc.data_path() / "ERP_CORE" / "P3"

# participant IDs available in this dataset (15 to 19)
participant_ids = range(15, 20)

# load each subject's evoked data into a list
evokeds_allsubs = []
for pid in participant_ids:
    # filename with the ID zero-padded to three digits
    filename_p3 = f"sub-{pid:03d}_ses-P3_task-P3_ave.fif"
    p3_file_path = Path(path_to_p3) / filename_p3
    evokeds = mne.read_evokeds(p3_file_path)
    evokeds_allsubs.append(evokeds)

# split the two conditions into separate per-subject lists
target_only = [evoked[0] for evoked in evokeds_allsubs]
non_target_only = [evoked[1] for evoked in evokeds_allsubs]
Reading /home/circleci/mne_data/MNE-misc-data/ERP_CORE/P3/sub-015_ses-P3_task-P3_ave.fif ...
    Found the data of interest:
        t =    -203.12 ...     796.88 ms (0.20 × stimulus/target/11 + 0.20 × stimulus/target/22 + 0.20 × stimulus/target/33 + 0.20 × stimulus/target/44 + 0.20 × stimulus/target/55)
        0 CTF compensation matrices available
        nave = 40 - aspect type = 100
No projector specified for this dataset. Please consider the method self.add_proj.
Loaded Evoked data is baseline-corrected (baseline: [-0.203125, 0] s)
    Found the data of interest:
        t =    -203.12 ...     796.88 ms (0.06 × stimulus/non-target/12 + 0.04 × stimulus/non-target/13 + 0.05 × stimulus/non-target/14 + 0.06 × stimulus/non-target/15 + 0.04 × stimulus/non-target/21 + 0.04 × stimulus/non-target/23 + 0.06 × stimulus/non-target/24 + 0.06 × stimulus/non-target/25 + 0.07 × stimulus/non-target/31 + 0.08 × stimulus/non-target/32 + 0.02 × stimulus/non-target/34 + 0.03 × stimulus/non-target/35 + 0.03 × stimulus/non-target/41 + 0.07 × stimulus/non-target/42 + 0.06 × stimulus/non-target/43 + 0.04 × stimulus/non-target/45 + 0.03 × stimulus/non-target/51 + 0.05 × stimulus/non-target/52 + 0.06 × stimulus/non-target/53 + 0.06 × stimulus/non-target/54)
        0 CTF compensation matrices available
        nave = 160 - aspect type = 100
No projector specified for this dataset. Please consider the method self.add_proj.
Loaded Evoked data is baseline-corrected (baseline: [-0.203125, 0] s)
    Found the data of interest:
        t =    -203.12 ...     796.88 ms ((0.20 × stimulus/target/11 + 0.20 × stimulus/target/22 + 0.20 × stimulus/target/33 + 0.20 × stimulus/target/44 + 0.20 × stimulus/target/55) - (0.06 × stimulus/non-target/12 + 0.04 × stimulus/non-target/13 + 0.05 × stimulus/non-target/14 + 0.06 × stimulus/non-target/15 + 0.04 × stimulus/non-target/21 + 0.04 × stimulus/non-target/23 + 0.06 × stimulus/non-target/24 + 0.06 × stimulus/non-target/25 + 0.07 × stimulus/non-target/31 + 0.08 × stimulus/non-target/32 + 0.02 × stimulus/non-target/34 + 0.03 × stimulus/non-target/35 + 0.03 × stimulus/non-target/41 + 0.07 × stimulus/non-target/42 + 0.06 × stimulus/non-target/43 + 0.04 × stimulus/non-target/45 + 0.03 × stimulus/non-target/51 + 0.05 × stimulus/non-target/52 + 0.06 × stimulus/non-target/53 + 0.06 × stimulus/non-target/54))
        0 CTF compensation matrices available
        nave = 32 - aspect type = 100
No projector specified for this dataset. Please consider the method self.add_proj.
Loaded Evoked data is baseline-corrected (baseline: [-0.203125, 0] s)
Reading /home/circleci/mne_data/MNE-misc-data/ERP_CORE/P3/sub-016_ses-P3_task-P3_ave.fif ...
    Found the data of interest:
        t =    -203.12 ...     796.88 ms (0.20 × stimulus/target/11 + 0.20 × stimulus/target/22 + 0.20 × stimulus/target/33 + 0.20 × stimulus/target/44 + 0.20 × stimulus/target/55)
        0 CTF compensation matrices available
        nave = 40 - aspect type = 100
No projector specified for this dataset. Please consider the method self.add_proj.
Loaded Evoked data is baseline-corrected (baseline: [-0.203125, 0] s)
    Found the data of interest:
        t =    -203.12 ...     796.88 ms (0.04 × stimulus/non-target/12 + 0.06 × stimulus/non-target/13 + 0.05 × stimulus/non-target/14 + 0.05 × stimulus/non-target/15 + 0.06 × stimulus/non-target/21 + 0.06 × stimulus/non-target/23 + 0.04 × stimulus/non-target/24 + 0.05 × stimulus/non-target/25 + 0.03 × stimulus/non-target/31 + 0.04 × stimulus/non-target/32 + 0.07 × stimulus/non-target/34 + 0.06 × stimulus/non-target/35 + 0.06 × stimulus/non-target/41 + 0.07 × stimulus/non-target/42 + 0.03 × stimulus/non-target/43 + 0.04 × stimulus/non-target/45 + 0.04 × stimulus/non-target/51 + 0.06 × stimulus/non-target/52 + 0.05 × stimulus/non-target/53 + 0.05 × stimulus/non-target/54)
        0 CTF compensation matrices available
        nave = 160 - aspect type = 100
No projector specified for this dataset. Please consider the method self.add_proj.
Loaded Evoked data is baseline-corrected (baseline: [-0.203125, 0] s)
    Found the data of interest:
        t =    -203.12 ...     796.88 ms ((0.20 × stimulus/target/11 + 0.20 × stimulus/target/22 + 0.20 × stimulus/target/33 + 0.20 × stimulus/target/44 + 0.20 × stimulus/target/55) - (0.04 × stimulus/non-target/12 + 0.06 × stimulus/non-target/13 + 0.05 × stimulus/non-target/14 + 0.05 × stimulus/non-target/15 + 0.06 × stimulus/non-target/21 + 0.06 × stimulus/non-target/23 + 0.04 × stimulus/non-target/24 + 0.05 × stimulus/non-target/25 + 0.03 × stimulus/non-target/31 + 0.04 × stimulus/non-target/32 + 0.07 × stimulus/non-target/34 + 0.06 × stimulus/non-target/35 + 0.06 × stimulus/non-target/41 + 0.07 × stimulus/non-target/42 + 0.03 × stimulus/non-target/43 + 0.04 × stimulus/non-target/45 + 0.04 × stimulus/non-target/51 + 0.06 × stimulus/non-target/52 + 0.05 × stimulus/non-target/53 + 0.05 × stimulus/non-target/54))
        0 CTF compensation matrices available
        nave = 32 - aspect type = 100
No projector specified for this dataset. Please consider the method self.add_proj.
Loaded Evoked data is baseline-corrected (baseline: [-0.203125, 0] s)
Reading /home/circleci/mne_data/MNE-misc-data/ERP_CORE/P3/sub-017_ses-P3_task-P3_ave.fif ...
    Found the data of interest:
        t =    -203.12 ...     796.88 ms (0.20 × stimulus/target/11 + 0.20 × stimulus/target/22 + 0.20 × stimulus/target/33 + 0.20 × stimulus/target/44 + 0.20 × stimulus/target/55)
        0 CTF compensation matrices available
        nave = 40 - aspect type = 100
No projector specified for this dataset. Please consider the method self.add_proj.
Loaded Evoked data is baseline-corrected (baseline: [-0.203125, 0] s)
    Found the data of interest:
        t =    -203.12 ...     796.88 ms (0.07 × stimulus/non-target/12 + 0.04 × stimulus/non-target/13 + 0.06 × stimulus/non-target/14 + 0.03 × stimulus/non-target/15 + 0.04 × stimulus/non-target/21 + 0.05 × stimulus/non-target/23 + 0.06 × stimulus/non-target/24 + 0.05 × stimulus/non-target/25 + 0.07 × stimulus/non-target/31 + 0.04 × stimulus/non-target/32 + 0.03 × stimulus/non-target/34 + 0.06 × stimulus/non-target/35 + 0.05 × stimulus/non-target/41 + 0.06 × stimulus/non-target/42 + 0.05 × stimulus/non-target/43 + 0.04 × stimulus/non-target/45 + 0.03 × stimulus/non-target/51 + 0.05 × stimulus/non-target/52 + 0.07 × stimulus/non-target/53 + 0.05 × stimulus/non-target/54)
        0 CTF compensation matrices available
        nave = 160 - aspect type = 100
No projector specified for this dataset. Please consider the method self.add_proj.
Loaded Evoked data is baseline-corrected (baseline: [-0.203125, 0] s)
    Found the data of interest:
        t =    -203.12 ...     796.88 ms ((0.20 × stimulus/target/11 + 0.20 × stimulus/target/22 + 0.20 × stimulus/target/33 + 0.20 × stimulus/target/44 + 0.20 × stimulus/target/55) - (0.07 × stimulus/non-target/12 + 0.04 × stimulus/non-target/13 + 0.06 × stimulus/non-target/14 + 0.03 × stimulus/non-target/15 + 0.04 × stimulus/non-target/21 + 0.05 × stimulus/non-target/23 + 0.06 × stimulus/non-target/24 + 0.05 × stimulus/non-target/25 + 0.07 × stimulus/non-target/31 + 0.04 × stimulus/non-target/32 + 0.03 × stimulus/non-target/34 + 0.06 × stimulus/non-target/35 + 0.05 × stimulus/non-target/41 + 0.06 × stimulus/non-target/42 + 0.05 × stimulus/non-target/43 + 0.04 × stimulus/non-target/45 + 0.03 × stimulus/non-target/51 + 0.05 × stimulus/non-target/52 + 0.07 × stimulus/non-target/53 + 0.05 × stimulus/non-target/54))
        0 CTF compensation matrices available
        nave = 32 - aspect type = 100
No projector specified for this dataset. Please consider the method self.add_proj.
Loaded Evoked data is baseline-corrected (baseline: [-0.203125, 0] s)
Reading /home/circleci/mne_data/MNE-misc-data/ERP_CORE/P3/sub-018_ses-P3_task-P3_ave.fif ...
    Found the data of interest:
        t =    -203.12 ...     796.88 ms (0.20 × stimulus/target/11 + 0.20 × stimulus/target/22 + 0.20 × stimulus/target/33 + 0.20 × stimulus/target/44 + 0.20 × stimulus/target/55)
        0 CTF compensation matrices available
        nave = 40 - aspect type = 100
No projector specified for this dataset. Please consider the method self.add_proj.
Loaded Evoked data is baseline-corrected (baseline: [-0.203125, 0] s)
    Found the data of interest:
        t =    -203.12 ...     796.88 ms (0.04 × stimulus/non-target/12 + 0.03 × stimulus/non-target/13 + 0.06 × stimulus/non-target/14 + 0.07 × stimulus/non-target/15 + 0.04 × stimulus/non-target/21 + 0.03 × stimulus/non-target/23 + 0.07 × stimulus/non-target/24 + 0.06 × stimulus/non-target/25 + 0.04 × stimulus/non-target/31 + 0.06 × stimulus/non-target/32 + 0.04 × stimulus/non-target/34 + 0.06 × stimulus/non-target/35 + 0.04 × stimulus/non-target/41 + 0.02 × stimulus/non-target/42 + 0.09 × stimulus/non-target/43 + 0.05 × stimulus/non-target/45 + 0.06 × stimulus/non-target/51 + 0.03 × stimulus/non-target/52 + 0.04 × stimulus/non-target/53 + 0.06 × stimulus/non-target/54)
        0 CTF compensation matrices available
        nave = 160 - aspect type = 100
No projector specified for this dataset. Please consider the method self.add_proj.
Loaded Evoked data is baseline-corrected (baseline: [-0.203125, 0] s)
    Found the data of interest:
        t =    -203.12 ...     796.88 ms ((0.20 × stimulus/target/11 + 0.20 × stimulus/target/22 + 0.20 × stimulus/target/33 + 0.20 × stimulus/target/44 + 0.20 × stimulus/target/55) - (0.04 × stimulus/non-target/12 + 0.03 × stimulus/non-target/13 + 0.06 × stimulus/non-target/14 + 0.07 × stimulus/non-target/15 + 0.04 × stimulus/non-target/21 + 0.03 × stimulus/non-target/23 + 0.07 × stimulus/non-target/24 + 0.06 × stimulus/non-target/25 + 0.04 × stimulus/non-target/31 + 0.06 × stimulus/non-target/32 + 0.04 × stimulus/non-target/34 + 0.06 × stimulus/non-target/35 + 0.04 × stimulus/non-target/41 + 0.02 × stimulus/non-target/42 + 0.09 × stimulus/non-target/43 + 0.05 × stimulus/non-target/45 + 0.06 × stimulus/non-target/51 + 0.03 × stimulus/non-target/52 + 0.04 × stimulus/non-target/53 + 0.06 × stimulus/non-target/54))
        0 CTF compensation matrices available
        nave = 32 - aspect type = 100
No projector specified for this dataset. Please consider the method self.add_proj.
Loaded Evoked data is baseline-corrected (baseline: [-0.203125, 0] s)
Reading /home/circleci/mne_data/MNE-misc-data/ERP_CORE/P3/sub-019_ses-P3_task-P3_ave.fif ...
    Found the data of interest:
        t =    -203.12 ...     796.88 ms (0.20 × stimulus/target/11 + 0.20 × stimulus/target/22 + 0.20 × stimulus/target/33 + 0.20 × stimulus/target/44 + 0.20 × stimulus/target/55)
        0 CTF compensation matrices available
        nave = 40 - aspect type = 100
No projector specified for this dataset. Please consider the method self.add_proj.
Loaded Evoked data is baseline-corrected (baseline: [-0.203125, 0] s)
    Found the data of interest:
        t =    -203.12 ...     796.88 ms (0.06 × stimulus/non-target/12 + 0.04 × stimulus/non-target/13 + 0.05 × stimulus/non-target/14 + 0.04 × stimulus/non-target/15 + 0.09 × stimulus/non-target/21 + 0.04 × stimulus/non-target/23 + 0.03 × stimulus/non-target/24 + 0.04 × stimulus/non-target/25 + 0.04 × stimulus/non-target/31 + 0.04 × stimulus/non-target/32 + 0.07 × stimulus/non-target/34 + 0.04 × stimulus/non-target/35 + 0.07 × stimulus/non-target/41 + 0.03 × stimulus/non-target/42 + 0.01 × stimulus/non-target/43 + 0.08 × stimulus/non-target/45 + 0.04 × stimulus/non-target/51 + 0.07 × stimulus/non-target/52 + 0.05 × stimulus/non-target/53 + 0.04 × stimulus/non-target/54)
        0 CTF compensation matrices available
        nave = 160 - aspect type = 100
No projector specified for this dataset. Please consider the method self.add_proj.
Loaded Evoked data is baseline-corrected (baseline: [-0.203125, 0] s)
    Found the data of interest:
        t =    -203.12 ...     796.88 ms ((0.20 × stimulus/target/11 + 0.20 × stimulus/target/22 + 0.20 × stimulus/target/33 + 0.20 × stimulus/target/44 + 0.20 × stimulus/target/55) - (0.06 × stimulus/non-target/12 + 0.04 × stimulus/non-target/13 + 0.05 × stimulus/non-target/14 + 0.04 × stimulus/non-target/15 + 0.09 × stimulus/non-target/21 + 0.04 × stimulus/non-target/23 + 0.03 × stimulus/non-target/24 + 0.04 × stimulus/non-target/25 + 0.04 × stimulus/non-target/31 + 0.04 × stimulus/non-target/32 + 0.07 × stimulus/non-target/34 + 0.04 × stimulus/non-target/35 + 0.07 × stimulus/non-target/41 + 0.03 × stimulus/non-target/42 + 0.01 × stimulus/non-target/43 + 0.08 × stimulus/non-target/45 + 0.04 × stimulus/non-target/51 + 0.07 × stimulus/non-target/52 + 0.05 × stimulus/non-target/53 + 0.04 × stimulus/non-target/54))
        0 CTF compensation matrices available
        nave = 32 - aspect type = 100
No projector specified for this dataset. Please consider the method self.add_proj.
Loaded Evoked data is baseline-corrected (baseline: [-0.203125, 0] s)

Inspect the contrast before testing#

Before running any statistics, look at the effect you are about to test. We form the per-subject difference (target minus non-target) and plot its grand average. A positive deflection means targets evoke the stronger response.

diff_evoked = [
    mne.combine_evoked([evoked_target, evoked_non_target], weights=[1, -1])
    for evoked_target, evoked_non_target in zip(target_only, non_target_only)
]

grand_avg_diff = mne.grand_average(diff_evoked)
grand_avg_diff.plot()
grand_avg_diff.plot_topomap()
  • EEG (30 channels)
  • -0.203 s, 0.130 s, 0.464 s, 0.797 s, µV
Identifying common channels ...

You should see the largest difference around 400 ms over central-parietal channels – the expected P3b effect, stronger for target stimuli. This is the contrast the cluster test will evaluate formally.

diff_evoked is used only for this visualization. The cluster test below works from a dataframe holding both conditions and forms the contrast from the formula.

Build the dataframe for the cluster test#

The formula interface takes a long-format pandas.DataFrame with one row per observation. Each row holds one subject’s evoked response for one condition, so every subject contributes two rows (target and non-target). Every subject must contribute the same set of conditions. The columns are:

  • evoked: the single-subject Evoked object

  • condition: the condition label, referenced by the formula

  • subject_index: identifies which observations are paired within a subject

evokeds_conditions = target_only + non_target_only
conditions = ["target"] * len(target_only) + ["non-target"] * len(non_target_only)
subject_index = list(participant_ids) * 2

df = pd.DataFrame(
    {
        "evoked": evokeds_conditions,
        "condition": conditions,
        "subject_index": subject_index,
    }
)
df
evoked condition subject_index
0 <Evoked | '0.20 × stimulus/target/11 + 0.20 × ... target 15
1 <Evoked | '0.20 × stimulus/target/11 + 0.20 × ... target 16
2 <Evoked | '0.20 × stimulus/target/11 + 0.20 × ... target 17
3 <Evoked | '0.20 × stimulus/target/11 + 0.20 × ... target 18
4 <Evoked | '0.20 × stimulus/target/11 + 0.20 × ... target 19
5 <Evoked | '0.06 × stimulus/non-target/12 + 0.0... non-target 15
6 <Evoked | '0.04 × stimulus/non-target/12 + 0.0... non-target 16
7 <Evoked | '0.07 × stimulus/non-target/12 + 0.0... non-target 17
8 <Evoked | '0.04 × stimulus/non-target/12 + 0.0... non-target 18
9 <Evoked | '0.06 × stimulus/non-target/12 + 0.0... non-target 19


Run the cluster test with a formula#

The contrast is written as a Wilkinson (R-style) formula, the same notation used by R’s lmer/glmer. Here "evoked ~ condition" models the evoked response as a function of condition: condition is categorical and is dummy-coded automatically, and an intercept is included implicitly.

formula = "evoked ~ condition"

# Passing ``within_id="subject_index"`` makes this a within-subject (paired)
# test: the two conditions are subtracted within each subject and the resulting
# differences are tested against zero (a one-sample t-test), with the null
# distribution built by sign-flipping those per-subject differences. Because we
# set ``non-target`` as the reference, the difference is formed as
# target minus non-target. Not setting the reference explicitly will order the contrast
# levels alphabetically and the reference will be target.

cluster_result = mne.stats.cluster_test(
    df=df, formula=formula, within_id="subject_index", reference="non-target"
)

# We leave ``adjacency`` at its default of ``"auto"``, which reads the sensor
# locations from the data to decide which channels are neighbors, and treats
# consecutive time points as neighbors. Clusters can therefore grow across both
# neighboring sensors and adjacent time points. If your data have no montage,
# ``"auto"`` will say so rather than guess; you can then set one, or build the
# matrix yourself with :func:`mne.channels.find_ch_adjacency` and
# :func:`mne.stats.combine_adjacency`.

print(f"Smallest cluster p-value: {cluster_result.cluster_p_values.min():.4f}")
Could not find a adjacency matrix for the data. Computing adjacency based on Delaunay triangulations.
-- number of adjacent vertices : 30
Using automatic eeg adjacency between 30 channels, combined with a lattice over the remaining 1 dimension.
Subtracting (target - non-target) of column 'condition' before computing cluster statistics.
Using a threshold of 2.776445
Chosen statistic: one-sample T-test -- ttest_1samp_no_p
stat_fun(H1): min=-12.827606381327438 max=18.720182844608466
Running initial clustering …
Found 33 clusters

  0%|          | Permuting (exact test) : 0/15 [00:00<?,       ?it/s]
100%|██████████| Permuting (exact test) : 15/15 [00:00<00:00, 2313.55it/s]
Smallest cluster p-value: 0.0625

The smallest cluster p-value is about 0.06, so no cluster is significant at alpha = 0.05 – and with five subjects none ever could be. Here is why.

The null distribution is built by sign-flipping the five per-subject difference scores. There are 2 ** 5 = 32 ways to assign signs, but flipping every sign only mirrors the partition, so just 2 ** (5 - 1) = 16 are distinct. Because the test is exact, all 16 are evaluated rather than sampled at random – which is why cluster_result.n_permutations below reports 16 rather than the 1024 requested by default. Fifteen of those are surrogates (you will see 15/15 in the progress log); the sixteenth is the arrangement actually observed.

The finest p-value this can resolve is therefore 1 / 16 = 0.0625, so even the most extreme possible cluster lands just above 0.05. The near-0.0625 result means the observed cluster was the most extreme one – there is simply not enough data to reach significance. Detecting an effect here would need more subjects; that, not the specific p-value, is the takeaway.

Inspect the results#

The result object carries the observed cluster-level statistics. We plot the observed t-values as a heatmap with time on the x-axis and channel names on the y-axis. Because the contrast is target minus non-target, positive t-values mean a stronger response to targets, matching the difference plotted earlier.

print(f"Number of permutations run: {cluster_result.n_permutations}")

# times (in seconds) and channel names come from the evoked data
times = grand_avg_diff.times
ch_names = grand_avg_diff.ch_names

# stat_obs holds the observed t-values, arranged as (channels, times)
stat_obs = cluster_result.stat_obs

# symmetric colour limits so the diverging colormap is centred on zero
vlim = np.abs(stat_obs).max()

fig, ax = plt.subplots(layout="constrained")
im = ax.imshow(
    stat_obs,
    aspect="auto",
    origin="lower",
    extent=[times[0], times[-1], 0, len(ch_names)],
    cmap="RdBu_r",
    vmin=-vlim,
    vmax=vlim,
)
ax.set_yticks(np.arange(len(ch_names)) + 0.5)
ax.set_yticklabels(ch_names)
ax.set_xlabel("time (s)")
ax.set_ylabel("channel")
ax.set_title("Observed cluster statistic (target - non-target)")
fig.colorbar(im, ax=ax, label="t-value")
Observed cluster statistic (target - non-target)
Number of permutations run: 16

Total running time of the script: (0 minutes 1.242 seconds)

Gallery generated by Sphinx-Gallery