nb-3-3-measurement · Measuring ERPs (L3.3)¶
Lesson L3.3 · Level 3 · Status draft — for expert review; uncertain points carry TODO(confirm).
One difference wave, five measurements: peak amplitude, mean amplitude, area, peak latency and 50 % fractional-area latency. They are not five views of one number — they have different biases, different noise sensitivities and different across-subject reliability, and this notebook measures all three properties on the same ten subjects.
Three results the last cell prints as answer keys: the peak-versus-mean amplitude of a low-trial subject, the across-subject standard deviation of each method, and each method's split-half reliability.
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.
Scope note (TODO(confirm)). Spec §6 L3.3 names N170 and P3. This notebook measures P3 only: the N170
paradigm is a second 58 MB-per-subject download from a different OSF component and Phase 2's addendum makes P3
the Phase 2 paradigm. The same five measurement functions (helpers_l3.MEASURES) apply unchanged to a negative
component by passing mode="neg"; adding the N170 section is a one-cell change once the N170 subjects are
fetched.
# 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")
1 · The pipeline, stated once¶
# 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")
SUBJECTS = list(L3.SUBSET_DEFAULT)
W = L3.P3_WINDOW
CH = L3.P3_CHANNEL
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)
diff = {s: store[s]["target"].mean(0)[i_ch] - store[s]["standard"].mean(0)[i_ch] for s in SUBJECTS}
n_target = {s: store[s]["target"].shape[0] for s in SUBJECTS}
print(f"{len(SUBJECTS)} subjects loaded; target trials per subject: "
+ ", ".join(f"{L3.erpcore_subject_id(s)} {n_target[s]}" for s in SUBJECTS))
LOW = min(SUBJECTS, key=lambda s: n_target[s])
print(f"lowest-trial subject: {L3.erpcore_subject_id(LOW)} with {n_target[LOW]} target trials "
f"(chosen by trial count alone, not by its amplitude)")
2 · Five measurements of one waveform¶
Every measurement here takes an explicit window; helpers_l3 has no default window on any of them, because a
measurement without a stated window is not a measurement.
- peak amplitude — the largest sample in the window. Simple, and biased: the maximum of a set of noisy samples grows with the noise, so a noisier average scores higher for no neural reason.
- mean amplitude — the average of the window. Unbiased by noise (the expectation of a mean does not depend on the noise), linear (so the mean of a difference is the difference of the means), and insensitive to latency jitter within the window.
- area — the integral over the window, here rectified to the positive part. Between the two: it uses every sample, but rectification reintroduces a noise bias.
- peak latency — the time of the peak. Discrete (it can only land on a sample), and as noise-sensitive as the peak itself.
- fractional-area latency — the time at which 50 % of the area has accumulated, interpolated between samples. Uses the whole window, so it moves smoothly.
d_low = diff[LOW]
fig, axes = plt.subplots(1, 2, figsize=(13, 4.6))
ax = axes[0]
L3.plot_erp({f"{L3.erpcore_subject_id(LOW)} difference wave (n = {n_target[LOW]} target trials)": d_low},
times, window=W, ax=ax, title=f"Five measurements of one difference wave at {CH} (uV)")
m = (times >= W[0]) & (times <= W[1])
pk = L3.peak_amplitude(d_low, None, W, times=times)
pk_t = L3.peak_latency(d_low, None, W, times=times)
mn = L3.mean_amplitude(d_low, None, W, times=times)
fal = L3.fractional_area_latency(d_low, None, W, times=times)
ax.plot(pk_t * 1000, pk, "v", color="tab:red", ms=9, label=f"peak {pk:+.2f} uV at {pk_t * 1000:.0f} ms")
ax.hlines(mn, W[0] * 1000, W[1] * 1000, color="tab:green", lw=2, label=f"mean {mn:+.2f} uV")
ax.axvline(fal * 1000, color="tab:purple", lw=1.5, ls="--", label=f"50% area latency {fal * 1000:.0f} ms")
ax.fill_between(times[m] * 1000, 0, np.clip(d_low[m], 0, None), color="tab:orange", alpha=0.25,
label=f"positive area {L3.area_amplitude(d_low, None, W, times=times):.3f} uV*s")
ax.legend(fontsize=8)
for s in SUBJECTS:
axes[1].plot(times * 1000, diff[s], lw=0.9, alpha=0.7)
p_t = L3.peak_latency(diff[s], None, W, times=times)
axes[1].plot(p_t * 1000, L3.peak_amplitude(diff[s], None, W, times=times), "v", ms=5, color="tab:red")
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].set(xlabel="Time from stimulus (ms)", ylabel="Amplitude (uV)",
title=f"Each subject's difference wave and its peak ({CH}, uV) -- the peaks scatter in both axes")
axes[1].grid(alpha=0.3)
fig.tight_layout()
plt.show() # render the static figure(s) of this cell inline
print(f"Per subject, {CH}, target minus standard, window {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms")
header = f"{'subject':9s} {'nT':>4s} " + " ".join(f"{k:>14s}" for k in L3.MEASURES)
print(header)
table = {k: [] for k in L3.MEASURES}
for s in SUBJECTS:
vals = L3.measure_all(diff[s], None, W, times=times)
for k, v in vals.items():
table[k].append(v)
print(f"{L3.erpcore_subject_id(s):9s} {n_target[s]:4d} "
+ " ".join(f"{vals[k]:14.4f}" for k in L3.MEASURES))
table = {k: np.array(v) for k, v in table.items()}
print()
print(f"{'method':30s} {'unit':7s} {'mean':>10s} {'SD':>10s} {'SEM':>9s} {'CV':>8s}")
for k, (fn, unit) in L3.MEASURES.items():
v = table[k]
print(f"{k:30s} {unit:7s} {v.mean():10.4f} {v.std(ddof=1):10.4f} "
f"{v.std(ddof=1) / np.sqrt(len(v)):9.4f} {abs(v.std(ddof=1) / v.mean()):8.3f}")
The coefficient of variation compares the two amplitude measures honestly (same unit, same quantity). It cannot compare an amplitude with a latency: a latency's mean is an arbitrary offset from stimulus onset, so its CV says more about where zero is than about the measurement. For that comparison a reliability coefficient is needed, and that is section 4.
3 · The noise bias of the peak, made visible¶
The claim is that a peak measured on fewer trials is larger, because the maximum of a noisier waveform is larger. It is testable: take the subjects that have the most trials, subsample their target trials down to N, and watch the two measures as N falls. Everything else — the window, the channel, the standard condition — is held fixed. If the claim is right, mean amplitude stays flat and peak amplitude climbs.
rng = np.random.default_rng(20260918)
N_REP = 60
sizes = [5, 8, 12, 16, 24, 32]
sizes = [n for n in sizes if n <= min(n_target.values())] + [min(n_target.values())]
sizes = sorted(set(sizes))
curve = {"peak amplitude": np.zeros((len(SUBJECTS), len(sizes))),
"mean amplitude": np.zeros((len(SUBJECTS), len(sizes)))}
for k, s in enumerate(SUBJECTS):
tgt = store[s]["target"][:, i_ch, :]
std_avg = store[s]["standard"].mean(0)[i_ch]
for j, n in enumerate(sizes):
pk, mn_ = [], []
for _ in range(N_REP):
sel = rng.choice(tgt.shape[0], n, replace=False)
d = tgt[sel].mean(0) - std_avg
pk.append(L3.peak_amplitude(d, None, W, times=times))
mn_.append(L3.mean_amplitude(d, None, W, times=times))
curve["peak amplitude"][k, j] = np.mean(pk)
curve["mean amplitude"][k, j] = np.mean(mn_)
fig, ax = plt.subplots(figsize=(8.5, 4.2))
for name, style in (("peak amplitude", "-o"), ("mean amplitude", "-s")):
y = curve[name].mean(0)
e = curve[name].std(0, ddof=1) / np.sqrt(len(SUBJECTS))
ax.errorbar(sizes, y, yerr=e, fmt=style, capsize=3, label=name)
ax.set(xlabel="Target trials averaged (random subsets, 60 draws per subject)", ylabel="Amplitude (uV)",
title=f"Peak amplitude is biased by trial count; mean amplitude is not ({CH}, "
f"{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
for name in ("peak amplitude", "mean amplitude"):
y = curve[name].mean(0)
print(f" {name:15s}: N = {sizes[0]:2d} -> {y[0]:+.3f} uV, N = {sizes[-1]:2d} -> {y[-1]:+.3f} uV "
f"(change {y[0] - y[-1]:+.3f} uV, {100 * (y[0] - y[-1]) / abs(y[-1]):+.1f} %)")
4 · Across-subject reliability¶
"Reliable" here means: if the same subjects were measured again, would the same subjects come out high and low?
The split-half estimate answers it without a second session. Score each subject twice — once from the
odd-numbered trials, once from the even-numbered — correlate the two scores across subjects, and correct the
correlation for the halving with the Spearman–Brown formula, r_full = 2r / (1 + r).
Because both halves come from the same session, this is an upper bound on test–retest reliability, and it is unitless, so the five methods can be compared with one another.
def split_half(measure_fn, mode_kwargs=None):
"""(r_half, r_spearman_brown) over subjects for one measurement, odd vs even target trials."""
mode_kwargs = mode_kwargs or {}
a, b = [], []
for s in SUBJECTS:
tgt = store[s]["target"][:, i_ch, :]
std_avg = store[s]["standard"].mean(0)[i_ch]
odd = tgt[1::2].mean(0) - std_avg
even = tgt[0::2].mean(0) - std_avg
a.append(measure_fn(odd, None, W, times=times, **mode_kwargs))
b.append(measure_fn(even, None, W, times=times, **mode_kwargs))
a, b = np.array(a), np.array(b)
ok = np.isfinite(a) & np.isfinite(b)
r = float(np.corrcoef(a[ok], b[ok])[0, 1])
# Spearman-Brown is only meaningful for a positive half correlation: a negative r means the two
# halves rank the subjects differently, i.e. the measure carries no reliable between-subject signal,
# and "correcting" it produces a number below -1 that should not be reported as a reliability.
rsb = 2 * r / (1 + r) if r > 0 else float("nan")
return r, rsb
print(f"Split-half reliability over {len(SUBJECTS)} subjects (odd vs even target trials, same standard average)")
print(f"{'method':30s} {'unit':7s} {'across-subject SD':>19s} {'r(half)':>9s} {'r(Spearman-Brown)':>19s}")
reliability = {}
for k, (fn, unit) in L3.MEASURES.items():
r, rsb = split_half(fn)
reliability[k] = (r, rsb)
shown = f"{rsb:19.3f}" if np.isfinite(rsb) else f"{'n/a (r <= 0)':>19s}"
print(f"{k:30s} {unit:7s} {table[k].std(ddof=1):19.4f} {r:9.3f} {shown}")
print()
print("Read the last column as: how much of the between-subject variance in this measure is signal rather "
"than noise. A negative half correlation is not a small reliability, it is no reliability: the two "
"halves of the same session rank the subjects differently, so the measure is dominated by noise.")
5 · Where the window came from, and what a collapsed localizer would give¶
The window used above, and by every other Level-3 notebook, is fixed in helpers_l3.P3_WINDOW and is a course
decision, not a value read out of these data. The alternative that keeps a data-driven window honest is the
collapsed localizer: define the window from the average of both conditions, which is orthogonal to the
target-minus-standard contrast, and then measure the contrast inside it. Choosing the window from the difference
wave instead — where the difference happens to be biggest — is the circularity pf-post-hoc-windows is about, and
the cell below shows what it costs by doing all three.
collapsed = np.mean([(store[s]["target"].sum(0) + store[s]["standard"].sum(0))
/ (store[s]["target"].shape[0] + store[s]["standard"].shape[0])
for s in SUBJECTS], axis=0)[i_ch]
grand_diff = np.mean([diff[s] for s in SUBJECTS], axis=0)
post = (times >= 0) & (times <= 0.8)
t_collapsed = times[post][int(collapsed[post].argmax())]
t_diffpeak = times[post][int(grand_diff[post].argmax())]
HALF = 0.150
windows = {
f"a-priori (helpers_l3.P3_WINDOW) {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms": W,
f"collapsed localizer: peak of the condition-collapsed grand average "
f"({t_collapsed * 1000:.0f} ms) +/- {HALF * 1000:.0f} ms": (t_collapsed - HALF, t_collapsed + HALF),
f"circular: peak of the grand-average DIFFERENCE ({t_diffpeak * 1000:.0f} ms) "
f"+/- {HALF * 1000:.0f} ms": (t_diffpeak - HALF, t_diffpeak + HALF),
}
from scipy import stats
print(f"Mean amplitude of the difference wave at {CH} under three ways of choosing the window "
f"({len(SUBJECTS)} subjects):")
for label, win in windows.items():
v = np.array([L3.mean_amplitude(diff[s], None, win, times=times) for s in SUBJECTS])
tstat, pval = stats.ttest_1samp(v, 0)
print(f" {label}")
print(f" mean {v.mean():+.3f} uV, SD {v.std(ddof=1):.3f}, t({len(v) - 1}) = {tstat:.3f}, p = {pval:.5f}, "
f"dz = {v.mean() / v.std(ddof=1):.3f}")
fig, ax = plt.subplots(figsize=(9, 4.2))
L3.plot_erp({"condition-collapsed grand average (the localizer)": (collapsed, {"color": "tab:blue"}),
"grand-average difference (target - standard)": (grand_diff, {"color": "k", "lw": 1.8})},
times, ax=ax, title=f"Collapsed localizer versus the contrast at {CH} (uV)")
colors = ["tab:orange", "tab:green", "tab:red"]
for (label, win), c in zip(windows.items(), colors):
ax.axvspan(win[0] * 1000, win[1] * 1000, color=c, alpha=0.15, lw=0, label=label.split(":")[0])
ax.legend(fontsize=7)
fig.tight_layout()
plt.show() # render the static figure(s) of this cell inline
6 · The numbers¶
d_low_pk = L3.peak_amplitude(diff[LOW], None, W, times=times)
d_low_mn = L3.mean_amplitude(diff[LOW], None, W, times=times)
HIGH = max(SUBJECTS, key=lambda s: n_target[s])
d_high_pk = L3.peak_amplitude(diff[HIGH], None, W, times=times)
d_high_mn = L3.mean_amplitude(diff[HIGH], None, W, times=times)
print("nb-3-3-measurement -- L3.3 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"Measure: channel {CH}, target minus standard, a-priori window "
f"{W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms")
print()
print(f"ANSWER KEY -- ex-3-3 (numeric), low-trial subject: {L3.erpcore_subject_id(LOW)} has "
f"{n_target[LOW]} target trials (the fewest in the subset) and gives")
print(f" peak amplitude = {d_low_pk:+.2f} uV mean amplitude = {d_low_mn:+.2f} uV "
f"(peak exceeds mean by {d_low_pk - d_low_mn:+.2f} uV)")
print(f" for comparison {L3.erpcore_subject_id(HIGH)}, {n_target[HIGH]} target trials: "
f"peak {d_high_pk:+.2f} uV, mean {d_high_mn:+.2f} uV (gap {d_high_pk - d_high_mn:+.2f} uV)")
print(f" across all {len(SUBJECTS)} subjects the peak exceeds the mean by "
f"{(table['peak amplitude'] - table['mean amplitude']).mean():+.2f} uV on average "
f"(range {(table['peak amplitude'] - table['mean amplitude']).min():+.2f} to "
f"{(table['peak amplitude'] - table['mean amplitude']).max():+.2f})")
print(f"ANSWER KEY -- ex-3-3 (free response): subsampling the target trials of every subject shows the "
f"mechanism -- averaging {sizes[0]} instead of {sizes[-1]} target trials moves the peak from "
f"{curve['peak amplitude'].mean(0)[-1]:+.3f} to {curve['peak amplitude'].mean(0)[0]:+.3f} uV "
f"({100 * (curve['peak amplitude'].mean(0)[0] - curve['peak amplitude'].mean(0)[-1]) / abs(curve['peak amplitude'].mean(0)[-1]):+.1f} %) "
f"while the mean moves from {curve['mean amplitude'].mean(0)[-1]:+.3f} to "
f"{curve['mean amplitude'].mean(0)[0]:+.3f} uV "
f"({100 * (curve['mean amplitude'].mean(0)[0] - curve['mean amplitude'].mean(0)[-1]) / abs(curve['mean amplitude'].mean(0)[-1]):+.1f} %). "
f"The peak is the maximum of a noisy set and the maximum grows with the noise; the mean is unbiased.")
print()
print("ANSWER KEY -- across-subject standard deviation of each method "
f"({len(SUBJECTS)} subjects, same window, same channel):")
for k, (fn, unit) in L3.MEASURES.items():
v = table[k]
r, rsb = reliability[k]
extra = f" ({v.std(ddof=1) * 1000:.1f} ms)" if unit == "s" else ""
sb = f"Spearman-Brown {rsb:.3f}" if np.isfinite(rsb) else "Spearman-Brown n/a (half correlation <= 0)"
print(f" {k:30s} SD = {v.std(ddof=1):.4f} {unit}{extra}; mean {v.mean():.4f} {unit}; "
f"split-half r = {r:.3f}, {sb}")
print()
print(f"Supporting -- window choice: a-priori {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms gives "
f"{table['mean amplitude'].mean():+.3f} uV; a collapsed localizer centred on the condition-collapsed "
f"peak ({t_collapsed * 1000:.0f} ms) gives "
f"{np.mean([L3.mean_amplitude(diff[s], None, (t_collapsed - HALF, t_collapsed + HALF), times=times) for s in SUBJECTS]):+.3f} uV; "
f"a window centred on the peak of the difference itself ({t_diffpeak * 1000:.0f} ms) gives "
f"{np.mean([L3.mean_amplitude(diff[s], None, (t_diffpeak - HALF, t_diffpeak + HALF), times=times) for s in SUBJECTS]):+.3f} uV -- "
f"the last is circular and is reported only to show the size of the circularity.")
print(f"Pitfalls: pf-peak-amplitude-noise-bias, pf-post-hoc-windows. Widget: w-measurement-explorer.")