Measuring ERPs: peak, mean, area, peak latency and fractional-area latency compared for noise bias and across-subject reliability

nb-3-3-measurement Level 3 · Event-Related Analysis ~5 min Used in L3.3 · Measuring ERPs

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-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.

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

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)")
10 subjects loaded; target trials per subject: sub-001 35, sub-002 40, sub-003 36, sub-004 40, sub-005 38, sub-006 32, sub-007 40, sub-008 40, sub-009 40, sub-010 38
lowest-trial subject: sub-006 with 32 target trials (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.
In [4]:
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
Figure 1 of notebook nb-3-3-measurement, an output plot. The text around it states what it shows and the units of every axis.
In [5]:
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}")
Per subject, Pz, target minus standard, window 300-600 ms
subject     nT peak amplitude mean amplitude area (positive)   peak latency 50% fractional-area latency
sub-001     35         4.0898         2.6827         0.7844         0.3740         0.4351
sub-002     40        13.9730         9.6275         2.8390         0.4951         0.4718
sub-003     36        11.4722         8.4180         2.4700         0.3740         0.4464
sub-004     40         5.6483         3.2365         0.9515         0.4795         0.4744
sub-005     38         4.6962         2.1255         0.6340         0.4287         0.4210
sub-006     32         2.3260         0.7310         0.2420         0.5615         0.3865
sub-007     40         4.8156         2.8193         0.8267         0.3506         0.4168
sub-008     40         3.5552         1.4663         0.4306         0.4170         0.4174
sub-009     40         3.3745         2.1667         0.6386         0.4053         0.4465
sub-010     38         5.2329         2.4337         0.7682         0.5186         0.4941

method                         unit          mean         SD       SEM       CV
peak amplitude                 uV          5.9184     3.7606    1.1892    0.635
mean amplitude                 uV          3.5707     2.9719    0.9398    0.832
area (positive)                uV*s        1.0585     0.8695    0.2750    0.821
peak latency                   s           0.4404     0.0700    0.0221    0.159
50% fractional-area latency    s           0.4410     0.0325    0.0103    0.074

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.

In [6]:
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} %)")
Figure 2 of notebook nb-3-3-measurement, an output plot. The text around it states what it shows and the units of every axis.
  peak amplitude : N =  5 -> +8.035 uV, N = 32 -> +6.002 uV (change +2.033 uV, +33.9 %)
  mean amplitude : N =  5 -> +3.512 uV, N = 32 -> +3.581 uV (change -0.070 uV, -1.9 %)

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.

In [7]:
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.")
Split-half reliability over 10 subjects (odd vs even target trials, same standard average)
method                         unit      across-subject SD   r(half)   r(Spearman-Brown)
peak amplitude                 uV                   3.7606     0.867               0.929
mean amplitude                 uV                   2.9719     0.729               0.843
area (positive)                uV*s                 0.8695     0.833               0.909
peak latency                   s                    0.0700    -0.329        n/a (r <= 0)
50% fractional-area latency    s                    0.0325     0.443               0.614

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.

In [8]:
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
Mean amplitude of the difference wave at Pz under three ways of choosing the window (10 subjects):
  a-priori (helpers_l3.P3_WINDOW) 300-600 ms
      mean +3.571 uV, SD 2.972, t(9) = 3.799, p = 0.00422, dz = 1.201
  collapsed localizer: peak of the condition-collapsed grand average (331 ms) +/- 150 ms
      mean +2.508 uV, SD 2.140, t(9) = 3.707, p = 0.00487, dz = 1.172
  circular: peak of the grand-average DIFFERENCE (491 ms) +/- 150 ms
      mean +3.548 uV, SD 3.087, t(9) = 3.634, p = 0.00545, dz = 1.149
Figure 3 of notebook nb-3-3-measurement, an output plot. The text around it states what it shows and the units of every axis.

6 · The numbers

In [9]:
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.")
nb-3-3-measurement -- L3.3 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)
Measure: channel Pz, target minus standard, a-priori window 300-600 ms

ANSWER KEY -- ex-3-3 (numeric), low-trial subject: sub-006 has 32 target trials (the fewest in the subset) and gives
    peak amplitude = +2.33 uV   mean amplitude = +0.73 uV   (peak exceeds mean by +1.60 uV)
    for comparison sub-002, 40 target trials: peak +13.97 uV, mean +9.63 uV (gap +4.35 uV)
    across all 10 subjects the peak exceeds the mean by +2.35 uV on average (range +1.21 to +4.35)
ANSWER KEY -- ex-3-3 (free response): subsampling the target trials of every subject shows the mechanism -- averaging 5 instead of 32 target trials moves the peak from +6.002 to +8.035 uV (+33.9 %) while the mean moves from +3.581 to +3.512 uV (-1.9 %). The peak is the maximum of a noisy set and the maximum grows with the noise; the mean is unbiased.

ANSWER KEY -- across-subject standard deviation of each method (10 subjects, same window, same channel):
    peak amplitude                 SD = 3.7606 uV; mean 5.9184 uV; split-half r = 0.867, Spearman-Brown 0.929
    mean amplitude                 SD = 2.9719 uV; mean 3.5707 uV; split-half r = 0.729, Spearman-Brown 0.843
    area (positive)                SD = 0.8695 uV*s; mean 1.0585 uV*s; split-half r = 0.833, Spearman-Brown 0.909
    peak latency                   SD = 0.0700 s  (70.0 ms); mean 0.4404 s; split-half r = -0.329, Spearman-Brown n/a (half correlation <= 0)
    50% fractional-area latency    SD = 0.0325 s  (32.5 ms); mean 0.4410 s; split-half r = 0.443, Spearman-Brown 0.614

Supporting -- window choice: a-priori 300-600 ms gives +3.571 uV; a collapsed localizer centred on the condition-collapsed peak (331 ms) gives +2.508 uV; a window centred on the peak of the difference itself (491 ms) gives +3.548 uV -- the last is circular and is reported only to show the size of the circularity.
Pitfalls: pf-peak-amplitude-noise-bias, pf-post-hoc-windows.  Widget: w-measurement-explorer.