Statistics for ERPs: a paired test on one measure, uncorrected time-point tests, and cluster-based permutation tests in time and over the scalp

nb-3-7-cluster-test Level 3 · Event-Related Analysis ~6 min Used in L3.7 · Statistics for ERPs

Downloads from ds-erpcore when you run it.

Download the notebook (.ipynb) Outputs below are the ones stored when it was executed — you do not need to run anything to read it.

nb-3-7-cluster-test · Statistics for ERPs (L3.7)

Lesson L3.7 · Level 3 · Status draft — for expert review; uncertain points carry TODO(confirm).

Three tests on the same ten difference waves, in order of how much they assume:

  1. a paired test on one measured value — the mean amplitude in the a-priori window — which is one test, one p-value, and no multiple-comparison problem at all;
  2. a t test at every time point, uncorrected — 256 tests whose family-wise error rate is unknown because neighbouring samples are not independent, which the notebook quantifies rather than asserts;
  3. a cluster-based permutation test (mne.stats.permutation_cluster_1samp_test) over time at Pz, and again over channels and time together, with the exact call, the seed and the number of permutations recorded.

The last cell prints every cluster with its t-sum, p-value and time range, and a statement of exactly what that p-value licenses — which is less than most papers claim.

Data. ds-erpcore — ERP CORE, Kappenman, Farrens, Zhang, Stewart & Luck (2020), ERP CORE: An Open Resource for Human Event-related Potential Research, PsyArXiv, DOI 10.31234/osf.io/4azqm; dataset DOI 10.18112/openneuro.ds003069.v1.0.0. Paradigm P3, an active visual oddball task. From data/directory.yaml: Biosemi ActiveTwo, 30 EEG + 3 EOG electrodes in a 10-20 placement scheme, 1024 Hz, CMS reference, 60 Hz mains, no software filters, 40 participants, access: open.

Licence — CC BY-SA 4.0, contested at source. Three statements exist and all three are real: the LICENSE file shipped with the data says CC BY-SA 4.0 with explicit share-alike wording, the BIDS dataset_description.json says CC0, and the OSF node thsqg record says CC BY 4.0. Spec §10.7 makes the most restrictive reading govern, so the site records CC-BY-SA-4.0 (data/directory.yaml, 2026-09-18) and share-alike is assumed to bind anything derived from these data. helpers_l3.ERPCORE_LICENCE_STATEMENTS carries all three verbatim. Redistribution is permitted under every reading; only share-alike is in question.

Files are fetched per subject from the paradigm's own OSF component (etdkz) and cached locally; a checkout that already holds them downloads nothing.

No published values are quoted. The catalog carries the citation and the DOIs but no published amplitudes, latencies or effect sizes, so every comparison with the paper's own numbers is a literal TODO(confirm) rather than a number from memory.

Conditions come from the dataset's own code dictionary (task-P3_events.json): a stimulus code's first digit is the block's target letter and its second digit is the letter shown, so equal digits = target, unequal digits = standard. The design gives p = .2 for the target category, so a subject contributes about 40 target and 160 standard trials.

In [1]:
# Setup: dependencies, the shared helpers, non-interactive plotting.
import importlib.util
import subprocess
import sys
import warnings
from pathlib import Path

# 1. Dependencies are pinned in notebooks/requirements.txt.  Nothing is installed when the
#    pinned stack is already present (local runs, CI); a fresh Colab or Binder kernel installs
#    it once.  On Colab, run from a clone of the repository so that notebooks/_shared/ is
#    available (repository URL: TODO(confirm), spec section 13 item 3).
_needed = ("mne", "scipy", "matplotlib", "pandas", "pooch")
_missing = [p for p in _needed if importlib.util.find_spec(p) is None]
if _missing:
    _req = next((d / "requirements.txt" for d in (Path.cwd(), *Path.cwd().parents)
                 if (d / "requirements.txt").exists()), None)
    _cmd = [sys.executable, "-m", "pip", "install", "-q"]
    _cmd += ["-r", str(_req)] if _req else ["mne==1.10.2", "pooch>=1.8"]
    subprocess.check_call(_cmd)

# 2. Shared helpers, located relative to the working directory -- notebooks/<level>/ or
#    notebooks/ -- never through an absolute path.
_shared = next((d / "_shared" for d in (Path.cwd(), *Path.cwd().parents)
                if (d / "_shared" / "helpers_l3.py").exists()), None)
if _shared is None:
    raise FileNotFoundError("start the kernel in notebooks/L3/ (or notebooks/) so that _shared/helpers_l3.py is found")
sys.path.insert(0, str(_shared))
import helpers
import helpers_l3 as L3

# 3. Plotting: Jupyter's default inline backend renders static PNGs through Agg (no windows,
#    nothing blocks); outside Jupyter the helpers select Agg.  Every MNE figure is requested
#    with show=False and each figure cell ends with plt.show().
import matplotlib.pyplot as plt
import numpy as np
import mne

mne.set_log_level("WARNING")
plt.rcParams["figure.dpi"] = 72
print(f"MNE {mne.__version__}; helpers_l3 imported from notebooks/_shared")
print(f"ERP CORE cache: {L3.erpcore_root().name}/ (resolved relative to the working directory, "
      "or $EEG_COURSE_ERPCORE); only the subjects this notebook names are fetched")
MNE 1.10.2; helpers_l3 imported from notebooks/_shared
ERP CORE cache: erpcore/ (resolved relative to the working directory, or $EEG_COURSE_ERPCORE); only the subjects this notebook names are fetched

1 · The pipeline, stated once

In [2]:
# The one Level-3 pipeline, printed rather than described.  Every Level-3 notebook and the C3
# capstone call the same helpers_l3.load_p3_epochs, so their numbers are comparable.
for key, value in L3.P3_PIPELINE.items():
    print(f"{key:15s} : {value}")
print()
print(f"a-priori measurement window : {L3.P3_WINDOW[0] * 1000:.0f}-{L3.P3_WINDOW[1] * 1000:.0f} ms "
      f"at {L3.P3_CHANNEL}, fixed in helpers_l3.P3_WINDOW")
dataset         : ds-erpcore, paradigm P3 (active visual oddball)
conditions      : target vs standard, read from the dataset's own code dictionary (task-P3_events.json): a stimulus code's first digit is the block's target letter and its second digit the letter shown, so equal digits = target, unequal digits = standard
channel_names   : FP1/FP2 renamed Fp1/Fp2 so MNE's standard_1005 montage matches; the three EOG channels (HEOG_left, HEOG_right, VEOG_lower) typed as EOG and excluded from every EEG average
montage         : standard_1005 (MNE), matched by name
bad_channels    : a channel is bad when its standard deviation over the whole recording exceeds 5x the median of the 30 EEG channels AND its largest absolute correlation with its four nearest neighbours is below 0.4 (both measured on a 1-40 Hz copy); bad channels are interpolated (spherical splines) before re-referencing.  Two conditions, because a channel dominated by blinks is large but still correlates with its neighbours.
reference       : average of the 30 EEG channels, applied after interpolation
filter          : FIR band-pass 0.1-40 Hz at the native 1024 Hz (MNE raw.filter defaults: firwin, Hamming, zero-phase, 'auto' transition bands)
ocular          : FastICA (n_components=15, random_state=20260918) fitted on a 1 Hz high-passed 128 Hz copy; components whose absolute correlation with any EOG channel reaches 0.5 are removed.  Level 2 owns this step (L2.6) and Level 3 does not re-teach it; it is here so that the frontal ocular artifact does not decide which trials survive.
epochs          : -200 to +800 ms around the stimulus event, baseline -200 to 0 ms (mean subtraction), no annotation-based rejection; trial metadata attached
resample        : 1024 -> 256 Hz after epoching (MNE epochs.resample, FFT-based)
rejection       : an epoch is rejected when the peak-to-peak amplitude over -200 to +800 ms exceeds 150 uV on any of the 30 EEG channels (label_source: algorithmic).  The criterion is always evaluated over that window, whatever window the epochs were cut to, so every Level-3 notebook rejects the same trials
measurement     : P3 = mean amplitude over 300-600 ms at Pz, target minus standard; the window is fixed a priori in helpers_l3.P3_WINDOW and is not moved after looking at the data (nb-1-5-filters used 300-500 ms on a different dataset; nb-3-3 shows what a collapsed-localizer window would give instead)

a-priori measurement window : 300-600 ms at Pz, fixed in helpers_l3.P3_WINDOW
In [3]:
SUBJECTS = list(L3.SUBSET_DEFAULT)
W = L3.P3_WINDOW
CH = L3.P3_CHANNEL
ALPHA = 0.05
SEED = 20260918

store = {}
for s in SUBJECTS:
    ep, nfo = L3.load_p3_epochs(s, verbose=False)
    store[s] = {"target": L3.condition_epochs(ep, "target").average(),
                "standard": L3.condition_epochs(ep, "standard").average(), "info": nfo}
times = ep.times
proto = store[SUBJECTS[0]]["target"].copy().pick("eeg")
eeg = proto.ch_names
i_ch = eeg.index(CH)

# subject-level difference waves: one per subject, which is the unit of analysis for every test below
X_all = np.stack([(store[s]["target"].copy().pick("eeg").data
                   - store[s]["standard"].copy().pick("eeg").data) * 1e6 for s in SUBJECTS])   # subj x ch x time
X = X_all[:, i_ch, :]                                                                          # subj x time
n = len(SUBJECTS)
epochs_sfreq = float(ep.info["sfreq"])
print(f"{n} subject-level difference waves (target minus standard), {X_all.shape[1]} channels, "
      f"{X_all.shape[2]} time points at {epochs_sfreq:.0f} Hz")
print(f"trials behind them: target "
      f"{[store[s]['info']['n_kept']['target'] for s in SUBJECTS]}, standard "
      f"{[store[s]['info']['n_kept']['standard'] for s in SUBJECTS]}")
10 subject-level difference waves (target minus standard), 30 channels, 256 time points at 256 Hz
trials behind them: target [35, 40, 36, 40, 38, 32, 40, 40, 40, 38], standard [138, 158, 144, 159, 150, 138, 160, 151, 160, 153]

2 · One measured value, one test

This is the test the rest of Level 3 has been building towards: measure the P3 once per subject in the a-priori window, and test those ten numbers against zero. There is nothing to correct for, because there is one test.

In [4]:
from scipy import stats

amp = np.array([L3.mean_amplitude(X[k], None, W, times=times) for k in range(n)])
t_stat, p_val = stats.ttest_1samp(amp, 0)
dz = amp.mean() / amp.std(ddof=1)
ci = stats.t.ppf(1 - ALPHA / 2, n - 1) * amp.std(ddof=1) / np.sqrt(n)
w_stat, w_p = stats.wilcoxon(amp)

print(f"P3 mean amplitude at {CH}, {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms, target minus standard, {n} subjects")
print(f"  per subject: " + ", ".join(f"{v:+.2f}" for v in amp))
print(f"  mean {amp.mean():+.3f} uV, SD {amp.std(ddof=1):.3f}, SEM {amp.std(ddof=1) / np.sqrt(n):.3f}, "
      f"95% CI [{amp.mean() - ci:+.3f}, {amp.mean() + ci:+.3f}]")
print(f"  one-sample t({n - 1}) = {t_stat:.3f}, p = {p_val:.5f}, Cohen dz = {dz:.3f}")
print(f"  Wilcoxon signed rank (no normality assumption): W = {w_stat:.1f}, p = {w_p:.5f}")
print(f"  {int((amp > 0).sum())} of {n} subjects show a positive effect")
P3 mean amplitude at Pz, 300-600 ms, target minus standard, 10 subjects
  per subject: +2.68, +9.63, +8.42, +3.24, +2.13, +0.73, +2.82, +1.47, +2.17, +2.43
  mean +3.571 uV, SD 2.972, SEM 0.940, 95% CI [+1.445, +5.697]
  one-sample t(9) = 3.799, p = 0.00422, Cohen dz = 1.201
  Wilcoxon signed rank (no normality assumption): W = 0.0, p = 0.00195
  10 of 10 subjects show a positive effect

3 · A t test at every time point, and why it is not a test

Running the same paired test at all 256 time points gives 256 p-values, and the temptation is to read the ones below 0.05 as "the effect". Two things make that wrong, and the cell below measures both.

The rate is not 5 %. With 256 tests at α = 0.05 you would expect about 13 false positives if the tests were independent under the null. They are not independent — neighbouring samples of a 0.1–40 Hz signal are nearly the same number — so the family-wise error rate is neither 5 % nor 1 − 0.95²⁵⁶. It is unknown, which is precisely the problem: there is no correction you can apply after the fact because you do not know what to correct for.

The pre-stimulus window is not a fair null either. It is tempting to count false positives in the baseline, but baseline correction subtracted each epoch's mean over −200 to 0 ms, which forces the average of that window to zero in both conditions. The baseline is therefore quieter than a true null and whatever it shows is a lower bound. The cell prints it anyway, labelled as such.

In [5]:
t_point, p_point = stats.ttest_1samp(X, 0, axis=0)
sig = p_point < ALPHA
pre = times < 0
post = times >= 0

print(f"uncorrected t tests at every time point ({len(times)} tests at alpha = {ALPHA})")
print(f"  significant anywhere      : {int(sig.sum()):3d} of {len(times)} ({100 * sig.mean():.1f} %)")
print(f"  significant before 0 ms   : {int((sig & pre).sum()):3d} of {int(pre.sum())} "
      f"({100 * sig[pre].mean():.1f} %) -- a LOWER bound, not a null rate: baseline correction forced the "
      f"mean of -200..0 ms to zero in every epoch")
print(f"  significant after 0 ms    : {int((sig & post).sum()):3d} of {int(post.sum())} "
      f"({100 * sig[post].mean():.1f} %)")
runs = [len(r) for r in "".join("1" if v else "0" for v in sig[pre]).split("0") if r]
print(f"  longest run of consecutive significant points before 0 ms: {max(runs or [0])} samples")
print(f"  if the {len(times)} tests were independent, alpha = {ALPHA} would give about "
      f"{ALPHA * len(times):.0f} false positives; at {epochs_sfreq:.0f} Hz they are nowhere near "
      f"independent, so the family-wise error rate is neither {100 * ALPHA:.0f} % nor "
      f"1 - {1 - ALPHA:g}^{len(times)} = {1 - (1 - ALPHA) ** len(times):.3f}; it is unknown, which is the "
      f"problem.")
print(f"  the {int(sig[post].sum())} significant points after 0 ms are also not {int(sig[post].sum())} "
      f"independent findings: they form "
      f"{len([r for r in ''.join('1' if v else '0' for v in sig[post]).split('0') if r])} contiguous "
      f"run(s), which is what the cluster test in section 4 is built to exploit.")

fig, axes = plt.subplots(2, 1, figsize=(10, 6.5), sharex=True)
L3.plot_erp({f"grand-average difference (n = {n} subjects)": (X.mean(0), {"color": "k", "lw": 1.8})},
            times, window=W, ax=axes[0], title=f"Subject-level difference waves at {CH} (uV)")
for k in range(n):
    axes[0].plot(times * 1000, X[k], lw=0.7, alpha=0.5)
axes[1].plot(times * 1000, t_point, "k", lw=1.2)
axes[1].fill_between(times * 1000, t_point, 0, where=sig, color="tab:red", alpha=0.35,
                     label=f"p < {ALPHA} uncorrected ({int(sig.sum())} points)")
for s_ in (1, -1):
    axes[1].axhline(s_ * stats.t.ppf(1 - ALPHA / 2, n - 1), color="tab:red", ls=":", lw=0.9)
axes[1].axhline(0, color="gray", lw=0.6)
axes[1].axvline(0, color="gray", lw=0.6)
axes[1].set(xlabel="Time from stimulus (ms)", ylabel=f"t (one-sample, {n - 1} df)",
            title="The same data as 256 uncorrected tests")
axes[1].grid(alpha=0.3)
axes[1].legend(fontsize=8)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
uncorrected t tests at every time point (256 tests at alpha = 0.05)
  significant anywhere      :  97 of 256 (37.9 %)
  significant before 0 ms   :   0 of 52 (0.0 %) -- a LOWER bound, not a null rate: baseline correction forced the mean of -200..0 ms to zero in every epoch
  significant after 0 ms    :  97 of 204 (47.5 %)
  longest run of consecutive significant points before 0 ms: 0 samples
  if the 256 tests were independent, alpha = 0.05 would give about 13 false positives; at 256 Hz they are nowhere near independent, so the family-wise error rate is neither 5 % nor 1 - 0.95^256 = 1.000; it is unknown, which is the problem.
  the 97 significant points after 0 ms are also not 97 independent findings: they form 2 contiguous run(s), which is what the cluster test in section 4 is built to exploit.
Figure 1 of notebook nb-3-7-cluster-test, an output plot. The text around it states what it shows and the units of every axis.

4 · The cluster-based permutation test

The test that does control the family-wise error rate without assuming the time points are independent:

  1. compute the t statistic at every time point;
  2. keep the points whose |t| exceeds a cluster-forming threshold and group the adjacent ones into clusters;
  3. give each cluster a statistic — here the sum of its t values, which rewards both size and strength;
  4. flip the signs of a random subset of the subjects (the null hypothesis says the sign is arbitrary), redo steps 1–3, and keep the largest cluster statistic of that permutation;
  5. a cluster's p-value is the fraction of permutations whose largest cluster statistic is at least as extreme.

Every choice in that list changes the answer and must be reported: the threshold, the cluster statistic, the number of permutations, the tail, and the seed. The call below states all of them.

Ten subjects is 2¹⁰ = 1024 possible sign flips, so the permutation distribution is exhaustive rather than sampled and there is a floor on how small a p-value can be. MNE reports the number it actually used, and the last cell prints that floor.

In [6]:
threshold = float(stats.t.ppf(1 - ALPHA / 2, n - 1))     # two-tailed cluster-forming threshold at alpha = 0.05
N_PERM = 10000                                            # MNE reduces this to the exhaustive count when smaller

t_obs, clusters, cluster_p, H0 = mne.stats.permutation_cluster_1samp_test(
    X, threshold=threshold, n_permutations=N_PERM, tail=0, out_type="indices",
    seed=SEED, verbose=False)

print("call: mne.stats.permutation_cluster_1samp_test(")
print(f"          X,                      # {X.shape[0]} subjects x {X.shape[1]} time points, uV, {CH}")
print(f"          threshold={threshold:.4f},  # two-tailed t threshold at alpha = {ALPHA} with {n - 1} df")
print(f"          n_permutations={N_PERM}, tail=0, out_type='indices', seed={SEED})")
print(f"permutations actually used: {len(H0)}  (2^{n} = {2 ** n} sign flips exist; MNE enumerates them when "
      f"that is fewer than n_permutations)")
print(f"smallest p-value this test can return: {1 / len(H0):.5f}")
print()
order = np.argsort(cluster_p)
print(f"{'#':>2s} {'time range (ms)':>20s} {'samples':>8s} {'t-sum':>10s} {'max |t|':>8s} {'p':>8s} {'':>12s}")
cluster_rows = []
for rank, i in enumerate(order):
    idx = np.asarray(clusters[i][0])
    row = {"start_ms": float(times[idx[0]] * 1000), "end_ms": float(times[idx[-1]] * 1000),
           "n": int(len(idx)), "t_sum": float(t_obs[idx].sum()),
           "max_t": float(np.abs(t_obs[idx]).max()), "p": float(cluster_p[i]),
           "sign": "positive" if t_obs[idx].sum() > 0 else "negative"}
    cluster_rows.append(row)
    mark = "SIGNIFICANT" if row["p"] <= ALPHA else ""
    print(f"{rank:2d} {row['start_ms']:8.1f} to {row['end_ms']:8.1f} {row['n']:8d} {row['t_sum']:+10.2f} "
          f"{row['max_t']:8.2f} {row['p']:8.4f} {mark:>12s}")
sig_clusters = [r for r in cluster_rows if r["p"] <= ALPHA]
print(f"\n{len(sig_clusters)} of {len(cluster_rows)} clusters survive at alpha = {ALPHA}")
call: mne.stats.permutation_cluster_1samp_test(
          X,                      # 10 subjects x 256 time points, uV, Pz
          threshold=2.2622,  # two-tailed t threshold at alpha = 0.05 with 9 df
          n_permutations=10000, tail=0, out_type='indices', seed=20260918)
permutations actually used: 512  (2^10 = 1024 sign flips exist; MNE enumerates them when that is fewer than n_permutations)
smallest p-value this test can return: 0.00195

 #      time range (ms)  samples      t-sum  max |t|        p             
 0    272.5 to    639.6       95    +311.78     4.13   0.0020  SIGNIFICANT
 1    686.5 to    690.4        2      +4.71     2.37   0.6758             

1 of 2 clusters survive at alpha = 0.05
In [7]:
fig, axes = plt.subplots(2, 1, figsize=(10, 7))
L3.plot_cluster_test(times, t_obs, clusters, cluster_p, alpha=ALPHA, threshold=threshold, ax=axes[0],
                     title=f"Cluster permutation test at {CH}: observed t and the surviving clusters "
                           f"({n} subjects)")
axes[1].hist(H0, bins=50, color="tab:blue", alpha=0.8)
for r in sig_clusters:
    axes[1].axvline(r["t_sum"], color="tab:orange", lw=2,
                    label=f"observed t-sum {r['t_sum']:+.0f} (p = {r['p']:.4f})")
axes[1].set(xlabel="Largest cluster t-sum per permutation", ylabel="Permutations",
            title=f"The permutation distribution ({len(H0)} sign-flip permutations, seed {SEED})")
axes[1].grid(alpha=0.3)
axes[1].legend(fontsize=8)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline

print(f"the observed t-sum sits at the {100 * np.mean(np.abs(H0) < abs(sig_clusters[0]['t_sum'])):.2f}nd "
      f"percentile of the permutation distribution" if sig_clusters else
      "no cluster survived, so there is nothing to locate in the permutation distribution")
Figure 2 of notebook nb-3-7-cluster-test, an output plot. The text around it states what it shows and the units of every axis.
the observed t-sum sits at the 99.80nd percentile of the permutation distribution

The threshold is a choice, not a property of the data

A higher cluster-forming threshold favours short, strong effects; a lower one favours long, weak effects. It does not change the false-positive rate — that is what the permutation handles — but it does change which effects the test can find, and it changes the boundaries of the cluster it reports. The sweep below is here so that the dependence is visible rather than hidden, and so that the one threshold reported above is clearly a decision.

In [8]:
print(f"cluster-forming threshold sweep ({CH}, {n} subjects, {len(H0)} permutations, seed {SEED})")
print(f"{'alpha_form':>11s} {'t threshold':>12s} {'clusters':>9s} {'best p':>8s} {'its range (ms)':>22s} "
      f"{'its t-sum':>10s}")
for a_form in (0.20, 0.10, 0.05, 0.01, 0.005):
    thr = float(stats.t.ppf(1 - a_form / 2, n - 1))
    to, cl, cp, h0 = mne.stats.permutation_cluster_1samp_test(
        X, threshold=thr, n_permutations=N_PERM, tail=0, out_type="indices", seed=SEED, verbose=False)
    if len(cp) == 0:
        print(f"{a_form:11.3f} {thr:12.3f} {0:9d} {'-':>8s} {'-':>22s} {'-':>10s}")
        continue
    b = int(np.argmin(cp))
    idx = np.asarray(cl[b][0])
    print(f"{a_form:11.3f} {thr:12.3f} {len(cp):9d} {cp[b]:8.4f} "
          f"{times[idx[0]] * 1000:9.1f} to {times[idx[-1]] * 1000:8.1f} {to[idx].sum():+10.2f}")
cluster-forming threshold sweep (Pz, 10 subjects, 512 permutations, seed 20260918)
 alpha_form  t threshold  clusters   best p         its range (ms)  its t-sum
      0.200        1.383         8   0.0020     252.9 to    702.1    +351.77
      0.100        1.833         8   0.0020     260.7 to    647.5    +321.60
      0.050        2.262         2   0.0020     272.5 to    639.6    +311.78
      0.010        3.250         3   0.0020     327.1 to    444.3    +112.99
      0.005        3.690         4   0.0254     331.1 to    350.6     +23.64

5 · Channels and time together

Restricting the test to Pz was itself a choice — an a-priori one, but a choice. The spatio-temporal version tests every channel and every time point at once, with an adjacency matrix saying which channels count as neighbours, so a cluster can spread in space as well as in time. It needs no a-priori channel, and it pays for that with a larger search space.

In [9]:
adjacency, ch_names_adj = mne.channels.find_ch_adjacency(proto.info, ch_type="eeg")
print(f"adjacency: {adjacency.shape[0]} channels, "
      f"{int(adjacency.sum() - adjacency.shape[0]) // 2} neighbour pairs "
      f"(mne.channels.find_ch_adjacency, Delaunay triangulation of the montage)")

X_st = np.transpose(X_all, (0, 2, 1))          # MNE wants subjects x times x channels
t_st, cl_st, p_st, H0_st = mne.stats.spatio_temporal_cluster_1samp_test(
    X_st, threshold=threshold, n_permutations=N_PERM, tail=0, adjacency=adjacency,
    out_type="mask", seed=SEED, verbose=False)

print(f"permutations used: {len(H0_st)}; smallest possible p-value {1 / len(H0_st):.5f}")
print(f"{'#':>2s} {'time range (ms)':>20s} {'channels':>9s} {'t-sum':>10s} {'p':>8s}  channels")
st_rows = []
for rank, i in enumerate(np.argsort(p_st)):
    mask = cl_st[i]
    ti = np.where(mask.any(axis=1))[0]
    chan_idx = np.where(mask.any(axis=0))[0]
    row = {"start_ms": float(times[ti[0]] * 1000), "end_ms": float(times[ti[-1]] * 1000),
           "n_ch": int(len(chan_idx)), "t_sum": float(t_st[mask].sum()), "p": float(p_st[i]),
           "channels": [eeg[j] for j in chan_idx]}
    st_rows.append(row)
    if row["p"] <= ALPHA or rank < 4:
        print(f"{rank:2d} {row['start_ms']:8.1f} to {row['end_ms']:8.1f} {row['n_ch']:9d} "
              f"{row['t_sum']:+10.2f} {row['p']:8.4f}  {', '.join(row['channels'][:10])}"
              + (" ..." if len(row["channels"]) > 10 else ""))
st_sig = [r for r in st_rows if r["p"] <= ALPHA]
print(f"\n{len(st_sig)} of {len(st_rows)} spatio-temporal clusters survive at alpha = {ALPHA}")

if st_sig:
    fig, axes = plt.subplots(1, len(st_sig) + 1, figsize=(3.4 * len(st_sig) + 1.2, 3.4),
                             gridspec_kw={"width_ratios": [1] * len(st_sig) + [0.09]})
    axes = np.atleast_1d(axes)
    v = float(np.abs(t_st).max())
    im = None
    for ax, (i, row) in zip(axes[:-1], zip(np.argsort(p_st), st_sig)):
        mask = cl_st[i]
        t_in_window = t_st[mask.any(axis=1)].mean(axis=0)     # mean t over the cluster's own time range
        im, _ = mne.viz.plot_topomap(t_in_window, proto.info, axes=ax, show=False, contours=4,
                                     vlim=(-v, v), sensors=True, mask=mask.any(axis=0),
                                     mask_params=dict(marker="o", markerfacecolor="k", markersize=5))
        ax.set_title(f"{row['start_ms']:.0f}-{row['end_ms']:.0f} ms\nt-sum {row['t_sum']:+.0f}, "
                     f"p = {row['p']:.4f}", fontsize=9)
    cb = fig.colorbar(im, cax=axes[-1])
    cb.set_label("mean t over the cluster's time range")
    fig.suptitle("Surviving spatio-temporal clusters: black dots are the channels in the cluster",
                 y=1.04, fontsize=10)
    fig.tight_layout()
    plt.show()   # render the static figure(s) of this cell inline
adjacency: 30 channels, 78 neighbour pairs (mne.channels.find_ch_adjacency, Delaunay triangulation of the montage)
permutations used: 512; smallest possible p-value 0.00195
 #      time range (ms)  channels      t-sum        p  channels
 0    272.5 to    702.1         8   +1495.05   0.0039  P3, PO3, Pz, CPz, FCz, Cz, C4, P4
 1    284.2 to    698.2         9   -1235.55   0.0078  Fp1, F7, P7, P9, PO7, Fp2, F8, P8, P10
 2    225.6 to    256.8         4     -54.57   0.9043  P3, P7, PO7, PO3
 3   -180.7 to   -165.0         4     +30.49   0.9980  FC3, C3, CPz, Cz

2 of 48 spatio-temporal clusters survive at alpha = 0.05
Figure 3 of notebook nb-3-7-cluster-test, an output plot. The text around it states what it shows and the units of every axis.

6 · What the result licenses

A cluster p-value tests one hypothesis: that the condition difference is zero everywhere in the tested space. A small p-value rejects that, and nothing more. In particular:

It does license — "the target and standard conditions differ somewhere in the tested window and channel set, with the family-wise error rate controlled at α over that whole space."

It does not license — any statement about where or when. The cluster's boundaries are not a confidence interval on the extent of the effect: they are the places where the t statistic happened to exceed a threshold that was itself arbitrary, and the sweep in section 4 shows them moving when the threshold moves. Two papers with different thresholds will report different time ranges for the same effect.

It does not license comparing clusters with one another. The largest cluster is not "the strongest effect"; a cluster that just misses α is not "a trend"; and a cluster that survives in one condition but not another does not show that the two conditions differ from each other — that requires testing the interaction directly.

It says nothing about effect size. The t-sum grows with the number of points in the cluster, so a long weak effect and a short strong one can share a t-sum. Report the measured amplitude (section 2) next to the cluster, which is why this notebook runs both tests on the same data.

In [10]:
print("nb-3-7-cluster-test -- L3.7 numbers (draft; TODO(confirm) at author review)")
print(f"Data: ds-erpcore P3, sub-001 to sub-{SUBJECTS[-1]:03d} ({n} subjects, helpers_l3.SUBSET_DEFAULT); "
      f"CC-BY-SA-4.0 per data/directory.yaml (contested at source; spec 10.7 most-restrictive rule)")
print(f"Pipeline: helpers_l3.P3_PIPELINE (printed in section 1); unit of analysis = one difference wave "
      f"(target minus standard) per subject")
print()
print(f"1. PAIRED TEST ON ONE MEASURE ({CH}, {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms mean amplitude):")
print(f"     mean {amp.mean():+.3f} uV, 95% CI [{amp.mean() - ci:+.3f}, {amp.mean() + ci:+.3f}], "
      f"t({n - 1}) = {t_stat:.3f}, p = {p_val:.5f}, dz = {dz:.3f}")
print()
print(f"2. UNCORRECTED TIME-POINT TESTS: {int(sig.sum())} of {len(times)} points reach p < {ALPHA} "
      f"({100 * sig.mean():.1f} %), of which {int((sig & pre).sum())} lie in the pre-stimulus baseline where "
      f"the epochs were zeroed and nothing can be happening.")
print()
print(f"3. CLUSTER PERMUTATION TEST at {CH} -- exact call:")
print(f"     mne.stats.permutation_cluster_1samp_test(X, threshold={threshold:.4f}, "
      f"n_permutations={N_PERM}, tail=0, out_type='indices', seed={SEED})")
print(f"     X is {X.shape[0]} subjects x {X.shape[1]} time points of the {CH} difference wave in uV; "
      f"threshold = two-tailed t at alpha = {ALPHA} with {n - 1} df")
print(f"     permutations used {len(H0)} (exhaustive: 2^{n} = {2 ** n} sign flips), so the smallest "
      f"attainable p-value is {1 / len(H0):.5f}")
print(f"     CLUSTERS ({len(cluster_rows)} formed, {len(sig_clusters)} significant at alpha = {ALPHA}):")
for r in cluster_rows:
    print(f"       {r['sign']:8s} cluster {r['start_ms']:7.1f} to {r['end_ms']:7.1f} ms "
          f"({r['n']:3d} samples): t-sum {r['t_sum']:+9.2f}, max |t| {r['max_t']:.2f}, p = {r['p']:.4f}"
          + ("   <- significant" if r["p"] <= ALPHA else ""))
print()
print(f"4. SPATIO-TEMPORAL CLUSTER TEST over {len(eeg)} channels and {len(times)} time points "
      f"(mne.stats.spatio_temporal_cluster_1samp_test, same threshold, seed and permutation count, "
      f"adjacency from mne.channels.find_ch_adjacency):")
for r in st_rows[:4]:
    print(f"       {r['start_ms']:7.1f} to {r['end_ms']:7.1f} ms, {r['n_ch']:2d} channels: "
          f"t-sum {r['t_sum']:+9.1f}, p = {r['p']:.4f}"
          + ("   <- significant" if r["p"] <= ALPHA else ""))
    print(f"           channels: {', '.join(r['channels'])}")
print()
print("ANSWER KEY -- ex-3-7 (multiple select), what the result licenses:")
if sig_clusters:
    best = sig_clusters[0]
    print(f"  LICENSED: 'target and standard differ somewhere in the tested window and channel set' "
          f"(cluster p = {best['p']:.4f}, family-wise error rate controlled at alpha = {ALPHA} over the "
          f"whole tested space).")
    print(f"  LICENSED: reporting the effect size separately -- {amp.mean():+.3f} uV "
          f"(95% CI [{amp.mean() - ci:+.3f}, {amp.mean() + ci:+.3f}], dz = {dz:.3f}) from the a-priori "
          f"window, which the cluster test does not provide.")
    print(f"  NOT LICENSED: 'the effect starts at {best['start_ms']:.0f} ms and ends at "
          f"{best['end_ms']:.0f} ms'. Those boundaries are where |t| crossed an arbitrary threshold; the "
          f"sweep in section 4 moves them by changing only that threshold.")
    print(f"  NOT LICENSED: 'the effect is significant at {CH} and not elsewhere' -- the {CH}-only test "
          f"never looked elsewhere, and the spatio-temporal test found "
          f"{len(st_sig)} cluster(s) spanning up to {max((r['n_ch'] for r in st_sig), default=0)} channels.")
    print(f"  NOT LICENSED: comparing the t-sums of two clusters, or calling a cluster with p just above "
          f"{ALPHA} 'a trend'. The t-sum grows with cluster length and is not an effect size.")
else:
    print("  no cluster survived; the licensed statement is that this test did not reject the null, which "
          "is not evidence that the conditions are the same.")
print()
print(f"Pitfalls: pf-uncorrected-timepoint-tests, pf-cluster-inference-misread.  "
      f"Widget: w-cluster-permutation-viz (mode erp).")
nb-3-7-cluster-test -- L3.7 numbers (draft; TODO(confirm) at author review)
Data: ds-erpcore P3, sub-001 to sub-010 (10 subjects, helpers_l3.SUBSET_DEFAULT); CC-BY-SA-4.0 per data/directory.yaml (contested at source; spec 10.7 most-restrictive rule)
Pipeline: helpers_l3.P3_PIPELINE (printed in section 1); unit of analysis = one difference wave (target minus standard) per subject

1. PAIRED TEST ON ONE MEASURE (Pz, 300-600 ms mean amplitude):
     mean +3.571 uV, 95% CI [+1.445, +5.697], t(9) = 3.799, p = 0.00422, dz = 1.201

2. UNCORRECTED TIME-POINT TESTS: 97 of 256 points reach p < 0.05 (37.9 %), of which 0 lie in the pre-stimulus baseline where the epochs were zeroed and nothing can be happening.

3. CLUSTER PERMUTATION TEST at Pz -- exact call:
     mne.stats.permutation_cluster_1samp_test(X, threshold=2.2622, n_permutations=10000, tail=0, out_type='indices', seed=20260918)
     X is 10 subjects x 256 time points of the Pz difference wave in uV; threshold = two-tailed t at alpha = 0.05 with 9 df
     permutations used 512 (exhaustive: 2^10 = 1024 sign flips), so the smallest attainable p-value is 0.00195
     CLUSTERS (2 formed, 1 significant at alpha = 0.05):
       positive cluster   272.5 to   639.6 ms ( 95 samples): t-sum   +311.78, max |t| 4.13, p = 0.0020   <- significant
       positive cluster   686.5 to   690.4 ms (  2 samples): t-sum     +4.71, max |t| 2.37, p = 0.6758

4. SPATIO-TEMPORAL CLUSTER TEST over 30 channels and 256 time points (mne.stats.spatio_temporal_cluster_1samp_test, same threshold, seed and permutation count, adjacency from mne.channels.find_ch_adjacency):
         272.5 to   702.1 ms,  8 channels: t-sum   +1495.0, p = 0.0039   <- significant
           channels: P3, PO3, Pz, CPz, FCz, Cz, C4, P4
         284.2 to   698.2 ms,  9 channels: t-sum   -1235.5, p = 0.0078   <- significant
           channels: Fp1, F7, P7, P9, PO7, Fp2, F8, P8, P10
         225.6 to   256.8 ms,  4 channels: t-sum     -54.6, p = 0.9043
           channels: P3, P7, PO7, PO3
        -180.7 to  -165.0 ms,  4 channels: t-sum     +30.5, p = 0.9980
           channels: FC3, C3, CPz, Cz

ANSWER KEY -- ex-3-7 (multiple select), what the result licenses:
  LICENSED: 'target and standard differ somewhere in the tested window and channel set' (cluster p = 0.0020, family-wise error rate controlled at alpha = 0.05 over the whole tested space).
  LICENSED: reporting the effect size separately -- +3.571 uV (95% CI [+1.445, +5.697], dz = 1.201) from the a-priori window, which the cluster test does not provide.
  NOT LICENSED: 'the effect starts at 272 ms and ends at 640 ms'. Those boundaries are where |t| crossed an arbitrary threshold; the sweep in section 4 moves them by changing only that threshold.
  NOT LICENSED: 'the effect is significant at Pz and not elsewhere' -- the Pz-only test never looked elsewhere, and the spatio-temporal test found 2 cluster(s) spanning up to 9 channels.
  NOT LICENSED: comparing the t-sums of two clusters, or calling a cluster with p just above 0.05 'a trend'. The t-sum grows with cluster length and is not an effect size.

Pitfalls: pf-uncorrected-timepoint-tests, pf-cluster-inference-misread.  Widget: w-cluster-permutation-viz (mode erp).