Topographies and scalp maps: P3 map sequences under two references, map shape versus map amplitude, and where interpolation becomes extrapolation

nb-3-4-topomaps Level 3 · Event-Related Analysis ~6 min Used in L3.4 · Topographies and scalp maps

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-4-topomaps · Topographies and scalp maps (L3.4)

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

A topographic map is an interpolation between 30 numbers, drawn on a head, under a reference that decides what zero means. This notebook draws the P3 as a sequence of maps from 100 to 700 ms under two references — the average of the 30 EEG channels, and linked mastoids (the mean of P9 and P10) — and prints the peak channel and peak time under each. Then it shows what a reference change can and cannot do to a map (it shifts it by a constant; it never changes its shape) and separates map shape from map amplitude on five maps that genuinely differ, which is the distinction the L3.4 exercise turns on.

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.4 names N170 and P3. This notebook maps P3 only, for the same reason as nb-3-3: the N170 paradigm is a separate 58 MB-per-subject download and Phase 2's addendum makes P3 the Phase 2 paradigm.

Reference note (TODO(confirm)). The ERP CORE 30-channel montage has no mastoid electrodes. P9 and P10 are the nearest inferior posterior sites and stand in for them here, exactly as the w-reference-explorer assets do; a real linked-mastoid reference would use M1/M2. The substitution is stated wherever the number is printed.

In [1]:
# Setup: dependencies, the shared helpers, non-interactive plotting.
import importlib.util
import subprocess
import sys
import warnings
from pathlib import Path

# 1. Dependencies are pinned in notebooks/requirements.txt.  Nothing is installed when the
#    pinned stack is already present (local runs, CI); a fresh Colab or Binder kernel installs
#    it once.  On Colab, run from a clone of the repository so that notebooks/_shared/ is
#    available (repository URL: TODO(confirm), spec section 13 item 3).
_needed = ("mne", "scipy", "matplotlib", "pandas", "pooch")
_missing = [p for p in _needed if importlib.util.find_spec(p) is None]
if _missing:
    _req = next((d / "requirements.txt" for d in (Path.cwd(), *Path.cwd().parents)
                 if (d / "requirements.txt").exists()), None)
    _cmd = [sys.executable, "-m", "pip", "install", "-q"]
    _cmd += ["-r", str(_req)] if _req else ["mne==1.10.2", "pooch>=1.8"]
    subprocess.check_call(_cmd)

# 2. Shared helpers, located relative to the working directory -- notebooks/<level>/ or
#    notebooks/ -- never through an absolute path.
_shared = next((d / "_shared" for d in (Path.cwd(), *Path.cwd().parents)
                if (d / "_shared" / "helpers_l3.py").exists()), None)
if _shared is None:
    raise FileNotFoundError("start the kernel in notebooks/L3/ (or notebooks/) so that _shared/helpers_l3.py is found")
sys.path.insert(0, str(_shared))
import helpers
import helpers_l3 as L3

# 3. Plotting: Jupyter's default inline backend renders static PNGs through Agg (no windows,
#    nothing blocks); outside Jupyter the helpers select Agg.  Every MNE figure is requested
#    with show=False and each figure cell ends with plt.show().
import matplotlib.pyplot as plt
import numpy as np
import mne

mne.set_log_level("WARNING")
plt.rcParams["figure.dpi"] = 72
print(f"MNE {mne.__version__}; helpers_l3 imported from notebooks/_shared")
print(f"ERP CORE cache: {L3.erpcore_root().name}/ (resolved relative to the working directory, "
      "or $EEG_COURSE_ERPCORE); only the subjects this notebook names are fetched")
MNE 1.10.2; helpers_l3 imported from notebooks/_shared
ERP CORE cache: erpcore/ (resolved relative to the working directory, or $EEG_COURSE_ERPCORE); only the subjects this notebook names are fetched

1 · The pipeline, stated once

In [2]:
# The one Level-3 pipeline, printed rather than described.  Every Level-3 notebook and the C3
# capstone call the same helpers_l3.load_p3_epochs, so their numbers are comparable.
for key, value in L3.P3_PIPELINE.items():
    print(f"{key:15s} : {value}")
print()
print(f"a-priori measurement window : {L3.P3_WINDOW[0] * 1000:.0f}-{L3.P3_WINDOW[1] * 1000:.0f} ms "
      f"at {L3.P3_CHANNEL}, fixed in helpers_l3.P3_WINDOW")
dataset         : ds-erpcore, paradigm P3 (active visual oddball)
conditions      : target vs standard, read from the dataset's own code dictionary (task-P3_events.json): a stimulus code's first digit is the block's target letter and its second digit the letter shown, so equal digits = target, unequal digits = standard
channel_names   : FP1/FP2 renamed Fp1/Fp2 so MNE's standard_1005 montage matches; the three EOG channels (HEOG_left, HEOG_right, VEOG_lower) typed as EOG and excluded from every EEG average
montage         : standard_1005 (MNE), matched by name
bad_channels    : a channel is bad when its standard deviation over the whole recording exceeds 5x the median of the 30 EEG channels AND its largest absolute correlation with its four nearest neighbours is below 0.4 (both measured on a 1-40 Hz copy); bad channels are interpolated (spherical splines) before re-referencing.  Two conditions, because a channel dominated by blinks is large but still correlates with its neighbours.
reference       : average of the 30 EEG channels, applied after interpolation
filter          : FIR band-pass 0.1-40 Hz at the native 1024 Hz (MNE raw.filter defaults: firwin, Hamming, zero-phase, 'auto' transition bands)
ocular          : FastICA (n_components=15, random_state=20260918) fitted on a 1 Hz high-passed 128 Hz copy; components whose absolute correlation with any EOG channel reaches 0.5 are removed.  Level 2 owns this step (L2.6) and Level 3 does not re-teach it; it is here so that the frontal ocular artifact does not decide which trials survive.
epochs          : -200 to +800 ms around the stimulus event, baseline -200 to 0 ms (mean subtraction), no annotation-based rejection; trial metadata attached
resample        : 1024 -> 256 Hz after epoching (MNE epochs.resample, FFT-based)
rejection       : an epoch is rejected when the peak-to-peak amplitude over -200 to +800 ms exceeds 150 uV on any of the 30 EEG channels (label_source: algorithmic).  The criterion is always evaluated over that window, whatever window the epochs were cut to, so every Level-3 notebook rejects the same trials
measurement     : P3 = mean amplitude over 300-600 ms at Pz, target minus standard; the window is fixed a priori in helpers_l3.P3_WINDOW and is not moved after looking at the data (nb-1-5-filters used 300-500 ms on a different dataset; nb-3-3 shows what a collapsed-localizer window would give instead)

a-priori measurement window : 300-600 ms at Pz, fixed in helpers_l3.P3_WINDOW

2 · Re-referencing is arithmetic, not re-recording

Every EEG sample is a difference between an electrode and a reference. Changing the reference means subtracting a different number from every channel at every time point — nothing is measured again and no information is created. Because it is an affine operation on the channel vector, any reference can be derived from any other as long as all the channels are present:

  • average reference: x_avg[c] = x[c] − mean_over_channels(x)
  • linked mastoids (P9/P10 stand-in): x_LM[c] = x[c] − (x[P9] + x[P10]) / 2
  • and therefore x_LM[c] = x_avg[c] − (x_avg[P9] + x_avg[P10]) / 2, which is how this notebook derives it.

The subject loop below stores the average-referenced grand average once; the other references are one line of arithmetic each.

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

store = {}
for s in SUBJECTS:
    ep, nfo = L3.load_p3_epochs(s, verbose=False)
    store[s] = {"target": L3.condition_epochs(ep, "target").average(),
                "standard": L3.condition_epochs(ep, "standard").average(), "info": nfo}
times = ep.times
proto = store[SUBJECTS[0]]["target"].copy().pick("eeg")
eeg = proto.ch_names
print(f"{len(SUBJECTS)} subjects; {len(eeg)} EEG channels in the dataset's own order:")
print("  " + ", ".join(eeg))

grand = {}
for cond in ("target", "standard"):
    grand[cond] = np.mean([store[s][cond].copy().pick("eeg").data for s in SUBJECTS], axis=0)
grand["difference"] = grand["target"] - grand["standard"]

i9, i10, icz = eeg.index("P9"), eeg.index("P10"), eeg.index("Cz")
REFS = {
    "average of the 30 EEG channels": lambda X: X,
    "linked mastoids (mean of P9 and P10; stand-in, no mastoid electrode exists here)":
        lambda X: X - 0.5 * (X[[i9]] + X[[i10]]),
    "single electrode Cz": lambda X: X - X[[icz]],
}
print()
print("check that re-referencing conserves the differences between channels (it must, it is a subtraction):")
X = grand["difference"]
for name, fn in REFS.items():
    Y = fn(X)
    d_pz_cpz = np.abs((X[eeg.index("Pz")] - X[eeg.index("CPz")]) - (Y[eeg.index("Pz")] - Y[eeg.index("CPz")])).max()
    print(f"  {name[:46]:46s}: max |(Pz - CPz) change| = {d_pz_cpz:.3e} uV")
10 subjects; 30 EEG channels in the dataset's own order:
  Fp1, F3, F7, FC3, C3, C5, P3, P7, P9, PO7, PO3, O1, Oz, Pz, CPz, Fp2, Fz, F4, F8, FC4, FCz, Cz, C4, C6, P4, P8, P10, PO8, PO4, O2

check that re-referencing conserves the differences between channels (it must, it is a subtraction):
  average of the 30 EEG channels                : max |(Pz - CPz) change| = 0.000e+00 uV
  linked mastoids (mean of P9 and P10; stand-in,: max |(Pz - CPz) change| = 1.694e-21 uV
  single electrode Cz                           : max |(Pz - CPz) change| = 2.118e-22 uV

3 · Two references, two map sequences

The same grand-average difference wave, drawn every 100 ms from 100 to 700 ms, under the two references. Each row uses one colour scale across the whole row, so that the maps within a row are comparable in amplitude; the two rows do not share a scale, and that is the first thing the printout below quantifies.

In [4]:
TIMES = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]
TWO = ["average of the 30 EEG channels",
       "linked mastoids (mean of P9 and P10; stand-in, no mastoid electrode exists here)"]

fig, axes = plt.subplots(len(TWO), len(TIMES) + 1, figsize=(2.05 * len(TIMES) + 1.5, 2.55 * len(TWO)),
                         gridspec_kw={"width_ratios": [1] * len(TIMES) + [0.09]})
for row, name in enumerate(TWO):
    Y = REFS[name](grand["difference"]) * 1e6
    v = float(np.abs(Y[:, (times >= 0) & (times <= 0.8)]).max())
    im = None
    for col, t_ in enumerate(TIMES):
        i = int(np.argmin(np.abs(times - t_)))
        im, _ = mne.viz.plot_topomap(Y[:, i], proto.info, axes=axes[row, col], show=False,
                                     contours=4, vlim=(-v, v), sensors=True)
        if row == 0:
            axes[row, col].set_title(f"{times[i] * 1000:.0f} ms", fontsize=9)
    cb = fig.colorbar(im, cax=axes[row, -1])
    cb.set_label("uV")
    axes[row, 0].set_ylabel(name.split("(")[0].strip(), fontsize=8)
    axes[row, -1].set_title(f"+/-{v:.1f}", fontsize=8)
fig.suptitle("Grand-average P3 (target - standard) every 100 ms, two references -- "
             "one colour scale per row, in uV", y=1.0, fontsize=11)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
Figure 1 of notebook nb-3-4-topomaps, an output plot. The text around it states what it shows and the units of every axis.
In [5]:
print(f"Peak channel and peak time of the grand-average difference wave, 0-800 ms "
      f"({len(SUBJECTS)} subjects, uV)")
post = (times >= 0) & (times <= 0.8)
t_post = times[post]
peaks = {}
for name, fn in REFS.items():
    Y = fn(grand["difference"])[:, post] * 1e6
    i_pos, j_pos = np.unravel_index(int(Y.argmax()), Y.shape)
    i_abs, j_abs = np.unravel_index(int(np.abs(Y).argmax()), Y.shape)
    win = (times >= W[0]) & (times <= W[1])
    Yw = fn(grand["difference"])[:, win].mean(1) * 1e6
    peaks[name] = {"pos_ch": eeg[i_pos], "pos_t": float(t_post[j_pos]), "pos_v": float(Y[i_pos, j_pos]),
                   "abs_ch": eeg[i_abs], "abs_t": float(t_post[j_abs]), "abs_v": float(Y[i_abs, j_abs]),
                   "win_ch": eeg[int(Yw.argmax())], "win_v": float(Yw.max()),
                   "win_min_ch": eeg[int(Yw.argmin())], "win_min_v": float(Yw.min()),
                   "map": Yw}
    p = peaks[name]
    print(f"  {name}")
    print(f"      most positive sample : {p['pos_ch']:>4s} at {p['pos_t'] * 1000:6.1f} ms = {p['pos_v']:+7.3f} uV")
    print(f"      largest |amplitude|  : {p['abs_ch']:>4s} at {p['abs_t'] * 1000:6.1f} ms = {p['abs_v']:+7.3f} uV")
    print(f"      window mean {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms: peak channel {p['win_ch']:>4s} "
          f"{p['win_v']:+7.3f} uV, most negative {p['win_min_ch']:>4s} {p['win_min_v']:+7.3f} uV")
Peak channel and peak time of the grand-average difference wave, 0-800 ms (10 subjects, uV)
  average of the 30 EEG channels
      most positive sample :   Pz at  491.2 ms =  +4.292 uV
      largest |amplitude|  :  P10 at  409.2 ms =  -4.596 uV
      window mean 300-600 ms: peak channel   Pz  +3.571 uV, most negative  P10  -3.373 uV
  linked mastoids (mean of P9 and P10; stand-in, no mastoid electrode exists here)
      most positive sample :  CPz at  424.8 ms =  +8.045 uV
      largest |amplitude|  :  CPz at  424.8 ms =  +8.045 uV
      window mean 300-600 ms: peak channel   Pz  +6.707 uV, most negative  P10  -0.237 uV
  single electrode Cz
      most positive sample :   Pz at  483.4 ms =  +2.040 uV
      largest |amplitude|  :  P10 at  413.1 ms =  -7.514 uV
      window mean 300-600 ms: peak channel   Pz  +1.372 uV, most negative  P10  -5.573 uV

Under the average reference the largest number on the head in this window is negative, at an inferior posterior site, and it is there because an average reference forces the 30 channel values to sum to zero at every time point: a centro-parietal positivity must be paid for somewhere. Under the linked-mastoid stand-in the same data show a large centro-parietal positivity and almost nothing negative, because the reference sites now sit close to the negative end of the field and are subtracted away.

Neither map is wrong. They are the same measurement expressed against different zeros, and "where is the P3 largest" is only answerable once the reference is stated.

4 · What a reference change does to a map, exactly

Changing the reference subtracts, at each time point, the same number from every channel. On the map that is an additive constant: the surface moves up or down as a whole, so which channels come out positive and which negative changes, and the root-mean-square across channels changes — but the pattern does not. The across-channel correlation between two references is therefore exactly 1, and mean-centring both maps makes them identical to machine precision. That is also why the average reference is the canonical view of a map's shape: it is the mean-centred version, by construction.

In [6]:
def centre(v):
    return np.asarray(v, float) - np.mean(v)


maps = {name: peaks[name]["map"] for name in REFS}
names = list(maps)
print(f"Window-mean maps ({W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms) under three references, across 30 channels:")
print(f"{'':52s} {'RMS (uV)':>10s} {'r with avg ref':>15s} {'max |diff| mean-centred':>25s}")
for name in names:
    r = float(np.corrcoef(maps[name], maps[names[0]])[0, 1])
    d = float(np.abs(centre(maps[name]) - centre(maps[names[0]])).max())
    print(f"  {name[:50]:50s} {np.sqrt(np.mean(maps[name] ** 2)):10.3f} {r:15.3f} {d:25.2e}")
print()
print("A reference change cannot change the shape of a map, only where zero sits on it -- which is enough to "
      "move the most extreme value from one side of the head to the other, as section 3 printed.")

fig, axes = plt.subplots(2, len(names) + 1, figsize=(3.1 * len(names) + 1.4, 5.6),
                         gridspec_kw={"width_ratios": [1] * len(names) + [0.09]})
v_raw = max(np.abs(m).max() for m in maps.values())
for col, name in enumerate(names):
    im0, _ = mne.viz.plot_topomap(maps[name], proto.info, axes=axes[0, col], show=False, contours=4,
                                  vlim=(-v_raw, v_raw), sensors=True)
    axes[0, col].set_title(name.split("(")[0].strip()[:28], fontsize=8)
    im1, _ = mne.viz.plot_topomap(centre(maps[name]), proto.info, axes=axes[1, col], show=False,
                                  contours=4, vlim=(-v_raw, v_raw), sensors=True)
cb0 = fig.colorbar(im0, cax=axes[0, -1]); cb0.set_label("uV")
cb1 = fig.colorbar(im1, cax=axes[1, -1]); cb1.set_label("uV")
axes[0, 0].set_ylabel("as measured (uV)", fontsize=9)
axes[1, 0].set_ylabel("mean-centred across channels", fontsize=9)
fig.suptitle(f"P3 window mean {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms under three references: "
             "the bottom row is the same map three times", y=1.0, fontsize=11)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
Window-mean maps (300-600 ms) under three references, across 30 channels:
                                                       RMS (uV)  r with avg ref   max |diff| mean-centred
  average of the 30 EEG channels                          1.685           1.000                  0.00e+00
  linked mastoids (mean of P9 and P10; stand-in, no       3.560           1.000                  2.66e-15
  single electrode Cz                                     2.771           1.000                  1.78e-15

A reference change cannot change the shape of a map, only where zero sits on it -- which is enough to move the most extreme value from one side of the head to the other, as section 3 printed.
Figure 2 of notebook nb-3-4-topomaps, an output plot. The text around it states what it shows and the units of every axis.

Shape versus amplitude, where the difference is real

Reference changes cannot answer the L3.4 exercise, because they never change a map's shape. Maps that genuinely differ do, and this dataset supplies them: the P3's topography at two moments of its decay, the early sensory response at 100–200 ms in each condition, and the difference wave in an early window where there is almost nothing to see. The comparison is a correlation across the 30 channels (shape) beside the root-mean-square across channels (amplitude):

  • high r, different RMS → the same field, bigger or smaller: an amplitude difference;
  • low r → a different field: a shape difference, and therefore, at least in part, different generators.
In [7]:
def window_map(X, a, b):
    m = (times >= a) & (times <= b)
    return X[:, m].mean(1) * 1e6


CANDIDATES = {
    "A  difference 400-500 ms": window_map(grand["difference"], 0.40, 0.50),
    "B  difference 500-600 ms": window_map(grand["difference"], 0.50, 0.60),
    "C  difference 150-250 ms": window_map(grand["difference"], 0.15, 0.25),
    "D  target 100-200 ms": window_map(grand["target"], 0.10, 0.20),
    "E  standard 100-200 ms": window_map(grand["standard"], 0.10, 0.20),
}
keys = list(CANDIDATES)
print(f"Across-channel correlation (shape) and RMS (amplitude), average reference, {len(SUBJECTS)} subjects")
print(f"{'':26s}" + "".join(f"{k.split()[0]:>8s}" for k in keys) + f"{'RMS (uV)':>11s}")
corr = np.zeros((len(keys), len(keys)))
for i, a in enumerate(keys):
    for j, b in enumerate(keys):
        corr[i, j] = float(np.corrcoef(CANDIDATES[a], CANDIDATES[b])[0, 1])
    print(f"{a:26s}" + "".join(f"{corr[i, j]:8.3f}" for j in range(len(keys)))
          + f"{np.sqrt(np.mean(CANDIDATES[a] ** 2)):11.3f}")
print()
print(f"  A vs B: r = {corr[0, 1]:.3f}, RMS {np.sqrt(np.mean(CANDIDATES[keys[0]] ** 2)):.2f} against "
      f"{np.sqrt(np.mean(CANDIDATES[keys[1]] ** 2)):.2f} uV -- the same field, decaying: AMPLITUDE")
print(f"  A vs C: r = {corr[0, 2]:.3f} -- a different field: SHAPE")
print(f"  D vs E: r = {corr[3, 4]:.3f}, RMS {np.sqrt(np.mean(CANDIDATES[keys[3]] ** 2)):.2f} against "
      f"{np.sqrt(np.mean(CANDIDATES[keys[4]] ** 2)):.2f} uV -- the early sensory response the two conditions "
      f"share: neither differs")

fig, axes = plt.subplots(2, len(keys), figsize=(2.3 * len(keys), 5.2))
v_all = max(np.abs(m).max() for m in CANDIDATES.values())
for col, k in enumerate(keys):
    im0, _ = mne.viz.plot_topomap(CANDIDATES[k], proto.info, axes=axes[0, col], show=False, contours=4,
                                  vlim=(-v_all, v_all), sensors=True)
    axes[0, col].set_title(k, fontsize=8)
    im1, _ = mne.viz.plot_topomap(CANDIDATES[k] / np.sqrt(np.mean(CANDIDATES[k] ** 2)), proto.info,
                                  axes=axes[1, col], show=False, contours=4, vlim=(-2.5, 2.5), sensors=True)
axes[0, 0].set_ylabel("as measured (uV)", fontsize=9)
axes[1, 0].set_ylabel("normalised: shape only", fontsize=9)
fig.colorbar(im0, ax=axes[0, :], shrink=0.75, label="uV")
fig.colorbar(im1, ax=axes[1, :], shrink=0.75, label="normalised (RMS = 1)")
fig.suptitle("Five maps: which pairs differ only in amplitude, and which differ in shape?", y=1.0, fontsize=11)
plt.show()   # render the static figure(s) of this cell inline
Across-channel correlation (shape) and RMS (amplitude), average reference, 10 subjects
                                 A       B       C       D       E   RMS (uV)
A  difference 400-500 ms     1.000   0.992   0.312   0.717   0.649      1.939
B  difference 500-600 ms     0.992   1.000   0.292   0.653   0.577      1.716
C  difference 150-250 ms     0.312   0.292   1.000   0.533   0.359      0.401
D  target 100-200 ms         0.717   0.653   0.533   1.000   0.965      1.004
E  standard 100-200 ms       0.649   0.577   0.359   0.965   1.000      0.949

  A vs B: r = 0.992, RMS 1.94 against 1.72 uV -- the same field, decaying: AMPLITUDE
  A vs C: r = 0.312 -- a different field: SHAPE
  D vs E: r = 0.965, RMS 1.00 against 0.95 uV -- the early sensory response the two conditions share: neither differs
Figure 3 of notebook nb-3-4-topomaps, an output plot. The text around it states what it shows and the units of every axis.

5 · Interpolation, and where it stops being interpolation

The coloured surface between the electrodes is a spherical-spline interpolation of 30 values. Inside the convex hull of the electrodes it interpolates; outside it — the rim of the head, the area below the ears — it extrapolates, and the smooth colour there is an assumption, not a measurement. The panel below shows the same map with and without the sensors drawn, and with the interpolation restricted to the electrode hull, so the difference is visible rather than asserted.

In [8]:
m_avg = maps[names[0]]
fig, axes = plt.subplots(1, 3, figsize=(11, 3.4))
v = float(np.abs(m_avg).max())
for ax, (extrap, title) in zip(axes, [("head", "extrapolate to the head outline (MNE default for EEG)"),
                                      ("local", "extrapolate only near the sensors ('local')"),
                                      ("box", "extrapolate to a bounding box ('box')")]):
    im, _ = mne.viz.plot_topomap(m_avg, proto.info, axes=ax, show=False, contours=4, vlim=(-v, v),
                                 sensors=True, extrapolate=extrap)
    ax.set_title(title, fontsize=8)
cb = fig.colorbar(im, ax=axes, shrink=0.8)
cb.set_label("uV")
fig.suptitle(f"The same 30 numbers, three extrapolation rules "
             f"(P3 window mean, average reference, uV)", y=1.03, fontsize=10)
plt.show()   # render the static figure(s) of this cell inline

hull = [c for c in eeg]
print(f"the map is drawn from {len(hull)} electrode values; everything between them is interpolated and "
      f"everything outside their hull is extrapolated")
print(f"  the most extreme *measured* values in this window are "
      f"{eeg[int(m_avg.argmax())]} {m_avg.max():+.3f} uV and "
      f"{eeg[int(m_avg.argmin())]} {m_avg.min():+.3f} uV; any colour more extreme than that on the map "
      f"is the interpolator, not the data")
Figure 4 of notebook nb-3-4-topomaps, an output plot. The text around it states what it shows and the units of every axis.
the map is drawn from 30 electrode values; everything between them is interpolated and everything outside their hull is extrapolated
  the most extreme *measured* values in this window are Pz +3.571 uV and P10 -3.373 uV; any colour more extreme than that on the map is the interpolator, not the data

6 · The numbers

In [9]:
print("nb-3-4-topomaps -- L3.4 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); grand average of per-subject averages, "
      f"unweighted")
print(f"Maps: grand-average difference wave (target - standard), spherical-spline interpolation (MNE "
      f"plot_topomap), 30 channels, standard_1005 positions")
print()
for name in TWO:
    p = peaks[name]
    print(f"ANSWER KEY -- peak channel and time, reference = {name}:")
    print(f"    most positive sample over 0-800 ms : {p['pos_ch']} at {p['pos_t'] * 1000:.1f} ms, "
          f"{p['pos_v']:+.3f} uV")
    print(f"    largest |amplitude| over 0-800 ms  : {p['abs_ch']} at {p['abs_t'] * 1000:.1f} ms, "
          f"{p['abs_v']:+.3f} uV")
    print(f"    peak channel of the {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms window mean: {p['win_ch']} "
          f"({p['win_v']:+.3f} uV); most negative {p['win_min_ch']} ({p['win_min_v']:+.3f} uV)")
r_lm = float(np.corrcoef(maps[TWO[0]], maps[TWO[1]])[0, 1])
print()
print(f"ANSWER KEY -- what a reference change does: the three references give window-mean maps whose "
      f"across-channel correlation is r = {r_lm:.3f} (exactly 1 -- a reference change is an additive "
      f"constant across channels) but whose RMS amplitudes are "
      + ", ".join(f"{np.sqrt(np.mean(maps[n] ** 2)):.3f} uV ({n.split('(')[0].strip()})" for n in names)
      + ".  Mean-centring the maps makes them identical to machine precision, so a reference change moves "
        "the peak channel and the zero line but never the shape.")
print(f"ANSWER KEY -- ex-3-4 (which maps differ only in amplitude, which in shape), average reference, "
      f"{len(SUBJECTS)} subjects:")
for i, a in enumerate(keys):
    print(f"    {a:26s} RMS {np.sqrt(np.mean(CANDIDATES[a] ** 2)):6.3f} uV; r with A = {corr[0, i]:6.3f}")
print(f"    amplitude-only pair: A (difference 400-500 ms) and B (difference 500-600 ms), "
      f"r = {corr[0, 1]:.3f}, RMS {np.sqrt(np.mean(CANDIDATES[keys[0]] ** 2)):.3f} vs "
      f"{np.sqrt(np.mean(CANDIDATES[keys[1]] ** 2)):.3f} uV")
print(f"    shape pair: A and C (difference 150-250 ms), r = {corr[0, 2]:.3f}")
print(f"    D and E (the two conditions' early sensory maps) agree in both: r = {corr[3, 4]:.3f}, "
      f"RMS {np.sqrt(np.mean(CANDIDATES[keys[3]] ** 2)):.3f} vs "
      f"{np.sqrt(np.mean(CANDIDATES[keys[4]] ** 2)):.3f} uV")
print(f"Caveat printed with every linked-mastoid number: the ERP CORE 30-channel montage has no mastoid "
      f"electrode; P9 and P10 are the nearest inferior posterior sites and stand in for M1/M2 "
      f"(TODO(confirm)).")
print(f"Pitfall: pf-reference-changes-everything.  Widget: w-reference-explorer (mode topo).")
nb-3-4-topomaps -- L3.4 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); grand average of per-subject averages, unweighted
Maps: grand-average difference wave (target - standard), spherical-spline interpolation (MNE plot_topomap), 30 channels, standard_1005 positions

ANSWER KEY -- peak channel and time, reference = average of the 30 EEG channels:
    most positive sample over 0-800 ms : Pz at 491.2 ms, +4.292 uV
    largest |amplitude| over 0-800 ms  : P10 at 409.2 ms, -4.596 uV
    peak channel of the 300-600 ms window mean: Pz (+3.571 uV); most negative P10 (-3.373 uV)
ANSWER KEY -- peak channel and time, reference = linked mastoids (mean of P9 and P10; stand-in, no mastoid electrode exists here):
    most positive sample over 0-800 ms : CPz at 424.8 ms, +8.045 uV
    largest |amplitude| over 0-800 ms  : CPz at 424.8 ms, +8.045 uV
    peak channel of the 300-600 ms window mean: Pz (+6.707 uV); most negative P10 (-0.237 uV)

ANSWER KEY -- what a reference change does: the three references give window-mean maps whose across-channel correlation is r = 1.000 (exactly 1 -- a reference change is an additive constant across channels) but whose RMS amplitudes are 1.685 uV (average of the 30 EEG channels), 3.560 uV (linked mastoids), 2.771 uV (single electrode Cz).  Mean-centring the maps makes them identical to machine precision, so a reference change moves the peak channel and the zero line but never the shape.
ANSWER KEY -- ex-3-4 (which maps differ only in amplitude, which in shape), average reference, 10 subjects:
    A  difference 400-500 ms   RMS  1.939 uV; r with A =  1.000
    B  difference 500-600 ms   RMS  1.716 uV; r with A =  0.992
    C  difference 150-250 ms   RMS  0.401 uV; r with A =  0.312
    D  target 100-200 ms       RMS  1.004 uV; r with A =  0.717
    E  standard 100-200 ms     RMS  0.949 uV; r with A =  0.649
    amplitude-only pair: A (difference 400-500 ms) and B (difference 500-600 ms), r = 0.992, RMS 1.939 vs 1.716 uV
    shape pair: A and C (difference 150-250 ms), r = 0.312
    D and E (the two conditions' early sensory maps) agree in both: r = 0.965, RMS 1.004 vs 0.949 uV
Caveat printed with every linked-mastoid number: the ERP CORE 30-channel montage has no mastoid electrode; P9 and P10 are the nearest inferior posterior sites and stand in for M1/M2 (TODO(confirm)).
Pitfall: pf-reference-changes-everything.  Widget: w-reference-explorer (mode topo).