The multiple-comparisons landscape: one ERP contrast through six inference strategies, their error rates measured on a true null, and the family-wise error rate of the search itself

nb-6-1-corrections Level 6 · Inference and Rigor ~8 min Used in L6.1 · The multiple-comparisons landscape

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-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:

  1. uncorrected point-wise t tests — the yardstick, not an option;
  2. Bonferroni — exact family-wise control, bought by assuming independence the data do not have;
  3. Benjamini–Hochberg FDR — a different promise (the share of rejections that are false), not a weaker version of the same one;
  4. 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;
  5. cluster-based permutation — powerful for broad effects, and its p-value belongs to the cluster, not to any moment inside it;
  6. 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.

In [1]:
# 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")
MNE 1.10.2; helpers_l6 imported from notebooks/_shared
FULL_RUN = False: 10000 permutations for the time-course tests, 1024 for the spatio-temporal cluster test, 256 for spatio-temporal TFCE, 200 null draws
ERP CORE cache: erpcore/ (resolved relative to the working directory, or $EEG_COURSE_ERPCORE); only the subjects this notebook names are fetched, and each subject's 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?

In [2]:
# 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.")
A 64-channel montage tested at 200 time points is 64 x 200 = 12,800 tests.
At alpha = 0.05, the expected number of false positives under the null is 0.05 x 12,800 = 640.

Two things that expectation is NOT:
  - it is not the family-wise error rate.  That would be 1 - (1 - 0.05)^12800, which is 1.000000, i.e. a certainty -- but only IF the tests were independent.
  - 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.
    Dependence does not reduce the EXPECTED count (expectation is linear whatever the dependence);
    it makes the count CLUMPY -- false positives arrive in runs, which is exactly what makes them
    look like findings.  Section 5 measures that on data with nothing in it.

Add a frequency axis, two conditions and an electrode-cluster choice and the space multiplies again. 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.

In [3]:
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.")
free disk before any download: 4.29 GB  (ERP CORE cache 0.0 MB, Level-6 products 0.0 MB)

the pre-specified pipeline (helpers_l6.PRESPECIFIED), fixed a priori:
   reference   : average
   highpass    : 0.1
   baseline    : -200..0
   rejection   : 100 uV
   window      : 300-600
   electrode   : Pz

held fixed for every path in this notebook (helpers_l6.FIXED_CHOICES):
   low_pass_hz                : 30.0
   epoch_s                    : (-0.2, 0.8)
   sfreq_hz                   : 256.0
   source_sfreq_hz            : 1024.0
   ocular_correction          : regression on bipolar HEOG/VEOG derivations (mne.preprocessing.EOGRegression), fitted on epochs with each condition's evoked response removed (L2.6)
   measure                    : mean amplitude over the measurement window
   group_test                 : two-sided paired t-test of the per-subject target-minus-standard mean amplitude against zero
   min_trials_per_condition   : 15
   note                       : these are held fixed so the grid stays readable.  The real space of defensible pipelines is larger than 486, which is itself part of the lesson.

loading 20 subjects (each ~58 MB, deleted immediately after measurement):
  sub-001: 200 trials, computed in 140.0 s; free disk 4.29 GB
  sub-002: 200 trials, computed in 20.2 s; free disk 4.29 GB
  sub-003: 200 trials, computed in 17.9 s; free disk 4.29 GB
  sub-004: 200 trials, computed in 29.7 s; free disk 4.30 GB
  sub-005: 200 trials, computed in 22.4 s; free disk 4.29 GB
  sub-006: 200 trials, computed in 81.9 s; free disk 4.28 GB
  sub-007: 200 trials, computed in 23.6 s; free disk 4.29 GB
  sub-008: 200 trials, computed in 12.4 s; free disk 4.29 GB
  sub-009: 200 trials, computed in 15.7 s; free disk 4.31 GB
  sub-010: 200 trials, computed in 17.4 s; free disk 4.30 GB
  sub-011: 200 trials, computed in 18.7 s; free disk 4.28 GB
  sub-012: 200 trials, computed in 72.5 s; free disk 4.12 GB
  sub-013: 200 trials, computed in 32.8 s; free disk 4.11 GB
  sub-014: 200 trials, computed in 27.2 s; free disk 4.26 GB
  sub-015: 200 trials, computed in 19.6 s; free disk 4.29 GB
  sub-016: 200 trials, computed in 18.7 s; free disk 4.28 GB
  sub-017: 200 trials, computed in 20.9 s; free disk 4.27 GB
  sub-018: 200 trials, computed in 18.1 s; free disk 4.26 GB
  sub-019: 200 trials, computed in 20.3 s; free disk 4.28 GB
  sub-020: 200 trials, computed in 20.6 s; free disk 4.29 GB
20 subjects in 651 s
free disk after the download loop: 4.29 GB  (ERP CORE cache 0.4 MB, Level-6 products 7.5 MB)
free space moved -0.01 GB over the loop, which is NOT the notebook's footprint: free space on a working machine moves for reasons a notebook knows nothing about.  The folder sizes printed beside it are the footprint, and they are what to check -- the ~58 MB EEGLAB pair of each subject is deleted before the next one is fetched, so only the kB-sized BIDS sidecars and the per-subject products remain.
In [4]:
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.")
trials surviving the pre-specified 100 uV criterion:
   target   [31, 40, 37, 40, 37, 18, 40, 38, 1, 35, 28, 40, 40, 40, 38, 35, 39, 38, 40, 39]
   standard [130, 157, 155, 157, 148, 81, 160, 144, 4, 145, 101, 154, 160, 151, 157, 144, 158, 157, 159, 157]

exclusion rule: at least 15 surviving trials in both conditions
   EXCLUDED sub-009: 1 target / 4 standard trials survive.  A 1-trial average is not a measurement of anything, and the exclusion is decided by the stated rule rather than by looking at the result.
   19 of 20 subjects enter sections 2-5
   (sections 6 and nb-6-4 keep all 20: the same rule is applied there path by path, because the rejection threshold is one of the things that forks)

19 subject-level difference waves (target minus standard), 30 channels, 257 time points at 256 Hz

the one test the pre-registration would have run -- mean amplitude at Pz, 300-600 ms:
   +2.737 uV, 95% CI [+1.600, +3.874], t(18) = 5.057, p = 8.21e-05, dz = 1.160
   19 of 19 subjects positive

Everything below tests the same 257 time points instead, which is a different question 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.
In [5]:
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()
six strategies on 19 subjects x 257 time points, computed in 8.3 s

      strategy  points p < a            extent (ms)  detail
   uncorrected            89            -160 to 637  257 independent-looking tests at alpha = 0.05
    bonferroni            32             410 to 531  alpha/257 = 1.946e-04 per test
        fdr-bh            80             316 to 625  Benjamini-Hochberg at q = 0.05
 max-statistic            61             367 to 609  mne.stats.permutation_t_test(n_permutations=10000, tail=0, seed=20260918)
       cluster            86             305 to 637  cluster-forming |t| > 2.101 (two-sided t at alpha = 0.05, 18 df); 10000 permutations, seed 20260918
          tfce            56             371 to 605  TFCE threshold=dict(start=0, step=0.2); 10000 permutations, seed 20260918

uncorrected: no correction at all.  Included as the yardstick, not as an option: with this many tests the family-wise error rate is not 5 %, and because neighbouring samples of a filtered signal are nearly the same number it is not 1 - 0.95^n either.  It is unknown.

bonferroni: controls the family-wise error rate by dividing alpha by the number of tests.  Exact and assumption-free, and far too conservative when the tests are as correlated as adjacent EEG samples are -- it pays for independence it does not have.

fdr-bh: Benjamini-Hochberg controls the *false discovery rate*: the expected share of the rejections that are false, not the chance of any false rejection.  A different promise, not a weaker version of the same one, and one that many EEG questions are happy with.

max-statistic: permutation on the largest |t| in the map.  Exact family-wise control under the sign-flip null, adapts to whatever correlation the data have, and -- unlike the cluster test -- every point it rejects is individually significant, so it licenses statements about single time points.

cluster: permutation on the summed t of contiguous supra-threshold points.  Powerful for broad, sustained effects, and the p-value belongs to the cluster as a whole: it licenses 'there is an effect somewhere in the tested space' and nothing about where or when.

tfce: threshold-free cluster enhancement: integrate support over every threshold instead of picking one, then permute the enhanced map.  Removes the cluster-forming threshold as a researcher degree of freedom and returns a p-value per point, at a cost in computation.

In [6]:
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 ""))
Where each strategy rejects, and what that implies:
      strategy  total  pre-stimulus  post-stimulus  comment
   uncorrected     89             3             86  rejects 3 point(s) BEFORE the stimulus, where nothing can be happening
    bonferroni     32             0             32  
        fdr-bh     80             0             80  
 max-statistic     61             0             61  
       cluster     86             0             86  
          tfce     56             0             56  

The pre-stimulus column is the cheapest reality check there is, and it is a LOWER bound rather than a null rate: baseline correction subtracted each epoch's mean over -200 to 0 ms, which forces that window's average to zero in both conditions and makes the baseline quieter than a true null.

cluster test: 2 clusters formed, 1 significant
 #           range (ms)  samples      t-sum        p
 0    304.7 to    636.7       86    +359.48   0.0001   <- significant
 1   -160.2 to   -152.3        3      +7.19   0.6093
In [7]:
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
Figure 1 of notebook nb-6-1-corrections, an output plot. The text around it states what it shows and the units of every axis.

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.

In [8]:
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)}")
adjacency: 30 channels, 78 neighbour pairs (mne.channels.find_ch_adjacency, Delaunay triangulation of the montage)
search space: 30 channels x 257 time points = 7,710 tests (386 expected false positives if they were independent)
cluster: 43 clusters, 2 significant, 1024 permutations, 4.5 s
  0:   191.4 to   656.2 ms, 17 channels, t-sum   -2056.9, p = 0.0010   <- significant
     Fp1, F7, C3, C5, P3, P7, P9, PO7, PO3, O1, Oz, Fp2, F4, F8, P8, P10, PO8
  1:   304.7 to   730.5 ms, 11 channels, t-sum   +1977.6, p = 0.0010   <- significant
     P3, PO3, Pz, CPz, FC4, FCz, Cz, C4, C6, P4, PO8
  2:   203.1 to   324.2 ms, 11 channels, t-sum    +578.9, p = 0.0693
     Fp1, F3, F7, Fp2, Fz, F4, F8, FC4, FCz, C4, C6
  3:   562.5 to   617.2 ms,  3 channels, t-sum     -64.7, p = 0.8857
     F8, P8, P10
In [9]:
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.")
TFCE: 1119 of 7,710 channel-time points reject at alpha = 0.05, 256 permutations, 33.1 s
   channels with any rejected point: Fp1, F3, F7, C3, C5, P3, P7, P9, PO7, PO3, O1, Oz, Pz, CPz, Fp2, Fz, F4, F8, FCz, Cz, C4, C6, P4, P8, P10, PO8
   earliest rejected point 195 ms, latest 680 ms

Cost, measured on this machine and linear in the permutation count:
   spatio-temporal cluster,  1024 permutations :    4.5 s ->     44 s at 10,000
   spatio-temporal TFCE,      256 permutations :   33.1 s ->   1294 s at 10,000
   That is why FULL_RUN exists.  A published analysis should use enough permutations that the p-value's own resolution (1/256 = 0.0039 here) is well below alpha; these shipped counts are a teaching budget, not a recommendation.
In [10]:
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")
Figure 2 of notebook nb-6-1-corrections, an output plot. The text around it states what it shows and the units of every axis.

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.

In [11]:
# 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)")
single-trial Pz waveforms from the stored products, no extra download:
   19 subjects, [161, 197, 192, 197, 185, 99, 200, 182, 180, 129, 194, 200, 191, 195, 179, 197, 195, 199, 196] trials kept under the pre-specified 100 uV criterion
   40 null datasets x six strategies, 1000 permutations per permutation test (reduced from 10000: estimating a RATE needs draws, not permutations)
In [12]:
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.")
40 null datasets x six strategies in 45 s (1000 permutations per permutation test)

      strategy  rejected anything  mean points rejected  verdict
   uncorrected             75.0%                  12.9  no claim to check -- this is the yardstick
    bonferroni              2.5%                   0.1  consistent with alpha
        fdr-bh              2.5%                   0.1  FDR's guarantee reduces to FWER when nothing is true, so this rate should also be about alpha
 max-statistic              5.0%                   0.1  consistent with alpha
       cluster             10.0%                   2.9  consistent with alpha
          tfce              5.0%                   0.1  consistent with alpha

binomial 95% interval for a true 5% rate over 40 draws: 0.0% to 12.5%.  With 40 draws this check can tell 5% from 80%; it 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.

In [13]:
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()}")
200 null datasets x 486 paths = 97,200 analyses in 0.7 s
   (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)

THE CALIBRATION CHECK -- one pre-specified path (average ref, 0.1 Hz high-pass, baseline -200..0 ms, rejection 100 uV, 300-600 ms, Pz):
   reaches p < 0.05 in 4.0% of 200 null datasets (alpha = 5%; binomial 95% interval 2.0% to 8.0%)
   pooled over all 486 paths the per-path rate is 5.23%, also about alpha.  Every individual path is well behaved.

THE GARDEN -- at least one of the 486 paths reaches p < 0.05 in 79.5% of the same 200 datasets (Monte-Carlo 95% interval 74.0% to 85.0%: this is an ESTIMATE from 200 draws, and it deserves its own error bar as much as anything else here)
   when any path fires, the median number that fire is 18; the worst draw had 220 of 486
   draws with nothing significant at all: 41 of 200
   counts per draw: min 0, q1 1, median 11, q3 35, max 220
In [14]:
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
Figure 3 of notebook nb-6-1-corrections, an output plot. The text around it states what it shows and the units of every axis.

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.

In [15]:
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).")
nb-6-1-corrections -- L6.1 numbers (draft; TODO(confirm) at author review)
Data: ds-erpcore P3, sub-001 to sub-020 (19 subjects, helpers_l6.SUBSET_DEFAULT); CC-BY-SA-4.0 per data/directory.yaml (contested at source; spec 10.7 most-restrictive rule)
Pipeline: helpers_l6.PRESPECIFIED = average ref, 0.1 Hz high-pass, baseline -200..0 ms, rejection 100 uV, 300-600 ms, Pz

1. ex-6-1 SEARCH SPACE (arithmetic, no data):
     64 channels x 200 time points = 12,800 uncorrected tests;
     expected false positives at alpha = 0.05 under the null = 640.

2. THE ONE PRE-SPECIFIED TEST (Pz, 300-600 ms mean amplitude, target minus standard):
     +2.737 uV, 95% CI [+1.600, +3.874], t(18) = 5.057, p = 8.21e-05

3. SIX STRATEGIES on 19 subjects x 257 time points at Pz (alpha = 0.05, 10000 permutations, seed 20260918):
        uncorrected:  89 points, -160 to 637 ms
         bonferroni:  32 points, 410 to 531 ms
             fdr-bh:  80 points, 316 to 625 ms
      max-statistic:  61 points, 367 to 609 ms
            cluster:  86 points, 305 to 637 ms
               tfce:  56 points, 371 to 605 ms

4. SPATIO-TEMPORAL (30 channels x 257 times = 7,710 tests):
     cluster (1024 permutations): 2 of 43 clusters significant; smallest p = 0.0010
     TFCE (256 permutations): 1119 of 7,710 points reject

5. MEASURED ERROR RATES on 40 label-permuted datasets (19 subjects, 1000 permutations per test):
        uncorrected: rejected something in 75.0% of draws (mean 12.9 points)
         bonferroni: rejected something in 2.5% of draws (mean 0.1 points)
             fdr-bh: rejected something in 2.5% of draws (mean 0.1 points)
      max-statistic: rejected something in 5.0% of draws (mean 0.1 points)
            cluster: rejected something in 10.0% of draws (mean 2.9 points)
               tfce: rejected something in 5.0% of draws (mean 0.1 points)

6. THE GARDEN (486 defensible paths, 200 null datasets, seed 20260918):
     at least one path reaches p < 0.05 in 79.5% of datasets (Monte-Carlo 95% interval 74.0% to 85.0%)
     the single pre-specified path reaches it in 4.0%  <- the calibration check; must be near alpha = 5%
     median paths significant when any are: 18; worst draw 220 of 486
     pooled per-path false-positive rate 5.23%

CROSS-CHECK against w-garden-of-forking-paths (paths.json, mode null, 20 subjects, 200 draws):
     widget: 79.5% any-path, 4.0% pre-specified.  this notebook: 79.5% any-path, 4.0% pre-specified.
     The notebook re-derives the grid from the raw files with its own code; agreement to the printed precision means the two implementations of the same construction agree.
     BUT BOTH ARE 200-DRAW ESTIMATES.  Rerunning the same construction with FULL_RUN = True (2,000 draws) gives 75.4% any-path and 5.25% pre-specified on this machine.  Both pairs are inside each other's Monte-Carlo intervals, and the second is the better estimate of the same two quantities.  A lesson that quotes '79.5%' to three significant figures is claiming a precision the 200 draws do not have; 'about four in five' is the honest reading, and the pre-specified rate is alpha either way.

Pitfalls: pf-uncorrected-timepoint-tests, pf-cluster-inference-misread.  Widgets: w-garden-of-forking-paths (mode null), w-cluster-permutation-viz (mode erp).
Statistics thread: L3.7 -> L4.7 -> L6.1 -> L6.4 (nb-6-4-multiverse runs the same 486 paths on the real contrast).