nb-3-1-epochs · Epoching and baseline (L3.1)¶
Lesson L3.1 · Level 3 · Status draft — for expert review; uncertain points carry TODO(confirm).
Events become epochs, epochs carry metadata, and a baseline window is a choice with a number attached to it.
This notebook builds Epochs with a trial table attached (condition, event code, reaction time, accuracy,
stimulus-onset asynchrony), then measures the same P3 under four baseline windows: none, the pipeline's
pre-stimulus window, a pre-stimulus window that contains a condition difference, and the measurement window
itself. The last cell prints how much the P3 moves when the baseline moves — the L3.1 exercise's answer key.
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.
What this notebook is not. It does not choose a baseline for you. It shows what each choice costs, on 10 subjects, with the same measurement window throughout.
# 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¶
Level 3 uses one pipeline for every notebook, so that a number from nb-3-3 can be compared with a number from
nb-3-7. It lives in helpers_l3.P3_PIPELINE and is printed here rather than paraphrased. Bad-channel detection,
re-referencing and ocular correction belong to Level 2 (L2.2, L2.3, L2.6) and are not re-taught here; they are
in the pipeline so that the frontal ocular artifact does not decide which trials survive.
# 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")
2 · Events to epochs, with metadata¶
mne.Epochs takes an (n_events, 3) array of sample / previous / code. That array says when and which
condition, and nothing else. Everything else a trial knows — its reaction time, whether the response was
correct, how long until the next stimulus — goes into metadata, a pandas DataFrame with one row per epoch,
which MNE keeps aligned through every selection and drop.
Two consequences worth knowing before you use it:
- with metadata attached,
epochs["target"]stops being an event-id lookup and becomes a pandas query over the metadata, so it fails unless a column namedtargetexists. Select with the query you mean:epochs["condition == 'target'"](that is whathelpers_l3.condition_epochsdoes); - metadata rows follow the epochs. After a rejection step,
epochs.metadata["rt_ms"]is the reaction time of the surviving trials, which is exactly what a single-trial regression (nb-3-6) needs.
The first subject is loaded with a wide window (−1 to +1 s) and no baseline correction, because the rest of this notebook applies baselines itself.
SUBJECT = 1 # the detail subject: the first of the documented subset, chosen before looking
WIDE = (-1.0, 1.0) # wide enough to hold every baseline window this notebook tries
epochs, info = L3.load_p3_epochs(SUBJECT, tmin=WIDE[0], tmax=WIDE[1], baseline=None, verbose=True)
print()
print(epochs)
print()
print("metadata columns:", list(epochs.metadata.columns))
print(epochs.metadata.head(6).to_string(index=False))
print()
print(f"{info['subject']}: {info['n_stimulus_events']} stimulus events, {info['n_epochs']} epochs survived the "
f"{WIDE[0]:+g} to {WIDE[1]:+g} s window, {info['n_rejected']} rejected at "
f"{info['reject_ptp_uv']:g} uV peak-to-peak")
print(f" kept: {info['n_kept']['target']} target, {info['n_kept']['standard']} standard "
f"(design ratio 1:4, p(target) = .2)")
print(f" bad channels flagged and interpolated: {info['bad_channels'] or 'none'}; "
f"ICA components removed as ocular: {info['ica_excluded']}")
print(f" mean reaction time: target {info['mean_rt_ms']['target']:.0f} ms, "
f"standard {info['mean_rt_ms']['standard']:.0f} ms; accuracy {100 * info['accuracy']:.1f} %")
The epoch window is a decision too¶
The window must hold the component, its baseline, and enough after it to see the component come back to zero — but not so much that it swallows the next trial. The stimulus-onset asynchrony in this paradigm decides the ceiling, and it is in the metadata, so it can be measured rather than assumed.
soa = epochs.metadata["soa_s"].to_numpy(float)
soa = soa[np.isfinite(soa)]
print(f"stimulus-onset asynchrony: median {np.median(soa):.3f} s, "
f"range {soa.min():.3f}-{soa.max():.3f} s ({len(soa)} intervals)")
print(f" an epoch reaching {WIDE[1]:+g} s therefore overlaps the next stimulus on "
f"{100 * (soa < WIDE[1]).mean():.0f} % of trials, and an epoch starting at {WIDE[0]:+g} s overlaps the "
f"previous one on {100 * (soa < -WIDE[0]).mean():.0f} %")
print(f" the pipeline's analysis window is {L3.EPOCH_TMIN:+g} to {L3.EPOCH_TMAX:+g} s, inside the shortest "
f"observed asynchrony ({soa.min():.3f} s), so no analysed epoch contains a second stimulus")
fig, ax = plt.subplots(figsize=(8, 3.2))
ax.hist(soa * 1000, bins=40, color="tab:blue", alpha=0.8)
for edge, label in ((L3.EPOCH_TMAX * 1000, "analysis tmax"), (-L3.EPOCH_TMIN * 1000, "|analysis tmin|")):
ax.axvline(edge, color="tab:orange", lw=1.2, ls="--", label=f"{label} = {edge:.0f} ms")
ax.set(xlabel="Stimulus-onset asynchrony (ms)", ylabel="Trials",
title=f"{info['subject']}: stimulus-onset asynchrony and the epoch window (ms)")
ax.grid(alpha=0.3)
ax.legend(fontsize=8)
fig.tight_layout()
plt.show() # render the static figure(s) of this cell inline
3 · Four baselines on one subject¶
A baseline correction subtracts, from every sample of an epoch, the mean of that epoch over the baseline window. It is therefore a per-trial constant: it cannot remove a drift within an epoch, only re-zero it. Its whole job is to make the epochs comparable before they are averaged.
Four windows, on the same trials, measured the same way:
| window | what it assumes |
|---|---|
| none | the epochs are already zero-mean where it matters (true after a 0.1 Hz high-pass and an average reference — check, do not assume) |
| −200 to 0 ms | the 200 ms before the stimulus contain no condition difference |
| −600 to −400 ms | the same assumption, further back — tested in section 4 |
| 150 to 300 ms | that the 150 ms before the component starts are condition-neutral: a post-stimulus baseline, which some pipelines use and which cannot be neutral if the component begins inside it |
| 300 to 600 ms | the measurement window is its own reference: a degenerate case, kept because its answer is instructive |
BASELINES = [
("none", None),
("-200 to 0 ms (pipeline default)", (-0.2, 0.0)),
("-600 to -400 ms", (-0.6, -0.4)),
("150 to 300 ms (the component's rising edge)", (0.15, 0.30)),
("300 to 600 ms (the measurement window itself)", (0.3, 0.6)),
]
W = L3.P3_WINDOW
times = epochs.times
eeg = info["eeg_channels"]
i_pz = eeg.index(L3.P3_CHANNEL)
tgt = L3.condition_epochs(epochs, "target").get_data(picks="eeg") * 1e6 # trials x 30 x times, uV
std = L3.condition_epochs(epochs, "standard").get_data(picks="eeg") * 1e6
def rebaseline(x, window):
"""Subtract each epoch's mean over `window` (None = leave the epoch alone)."""
if window is None:
return x
m = (times >= window[0]) & (times <= window[1])
return x - x[..., m].mean(axis=-1, keepdims=True)
rows = []
waves = {}
for label, win in BASELINES:
t_avg, s_avg = rebaseline(tgt, win).mean(0), rebaseline(std, win).mean(0)
diff = t_avg - s_avg
waves[label] = diff[i_pz]
rows.append((label,
L3.mean_amplitude(t_avg[i_pz], None, W, times=times),
L3.mean_amplitude(s_avg[i_pz], None, W, times=times),
L3.mean_amplitude(diff[i_pz], None, W, times=times)))
print(f"{info['subject']}, {L3.P3_CHANNEL}, mean amplitude over "
f"{W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms (uV)")
print(f"{'baseline window':48s} {'target':>8s} {'standard':>9s} {'T - S':>8s}")
for label, a, b, c in rows:
print(f"{label:48s} {a:+8.3f} {b:+9.3f} {c:+8.3f}")
fig, axes = plt.subplots(1, 2, figsize=(13, 4.4))
L3.plot_erp({label: waves[label] for label, _ in BASELINES}, times, window=W, ax=axes[0],
title=f"{info['subject']}: target - standard at {L3.P3_CHANNEL} under four baselines (uV)")
for label, win in BASELINES:
if win is not None:
axes[0].axvspan(win[0] * 1000, win[1] * 1000, color="tab:green", alpha=0.07, lw=0)
b_tgt = rebaseline(tgt, (-0.2, 0.0)).mean(0)
b_std = rebaseline(std, (-0.2, 0.0)).mean(0)
L3.plot_erp({f"target (n = {tgt.shape[0]})": b_tgt[i_pz],
f"standard (n = {std.shape[0]})": b_std[i_pz],
"difference": (b_tgt - b_std)[i_pz]}, times, window=W, ax=axes[1],
title=f"{info['subject']}: the two conditions, baseline -200 to 0 ms (uV)")
fig.tight_layout()
plt.show() # render the static figure(s) of this cell inline
The 300–600 ms row is exactly +0.000, in both conditions and in the difference, and that is not a rounding
accident: subtracting the mean of a window from a waveform makes the mean of that window zero by construction. A
baseline window inside the measurement window does not reduce the measurement — it deletes it. Nothing about
the data changed; the measure did.
4 · Is the pre-stimulus window condition-neutral?¶
Baseline correction assumes the two conditions do not differ inside the baseline window. If they do, the difference is subtracted out of the post-stimulus difference, one for one. The assumption is checkable: measure the target-minus-standard difference in several pre-stimulus windows on unbaselined epochs, across the documented subset.
SUBJECTS = list(L3.SUBSET_DEFAULT) # the documented subset: helpers_l3.SUBSET_DEFAULT
print(f"documented subset: {len(SUBJECTS)} subjects, sub-001 to sub-{SUBJECTS[-1]:03d} "
f"(the first N of the 40 the dataset ships; no subject was chosen by its result)")
store = {}
for s in SUBJECTS:
ep, nfo = L3.load_p3_epochs(s, tmin=WIDE[0], tmax=WIDE[1], baseline=None, 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,
}
print(f" {nfo['subject']}: {nfo['n_kept']['target']:3d} target, {nfo['n_kept']['standard']:3d} standard "
f"kept; {nfo['n_rejected']:3d} rejected; bads {nfo['bad_channels'] or '[]'}; "
f"ICA excluded {nfo['ica_excluded']}")
PRE_WINDOWS = [(-1.0, -0.8), (-0.8, -0.6), (-0.6, -0.4), (-0.4, -0.2), (-0.2, 0.0)]
print(f"target - standard at {L3.P3_CHANNEL} in pre-stimulus windows, unbaselined, "
f"mean over {len(SUBJECTS)} subjects (uV):")
pre_means = {}
for win in PRE_WINDOWS:
vals = np.array([L3.mean_amplitude((store[s]["target"].mean(0) - store[s]["standard"].mean(0))[i_pz],
None, win, times=times) for s in SUBJECTS])
pre_means[win] = vals
print(f" {win[0] * 1000:+6.0f} to {win[1] * 1000:+6.0f} ms : {vals.mean():+6.3f} uV "
f"(SD {vals.std(ddof=1):5.3f}, {int((vals > 0).sum())}/{len(vals)} subjects positive)")
post = np.array([L3.mean_amplitude((store[s]["target"].mean(0) - store[s]["standard"].mean(0))[i_pz],
None, W, times=times) for s in SUBJECTS])
print(f" {W[0] * 1000:+6.0f} to {W[1] * 1000:+6.0f} ms : {post.mean():+6.3f} uV <- the measurement window")
The pre-stimulus windows are not condition-neutral in this group average: the difference is largest around
−600 to −400 ms and has not fully decayed by −200 ms. Two mechanisms could produce it and this notebook does not
choose between them (TODO(confirm); the w-epoch-builder widget lets you probe both):
- trial overlap — the stimulus-onset asynchrony is about 1.5 s, so a slow component of one trial can still be running when the next begins, and the preceding trials of a target are not a random sample of trials;
- zero-phase filtering — a 0.1 Hz zero-phase high-pass has an impulse response seconds long and spreads the
low-frequency energy it removes symmetrically, so some of a slow post-stimulus difference reappears before
the stimulus.
nb-1-5-filtersshows that mechanism directly on a single transient.
Either way the consequence for the baseline is the same, and it is what the next cell measures.
5 · The number the exercise asks for¶
ex-3-1-* asks what happens to the P3 when the baseline window is moved into a window that contains a condition
difference. That is the −600 to −400 ms window above. The change is computed per subject and then averaged, so the
spread is visible.
def measure(window_baseline):
out = []
for s in SUBJECTS:
t_avg = rebaseline(store[s]["target"], window_baseline).mean(0)
s_avg = rebaseline(store[s]["standard"], window_baseline).mean(0)
out.append(L3.mean_amplitude((t_avg - s_avg)[i_pz], None, W, times=times))
return np.array(out)
per_baseline = {label: measure(win) for label, win in BASELINES}
default = per_baseline["-200 to 0 ms (pipeline default)"]
contaminated = per_baseline["-600 to -400 ms"]
shift = contaminated - default
print(f"P3 (target - standard, {L3.P3_CHANNEL}, {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms mean amplitude), "
f"{len(SUBJECTS)} subjects")
for label, vals in per_baseline.items():
print(f" baseline {label:48s} {vals.mean():+7.3f} uV (SD {vals.std(ddof=1):5.3f}, "
f"SEM {vals.std(ddof=1) / np.sqrt(len(vals)):5.3f})")
print()
print(f"moving the baseline from -200..0 ms to -600..-400 ms changes the P3 by "
f"{shift.mean():+.3f} uV (SD {shift.std(ddof=1):.3f}), i.e. "
f"{100 * shift.mean() / default.mean():+.1f} % of the P3 measured with the default baseline")
print(f" per subject: " + ", ".join(f"{v:+.2f}" for v in shift))
predicted = pre_means[(-0.2, 0.0)].mean() - pre_means[(-0.6, -0.4)].mean()
print(f" the arithmetic closes: the two baseline windows differ in their own condition difference by "
f"{pre_means[(-0.2, 0.0)].mean():+.3f} - {pre_means[(-0.6, -0.4)].mean():+.3f} = {predicted:+.3f} uV, "
f"and the P3 moves by {shift.mean():+.3f} uV. A baseline is subtracted one for one.")
fig, ax = plt.subplots(figsize=(8.5, 4))
x = np.arange(len(SUBJECTS))
ax.bar(x - 0.2, default, 0.4, label="baseline -200 to 0 ms")
ax.bar(x + 0.2, contaminated, 0.4, label="baseline -600 to -400 ms")
ax.set_xticks(x)
ax.set_xticklabels([f"{s:03d}" for s in SUBJECTS], fontsize=8)
ax.axhline(0, color="gray", lw=0.6)
ax.set(xlabel="Subject", ylabel="Amplitude (uV)",
title=f"P3 per subject under two baselines ({L3.P3_CHANNEL}, "
f"{W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms mean, target - standard, 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
6 · Baseline as a covariate, in one line of arithmetic¶
Mean-subtraction is not the only option. The pre-stimulus mean can instead enter a regression as a covariate:
amplitude ~ 1 + condition + prestimulus_mean. Subtraction is the special case in which the coefficient on
prestimulus_mean is fixed at exactly 1; estimating it instead lets the data say how much of the pre-stimulus
level actually carries into the measurement window. The cell below estimates that coefficient on the single
trials of the documented subset — not as a recommendation, but so that the number is visible.
b_pre = []
for s in SUBJECTS:
x = np.concatenate([store[s]["target"][:, i_pz, :], store[s]["standard"][:, i_pz, :]])
cond = np.r_[np.ones(store[s]["target"].shape[0]), np.zeros(store[s]["standard"].shape[0])]
m_pre = (times >= -0.2) & (times <= 0.0)
m_win = (times >= W[0]) & (times <= W[1])
pre, post_ = x[:, m_pre].mean(1), x[:, m_win].mean(1)
design = np.c_[np.ones_like(cond), cond, pre - pre.mean()]
beta, *_ = np.linalg.lstsq(design, post_, rcond=None)
b_pre.append(beta[2])
b_pre = np.array(b_pre)
print(f"coefficient on the pre-stimulus mean, single-trial regression per subject "
f"(amplitude ~ 1 + condition + pre-stimulus mean):")
print(f" mean {b_pre.mean():+.3f}, SD {b_pre.std(ddof=1):.3f}, range {b_pre.min():+.3f} to {b_pre.max():+.3f}")
print(f" mean subtraction assumes this coefficient is exactly +1.000; the estimate is "
f"{b_pre.mean():+.3f}, so subtraction removes "
f"{'more' if b_pre.mean() < 1 else 'less'} than the data support here (TODO(confirm) at author review)")
7 · The numbers¶
print("nb-3-1-epochs -- L3.1 exercise numbers (draft; TODO(confirm) at author review)")
print(f"Data: ds-erpcore P3, subjects sub-001 to sub-{SUBJECTS[-1]:03d} ({len(SUBJECTS)} 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)")
print(f"Pipeline: helpers_l3.P3_PIPELINE (printed in section 1); epochs {WIDE[0]:+g} to {WIDE[1]:+g} s, "
f"baseline applied afterwards per variant; {L3.REJECT_PTP_UV:g} uV peak-to-peak rejection")
print(f"Measure: mean amplitude of the target-minus-standard difference wave, "
f"{W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms (a-priori window), channel {L3.P3_CHANNEL}")
print()
for label, vals in per_baseline.items():
print(f" baseline {label:48s} P3 = {vals.mean():+6.3f} uV (SD {vals.std(ddof=1):5.3f})")
print()
print(f"ANSWER KEY -- ex-3-1 (numeric): moving the baseline window from -200..0 ms to -600..-400 ms, "
f"a window that does contain a condition difference, changes the P3 from "
f"{default.mean():+.3f} uV to {contaminated.mean():+.3f} uV, a change of {shift.mean():+.3f} uV "
f"({100 * shift.mean() / default.mean():+.1f} %).")
print(f"ANSWER KEY -- ex-3-1 (free response): the pre-stimulus target-minus-standard difference at "
f"{L3.P3_CHANNEL} is {pre_means[(-0.6, -0.4)].mean():+.3f} uV over -600..-400 ms and "
f"{pre_means[(-0.2, 0.0)].mean():+.3f} uV over -200..0 ms, so a baseline taken there is subtracted "
f"from the effect one for one; candidates for the pre-stimulus difference are trial overlap at a "
f"{np.median(soa):.2f} s stimulus-onset asynchrony and the symmetric smearing of a zero-phase high-pass "
f"(TODO(confirm)).")
print(f"ANSWER KEY -- degenerate case: a baseline equal to the measurement window gives exactly "
f"{per_baseline['300 to 600 ms (the measurement window itself)'].mean():+.3f} uV by construction.")
print(f"Supporting: no baseline at all gives {per_baseline['none'].mean():+.3f} uV "
f"(SD {per_baseline['none'].std(ddof=1):.3f}) against {default.mean():+.3f} uV "
f"(SD {default.std(ddof=1):.3f}) with the default baseline -- on these already high-passed, "
f"average-referenced epochs the short baseline window adds across-subject variance rather than "
f"removing it.")
print(f"Pitfall: pf-baseline-contamination. Widget: w-epoch-builder.")