Effect sizes, power and precision: a simulated subjects-by-trials power surface for the P3, a between-group resting pilot on Iowa PD, and what a small pilot really tells you, measured by subsampling Dortmund

nb-6-3-power-sim Level 6 · Inference and Rigor ~12 min Used in L6.3 · Effect sizes, power and precision

Downloads from ds-erpcore, ds-iowapd, ds-dortmund 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-3-power-sim · Effect sizes, power and precision (L6.3)

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

"How many subjects do I need?" has no answer until three other questions are answered: how big is the effect, how variable is it between people, and how precisely can one person be measured. An EEG design has two sample sizes — subjects and trials — and they buy different things. This notebook simulates both.

  1. Within-subject ERP power, with the three variance terms estimated from the ERP CORE P3 cohort rather than guessed, and a subjects × trials power surface to read a design off.
  2. Pilot data, honestly: a between-group resting contrast on ds-iowapd (100 PD, 49 controls; CC0) used as a pilot, with the required-N estimate it implies and the uncertainty on that estimate.
  3. What a small pilot actually tells you, measured by subsampling ds-dortmund (608 adults, CC0): draw small pilots from a pool, compute the required N each one implies, and compare with the pool's own answer. The spread is the point.
  4. ds-bonn as the pseudoreplication example — discussed and cited, never computed from. Its licence is informal-academic with no formal text (spec §13 item 19 unanswered), which data/scripts/build_dataset_pages.derive_snippets resolves to snippets: no, so nothing in this course is derived from it.

Data.

  • ds-erpcore — ERP CORE, Kappenman, Farrens, Zhang, Stewart & Luck (2020), PsyArXiv DOI 10.31234/osf.io/4azqm; dataset DOI 10.18112/openneuro.ds003069.v1.0.0. Licence CC BY-SA 4.0, contested at source (the shipped LICENSE says CC BY-SA 4.0, the BIDS dataset_description.json says CC0, the OSF node thsqg record says CC BY 4.0; §10.7 makes the most restrictive reading govern).
  • ds-iowapd — Anjum, Espinoza, Cole, Singh, May, Uc, Dasgupta & Narayanan (2024), Resting-state EEG measures cognitive impairment in Parkinson's disease, npj Parkinson's Disease 10, 6, DOI 10.1038/s41531-023-00602-0; dataset "Rest eyes open", OpenNeuro ds004584 v1.0.0, DOI 10.18112/openneuro.ds004584.v1.0.0. Licence CC0, access: open.
  • ds-dortmund — Wascher, Schneider, Gajewski & Getzmann (2024), Resting-state EEG data before and after cognitive activity across the adult lifespan and a 5-year follow-up, Scientific Data 11, 988, DOI 10.1038/s41597-024-03797-w; OpenNeuro ds005385 v1.0.3, DOI 10.18112/openneuro.ds005385.v1.0.3. Licence CC0 on OpenNeuro; the data-descriptor paper's text says CC BY 4.0 and data/directory.yaml records that the repository's data licence governs.
  • ds-bonn — Andrzejak, Lehnertz, Mormann, Rieke, David & Elger (2001), Physical Review E 64(6), 061907, DOI 10.1103/PhysRevE.64.061907. Discussed only.

No published values are quoted. The catalog carries citations and DOIs; where a comparison with a paper's own numbers would be wanted, this notebook prints a literal TODO(confirm) rather than a number from memory.

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

_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)

_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_l1 as L1
import helpers_l3 as L3
import helpers_l6 as L6

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import mne
from scipy import stats

mne.set_log_level("WARNING")
plt.rcParams["figure.dpi"] = 72

# Iteration counts.  A power simulation is linear in its simulation count and every download is
# linear in the cohort size, so both are budgeted here and FULL_RUN says what a thorough run costs.
FULL_RUN = False
N_SIMS = 2000 if not FULL_RUN else 20000        # simulated studies per grid cell
N_SIMS_BETWEEN = 4000 if not FULL_RUN else 20000
N_PILOT = 10 if not FULL_RUN else 49            # ds-iowapd subjects PER GROUP (49 controls is the cohort cap)
N_POOL = 36 if not FULL_RUN else 120            # ds-dortmund subjects in the subsampling pool
N_BOOT = 400 if not FULL_RUN else 4000          # bootstrap replicates of a pilot
SEED = L6.SEED
ALPHA = L6.ALPHA
TARGET_POWER = 0.80

print(f"MNE {mne.__version__}; helpers_l6 imported from notebooks/_shared")
print(f"FULL_RUN = {FULL_RUN}: {N_SIMS} simulated studies per power cell, {N_PILOT} ds-iowapd subjects per "
      f"group, {N_POOL} ds-dortmund subjects in the pool, {N_BOOT} bootstrap replicates")
print(f"Downloads: ~{N_PILOT * 2 * 36} MB of ds-iowapd and ~{N_POOL * 24} MB of ds-dortmund, fetched one "
      f"subject at a time and deleted before the next is fetched, so at most one recording is ever on disk")
MNE 1.10.2; helpers_l6 imported from notebooks/_shared
FULL_RUN = False: 2000 simulated studies per power cell, 10 ds-iowapd subjects per group, 36 ds-dortmund subjects in the pool, 400 bootstrap replicates
Downloads: ~720 MB of ds-iowapd and ~864 MB of ds-dortmund, fetched one subject at a time and deleted before the next is fetched, so at most one recording is ever on disk

1 · Three variances, not one

A subject's measured difference score is the sum of two independent things:

$$\text{observed}_i = \underbrace{\mu + \tau\,z_i}_{\text{that subject's true effect}} + \underbrace{\epsilon_i}_{\text{measurement noise}}, \qquad \text{SD}(\epsilon_i) = \sigma\sqrt{\tfrac{1}{n_{\text{rare}}} + \tfrac{1}{n_{\text{common}}}}$$

  • μ is the group effect — what the paper reports.
  • τ is how much people genuinely differ. More trials do not touch it.
  • σ is the trial-to-trial standard deviation. More trials shrink its contribution as 1/√n, and that is the standardized measurement error of L3.5.

Estimating all three separately is what makes a power simulation more than a guess: the observed spread of subject scores is √(τ² + mean measurement variance), so τ² is what is left after the measurement variance is subtracted. The cell below does that on the ERP CORE cohort.

In [2]:
SUBJECTS = list(L6.SUBSET_DEFAULT)
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")
L6.disk_report("after the ERP CORE loop")

i_prespec = L6.path_index(L6.PRESPECIFIED)
MIN_TRIALS = int(L6.FIXED_CHOICES["min_trials_per_condition"])
rows = []
for s in sorted(cohort):
    p = cohort[s]
    keep = p["keep"][i_prespec]
    t_scores = p["amp"][i_prespec][keep & p["is_target"]].astype(float)
    s_scores = p["amp"][i_prespec][keep & ~p["is_target"]].astype(float)
    if min(len(t_scores), len(s_scores)) < MIN_TRIALS:
        print(f"EXCLUDED {s}: {len(t_scores)} target / {len(s_scores)} standard trials survive "
              f"(stated minimum {MIN_TRIALS})")
        continue
    rows.append({"subject": s, "n_target": len(t_scores), "n_standard": len(s_scores),
                 "diff_uv": t_scores.mean() - s_scores.mean(),
                 "sd_target": t_scores.std(ddof=1), "sd_standard": s_scores.std(ddof=1),
                 "sme_uv": np.sqrt(t_scores.var(ddof=1) / len(t_scores)
                                   + s_scores.var(ddof=1) / len(s_scores))})
pilot = pd.DataFrame(rows)
mu = float(pilot.diff_uv.mean())
sd_obs = float(pilot.diff_uv.std(ddof=1))
mean_meas_var = float((pilot.sme_uv ** 2).mean())
tau2 = sd_obs ** 2 - mean_meas_var
tau = float(np.sqrt(max(tau2, 0.0)))
sigma = float(np.sqrt(np.average(np.r_[pilot.sd_target ** 2, pilot.sd_standard ** 2],
                                 weights=np.r_[pilot.n_target - 1, pilot.n_standard - 1])))
print(pilot.round(3).to_string(index=False))
print(f"\nVARIANCE DECOMPOSITION ({len(pilot)} subjects, P3 mean amplitude 300-600 ms at Pz, "
      f"target minus standard):")
print(f"   mu    group effect                              {mu:+.4f} uV")
print(f"   observed SD of the subject scores              {sd_obs:.4f} uV")
print(f"   mean measurement variance (SME^2)              {mean_meas_var:.4f} uV^2  "
      f"(mean SME {pilot.sme_uv.mean():.3f} uV)")
print(f"   tau   between-subject SD of the TRUE effect    {tau:.4f} uV   "
      f"= sqrt({sd_obs:.4f}^2 - {mean_meas_var:.4f})")
print(f"   sigma trial-to-trial SD (pooled within-subject) {sigma:.4f} uV")
print()
print(f"   measurement noise accounts for {100 * mean_meas_var / sd_obs ** 2:.1f} % of the observed "
      f"between-subject variance; the remaining {100 * tau2 / sd_obs ** 2:.1f} % is real "
      f"between-subject difference that no number of trials will remove.")
print(f"   Cohen's dz on the observed scores: {mu / sd_obs:.3f}.  With an infinite number of trials per "
      f"subject it would rise to mu/tau = {mu / tau:.3f}, which is the ceiling this design has.")
free disk before any download: 4.31 GB  (ERP CORE cache 0.4 MB, Level-6 products 7.5 MB)
  sub-001: 200 trials, cache in 0.0 s; free disk 4.31 GB
  sub-002: 200 trials, cache in 0.0 s; free disk 4.31 GB
  sub-003: 200 trials, cache in 0.0 s; free disk 4.31 GB
  sub-004: 200 trials, cache in 0.0 s; free disk 4.31 GB
  sub-005: 200 trials, cache in 0.0 s; free disk 4.31 GB
  sub-006: 200 trials, cache in 0.0 s; free disk 4.31 GB
  sub-007: 200 trials, cache in 0.0 s; free disk 4.31 GB
  sub-008: 200 trials, cache in 0.0 s; free disk 4.31 GB
  sub-009: 200 trials, cache in 0.0 s; free disk 4.31 GB
  sub-010: 200 trials, cache in 0.0 s; free disk 4.31 GB
  sub-011: 200 trials, cache in 0.0 s; free disk 4.31 GB
  sub-012: 200 trials, cache in 0.0 s; free disk 4.31 GB
  sub-013: 200 trials, cache in 0.0 s; free disk 4.31 GB
  sub-014: 200 trials, cache in 0.0 s; free disk 4.31 GB
  sub-015: 200 trials, cache in 0.0 s; free disk 4.31 GB
  sub-016: 200 trials, cache in 0.0 s; free disk 4.31 GB
  sub-017: 200 trials, cache in 0.0 s; free disk 4.31 GB
  sub-018: 200 trials, cache in 0.0 s; free disk 4.31 GB
  sub-019: 200 trials, cache in 0.0 s; free disk 4.31 GB
  sub-020: 200 trials, cache in 0.0 s; free disk 4.31 GB
20 subjects in 0 s
free disk after the ERP CORE loop: 4.31 GB  (ERP CORE cache 0.4 MB, Level-6 products 7.5 MB)
EXCLUDED sub-009: 1 target / 4 standard trials survive (stated minimum 15)
subject  n_target  n_standard  diff_uv  sd_target  sd_standard  sme_uv
sub-001        31         130    2.776      4.712        5.208   0.962
sub-002        40         157    9.229      6.918        6.937   1.226
sub-003        37         155    8.013      7.199        7.344   1.322
sub-004        40         157    3.878      3.958        4.787   0.733
sub-005        37         148    2.421      5.077        4.517   0.914
sub-006        18          81    2.541     11.919       12.305   3.124
sub-007        40         160    2.476      6.016        5.852   1.058
sub-008        38         144    2.544      6.496        6.699   1.193
sub-010        35         145    1.626      3.710        4.959   0.750
sub-011        28         101    1.220      5.307        6.075   1.171
sub-012        40         154    3.439      5.683        6.140   1.026
sub-013        40         160    1.221      3.989        3.334   0.684
sub-014        40         151    0.096      5.216        7.327   1.018
sub-015        38         157    0.510      5.189        4.791   0.925
sub-016        35         144    3.309      6.954        6.800   1.305
sub-017        39         158    1.325      7.502       10.472   1.462
sub-018        38         157    3.664      5.609        4.400   0.975
sub-019        40         159    1.092      6.037        6.281   1.077
sub-020        39         157    0.618      6.750        5.326   1.161

VARIANCE DECOMPOSITION (19 subjects, P3 mean amplitude 300-600 ms at Pz, target minus standard):
   mu    group effect                              +2.7366 uV
   observed SD of the subject scores              2.3589 uV
   mean measurement variance (SME^2)              1.6050 uV^2  (mean SME 1.162 uV)
   tau   between-subject SD of the TRUE effect    1.9898 uV   = sqrt(2.3589^2 - 1.6050)
   sigma trial-to-trial SD (pooled within-subject) 6.3512 uV

   measurement noise accounts for 28.8 % of the observed between-subject variance; the remaining 71.2 % is real between-subject difference that no number of trials will remove.
   Cohen's dz on the observed scores: 1.160.  With an infinite number of trials per subject it would rise to mu/tau = 1.375, which is the ceiling this design has.

2 · The power surface

Simulate whole studies. Each simulated study draws n_subjects true effects from Normal(μ, τ), adds measurement noise for the trial count on offer, and runs the same two-sided one-sample t-test the real analysis runs. Power is the fraction of studies that reject.

The trial ratio is fixed at the paradigm's own: four standards for every target, because that is what a p = .2 oddball gives you and it is not a free parameter.

In [3]:
SUBJECT_COUNTS = [6, 8, 10, 12, 15, 20, 25, 30, 40]
TRIAL_COUNTS = [5, 10, 15, 20, 30, 40, 60, 100]
t0 = time.time()
grid = L6.power_grid(effect_uv=mu, between_sd_uv=tau, trial_sd_uv=sigma,
                     subject_counts=SUBJECT_COUNTS, trial_counts=TRIAL_COUNTS,
                     trial_ratio=4.0, n_sims=N_SIMS, alpha=ALPHA, seed=SEED)
_dt = time.time() - t0
print(f"{len(SUBJECT_COUNTS)} x {len(TRIAL_COUNTS)} = {grid.size} cells x {N_SIMS} simulated studies "
      f"= {grid.size * N_SIMS:,} t-tests in {_dt:.2f} s")
print(f"(a full run at 20,000 simulations per cell would take about {_dt * 20000 / N_SIMS:.1f} s -- the "
      f"simulation is the cheap part of this notebook; the downloads in sections 3 and 4 are not.  The "
      f"Monte-Carlo standard error on a power near 0.8 is sqrt(.8 x .2 / n) = "
      f"{np.sqrt(0.8 * 0.2 / N_SIMS):.4f} at {N_SIMS} and {np.sqrt(0.8 * 0.2 / 20000):.4f} at 20,000)")

small = L6.smallest_design(grid, SUBJECT_COUNTS, TRIAL_COUNTS, target=TARGET_POWER)
print(f"\nex-6-3 GRID READ-OFF -- smallest subject count reaching {TARGET_POWER:.0%} power, "
      f"per target-trial count:")
print(f"{'target trials':>14s} {'standards':>10s} {'subjects':>9s} {'power there':>12s}")
for r in small:
    print(f"{r['n_trials_rare']:14d} {r['n_trials_rare'] * 4:10d} "
          f"{str(r['n_subjects']) if r['reached'] else '> ' + str(SUBJECT_COUNTS[-1]):>9s} "
          f"{r['power']:12.3f}")
print()
print(f"Read that table with one grid step of slack.  A cell whose power lands near {TARGET_POWER:.2f} can "
      f"cross the line either way between runs: with FULL_RUN = True (20,000 simulations instead of "
      f"{N_SIMS}) one row of this table moves on this machine -- 30 target trials asks for 10 subjects "
      f"rather than 8, because the 8-subject cell sits at 0.802.  Every other row is unchanged.  The "
      f"answer to 'how many subjects' is a region of this grid, not a cell.")
9 x 8 = 72 cells x 2000 simulated studies = 144,000 t-tests in 0.03 s
(a full run at 20,000 simulations per cell would take about 0.3 s -- the simulation is the cheap part of this notebook; the downloads in sections 3 and 4 are not.  The Monte-Carlo standard error on a power near 0.8 is sqrt(.8 x .2 / n) = 0.0089 at 2000 and 0.0028 at 20,000)

ex-6-3 GRID READ-OFF -- smallest subject count reaching 80% power, per target-trial count:
 target trials  standards  subjects  power there
             5         20        20        0.889
            10         40        12        0.819
            15         60        10        0.809
            20         80        10        0.858
            30        120         8        0.802
            40        160         8        0.822
            60        240         8        0.859
           100        400         8        0.894

Read that table with one grid step of slack.  A cell whose power lands near 0.80 can cross the line either way between runs: with FULL_RUN = True (20,000 simulations instead of 2000) one row of this table moves on this machine -- 30 target trials asks for 10 subjects rather than 8, because the 8-subject cell sits at 0.802.  Every other row is unchanged.  The answer to 'how many subjects' is a region of this grid, not a cell.
In [4]:
fig, ax = L6.plot_power_grid(grid, SUBJECT_COUNTS, TRIAL_COUNTS, target=TARGET_POWER,
                             title=f"Simulated power, P3 target − standard at Pz "
                                   f"(µ = {mu:+.2f} µV, τ = {tau:.2f} µV, σ = {sigma:.2f} µV, "
                                   f"{N_SIMS} studies per cell)")
plt.show()   # render the static figure(s) of this cell inline
Figure 1 of notebook nb-6-3-power-sim, an output plot. The text around it states what it shows and the units of every axis.
In [5]:
print("Where the two sample sizes stop helping:")
j40 = TRIAL_COUNTS.index(40)
for i, ns in enumerate(SUBJECT_COUNTS):
    print(f"   {ns:3d} subjects: power {grid[i, 0]:.3f} at {TRIAL_COUNTS[0]:3d} target trials -> "
          f"{grid[i, j40]:.3f} at 40 -> {grid[i, -1]:.3f} at {TRIAL_COUNTS[-1]}")
print()
print(f"Read the rows, not the cells.  Adding trials moves power a long way while the measurement noise is "
      f"still large compared with tau, and then stops: past about "
      f"{TRIAL_COUNTS[int(np.argmax(grid[len(SUBJECT_COUNTS) // 2] > 0.95 * grid[len(SUBJECT_COUNTS) // 2].max()))]} "
      f"target trials the remaining variance is between-subject variance, which only subjects can buy.")
print(f"The ceiling for an infinite number of trials, at each subject count:")
inf = [L6.simulate_power(effect_uv=mu, between_sd_uv=tau, trial_sd_uv=0.0, n_subjects=ns,
                         n_trials_rare=1, n_sims=N_SIMS, alpha=ALPHA,
                         rng=np.random.default_rng(SEED + 7)) for ns in SUBJECT_COUNTS]
print("   " + "  ".join(f"{ns}:{p:.2f}" for ns, p in zip(SUBJECT_COUNTS, inf)))
print(f"\nThis dataset's own design ({int(pilot.n_target.median())} median surviving target trials, "
      f"{len(pilot)} subjects) sits at simulated power "
      f"{L6.simulate_power(effect_uv=mu, between_sd_uv=tau, trial_sd_uv=sigma, n_subjects=len(pilot), n_trials_rare=int(pilot.n_target.median()), n_sims=N_SIMS, alpha=ALPHA, rng=np.random.default_rng(SEED + 11)):.3f} "
      f"for an effect of {mu:+.2f} uV.")
Where the two sample sizes stop helping:
     6 subjects: power 0.307 at   5 target trials -> 0.656 at 40 -> 0.716 at 100
     8 subjects: power 0.436 at   5 target trials -> 0.822 at 40 -> 0.894 at 100
    10 subjects: power 0.557 at   5 target trials -> 0.911 at 40 -> 0.955 at 100
    12 subjects: power 0.650 at   5 target trials -> 0.961 at 40 -> 0.985 at 100
    15 subjects: power 0.745 at   5 target trials -> 0.991 at 40 -> 0.995 at 100
    20 subjects: power 0.889 at   5 target trials -> 0.999 at 40 -> 1.000 at 100
    25 subjects: power 0.938 at   5 target trials -> 1.000 at 40 -> 1.000 at 100
    30 subjects: power 0.965 at   5 target trials -> 1.000 at 40 -> 1.000 at 100
    40 subjects: power 0.992 at   5 target trials -> 1.000 at 40 -> 1.000 at 100

Read the rows, not the cells.  Adding trials moves power a long way while the measurement noise is still large compared with tau, and then stops: past about 15 target trials the remaining variance is between-subject variance, which only subjects can buy.
The ceiling for an infinite number of trials, at each subject count:
   6:0.76  8:0.91  10:0.97  12:0.99  15:1.00  20:1.00  25:1.00  30:1.00  40:1.00

This dataset's own design (38 median surviving target trials, 19 subjects) sits at simulated power 0.998 for an effect of +2.74 uV.

Power is not the only question, and often not the best one

Power asks whether the test will reject. Precision asks how wide the confidence interval will be, and for a measurement it is usually the more useful target: a study powered at 80 % for a 2.7 µV effect will routinely report intervals that run from "barely there" to "twice as big as expected".

In [6]:
print(f"Expected half-width of the 95 % CI (uV), by design, for tau = {tau:.3f} and sigma = {sigma:.3f}:")
print(f"{'subjects':>9s} " + "".join(f"{t:>9d}" for t in TRIAL_COUNTS) + "   <- target trials")
for ns in SUBJECT_COUNTS:
    widths = []
    for nt in TRIAL_COUNTS:
        sd_score = np.sqrt(tau ** 2 + sigma ** 2 * (1 / nt + 1 / (4 * nt)))
        widths.append(stats.t.ppf(1 - ALPHA / 2, ns - 1) * sd_score / np.sqrt(ns))
    print(f"{ns:9d} " + "".join(f"{w:9.3f}" for w in widths))
print()
print(f"At {len(pilot)} subjects and {int(pilot.n_target.median())} target trials the expected half-width is "
      f"about {stats.t.ppf(1 - ALPHA / 2, len(pilot) - 1) * np.sqrt(tau ** 2 + sigma ** 2 * (1 / pilot.n_target.median() + 1 / pilot.n_standard.median())) / np.sqrt(len(pilot)):.2f} uV "
      f"on an effect of {mu:.2f} uV -- so the study can say the effect is there and can barely say how big.")
Expected half-width of the 95 % CI (uV), by design, for tau = 1.990 and sigma = 6.351:
 subjects         5       10       15       20       30       40       60      100   <- target trials
        6     3.933    3.149    2.839    2.672    2.492    2.398    2.299    2.217
        8     3.133    2.508    2.262    2.128    1.985    1.910    1.832    1.766
       10     2.681    2.146    1.936    1.821    1.699    1.634    1.567    1.511
       12     2.381    1.906    1.719    1.617    1.509    1.452    1.392    1.342
       15     2.075    1.661    1.498    1.410    1.315    1.265    1.213    1.170
       20     1.754    1.404    1.266    1.191    1.111    1.069    1.025    0.989
       25     1.547    1.238    1.117    1.051    0.980    0.943    0.904    0.872
       30     1.399    1.120    1.010    0.951    0.887    0.853    0.818    0.789
       40     1.199    0.960    0.865    0.814    0.760    0.731    0.701    0.676

At 19 subjects and 38 target trials the expected half-width is about 1.11 uV on an effect of 2.74 uV -- so the study can say the effect is there and can barely say how big.

3 · Pilot data I — a between-group resting contrast on ds-iowapd

A different design and a harder one: two groups of different people, one measurement each. ds-iowapd is 100 patients with Parkinson's disease, recorded on dopaminergic medication, and 49 controls, eyes-open rest (data/directory.yaml). The catalog's own caveats are part of the analysis and are printed below rather than discovered later.

The measure is fixed before any of it is loaded, in helpers_l6.RESTING_MEASURE, and both datasets in this notebook are measured with it unchanged.

In [7]:
for k, v in L6.RESTING_MEASURE.items():
    print(f"   {k:14s} : {v}")
print()
print("ds-iowapd, from data/directory.yaml:")
for k in ("device", "sfreq", "n_channels", "population", "paradigm", "reference", "online_filters",
          "mains_hz", "license", "access"):
    print(f"   {k:14s} : {L6.DATASETS_L6['ds-iowapd'][k]}")
print("   caveats        :")
for c in L6.DATASETS_L6["ds-iowapd"]["caveats"]:
    print(f"                    - {c}")
   name           : relative beta power
   definition     : the share of 1-45 Hz power that falls in 13-30 Hz, averaged over C3, Cz and C4
   band_hz        : (13.0, 30.0)
   total_hz       : (1.0, 45.0)
   channels       : ('C3', 'Cz', 'C4')
   reference      : average of the available EEG channels, applied after flat/bad channels are dropped, because the two datasets have different online references (Pz and FCz) and raw power under different references is not comparable
   segment        : seconds 10 to 130 of the recording (120 s), skipping the start of the block
   spectrum       : Welch, 2-s Hann segments, 50 % overlap (scipy.signal.welch through helpers_l1.welch_psd)
   why_this_one   : it is pre-specified, it is one number per participant, it needs no source model and no montage beyond three central electrodes that both datasets record, and the ds-iowapd catalog names beta as the band its cohort's medication state is expected to affect.  It is NOT chosen because it worked: it is written down here, once, and both datasets are measured with it unchanged.
   known_limits   : eyes-open rest in ds-iowapd and eyes-open rest in ds-dortmund are the same condition, but the recordings differ in sampling rate (500 vs 1000 Hz), online reference (Pz vs FCz), online filtering (0.1 Hz high-pass vs 250 Hz low-pass only) and mains frequency (60 vs 50 Hz).  A measure that is a *ratio within one recording* survives a lot of that; it does not make the two cohorts interchangeable.

ds-iowapd, from data/directory.yaml:
   device         : Brain Vision system with 64-channel actiCAP (Brain Products)
   sfreq          : 500
   n_channels     : 64 recorded; 60 analyzable (Pz reference; Iz, I1, I2 excluded)
   population     : 100 PD (on dopaminergic medication; 68 M / 32 F; ~68.5 y) + 49 controls (~70.9 y)
   paradigm       : eyes-open rest, ~3 min on average, once per participant
   reference      : Pz (online)
   online_filters : 0.1 Hz online high-pass
   mains_hz       : 60
   license        : CC0
   access         : open
   caveats        :
                    - PD recorded on medication (attenuates beta signatures).
                    - 100 vs 49 group imbalance.
                    - Pz reference channel is flat.
In [8]:
parts = L6.iowapd_participants()
print(f"participants.tsv: {len(parts)} rows, columns {list(parts.columns)}")
print(parts.GROUP.value_counts().to_string())
print(f"\nage: PD {parts.loc[parts.GROUP == 'PD', 'AGE'].mean():.1f} +- "
      f"{parts.loc[parts.GROUP == 'PD', 'AGE'].std():.1f}, "
      f"Control {parts.loc[parts.GROUP == 'Control', 'AGE'].mean():.1f} +- "
      f"{parts.loc[parts.GROUP == 'Control', 'AGE'].std():.1f}")
# Selection rule, stated before any recording is read: the first N_PILOT of each group in file order.
pd_ids = parts.loc[parts.GROUP == "PD", "participant_id"].tolist()[:N_PILOT]
hc_ids = parts.loc[parts.GROUP == "Control", "participant_id"].tolist()[:N_PILOT]
print(f"\nselection rule (stated before any recording is read): the first {N_PILOT} PD and the first "
      f"{N_PILOT} Control participants in participants.tsv order.  No recording is inspected first, and "
      f"no subject is swapped for another afterwards.")
print(f"   PD      : {', '.join(pd_ids)}")
print(f"   Control : {', '.join(hc_ids)}")

t0 = time.time()
free0 = L6.disk_report("before the ds-iowapd loop",
                       folders={"course downloads": L1.download_dir()})["free_gb"]
iowa = L6.resting_cohort("ds-iowapd", pd_ids + hc_ids, progress=True)
iowa["group"] = ["PD"] * len(pd_ids) + ["Control"] * len(hc_ids)
print(f"\n{int(iowa.ok.sum())} of {len(iowa)} recordings measured in {time.time() - t0:.0f} s")
L6.disk_report("after the ds-iowapd loop", folders={"course downloads": L1.download_dir()})
if (~iowa.ok).any():
    print("failures:")
    print(iowa.loc[~iowa.ok, ["subject", "reason"]].to_string(index=False))
participants.tsv: 149 rows, columns ['participant_id', 'GROUP', 'ID', 'EEG', 'AGE', 'GENDER', 'MOCA', 'UPDRS', 'TYPE']
GROUP
PD         100
Control     49

age: PD 68.5 +- 8.1, Control 70.9 +- 7.6

selection rule (stated before any recording is read): the first 10 PD and the first 10 Control participants in participants.tsv order.  No recording is inspected first, and no subject is swapped for another afterwards.
   PD      : sub-001, sub-002, sub-003, sub-004, sub-005, sub-006, sub-007, sub-008, sub-009, sub-010
   Control : sub-101, sub-102, sub-103, sub-104, sub-105, sub-106, sub-107, sub-108, sub-109, sub-110
free disk before the ds-iowapd loop: 4.31 GB  (course downloads 90.3 MB)
  ds-iowapd sub-001: 0.3637  (free disk 4.31 GB)
  ds-iowapd sub-002: 0.1390  (free disk 4.30 GB)
  ds-iowapd sub-003: 0.2470  (free disk 4.30 GB)
  ds-iowapd sub-004: 0.0367  (free disk 4.30 GB)
  ds-iowapd sub-005: 0.1784  (free disk 4.30 GB)
  ds-iowapd sub-006: 0.1611  (free disk 4.29 GB)
  ds-iowapd sub-007: 0.1709  (free disk 4.12 GB)
  ds-iowapd sub-008: 0.1118  (free disk 4.12 GB)
  ds-iowapd sub-009: 0.1755  (free disk 4.12 GB)
  ds-iowapd sub-010: 0.1134  (free disk 4.12 GB)
  ds-iowapd sub-101: 0.3020  (free disk 4.12 GB)
  ds-iowapd sub-102: 0.2729  (free disk 4.12 GB)
  ds-iowapd sub-103: 0.2066  (free disk 4.12 GB)
  ds-iowapd sub-104: 0.3513  (free disk 4.11 GB)
  ds-iowapd sub-105: 0.1051  (free disk 4.11 GB)
  ds-iowapd sub-106: 0.2417  (free disk 4.04 GB)
  ds-iowapd sub-107: 0.3938  (free disk 4.12 GB)
  ds-iowapd sub-108: 0.2783  (free disk 4.30 GB)
  ds-iowapd sub-109: 0.3381  (free disk 4.30 GB)
  ds-iowapd sub-110: 0.2365  (free disk 4.29 GB)
20 of 20 recordings measured in 150 s
free disk after the ds-iowapd loop: 4.29 GB  (course downloads 90.3 MB)
In [9]:
ok = iowa[iowa.ok]
a = ok.loc[ok.group == "PD", "value"].to_numpy()
b = ok.loc[ok.group == "Control", "value"].to_numpy()
g = L6.hedges_g(a, b)
t_ind, p_ind = stats.ttest_ind(a, b, equal_var=False)
print(f"PILOT RESULT -- {L6.RESTING_MEASURE['name']} ({L6.RESTING_MEASURE['definition']}), eyes-open rest:")
print(f"   PD      n = {len(a):2d}, mean {a.mean():.4f}, SD {a.std(ddof=1):.4f}")
print(f"   Control n = {len(b):2d}, mean {b.mean():.4f}, SD {b.std(ddof=1):.4f}")
print(f"   difference {a.mean() - b.mean():+.4f} (PD minus Control)")
print(f"   Welch t({stats.ttest_ind(a, b, equal_var=False).df:.1f}) = {t_ind:.3f}, p = {p_ind:.4f}")
print(f"   Hedges' g = {g['g']:+.4f}, 95% CI [{g['ci'][0]:+.4f}, {g['ci'][1]:+.4f}] "
      f"(Cohen's d {g['d']:+.4f}, correction factor {g['correction_j']:.4f})")
print()
print(f"   The confidence interval is what the sample size question actually depends on, and it spans "
      f"{g['ci'][1] - g['ci'][0]:.2f} standardized units.")

t0 = time.time()
g_small = min(g["ci"], key=abs)            # the CI end nearer zero: the smallest effect still compatible
g_large = max(g["ci"], key=abs)            # the CI end further from zero: the largest
n_point = L6.required_n_between(g["g"], power=TARGET_POWER, alpha=ALPHA, n_sims=N_SIMS_BETWEEN, seed=SEED)
n_small = L6.required_n_between(g_small, power=TARGET_POWER, alpha=ALPHA, n_sims=N_SIMS_BETWEEN, seed=SEED)
n_large = L6.required_n_between(g_large, power=TARGET_POWER, alpha=ALPHA, n_sims=N_SIMS_BETWEEN, seed=SEED)
print(f"REQUIRED N per group for {TARGET_POWER:.0%} power at alpha = {ALPHA}, by simulation "
      f"({N_SIMS_BETWEEN} studies per candidate n, bisection; {time.time() - t0:.1f} s):")
print(f"   from the point estimate       g = {g['g']:+.3f} : "
      f"{n_point if n_point else '> 4000'} per group")
print(f"   from the CI end NEARER zero   g = {g_small:+.3f} : "
      f"{n_small if n_small else '> 4000'} per group   <- the smallest effect the pilot still allows, so "
      f"the LARGEST study it implies")
print(f"   from the CI end FURTHER away  g = {g_large:+.3f} : "
      f"{n_large if n_large else '> 4000'} per group   <- the most optimistic reading")
print()
print(f"   A pilot of {len(a)} and {len(b)} does not give you a sample size.  It gives you a RANGE of sample "
      f"sizes, and the range here is wide enough that the point estimate is close to useless on its own.")
PILOT RESULT -- relative beta power (the share of 1-45 Hz power that falls in 13-30 Hz, averaged over C3, Cz and C4), eyes-open rest:
   PD      n = 10, mean 0.1698, SD 0.0875
   Control n = 10, mean 0.2726, SD 0.0821
   difference -0.1029 (PD minus Control)
   Welch t(17.9) = -2.710, p = 0.0144
   Hedges' g = -1.1608, 95% CI [-2.1083, -0.2133] (Cohen's d -1.2120, correction factor 0.9577)

   The confidence interval is what the sample size question actually depends on, and it spans 1.89 standardized units.
REQUIRED N per group for 80% power at alpha = 0.05, by simulation (4000 studies per candidate n, bisection; 0.2 s):
   from the point estimate       g = -1.161 : 13 per group
   from the CI end NEARER zero   g = -0.213 : 347 per group   <- the smallest effect the pilot still allows, so the LARGEST study it implies
   from the CI end FURTHER away  g = -2.108 : 5 per group   <- the most optimistic reading

   A pilot of 10 and 10 does not give you a sample size.  It gives you a RANGE of sample sizes, and the range here is wide enough that the point estimate is close to useless on its own.

The winner's curse, with a bootstrap

Why the point estimate is optimistic even when nobody does anything wrong: a pilot that happened to sample a large effect gives a small required N, and a pilot that happened to sample a small one gives a large required N — but the small required N is the one that makes the grant look fundable. Resampling this pilot shows how far that estimate moves from nothing but sampling.

In [10]:
rng = np.random.default_rng(SEED)
boot_g, boot_n = [], []
t0 = time.time()
for _ in range(N_BOOT):
    ga = L6.hedges_g(rng.choice(a, len(a), replace=True), rng.choice(b, len(b), replace=True))["g"]
    boot_g.append(ga)
    boot_n.append(L6.required_n_between(ga, power=TARGET_POWER, alpha=ALPHA, n_sims=600, seed=SEED))
boot_g = np.array(boot_g)
finite = np.array([n for n in boot_n if n is not None], float)
print(f"{N_BOOT} bootstrap pilots of the same size in {time.time() - t0:.0f} s "
      f"(600 simulations per required-N search, so each n is coarse; the spread, not the digits, is the point)")
print(f"   bootstrap g      : median {np.median(boot_g):+.3f}, 2.5-97.5 % "
      f"[{np.percentile(boot_g, 2.5):+.3f}, {np.percentile(boot_g, 97.5):+.3f}]")
print(f"   sign flips in    : {100 * np.mean(np.sign(boot_g) != np.sign(g['g'])):.1f} % of bootstrap pilots")
print(f"   required N       : median {np.median(finite):.0f}, 2.5-97.5 % "
      f"[{np.percentile(finite, 2.5):.0f}, {np.percentile(finite, 97.5):.0f}] per group"
      f" ({len(boot_n) - len(finite)} of {N_BOOT} pilots implied more than 4000 per group)")
print(f"   ratio of the 97.5th to the 2.5th percentile: "
      f"{np.percentile(finite, 97.5) / max(np.percentile(finite, 2.5), 1):.0f}x")
400 bootstrap pilots of the same size in 2 s (600 simulations per required-N search, so each n is coarse; the spread, not the digits, is the point)
   bootstrap g      : median -1.251, 2.5-97.5 % [-2.872, -0.311]
   sign flips in    : 1.0 % of bootstrap pilots
   required N       : median 11, 2.5-97.5 % [4, 97] per group (4 of 400 pilots implied more than 4000 per group)
   ratio of the 97.5th to the 2.5th percentile: 24x

4 · Pilot data II — subsampling ds-dortmund to see how required N behaves

The bootstrap above resamples one pilot. Here the pilots are real and independent: a pool of ds-dortmund participants, split into two groups by age, from which small pilots are drawn. The pool's own estimate is the closest thing to a truth available, and the question is how close a pilot of 5, 8 or 12 per group gets to it.

ds-dortmund is 608 healthy adults aged 20–70 with CC0 data on OpenNeuro. Its eyes-open resting block is measured with exactly the same pre-specified measure, which is the only way the two sections are comparable — and even then the recordings differ in sampling rate, online reference, online filtering and mains frequency, so the numbers below are about the behaviour of a pilot, not about Parkinson's disease.

In [11]:
for k in ("device", "sfreq", "n_channels", "population", "paradigm", "reference", "online_filters",
          "mains_hz", "license", "license_note", "access"):
    print(f"   {k:14s} : {L6.DATASETS_L6['ds-dortmund'][k]}")
print("   caveats        :")
for c in L6.DATASETS_L6["ds-dortmund"]["caveats"]:
    print(f"                    - {c}")

dparts = L6.dortmund_participants()
print(f"\nparticipants.tsv: {len(dparts)} rows, columns {list(dparts.columns)}")
print(f"age {dparts.age.min()}-{dparts.age.max()}, median {dparts.age.median():.0f}; "
      f"sex {dparts.sex.value_counts().to_dict()}")
# Selection rule, stated before any recording is read.
pool_ids = dparts.loc[dparts.session1 == "yes", "participant_id"].tolist()[:N_POOL]
ages = dparts.set_index("participant_id").loc[pool_ids, "age"]
cut = float(ages.median())
print(f"\nselection rule (stated before any recording is read): the first {N_POOL} participants with a "
      f"ses-1 recording, split at the pool's median age ({cut:.0f} years) into 'younger' and 'older'.  "
      f"The split is a median split for the arithmetic's sake, not a claim about ageing.")
print(f"   pool ages: {ages.min()}-{ages.max()}, median {cut:.0f}")

t0 = time.time()
L6.disk_report("before the ds-dortmund loop", folders={"course downloads": L1.download_dir()})
dort = L6.resting_cohort("ds-dortmund", pool_ids, task="EyesOpen", acq="pre", progress=True)
dort["age"] = ages.to_numpy()
dort["group"] = np.where(dort.age <= cut, "younger", "older")
print(f"\n{int(dort.ok.sum())} of {len(dort)} recordings measured in {time.time() - t0:.0f} s")
L6.disk_report("after the ds-dortmund loop", folders={"course downloads": L1.download_dir()})
if (~dort.ok).any():
    print(dort.loc[~dort.ok, ["subject", "reason"]].to_string(index=False))
   device         : Brain Products BrainAmp DC, 64-channel elastic cap (extended 10-20)
   sfreq          : 1000
   n_channels     : 64
   population     : 608 healthy adults 20-70 (376 F / 232 M); 208 re-recorded ~5 years later
   paradigm       : 3-min eyes-closed and 3-min eyes-open blocks, before (pre) and after (post) a ~2-h cognitive battery
   reference      : FCz
   online_filters : 250 Hz online low-pass; no online high-pass; no online notch
   mains_hz       : 50
   license        : CC0
   license_note   : CC0 on OpenNeuro; the data-descriptor paper text states CC BY 4.0 -- the repository's data licence governs (data/directory.yaml).
   access         : open
   caveats        :
                    - 'pre'/'post' are within-session labels around a cognitive battery, not the two longitudinal sessions.
                    - The OpenNeuro page and the paper state different licences (CC0 vs CC BY 4.0).
                    - EC and EO are separate recordings -- no within-file transition.

participants.tsv: 608 rows, columns ['participant_id', 'sex', 'age', 'handedness', 'session1', 'late_ses1', 'session2', 'late_ses2']
age 20-70, median 46; sex {'F': 376, 'M': 232}

selection rule (stated before any recording is read): the first 36 participants with a ses-1 recording, split at the pool's median age (43 years) into 'younger' and 'older'.  The split is a median split for the arithmetic's sake, not a claim about ageing.
   pool ages: 20-68, median 43
free disk before the ds-dortmund loop: 4.27 GB  (course downloads 90.3 MB)
  ds-dortmund sub-001: 0.3911  (free disk 4.25 GB)
  ds-dortmund sub-002: 0.3516  (free disk 4.24 GB)
  ds-dortmund sub-003: 0.1516  (free disk 4.29 GB)
  ds-dortmund sub-004: 0.1203  (free disk 4.29 GB)
  ds-dortmund sub-005: 0.1448  (free disk 4.29 GB)
  ds-dortmund sub-006: 0.1679  (free disk 4.26 GB)
  ds-dortmund sub-007: 0.1527  (free disk 4.24 GB)
  ds-dortmund sub-008: 0.1092  (free disk 4.29 GB)
  ds-dortmund sub-009: 0.2117  (free disk 4.29 GB)
  ds-dortmund sub-010: 0.1334  (free disk 4.29 GB)
  ds-dortmund sub-011: 0.3109  (free disk 4.30 GB)
  ds-dortmund sub-012: 0.2247  (free disk 4.30 GB)
  ds-dortmund sub-013: 0.2609  (free disk 4.30 GB)
  ds-dortmund sub-014: 0.1595  (free disk 4.30 GB)
  ds-dortmund sub-015: 0.2785  (free disk 4.30 GB)
  ds-dortmund sub-016: 0.0928  (free disk 4.30 GB)
  ds-dortmund sub-017: 0.1413  (free disk 4.29 GB)
  ds-dortmund sub-018: 0.1672  (free disk 4.30 GB)
  ds-dortmund sub-019: 0.3188  (free disk 4.30 GB)
  ds-dortmund sub-020: 0.2482  (free disk 4.29 GB)
  ds-dortmund sub-021: 0.0806  (free disk 4.29 GB)
  ds-dortmund sub-022: 0.2876  (free disk 4.29 GB)
  ds-dortmund sub-023: 0.1077  (free disk 4.30 GB)
  ds-dortmund sub-024: 0.3363  (free disk 4.30 GB)
  ds-dortmund sub-025: 0.2012  (free disk 4.30 GB)
  ds-dortmund sub-026: 0.1156  (free disk 4.29 GB)
  ds-dortmund sub-027: 0.0983  (free disk 4.29 GB)
  ds-dortmund sub-028: 0.2677  (free disk 4.24 GB)
  ds-dortmund sub-029: 0.2265  (free disk 4.29 GB)
  ds-dortmund sub-030: 0.3120  (free disk 4.29 GB)
  ds-dortmund sub-031: 0.1117  (free disk 4.29 GB)
  ds-dortmund sub-032: 0.1253  (free disk 4.29 GB)
  ds-dortmund sub-033: 0.2430  (free disk 4.28 GB)
  ds-dortmund sub-034: 0.1639  (free disk 4.28 GB)
  ds-dortmund sub-035: 0.2855  (free disk 4.28 GB)
  ds-dortmund sub-036: 0.3939  (free disk 4.28 GB)
36 of 36 recordings measured in 99 s
free disk after the ds-dortmund loop: 4.28 GB  (course downloads 90.3 MB)
In [12]:
dok = dort[dort.ok]
ya = dok.loc[dok.group == "younger", "value"].to_numpy()
oa = dok.loc[dok.group == "older", "value"].to_numpy()
g_pool = L6.hedges_g(oa, ya)
t_pool, p_pool = stats.ttest_ind(oa, ya, equal_var=False)
n_pool = L6.required_n_between(g_pool["g"], power=TARGET_POWER, alpha=ALPHA, n_sims=N_SIMS_BETWEEN, seed=SEED)
print(f"THE POOL ({len(oa)} older, {len(ya)} younger), same measure:")
print(f"   older   mean {oa.mean():.4f}, SD {oa.std(ddof=1):.4f}")
print(f"   younger mean {ya.mean():.4f}, SD {ya.std(ddof=1):.4f}")
print(f"   Welch t = {t_pool:.3f}, p = {p_pool:.4f}; Hedges' g = {g_pool['g']:+.4f} "
      f"[{g_pool['ci'][0]:+.4f}, {g_pool['ci'][1]:+.4f}]")
print(f"   required N from the pool's own estimate: "
      f"{n_pool if n_pool else '> 4000'} per group")
print()
print(f"   The pool is {len(dok)} people, which is itself a small study -- it is the best available truth "
      f"here, not the truth.  Everything below compares pilots with IT, not with reality.")
THE POOL (18 older, 18 younger), same measure:
   older   mean 0.2454, SD 0.0960
   younger mean 0.1709, SD 0.0689
   Welch t = 2.675, p = 0.0118; Hedges' g = +0.8719 [+0.1882, +1.5556]
   required N from the pool's own estimate: 22 per group

   The pool is 36 people, which is itself a small study -- it is the best available truth here, not the truth.  Everything below compares pilots with IT, not with reality.
In [13]:
rng = np.random.default_rng(SEED + 3)
# A pilot size must be strictly smaller than the pool's group size, or every "independent pilot"
# is the same pilot and the spread collapses to zero.
PILOT_SIZES = [k for k in ([4, 8, 12] if not FULL_RUN else [5, 10, 20, 40])
               if k < min(len(ya), len(oa))]
N_PILOTS = 200 if not FULL_RUN else 1000
t0 = time.time()
sub_rows = []
for k in PILOT_SIZES:
    gs, ns = [], []
    for _ in range(N_PILOTS):
        ia = rng.choice(len(oa), k, replace=False)
        ib = rng.choice(len(ya), k, replace=False)
        gg = L6.hedges_g(oa[ia], ya[ib])["g"]
        gs.append(gg)
        ns.append(L6.required_n_between(gg, power=TARGET_POWER, alpha=ALPHA, n_sims=400, seed=SEED))
    gs = np.array(gs)
    fin = np.array([v for v in ns if v is not None], float)
    sub_rows.append({"n_per_group": k, "g_median": float(np.median(gs)),
                     "g_lo": float(np.percentile(gs, 2.5)), "g_hi": float(np.percentile(gs, 97.5)),
                     "sign_flip": float(np.mean(np.sign(gs) != np.sign(g_pool["g"]))),
                     "n_median": float(np.median(fin)) if fin.size else np.nan,
                     "n_lo": float(np.percentile(fin, 2.5)) if fin.size else np.nan,
                     "n_hi": float(np.percentile(fin, 97.5)) if fin.size else np.nan,
                     "n_unbounded": int(len(ns) - len(fin)), "n_pilots": len(ns),
                     "gs": gs, "ns": fin})
print(f"{len(sub_rows)} pilot sizes x {N_PILOTS} pilots drawn without replacement from "
      f"{len(oa)} older and {len(ya)} younger in {time.time() - t0:.0f} s")
print(f"(the pilots overlap, because the pool is finite; the spread below is therefore a LOWER bound on "
      f"what genuinely independent pilots would show)")
print(f"\npool: g = {g_pool['g']:+.3f}, required N = {n_pool if n_pool else '> 4000'} per group\n")
print(f"{'pilot n/group':>14s} {'median g':>9s} {'g 2.5-97.5%':>20s} {'sign flip':>10s} "
      f"{'median req N':>13s} {'req N 2.5-97.5%':>18s} {'no answer':>10s}")
for r in sub_rows:
    print(f"{r['n_per_group']:14d} {r['g_median']:+9.3f} "
          f"{r['g_lo']:+8.3f} to {r['g_hi']:+7.3f} {r['sign_flip']:10.1%} "
          f"{r['n_median']:13.0f} {r['n_lo']:8.0f} to {r['n_hi']:7.0f} "
          f"{r['n_unbounded']:4d}/{r['n_pilots']:<5d}")
print()
print(f"'no answer' counts pilots whose estimated effect was so near zero that no n up to 4000 per group "
      f"reached {TARGET_POWER:.0%} power.  Those are not failures of the method: they are what a pilot that "
      f"sampled badly actually tells you, and dropping them is how the published required-N estimates get "
      f"optimistic.")
3 pilot sizes x 200 pilots drawn without replacement from 18 older and 18 younger in 5 s
(the pilots overlap, because the pool is finite; the spread below is therefore a LOWER bound on what genuinely independent pilots would show)

pool: g = +0.872, required N = 22 per group

 pilot n/group  median g          g 2.5-97.5%  sign flip  median req N    req N 2.5-97.5%  no answer
             4    +0.734   -0.359 to  +2.779       8.5%            25        4 to     674    7/200  
             8    +0.848   +0.172 to  +1.717       0.5%            23        7 to     540    0/200  
            12    +0.889   +0.496 to  +1.422       0.0%            21       10 to      66    0/200  

'no answer' counts pilots whose estimated effect was so near zero that no n up to 4000 per group reached 80% power.  Those are not failures of the method: they are what a pilot that sampled badly actually tells you, and dropping them is how the published required-N estimates get optimistic.
In [14]:
fig, axes = plt.subplots(1, 2, figsize=(11.5, 4.3))
for r in sub_rows:
    axes[0].hist(r["gs"], bins=30, alpha=0.5, label=f"pilot n = {r['n_per_group']}/group", density=True)
axes[0].axvline(g_pool["g"], color="k", lw=2, label=f"pool g = {g_pool['g']:+.3f}")
axes[0].axvline(0, color="gray", lw=0.8)
axes[0].set(xlabel="Hedges' g estimated by the pilot (dimensionless)", ylabel="Density",
            title=f"{N_PILOTS} independent pilots per size, drawn from a {len(dok)}-person pool")
axes[0].legend(fontsize=8)
axes[0].grid(alpha=0.3)

pos = np.arange(len(sub_rows))
axes[1].boxplot([np.clip(r["ns"], 1, 4000) for r in sub_rows], positions=pos, widths=0.55,
                showfliers=False, patch_artist=True,
                boxprops=dict(facecolor="tab:blue", alpha=0.45))
if n_pool:
    axes[1].axhline(n_pool, color="k", lw=2, label=f"from the pool: {n_pool}/group")
axes[1].set_yscale("log")
axes[1].set_xticks(pos, [f"{r['n_per_group']}/group" for r in sub_rows])
axes[1].set(xlabel="Pilot size", ylabel="Required N per group for 80 % power (count, log scale)",
            title="What each pilot would have told you to plan for")
axes[1].legend(fontsize=8)
axes[1].grid(alpha=0.3, axis="y")
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
Figure 2 of notebook nb-6-3-power-sim, an output plot. The text around it states what it shows and the units of every axis.

5 · ds-bonn: 500 segments, 10 people

Judgment. ds-bonn (Andrzejak et al., 2001) is 500 artifact-free single-channel segments of 23.6 s, released in five sets of 100, and they come from 10 individuals — five healthy volunteers for the scalp sets and five epilepsy patients for the intracranial ones (data/directory.yaml). It is one of the most reused EEG datasets in the machine-learning literature, and a large part of that literature reports cross-validated accuracies over the 500 segments as if they were 500 independent observations.

They are not. A segment is not a person. The unit that generalises to a new patient is a patient, and there are five of them per class. Everything a power calculation, a confidence interval or a p-value says about "n = 500" is a statement about a sample size the study does not have — and the inflation is not small, because segments from one person share their electrode placement, their pathology, their medication and their amplifier.

The same applies, less visibly, to any EEG analysis that treats trials as the unit: 4,000 single trials from 19 subjects is 19 independent observations wearing a larger number. The mixed model of nb-6-2-lmm is one way to keep that straight; grouping the cross-validation folds by subject, as nb-6-5-decoding does, is another.

Nothing in this course is computed from ds-bonn. Its licence is informal-academic — non-commercial academic use with citation requested, no formal licence text, spec §13 item 19 unanswered — which data/scripts/build_dataset_pages.derive_snippets resolves to snippets: no. It can be discussed and cited, and that is exactly what this section does.

In [15]:
print("ds-bonn, from data/directory.yaml -- discussed, never derived from:")
for k in ("population", "structure", "reference", "online_filters", "license", "license_note",
          "paper_doi", "source"):
    print(f"   {k:14s} : {L6.DATASETS_L6['ds-bonn'][k]}")
print("   caveats        :")
for c in L6.DATASETS_L6["ds-bonn"]["caveats"]:
    print(f"                    - {c}")
print()
print(L6.BONN_POLICY)
print()
print("The arithmetic of the mistake, with no data required:")
for n_people, n_seg in ((10, 500), (5, 100)):
    se_seg = 1 / np.sqrt(n_seg)
    se_ppl = 1 / np.sqrt(n_people)
    print(f"   {n_seg} segments from {n_people} people: a standard error computed on the segments is "
          f"{se_seg:.3f} sigma, on the people {se_ppl:.3f} sigma -- too small by a factor of "
          f"{se_ppl / se_seg:.1f} if the within-person correlation is perfect.")
print("   The truth lies between the two and depends on the intraclass correlation, which is why the fix is "
      "to model the grouping (nb-6-2-lmm) rather than to pick one of the two numbers.")
ds-bonn, from data/directory.yaml -- discussed, never derived from:
   population     : 10 individuals: 5 healthy volunteers (scalp), 5 epilepsy patients (intracranial)
   structure      : 500 artefact-free 23.6-s single-channel segments (4,097 samples each) in five 100-segment sets A-E (files Z/O/N/F/S)
   reference      : average common reference
   online_filters : 0.53-40 Hz band-pass; 12-bit
   license        : informal-academic
   license_note   : non-commercial / academic use with citation requested; no formal licence text (TODO(confirm), spec section 13 item 19).
   paper_doi      : 10.1103/PhysRevE.64.061907
   source         : https://www.upf.edu/web/ntsa/downloads
   caveats        :
                    - 500 segments from 10 people (pseudoreplication).
                    - Two lettering conventions (A-E vs Z/O/N/F/S) that some secondary sources swap.
                    - Scalp and intracranial sets differ by orders of magnitude in amplitude.

ds-bonn ships nothing and this module has no loader for it.  data/directory.yaml records its licence as `informal-academic` -- non-commercial/academic use with citation requested, no formal licence text, spec section 13 item 19 unanswered -- and data/scripts/build_dataset_pages.derive_snippets resolves that to `snippets: no`.  The dataset may be discussed and cited, which L6.3 does because 500 segments from 10 people is the clearest pseudoreplication example in the whole directory, but no number in this course is computed from it.

The arithmetic of the mistake, with no data required:
   500 segments from 10 people: a standard error computed on the segments is 0.045 sigma, on the people 0.316 sigma -- too small by a factor of 7.1 if the within-person correlation is perfect.
   100 segments from 5 people: a standard error computed on the segments is 0.100 sigma, on the people 0.447 sigma -- too small by a factor of 4.5 if the within-person correlation is perfect.
   The truth lies between the two and depends on the intraclass correlation, which is why the fix is to model the grouping (nb-6-2-lmm) rather than to pick one of the two numbers.
In [16]:
print("nb-6-3-power-sim -- L6.3 numbers (draft; TODO(confirm) at author review)")
print()
print(f"1. VARIANCE DECOMPOSITION -- ds-erpcore P3, {len(pilot)} subjects, mean amplitude 300-600 ms at Pz "
      f"under helpers_l6.PRESPECIFIED:")
print(f"     mu (group effect)                    {mu:+.4f} uV")
print(f"     tau (between-subject SD, true)       {tau:.4f} uV")
print(f"     sigma (trial-to-trial SD)            {sigma:.4f} uV")
print(f"     observed SD of subject scores        {sd_obs:.4f} uV; mean SME {pilot.sme_uv.mean():.4f} uV")
print(f"     dz observed {mu / sd_obs:.3f}; ceiling with infinite trials mu/tau = {mu / tau:.3f}")
print()
print(f"2. ex-6-3 POWER GRID ({N_SIMS} simulated studies per cell, seed {SEED}, 4 standards per target, "
      f"alpha = {ALPHA}) -- smallest subject count for {TARGET_POWER:.0%} power:")
for r in small:
    print(f"     {r['n_trials_rare']:3d} target trials -> "
          f"{str(r['n_subjects']) if r['reached'] else '> ' + str(SUBJECT_COUNTS[-1])} subjects "
          f"(power {r['power']:.3f})")
print()
print(f"3. ds-iowapd PILOT ({len(a)} PD, {len(b)} Control, first-in-file-order rule), "
      f"{L6.RESTING_MEASURE['name']}:")
print(f"     PD {a.mean():.4f} +- {a.std(ddof=1):.4f}; Control {b.mean():.4f} +- {b.std(ddof=1):.4f}")
print(f"     Hedges' g {g['g']:+.4f} [{g['ci'][0]:+.4f}, {g['ci'][1]:+.4f}]; Welch p = {p_ind:.4f}")
print(f"     required N per group for {TARGET_POWER:.0%} power: {n_point if n_point else '> 4000'} from the "
      f"point estimate; {n_large if n_large else '> 4000'} (optimistic CI end) to "
      f"{n_small if n_small else '> 4000'} (pessimistic CI end)")
print(f"     bootstrap over {N_BOOT} resampled pilots: median required N {np.median(finite):.0f}, "
      f"2.5-97.5 % [{np.percentile(finite, 2.5):.0f}, {np.percentile(finite, 97.5):.0f}], "
      f"sign flip in {100 * np.mean(np.sign(boot_g) != np.sign(g['g'])):.1f} % of resamples")
print()
print(f"4. ds-dortmund SUBSAMPLING (pool of {len(dok)}, median-age split at {cut:.0f} y, "
      f"{N_PILOTS} independent pilots per size):")
print(f"     pool: g = {g_pool['g']:+.4f} [{g_pool['ci'][0]:+.4f}, {g_pool['ci'][1]:+.4f}], "
      f"required N {n_pool if n_pool else '> 4000'} per group")
for r in sub_rows:
    print(f"     pilot n = {r['n_per_group']:2d}/group: median g {r['g_median']:+.3f} "
          f"[{r['g_lo']:+.3f}, {r['g_hi']:+.3f}], sign flip {r['sign_flip']:.1%}, "
          f"median required N {r['n_median']:.0f} [{r['n_lo']:.0f}, {r['n_hi']:.0f}], "
          f"{r['n_unbounded']} of {r['n_pilots']} gave no bounded answer")
print()
print(f"5. ds-bonn: discussed, never derived from (licence informal-academic, snippets: no).  500 segments "
      f"from 10 people.")
print()
print("TODO(confirm): the resting measure here is a course decision (helpers_l6.RESTING_MEASURE), not a "
      "value taken from either paper.  Neither the ds-iowapd nor the ds-dortmund result should be read as a "
      "replication of anything those papers report -- the catalog carries no published numbers to compare "
      "with, and the pipelines are not theirs.")
print()
print("Pitfall: pf-site-device-confound.  No widget for this lesson.")
nb-6-3-power-sim -- L6.3 numbers (draft; TODO(confirm) at author review)

1. VARIANCE DECOMPOSITION -- ds-erpcore P3, 19 subjects, mean amplitude 300-600 ms at Pz under helpers_l6.PRESPECIFIED:
     mu (group effect)                    +2.7366 uV
     tau (between-subject SD, true)       1.9898 uV
     sigma (trial-to-trial SD)            6.3512 uV
     observed SD of subject scores        2.3589 uV; mean SME 1.1623 uV
     dz observed 1.160; ceiling with infinite trials mu/tau = 1.375

2. ex-6-3 POWER GRID (2000 simulated studies per cell, seed 20260918, 4 standards per target, alpha = 0.05) -- smallest subject count for 80% power:
       5 target trials -> 20 subjects (power 0.889)
      10 target trials -> 12 subjects (power 0.819)
      15 target trials -> 10 subjects (power 0.809)
      20 target trials -> 10 subjects (power 0.858)
      30 target trials -> 8 subjects (power 0.802)
      40 target trials -> 8 subjects (power 0.822)
      60 target trials -> 8 subjects (power 0.859)
     100 target trials -> 8 subjects (power 0.894)

3. ds-iowapd PILOT (10 PD, 10 Control, first-in-file-order rule), relative beta power:
     PD 0.1698 +- 0.0875; Control 0.2726 +- 0.0821
     Hedges' g -1.1608 [-2.1083, -0.2133]; Welch p = 0.0144
     required N per group for 80% power: 13 from the point estimate; 5 (optimistic CI end) to 347 (pessimistic CI end)
     bootstrap over 400 resampled pilots: median required N 11, 2.5-97.5 % [4, 97], sign flip in 1.0 % of resamples

4. ds-dortmund SUBSAMPLING (pool of 36, median-age split at 43 y, 200 independent pilots per size):
     pool: g = +0.8719 [+0.1882, +1.5556], required N 22 per group
     pilot n =  4/group: median g +0.734 [-0.359, +2.779], sign flip 8.5%, median required N 25 [4, 674], 7 of 200 gave no bounded answer
     pilot n =  8/group: median g +0.848 [+0.172, +1.717], sign flip 0.5%, median required N 23 [7, 540], 0 of 200 gave no bounded answer
     pilot n = 12/group: median g +0.889 [+0.496, +1.422], sign flip 0.0%, median required N 21 [10, 66], 0 of 200 gave no bounded answer

5. ds-bonn: discussed, never derived from (licence informal-academic, snippets: no).  500 segments from 10 people.

TODO(confirm): the resting measure here is a course decision (helpers_l6.RESTING_MEASURE), not a value taken from either paper.  Neither the ds-iowapd nor the ds-dortmund result should be read as a replication of anything those papers report -- the catalog carries no published numbers to compare with, and the pipelines are not theirs.

Pitfall: pf-site-device-confound.  No widget for this lesson.