Circularity and analytic flexibility: the false-positive cost of choosing a window and electrode from the tested data, two fixes measured against it, and 486 defensible pipelines on one contrast

nb-6-4-multiverse Level 6 · Inference and Rigor ~7 min Used in L6.4 · Circularity and analytic flexibility

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-4-multiverse · Circularity and analytic flexibility (L6.4)

Lesson L6.4 · Level 6 · Status draft — for expert review; uncertain points carry TODO(confirm).

nb-6-1-corrections measured what a search space does when there is nothing to find: at least one of 486 defensible pipelines reaches p < .05 in 79.5 % of datasets where the null is true by construction, while a single pre-specified pipeline reaches it in 4.0 %. This notebook does the other half.

  1. Circularity, measured rather than asserted. Choosing the measurement window and electrode by looking at the contrast you are about to test inflates the false-positive rate; the notebook puts a number on it under the null, then measures two fixes — a collapsed localizer (select on a statistic that does not know the condition labels) and leave-one-subject-out selection — and shows which of them actually works.
  2. The multiverse. The same 486 pipelines on the real target-minus-standard contrast: how many reach significance, how far apart their estimates are, and which single decision moves the estimate most.
  3. A preregistration, written out in ten lines and hashed, because the only reliable way to shrink a search space is to fix it before the data arrive.

Nothing here is an accusation. Every one of the 486 paths is a pipeline a competent analyst could defend in a methods section, and an analyst who picks a window by looking at a grand average is doing what a great deal of published work does. The point is that the cost of that is measurable, and it is measured below.

Statistics thread: L3.7 → L4.7 → L6.1 → L6.4.

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. 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). helpers_l3.ERPCORE_LICENCE_STATEMENTS carries all three verbatim.

No published values are quoted. Every comparison with a paper's own numbers is a literal TODO(confirm).

In [1]:
# Setup: dependencies, the shared helpers, non-interactive plotting.
import hashlib
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: static PNGs through the inline backend; every 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.  Everything expensive here is a null draw, and a draw is cheap because the
#    per-trial measurements are precomputed; FULL_RUN raises the draw count where it buys precision.
FULL_RUN = False
N_DRAWS = 200 if not FULL_RUN else 2000         # null datasets for the circularity and garden sections
SEED = L6.SEED
ALPHA = L6.ALPHA

print(f"MNE {mne.__version__}; helpers_l6 imported from notebooks/_shared")
print(f"FULL_RUN = {FULL_RUN}: {N_DRAWS} null datasets")
print(f"ERP CORE cache: {L3.erpcore_root().name}/ (resolved relative to the working directory, or "
      f"$EEG_COURSE_ERPCORE); each subject's EEGLAB pair is deleted as soon as its measurements exist")
MNE 1.10.2; helpers_l6 imported from notebooks/_shared
FULL_RUN = False: 200 null datasets
ERP CORE cache: erpcore/ (resolved relative to the working directory, or $EEG_COURSE_ERPCORE); each subject's EEGLAB pair is deleted as soon as its measurements exist

1 · The cohort

The same twenty subjects and the same loader as nb-6-1-corrections, so the two notebooks' numbers are comparable. helpers_l6.subject_products stores each path's per-trial amplitude and rejection mask, which is what makes 486 pipelines × 200 null datasets affordable: none of the per-trial work depends on the condition labels, so permuting the labels is a masked mean over a stored vector rather than a re-analysis.

In [2]:
SUBJECTS = list(L6.SUBSET_DEFAULT)
before = L6.disk_report("before any download")
t0 = time.time()
cohort = L6.load_cohort(SUBJECTS)
print(f"{len(cohort)} subjects in {time.time() - t0:.0f} s")
after = L6.disk_report("after the download loop")
print(f"the ~58 MB EEGLAB pair of each subject is deleted before the next is fetched, so the folder sizes "
      f"above are the whole footprint; free space itself moves for reasons a notebook knows nothing about")

paths = L6.all_paths()
subs = sorted(cohort)
times = cohort[subs[0]]["times"].astype(float)
print(f"\n{len(paths)} paths from {len(L6.CHOICES)} decisions:")
for c in L6.CHOICES:
    print(f"   {c['id']:11s} ({len(c['options'])}) {', '.join(c['options'])}")
    print(f"               why each is defensible: {L6.CHOICE_RATIONALE[c['id']]}")
free disk before any download: 4.71 GB  (ERP CORE cache 0.0 MB, Level-6 products 0.0 MB)
  sub-001: 200 trials, computed in 53.9 s; free disk 4.71 GB
  sub-002: 200 trials, computed in 24.2 s; free disk 4.70 GB
  sub-003: 200 trials, computed in 26.9 s; free disk 4.70 GB
  sub-004: 200 trials, computed in 31.9 s; free disk 4.71 GB
  sub-005: 200 trials, computed in 25.7 s; free disk 4.71 GB
  sub-006: 200 trials, computed in 23.7 s; free disk 4.69 GB
  sub-007: 200 trials, computed in 19.5 s; free disk 4.69 GB
  sub-008: 200 trials, computed in 16.4 s; free disk 4.69 GB
  sub-009: 200 trials, computed in 14.0 s; free disk 4.68 GB
  sub-010: 200 trials, computed in 22.0 s; free disk 4.69 GB
  sub-011: 200 trials, computed in 23.9 s; free disk 4.69 GB
  sub-012: 200 trials, computed in 49.2 s; free disk 4.69 GB
  sub-013: 200 trials, computed in 17.3 s; free disk 4.69 GB
  sub-014: 200 trials, computed in 18.8 s; free disk 4.69 GB
  sub-015: 200 trials, computed in 20.2 s; free disk 4.69 GB
  sub-016: 200 trials, computed in 19.2 s; free disk 4.69 GB
  sub-017: 200 trials, computed in 14.4 s; free disk 4.68 GB
  sub-018: 200 trials, computed in 17.8 s; free disk 4.68 GB
  sub-019: 200 trials, computed in 19.9 s; free disk 4.68 GB
  sub-020: 200 trials, computed in 18.5 s; free disk 4.68 GB
20 subjects in 478 s
free disk after the download loop: 4.68 GB  (ERP CORE cache 0.4 MB, Level-6 products 7.5 MB)
the ~58 MB EEGLAB pair of each subject is deleted before the next is fetched, so the folder sizes above are the whole footprint; free space itself moves for reasons a notebook knows nothing about

486 paths from 6 decisions:
   reference   (3) average, linked-mastoids, Cz
               why each is defensible: average of the 30 EEG channels, linked mastoids (P9 + P10)/2, or Cz.  All three are in current use for the P3: the ERP CORE papers use average, older P3 work overwhelmingly used mastoids, and Cz is still seen.  L2.3 is the lesson that says the choice changes the waveform.
   highpass    (3) 0.01, 0.1, 0.5
               why each is defensible: 0.1 Hz is the usual ERP compromise, 0.01 Hz the conservative choice that distorts least, and 0.5 Hz the upper end the ERP literature tolerates for drift-heavy data (pf-hp-cutoff-erp is about going further than that).
   baseline    (2) -200..0, -100..0
               why each is defensible: two standard pre-stimulus baselines, both of which appear in published P3 pipelines.
   rejection   (3) 75 uV, 100 uV, none
               why each is defensible: peak-to-peak above 75 uV, above 100 uV, or no rejection at all.  All three appear in published P3 pipelines, and 'none' is defensible here because ocular correction has already been applied.
   window      (3) 300-500, 300-600, 350-650
               why each is defensible: three a-priori P3 windows from the literature.  None of them is chosen by looking at the data, which is the point: even pre-specified windows fork.
   electrode   (3) Pz, CPz, Pz+CPz+Cz
               why each is defensible: the P3b is centro-parietal, so Pz, CPz and a three-site centro-parietal cluster are all pre-specifiable.

2 · Circularity, with a number on it

Double dipping is selecting the thing you measure from the data you then test. The window and the electrode are the classic pair: look at the grand-average difference wave, see where it peaks, measure there, test.

The measurement window and electrode are two of the six forks, so the stored products already contain every answer. Fix the other four decisions at their pre-specified values and there are 3 windows × 3 electrodes = 9 measurements of the same data. Four selection rules then decide which one to report:

Rule How the window and electrode are chosen
pre-specified fixed before the data: 300–600 ms at Pz
best-of-9 whichever of the nine gives the largest |t| on the contrast being tested — double dipping
collapsed localizer whichever gives the largest absolute condition-collapsed amplitude, a statistic that does not know the labels
leave-one-subject-out for each subject, whichever is largest on the other subjects' contrast

Run all four on data where the null is true by construction and their false-positive rates are measurable. A rule that controls the error rate comes out near α; one that does not, does not.

In [3]:
# The nine window x electrode measurements, with the other four decisions at their pre-specified values.
combos = [dict(L6.PRESPECIFIED, window=w, electrode=e)
          for w in ("300-500", "300-600", "350-650") for e in ("Pz", "CPz", "Pz+CPz+Cz")]
combo_idx = np.array([L6.path_index(c, paths) for c in combos])
i_prespec = L6.path_index(L6.PRESPECIFIED, paths)
j_prespec = int(np.where(combo_idx == i_prespec)[0][0])
print(f"{len(combos)} window x electrode measurements (the other four decisions fixed at "
      f"{L6.PRESPECIFIED['reference']} / {L6.PRESPECIFIED['highpass']} Hz / "
      f"{L6.PRESPECIFIED['baseline']} / {L6.PRESPECIFIED['rejection']}):")
for j, c in enumerate(combos):
    print(f"   {j}: {c['window']:8s} ms at {c['electrode']:10s}" + ("   <- pre-specified" if j == j_prespec else ""))

MIN_TRIALS = int(L6.FIXED_CHOICES["min_trials_per_condition"])
usable = [s for s in subs if min(cohort[s]["n_kept"].values()) >= MIN_TRIALS]
print(f"\n{len(usable)} of {len(subs)} subjects meet the stated minimum of {MIN_TRIALS} surviving trials per "
      f"condition under this rejection setting"
      + (f" (excluded: {', '.join(s for s in subs if s not in usable)})" if len(usable) < len(subs) else ""))

# collapsed-localizer statistic: each subject's mean amplitude over ALL trials, per measurement.
# It is computed once because it does not depend on the condition labels at all -- which is the
# property that makes it a legitimate selector.
collapsed = np.stack([
    np.array([float(cohort[s]["amp"][i][cohort[s]["keep"][i]].mean()) for i in combo_idx])
    for s in usable])                                                    # subjects x 9
print(f"\ncollapsed-localizer statistic (group mean over all trials, uV): "
      + ", ".join(f"{v:+.2f}" for v in collapsed.mean(0)))
j_collapsed = int(np.argmax(np.abs(collapsed.mean(0))))
print(f"   it selects measurement {j_collapsed}: {combos[j_collapsed]['window']} ms at "
      f"{combos[j_collapsed]['electrode']}.  Under label permutation this choice never changes, because "
      f"nothing it looks at depends on the labels.")
9 window x electrode measurements (the other four decisions fixed at average / 0.1 Hz / -200..0 / 100 uV):
   0: 300-500  ms at Pz        
   1: 300-500  ms at CPz       
   2: 300-500  ms at Pz+CPz+Cz 
   3: 300-600  ms at Pz           <- pre-specified
   4: 300-600  ms at CPz       
   5: 300-600  ms at Pz+CPz+Cz 
   6: 350-650  ms at Pz        
   7: 350-650  ms at CPz       
   8: 350-650  ms at Pz+CPz+Cz 

19 of 20 subjects meet the stated minimum of 15 surviving trials per condition under this rejection setting (excluded: sub-009)

collapsed-localizer statistic (group mean over all trials, uV): +4.13, +4.63, +4.27, +3.70, +4.48, +4.11, +3.29, +4.44, +4.06
   it selects measurement 1: 300-500 ms at CPz.  Under label permutation this choice never changes, because nothing it looks at depends on the labels.
In [4]:
def selection_rates(label_source, n_draws, seed_offset):
    """False-positive (or hit) rate of each selection rule over n_draws label assignments.

    A permuted label vector can leave a subject below the 15-trial minimum on some measurements
    even when its true labels do not, so `helpers_l6.subject_diffs` returns NaN there and the
    group test is the NaN-aware `helpers_l6.paired_t` rather than `scipy.stats.ttest_1samp`.
    `n_nan` counts how often that happened, because silently turning a NaN into "not significant"
    would bias every rate downwards.
    """
    rng = {s: np.random.default_rng(SEED + seed_offset + int(s.split("-")[1])) for s in usable}
    out = {k: np.zeros(n_draws, bool) for k in ("pre-specified", "best-of-9", "collapsed", "loso")}
    tvals = {k: np.zeros(n_draws) for k in out}
    n_nan = 0
    for d in range(n_draws):
        D = np.empty((len(usable), len(combos)))                          # subjects x 9 estimates
        for k, s in enumerate(usable):
            lab = (cohort[s]["is_target"] if label_source == "real"
                   else rng[s].permutation(cohort[s]["is_target"]))
            D[k] = L6.subject_diffs(cohort[s], lab)[combo_idx]
        n_nan += int((~np.isfinite(D)).any())
        t, p, _m, _n = L6.paired_t(D.T)                                   # 9 group tests, NaN-aware

        out["pre-specified"][d] = p[j_prespec] < ALPHA
        tvals["pre-specified"][d] = t[j_prespec]

        j_best = int(np.nanargmax(np.abs(t)))                             # <- selected ON the tested contrast
        out["best-of-9"][d] = p[j_best] < ALPHA
        tvals["best-of-9"][d] = t[j_best]

        out["collapsed"][d] = p[j_collapsed] < ALPHA
        tvals["collapsed"][d] = t[j_collapsed]

        scores = np.empty(len(usable))                                    # leave-one-subject-out selection
        for k in range(len(usable)):
            others = np.delete(np.arange(len(usable)), k)
            t_o, _p, _m, _n = L6.paired_t(D[others].T)
            scores[k] = D[k, int(np.nanargmax(np.abs(t_o)))]
        t_l, p_l, _m, _n = L6.paired_t(scores)
        out["loso"][d] = bool(p_l < ALPHA)
        tvals["loso"][d] = float(t_l)
    return out, tvals, n_nan

t0 = time.time()
null_rates, null_t, n_nan = selection_rates("null", N_DRAWS, seed_offset=0)
print(f"{N_DRAWS} null datasets x 4 selection rules in {time.time() - t0:.1f} s")
print(f"draws in which at least one subject fell below the {MIN_TRIALS}-trial minimum on at least one "
      f"measurement, and was dropped from it: {n_nan} of {N_DRAWS}\n")
lo, hi = stats.binom.interval(0.95, N_DRAWS, ALPHA)
print(f"{'selection rule':>22s} {'false-positive rate':>20s} {'mean |t|':>9s}  verdict")
for k in null_rates:
    r = null_rates[k].mean()
    inside = lo / N_DRAWS <= r <= hi / N_DRAWS
    verdict = ("consistent with alpha" if inside else
               f"{r / ALPHA:.1f}x alpha -- OUTSIDE the binomial interval")
    print(f"{k:>22s} {r:19.1%} {np.abs(null_t[k]).mean():9.2f}  {verdict}")
print(f"\nbinomial 95% interval for a true {ALPHA:.0%} rate over {N_DRAWS} draws: "
      f"{lo / N_DRAWS:.1%} to {hi / N_DRAWS:.1%}")
200 null datasets x 4 selection rules in 0.8 s
draws in which at least one subject fell below the 15-trial minimum on at least one measurement, and was dropped from it: 8 of 200

        selection rule  false-positive rate  mean |t|  verdict
         pre-specified                4.0%      0.87  consistent with alpha
             best-of-9               17.0%      1.45  3.4x alpha -- OUTSIDE the binomial interval
             collapsed                4.5%      0.87  consistent with alpha
                  loso                9.5%      1.06  1.9x alpha -- OUTSIDE the binomial interval

binomial 95% interval for a true 5% rate over 200 draws: 2.0% to 8.0%

Reading that table

Nine is a small garden. Only two of the six decisions were allowed to vary, and only within the options the literature already uses. The inflation it produces is the floor of what selecting a window and an electrode after the fact costs, not the ceiling.

The collapsed localizer works, and it is obvious why. Its selection statistic — the condition-collapsed average — is arithmetically independent of which trials carry which label, so permuting the labels never changes what it selects. Selection that cannot see the contrast cannot bias the contrast. That is the whole principle, and it is why "choose the window from the grand average of both conditions" is a real fix rather than a rationalisation. It does have a cost: the collapsed peak need not sit where the difference is largest, so the measurement can be less sensitive than a well-chosen a-priori window.

Leave-one-subject-out is the interesting one. It is often offered as a fix, and it does remove the most obvious circularity — no subject's own data chooses that subject's window. It is still a rule that consults the labels, so whether it controls the error rate is an empirical question rather than a theorem, and the row above is this dataset's answer to it.

In [5]:
real_rates, real_t, _ = selection_rates("real", 1, seed_offset=0)
print("The same four rules on the REAL contrast (one dataset, so these are single results, not rates):")
for k in real_rates:
    print(f"   {k:>16s}: t = {real_t[k][0]:+.3f}, "
          f"{'reaches' if real_rates[k][0] else 'does not reach'} p < {ALPHA}")
print(f"\nOn a real effect this size every rule agrees, which is exactly why the null simulation above is the "
      f"only way to tell them apart.  A selection rule that is wrong 4 times in 10 under the null still looks "
      f"fine on data with a large effect in it -- and most published contrasts are nowhere near this large.")
The same four rules on the REAL contrast (one dataset, so these are single results, not rates):
      pre-specified: t = +5.057, reaches p < 0.05
          best-of-9: t = +5.057, reaches p < 0.05
          collapsed: t = +4.104, reaches p < 0.05
               loso: t = +4.517, reaches p < 0.05

On a real effect this size every rule agrees, which is exactly why the null simulation above is the only way to tell them apart.  A selection rule that is wrong 4 times in 10 under the null still looks fine on data with a large effect in it -- and most published contrasts are nowhere near this large.

3 · The multiverse

Selection is one problem. The other is that even without selecting anything, the report is one cell of a grid nobody sees. The 486 paths of nb-6-1-corrections run here on the real contrast: not to find the right answer, but to see the whole distribution the one reported number is drawn from.

In [6]:
diffs = L6.cohort_diffs(cohort)                  # (486 paths, 20 subjects), NaN where a path runs out of trials
grid = L6.grid_summary(paths, diffs, alpha=ALPHA)
print(f"MULTIVERSE: {grid['n_significant']} of {grid['n_paths']} paths reach p < {ALPHA} "
      f"({grid['n_significant'] / grid['n_paths']:.1%})")
print(f"   estimate  {grid['effect_min']:+.4f} to {grid['effect_max']:+.4f} uV, median "
      f"{grid['effect_median']:+.4f}, IQR {grid['effect_iqr']:.4f}")
print(f"   all paths point the same way: {grid['all_same_sign']}")
print(f"   p         min {grid['p_min']:.3g}, median {grid['p_median']:.4f}, max {grid['p_max']:.4f}")
print(f"   ratio of the largest to the smallest estimate: "
      f"{grid['effect_max'] / grid['effect_min']:.1f}x")
print(f"\n   the pre-specified path: {grid['effect_uv'][i_prespec]:+.4f} uV, p = {grid['p'][i_prespec]:.3g}, "
      f"{int(grid['n_subjects'][i_prespec])} subjects")
print(f"   subjects contributing per path: min {int(np.min(grid['n_subjects']))}, "
      f"max {int(np.max(grid['n_subjects']))} (a path with a tight rejection threshold loses subjects who "
      f"fall below the {MIN_TRIALS}-trial minimum)")
MULTIVERSE: 476 of 486 paths reach p < 0.05 (97.9%)
   estimate  +0.3963 to +6.0477 uV, median +2.3202, IQR 3.7602
   all paths point the same way: True
   p         min 7.62e-08, median 0.0004, max 0.2051
   ratio of the largest to the smallest estimate: 15.3x

   the pre-specified path: +2.7366 uV, p = 8.21e-05, 19 subjects
   subjects contributing per path: min 12, max 20 (a path with a tight rejection threshold loses subjects who fall below the 15-trial minimum)
In [7]:
fig, axes = L6.plot_specification_curve(
    grid["effect_uv"], grid["p"], paths, alpha=ALPHA, highlight=L6.PRESPECIFIED,
    title=f"Specification curve: ERP CORE P3 target minus standard, {len(paths)} defensible pipelines (µV)")
plt.show()   # render the static figure(s) of this cell inline
Figure 1 of notebook nb-6-4-multiverse, an output plot. The text around it states what it shows and the units of every axis.
In [8]:
rows = L6.marginals(paths, grid["effect_uv"], grid["significant"])
print("Marginals: each option's median estimate with every other decision averaged over.")
print(f"{'decision':>11s} {'option':>17s} {'paths':>6s} {'median (uV)':>12s} {'range (uV)':>20s} "
      f"{'p < .05':>9s}")
spread = {}
for r in rows:
    print(f"{r['choice']:>11s} {r['option']:>17s} {r['n_paths']:6d} {r['effect_median']:+12.4f} "
          f"{r['effect_min']:+9.2f} to {r['effect_max']:+6.2f} {r['n_significant']:4d}/{r['n_paths']:<4d}")
    spread.setdefault(r["choice"], []).append(r["effect_median"])
print()
print("Which single decision moves the estimate most (spread of the option medians):")
for k, v in sorted(spread.items(), key=lambda kv: -(max(kv[1]) - min(kv[1]))):
    print(f"   {k:11s} {max(v) - min(v):6.3f} uV   ({min(v):+.3f} to {max(v):+.3f})")
worst = max(spread, key=lambda k: max(spread[k]) - min(spread[k]))
print(f"\nThe {worst} decision alone moves the median estimate by "
      f"{max(spread[worst]) - min(spread[worst]):.2f} uV.  Two analysts who agree about every other step and "
      f"differ only there will report numbers that far apart from the same recordings, and both methods "
      f"sections will be correct.")
Marginals: each option's median estimate with every other decision averaged over.
   decision            option  paths  median (uV)           range (uV)   p < .05
  reference           average    162      +2.3202     +1.96 to  +2.80  162/162 
  reference   linked-mastoids    162      +5.2622     +4.39 to  +6.05  162/162 
  reference                Cz    162      +0.9553     +0.40 to  +1.77  152/162 
   highpass              0.01    162      +2.3328     +0.47 to  +5.88  161/162 
   highpass               0.1    162      +2.3500     +0.59 to  +6.05  158/162 
   highpass               0.5    162      +2.2730     +0.40 to  +5.77  157/162 
   baseline           -200..0    243      +2.2860     +0.46 to  +5.92  240/243 
   baseline           -100..0    243      +2.3533     +0.40 to  +6.05  236/243 
  rejection             75 uV    162      +2.3174     +0.40 to  +5.52  152/162 
  rejection            100 uV    162      +2.3004     +0.65 to  +6.05  162/162 
  rejection              none    162      +2.3416     +0.66 to  +5.88  162/162 
     window           300-500    162      +2.2580     +0.40 to  +6.05  155/162 
     window           300-600    162      +2.3367     +0.51 to  +6.00  159/162 
     window           350-650    162      +2.3837     +0.61 to  +5.85  162/162 
  electrode                Pz    162      +2.6722     +0.77 to  +6.05  162/162 
  electrode               CPz    162      +2.3202     +0.42 to  +5.62  154/162 
  electrode         Pz+CPz+Cz    162      +2.0754     +0.40 to  +5.42  160/162 

Which single decision moves the estimate most (spread of the option medians):
   reference    4.307 uV   (+0.955 to +5.262)
   electrode    0.597 uV   (+2.075 to +2.672)
   window       0.126 uV   (+2.258 to +2.384)
   highpass     0.077 uV   (+2.273 to +2.350)
   baseline     0.067 uV   (+2.286 to +2.353)
   rejection    0.041 uV   (+2.300 to +2.342)

The reference decision alone moves the median estimate by 4.31 uV.  Two analysts who agree about every other step and differ only there will report numbers that far apart from the same recordings, and both methods sections will be correct.
In [9]:
fig, axes = plt.subplots(1, 2, figsize=(11.5, 4.4))
order = [c["id"] for c in L6.CHOICES]
pos, labels, data = [], [], []
y = 0
for cid in order:
    for option in next(c["options"] for c in L6.CHOICES if c["id"] == cid):
        m = np.array([p[cid] == option for p in paths])
        data.append(grid["effect_uv"][m][np.isfinite(grid["effect_uv"][m])])
        labels.append(f"{cid}: {option}")
        pos.append(y)
        y += 1
    y += 0.6
bp = axes[0].boxplot(data, positions=pos, vert=False, widths=0.6, showfliers=False, patch_artist=True)
for patch in bp["boxes"]:
    patch.set_facecolor("tab:blue")
    patch.set_alpha(0.45)
axes[0].axvline(grid["effect_median"], color="k", ls=":", lw=1,
                label=f"overall median {grid['effect_median']:+.2f} µV")
axes[0].axvline(0, color="gray", lw=0.8)
axes[0].set_yticks(pos, labels, fontsize=8)
axes[0].set(xlabel="Estimate (µV)", title="Each option's estimates, everything else averaged over")
axes[0].grid(alpha=0.3, axis="x")
axes[0].legend(fontsize=8, loc="lower right")

axes[1].hist(grid["effect_uv"][np.isfinite(grid["effect_uv"])], bins=30, color="tab:blue", alpha=0.85)
axes[1].axvline(grid["effect_uv"][i_prespec], color="tab:orange", lw=2,
                label=f"pre-specified path {grid['effect_uv'][i_prespec]:+.2f} µV")
axes[1].axvline(0, color="gray", lw=0.8)
axes[1].set(xlabel="Estimate (µV)", ylabel="Paths",
            title=f"{grid['n_paths']} defensible pipelines, one dataset")
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
Figure 2 of notebook nb-6-4-multiverse, an output plot. The text around it states what it shows and the units of every axis.

What a multiverse is evidence of

The grid above is not a sensitivity check that passed. Read it carefully and it says three different things at once:

  • The direction is robust. Every path points the same way. That is worth much more than any single p-value, and it is the claim the multiverse actually supports.
  • The magnitude is not. The largest estimate is many times the smallest, and both come from pipelines a reviewer would accept. Any sentence of the form "the P3 effect was X µV" is a statement about a pipeline as much as about the brain, and the reference alone accounts for most of the spread.
  • A significance count is the least interesting row. "476 of 486 paths were significant" sounds like overwhelming support, but the paths share subjects, trials and most of the pipeline, so they are 486 highly dependent looks at one dataset, not 486 replications. The count says the result is not fragile to these particular choices; it does not multiply the evidence.

And the multiverse does not repair a search. Reporting the whole grid after having already looked at it is better than reporting one cell, but the honest version is to fix the grid — or the single path — in advance.

w-garden-of-forking-paths (mode multiverse) is the same grid to explore by hand.

4 · The preregistration

A preregistration is not a contract with a journal; it is a way of making the search space small enough that α means what it says. Ten lines is enough for most ERP analyses. The one below would have produced the pre-specified path of this notebook, and nothing else.

The hash is the point of the cell: a plan is only a preregistration if it can be shown to predate the analysis. In a repository, a timestamped commit does that; here the notebook prints a digest of the exact text so a reader can check that the plan they are reading is the plan that was run.

In [10]:
PREREGISTRATION = """
1. HYPOTHESIS   Rare target letters elicit a larger positive deflection than frequent standards over
                centro-parietal scalp, 300-600 ms after stimulus onset (the P3b).
2. DATA         ds-erpcore, paradigm P3, sub-001 to sub-020.  No subject is inspected before the plan is fixed.
3. EXCLUSION    A subject is excluded if fewer than 15 trials survive artifact rejection in either condition.
                Decided before analysis; the count and the reason are reported for every exclusion.
4. PREPROCESS   Resample 1024 -> 256 Hz; FIR band-pass 0.1-30 Hz; bipolar HEOG/VEOG; ocular correction by
                regression fitted on epochs with each condition's evoked response removed.
5. REFERENCE    Average of the 30 EEG channels.
6. EPOCHS       -200 to +800 ms, baseline -200 to 0 ms.  Reject an epoch whose peak-to-peak amplitude
                exceeds 100 uV on any EEG channel.
7. MEASURE      Mean amplitude 300-600 ms at Pz, target minus standard, one value per subject.
8. TEST         Two-sided one-sample t-test against zero, alpha = .05.  Report the estimate with a 95 %
                confidence interval and Cohen's dz.
9. CORRECTION   One test, so none is required.  Any additional time point, channel or window reported is
                exploratory and labelled as such, with a cluster-based permutation test over the tested space.
10. DEVIATIONS  Every departure from lines 1-9 is reported with the reason and the stage at which it was made.
"""
digest = hashlib.sha256(PREREGISTRATION.encode("utf-8")).hexdigest()
print(PREREGISTRATION)
print(f"SHA-256 of the plan above: {digest}")
print(f"length: 10 numbered items over {len(PREREGISTRATION.strip().splitlines())} physical lines, "
      f"{len(PREREGISTRATION)} characters")
print()
print("What each line buys, in the terms of this notebook:")
print(f"   line 3  removes the exclusion fork      -- and it fires: "
      f"{len(subs) - len(usable)} of {len(subs)} subjects excluded under this pipeline")
print(f"   line 5  removes the reference fork      -- worth {max(spread['reference']) - min(spread['reference']):.2f} uV "
      f"of spread in the multiverse above")
print(f"   line 6  removes baseline and rejection  -- worth "
      f"{max(spread['baseline']) - min(spread['baseline']):.2f} and "
      f"{max(spread['rejection']) - min(spread['rejection']):.2f} uV")
print(f"   line 7  removes window and electrode    -- worth "
      f"{max(spread['window']) - min(spread['window']):.2f} and "
      f"{max(spread['electrode']) - min(spread['electrode']):.2f} uV, and takes the false-positive rate from "
      f"{null_rates['best-of-9'].mean():.1%} back to {null_rates['pre-specified'].mean():.1%}")
print(f"   line 4  removes the high-pass fork      -- worth {max(spread['highpass']) - min(spread['highpass']):.2f} uV")
print(f"   Together lines 3-7 turn {len(paths)} pipelines into 1.")
1. HYPOTHESIS   Rare target letters elicit a larger positive deflection than frequent standards over
                centro-parietal scalp, 300-600 ms after stimulus onset (the P3b).
2. DATA         ds-erpcore, paradigm P3, sub-001 to sub-020.  No subject is inspected before the plan is fixed.
3. EXCLUSION    A subject is excluded if fewer than 15 trials survive artifact rejection in either condition.
                Decided before analysis; the count and the reason are reported for every exclusion.
4. PREPROCESS   Resample 1024 -> 256 Hz; FIR band-pass 0.1-30 Hz; bipolar HEOG/VEOG; ocular correction by
                regression fitted on epochs with each condition's evoked response removed.
5. REFERENCE    Average of the 30 EEG channels.
6. EPOCHS       -200 to +800 ms, baseline -200 to 0 ms.  Reject an epoch whose peak-to-peak amplitude
                exceeds 100 uV on any EEG channel.
7. MEASURE      Mean amplitude 300-600 ms at Pz, target minus standard, one value per subject.
8. TEST         Two-sided one-sample t-test against zero, alpha = .05.  Report the estimate with a 95 %
                confidence interval and Cohen's dz.
9. CORRECTION   One test, so none is required.  Any additional time point, channel or window reported is
                exploratory and labelled as such, with a cluster-based permutation test over the tested space.
10. DEVIATIONS  Every departure from lines 1-9 is reported with the reason and the stage at which it was made.

SHA-256 of the plan above: c1d0134f5a327ad2236d5f49dc0b3ab3818b36fab25418d4decdf268f14206b6
length: 10 numbered items over 16 physical lines, 1482 characters

What each line buys, in the terms of this notebook:
   line 3  removes the exclusion fork      -- and it fires: 1 of 20 subjects excluded under this pipeline
   line 5  removes the reference fork      -- worth 4.31 uV of spread in the multiverse above
   line 6  removes baseline and rejection  -- worth 0.07 and 0.04 uV
   line 7  removes window and electrode    -- worth 0.13 and 0.60 uV, and takes the false-positive rate from 17.0% back to 4.0%
   line 4  removes the high-pass fork      -- worth 0.08 uV
   Together lines 3-7 turn 486 pipelines into 1.
In [11]:
print("nb-6-4-multiverse -- L6.4 numbers (draft; TODO(confirm) at author review)")
print(f"Data: ds-erpcore P3, sub-001 to sub-{SUBJECTS[-1]:03d} ({len(subs)} loaded, {len(usable)} meeting the "
      f"{MIN_TRIALS}-trial minimum under the pre-specified rejection); CC-BY-SA-4.0 per data/directory.yaml")
print(f"Pipeline: helpers_l6.PRESPECIFIED = {L6.describe_path(L6.PRESPECIFIED)}")
print()
print(f"1. CIRCULARITY -- false-positive rate of four selection rules over {N_DRAWS} label-permuted datasets "
      f"(9 window x electrode measurements, everything else fixed):")
for k in null_rates:
    print(f"     {k:>16s}: {null_rates[k].mean():6.1%}"
          + ("   <- nominal alpha" if k == "pre-specified" else "")
          + ("   <- double dipping" if k == "best-of-9" else ""))
print(f"     inflation from choosing the best of {len(combos)} after the fact: "
      f"{null_rates['best-of-9'].mean() - null_rates['pre-specified'].mean():+.1%} "
      f"({null_rates['best-of-9'].mean() / max(null_rates['pre-specified'].mean(), 1e-9):.1f}x)")
print()
print(f"2. MULTIVERSE -- {len(paths)} defensible pipelines on the real contrast:")
print(f"     significant      : {grid['n_significant']} of {grid['n_paths']} "
      f"({grid['n_significant'] / grid['n_paths']:.1%})")
print(f"     estimate         : {grid['effect_min']:+.4f} to {grid['effect_max']:+.4f} uV, median "
      f"{grid['effect_median']:+.4f}, IQR {grid['effect_iqr']:.4f}")
print(f"     all same sign    : {grid['all_same_sign']}")
print(f"     p                : min {grid['p_min']:.3g}, median {grid['p_median']:.4f}, "
      f"max {grid['p_max']:.4f}")
print(f"     pre-specified    : {grid['effect_uv'][i_prespec]:+.4f} uV, p = {grid['p'][i_prespec]:.3g}")
print(f"     reference marginal (median estimate): "
      + " | ".join(f"{r['option']} {r['effect_median']:+.4f} uV {r['n_significant']}/{r['n_paths']}"
                   for r in rows if r["choice"] == "reference"))
print(f"     electrode marginal: "
      + " | ".join(f"{r['option']} {r['effect_median']:+.4f} uV {r['n_significant']}/{r['n_paths']}"
                   for r in rows if r["choice"] == "electrode"))
print()
print("3. PREREGISTRATION: 10 numbered items, SHA-256 " + digest)
print()
print(f"CROSS-CHECK against w-garden-of-forking-paths (paths.json, mode multiverse, 20 subjects):")
print(f"     widget:   476/486 significant; effect +0.40 to +6.05 uV, median +2.32; all positive;")
print(f"               reference marginal Cz +0.96, average +2.32, linked mastoids +5.26 uV.")
print(f"     notebook: {grid['n_significant']}/{grid['n_paths']} significant; effect "
      f"{grid['effect_min']:+.2f} to {grid['effect_max']:+.2f} uV, median {grid['effect_median']:+.2f}; "
      f"all positive = {grid['all_same_sign']};")
print(f"               reference marginal "
      + ", ".join(f"{r['option']} {r['effect_median']:+.2f}" for r in rows if r["choice"] == "reference")
      + " uV.")
print(f"     The notebook re-derives the grid from the raw files with its own code; agreement to the printed "
      f"precision means the two implementations of the same 486 pipelines agree.")
print()
print(f"Pitfall: pf-post-hoc-windows.  Widget: w-garden-of-forking-paths (mode multiverse).")
print(f"Statistics thread: L3.7 -> L4.7 -> L6.1 -> L6.4.")
nb-6-4-multiverse -- L6.4 numbers (draft; TODO(confirm) at author review)
Data: ds-erpcore P3, sub-001 to sub-020 (20 loaded, 19 meeting the 15-trial minimum under the pre-specified rejection); CC-BY-SA-4.0 per data/directory.yaml
Pipeline: helpers_l6.PRESPECIFIED = average ref, 0.1 Hz high-pass, baseline -200..0 ms, rejection 100 uV, 300-600 ms, Pz

1. CIRCULARITY -- false-positive rate of four selection rules over 200 label-permuted datasets (9 window x electrode measurements, everything else fixed):
        pre-specified:   4.0%   <- nominal alpha
            best-of-9:  17.0%   <- double dipping
            collapsed:   4.5%
                 loso:   9.5%
     inflation from choosing the best of 9 after the fact: +13.0% (4.2x)

2. MULTIVERSE -- 486 defensible pipelines on the real contrast:
     significant      : 476 of 486 (97.9%)
     estimate         : +0.3963 to +6.0477 uV, median +2.3202, IQR 3.7602
     all same sign    : True
     p                : min 7.62e-08, median 0.0004, max 0.2051
     pre-specified    : +2.7366 uV, p = 8.21e-05
     reference marginal (median estimate): average +2.3202 uV 162/162 | linked-mastoids +5.2622 uV 162/162 | Cz +0.9553 uV 152/162
     electrode marginal: Pz +2.6722 uV 162/162 | CPz +2.3202 uV 154/162 | Pz+CPz+Cz +2.0754 uV 160/162

3. PREREGISTRATION: 10 numbered items, SHA-256 c1d0134f5a327ad2236d5f49dc0b3ab3818b36fab25418d4decdf268f14206b6

CROSS-CHECK against w-garden-of-forking-paths (paths.json, mode multiverse, 20 subjects):
     widget:   476/486 significant; effect +0.40 to +6.05 uV, median +2.32; all positive;
               reference marginal Cz +0.96, average +2.32, linked mastoids +5.26 uV.
     notebook: 476/486 significant; effect +0.40 to +6.05 uV, median +2.32; all positive = True;
               reference marginal average +2.32, linked-mastoids +5.26, Cz +0.96 uV.
     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 486 pipelines agree.

Pitfall: pf-post-hoc-windows.  Widget: w-garden-of-forking-paths (mode multiverse).
Statistics thread: L3.7 -> L4.7 -> L6.1 -> L6.4.