Single-trial approaches: an ERP image sorted by reaction time, single-trial P3 latency against reaction time, and a regression ERP

nb-3-6-erp-image Level 3 · Event-Related Analysis ~5 min Used in L3.6 · Single-trial approaches

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-6-erp-image · Single-trial approaches (L3.6)

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

An average throws away everything that differs between trials. This notebook puts the single trials back on the screen — as an ERP image sorted by reaction time — and then puts one trial-by-trial variable back into the model, as a regression ERP: a per-time-point regression of single-trial amplitude on reaction time, which gives a beta waveform in µV per millisecond of reaction time instead of one number per condition.

The question the L3.6 exercise asks is whether P3 latency tracks reaction time in one subject. The last cell prints that correlation with its p-value, for the detail subject and for every subject in the documented subset, so that one subject's answer can be read against the spread.

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.

Reaction time comes from the subject's own events.tsv: the first response event after a stimulus and before the next one (helpers_l3.trial_table). Trials with no response, and trials the artifact criterion rejected, are excluded and counted.

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]:
DETAIL = 1                       # the detail subject: the first of the documented subset, fixed before looking
SUBJECTS = list(L3.SUBSET_DEFAULT)
W = L3.P3_WINDOW
CH = L3.P3_CHANNEL
LAT_WINDOW = (0.20, 0.80)        # a-priori window for a SINGLE-TRIAL latency: wider than the measurement window,
                                 # because single trials scatter in latency far more than the average does
SMOOTH_HZ = 10.0                 # single trials are low-passed before any latency is read off them

epochs, info = L3.load_p3_epochs(DETAIL, verbose=True)
times = epochs.times
eeg = info["eeg_channels"]
i_ch = eeg.index(CH)
tgt = L3.condition_epochs(epochs, "target")
std = L3.condition_epochs(epochs, "standard")
x = tgt.get_data(picks="eeg")[:, i_ch, :] * 1e6           # target single trials at Pz, uV
rt = tgt.metadata["rt_ms"].to_numpy(float)
acc = tgt.metadata["correct"].to_numpy(float)

print()
print(f"{info['subject']}: {x.shape[0]} target trials kept at {CH} ({info['n_rejected']} of "
      f"{info['n_stimulus_events']} epochs rejected at {L3.REJECT_PTP_UV:g} uV peak-to-peak)")
print(f"  reaction time: {np.sum(np.isfinite(rt))} trials have one; median {np.nanmedian(rt):.0f} ms, "
      f"range {np.nanmin(rt):.0f}-{np.nanmax(rt):.0f} ms, IQR "
      f"{np.nanpercentile(rt, 25):.0f}-{np.nanpercentile(rt, 75):.0f} ms")
print(f"  accuracy on those trials: {100 * np.nanmean(acc):.1f} %")
cached: sub-001 (7 files, 64 MiB)
sub-001: 35 target trials kept at Pz (27 of 200 epochs rejected at 150 uV peak-to-peak)
  reaction time: 35 trials have one; median 570 ms, range 302-726 ms, IQR 513-629 ms
  accuracy on those trials: 94.3 %

2 · The ERP image

Every row is one trial, time runs left to right, colour is amplitude in microvolts. Unsorted, the image is a field of noise with a faint vertical band where the P3 is. Sorted by reaction time it becomes a diagonal if the component moves with the response, and stays vertical if it does not — and that is the whole diagnostic.

Two conventions worth stating because they change what you see: the rows are smoothed over a few neighbouring trials (a moving average along the sort variable, not along time), and the colour scale is symmetric and clipped at the 98th percentile of |amplitude| so that a handful of large trials do not flatten everything else.

In [4]:
ok = np.isfinite(rt)
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
L3.plot_erp_image(x[ok], times, title=f"{info['subject']} {CH}: target trials, unsorted (uV)", ax=axes[0])
L3.plot_erp_image(x[ok], times, sort_values=rt[ok], sort_label="reaction time", overlay=rt[ok] / 1000,
                  title=f"{info['subject']} {CH}: sorted by reaction time (uV)", ax=axes[1])
L3.plot_erp_image(x[ok], times, sort_values=rt[ok], sort_label="reaction time", smooth=5,
                  overlay=rt[ok] / 1000,
                  title=f"{info['subject']} {CH}: sorted by RT, 5-trial moving average (uV)", ax=axes[2])
for ax in axes:
    ax.axvspan(W[0] * 1000, W[1] * 1000, color="none", ec="k", lw=0.8, ls="--")
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline

fast = rt[ok] <= np.nanmedian(rt[ok])
fig, ax = plt.subplots(figsize=(9, 4.2))
L3.plot_erp({f"fast half (n = {int(fast.sum())}, median RT {np.median(rt[ok][fast]):.0f} ms)": x[ok][fast].mean(0),
             f"slow half (n = {int((~fast).sum())}, median RT {np.median(rt[ok][~fast]):.0f} ms)":
                 x[ok][~fast].mean(0),
             "all target trials": (x[ok].mean(0), {"color": "k", "lw": 1.8})},
            times, window=W, ax=ax,
            title=f"{info['subject']} {CH}: target average split at the median reaction time (uV)")
for half, c in ((fast, "tab:blue"), (~fast, "tab:orange")):
    ax.axvline(np.median(rt[ok][half]), color=c, lw=1.0, ls=":")
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
Figure 1 of notebook nb-3-6-erp-image, an output plot. The text around it states what it shows and the units of every axis.
Figure 2 of notebook nb-3-6-erp-image, an output plot. The text around it states what it shows and the units of every axis.

3 · A single-trial latency, and what it costs

To ask whether P3 latency tracks reaction time, each trial needs a latency. A peak latency on a single trial is almost pure noise (nb-3-5 bootstrapped that on the average; on one trial it is far worse), so the measure here is the 50 % fractional-area latency over a wide a-priori window on a 10 Hz low-passed copy of the trial — every sample in the window contributes, so one noisy sample cannot move it far.

Both decisions — the 200–800 ms window and the 10 Hz smoothing — are stated before the correlation is computed and are not tuned to it. The cell below also reports what happens without the smoothing, so the reader can see how much of the answer depends on that choice.

In [5]:
from scipy import signal, stats

sos = signal.butter(4, SMOOTH_HZ, btype="lowpass", fs=float(epochs.info["sfreq"]), output="sos")


def single_trial_latency(trials, smooth=True):
    y = signal.sosfiltfilt(sos, trials, axis=1) if smooth else trials
    return np.array([L3.fractional_area_latency(y[i], None, LAT_WINDOW, times=times) for i in range(y.shape[0])])


lat = single_trial_latency(x)
lat_raw = single_trial_latency(x, smooth=False)
amp = x[:, (times >= W[0]) & (times <= W[1])].mean(1)

good = np.isfinite(lat) & np.isfinite(rt)
r, p = stats.pearsonr(lat[good] * 1000, rt[good])
rho, p_rho = stats.spearmanr(lat[good] * 1000, rt[good])
r_raw, p_raw = stats.pearsonr(lat_raw[good] * 1000, rt[good])
r_amp, p_amp = stats.pearsonr(amp[good], rt[good])

print(f"{info['subject']}, {CH}, {int(good.sum())} target trials with a reaction time")
print(f"  single-trial 50% fractional-area latency over "
      f"{LAT_WINDOW[0] * 1000:.0f}-{LAT_WINDOW[1] * 1000:.0f} ms, trials low-passed at {SMOOTH_HZ:g} Hz:")
print(f"      median {1000 * np.nanmedian(lat[good]):.0f} ms, SD {1000 * np.nanstd(lat[good], ddof=1):.0f} ms")
print(f"  correlation with reaction time: Pearson r = {r:+.3f}, p = {p:.4f}; "
      f"Spearman rho = {rho:+.3f}, p = {p_rho:.4f}")
print(f"  without the {SMOOTH_HZ:g} Hz smoothing:  Pearson r = {r_raw:+.3f}, p = {p_raw:.4f}")
print(f"  single-trial mean AMPLITUDE against reaction time: r = {r_amp:+.3f}, p = {p_amp:.4f}")

fig, axes = plt.subplots(1, 2, figsize=(12, 4.4))
axes[0].plot(rt[good], lat[good] * 1000, "o", ms=5, alpha=0.75)
b1, b0 = np.polyfit(rt[good], lat[good] * 1000, 1)
xs = np.linspace(np.nanmin(rt[good]), np.nanmax(rt[good]), 10)
axes[0].plot(xs, b0 + b1 * xs, "k-", lw=1.4,
             label=f"slope {b1:+.3f} ms of latency per ms of RT\nr = {r:+.3f}, p = {p:.4f}")
axes[0].set(xlabel="Reaction time (ms)", ylabel=f"Single-trial 50% area latency at {CH} (ms)",
            title=f"{info['subject']}: does P3 latency track reaction time?")
axes[0].grid(alpha=0.3)
axes[0].legend(fontsize=8)
axes[1].plot(rt[good], amp[good], "o", ms=5, alpha=0.75, color="tab:orange")
b1a, b0a = np.polyfit(rt[good], amp[good], 1)
axes[1].plot(xs, b0a + b1a * xs, "k-", lw=1.4,
             label=f"slope {b1a:+.4f} uV per ms of RT\nr = {r_amp:+.3f}, p = {p_amp:.4f}")
axes[1].set(xlabel="Reaction time (ms)",
            ylabel=f"Single-trial mean amplitude at {CH}, "
                   f"{W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms (uV)",
            title=f"{info['subject']}: does P3 amplitude track reaction time?")
axes[1].grid(alpha=0.3)
axes[1].legend(fontsize=8)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
sub-001, Pz, 34 target trials with a reaction time
  single-trial 50% fractional-area latency over 200-800 ms, trials low-passed at 10 Hz:
      median 425 ms, SD 87 ms
  correlation with reaction time: Pearson r = +0.107, p = 0.5461; Spearman rho = +0.125, p = 0.4828
  without the 10 Hz smoothing:  Pearson r = +0.101, p = 0.5699
  single-trial mean AMPLITUDE against reaction time: r = +0.124, p = 0.4834
Figure 3 of notebook nb-3-6-erp-image, an output plot. The text around it states what it shows and the units of every axis.

4 · The regression ERP

A difference wave answers one question — how do two averages differ? — and can only handle variables that are categorical. A regression ERP answers the same question for a continuous variable, one time point at a time: fit amplitude(t) ~ 1 + condition + reaction_time across trials, and the estimated coefficients form waveforms. The intercept is the ERP of a reference trial; the reaction-time beta is microvolts per millisecond of reaction time, plotted against time like any other waveform.

mne.stats.linear_regression does this over every channel and time point at once and returns, for each regressor, an Evoked of betas plus t values and p values. Reaction time is mean-centred and expressed in units of 100 ms, so the beta reads as "µV per 100 ms of reaction time" rather than a number with four leading zeros.

In [6]:
import pandas as pd

all_ep = epochs[np.where(np.isfinite(epochs.metadata["rt_ms"].to_numpy(float)))[0]].copy().pick("eeg")
md_ = all_ep.metadata
rt_all = md_["rt_ms"].to_numpy(float)
is_target = (md_["condition"].to_numpy() == "target").astype(float)
rt_centred = (rt_all - rt_all.mean()) / 100.0        # units of 100 ms, mean-centred

design = pd.DataFrame({"intercept": np.ones(len(md_)),
                       "target": is_target - is_target.mean(),
                       "rt_100ms": rt_centred})
res = mne.stats.linear_regression(all_ep, design, names=list(design.columns))

print(f"regression ERP on {len(md_)} trials of {info['subject']} "
      f"(both conditions, only trials with a reaction time)")
print(f"  design: {list(design.columns)}; 'target' and 'rt_100ms' are mean-centred, so the intercept is the "
      f"average trial")
print(f"  correlation between the two centred regressors: "
      f"{np.corrcoef(design['target'], design['rt_100ms'])[0, 1]:+.3f} "
      f"(targets are responded to more slowly here, so the two are not independent -- which is exactly why "
      f"they belong in the same model)")

fig, axes = plt.subplots(1, 2, figsize=(13, 4.4))
for name, ax in (("target", axes[0]), ("rt_100ms", axes[1])):
    beta = res[name].beta.data[all_ep.ch_names.index(CH)] * 1e6
    tval = res[name].t_val.data[all_ep.ch_names.index(CH)]
    ax.plot(times * 1000, beta, "k", lw=1.6, label="beta")
    ax.axvspan(W[0] * 1000, W[1] * 1000, color="tab:orange", alpha=0.18, lw=0, label="measurement window")
    ax.axhline(0, color="gray", lw=0.6)
    ax.axvline(0, color="gray", lw=0.6)
    sig = np.abs(tval) > 2.0
    ax.fill_between(times * 1000, beta, 0, where=sig, color="tab:blue", alpha=0.3,
                    label="|t| > 2 (uncorrected, see nb-3-7)")
    unit = "uV (target minus standard)" if name == "target" else "uV per 100 ms of reaction time"
    ax.set(xlabel="Time from stimulus (ms)", ylabel=unit,
           title=f"{info['subject']} {CH}: regression ERP, beta for '{name}'")
    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 ("target", "rt_100ms"):
    b = res[name].beta.data[all_ep.ch_names.index(CH)] * 1e6
    t_ = res[name].t_val.data[all_ep.ch_names.index(CH)]
    m = (times >= W[0]) & (times <= W[1])
    j = int(np.argmax(np.abs(b[m])))
    unit = "uV" if name == "target" else "uV per 100 ms RT"
    print(f"  beta '{name}': window mean {b[m].mean():+.4f} {unit}; largest |beta| in the window "
          f"{b[m][j]:+.4f} {unit} at {1000 * times[m][j]:.0f} ms (t = {t_[m][j]:+.2f})")
regression ERP on 173 trials of sub-001 (both conditions, only trials with a reaction time)
  design: ['intercept', 'target', 'rt_100ms']; 'target' and 'rt_100ms' are mean-centred, so the intercept is the average trial
  correlation between the two centred regressors: +0.445 (targets are responded to more slowly here, so the two are not independent -- which is exactly why they belong in the same model)
Figure 4 of notebook nb-3-6-erp-image, an output plot. The text around it states what it shows and the units of every axis.
  beta 'target': window mean +3.3429 uV; largest |beta| in the window +4.9693 uV at 339 ms (t = +2.76)
  beta 'rt_100ms': window mean -0.5880 uV per 100 ms RT; largest |beta| in the window -1.4988 uV per 100 ms RT at 331 ms (t = -2.13)

The two beta waveforms answer different questions on the same trials. The target beta is the difference wave the earlier notebooks measured, recovered here as a regression coefficient — with reaction time held constant, which the difference wave does not do. The rt_100ms beta says how the waveform changes per 100 ms of reaction time within condition. If a slower response goes with a later P3 the beta is negative early in the component and positive late, because the component has slid to the right: that shape, not a single number, is the signature of a latency shift, and it is why a regression ERP is worth the extra machinery.

Deconvolution, in one paragraph. When events are close enough together that their responses overlap, no epoch is free of the neighbours' activity. Linear deconvolution generalises the regression above to a continuous design matrix over the whole recording, with one set of time-shifted predictors per event type, and estimates the responses that, summed at the real event times, best reproduce the continuous data. It is the same least-squares idea, applied before epoching rather than after. This paradigm's 1.5 s stimulus-onset asynchrony makes overlap small but not zero (nb-3-1 measured the pre-stimulus residue), and the tooling for it is outside this notebook — TODO(confirm) which package the course recommends.

5 · The same question in every subject

One subject is one subject. The cell below runs the identical measurement in each of the ten and prints the distribution, so the detail subject's answer can be read for what it is.

In [7]:
rows = []
for s in SUBJECTS:
    ep_s, nfo = L3.load_p3_epochs(s, verbose=False)
    t_s = L3.condition_epochs(ep_s, "target")
    xs = t_s.get_data(picks="eeg")[:, i_ch, :] * 1e6
    rts = t_s.metadata["rt_ms"].to_numpy(float)
    ls = single_trial_latency(xs)
    g = np.isfinite(ls) & np.isfinite(rts)
    if g.sum() < 10:
        rows.append((nfo["subject"], int(g.sum()), np.nan, np.nan, np.nan, np.nan))
        continue
    rr, pp = stats.pearsonr(ls[g] * 1000, rts[g])
    ra, pa = stats.pearsonr(xs[:, (times >= W[0]) & (times <= W[1])].mean(1)[g], rts[g])
    rows.append((nfo["subject"], int(g.sum()), rr, pp, ra, pa))

print(f"single-trial P3 latency and amplitude against reaction time, {CH}, per subject")
print(f"{'subject':9s} {'n':>4s} {'r(latency, RT)':>15s} {'p':>9s} {'r(amplitude, RT)':>17s} {'p':>9s}")
for r_ in rows:
    print(f"{r_[0]:9s} {r_[1]:4d} {r_[2]:15.3f} {r_[3]:9.4f} {r_[4]:17.3f} {r_[5]:9.4f}")
rs = np.array([r_[2] for r_ in rows], float)
ps = np.array([r_[3] for r_ in rows], float)
t_group, p_group = stats.ttest_1samp(rs[np.isfinite(rs)], 0)
print(f"\nacross the {len(SUBJECTS)} subjects: mean r = {np.nanmean(rs):+.3f} "
      f"(SD {np.nanstd(rs, ddof=1):.3f}), "
      f"{int(np.sum(ps < 0.05))} subjects reach p < 0.05 individually "
      f"({int(np.sum((ps < 0.05) & (rs > 0)))} positive, {int(np.sum((ps < 0.05) & (rs < 0)))} negative)")
print(f"  one-sample t test on the {len(rs[np.isfinite(rs)])} correlations: t = {t_group:.3f}, "
      f"p = {p_group:.4f}")
single-trial P3 latency and amplitude against reaction time, Pz, per subject
subject      n  r(latency, RT)         p  r(amplitude, RT)         p
sub-001     34           0.107    0.5461             0.124    0.4834
sub-002     40           0.465    0.0025            -0.079    0.6278
sub-003     36           0.712    0.0000            -0.029    0.8676
sub-004     40          -0.326    0.0402             0.202    0.2116
sub-005     36           0.055    0.7522            -0.358    0.0319
sub-006     26          -0.052    0.8015             0.066    0.7499
sub-007     39           0.224    0.1711             0.151    0.3604
sub-008     36          -0.250    0.1412             0.613    0.0001
sub-009     36           0.664    0.0000            -0.150    0.3826
sub-010     38          -0.202    0.2230            -0.015    0.9290

across the 10 subjects: mean r = +0.140 (SD 0.372), 4 subjects reach p < 0.05 individually (3 positive, 1 negative)
  one-sample t test on the 10 correlations: t = 1.186, p = 0.2658

6 · The numbers

In [8]:
print("nb-3-6-erp-image -- L3.6 exercise numbers (draft; TODO(confirm) at author review)")
print(f"Data: ds-erpcore P3; detail subject {info['subject']} (the first of the documented subset, fixed "
      f"before looking at any result); group table over sub-001 to sub-{SUBJECTS[-1]:03d} "
      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"Single-trial latency: 50% fractional-area latency over "
      f"{LAT_WINDOW[0] * 1000:.0f}-{LAT_WINDOW[1] * 1000:.0f} ms at {CH}, trials low-passed at "
      f"{SMOOTH_HZ:g} Hz; both choices fixed before the correlation was computed")
print(f"Reaction time: first response event after the stimulus and before the next one, from the subject's "
      f"own events.tsv")
print()
print(f"ANSWER KEY -- ex-3-6 (numeric): in {info['subject']}, over {int(good.sum())} target trials, "
      f"single-trial P3 latency correlates with reaction time at")
print(f"    Pearson r = {r:+.3f}, p = {p:.4f}   (Spearman rho = {rho:+.3f}, p = {p_rho:.4f})")
print(f"    slope {b1:+.3f} ms of P3 latency per ms of reaction time")
verdict = ("does NOT track" if p >= 0.05 else ("tracks" if r > 0 else "tracks INVERSELY"))
print(f"ANSWER KEY -- ex-3-6 (free response): in this subject P3 latency {verdict} reaction time "
      f"(p = {p:.4f} against alpha = 0.05). Single-trial amplitude against reaction time in the same "
      f"subject: r = {r_amp:+.3f}, p = {p_amp:.4f}.")
print(f"    The group table is the context the single-subject answer needs: mean r = {np.nanmean(rs):+.3f} "
      f"across {len(SUBJECTS)} subjects, {int(np.sum(ps < 0.05))} of them individually significant, "
      f"one-sample t({len(rs[np.isfinite(rs)]) - 1}) = {t_group:.3f}, p = {p_group:.4f}. A single subject's "
      f"correlation on ~{int(good.sum())} trials has a wide confidence interval; the honest answer names the "
      f"trial count.")
print(f"    Without the {SMOOTH_HZ:g} Hz single-trial smoothing the same measurement gives r = {r_raw:+.3f}, "
      f"p = {p_raw:.4f}, so the conclusion "
      f"{'does not depend' if (p < 0.05) == (p_raw < 0.05) else 'DOES depend'} on that choice.")
print()
print(f"Supporting -- regression ERP ({CH}, {len(md_)} trials, both conditions, reaction time mean-centred "
      f"in units of 100 ms):")
for name in ("target", "rt_100ms"):
    b = res[name].beta.data[all_ep.ch_names.index(CH)] * 1e6
    mm = (times >= W[0]) & (times <= W[1])
    unit = "uV" if name == "target" else "uV per 100 ms of reaction time"
    print(f"    beta '{name}' window mean {W[0] * 1000:.0f}-{W[1] * 1000:.0f} ms = {b[mm].mean():+.4f} {unit}")
print(f"Pitfalls: none named for L3.6 in spec section 6.  Widget: none.")
nb-3-6-erp-image -- L3.6 exercise numbers (draft; TODO(confirm) at author review)
Data: ds-erpcore P3; detail subject sub-001 (the first of the documented subset, fixed before looking at any result); group table over sub-001 to sub-010 (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)
Single-trial latency: 50% fractional-area latency over 200-800 ms at Pz, trials low-passed at 10 Hz; both choices fixed before the correlation was computed
Reaction time: first response event after the stimulus and before the next one, from the subject's own events.tsv

ANSWER KEY -- ex-3-6 (numeric): in sub-001, over 34 target trials, single-trial P3 latency correlates with reaction time at
    Pearson r = +0.107, p = 0.5461   (Spearman rho = +0.125, p = 0.4828)
    slope +0.105 ms of P3 latency per ms of reaction time
ANSWER KEY -- ex-3-6 (free response): in this subject P3 latency does NOT track reaction time (p = 0.5461 against alpha = 0.05). Single-trial amplitude against reaction time in the same subject: r = +0.124, p = 0.4834.
    The group table is the context the single-subject answer needs: mean r = +0.140 across 10 subjects, 4 of them individually significant, one-sample t(9) = 1.186, p = 0.2658. A single subject's correlation on ~34 trials has a wide confidence interval; the honest answer names the trial count.
    Without the 10 Hz single-trial smoothing the same measurement gives r = +0.101, p = 0.5699, so the conclusion does not depend on that choice.

Supporting -- regression ERP (Pz, 173 trials, both conditions, reaction time mean-centred in units of 100 ms):
    beta 'target' window mean 300-600 ms = +3.3429 uV
    beta 'rt_100ms' window mean 300-600 ms = -0.5880 uV per 100 ms of reaction time
Pitfalls: none named for L3.6 in spec section 6.  Widget: none.