Epoching and baseline: Epochs with trial metadata on ERP CORE P3, and what the P3 does when the baseline window moves

nb-3-1-epochs Level 3 · Event-Related Analysis ~4 min Used in L3.1 · Epoching and baseline

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

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

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.

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 · 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 named target exists. Select with the query you mean: epochs["condition == 'target'"] (that is what helpers_l3.condition_epochs does);
  • 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.

In [3]:
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} %")
cached: sub-001 (7 files, 64 MiB)
<Epochs | 172 events (all good), -1 – 0.996 s (baseline off), ~22.2 MiB, data loaded, with metadata,
 'target': 35
 'standard': 137>

metadata columns: ['trial', 'condition', 'event_code', 'onset_s', 'sample', 'rt_ms', 'response_code', 'correct', 'n_responses', 'soa_s']
 trial condition  event_code  onset_s  sample  rt_ms  response_code  correct  n_responses  soa_s
     0    target          11  19.4121   19879  480.5          201.0      1.0            1 1.4502
     1  standard          12  20.8623   21364  454.1          201.0      1.0            1 1.5156
     2  standard          14  22.3779   22916  419.0          201.0      1.0            1 1.4326
     3  standard          13  23.8105   24383  418.0          201.0      1.0            1 1.5831
     4  standard          13  25.3936   26004  411.1          201.0      1.0            1 1.5332
     5  standard          12  26.9268   27574  477.5          201.0      1.0            1 1.5488

sub-001: 200 stimulus events, 200 epochs survived the -1 to +1 s window, 28 rejected at 150 uV peak-to-peak
  kept: 35 target, 137 standard (design ratio 1:4, p(target) = .2)
  bad channels flagged and interpolated: none; ICA components removed as ocular: [0]
  mean reaction time: target 569 ms, standard 458 ms; accuracy 97.5 %

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.

In [4]:
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
stimulus-onset asynchrony: median 1.500 s, range 1.399-57.435 s (172 intervals)
  an epoch reaching +1 s therefore overlaps the next stimulus on 0 % of trials, and an epoch starting at -1 s overlaps the previous one on 0 %
  the pipeline's analysis window is -0.2 to +0.8 s, inside the shortest observed asynchrony (1.399 s), so no analysed epoch contains a second stimulus
Figure 1 of notebook nb-3-1-epochs, an output plot. The text around it states what it shows and the units of every axis.

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
In [5]:
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}")
sub-001, Pz, mean amplitude over 300-600 ms (uV)
baseline window                                    target  standard    T - S
none                                               +5.392    +1.573   +3.819
-200 to 0 ms (pipeline default)                    +5.344    +2.649   +2.695
-600 to -400 ms                                    +5.607    +2.864   +2.744
150 to 300 ms (the component's rising edge)        +1.553    -0.407   +1.960
300 to 600 ms (the measurement window itself)      -0.000    -0.000   -0.000
In [6]:
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
Figure 2 of notebook nb-3-1-epochs, an output plot. The text around it states what it shows and the units of every axis.

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.

In [7]:
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']}")
documented subset: 10 subjects, sub-001 to sub-010 (the first N of the 40 the dataset ships; no subject was chosen by its result)
  sub-001:  35 target, 137 standard kept;  28 rejected; bads []; ICA excluded [0]
  sub-002:  40 target, 158 standard kept;   2 rejected; bads []; ICA excluded [5]
  sub-003:  36 target, 146 standard kept;  18 rejected; bads []; ICA excluded [0, 2]
  sub-004:  40 target, 159 standard kept;   1 rejected; bads []; ICA excluded [0, 4]
  sub-005:  38 target, 150 standard kept;  12 rejected; bads []; ICA excluded [0, 1]
  sub-006:  32 target, 137 standard kept;  31 rejected; bads []; ICA excluded [0, 3]
  sub-007:  40 target, 160 standard kept;   0 rejected; bads []; ICA excluded [0, 1]
  sub-008:  40 target, 151 standard kept;   9 rejected; bads []; ICA excluded [0, 4]
  sub-009:  40 target, 160 standard kept;   0 rejected; bads ['P10', 'PO8']; ICA excluded [0, 2]
  sub-010:  38 target, 153 standard kept;   9 rejected; bads []; ICA excluded [0, 3]
In [8]:
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")
target - standard at Pz in pre-stimulus windows, unbaselined, mean over 10 subjects (uV):
   -1000 to   -800 ms : +0.413 uV  (SD 1.499, 7/10 subjects positive)
    -800 to   -600 ms : +0.555 uV  (SD 1.286, 8/10 subjects positive)
    -600 to   -400 ms : +0.945 uV  (SD 1.748, 8/10 subjects positive)
    -400 to   -200 ms : +0.907 uV  (SD 1.902, 8/10 subjects positive)
    -200 to     +0 ms : +0.750 uV  (SD 2.235, 8/10 subjects positive)
    +300 to   +600 ms : +4.282 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-filters shows 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.

In [9]:
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
P3 (target - standard, Pz, 300-600 ms mean amplitude), 10 subjects
  baseline none                                              +4.282 uV   (SD 1.005, SEM 0.318)
  baseline -200 to 0 ms (pipeline default)                   +3.533 uV   (SD 2.912, SEM 0.921)
  baseline -600 to -400 ms                                   +3.337 uV   (SD 2.496, SEM 0.789)
  baseline 150 to 300 ms (the component's rising edge)       +2.719 uV   (SD 2.487, SEM 0.787)
  baseline 300 to 600 ms (the measurement window itself)     +0.000 uV   (SD 0.000, SEM 0.000)

moving the baseline from -200..0 ms to -600..-400 ms changes the P3 by -0.196 uV (SD 0.814), i.e. -5.5 % of the P3 measured with the default baseline
  per subject: +0.05, -1.52, -0.45, -0.57, +0.15, +1.19, -0.47, -0.73, -0.58, +0.98
  the arithmetic closes: the two baseline windows differ in their own condition difference by +0.750 - +0.945 = -0.196 uV, and the P3 moves by -0.196 uV.  A baseline is subtracted one for one.
Figure 3 of notebook nb-3-1-epochs, an output plot. The text around it states what it shows and the units of every axis.

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.

In [10]:
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)")
coefficient on the pre-stimulus mean, single-trial regression per subject (amplitude ~ 1 + condition + pre-stimulus mean):
  mean +0.632, SD 0.138, range +0.453 to +0.853
  mean subtraction assumes this coefficient is exactly +1.000; the estimate is +0.632, so subtraction removes more than the data support here (TODO(confirm) at author review)

7 · The numbers

In [11]:
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.")
nb-3-1-epochs -- L3.1 exercise numbers (draft; TODO(confirm) at author review)
Data: ds-erpcore P3, subjects 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)
Pipeline: helpers_l3.P3_PIPELINE (printed in section 1); epochs -1 to +1 s, baseline applied afterwards per variant; 150 uV peak-to-peak rejection
Measure: mean amplitude of the target-minus-standard difference wave, 300-600 ms (a-priori window), channel Pz

  baseline none                                             P3 = +4.282 uV  (SD 1.005)
  baseline -200 to 0 ms (pipeline default)                  P3 = +3.533 uV  (SD 2.912)
  baseline -600 to -400 ms                                  P3 = +3.337 uV  (SD 2.496)
  baseline 150 to 300 ms (the component's rising edge)      P3 = +2.719 uV  (SD 2.487)
  baseline 300 to 600 ms (the measurement window itself)    P3 = +0.000 uV  (SD 0.000)

ANSWER KEY -- ex-3-1 (numeric): moving the baseline window from -200..0 ms to -600..-400 ms, a window that does contain a condition difference, changes the P3 from +3.533 uV to +3.337 uV, a change of -0.196 uV (-5.5 %).
ANSWER KEY -- ex-3-1 (free response): the pre-stimulus target-minus-standard difference at Pz is +0.945 uV over -600..-400 ms and +0.750 uV over -200..0 ms, so a baseline taken there is subtracted from the effect one for one; candidates for the pre-stimulus difference are trial overlap at a 1.50 s stimulus-onset asynchrony and the symmetric smearing of a zero-phase high-pass (TODO(confirm)).
ANSWER KEY -- degenerate case: a baseline equal to the measurement window gives exactly +0.000 uV by construction.
Supporting: no baseline at all gives +4.282 uV (SD 1.005) against +3.533 uV (SD 2.912) with the default baseline -- on these already high-passed, average-referenced epochs the short baseline window adds across-subject variance rather than removing it.
Pitfall: pf-baseline-contamination.  Widget: w-epoch-builder.