The ERP and its components: the grand-average P3, target versus standard, over ten ERP CORE subjects, and the same component on 16 dry electrodes

nb-3-2-erp-core-p3 Level 3 · Event-Related Analysis ~5 min Used in L3.2 · The ERP and its components

Downloads from ds-erpcore, ds-brain-invaders 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-2-erp-core-p3 · The ERP and its components (L3.2)

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

Part A — the grand-average P3. Ten ERP CORE subjects, target versus standard, averaged within subject and then across subjects. Averaging is the whole trick: the signal is the same on every trial and adds linearly, the noise is not and adds as the square root, so the signal-to-noise ratio of an average of N trials grows as √N. This notebook shows that curve on real trials, then prints the grand-average peak and mean amplitudes and the trial counts per condition.

Part B — the same component on 16 dry electrodes. ds-brain-invaders (bi2014a) subject 1: a P300 from a different laboratory, different electrodes, a different trial imbalance (about 1 target to 5 non-targets) and a very different signal-to-noise ratio. Same component, different data — which is the point.

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.

Second dataset. ds-brain-invaders — Brain Invaders bi2014a, Korczowski et al. 2019 (Zenodo DOI 10.5281/zenodo.3266223; CC BY 4.0). From the catalog: 16 active dry electrodes at 10-10 positions, 512 Hz, right-earlobe reference, no online digital filter, 50 Hz mains, about 198 target and 990 non-target one-second trials per subject. Subject 1 only, loaded through helpers.load_spine("ds-brain-invaders", 1).

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

2 · Ten subjects, one average at a time

The documented subset is the first ten subjects of the forty the dataset ships (helpers_l3.SUBSET_DEFAULT). No subject is chosen by its result, and every subject that loads is kept — including the one with a bad channel and the ones that lose trials to the rejection criterion, because dropping those would be a decision about the answer.

In [3]:
SUBJECTS = list(L3.SUBSET_DEFAULT)
W = L3.P3_WINDOW
CH = L3.P3_CHANNEL

store, rows = {}, []
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}
    rows.append((nfo["subject"], nfo["n_kept"]["target"], nfo["n_kept"]["standard"], nfo["n_rejected"],
                 nfo["bad_channels"], nfo["ica_excluded"],
                 nfo["mean_rt_ms"]["target"], nfo["mean_rt_ms"]["standard"], nfo["accuracy"]))

times = ep.times
eeg = store[SUBJECTS[0]]["info"]["eeg_channels"]
i_ch = eeg.index(CH)

print(f"{'subject':9s} {'target':>7s} {'standard':>9s} {'rejected':>9s}  {'bads':12s} {'ICA':8s} "
      f"{'RT tgt':>7s} {'RT std':>7s} {'acc':>6s}")
for r in rows:
    print(f"{r[0]:9s} {r[1]:7d} {r[2]:9d} {r[3]:9d}  {str(r[4]):12s} {str(r[5]):8s} "
          f"{r[6]:7.0f} {r[7]:7.0f} {100 * r[8]:5.1f}%")
n_t = sum(r[1] for r in rows)
n_s = sum(r[2] for r in rows)
print(f"{'TOTAL':9s} {n_t:7d} {n_s:9d} {sum(r[3] for r in rows):9d}   "
      f"({n_t / len(SUBJECTS):.1f} and {n_s / len(SUBJECTS):.1f} per subject; ratio 1:{n_s / n_t:.1f})")
subject    target  standard  rejected  bads         ICA       RT tgt  RT std    acc
sub-001        35       138        27  []           [0]          569     457  97.5%
sub-002        40       158         2  []           [5]          538     441  99.0%
sub-003        36       144        20  []           [0, 2]       460     464  92.0%
sub-004        40       159         1  []           [0, 4]       320     294  95.0%
sub-005        38       150        12  []           [0, 1]       627     623  98.0%
sub-006        32       138        30  []           [0, 3]       271     260  77.3%
sub-007        40       160         0  []           [0, 1]       359     332  97.0%
sub-008        40       151         9  []           [0, 4]       395     378  88.5%
sub-009        40       160         0  ['P10', 'PO8'] [0, 2]       514     507  92.5%
sub-010        38       153         9  []           [0, 3]       284     289  81.0%
TOTAL         379      1511       110   (37.9 and 151.1 per subject; ratio 1:4.0)

3 · The grand average

Two averaging steps, and the order matters. Within a subject, trials are averaged to an ERP. Across subjects, those ERPs are averaged unweighted — one subject, one vote — so that a subject who kept more trials does not count for more. (Weighting by trial count is defensible too; it answers a different question. Say which you did.)

In [4]:
per_subject = {c: np.stack([store[s][c].mean(0) for s in SUBJECTS]) for c in ("target", "standard")}
grand = {c: per_subject[c].mean(0) for c in ("target", "standard")}
grand["difference"] = grand["target"] - grand["standard"]

fig, axes = plt.subplots(1, 2, figsize=(13, 4.4))
L3.plot_erp({f"target (n = {n_t} trials, {len(SUBJECTS)} subjects)": grand["target"][i_ch],
             f"standard (n = {n_s} trials, {len(SUBJECTS)} subjects)": grand["standard"][i_ch],
             "difference (target - standard)": (grand["difference"][i_ch], {"color": "k", "lw": 1.8})},
            times, window=W, ax=axes[0],
            title=f"Grand-average P3 at {CH}, {len(SUBJECTS)} ERP CORE subjects (uV, positive up)")
for s in SUBJECTS:
    axes[1].plot(times * 1000, (store[s]["target"].mean(0) - store[s]["standard"].mean(0))[i_ch],
                 lw=0.8, alpha=0.65)
axes[1].plot(times * 1000, grand["difference"][i_ch], color="k", lw=2.2, label="grand average")
axes[1].axvspan(W[0] * 1000, W[1] * 1000, color="tab:orange", alpha=0.18, lw=0)
axes[1].axhline(0, color="gray", lw=0.6)
axes[1].axvline(0, color="gray", lw=0.6)
axes[1].set(xlabel="Time from stimulus (ms)", ylabel="Amplitude (uV)",
            title=f"Every subject's difference wave at {CH} (uV) -- the spread is the story")
axes[1].grid(alpha=0.3)
axes[1].legend(fontsize=8)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
Figure 1 of notebook nb-3-2-erp-core-p3, an output plot. The text around it states what it shows and the units of every axis.
In [5]:
print(f"Grand average over {len(SUBJECTS)} subjects at {CH}, window {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms:")
for name in ("target", "standard", "difference"):
    g = grand[name][i_ch]
    print(f"  {name:11s} mean amplitude {L3.mean_amplitude(g, None, W, times=times):+7.3f} uV | "
          f"peak amplitude {L3.peak_amplitude(g, None, W, times=times):+7.3f} uV at "
          f"{1000 * L3.peak_latency(g, None, W, times=times):6.1f} ms | "
          f"50% area latency {1000 * L3.fractional_area_latency(g, None, W, times=times):6.1f} ms")
print()
win_mean = np.array([L3.mean_amplitude(grand["difference"][i], None, W, times=times) for i in range(len(eeg))])
order = np.argsort(-win_mean)
print("largest window-mean difference by channel (uV): "
      + ", ".join(f"{eeg[i]} {win_mean[i]:+.2f}" for i in order[:5]))
print("most negative: " + ", ".join(f"{eeg[i]} {win_mean[i]:+.2f}" for i in order[-3:]))
print(f"  -- the negative sites are the other half of an average reference: the 30 channel values at any "
      f"time point sum to zero by construction, so a centro-parietal positivity forces a negativity elsewhere "
      f"(L2.3, nb-3-4).")
Grand average over 10 subjects at Pz, window 300-600 ms:
  target      mean amplitude  +5.501 uV | peak amplitude  +6.423 uV at  338.9 ms | 50% area latency  439.1 ms
  standard    mean amplitude  +1.930 uV | peak amplitude  +3.551 uV at  323.2 ms | 50% area latency  409.4 ms
  difference  mean amplitude  +3.571 uV | peak amplitude  +4.292 uV at  491.2 ms | 50% area latency  455.7 ms

largest window-mean difference by channel (uV): Pz +3.57, CPz +3.38, P4 +2.35, Cz +2.20, P3 +1.56
most negative: Fp2 -2.58, P9 -2.90, P10 -3.37
  -- the negative sites are the other half of an average reference: the 30 channel values at any time point sum to zero by construction, so a centro-parietal positivity forces a negativity elsewhere (L2.3, nb-3-4).

4 · Why we average: SNR grows as √N

Add trials one at a time and watch the ERP come out of the noise. The measure below is deliberately blunt: the P3's mean amplitude in the a-priori window divided by the standard deviation of the same average over the pre-stimulus baseline, which is an estimate of what is left of the noise after averaging N trials. Repeating with many random trial orders (seeded) gives the curve rather than one noisy realisation.

The dashed line is √N scaled to pass through the last point. It is not fitted to the data — it is the prediction.

In [6]:
rng = np.random.default_rng(20260918)
N_ORDERS = 40
m_win = (times >= W[0]) & (times <= W[1])
m_pre = (times >= -0.2) & (times < 0.0)
counts = np.arange(2, min(store[s]["target"].shape[0] for s in SUBJECTS) + 1)

snr = np.zeros((len(SUBJECTS), len(counts)))
for k, s in enumerate(SUBJECTS):
    x = store[s]["target"][:, i_ch, :]
    for _ in range(N_ORDERS):
        idx = rng.permutation(x.shape[0])
        run = np.cumsum(x[idx], axis=0) / np.arange(1, x.shape[0] + 1)[:, None]
        sel = run[counts - 1]
        snr[k] += np.abs(sel[:, m_win].mean(1)) / sel[:, m_pre].std(axis=1, ddof=1)
    snr[k] /= N_ORDERS

mean_snr = snr.mean(0)
fig, ax = plt.subplots(figsize=(8.5, 4.2))
for k, s in enumerate(SUBJECTS):
    ax.plot(counts, snr[k], lw=0.8, alpha=0.5)
ax.plot(counts, mean_snr, "k", lw=2.2, label=f"mean over {len(SUBJECTS)} subjects")
ax.plot(counts, mean_snr[-1] * np.sqrt(counts / counts[-1]), "k--", lw=1.2,
        label="sqrt(N), scaled to the last point")
ax.set(xlabel="Trials averaged (target condition)",
       ylabel="SNR (|window mean| / pre-stimulus SD of the average)",
       title=f"Averaging: SNR of the {CH} P3 against trial count "
             f"({N_ORDERS} random orders per subject, seed 20260918)")
ax.grid(alpha=0.3)
ax.legend(fontsize=8)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline

for n in (4, 8, 16, 32):
    if n <= counts[-1]:
        j = int(np.argmin(np.abs(counts - n)))
        print(f"  N = {n:3d} trials: mean SNR {mean_snr[j]:5.2f}"
              + (f"  (x{mean_snr[j] / mean_snr[0]:.2f} relative to N = {counts[0]}; "
                 f"sqrt law predicts x{np.sqrt(n / counts[0]):.2f})"))
Figure 2 of notebook nb-3-2-erp-core-p3, an output plot. The text around it states what it shows and the units of every axis.
  N =   4 trials: mean SNR  2.37  (x1.20 relative to N = 2; sqrt law predicts x1.41)
  N =   8 trials: mean SNR  3.28  (x1.66 relative to N = 2; sqrt law predicts x2.00)
  N =  16 trials: mean SNR  4.25  (x2.16 relative to N = 2; sqrt law predicts x2.83)
  N =  32 trials: mean SNR  5.74  (x2.91 relative to N = 2; sqrt law predicts x4.00)

5 · Component versus peak

The waveform at Pz is not "the P3". It is the sum of everything active at that moment, projected onto that electrode through the head. Three things follow, and all three are visible above:

  • the observed peak of the difference wave and the observed peak of the target wave are at different times (printed in section 3) — the difference wave removes what the two conditions share, which moves the peak;
  • the peak latency differs between subjects by more than the grand-average peak width, so the grand average is broader and lower than any individual subject's component (latency jitter, quantified in nb-3-3 and nb-3-6);
  • the sign at a given electrode depends on the reference (nb-3-4), so "positive-going at Pz" is a statement about a montage, not only about the brain.

The canonical component names of L3.2 (P1, N1, P2, N2, P3, N170, MMN, N2pc, N400, LRP, ERN/Pe) and the ERP CORE paradigm that elicits each are catalogued in the lesson, not here: TODO(confirm) — the paradigm-to-component mapping this notebook can verify from data is only the one it measures, P3 from the active visual oddball, whose task description is read from the subject's own sidecar below.

In [7]:
facts = L3.DATASETS_L3["ds-erpcore"]
print("ds-erpcore, from data/directory.yaml:")
for key in ("name", "citation", "device", "sfreq", "n_channels", "n_subjects", "reference", "mains_hz",
            "online_filters", "license", "access"):
    print(f"  {key:16s}: {facts[key]}")
print()
print("paradigm P3, TaskDescription from the subject's own eeg.json:")
print("  " + facts["p3_task"])
print()
print("the seven ERP CORE paradigms (OSF component ids, CONTRACTS.md Phase 2 addendum): "
      + ", ".join(f"{k} {v}" for k, v in L3.ERPCORE_COMPONENTS.items()))
print("  TODO(confirm): which component each paradigm targets is stated in the lesson from the dataset's "
      "own documentation; this notebook downloads and measures only P3.")
ds-erpcore, from data/directory.yaml:
  name            : ERP CORE (Compendium of Open Resources and Experiments)
  citation        : Kappenman, E., Farrens, J., Zhang, W., Stewart, A. X., & Luck, S. J. (2020). ERP CORE: An Open Resource for Human Event-related Potential Research. PsyArXiv.
  device          : Biosemi ActiveTwo, 30 EEG + 3 EOG electrodes, 10-20 placement scheme
  sfreq           : 1024
  n_channels      : 30
  n_subjects      : 40
  reference       : CMS (Biosemi)
  mains_hz        : 60
  online_filters  : n/a (SoftwareFilters: n/a in the BIDS sidecar)
  license         : CC-BY-SA-4.0 (data/directory.yaml, corrected 2026-09-18).  The licence is CONTESTED at source and the site records the most restrictive reading per spec section 10.7; see ERPCORE_LICENCE_STATEMENTS.  Share-alike binds anything derived from these data.
  access          : open

paradigm P3, TaskDescription from the subject's own eeg.json:
  active visual oddball (TaskDescription in the subject's eeg.json): the letters A-E are presented in random order, p = .2 each; one letter is the target for a block, the other four are non-targets, so the target probability is .2 while the same physical stimulus is a target in some blocks and a non-target in others

the seven ERP CORE paradigms (OSF component ids, CONTRACTS.md Phase 2 addendum): N170 pfde9, MMN 5q4xs, N2pc yefrq, N400 29xpq, P3 etdkz, ERN q6gwp, LRP 28e6c
  TODO(confirm): which component each paradigm targets is stated in the lesson from the dataset's own documentation; this notebook downloads and measures only P3.

6 · Part B · The same component on dry electrodes

ds-brain-invaders bi2014a subject 1 is a P300 from a calibration-less BCI game: 16 dry electrodes, 512 Hz, right-earlobe reference, 50 Hz mains, and about one target flash for every five non-targets. The pipeline is deliberately not the ERP CORE one — there are no EOG channels to correct with, and the recording reference is kept as the catalog records it, as nb-1-5-filters did. What is kept identical is the part that decides the number: the same band-pass, the same epoch window, the same baseline, the same peak-to-peak criterion at the same threshold and the same measurement window.

In [8]:
import os

os.environ.setdefault("MOABB_DOWNLOAD_PROVIDER", "upstream")   # pin moabb to the dataset's Zenodo record
raw_bi = helpers.load_spine("ds-brain-invaders", 1)
print(raw_bi)
print("channels:", raw_bi.ch_names)
raw_bi.filter(L3.HP_HZ, L3.LP_HZ, picks="eeg", method="fir", fir_design="firwin", phase="zero", verbose=False)
events_bi = mne.find_events(raw_bi, stim_channel="STI 014", shortest_event=1, verbose=False)
ep_bi = mne.Epochs(raw_bi, events_bi, helpers.BI2014A_EVENT_ID, tmin=L3.EPOCH_TMIN, tmax=L3.EPOCH_TMAX,
                   baseline=L3.EPOCH_BASELINE, picks="eeg", preload=True, verbose=False)
d_bi = ep_bi.get_data() * 1e6
ptp_bi = (d_bi.max(2) - d_bi.min(2)).max(1)
keep_bi = ptp_bi <= L3.REJECT_PTP_UV
ep_bi = ep_bi[np.where(keep_bi)[0]]
ev_t, ev_n = ep_bi["Target"].average(), ep_bi["NonTarget"].average()
diff_bi = mne.combine_evoked([ev_t, ev_n], weights=[1, -1])

print(f"\nbi2014a subject 1, {raw_bi.info['sfreq']:.0f} Hz, recording reference kept (right earlobe, catalog)")
print(f"  {len(events_bi)} flashes; the same {L3.REJECT_PTP_UV:g} uV peak-to-peak criterion rejects "
      f"{int((~keep_bi).sum())} of {len(keep_bi)} epochs ({100 * (~keep_bi).mean():.0f} %) -- against "
      f"{100 * sum(r[3] for r in rows) / (len(SUBJECTS) * 200):.0f} % on the ERP CORE gel caps")
print(f"  kept: {ev_t.nave} Target, {ev_n.nave} NonTarget (ratio 1:{ev_n.nave / ev_t.nave:.1f})")
for chn in ("Pz", "P3", "P4", "Cz"):
    print(f"  {chn}: target {L3.mean_amplitude(ev_t, chn, W):+6.2f} | non-target "
          f"{L3.mean_amplitude(ev_n, chn, W):+6.2f} | T - N {L3.mean_amplitude(diff_bi, chn, W):+6.2f} uV, "
          f"peak {L3.peak_amplitude(diff_bi, chn, W):+6.2f} uV at "
          f"{1000 * L3.peak_latency(diff_bi, chn, W):.0f} ms")
<RawArray | 17 x 417925 (816.3 s), ~54.2 MiB, data loaded>
channels: ['Fp1', 'Fp2', 'F3', 'AFz', 'F4', 'T7', 'Cz', 'T8', 'P7', 'P3', 'Pz', 'P4', 'P8', 'O1', 'Oz', 'O2', 'STI 014']
bi2014a subject 1, 512 Hz, recording reference kept (right earlobe, catalog)
  1188 flashes; the same 150 uV peak-to-peak criterion rejects 679 of 1188 epochs (57 %) -- against 6 % on the ERP CORE gel caps
  kept: 84 Target, 425 NonTarget (ratio 1:5.1)
  Pz: target  +1.29 | non-target  -0.14 | T - N  +1.43 uV, peak  +8.68 uV at 375 ms
  P3: target  +2.31 | non-target  -0.46 | T - N  +2.77 uV, peak  +8.90 uV at 373 ms
  P4: target  +3.94 | non-target  -1.29 | T - N  +5.24 uV, peak +10.34 uV at 375 ms
  Cz: target  +3.66 | non-target  -1.33 | T - N  +4.99 uV, peak +12.34 uV at 375 ms
In [9]:
fig, axes = plt.subplots(1, 2, figsize=(13, 4.4), sharey=False)
L3.plot_erp({f"target (n = {n_t})": grand["target"][i_ch], f"standard (n = {n_s})": grand["standard"][i_ch],
             "difference": (grand["difference"][i_ch], {"color": "k", "lw": 1.8})}, times, window=W, ax=axes[0],
            title=f"ds-erpcore P3, {len(SUBJECTS)} subjects, {CH} (uV)")
i_bi = diff_bi.ch_names.index("Pz")
L3.plot_erp({f"Target (n = {ev_t.nave})": ev_t.data[i_bi] * 1e6,
             f"NonTarget (n = {ev_n.nave})": ev_n.data[i_bi] * 1e6,
             "difference": (diff_bi.data[i_bi] * 1e6, {"color": "k", "lw": 1.8})},
            diff_bi.times, window=W, ax=axes[1],
            title="ds-brain-invaders bi2014a subject 1, dry electrodes, Pz (uV)")
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline

pre_bi = (diff_bi.times >= -0.2) & (diff_bi.times < 0)
pre_ec = (times >= -0.2) & (times < 0)
noise_bi = float(diff_bi.data[i_bi, pre_bi].std(ddof=1) * 1e6)
noise_ec = float(grand["difference"][i_ch, pre_ec].std(ddof=1))
print(f"pre-stimulus SD of the difference average (a noise estimate after averaging):")
print(f"  ds-erpcore grand average, {CH}: {noise_ec:.3f} uV over {n_t} target trials and {len(SUBJECTS)} subjects")
print(f"  bi2014a subject 1, Pz:        {noise_bi:.3f} uV over {ev_t.nave} target trials, one subject")
Figure 3 of notebook nb-3-2-erp-core-p3, an output plot. The text around it states what it shows and the units of every axis.
pre-stimulus SD of the difference average (a noise estimate after averaging):
  ds-erpcore grand average, Pz: 0.311 uV over 379 target trials and 10 subjects
  bi2014a subject 1, Pz:        1.392 uV over 84 target trials, one subject

7 · The numbers

In [10]:
g = grand["difference"][i_ch]
print("nb-3-2-erp-core-p3 -- L3.2 numbers (draft; TODO(confirm) at author review)")
print(f"Data: ds-erpcore P3, sub-001 to sub-{SUBJECTS[-1]:03d} ({len(SUBJECTS)} subjects, the documented subset "
      f"helpers_l3.SUBSET_DEFAULT); CC-BY-SA-4.0 per data/directory.yaml (contested at source; spec 10.7 most-restrictive rule). Second dataset: ds-brain-invaders "
      f"bi2014a subject 1 (Zenodo DOI 10.5281/zenodo.3266223; CC BY 4.0).")
print(f"Pipeline: helpers_l3.P3_PIPELINE (printed in section 1).")
print(f"Measure: channel {CH}, a-priori window {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms.")
print()
print(f"  trials per condition (ds-erpcore, after rejection): target {n_t} total, "
      f"{n_t / len(SUBJECTS):.1f} per subject (range {min(r[1] for r in rows)}-{max(r[1] for r in rows)}); "
      f"standard {n_s} total, {n_s / len(SUBJECTS):.1f} per subject "
      f"(range {min(r[2] for r in rows)}-{max(r[2] for r in rows)})")
for name in ("target", "standard", "difference"):
    gg = grand[name][i_ch]
    print(f"  grand-average {name:11s}: mean amplitude {L3.mean_amplitude(gg, None, W, times=times):+.3f} uV, "
          f"peak amplitude {L3.peak_amplitude(gg, None, W, times=times):+.3f} uV at "
          f"{1000 * L3.peak_latency(gg, None, W, times=times):.1f} ms")
sub_amp = np.array([L3.mean_amplitude((store[s]["target"].mean(0) - store[s]["standard"].mean(0))[i_ch],
                                      None, W, times=times) for s in SUBJECTS])
print(f"  per-subject P3 (mean amplitude of the difference): mean {sub_amp.mean():+.3f} uV, "
      f"SD {sub_amp.std(ddof=1):.3f}, range {sub_amp.min():+.3f} to {sub_amp.max():+.3f}")
print()
print(f"ANSWER KEY -- SNR and trial count ({CH} P3, SNR = |window mean| / pre-stimulus SD of the same "
      f"average, {N_ORDERS} random trial orders per subject, seed 20260918, mean over "
      f"{len(SUBJECTS)} subjects):")
print(f"    measured mean SNR by trial count: "
      + ", ".join(f"N={nn} -> {mean_snr[int(np.argmin(np.abs(counts - nn)))]:.2f}" for nn in (2, 4, 8, 16, 32)
                  if nn <= counts[-1]))
print(f"    trials needed to reach a target SNR: "
      + ", ".join((f"SNR {tgt:g} -> {int(counts[int(np.argmax(mean_snr >= tgt))])} trials"
                   if (mean_snr >= tgt).any()
                   else f"SNR {tgt:g} -> more than {counts[-1]} trials (the largest count every subject has)")
                  for tgt in (2, 3, 5, 8)))
print(f"    the sqrt law: going from N={counts[0]} to N={counts[-1]} is a factor "
      f"{np.sqrt(counts[-1] / counts[0]):.2f} in theory and {mean_snr[-1] / mean_snr[0]:.2f} measured")
print(f"ANSWER KEY -- dry versus gel: the same component, measured the same way, gives "
      f"{L3.mean_amplitude(diff_bi, 'Pz', W):+.2f} uV at Pz on bi2014a subject 1 "
      f"({ev_t.nave} target, {ev_n.nave} non-target trials, 1:{ev_n.nave / ev_t.nave:.1f}) against "
      f"{L3.mean_amplitude(g, None, W, times=times):+.2f} uV for the ERP CORE grand average; the "
      f"{L3.REJECT_PTP_UV:g} uV criterion costs {100 * (~keep_bi).mean():.0f} % of the dry-electrode epochs "
      f"and {100 * sum(r[3] for r in rows) / (len(SUBJECTS) * 200):.0f} % of the ERP CORE epochs.")
print(f"ANSWER KEY -- paradigm to component (multiple choice): TODO(confirm) -- the mapping is stated in the "
      f"lesson from the dataset's documentation; this notebook verifies only P3 from the active visual oddball.")
print(f"Widget: w-erp-averager (modes averager, sme).")
nb-3-2-erp-core-p3 -- L3.2 numbers (draft; TODO(confirm) at author review)
Data: ds-erpcore P3, sub-001 to sub-010 (10 subjects, the documented subset helpers_l3.SUBSET_DEFAULT); CC-BY-SA-4.0 per data/directory.yaml (contested at source; spec 10.7 most-restrictive rule). Second dataset: ds-brain-invaders bi2014a subject 1 (Zenodo DOI 10.5281/zenodo.3266223; CC BY 4.0).
Pipeline: helpers_l3.P3_PIPELINE (printed in section 1).
Measure: channel Pz, a-priori window 300-600 ms.

  trials per condition (ds-erpcore, after rejection): target 379 total, 37.9 per subject (range 32-40); standard 1511 total, 151.1 per subject (range 138-160)
  grand-average target     : mean amplitude +5.501 uV, peak amplitude +6.423 uV at 338.9 ms
  grand-average standard   : mean amplitude +1.930 uV, peak amplitude +3.551 uV at 323.2 ms
  grand-average difference : mean amplitude +3.571 uV, peak amplitude +4.292 uV at 491.2 ms
  per-subject P3 (mean amplitude of the difference): mean +3.571 uV, SD 2.972, range +0.731 to +9.628

ANSWER KEY -- SNR and trial count (Pz P3, SNR = |window mean| / pre-stimulus SD of the same average, 40 random trial orders per subject, seed 20260918, mean over 10 subjects):
    measured mean SNR by trial count: N=2 -> 1.97, N=4 -> 2.37, N=8 -> 3.28, N=16 -> 4.25, N=32 -> 5.74
    trials needed to reach a target SNR: SNR 2 -> 3 trials, SNR 3 -> 7 trials, SNR 5 -> 22 trials, SNR 8 -> more than 32 trials (the largest count every subject has)
    the sqrt law: going from N=2 to N=32 is a factor 4.00 in theory and 2.91 measured
ANSWER KEY -- dry versus gel: the same component, measured the same way, gives +1.43 uV at Pz on bi2014a subject 1 (84 target, 425 non-target trials, 1:5.1) against +3.57 uV for the ERP CORE grand average; the 150 uV criterion costs 57 % of the dry-electrode epochs and 6 % of the ERP CORE epochs.
ANSWER KEY -- paradigm to component (multiple choice): TODO(confirm) -- the mapping is stated in the lesson from the dataset's documentation; this notebook verifies only P3 from the active visual oddball.
Widget: w-erp-averager (modes averager, sme).