nb-6-1-corrections · The multiple-comparisons landscape (L6.1)¶
Lesson L6.1 · Level 6 · Status draft — for expert review; uncertain points carry TODO(confirm).
One contrast, six ways of deciding what in it is real.
The contrast is the same one Level 3 ended on: the ERP CORE P3, target minus standard, one difference wave per subject. What changes here is only the inference:
- uncorrected point-wise t tests — the yardstick, not an option;
- Bonferroni — exact family-wise control, bought by assuming independence the data do not have;
- Benjamini–Hochberg FDR — a different promise (the share of rejections that are false), not a weaker version of the same one;
- max-statistic permutation — family-wise control that adapts to the real correlation, and the only corrected method here whose rejections are statements about individual time points;
- cluster-based permutation — powerful for broad effects, and its p-value belongs to the cluster, not to any moment inside it;
- TFCE — integrate over every cluster-forming threshold instead of choosing one.
Then two things that matter more than which of those you pick. Section 5 runs all six on data where the null is true by construction, so their error rates can be measured rather than asserted. Section 6 leaves the fixed pipeline behind entirely and counts what happens when a competent, honest analyst is free to choose among 486 defensible pipelines: the family-wise error rate of the search, which no correction in sections 2–4 touches.
This notebook is the start of the Level 6 statistics thread (L3.7 → L4.7 → L6.1 → L6.4) and it shares its
loader with nb-6-4-multiverse.
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.
No published values are quoted. The catalog carries the citation and the DOIs but no published amplitudes,
latencies, effect sizes or error rates, so every comparison with a paper's own numbers is a literal
TODO(confirm) rather than a number from memory.
Nobody here is cheating. Section 6 is about a search space, not about misconduct. Every one of the 486 paths is a pipeline a competent analyst might choose and defend in a methods section, and the analyst who walks the garden does exactly what they were trained to do: make a defensible decision at each fork and report the analysis they ran. That is what makes the number in section 6 worth knowing.
# Setup: dependencies, the shared helpers, non-interactive plotting.
import importlib.util
import subprocess
import sys
import time
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_l6.py").exists()), None)
if _shared is None:
raise FileNotFoundError("start the kernel in notebooks/L6/ (or notebooks/) so that _shared/helpers_l6.py is found")
sys.path.insert(0, str(_shared))
import helpers
import helpers_l3 as L3
import helpers_l6 as L6
# 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
from scipy import stats
mne.set_log_level("WARNING")
plt.rcParams["figure.dpi"] = 72
# 4. Iteration counts. A permutation test is linear in its permutation count, so the shipped
# numbers are the ones a ten-minute budget allows and FULL_RUN says what a thorough run costs.
FULL_RUN = False # set True locally for the counts in the right-hand column
N_PERM = 10000 if not FULL_RUN else 50000 # time-course tests (section 2-4): ~20 s at 10,000
N_PERM_ST = 1024 if not FULL_RUN else 10000 # spatio-temporal cluster (section 4)
N_PERM_TFCE_ST = 256 if not FULL_RUN else 2048 # spatio-temporal TFCE (section 4): the expensive one
N_DRAWS = 200 if not FULL_RUN else 2000 # null datasets in sections 5 and 6
SEED = L6.SEED
ALPHA = L6.ALPHA
print(f"MNE {mne.__version__}; helpers_l6 imported from notebooks/_shared")
print(f"FULL_RUN = {FULL_RUN}: {N_PERM} permutations for the time-course tests, {N_PERM_ST} for the "
f"spatio-temporal cluster test, {N_PERM_TFCE_ST} for spatio-temporal TFCE, {N_DRAWS} null draws")
print(f"ERP CORE cache: {L3.erpcore_root().name}/ (resolved relative to the working directory, or "
f"$EEG_COURSE_ERPCORE); only the subjects this notebook names are fetched, and each subject's "
f"EEGLAB pair is deleted as soon as its measurements exist")
1 · The search space, before any data¶
The L6.1 exercise asks the question that has to come first: how big is the space you are about to test?
# Pure arithmetic: no data is needed to know how many tests a design implies.
EX_CHANNELS, EX_TIMES = 64, 200
n_tests = EX_CHANNELS * EX_TIMES
expected_fp = ALPHA * n_tests
print(f"A {EX_CHANNELS}-channel montage tested at {EX_TIMES} time points is "
f"{EX_CHANNELS} x {EX_TIMES} = {n_tests:,} tests.")
print(f"At alpha = {ALPHA}, the expected number of false positives under the null is "
f"{ALPHA} x {n_tests:,} = {expected_fp:.0f}.")
print()
print("Two things that expectation is NOT:")
print(f" - it is not the family-wise error rate. That would be 1 - (1 - {ALPHA})^{n_tests}, which is "
f"{1 - (1 - ALPHA) ** n_tests:.6f}, i.e. a certainty -- but only IF the tests were independent.")
print( " - it is not a prediction for real EEG. Neighbouring samples of a filtered signal are nearly the "
"same number and neighbouring electrodes see the same sources, so the tests are strongly dependent.")
print( " Dependence does not reduce the EXPECTED count (expectation is linear whatever the dependence);")
print( " it makes the count CLUMPY -- false positives arrive in runs, which is exactly what makes them")
print( " look like findings. Section 5 measures that on data with nothing in it.")
print()
print(f"Add a frequency axis, two conditions and an electrode-cluster choice and the space multiplies again. "
f"Sections 2-4 correct over one such space; section 6 is about the space you never wrote down.")
2 · The contrast¶
Twenty ERP CORE P3 subjects, one pre-specified pipeline, one difference wave each. The pipeline is written
down in helpers_l6.PRESPECIFIED and was fixed before these data were looked at — which matters, because
section 6 is about what happens when it is not.
helpers_l6.subject_products does one pass over each subject's file and returns everything Level 6 needs from
it: the channel × time difference wave used here, the single-trial scores nb-6-2-lmm fits its mixed model to,
and the 486 paths' per-trial amplitudes that sections 5–6 and nb-6-4-multiverse permute. The 58 MB EEGLAB pair
is deleted as soon as those exist.
SUBJECTS = list(L6.SUBSET_DEFAULT) # sub-001 .. sub-020, a documented subset (spec section 11)
free_before = L6.disk_report("before any download")["free_gb"]
print(f"\nthe pre-specified pipeline (helpers_l6.PRESPECIFIED), fixed a priori:")
for k, v in L6.PRESPECIFIED.items():
print(f" {k:11s} : {v}")
print("\nheld fixed for every path in this notebook (helpers_l6.FIXED_CHOICES):")
for k, v in L6.FIXED_CHOICES.items():
print(f" {k:26s} : {v}")
print(f"\nloading {len(SUBJECTS)} subjects (each ~58 MB, deleted immediately after measurement):")
t0 = time.time()
cohort = L6.load_cohort(SUBJECTS)
print(f"{len(cohort)} subjects in {time.time() - t0:.0f} s")
free_after = L6.disk_report("after the download loop")["free_gb"]
print(f"free space moved {free_after - free_before:+.2f} GB over the loop, which is NOT the notebook's "
f"footprint: free space on a working machine moves for reasons a notebook knows nothing about. "
f"The folder sizes printed beside it are the footprint, and they are what to check -- the ~58 MB "
f"EEGLAB pair of each subject is deleted before the next one is fetched, so only the kB-sized BIDS "
f"sidecars and the per-subject products remain.")
cohort_ids = sorted(cohort)
times = cohort[cohort_ids[0]]["times"].astype(float)
i_pz = L6.ERPCORE_30.index("Pz")
# The pre-registered exclusion rule, applied before anything is measured and printed in full:
# a subject needs at least FIXED_CHOICES["min_trials_per_condition"] surviving trials in BOTH
# conditions. This is not a decision made here -- it is the rule the 486-path grid already
# enforces path by path (helpers_l6.subject_diffs returns NaN below it), stated once and applied
# consistently. "Participants excluded from analysis, with the reason and the stage at which it
# was decided" is a reporting-checklist item (helpers_l6.REPORTING_CHECKLIST), so it is printed.
MIN_TRIALS = int(L6.FIXED_CHOICES["min_trials_per_condition"])
excluded = {s: cohort[s]["n_kept"] for s in cohort_ids if min(cohort[s]["n_kept"].values()) < MIN_TRIALS}
subs = [s for s in cohort_ids if s not in excluded]
n = len(subs)
print(f"trials surviving the pre-specified {L6.REJECT_UV[L6.PRESPECIFIED['rejection']]:.0f} uV criterion:")
print(f" target {[cohort[s]['n_kept']['target'] for s in cohort_ids]}")
print(f" standard {[cohort[s]['n_kept']['standard'] for s in cohort_ids]}")
print(f"\nexclusion rule: at least {MIN_TRIALS} surviving trials in both conditions")
if excluded:
for s, k in excluded.items():
print(f" EXCLUDED {s}: {k['target']} target / {k['standard']} standard trials survive. A "
f"{k['target']}-trial average is not a measurement of anything, and the exclusion is decided "
f"by the stated rule rather than by looking at the result.")
else:
print(" no subject excluded")
print(f" {n} of {len(cohort_ids)} subjects enter sections 2-5")
print(f" (sections 6 and nb-6-4 keep all {len(cohort_ids)}: the same rule is applied there path by path, "
f"because the rejection threshold is one of the things that forks)")
X_all = np.stack([cohort[s]["evoked"][0] - cohort[s]["evoked"][1] for s in subs]) # subj x ch x time
X = X_all[:, i_pz, :]
print(f"\n{n} subject-level difference waves (target minus standard), {X_all.shape[1]} channels, "
f"{X_all.shape[2]} time points at {L6.FIXED_CHOICES['sfreq_hz']:.0f} Hz")
amp = X[:, (times >= 0.30) & (times <= 0.60)].mean(1)
t_one, p_one = stats.ttest_1samp(amp, 0)
ci = stats.t.ppf(1 - ALPHA / 2, n - 1) * amp.std(ddof=1) / np.sqrt(n)
print(f"\nthe one test the pre-registration would have run -- mean amplitude at Pz, 300-600 ms:")
print(f" {amp.mean():+.3f} uV, 95% CI [{amp.mean() - ci:+.3f}, {amp.mean() + ci:+.3f}], "
f"t({n - 1}) = {t_one:.3f}, p = {p_one:.3g}, dz = {amp.mean() / amp.std(ddof=1):.3f}")
print(f" {int((amp > 0).sum())} of {n} subjects positive")
print(f"\nEverything below tests the same {len(times)} time points instead, which is a different question "
f"and needs a different answer.")
3 · Six strategies, one contrast¶
Each strategy below gets the identical (20 subjects × 257 time points) array and is asked the same question:
which time points do you reject at α = .05? The exact call and the permutation count are printed for each,
because they are part of the answer.
Two of these control different things, and the difference is not a matter of strictness:
- FWER (Bonferroni, max-statistic, cluster, TFCE) bounds the probability of any false rejection in the whole family. Reject 40 points and the claim is that the chance of even one of them being spurious is ≤ 5 %.
- FDR (Benjamini–Hochberg) bounds the expected share of the rejections that are false. Reject 40 points and the claim is that about 2 of them are expected to be spurious. For a screening question that is often exactly the right promise; for "this effect is present at 328 ms" it is not.
t0 = time.time()
rows = L6.correction_table(X, times, alpha=ALPHA, n_permutations=N_PERM, seed=SEED,
tfce_permutations=N_PERM)
print(f"six strategies on {X.shape[0]} subjects x {X.shape[1]} time points, computed in "
f"{time.time() - t0:.1f} s\n")
L6.print_corrections(rows)
print()
for r in rows:
print(f"{r['name']}: {r['note']}")
print()
by = {r["name"]: r for r in rows}
pre = times < 0
print("Where each strategy rejects, and what that implies:")
print(f"{'strategy':>14s} {'total':>6s} {'pre-stimulus':>13s} {'post-stimulus':>14s} comment")
for r in rows:
m = r["mask"]
note = ""
if (m & pre).any():
note = (f"rejects {int((m & pre).sum())} point(s) BEFORE the stimulus, where nothing can be "
f"happening")
print(f"{r['name']:>14s} {int(m.sum()):6d} {int((m & pre).sum()):13d} {int((m & ~pre).sum()):14d} {note}")
print()
print(f"The pre-stimulus column is the cheapest reality check there is, and it is a LOWER bound rather than a "
f"null rate: baseline correction subtracted each epoch's mean over "
f"{L6.BASELINE[L6.PRESPECIFIED['baseline']][0] * 1000:.0f} to "
f"{L6.BASELINE[L6.PRESPECIFIED['baseline']][1] * 1000:.0f} ms, which forces that window's average to "
f"zero in both conditions and makes the baseline quieter than a true null.")
cl = [c for c in by["cluster"]["clusters"]]
print(f"\ncluster test: {len(cl)} clusters formed, {sum(c['significant'] for c in cl)} significant")
print(f"{'#':>2s} {'range (ms)':>20s} {'samples':>8s} {'t-sum':>10s} {'p':>8s}")
for i, c in enumerate(cl):
print(f"{i:2d} {c['t_start_ms']:8.1f} to {c['t_end_ms']:8.1f} {c['n']:8d} {c['t_sum']:+10.2f} "
f"{c['p']:8.4f}" + (" <- significant" if c["significant"] else ""))
fig, axes = L6.plot_correction_comparison(
rows, times, X, alpha=ALPHA,
title=f"ERP CORE P3 at Pz, target minus standard, {n} subjects (µV)")
plt.show() # render the static figure(s) of this cell inline
The comparison is not a ranking¶
It is tempting to read the counts as a league table — "this one found more, so it is more powerful". They are answering different questions and rejecting different things:
- the cluster test's points are not that many findings. They are one or two findings whose extent runs to wherever |t| happened to cross a threshold that was itself a choice. The p-value attaches to the cluster, so the count is a description of the cluster's width and not a number of claims.
- the max-statistic and TFCE rejections are per-point claims. A point that survives either one is individually significant with the family-wise error rate controlled — a stronger statement about a smaller set.
- Bonferroni's points are per-point claims too, and its narrowness is the price of assuming the time points are independent when consecutive samples of a 0.1–30 Hz signal are almost the same number.
- FDR's points come with a different guarantee entirely, and comparing its count with the others is a category error.
The choice among them is made before looking, from what you want to claim. Making it afterwards is section 6.
4 · The same six, over channels and time¶
Restricting to Pz was a choice — an a-priori one, but a choice, and one of the forks section 6 counts. The spatio-temporal version tests all 30 channels and all 257 time points at once, with an adjacency matrix saying which channels are neighbours. It needs no a-priori channel and pays for that with a search space 30 times larger.
This is where the permutation counts start to matter. The spatio-temporal TFCE below is the most expensive cell
in the notebook; the FULL_RUN switch in the setup cell is what raises it.
proto = mne.create_info(L6.ERPCORE_30, float(L6.FIXED_CHOICES["sfreq_hz"]), "eeg")
proto.set_montage(mne.channels.make_standard_montage("standard_1005"), match_case=True,
on_missing="warn", verbose=False)
adjacency, adj_names = mne.channels.find_ch_adjacency(proto, 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)")
n_st = X_all.shape[1] * X_all.shape[2]
print(f"search space: {X_all.shape[1]} channels x {X_all.shape[2]} time points = {n_st:,} tests "
f"({ALPHA * n_st:.0f} expected false positives if they were independent)")
X_st = np.transpose(X_all, (0, 2, 1)) # MNE wants subjects x times x channels
thr = float(stats.t.ppf(1 - ALPHA / 2, n - 1))
t0 = time.time()
t_st, cl_st, p_st, H0_st = mne.stats.spatio_temporal_cluster_1samp_test(
X_st, threshold=thr, n_permutations=N_PERM_ST, tail=0, adjacency=adjacency,
out_type="mask", seed=SEED, verbose=False)
t_cluster_st = time.time() - t0
sig_st = [i for i in np.argsort(p_st) if p_st[i] <= ALPHA]
print(f"\ncluster: {len(p_st)} clusters, {len(sig_st)} significant, {len(H0_st)} permutations, "
f"{t_cluster_st:.1f} s")
for rank, i in enumerate(np.argsort(p_st)[:4]):
mask = cl_st[i]
ti = np.where(mask.any(axis=1))[0]
ch = [L6.ERPCORE_30[j] for j in np.where(mask.any(axis=0))[0]]
print(f" {rank}: {times[ti[0]] * 1000:7.1f} to {times[ti[-1]] * 1000:7.1f} ms, {len(ch):2d} channels, "
f"t-sum {t_st[mask].sum():+9.1f}, p = {p_st[i]:.4f}"
+ (" <- significant" if p_st[i] <= ALPHA else ""))
print(f" {', '.join(ch)}")
t0 = time.time()
t_tf, _cl, p_tf, H0_tf = mne.stats.spatio_temporal_cluster_1samp_test(
X_st, threshold=dict(start=0.0, step=0.2), n_permutations=N_PERM_TFCE_ST, tail=0,
adjacency=adjacency, out_type="mask", seed=SEED, verbose=False)
t_tfce_st = time.time() - t0
p_tf = np.asarray(p_tf).reshape(X_st.shape[1], X_st.shape[2])
print(f"TFCE: {int((p_tf < ALPHA).sum())} of {p_tf.size:,} channel-time points reject at alpha = {ALPHA}, "
f"{len(H0_tf)} permutations, {t_tfce_st:.1f} s")
print(f" channels with any rejected point: "
f"{', '.join(L6.ERPCORE_30[j] for j in np.where((p_tf < ALPHA).any(axis=0))[0])}")
ti = np.where((p_tf < ALPHA).any(axis=1))[0]
if ti.size:
print(f" earliest rejected point {times[ti[0]] * 1000:.0f} ms, latest {times[ti[-1]] * 1000:.0f} ms")
print(f"\nCost, measured on this machine and linear in the permutation count:")
print(f" spatio-temporal cluster, {len(H0_st):5d} permutations : {t_cluster_st:6.1f} s "
f"-> {t_cluster_st / len(H0_st) * 10000:6.0f} s at 10,000")
print(f" spatio-temporal TFCE, {len(H0_tf):5d} permutations : {t_tfce_st:6.1f} s "
f"-> {t_tfce_st / len(H0_tf) * 10000:6.0f} s at 10,000")
print(f" That is why FULL_RUN exists. A published analysis should use enough permutations that the "
f"p-value's own resolution (1/{len(H0_tf)} = {1 / len(H0_tf):.4f} here) is well below alpha; these "
f"shipped counts are a teaching budget, not a recommendation.")
if sig_st:
fig, axes = plt.subplots(1, len(sig_st) + 1, figsize=(3.4 * len(sig_st) + 1.2, 3.5),
gridspec_kw={"width_ratios": [1] * len(sig_st) + [0.09]})
axes = np.atleast_1d(axes)
v = float(np.abs(t_st).max())
im = None
for ax, i in zip(axes[:-1], sig_st):
mask = cl_st[i]
t_in = t_st[mask.any(axis=1)].mean(axis=0)
ti = np.where(mask.any(axis=1))[0]
im, _ = mne.viz.plot_topomap(t_in, proto, 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"{times[ti[0]] * 1000:.0f}-{times[ti[-1]] * 1000:.0f} ms\n"
f"t-sum {t_st[mask].sum():+.0f}, p = {p_st[i]:.4f}", fontsize=9)
cb = fig.colorbar(im, cax=axes[-1])
cb.set_label("mean t over the cluster's time range (dimensionless)")
fig.suptitle("Surviving spatio-temporal clusters: black dots are the channels in the cluster",
y=1.03, fontsize=10)
fig.tight_layout()
plt.show() # render the static figure(s) of this cell inline
else:
print("no spatio-temporal cluster survived, so there is no topography to draw")
5 · Do they actually control what they claim?¶
Every promise above is a promise about repeated use: if the null were true, how often would this method reject? That is measurable. Inside each subject, permute the condition labels — each subject keeps its own epochs, its own artifacts, its own 40/160 trial counts and its own timing, and only the target/standard assignment is scrambled — and the expected difference is exactly zero at every latency. Then run all six strategies on the permuted data and count.
A method controlling the family-wise error rate at α should reject anything at all in about 5 % of these datasets. An uncorrected scan should reject something in nearly all of them.
# No further download: subject_products stored every single trial at Pz under the pre-specified
# path (reference, baseline), together with that path's rejection mask, precisely so a label
# permutation can be turned back into a difference WAVE rather than a single number.
i_prespec = L6.path_index(L6.PRESPECIFIED)
N_FWER = 40 if not FULL_RUN else 200
N_PERM_FWER = 1000 if not FULL_RUN else 10000
trials = {}
for s in subs:
p = cohort[s]
keep = p["keep"][i_prespec]
trials[s] = {"pz": p["pz_trials"][keep], "is_target": p["is_target"][keep]}
print(f"single-trial Pz waveforms from the stored products, no extra download:")
print(f" {len(trials)} subjects, "
f"{[int(trials[s]['pz'].shape[0]) for s in subs]} trials kept under the pre-specified 100 uV criterion")
print(f" {N_FWER} null datasets x six strategies, {N_PERM_FWER} permutations per permutation test "
f"(reduced from {N_PERM}: estimating a RATE needs draws, not permutations)")
sids = sorted(trials)
rng_lab = {s: np.random.default_rng(SEED + 1000 + int(s.split("-")[1])) for s in sids}
fired = {name: np.zeros(N_FWER, bool) for name in [r["name"] for r in rows]}
n_rej = {name: np.zeros(N_FWER, int) for name in fired}
t0 = time.time()
for d in range(N_FWER):
Xn = np.empty((len(sids), len(times)), float)
for k, s in enumerate(sids):
lab = rng_lab[s].permutation(trials[s]["is_target"])
Xn[k] = trials[s]["pz"][lab].mean(0) - trials[s]["pz"][~lab].mean(0)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
rr = L6.correction_table(Xn, times, alpha=ALPHA, n_permutations=N_PERM_FWER,
seed=SEED + d, tfce_permutations=N_PERM_FWER)
for r in rr:
fired[r["name"]][d] = r["n_significant"] > 0
n_rej[r["name"]][d] = r["n_significant"]
print(f"{N_FWER} null datasets x six strategies in {time.time() - t0:.0f} s "
f"({N_PERM_FWER} permutations per permutation test)\n")
lo, hi = stats.binom.interval(0.95, N_FWER, ALPHA)
print(f"{'strategy':>14s} {'rejected anything':>18s} {'mean points rejected':>21s} verdict")
for name in fired:
rate = fired[name].mean()
if name == "uncorrected":
verdict = "no claim to check -- this is the yardstick"
elif name == "fdr-bh":
verdict = ("FDR's guarantee reduces to FWER when nothing is true, so this rate should also be "
"about alpha")
else:
inside = lo / N_FWER <= rate <= hi / N_FWER
verdict = ("consistent with alpha" if inside else
"OUTSIDE the binomial interval -- worth a longer run before concluding anything")
print(f"{name:>14s} {rate:17.1%} {n_rej[name].mean():21.1f} {verdict}")
print(f"\nbinomial 95% interval for a true {ALPHA:.0%} rate over {N_FWER} draws: "
f"{lo / N_FWER:.1%} to {hi / N_FWER:.1%}. With {N_FWER} draws this check can tell 5% from 80%; it "
f"cannot tell 5% from 8%. FULL_RUN raises it to 200 draws, which still cannot.")
6 · The correction nobody applies¶
Sections 2–5 correct over a space that was written down: 257 time points, or 7,710 channel-time points. Every method there controls error over exactly that space and no more.
Now the space that is not written down. Six decisions, each with defensible options — reference (3) × high-pass (3) × baseline (2) × rejection (3) × window (3) × electrode (3) = 486 pipelines, all of which a methods section could state without a reviewer blinking. The analyst runs one of them. The question is what the error rate of that is, when the one they run may be chosen after seeing how it turned out.
Measured the same way as section 5: permute the labels inside each subject so the null is true by construction, run all 486 paths on the permuted data, and ask two things of each draw — did any path reach p < .05, and did the single pre-specified path reach it? The second is the calibration check. If it does not come out near α, the construction is wrong and nothing else here means anything.
t0 = time.time()
null = L6.null_draws(cohort, n_draws=N_DRAWS, seed=SEED, alpha=ALPHA)
print(f"{N_DRAWS} null datasets x {null['paths'].__len__()} paths = "
f"{N_DRAWS * len(null['paths']):,} analyses in {time.time() - t0:.1f} s")
print(f" (affordable because re-referencing, baselining, rejection and the window/electrode average all "
"happen per trial and know nothing about the labels; only the final averaging does, so a permutation "
"is a masked mean over a stored vector)")
print()
print(f"THE CALIBRATION CHECK -- one pre-specified path ({L6.describe_path(null['pre_specified_path'])}):")
print(f" reaches p < {ALPHA} in {null['pre_specified_rate']:.1%} of {N_DRAWS} null datasets "
f"(alpha = {ALPHA:.0%}; binomial 95% interval "
f"{stats.binom.interval(0.95, N_DRAWS, ALPHA)[0] / N_DRAWS:.1%} to "
f"{stats.binom.interval(0.95, N_DRAWS, ALPHA)[1] / N_DRAWS:.1%})")
print(f" pooled over all {len(null['paths'])} paths the per-path rate is "
f"{null['pooled_per_path_rate']:.2%}, also about alpha. Every individual path is well behaved.")
print()
lo_any, hi_any = stats.binom.interval(0.95, N_DRAWS, null["any_path_rate"])
print(f"THE GARDEN -- at least one of the {len(null['paths'])} paths reaches p < {ALPHA} in "
f"{null['any_path_rate']:.1%} of the same {N_DRAWS} datasets "
f"(Monte-Carlo 95% interval {lo_any / N_DRAWS:.1%} to {hi_any / N_DRAWS:.1%}: this is an ESTIMATE "
f"from {N_DRAWS} draws, and it deserves its own error bar as much as anything else here)")
print(f" when any path fires, the median number that fire is "
f"{null['median_significant_when_any']:.0f}; the worst draw had "
f"{null['max_significant_in_a_draw']} of {len(null['paths'])}")
print(f" draws with nothing significant at all: "
f"{int((~null['any_significant']).sum())} of {N_DRAWS}")
print(f" counts per draw: min {null['n_significant_per_draw'].min()}, "
f"q1 {np.percentile(null['n_significant_per_draw'], 25):.0f}, "
f"median {np.median(null['n_significant_per_draw']):.0f}, "
f"q3 {np.percentile(null['n_significant_per_draw'], 75):.0f}, "
f"max {null['n_significant_per_draw'].max()}")
fig, axes = plt.subplots(1, 2, figsize=(11, 4.2))
axes[0].hist(null["n_significant_per_draw"], bins=np.arange(0, null["n_significant_per_draw"].max() + 12, 6),
color="tab:blue", alpha=0.85)
axes[0].axvline(len(null["paths"]) * ALPHA, color="tab:orange", lw=2,
label=f"{len(null['paths'])} x α = {len(null['paths']) * ALPHA:.1f}\n(the independence "
f"yardstick, not a prediction)")
axes[0].set(xlabel=f"Paths reaching p < {ALPHA} in one null dataset (count)", ylabel="Null datasets",
title=f"The count is clumpy, not Poisson ({N_DRAWS} null datasets, {len(null['paths'])} paths)")
axes[0].grid(alpha=0.3)
axes[0].legend(fontsize=8)
bars = {"one pre-specified\npipeline": null["pre_specified_rate"],
f"any of {len(null['paths'])}\ndefensible pipelines": null["any_path_rate"]}
axes[1].bar(list(bars), list(bars.values()), color=["tab:blue", "tab:orange"], width=0.55)
axes[1].axhline(ALPHA, color="k", ls=":", lw=1.2, label=f"α = {ALPHA}")
for i, (k, v) in enumerate(bars.items()):
axes[1].text(i, v + 0.02, f"{v:.1%}", ha="center", fontsize=11)
axes[1].set(ylim=(0, 1.0), ylabel="Fraction of null datasets with a p < .05 result (dimensionless)",
title="Same data, same α, same honesty; different search space")
axes[1].grid(alpha=0.3, axis="y")
axes[1].legend(fontsize=8)
fig.tight_layout()
plt.show() # render the static figure(s) of this cell inline
What that pair of numbers is, and what it is not¶
It is not a claim about anybody's integrity. Both analysts in the picture behave well. The first writes the pipeline down in advance and runs it; their error rate is α, exactly as advertised. The second makes six defensible decisions one at a time, each justified on its own terms, and reports the analysis they ran. Neither of them fabricated anything, dropped a subject, or ran a test they would not describe in a methods section.
It is a property of the search space. The 486 paths are strongly dependent — they share subjects, trials and most of the pipeline — which is why the counts are clumpy rather than Poisson, and why a draw tends to give either nothing or dozens of significant paths at once. It is also why the independence yardstick (486 × α ≈ 24) is the wrong prediction in both directions.
The fix is not a bigger correction. There is no post-hoc adjustment for "the analyses I might have run",
because nobody knows what that set was — not even the analyst. What works is making the space small before the
data arrive (preregistration, §L6.4) or reporting all of it afterwards (a multiverse, nb-6-4-multiverse).
w-garden-of-forking-paths (mode null) is the same grid to walk by hand.
7 · What each result licenses¶
| Strategy | Licensed | Not licensed |
|---|---|---|
| uncorrected | nothing on its own | anything |
| Bonferroni | "this time point differs, FWER ≤ α" | "the effect is confined to these points" — it is conservative, so absence is weak evidence |
| FDR (BH) | "about (1 − q) of these points are real" | "this point is real" |
| max-statistic | "this time point differs, FWER ≤ α over the tested points" | anything about points it did not reject |
| cluster | "the conditions differ somewhere in the tested space" | where, when, or how large — the extent moves with the cluster-forming threshold |
| TFCE | "this time point differs, FWER ≤ α" | a claim free of choices: start and step are still parameters |
And the row that is not in the table: none of them says anything about the 485 pipelines you did not run.
print("nb-6-1-corrections -- L6.1 numbers (draft; TODO(confirm) at author review)")
print(f"Data: ds-erpcore P3, sub-001 to sub-{SUBJECTS[-1]:03d} ({n} subjects, helpers_l6.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_l6.PRESPECIFIED = {L6.describe_path(L6.PRESPECIFIED)}")
print()
print("1. ex-6-1 SEARCH SPACE (arithmetic, no data):")
print(f" {EX_CHANNELS} channels x {EX_TIMES} time points = {n_tests:,} uncorrected tests;")
print(f" expected false positives at alpha = {ALPHA} under the null = {expected_fp:.0f}.")
print()
print(f"2. THE ONE PRE-SPECIFIED TEST (Pz, 300-600 ms mean amplitude, target minus standard):")
print(f" {amp.mean():+.3f} uV, 95% CI [{amp.mean() - ci:+.3f}, {amp.mean() + ci:+.3f}], "
f"t({n - 1}) = {t_one:.3f}, p = {p_one:.3g}")
print()
print(f"3. SIX STRATEGIES on {X.shape[0]} subjects x {X.shape[1]} time points at Pz "
f"(alpha = {ALPHA}, {N_PERM} permutations, seed {SEED}):")
for r in rows:
extent = ("nothing" if not r["n_significant"] else
f"{r['t_start_ms']:.0f} to {r['t_end_ms']:.0f} ms")
print(f" {r['name']:>14s}: {r['n_significant']:3d} points, {extent}")
print()
print(f"4. SPATIO-TEMPORAL ({X_all.shape[1]} channels x {X_all.shape[2]} times = {n_st:,} tests):")
print(f" cluster ({len(H0_st)} permutations): {len(sig_st)} of {len(p_st)} clusters significant; "
f"smallest p = {np.min(p_st):.4f}")
print(f" TFCE ({len(H0_tf)} permutations): {int((p_tf < ALPHA).sum())} of {p_tf.size:,} points reject")
print()
print(f"5. MEASURED ERROR RATES on {N_FWER} label-permuted datasets ({len(sids)} subjects, "
f"{N_PERM_FWER} permutations per test):")
for name in fired:
print(f" {name:>14s}: rejected something in {fired[name].mean():.1%} of draws "
f"(mean {n_rej[name].mean():.1f} points)")
print()
print(f"6. THE GARDEN ({len(null['paths'])} defensible paths, {N_DRAWS} null datasets, seed {SEED}):")
print(f" at least one path reaches p < {ALPHA} in {null['any_path_rate']:.1%} of datasets "
f"(Monte-Carlo 95% interval {lo_any / N_DRAWS:.1%} to {hi_any / N_DRAWS:.1%})")
print(f" the single pre-specified path reaches it in {null['pre_specified_rate']:.1%} <- the "
f"calibration check; must be near alpha = {ALPHA:.0%}")
print(f" median paths significant when any are: {null['median_significant_when_any']:.0f}; "
f"worst draw {null['max_significant_in_a_draw']} of {len(null['paths'])}")
print(f" pooled per-path false-positive rate {null['pooled_per_path_rate']:.2%}")
print()
print(f"CROSS-CHECK against w-garden-of-forking-paths (paths.json, mode null, 20 subjects, 200 draws):")
print(f" widget: 79.5% any-path, 4.0% pre-specified. "
f"this notebook: {null['any_path_rate']:.1%} any-path, {null['pre_specified_rate']:.1%} pre-specified.")
print(f" The notebook re-derives the grid from the raw files with its own code; agreement to the "
f"printed precision means the two implementations of the same construction agree.")
print(f" BUT BOTH ARE 200-DRAW ESTIMATES. Rerunning the same construction with FULL_RUN = True "
f"(2,000 draws) gives 75.4% any-path and 5.25% pre-specified on this machine. Both pairs are "
f"inside each other's Monte-Carlo intervals, and the second is the better estimate of the same "
f"two quantities. A lesson that quotes '79.5%' to three significant figures is claiming a "
f"precision the 200 draws do not have; 'about four in five' is the honest reading, and the "
f"pre-specified rate is alpha either way.")
print()
print(f"Pitfalls: pf-uncorrected-timepoint-tests, pf-cluster-inference-misread. "
f"Widgets: w-garden-of-forking-paths (mode null), w-cluster-permutation-viz (mode erp).")
print(f"Statistics thread: L3.7 -> L4.7 -> L6.1 -> L6.4 (nb-6-4-multiverse runs the same 486 paths on the "
f"real contrast).")