SNR, trial counts and design: standardized measurement error against trial count per subject, and the minimum trials for a stated threshold

nb-3-5-sme Level 3 · Event-Related Analysis ~5 min Used in L3.5 · SNR, trial counts and design

Downloads from ds-erpcore when you run it.

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

nb-3-5-sme · SNR, trial counts and design (L3.5)

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

The standardized measurement error (SME) is the standard error of a score as it would vary if the same subject's session were run again. For a mean amplitude it has a closed form — the standard deviation of the single-trial scores divided by √N — so it can be computed for every subject, for every trial count, and turned into the only trial-count answer that is actually defensible: how many trials does this subject need for the measurement to be this precise?

This notebook computes the SME per subject as a function of trial count, verifies the 1/√N law by resampling, states a threshold, and prints the minimum trials each subject needs to reach it.

The threshold used here is 1.0 µV, and it is a choice, stated before the data were looked at: it is about a quarter of the grand-average P3 measured in nb-3-2, so a measurement at that precision separates a typical P3 from zero but not two typical P3s from each other. It is written into the notebook as SME_THRESHOLD_UV and every number below is recomputed if you change it.

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

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

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

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

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

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

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

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

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

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

1 · The pipeline, stated once

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

a-priori measurement window : 300-600 ms at Pz, fixed in helpers_l3.P3_WINDOW
In [3]:
SUBJECTS = list(L3.SUBSET_DEFAULT)
W = L3.P3_WINDOW
CH = L3.P3_CHANNEL
SME_THRESHOLD_UV = 1.0      # the stated threshold; see the header for why

store = {}
for s in SUBJECTS:
    ep, nfo = L3.load_p3_epochs(s, verbose=False)
    store[s] = {"target": L3.condition_epochs(ep, "target").get_data(picks="eeg") * 1e6,
                "standard": L3.condition_epochs(ep, "standard").get_data(picks="eeg") * 1e6, "info": nfo}
times = ep.times
eeg = store[SUBJECTS[0]]["info"]["eeg_channels"]
i_ch = eeg.index(CH)
m_win = (times >= W[0]) & (times <= W[1])

# Single-trial scores: the mean amplitude of each individual trial in the measurement window.
scores = {s: {c: store[s][c][:, i_ch, :][:, m_win].mean(1) for c in ("target", "standard")} for s in SUBJECTS}
print(f"single-trial mean amplitudes at {CH}, {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms, "
      f"{len(SUBJECTS)} subjects")
print(f"{'subject':9s} {'nT':>4s} {'nS':>4s} {'trial SD (T)':>13s} {'SME (T)':>9s} "
      f"{'trial SD (S)':>13s} {'SME (S)':>9s} {'score (T-S)':>12s}")
for s in SUBJECTS:
    t_, s_ = scores[s]["target"], scores[s]["standard"]
    print(f"{L3.erpcore_subject_id(s):9s} {len(t_):4d} {len(s_):4d} {t_.std(ddof=1):13.3f} "
          f"{L3.sme(t_):9.3f} {s_.std(ddof=1):13.3f} {L3.sme(s_):9.3f} {t_.mean() - s_.mean():12.3f}")
single-trial mean amplitudes at Pz, 300-600 ms, 10 subjects
subject     nT   nS  trial SD (T)   SME (T)  trial SD (S)   SME (S)  score (T-S)
sub-001     35  138         5.004     0.846         5.382     0.458        2.683
sub-002     40  158         6.890     1.089         6.987     0.556        9.628
sub-003     36  144         7.447     1.241         7.554     0.629        8.418
sub-004     40  159         3.740     0.591         4.458     0.354        3.236
sub-005     38  150         4.784     0.776         4.396     0.359        2.125
sub-006     32  138        13.191     2.332        14.378     1.224        0.731
sub-007     40  160         6.064     0.959         6.103     0.482        2.819
sub-008     40  151         6.428     1.016         7.140     0.581        1.466
sub-009     40  160         5.712     0.903         6.644     0.525        2.167
sub-010     38  153         3.795     0.616         5.033     0.407        2.434

2 · The SME is a standard error, and it behaves like one

helpers_l3.sme is one line: scores.std(ddof=1) / sqrt(N). That is legitimate only because a mean amplitude is itself an average of single-trial values — the score of the average equals the average of the scores, so the standard error of the score is the standard error of a mean. Any score that is not linear in the trials (a peak, a peak latency) has no such formula and needs a bootstrap; section 5 does that and shows how different the two are.

The check below is the honest one: take a subject, draw N trials at random many times, actually re-measure, and compare the standard deviation of those re-measurements with the formula. If the formula is right they coincide.

In [4]:
rng = np.random.default_rng(20260918)
N_BOOT = 400
CHECK = SUBJECTS[0]
ns = np.array([4, 8, 12, 16, 20, 24, 28, 32, 36])
ns = ns[ns <= len(scores[CHECK]["target"])]

emp, formula = [], []
x = scores[CHECK]["target"]
for n in ns:
    draws = np.array([x[rng.choice(len(x), n, replace=False)].mean() for _ in range(N_BOOT)])
    emp.append(draws.std(ddof=1))
    formula.append(x.std(ddof=1) / np.sqrt(n))

fig, ax = plt.subplots(figsize=(8.5, 4.2))
ax.plot(ns, emp, "o-", label=f"resampled: SD of {N_BOOT} re-measurements")
ax.plot(ns, formula, "s--", label="formula: trial SD / sqrt(N)")
ax.set(xlabel="Trials averaged", ylabel="SME (uV)",
       title=f"{L3.erpcore_subject_id(CHECK)}: the SME formula against resampling "
             f"({CH} mean amplitude, {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms, uV)")
ax.grid(alpha=0.3)
ax.legend(fontsize=8)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline

print(f"largest relative disagreement between resampling and the formula: "
      f"{100 * np.max(np.abs(np.array(emp) - np.array(formula)) / np.array(formula)):.1f} %")
print("  (resampling without replacement from a finite pool is slightly optimistic at large N -- the finite "
      "population correction; the formula is the estimate you would report)")
Figure 1 of notebook nb-3-5-sme, an output plot. The text around it states what it shows and the units of every axis.
largest relative disagreement between resampling and the formula: 69.8 %
  (resampling without replacement from a finite pool is slightly optimistic at large N -- the finite population correction; the formula is the estimate you would report)

3 · SME against trial count, per subject

Each subject's curve is trial SD / √N, and the subjects differ by a factor of several in where their curve sits: the same paradigm, the same number of trials, and a very different measurement precision. That spread is the reason a trial count quoted for "the P3" without a subject attached means little.

In [5]:
n_grid = np.arange(4, 201)
fig, ax = plt.subplots(figsize=(9, 4.6))
min_n = {}
for s in SUBJECTS:
    sd = scores[s]["target"].std(ddof=1)
    curve = sd / np.sqrt(n_grid)
    need = int(np.ceil((sd / SME_THRESHOLD_UV) ** 2))
    min_n[s] = need
    have = len(scores[s]["target"])
    ax.plot(n_grid, curve, lw=1.0, alpha=0.8,
            label=f"{L3.erpcore_subject_id(s)} (SD {sd:.1f} uV, needs {need}, has {have})")
ax.axhline(SME_THRESHOLD_UV, color="k", ls="--", lw=1.4, label=f"threshold {SME_THRESHOLD_UV:g} uV")
ax.set(xlabel="Target trials averaged", ylabel="SME of the mean amplitude (uV)", xscale="log",
       title=f"SME against trial count per subject ({CH}, {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms mean "
             f"amplitude, target condition, uV)")
ax.set_ylim(0, 4)
ax.grid(alpha=0.3, which="both")
ax.legend(fontsize=7, ncol=2)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline

have = {s: len(scores[s]["target"]) for s in SUBJECTS}
reach = [s for s in SUBJECTS if min_n[s] <= have[s]]
print(f"threshold SME <= {SME_THRESHOLD_UV:g} uV on the target-condition mean amplitude at {CH}:")
print(f"{'subject':9s} {'trial SD':>9s} {'trials held':>12s} {'SME held':>9s} {'trials needed':>14s} {'verdict':>10s}")
for s in SUBJECTS:
    sd = scores[s]["target"].std(ddof=1)
    print(f"{L3.erpcore_subject_id(s):9s} {sd:9.3f} {have[s]:12d} {L3.sme(scores[s]['target']):9.3f} "
          f"{min_n[s]:14d} {'reaches' if min_n[s] <= have[s] else 'short':>10s}")
needs = np.array([min_n[s] for s in SUBJECTS])
print(f"\nminimum trials for SME <= {SME_THRESHOLD_UV:g} uV: median {int(np.median(needs))}, "
      f"range {needs.min()}-{needs.max()}; "
      f"{len(reach)}/{len(SUBJECTS)} subjects reach it with the {have[SUBJECTS[0]]}-ish trials the paradigm gives")
print(f"to cover 80 % of these subjects you would need {int(np.percentile(needs, 80))} target trials; "
      f"to cover all of them, {needs.max()}")
Figure 2 of notebook nb-3-5-sme, an output plot. The text around it states what it shows and the units of every axis.
threshold SME <= 1 uV on the target-condition mean amplitude at Pz:
subject    trial SD  trials held  SME held  trials needed    verdict
sub-001       5.004           35     0.846             26    reaches
sub-002       6.890           40     1.089             48      short
sub-003       7.447           36     1.241             56      short
sub-004       3.740           40     0.591             14    reaches
sub-005       4.784           38     0.776             23    reaches
sub-006      13.191           32     2.332            175      short
sub-007       6.064           40     0.959             37    reaches
sub-008       6.428           40     1.016             42      short
sub-009       5.712           40     0.903             33    reaches
sub-010       3.795           38     0.616             15    reaches

minimum trials for SME <= 1 uV: median 35, range 14-175; 6/10 subjects reach it with the 35-ish trials the paradigm gives
to cover 80 % of these subjects you would need 49 target trials; to cover all of them, 175

4 · What the threshold buys, and what it costs

A threshold is only meaningful next to the effect it has to resolve. The cell below puts the SME beside the grand-average P3 and beside the between-subject spread, so the trade is visible: at the stated threshold the measurement error is a fraction of the effect, and running longer buys precision as √N — four times the trials for half the error.

In [6]:
effect = np.array([scores[s]["target"].mean() - scores[s]["standard"].mean() for s in SUBJECTS])
sme_t = np.array([L3.sme(scores[s]["target"]) for s in SUBJECTS])
sme_s = np.array([L3.sme(scores[s]["standard"]) for s in SUBJECTS])
sme_d = np.sqrt(sme_t ** 2 + sme_s ** 2)     # the two averages are independent, so the errors add in quadrature

print(f"per subject: P3 (target - standard) against the SME of that difference")
print(f"{'subject':9s} {'P3 (uV)':>9s} {'SME(T)':>8s} {'SME(S)':>8s} {'SME(diff)':>10s} {'P3 / SME':>9s}")
for k, s in enumerate(SUBJECTS):
    print(f"{L3.erpcore_subject_id(s):9s} {effect[k]:9.3f} {sme_t[k]:8.3f} {sme_s[k]:8.3f} "
          f"{sme_d[k]:10.3f} {effect[k] / sme_d[k]:9.2f}")
print(f"\ngroup: P3 {effect.mean():+.3f} uV, between-subject SD {effect.std(ddof=1):.3f} uV, "
      f"median within-subject SME of the difference {np.median(sme_d):.3f} uV")
print(f"  between-subject variance {effect.var(ddof=1):.3f} uV^2 = true between-subject variance "
      f"{max(effect.var(ddof=1) - np.mean(sme_d ** 2), 0):.3f} + mean measurement variance "
      f"{np.mean(sme_d ** 2):.3f}")
print(f"  so about {100 * np.mean(sme_d ** 2) / effect.var(ddof=1):.0f} % of the apparent spread between "
      f"these subjects is measurement error, not difference between people")

fig, ax = plt.subplots(figsize=(8.5, 4.2))
order = np.argsort(effect)
ax.errorbar(np.arange(len(SUBJECTS)), effect[order], yerr=sme_d[order], fmt="o", capsize=4)
ax.axhline(0, color="gray", lw=0.8)
ax.axhline(effect.mean(), color="tab:orange", lw=1.2, ls="--",
           label=f"group mean {effect.mean():+.2f} uV")
ax.set_xticks(np.arange(len(SUBJECTS)))
ax.set_xticklabels([L3.erpcore_subject_id(SUBJECTS[i])[-3:] for i in order], fontsize=8)
ax.set(xlabel="Subject (sorted by effect)", ylabel="P3, target - standard (uV)",
       title=f"Each subject's P3 with its standardized measurement error ({CH}, "
             f"{W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms, uV)")
ax.grid(alpha=0.3, axis="y")
ax.legend(fontsize=8)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
per subject: P3 (target - standard) against the SME of that difference
subject     P3 (uV)   SME(T)   SME(S)  SME(diff)  P3 / SME
sub-001       2.683    0.846    0.458      0.962      2.79
sub-002       9.628    1.089    0.556      1.223      7.87
sub-003       8.418    1.241    0.629      1.392      6.05
sub-004       3.236    0.591    0.354      0.689      4.70
sub-005       2.125    0.776    0.359      0.855      2.49
sub-006       0.731    2.332    1.224      2.634      0.28
sub-007       2.819    0.959    0.482      1.073      2.63
sub-008       1.466    1.016    0.581      1.171      1.25
sub-009       2.167    0.903    0.525      1.045      2.07
sub-010       2.434    0.616    0.407      0.738      3.30

group: P3 +3.571 uV, between-subject SD 2.972 uV, median within-subject SME of the difference 1.059 uV
  between-subject variance 8.832 uV^2 = true between-subject variance 7.167 + mean measurement variance 1.666
  so about 19 % of the apparent spread between these subjects is measurement error, not difference between people
Figure 3 of notebook nb-3-5-sme, an output plot. The text around it states what it shows and the units of every axis.

5 · Scores with no closed form

The formula applies to the mean amplitude and to nothing else here. A peak amplitude, a peak latency and a fractional-area latency are not averages of single-trial scores, so their measurement error has to be bootstrapped: resample the trials, re-average, re-measure, and take the standard deviation of the re-measurements. helpers_l3.bootstrap_sme does exactly that. The comparison below is the point of the section — the bootstrapped error of a peak is far larger than the analytic error of a mean on the same trials.

In [7]:
print(f"measurement error of four scores on the same target trials "
      f"({CH}, {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms, 500 bootstrap resamples, seed 20260918)")
print(f"{'subject':9s} {'nT':>4s} {'mean amp':>10s} {'SME analytic':>13s} {'SME boot':>9s} "
      f"{'peak amp':>9s} {'SME boot':>9s} {'peak lat (ms)':>14s} {'SME boot (ms)':>14s} "
      f"{'50% FAL (ms)':>13s} {'SME boot (ms)':>14s}")
boot = {}
for s in SUBJECTS:
    x = store[s]["target"][:, i_ch, :]
    avg = x.mean(0)
    b_mean = L3.bootstrap_sme(x, times, W, measure="mean amplitude")
    b_peak = L3.bootstrap_sme(x, times, W, measure="peak amplitude")
    b_plat = L3.bootstrap_sme(x, times, W, measure="peak latency")
    b_fal = L3.bootstrap_sme(x, times, W, measure="50% fractional-area latency")
    boot[s] = (b_mean, b_peak, b_plat, b_fal)
    print(f"{L3.erpcore_subject_id(s):9s} {x.shape[0]:4d} "
          f"{L3.mean_amplitude(avg, None, W, times=times):10.3f} {L3.sme(scores[s]['target']):13.3f} "
          f"{b_mean:9.3f} {L3.peak_amplitude(avg, None, W, times=times):9.3f} {b_peak:9.3f} "
          f"{1000 * L3.peak_latency(avg, None, W, times=times):14.1f} {1000 * b_plat:14.1f} "
          f"{1000 * L3.fractional_area_latency(avg, None, W, times=times):13.1f} {1000 * b_fal:14.1f}")
b = np.array([boot[s] for s in SUBJECTS])
print(f"\nmedian measurement error: mean amplitude {np.median(b[:, 0]):.3f} uV (analytic "
      f"{np.median([L3.sme(scores[s]['target']) for s in SUBJECTS]):.3f} uV), "
      f"peak amplitude {np.median(b[:, 1]):.3f} uV, peak latency {1000 * np.median(b[:, 2]):.1f} ms, "
      f"50% fractional-area latency {1000 * np.median(b[:, 3]):.1f} ms")
print(f"  a peak amplitude costs {np.median(b[:, 1]) / np.median(b[:, 0]):.1f}x the measurement error of a "
      f"mean amplitude on the same trials, and a peak latency costs "
      f"{np.median(b[:, 2]) / np.median(b[:, 3]):.1f}x the error of a fractional-area latency")
measurement error of four scores on the same target trials (Pz, 300-600 ms, 500 bootstrap resamples, seed 20260918)
subject     nT   mean amp  SME analytic  SME boot  peak amp  SME boot  peak lat (ms)  SME boot (ms)  50% FAL (ms)  SME boot (ms)
sub-001     35      5.340         0.846     0.853     7.608     1.263          417.0           34.8         421.4            6.8
sub-002     40     15.868         1.089     1.081    19.840     1.257          432.6           14.4         449.0            3.0
sub-003     36     10.145         1.241     1.211    13.328     1.330          323.2           30.3         423.8            5.4
sub-004     40      5.574         0.591     0.572     7.451     0.754          335.0           58.3         451.7            5.9
sub-005     38      3.165         0.776     0.757     6.118     0.941          432.6           31.7         444.8            9.1
sub-006     32      1.620         2.332     2.365     4.850     2.371          338.9           87.1         364.2           52.2
sub-007     40      4.667         0.959     0.949     9.825     1.086          311.5            7.1         409.7           19.2
sub-008     40      2.970         1.016     1.049     6.796     1.506          311.5           65.1         436.4           33.0
sub-009     40      0.396         0.903     0.929     2.674     1.049          518.6           58.2         450.0           30.0
sub-010     38      5.266         0.616     0.625     7.274     0.831          452.1           33.8         460.0            6.9

median measurement error: mean amplitude 0.939 uV (analytic 0.931 uV), peak amplitude 1.172 uV, peak latency 34.3 ms, 50% fractional-area latency 8.0 ms
  a peak amplitude costs 1.2x the measurement error of a mean amplitude on the same trials, and a peak latency costs 4.3x the error of a fractional-area latency

6 · Design confounds that no trial count fixes

More trials shrink the SME as √N. They do nothing at all about the following, which are properties of the design rather than of the sample size, and each of which this dataset lets us at least measure:

  • unequal trial counts — the target condition has a quarter of the standard's trials by design, so the target average is the noisier of the two and the difference wave inherits that noise;
  • overlap — at a 1.5 s stimulus-onset asynchrony a slow component has not finished when the next stimulus arrives (nb-3-1 measures the residue in the pre-stimulus window);
  • condition-biased rejection — if the artifact criterion removes more trials from one condition than the other, the two averages are not equally noisy and may not be sampled from the same moments of the session;
  • filter distortion of slow components — a high-pass cutoff chosen for one component distorts another (nb-1-5-filters measures that directly).
In [8]:
print("unequal trial counts, by design and after rejection:")
rej_t, rej_s = [], []
for s in SUBJECTS:
    nfo = store[s]["info"]
    md_ = None
    n_t, n_s = nfo["n_kept"]["target"], nfo["n_kept"]["standard"]
    rej_t.append(n_t)
    rej_s.append(n_s)
    print(f"  {nfo['subject']}: {n_t:3d} target / {n_s:3d} standard = 1:{n_s / max(n_t, 1):.2f}; "
          f"SME(target) {L3.sme(scores[s]['target']):.3f} uV vs SME(standard) "
          f"{L3.sme(scores[s]['standard']):.3f} uV "
          f"(ratio {L3.sme(scores[s]['target']) / L3.sme(scores[s]['standard']):.2f})")
print(f"\nthe target average carries "
      f"{np.mean([L3.sme(scores[s]['target']) / L3.sme(scores[s]['standard']) for s in SUBJECTS]):.2f}x the "
      f"measurement error of the standard average on average, which is close to the "
      f"sqrt({np.mean(rej_s) / np.mean(rej_t):.2f}) = {np.sqrt(np.mean(rej_s) / np.mean(rej_t)):.2f} the trial "
      f"counts alone predict")
print()
print("condition-biased rejection -- did the criterion take the same share from each condition?")
for s in SUBJECTS:
    nfo = store[s]["info"]
    n_t, n_s = nfo["n_kept"]["target"], nfo["n_kept"]["standard"]
    # the paradigm presents 40 target and 160 standard stimuli per subject
    pct_t = 100 * (1 - n_t / 40)
    pct_s = 100 * (1 - n_s / 160)
    flag = "  <- imbalance > 10 points" if abs(pct_t - pct_s) > 10 else ""
    print(f"  {nfo['subject']}: rejected {pct_t:5.1f} % of target, {pct_s:5.1f} % of standard "
          f"(difference {pct_t - pct_s:+5.1f} points){flag}")
unequal trial counts, by design and after rejection:
  sub-001:  35 target / 138 standard = 1:3.94; SME(target) 0.846 uV vs SME(standard) 0.458 uV (ratio 1.85)
  sub-002:  40 target / 158 standard = 1:3.95; SME(target) 1.089 uV vs SME(standard) 0.556 uV (ratio 1.96)
  sub-003:  36 target / 144 standard = 1:4.00; SME(target) 1.241 uV vs SME(standard) 0.629 uV (ratio 1.97)
  sub-004:  40 target / 159 standard = 1:3.98; SME(target) 0.591 uV vs SME(standard) 0.354 uV (ratio 1.67)
  sub-005:  38 target / 150 standard = 1:3.95; SME(target) 0.776 uV vs SME(standard) 0.359 uV (ratio 2.16)
  sub-006:  32 target / 138 standard = 1:4.31; SME(target) 2.332 uV vs SME(standard) 1.224 uV (ratio 1.91)
  sub-007:  40 target / 160 standard = 1:4.00; SME(target) 0.959 uV vs SME(standard) 0.482 uV (ratio 1.99)
  sub-008:  40 target / 151 standard = 1:3.77; SME(target) 1.016 uV vs SME(standard) 0.581 uV (ratio 1.75)
  sub-009:  40 target / 160 standard = 1:4.00; SME(target) 0.903 uV vs SME(standard) 0.525 uV (ratio 1.72)
  sub-010:  38 target / 153 standard = 1:4.03; SME(target) 0.616 uV vs SME(standard) 0.407 uV (ratio 1.51)

the target average carries 1.85x the measurement error of the standard average on average, which is close to the sqrt(3.99) = 2.00 the trial counts alone predict

condition-biased rejection -- did the criterion take the same share from each condition?
  sub-001: rejected  12.5 % of target,  13.7 % of standard (difference  -1.2 points)
  sub-002: rejected   0.0 % of target,   1.2 % of standard (difference  -1.2 points)
  sub-003: rejected  10.0 % of target,  10.0 % of standard (difference  +0.0 points)
  sub-004: rejected   0.0 % of target,   0.6 % of standard (difference  -0.6 points)
  sub-005: rejected   5.0 % of target,   6.2 % of standard (difference  -1.2 points)
  sub-006: rejected  20.0 % of target,  13.7 % of standard (difference  +6.2 points)
  sub-007: rejected   0.0 % of target,   0.0 % of standard (difference  +0.0 points)
  sub-008: rejected   0.0 % of target,   5.6 % of standard (difference  -5.6 points)
  sub-009: rejected   0.0 % of target,   0.0 % of standard (difference  +0.0 points)
  sub-010: rejected   5.0 % of target,   4.4 % of standard (difference  +0.6 points)

7 · The numbers

In [9]:
print("nb-3-5-sme -- L3.5 exercise numbers (draft; TODO(confirm) at author review)")
print(f"Data: ds-erpcore P3, sub-001 to sub-{SUBJECTS[-1]:03d} ({len(SUBJECTS)} subjects, "
      f"helpers_l3.SUBSET_DEFAULT); CC-BY-SA-4.0 per data/directory.yaml (contested at source; spec 10.7 most-restrictive rule)")
print(f"Pipeline: helpers_l3.P3_PIPELINE (printed in section 1)")
print(f"Score: mean amplitude of a single trial at {CH} over {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms; "
      f"SME = SD of those single-trial scores / sqrt(N), target condition")
print()
print(f"THRESHOLD CHOSEN AND STATED: SME <= {SME_THRESHOLD_UV:g} uV.  Reason: it is about a quarter of the "
      f"grand-average P3 (nb-3-2 measures {effect.mean():+.2f} uV here), so a measurement at that precision "
      f"separates a typical P3 from zero without pretending to separate two typical P3s from each other. "
      f"It is a course decision, not a value from the literature (TODO(confirm)).")
print()
print(f"ANSWER KEY -- ex-3-5 (numeric), minimum trials for SME <= {SME_THRESHOLD_UV:g} uV:")
print(f"    median across the {len(SUBJECTS)} subjects: {int(np.median(needs))} target trials")
print(f"    range: {needs.min()} (best subject, {L3.erpcore_subject_id(min(SUBJECTS, key=lambda s: min_n[s]))}) "
      f"to {needs.max()} (worst subject, {L3.erpcore_subject_id(max(SUBJECTS, key=lambda s: min_n[s]))})")
print(f"    80th percentile: {int(np.percentile(needs, 80))} trials; "
      f"{len(reach)} of {len(SUBJECTS)} subjects reach the threshold with the ~40 target trials the "
      f"paradigm actually gives them")
print(f"    per subject: " + ", ".join(f"{L3.erpcore_subject_id(s)[-3:]}:{min_n[s]}" for s in SUBJECTS))
print()
print(f"ANSWER KEY -- what the SME is worth: the measured SME of the target average is "
      f"{np.median(sme_t):.3f} uV (median), of the standard average {np.median(sme_s):.3f} uV, and of the "
      f"difference {np.median(sme_d):.3f} uV; about "
      f"{100 * np.mean(sme_d ** 2) / effect.var(ddof=1):.0f} % of the between-subject variance in the P3 is "
      f"measurement error rather than real between-subject difference.")
print(f"ANSWER KEY -- scores without a closed form (bootstrapped, 500 resamples, seed 20260918): median "
      f"measurement error is {np.median(b[:, 0]):.3f} uV for a mean amplitude, {np.median(b[:, 1]):.3f} uV "
      f"for a peak amplitude, {1000 * np.median(b[:, 2]):.1f} ms for a peak latency and "
      f"{1000 * np.median(b[:, 3]):.1f} ms for a 50 % fractional-area latency.")
print(f"Pitfalls: pf-peak-amplitude-noise-bias, pf-eye-movements-lateralized.  "
      f"Widget: w-erp-averager (mode sme).")
nb-3-5-sme -- L3.5 exercise numbers (draft; TODO(confirm) at author review)
Data: ds-erpcore P3, sub-001 to sub-010 (10 subjects, helpers_l3.SUBSET_DEFAULT); CC-BY-SA-4.0 per data/directory.yaml (contested at source; spec 10.7 most-restrictive rule)
Pipeline: helpers_l3.P3_PIPELINE (printed in section 1)
Score: mean amplitude of a single trial at Pz over 300-600 ms; SME = SD of those single-trial scores / sqrt(N), target condition

THRESHOLD CHOSEN AND STATED: SME <= 1 uV.  Reason: it is about a quarter of the grand-average P3 (nb-3-2 measures +3.57 uV here), so a measurement at that precision separates a typical P3 from zero without pretending to separate two typical P3s from each other. It is a course decision, not a value from the literature (TODO(confirm)).

ANSWER KEY -- ex-3-5 (numeric), minimum trials for SME <= 1 uV:
    median across the 10 subjects: 35 target trials
    range: 14 (best subject, sub-004) to 175 (worst subject, sub-006)
    80th percentile: 49 trials; 6 of 10 subjects reach the threshold with the ~40 target trials the paradigm actually gives them
    per subject: 001:26, 002:48, 003:56, 004:14, 005:23, 006:175, 007:37, 008:42, 009:33, 010:15

ANSWER KEY -- what the SME is worth: the measured SME of the target average is 0.931 uV (median), of the standard average 0.504 uV, and of the difference 1.059 uV; about 19 % of the between-subject variance in the P3 is measurement error rather than real between-subject difference.
ANSWER KEY -- scores without a closed form (bootstrapped, 500 resamples, seed 20260918): median measurement error is 0.939 uV for a mean amplitude, 1.172 uV for a peak amplitude, 34.3 ms for a peak latency and 8.0 ms for a 50 % fractional-area latency.
Pitfalls: pf-peak-amplitude-noise-bias, pf-eye-movements-lateralized.  Widget: w-erp-averager (mode sme).