Decoding as inference: time-resolved decoding of the P3 with a permutation test, chance and its variance, temporal generalization, and leakage measured three ways

nb-6-5-decoding Level 6 · Inference and Rigor ~12 min Used in L6.5 · Decoding as inference

Downloads from ds-erpcore, ds-eegbci 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-6-5-decoding · Decoding as inference (L6.5)

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

A decoder is a statistic, not a result. "The classifier reached 87 %" answers no scientific question until three more are answered: above what, estimated how, and what does it license.

  1. Time-resolved decoding of the ERP CORE P3 with SlidingEstimator: one classifier per time point, cross-validated inside each subject, then a group-level permutation test on the resulting curves.
  2. Chance and its variance. Chance is not "50 %" — it is a distribution, and with 1:4 class imbalance the naive accuracy of a classifier that always answers "standard" is 80 %.
  3. Temporal generalization: train at one moment, test at every other, and read what the matrix's shape says about whether the representation is stable or moving.
  4. Leakage, measured three ways on real data — information arriving before the stimulus, channel selection made outside the cross-validation loop, and a spatial filter fitted outside it. The third reproduces the L5.7 key exactly, and the gap it opens is the number this lesson exists for.

Data.

  • ds-erpcore — ERP CORE, Kappenman, Farrens, Zhang, Stewart & Luck (2020), PsyArXiv DOI 10.31234/osf.io/4azqm; dataset DOI 10.18112/openneuro.ds003069.v1.0.0. Paradigm P3. Licence CC BY-SA 4.0, contested at source (shipped LICENSE CC BY-SA 4.0, BIDS dataset_description.json CC0, OSF node thsqg CC BY 4.0; §10.7 makes the most restrictive reading govern).
  • ds-eegbci — EEG Motor Movement/Imagery Dataset (EEGMMIDB), Schalk, McFarland, Hinterberger, Birbaumer & Wolpaw (2004), BCI2000, IEEE TBME 51(6):1034–1043, DOI 10.1109/TBME.2004.827072; PhysioNet v1.0.0, DOI 10.13026/C28G6P. Licence ODC-By 1.0, access: open. Runs R04, R08 and R12 — imagine opening and closing the left or the right fist.

A disclosure that is part of every number in section 5. The ds-eegbci subject used there was selected by accuracy in Level 5: the rule was "the lowest-numbered subject whose mean cross-validated accuracy reaches 0.75". Section 5 therefore re-runs the identical pipeline on every candidate and prints the whole distribution, because quoting the selected subject's score alone would teach exactly the selection effect this level exists to warn about.

No published values are quoted. Every comparison with a paper's own numbers is a literal TODO(confirm).

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

_needed = ("mne", "scipy", "matplotlib", "pandas", "pooch", "sklearn")
_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", "scikit-learn"]
    subprocess.check_call(_cmd)

_shared = next((d / "_shared" for d in (Path.cwd(), *Path.cwd().parents)
                if (d / "_shared" / "helpers_l6.py").exists()), None)
if _shared is None:
    raise FileNotFoundError("start the kernel in notebooks/L6/ (or notebooks/) so that _shared/helpers_l6.py is found")
sys.path.insert(0, str(_shared))
import helpers
import helpers_l3 as L3
import helpers_l6 as L6

import matplotlib.pyplot as plt
import numpy as np
import mne
from scipy import stats
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import roc_auc_score

mne.set_log_level("WARNING")
plt.rcParams["figure.dpi"] = 72

# Iteration counts.  Time-resolved decoding is linear in the number of time points and the number of
# folds; the group permutation test is linear in its permutation count.  Both are budgeted here.
FULL_RUN = False
N_SUBJECTS = 10 if not FULL_RUN else 20           # ds-erpcore subjects for the time-resolved analysis
DECIM = 2 if not FULL_RUN else 1                  # 256 Hz -> 128 Hz for the sliding estimator
DECIM_GEN = 4 if not FULL_RUN else 2              # 256 Hz -> 64 Hz for the generalization matrix
N_PERM = 10000 if not FULL_RUN else 50000         # group-level cluster permutation
N_PERM_WITHIN = 200 if not FULL_RUN else 2000     # label permutations inside one subject (section 3)
CSP_CANDIDATES = [f"S{i:03d}" for i in range(1, 13)]
FOLDS = 5
SEED = L6.SEED
ALPHA = L6.ALPHA

print(f"MNE {mne.__version__}; helpers_l6 imported from notebooks/_shared")
print(f"FULL_RUN = {FULL_RUN}: {N_SUBJECTS} ERP CORE subjects, decimation {DECIM} for the sliding "
      f"estimator and {DECIM_GEN} for the generalization matrix, {N_PERM} group permutations, "
      f"{N_PERM_WITHIN} within-subject label permutations, {len(CSP_CANDIDATES)} ds-eegbci candidates")
print(f"ERP CORE cache: {L3.erpcore_root().name}/; each subject's EEGLAB pair is deleted as soon as its "
      f"epochs have been measured")
MNE 1.10.2; helpers_l6 imported from notebooks/_shared
FULL_RUN = False: 10 ERP CORE subjects, decimation 2 for the sliding estimator and 4 for the generalization matrix, 10000 group permutations, 200 within-subject label permutations, 12 ds-eegbci candidates
ERP CORE cache: erpcore/; each subject's EEGLAB pair is deleted as soon as its epochs have been measured

1 · Time-resolved decoding

One classifier per time point, trained on the 30 scalp channels of that moment and asked to tell a target trial from a standard one. Cross-validation is within subject — the fold boundary never crosses a person — and the score is ROC AUC, not accuracy, because the design is 1:4 imbalanced and accuracy would reward a classifier that always answered "standard".

The pipeline is the same pre-specified one every Level 6 notebook uses, so this analysis and the ERP in nb-6-1-corrections are looking at the same numbers.

In [2]:
SUBJECTS = list(L6.SUBSET_DEFAULT)[:N_SUBJECTS]
L6.disk_report("before any download")
t0 = time.time()
data = {}
for s in SUBJECTS:
    sid = L3.erpcore_subject_id(s)
    t1 = time.time()
    try:
        cms, cond, tt, rt, acc, code = L6._subject_epochs(s, L6.HIGHPASS[L6.PRESPECIFIED["highpass"]],
                                                          verbose=False)
    finally:
        L3.delete_erpcore_subject(s, "P3", keep_small=True, verbose=False)
    ref = L6.rereference(cms, L6.PRESPECIFIED["reference"])
    b0, b1 = L6.BASELINE[L6.PRESPECIFIED["baseline"]]
    ref = ref - ref[:, :, (tt >= b0) & (tt <= b1)].mean(axis=2, keepdims=True)
    keep = np.ptp(ref, axis=2).max(axis=1) < L6.REJECT_UV[L6.PRESPECIFIED["rejection"]]
    y = (cond == "target").astype(int)[keep]
    if min(int(y.sum()), int((1 - y).sum())) < int(L6.FIXED_CHOICES["min_trials_per_condition"]):
        print(f"  {sid}: EXCLUDED, {int(y.sum())} target / {int((1 - y).sum())} standard trials survive")
        continue
    data[sid] = {"X": ref[keep][:, :, ::DECIM].astype(np.float64), "y": y}
    print(f"  {sid}: {data[sid]['X'].shape[0]} trials ({int(y.sum())} target), "
          f"{data[sid]['X'].shape[2]} time points in {time.time() - t1:.1f} s", flush=True)
times = tt[::DECIM]
print(f"{len(data)} subjects in {time.time() - t0:.0f} s; "
      f"sampling {1 / np.diff(times).mean():.0f} Hz after decimation by {DECIM}")
L6.disk_report("after the download loop")
free disk before any download: 4.29 GB  (ERP CORE cache 0.0 MB, Level-6 products 0.0 MB)
  sub-001: 161 trials (31 target), 129 time points in 150.5 s
  sub-002: 197 trials (40 target), 129 time points in 27.4 s
  sub-003: 192 trials (37 target), 129 time points in 28.5 s
  sub-004: 197 trials (40 target), 129 time points in 23.0 s
  sub-005: 185 trials (37 target), 129 time points in 23.5 s
  sub-006: 99 trials (18 target), 129 time points in 18.0 s
  sub-007: 200 trials (40 target), 129 time points in 19.0 s
  sub-008: 182 trials (38 target), 129 time points in 15.5 s
  sub-009: EXCLUDED, 1 target / 4 standard trials survive
  sub-010: 180 trials (35 target), 129 time points in 16.6 s
9 subjects in 340 s; sampling 128 Hz after decimation by 2
free disk after the download loop: 4.23 GB  (ERP CORE cache 0.2 MB, Level-6 products 0.0 MB)
Out[2]:
{'free_gb': 4.230090752,
 'folders_mb': {'ERP CORE cache': 0.218447, 'Level-6 products': 0.0}}
In [3]:
sids = sorted(data)
t0 = time.time()
auc = {}
for s in sids:
    sc, desc = L6.sliding_auc(data[s]["X"], data[s]["y"], folds=FOLDS, seed=SEED)
    auc[s] = sc.mean(0)                                # mean over folds, one curve per subject
print(f"time-resolved decoding of {len(sids)} subjects x {len(times)} time points x {FOLDS} folds "
      f"in {time.time() - t0:.0f} s")
print(f"estimator: {desc}")
A = np.stack([auc[s] for s in sids])                   # subjects x times
peak_i = int(np.argmax(A.mean(0)))
print(f"\ngroup mean AUC: peak {A.mean(0)[peak_i]:.4f} at {times[peak_i] * 1000:.0f} ms")
print(f"{'subject':9s} {'peak AUC':>9s} {'at (ms)':>8s} {'AUC at the group peak':>22s} {'pre-stimulus max':>18s}")
pre = times < 0
for s in sids:
    i = int(np.argmax(auc[s]))
    print(f"{s:9s} {auc[s][i]:9.4f} {times[i] * 1000:8.0f} {auc[s][peak_i]:22.4f} "
          f"{auc[s][pre].max():18.4f}")
time-resolved decoding of 9 subjects x 129 time points x 5 folds in 8 s
estimator: SlidingEstimator(make_pipeline(StandardScaler(), LogisticRegression(class_weight='balanced')), scoring='roc_auc'), StratifiedKFold(5, shuffle=True, random_state=20260918)

group mean AUC: peak 0.6769 at 387 ms
subject    peak AUC  at (ms)  AUC at the group peak   pre-stimulus max
sub-001      0.6723      -59                 0.5886             0.6723
sub-002      0.8870      434                 0.8040             0.7241
sub-003      0.7487      379                 0.7281             0.6843
sub-004      0.8651      480                 0.7201             0.6163
sub-005      0.7146      418                 0.6643             0.6400
sub-006      0.6788       -4                 0.6525             0.6788
sub-007      0.8508      223                 0.7484             0.5969
sub-008      0.6910      402                 0.5796             0.6315
sub-010      0.7222      520                 0.6069             0.5547

2 · The group test

Each subject contributes one curve; the group question is where those curves sit above 0.5. That is 128 tests along the time axis, so it is the same multiple-comparison problem nb-6-1-corrections is about, and it gets the same treatment: a cluster-based permutation test on AUC − 0.5, one-sided, because a decoder that is reliably below chance is a bug rather than a finding.

In [4]:
t0 = time.time()
thr = float(stats.t.ppf(1 - ALPHA, len(sids) - 1))          # one-sided cluster-forming threshold
t_obs, clusters, cluster_p, H0 = mne.stats.permutation_cluster_1samp_test(
    A - 0.5, threshold=thr, n_permutations=N_PERM, tail=1, out_type="indices", seed=SEED, verbose=False)
mask = np.zeros(len(times), bool)
print(f"mne.stats.permutation_cluster_1samp_test(AUC - 0.5, threshold={thr:.4f}, "
      f"n_permutations={N_PERM}, tail=1, seed={SEED})")
print(f"permutations used {len(H0)} (2^{len(sids)} = {2 ** len(sids):,} sign flips exist); smallest "
      f"attainable p = {1 / len(H0):.5f}; computed in {time.time() - t0:.1f} s\n")
print(f"{'#':>2s} {'range (ms)':>20s} {'samples':>8s} {'t-sum':>9s} {'p':>8s} {'peak AUC in it':>15s}")
sig_clusters = []
for rank, i in enumerate(np.argsort(cluster_p)):
    idx = np.asarray(clusters[i][0])
    ok = cluster_p[i] <= ALPHA
    if ok:
        mask[idx] = True
        sig_clusters.append({"t_start_ms": float(times[idx[0]] * 1000),
                             "t_end_ms": float(times[idx[-1]] * 1000),
                             "p": float(cluster_p[i]), "n": int(idx.size)})
    if ok or rank < 3:
        print(f"{rank:2d} {times[idx[0]] * 1000:8.0f} to {times[idx[-1]] * 1000:8.0f} {idx.size:8d} "
              f"{t_obs[idx].sum():+9.1f} {cluster_p[i]:8.4f} {A.mean(0)[idx].max():15.4f}"
              + ("   <- significant" if ok else ""))
print(f"\n{len(sig_clusters)} cluster(s) survive at alpha = {ALPHA}")
print(f"ex-6-5 ANSWER: the group peak is at {times[peak_i] * 1000:.0f} ms with AUC "
      f"{A.mean(0)[peak_i]:.4f}, and it "
      f"{'IS' if mask[peak_i] else 'is NOT'} inside a significant cluster.")
mne.stats.permutation_cluster_1samp_test(AUC - 0.5, threshold=1.8595, n_permutations=10000, tail=1, seed=20260918)
permutations used 512 (2^9 = 512 sign flips exist); smallest attainable p = 0.00195; computed in 0.1 s

 #           range (ms)  samples     t-sum        p  peak AUC in it
 0      332 to      629       39    +145.9   0.0039          0.6769   <- significant
 1      645 to      738       13     +41.6   0.0332          0.6161   <- significant
 2      270 to      301        5     +12.2   0.1211          0.5798

2 cluster(s) survive at alpha = 0.05
ex-6-5 ANSWER: the group peak is at 387 ms with AUC 0.6769, and it IS inside a significant cluster.
In [5]:
fig, ax = L6.plot_decoding_curve(
    times, A, chance=0.5, mask=mask,
    title=f"ERP CORE P3, target vs standard: time-resolved decoding, {len(sids)} subjects (ROC AUC)")
for s in sids:
    ax.plot(times * 1000, auc[s], lw=0.6, alpha=0.45, color="0.5", zorder=0)
ax.plot([], [], lw=0.6, color="0.5", label="individual subjects")
ax.legend(fontsize=8, loc="upper left")
plt.show()   # render the static figure(s) of this cell inline
Figure 1 of notebook nb-6-5-decoding, an output plot. The text around it states what it shows and the units of every axis.

3 · Chance is a distribution

"Above chance" needs a chance level and a spread. Three different numbers are often called "chance" and only one of them is the right yardstick here.

In [6]:
n_tr = int(np.median([len(data[s]["y"]) for s in sids]))
n_tg = int(np.median([int(data[s]["y"].sum()) for s in sids]))
maj = 1 - n_tg / n_tr
lo, hi = L6.chance_band(n_tr, 0.5)
print(f"On a median subject: {n_tr} trials, {n_tg} target and {n_tr - n_tg} standard.")
print(f"   1. 'accuracy of the majority class'  {maj:.3f}  <- what a classifier that always answers "
      f"'standard' scores.  This is why accuracy is the wrong metric for a 1:4 design.")
print(f"   2. 'AUC of a coin flip'              0.500  <- the right centre for AUC, which is invariant to "
      f"the class ratio.")
print(f"   3. the SPREAD of chance              binomial 95 % interval on {n_tr} balanced trials runs "
      f"{lo:.3f} to {hi:.3f}; a single subject inside that band is not evidence of anything.")
print()
print(f"But the analytic band above assumes independent trials and a balanced split, and cross-validated AUC "
      f"on {FOLDS} folds is neither.  The honest yardstick is the EMPIRICAL null: shuffle the labels inside "
      f"the subject and rerun the same cross-validation.")

t0 = time.time()
s_demo = sids[int(np.argsort([auc[s].max() for s in sids])[len(sids) // 2])]     # the median subject
Xd, yd = data[s_demo]["X"], data[s_demo]["y"]
i_win = (times >= 0.30) & (times <= 0.60)
Xw = Xd[:, :, i_win].reshape(len(yd), -1)
clf = make_pipeline(StandardScaler(), LogisticRegression(class_weight="balanced", max_iter=2000,
                                                         solver="liblinear"))
cv = StratifiedKFold(n_splits=FOLDS, shuffle=True, random_state=SEED)
with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    obs = float(cross_val_score(clf, Xw, yd, cv=cv, scoring="roc_auc").mean())
    rng = np.random.default_rng(SEED)
    null = np.array([cross_val_score(clf, Xw, rng.permutation(yd), cv=cv, scoring="roc_auc").mean()
                     for _ in range(N_PERM_WITHIN)])
p_emp = (1 + int((null >= obs).sum())) / (1 + len(null))
print(f"\n{s_demo}, 300-600 ms window, {Xw.shape[1]} features ({Xd.shape[1]} channels x "
      f"{int(i_win.sum())} samples):")
print(f"   observed cross-validated AUC {obs:.4f}")
print(f"   empirical null over {N_PERM_WITHIN} label shuffles: mean {null.mean():.4f}, SD {null.std(ddof=1):.4f}, "
      f"2.5-97.5 % [{np.percentile(null, 2.5):.4f}, {np.percentile(null, 97.5):.4f}]")
print(f"   p = (1 + #{{null >= observed}}) / (1 + {N_PERM_WITHIN}) = {p_emp:.4f}")
print(f"   the null's centre is {null.mean():.4f}, not exactly 0.500 -- with {len(yd)} trials and {FOLDS} "
      f"folds the estimator has a small bias, which is precisely why the permutation null is worth having")
print(f"   ({time.time() - t0:.0f} s; a full run would use {2000} shuffles, "
      f"about {(time.time() - t0) * 2000 / N_PERM_WITHIN:.0f} s)")
On a median subject: 185 trials, 37 target and 148 standard.
   1. 'accuracy of the majority class'  0.800  <- what a classifier that always answers 'standard' scores.  This is why accuracy is the wrong metric for a 1:4 design.
   2. 'AUC of a coin flip'              0.500  <- the right centre for AUC, which is invariant to the class ratio.
   3. the SPREAD of chance              binomial 95 % interval on 185 balanced trials runs 0.427 to 0.573; a single subject inside that band is not evidence of anything.

But the analytic band above assumes independent trials and a balanced split, and cross-validated AUC on 5 folds is neither.  The honest yardstick is the EMPIRICAL null: shuffle the labels inside the subject and rerun the same cross-validation.
sub-010, 300-600 ms window, 1170 features (30 channels x 39 samples):
   observed cross-validated AUC 0.6197
   empirical null over 200 label shuffles: mean 0.4941, SD 0.0730, 2.5-97.5 % [0.3524, 0.6259]
   p = (1 + #{null >= observed}) / (1 + 200) = 0.0348
   the null's centre is 0.4941, not exactly 0.500 -- with 180 trials and 5 folds the estimator has a small bias, which is precisely why the permutation null is worth having
   (25 s; a full run would use 2000 shuffles, about 252 s)
In [7]:
fig, ax = plt.subplots(figsize=(8.6, 4.0))
ax.hist(null, bins=40, color="tab:blue", alpha=0.8, label=f"{N_PERM_WITHIN} label shuffles")
ax.axvline(0.5, color="gray", lw=1.2, ls="--", label="0.5 (nominal chance)")
ax.axvline(null.mean(), color="k", lw=1.2, ls=":", label=f"null mean {null.mean():.3f}")
ax.axvline(obs, color="tab:orange", lw=2.2, label=f"observed {obs:.3f} (p = {p_emp:.4f})")
ax.set(xlabel="Cross-validated ROC AUC (dimensionless)", ylabel="Shuffles",
       title=f"The empirical null for one subject ({s_demo}), 300–600 ms window")
ax.grid(alpha=0.3)
ax.legend(fontsize=8)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
Figure 2 of notebook nb-6-5-decoding, an output plot. The text around it states what it shows and the units of every axis.

4 · Temporal generalization

Train at one moment, test at every other. The shape of the resulting matrix is the claim:

  • a diagonal band means the pattern the classifier uses changes from moment to moment — a sequence of different states;
  • a square block means one pattern persists — a single state maintained over time;
  • off-diagonal structure far from the diagonal means a pattern returns.

This is the most expensive cell in the notebook: it is one classifier per (train, test) pair, so its cost grows as the square of the number of time points. The time axis is decimated further here, and FULL_RUN says by how much less.

In [8]:
t0 = time.time()
Xg = data[s_demo]["X"][:, :, ::(DECIM_GEN // DECIM)]
tg = times[::(DECIM_GEN // DECIM)]
gen = L6.generalization_auc(Xg, data[s_demo]["y"], folds=FOLDS, seed=SEED).mean(0)
print(f"{s_demo}: {len(tg)} x {len(tg)} = {len(tg) ** 2:,} classifiers x {FOLDS} folds in "
      f"{time.time() - t0:.0f} s ({1 / np.diff(tg).mean():.0f} Hz after decimation by {DECIM_GEN})")
print(f"   a full run at {1 / np.diff(times).mean():.0f} Hz would be "
      f"{(len(times) / len(tg)) ** 2:.0f}x this, about {(time.time() - t0) * (len(times) / len(tg)) ** 2 / 60:.0f} min")
d = np.diag(gen)
print(f"   diagonal peak {d.max():.4f} at {tg[int(np.argmax(d))] * 1000:.0f} ms")
print(f"   matrix maximum {gen.max():.4f} at train {tg[np.unravel_index(gen.argmax(), gen.shape)[0]] * 1000:.0f} ms, "
      f"test {tg[np.unravel_index(gen.argmax(), gen.shape)[1]] * 1000:.0f} ms")
post = tg >= 0
off = gen[np.ix_(post, post)].copy()
np.fill_diagonal(off, np.nan)
print(f"   mean off-diagonal AUC after 0 ms: {np.nanmean(off):.4f} against a diagonal mean of "
      f"{d[post].mean():.4f}")
_ratio = (np.nanmean(off) - 0.5) / (d[post].mean() - 0.5)
print(f"   ratio {np.nanmean(off) - 0.5:+.4f} / {d[post].mean() - 0.5:+.4f} = {_ratio:+.3f}")
print(f"   Read it as: 1 would mean the pattern persists unchanged (a square block), 0 that it does not "
      f"generalise off the diagonal at all (a narrow band), and a NEGATIVE value that training at one "
      f"moment predicts the opposite class at another -- which happens when the ERP changes sign between "
      f"the early and late parts of the epoch, and is a finding about the waveform rather than a defect.")
sub-010: 65 x 65 = 4,225 classifiers x 5 folds in 10 s (64 Hz after decimation by 4)
   a full run at 128 Hz would be 4x this, about 1 min
   diagonal peak 0.7222 at 520 ms
   matrix maximum 0.7399 at train 520 ms, test 457 ms
   mean off-diagonal AUC after 0 ms: 0.4800 against a diagonal mean of 0.5130
   ratio -0.0200 / +0.0130 = -1.538
   Read it as: 1 would mean the pattern persists unchanged (a square block), 0 that it does not generalise off the diagonal at all (a narrow band), and a NEGATIVE value that training at one moment predicts the opposite class at another -- which happens when the ERP changes sign between the early and late parts of the epoch, and is a finding about the waveform rather than a defect.
In [9]:
fig, axes = plt.subplots(1, 2, figsize=(11.5, 4.4), gridspec_kw={"width_ratios": [1.15, 1]})
v = float(np.abs(gen - 0.5).max())
im = axes[0].imshow(gen, origin="lower", cmap="RdBu_r", vmin=0.5 - v, vmax=0.5 + v,
                    extent=[tg[0] * 1000, tg[-1] * 1000, tg[0] * 1000, tg[-1] * 1000])
axes[0].axhline(0, color="k", lw=0.6)
axes[0].axvline(0, color="k", lw=0.6)
axes[0].plot([tg[0] * 1000, tg[-1] * 1000], [tg[0] * 1000, tg[-1] * 1000], "k:", lw=0.7)
axes[0].set(xlabel="Test time (ms)", ylabel="Train time (ms)",
            title=f"Temporal generalization, {s_demo} (ROC AUC)")
cb = fig.colorbar(im, ax=axes[0])
cb.set_label("ROC AUC (dimensionless, 0.5 = chance)")

axes[1].plot(tg * 1000, d, color="tab:blue", lw=1.8, label="diagonal (train = test)")
axes[1].plot(tg * 1000, gen[int(np.argmax(d))], color="tab:orange", lw=1.5,
             label=f"trained at {tg[int(np.argmax(d))] * 1000:.0f} ms, tested everywhere")
axes[1].axhline(0.5, color="gray", ls="--", lw=0.9, label="chance")
axes[1].axvline(0, color="gray", lw=0.6)
axes[1].set(xlabel="Test time (ms)", ylabel="ROC AUC (dimensionless)",
            title="A slice through the matrix")
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
Figure 3 of notebook nb-6-5-decoding, an output plot. The text around it states what it shows and the units of every axis.

5 · Leakage, measured three ways

Leakage is information reaching the test fold that would not be available at prediction time. It is almost never deliberate and it is almost always invisible in the score, because the score goes up.

5a · Information that arrives before the stimulus

The cheapest diagnostic in decoding: look at the pre-stimulus interval. Nothing about the trial's condition can be knowable before the stimulus appears, so above-chance decoding there is a measurement of how much the pipeline has smeared.

Two steps do the smearing, and both are standard practice:

  • a zero-phase high-pass filter. At 0.1 Hz the filter's impulse response is seconds long, and applying it forwards and backwards spreads energy in both directions in time — so a large post-stimulus deflection leaves a trace before the stimulus that a classifier can find.
  • baseline correction, which subtracts each trial's own pre-stimulus mean and so couples the two intervals by construction.
In [10]:
print(f"Pre-stimulus decoding ({int(pre.sum())} time points before 0 ms):")
print(f"   group mean AUC over the pre-stimulus interval : {A[:, pre].mean():.4f}")
print(f"   group mean AUC at its pre-stimulus maximum    : {A.mean(0)[pre].max():.4f} at "
      f"{times[pre][int(np.argmax(A.mean(0)[pre]))] * 1000:.0f} ms")
print(f"   subjects whose pre-stimulus maximum exceeds 0.55: "
      f"{int(sum(auc[s][pre].max() > 0.55 for s in sids))} of {len(sids)}")
pre_sig = mask[pre].sum()
print(f"   pre-stimulus points inside a significant cluster: {int(pre_sig)}")
t_pre, p_pre = stats.ttest_1samp(A[:, pre].mean(axis=1), 0.5)
print(f"   t-test of the per-subject pre-stimulus mean against 0.5: t({len(sids) - 1}) = {t_pre:.3f}, "
      f"p = {p_pre:.4f}")
print()
print("What to do about it, in order of preference:")
print("   1. report it.  A decoding figure without its pre-stimulus interval hides its own control condition.")
print("   2. high-pass less aggressively, or use a causal filter when the timing of the effect is the claim.")
print("   3. baseline-correct with a window that is not inside the epoch being decoded, or not at all -- a")
print("      classifier with a per-trial scaler does not need the baseline the way an ERP average does.")
print("   TODO(confirm): how much of the pre-stimulus signal above is filter smearing and how much is")
print("   baseline coupling is not separated here.  Doing so needs the same epochs built without each step,")
print("   which is a comparison this notebook does not run.")
Pre-stimulus decoding (26 time points before 0 ms):
   group mean AUC over the pre-stimulus interval : 0.5023
   group mean AUC at its pre-stimulus maximum    : 0.5474 at -4 ms
   subjects whose pre-stimulus maximum exceeds 0.55: 9 of 9
   pre-stimulus points inside a significant cluster: 0
   t-test of the per-subject pre-stimulus mean against 0.5: t(8) = 0.207, p = 0.8411

What to do about it, in order of preference:
   1. report it.  A decoding figure without its pre-stimulus interval hides its own control condition.
   2. high-pass less aggressively, or use a causal filter when the timing of the effect is the claim.
   3. baseline-correct with a window that is not inside the epoch being decoded, or not at all -- a
      classifier with a per-trial scaler does not need the baseline the way an ERP average does.
   TODO(confirm): how much of the pre-stimulus signal above is filter smearing and how much is
   baseline coupling is not separated here.  Doing so needs the same epochs built without each step,
   which is a comparison this notebook does not run.

5b · Selecting channels outside the fold

A very common shortcut: find the channels where the conditions differ most, then cross-validate a classifier on those channels. The selection used every trial, including the ones in the test fold, so the test fold is no longer unseen.

The fix is one indentation: move the selection inside the loop, so it only ever sees training data.

In [11]:
K_CHANNELS = 5
t0 = time.time()
rows = []
for s in sids:
    X_, y_ = data[s]["X"], data[s]["y"]
    Xw_ = X_[:, :, i_win]                                     # 300-600 ms window, channels x samples
    feat = Xw_.mean(axis=2)                                   # mean amplitude per channel, one per trial

    # LEAKED: pick the K channels with the largest |t| using every trial, then cross-validate.
    t_all = np.abs(stats.ttest_ind(feat[y_ == 1], feat[y_ == 0], axis=0).statistic)
    sel_all = np.argsort(t_all)[-K_CHANNELS:]
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        leaked = float(cross_val_score(clf, feat[:, sel_all], y_, cv=cv, scoring="roc_auc").mean())

        # HONEST: the same selection, refitted inside every training fold.
        scores = []
        for tr, te in cv.split(feat, y_):
            t_tr = np.abs(stats.ttest_ind(feat[tr][y_[tr] == 1], feat[tr][y_[tr] == 0], axis=0).statistic)
            sel = np.argsort(t_tr)[-K_CHANNELS:]
            m = clf.fit(feat[tr][:, sel], y_[tr])
            scores.append(float(roc_auc_score(y_[te], m.predict_proba(feat[te][:, sel])[:, 1])))
        honest = float(np.mean(scores))
    rows.append({"subject": s, "honest": honest, "leaked": leaked, "gap": leaked - honest,
                 "selected_all": [L6.ERPCORE_30[i] for i in sorted(sel_all)]})
print(f"channel selection inside vs outside the fold, {len(rows)} subjects, top {K_CHANNELS} of "
      f"{len(L6.ERPCORE_30)} channels, {time.time() - t0:.0f} s\n")
print(f"{'subject':9s} {'honest AUC':>11s} {'leaked AUC':>11s} {'gap':>8s}  channels chosen on all the data")
for r in rows:
    print(f"{r['subject']:9s} {r['honest']:11.4f} {r['leaked']:11.4f} {r['gap']:+8.4f}  "
          f"{', '.join(r['selected_all'])}")
gaps = np.array([r["gap"] for r in rows])
print(f"\nmean honest {np.mean([r['honest'] for r in rows]):.4f}, mean leaked "
      f"{np.mean([r['leaked'] for r in rows]):.4f}, mean gap {gaps.mean():+.4f} "
      f"(range {gaps.min():+.4f} to {gaps.max():+.4f})")
print(f"subjects where the leak HELPED: {int((gaps > 0).sum())} of {len(gaps)}")
print(f"\nThe gap is modest here because {K_CHANNELS} of {len(L6.ERPCORE_30)} channels is a small selection "
      f"and the P3 effect is large and spatially broad, so the leaked and honest selections usually agree.  "
      f"It grows with the size of the search (selecting 5 of 500 voxels, or a time window as well as a "
      f"channel) and with the weakness of the effect -- which is exactly the regime where it does the most "
      f"damage and is the hardest to notice.")
channel selection inside vs outside the fold, 9 subjects, top 5 of 30 channels, 0 s

subject    honest AUC  leaked AUC      gap  channels chosen on all the data
sub-001        0.7179      0.7342  +0.0163  P9, Pz, CPz, C4, P10
sub-002        0.8784      0.8784  +0.0000  Fp1, Pz, CPz, Fp2, P4
sub-003        0.7340      0.7387  +0.0047  P3, Pz, CPz, F8, P4
sub-004        0.7216      0.7184  -0.0032  P3, Pz, CPz, Fp2, P4
sub-005        0.5537      0.6637  +0.1099  P3, PO3, Pz, FCz, Cz
sub-006        0.5148      0.6705  +0.1558  C4, C6, P4, PO4, O2
sub-007        0.7430      0.7555  +0.0125  PO7, O1, P10, PO8, O2
sub-008        0.6261      0.6071  -0.0191  CPz, FC4, FCz, Cz, C4
sub-010        0.6227      0.6778  +0.0552  C3, PO7, PO3, O1, C4

mean honest 0.6791, mean leaked 0.7160, mean gap +0.0369 (range -0.0191 to +0.1558)
subjects where the leak HELPED: 6 of 9

The gap is modest here because 5 of 30 channels is a small selection and the P3 effect is large and spatially broad, so the leaked and honest selections usually agree.  It grows with the size of the search (selecting 5 of 500 voxels, or a time window as well as a channel) and with the weakness of the effect -- which is exactly the regime where it does the most damage and is the hardest to notice.

5c · Fitting a spatial filter outside the fold

The same mistake, one level up, and the one Level 5 measured. CSP finds spatial filters that maximise the variance difference between two classes — a supervised step. Fitting it once on every epoch and then cross-validating only the classifier leaves the filters knowing the test folds' labels.

Same subject, same epochs, same folds, same seed. The only difference is where the fold boundary sits.

In [12]:
print("the L5.7 pipeline, restated (helpers_l6.CSP_SPEC):")
for k, v in L6.CSP_SPEC.items():
    print(f"   {k:14s} : {v}")

SELECTED = "S002"
DELETE_EEGBCI = True      # spec section 11: delete the subset this notebook downloads.
# The ds-eegbci cache is SHARED -- Level 0 and Level 5 notebooks read the same EDF files -- so the
# listing below records what was on disk before anything was fetched, and only files this run
# actually downloads are deleted afterwards.  Deleting a file another lesson had already cached
# would cost that lesson a re-download for no benefit.
PRE_EXISTING = sorted({str(p) for sid in ([SELECTED] + CSP_CANDIDATES)
                       for p in L6.eegbci_run_files(sid, L6.CSP_SPEC["runs"])})
print(f"ds-eegbci EDFs already cached before this notebook runs: {len(PRE_EXISTING)} of "
      f"{len(set([SELECTED] + CSP_CANDIDATES)) * len(L6.CSP_SPEC['runs'])}; only files fetched by this "
      f"run will be deleted")

t0 = time.time()
Xc, yc, ch = L6.csp_epochs(SELECTED)
lk = L6.csp_leakage(Xc, yc, n_components=int(L6.CSP_SPEC["n_components"]), folds=FOLDS, seed=SEED)
print(f"\n{SELECTED}: {len(yc)} epochs ({int((yc == 0).sum())} left, {int((yc == 1).sum())} right), "
      f"{len(ch)} channels, {time.time() - t0:.0f} s")
print(f"   HONEST (CSP refitted in every training fold) : {lk['honest']:.4f}  "
      f"folds {', '.join(f'{v:.4f}' for v in lk['honest_scores'])}")
print(f"   LEAKED (CSP fitted once on every epoch)      : {lk['leaked']:.4f}  "
      f"folds {', '.join(f'{v:.4f}' for v in lk['leaked_scores'])}")
print(f"   THE COST OF THE LEAK                         : {lk['inflation']:+.4f} "
      f"({100 * lk['inflation']:+.1f} percentage points)")
clo, chi = L6.chance_band(len(yc), 0.5)
print(f"   chance is 0.5; the binomial 95 % band on {len(yc)} epochs is {clo:.4f} to {chi:.4f}")
print(f"\n   {lk['what_leaks']}")
the L5.7 pipeline, restated (helpers_l6.CSP_SPEC):
   dataset        : ds-eegbci
   runs           : ('R04', 'R08', 'R12')
   runs_note      : PhysioNet EEGMMIDB runs 4, 8 and 12: imagine opening and closing the left or the right fist.  T1 marks the left fist and T2 the right fist in these runs.
   band_hz        : (8.0, 30.0)
   epoch_s        : (-1.0, 4.0)
   window_s       : (0.5, 2.5)
   n_components   : 6
   estimator      : Pipeline([('csp', mne.decoding.CSP(n_components=6, reg=None, log=True, norm_trace=False)), ('lda', LinearDiscriminantAnalysis())])
   cv             : StratifiedKFold(n_splits=5, shuffle=True, random_state=20260918)
   chance         : 0.5
   chance_note    : two near-balanced classes, so chance is 0.5; with 45 epochs the binomial 95 % interval around chance runs from about 0.354 to 0.646, so a single subject inside that band is not evidence of anything.
ds-eegbci EDFs already cached before this notebook runs: 0 of 36; only files fetched by this run will be deleted
Downloading file 'S002/S002R04.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S002/S002R04.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S002/S002R08.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S002/S002R08.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S002/S002R12.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S002/S002R12.edf' to '.../eegmmidb/1.0.0'.
S002: 45 epochs (23 left, 22 right), 64 channels, 87 s
   HONEST (CSP refitted in every training fold) : 0.8667  folds 0.8889, 0.6667, 1.0000, 1.0000, 0.7778
   LEAKED (CSP fitted once on every epoch)      : 0.9778  folds 1.0000, 1.0000, 1.0000, 1.0000, 0.8889
   THE COST OF THE LEAK                         : +0.1111 (+11.1 percentage points)
   chance is 0.5; the binomial 95 % band on 45 epochs is 0.3556 to 0.6444

   CSP was fitted once on every epoch, labels included, and only the LDA was cross-validated, so the spatial filters already know the test folds' labels.

The selection effect, stated beside the number

S002 was not an arbitrary subject. Level 5 chose it with the rule "the lowest-numbered subject whose mean cross-validated accuracy reaches 0.75", applied to twelve candidates. That is a selection on the outcome, and a lesson that quotes its score without saying so teaches the very thing this level is about.

So: the identical pipeline, on every candidate.

In [13]:
t0 = time.time()
survey = []
for sid in CSP_CANDIDATES:
    t1 = time.time()
    try:
        Xs, ys, _ = L6.csp_epochs(sid)
        r = L6.csp_accuracy(Xs, ys, n_components=int(L6.CSP_SPEC["n_components"]), folds=FOLDS, seed=SEED)
        survey.append({"subject": sid, "mean": r["mean"], "std": r["std"], "n": r["n_epochs"],
                       "scores": r["scores"]})
        print(f"  {sid}: {r['mean']:.4f} (n = {r['n_epochs']}) in {time.time() - t1:.0f} s", flush=True)
    except Exception as exc:                                     # noqa: BLE001
        print(f"  {sid}: skipped ({type(exc).__name__}: {exc})")
    finally:
        if DELETE_EEGBCI:
            L6.drop_eegbci_runs(sid, L6.CSP_SPEC["runs"], keep=PRE_EXISTING)
if DELETE_EEGBCI:
    L6.drop_eegbci_runs(SELECTED, L6.CSP_SPEC["runs"], keep=PRE_EXISTING)
means = np.array([r["mean"] for r in survey])
print(f"\n{len(survey)} of {len(CSP_CANDIDATES)} candidates in {time.time() - t0:.0f} s")
if len(survey) < len(CSP_CANDIDATES):
    print(f"   WARNING: {len(CSP_CANDIDATES) - len(survey)} candidate(s) could not be loaded, so the "
          f"summary below is NOT the full distribution.  Rerun before quoting it.")
L6.disk_report("after the ds-eegbci loop",
               folders={"ds-eegbci cache": helpers.download_root() / "MNE-eegbci-data"})
print(f"   median {np.median(means):.4f}, mean {means.mean():.4f}, range {means.min():.4f} to "
      f"{means.max():.4f}, IQR {np.percentile(means, 75) - np.percentile(means, 25):.4f}")
print(f"   the median and the mean differ by {abs(means.mean() - np.median(means)):.4f} because one subject "
      f"sits far above the rest; for a distribution this skewed the median is the summary and the range is "
      f"the caveat.")
print(f"   the selected subject, {SELECTED}: {dict((r['subject'], r['mean']) for r in survey).get(SELECTED, float('nan')):.4f} "
      f"-- the {100 * float(np.mean(means <= dict((r['subject'], r['mean']) for r in survey).get(SELECTED, np.nan))):.0f}th percentile of its own candidate pool")
print(f"   candidates inside the chance band {clo:.3f}-{chi:.3f}: "
      f"{int(((means >= clo) & (means <= chi)).sum())} of {len(means)}")
print()
print(f"   Quoting {dict((r['subject'], r['mean']) for r in survey).get(SELECTED, float('nan')):.1%} as "
      f"'what motor-imagery decoding achieves' would be wrong by about "
      f"{100 * (dict((r['subject'], r['mean']) for r in survey).get(SELECTED, np.nan) - np.median(means)):.0f} "
      f"percentage points.  The median is the honest summary and the range is the honest caveat.")
Downloading file 'S001/S001R04.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S001/S001R04.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S001/S001R08.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S001/S001R08.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S001/S001R12.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S001/S001R12.edf' to '.../eegmmidb/1.0.0'.
  S001: 0.6667 (n = 45) in 70 s
  S002: 0.8667 (n = 45) in 2 s
Downloading file 'S003/S003R04.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S003/S003R04.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S003/S003R08.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S003/S003R08.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S003/S003R12.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S003/S003R12.edf' to '.../eegmmidb/1.0.0'.
  S003: 0.5111 (n = 45) in 55 s
Downloading file 'S004/S004R04.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S004/S004R04.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S004/S004R08.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S004/S004R08.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S004/S004R12.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S004/S004R12.edf' to '.../eegmmidb/1.0.0'.
  S004: 0.4889 (n = 45) in 62 s
Downloading file 'S005/S005R04.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S005/S005R04.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S005/S005R08.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S005/S005R08.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S005/S005R12.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S005/S005R12.edf' to '.../eegmmidb/1.0.0'.
  S005: 0.4444 (n = 45) in 60 s
Downloading file 'S006/S006R04.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S006/S006R04.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S006/S006R08.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S006/S006R08.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S006/S006R12.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S006/S006R12.edf' to '.../eegmmidb/1.0.0'.
  S006: 0.6000 (n = 45) in 65 s
Downloading file 'S007/S007R04.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S007/S007R04.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S007/S007R08.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S007/S007R08.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S007/S007R12.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S007/S007R12.edf' to '.../eegmmidb/1.0.0'.
  S007: 0.9556 (n = 45) in 108 s
Downloading file 'S008/S008R04.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S008/S008R04.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S008/S008R08.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S008/S008R08.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S008/S008R12.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S008/S008R12.edf' to '.../eegmmidb/1.0.0'.
  S008: 0.6222 (n = 45) in 94 s
Downloading file 'S009/S009R04.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S009/S009R04.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S009/S009R08.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S009/S009R08.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S009/S009R12.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S009/S009R12.edf' to '.../eegmmidb/1.0.0'.
  S009: 0.4444 (n = 45) in 58 s
Downloading file 'S010/S010R04.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S010/S010R04.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S010/S010R08.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S010/S010R08.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S010/S010R12.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S010/S010R12.edf' to '.../eegmmidb/1.0.0'.
  S010: 0.4667 (n = 45) in 52 s
Downloading file 'S011/S011R04.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S011/S011R04.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S011/S011R08.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S011/S011R08.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S011/S011R12.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S011/S011R12.edf' to '.../eegmmidb/1.0.0'.
  S011: 0.5111 (n = 45) in 55 s
Downloading file 'S012/S012R04.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S012/S012R04.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S012/S012R08.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S012/S012R08.edf' to '.../eegmmidb/1.0.0'.
Downloading file 'S012/S012R12.edf' from 'https://physionet.org/files/eegmmidb/1.0.0/S012/S012R12.edf' to '.../eegmmidb/1.0.0'.
  S012: 0.6667 (n = 45) in 59 s
12 of 12 candidates in 741 s
free disk after the ds-eegbci loop: 4.69 GB  (ds-eegbci cache 0.0 MB)
   median 0.5556, mean 0.6037, range 0.4444 to 0.9556, IQR 0.1833
   the median and the mean differ by 0.0481 because one subject sits far above the rest; for a distribution this skewed the median is the summary and the range is the caveat.
   the selected subject, S002: 0.8667 -- the 92th percentile of its own candidate pool
   candidates inside the chance band 0.356-0.644: 8 of 12

   Quoting 86.7% as 'what motor-imagery decoding achieves' would be wrong by about 31 percentage points.  The median is the honest summary and the range is the honest caveat.
In [14]:
fig, axes = plt.subplots(1, 2, figsize=(11.5, 4.2))
order = np.argsort(means)
axes[0].bar(range(len(survey)), means[order],
            color=["tab:orange" if survey[i]["subject"] == SELECTED else "tab:blue" for i in order])
axes[0].axhline(0.5, color="gray", ls="--", lw=1, label="chance")
axes[0].axhspan(clo, chi, color="gray", alpha=0.18, label="binomial 95 % band around chance")
axes[0].axhline(float(np.median(means)), color="k", ls=":", lw=1.2,
                label=f"median {np.median(means):.3f}")
axes[0].set_xticks(range(len(survey)), [survey[i]["subject"] for i in order], rotation=60, fontsize=7)
axes[0].set(ylabel="Cross-validated accuracy (proportion correct)",
            title=f"The same pipeline on {len(survey)} candidates; the selected one in orange")
axes[0].legend(fontsize=8)
axes[0].grid(alpha=0.3, axis="y")

axes[1].bar(["honest\n(CSP inside the fold)", "leaked\n(CSP outside the fold)"],
            [lk["honest"], lk["leaked"]], color=["tab:blue", "tab:orange"], width=0.5)
axes[1].axhline(0.5, color="gray", ls="--", lw=1)
axes[1].axhspan(clo, chi, color="gray", alpha=0.18)
for i, v in enumerate([lk["honest"], lk["leaked"]]):
    axes[1].text(i, v + 0.015, f"{v:.3f}", ha="center", fontsize=11)
axes[1].annotate("", xy=(1, lk["leaked"]), xytext=(1, lk["honest"]),
                 arrowprops=dict(arrowstyle="<->", color="k"))
axes[1].text(1.06, (lk["honest"] + lk["leaked"]) / 2, f"{100 * lk['inflation']:+.1f} pp", fontsize=10)
axes[1].set(ylim=(0, 1.12), ylabel="Cross-validated accuracy (proportion correct)",
            title=f"{SELECTED}: the same data, the same folds, one line moved")
axes[1].grid(alpha=0.3, axis="y")
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
Figure 4 of notebook nb-6-5-decoding, an output plot. The text around it states what it shows and the units of every axis.

6 · What above-chance decoding does and does not mean

It does license: "the two conditions differ in a way that is linearly readable from these sensors at these latencies, with the fold boundary respected." That is a statement about discriminability, and it is a real one.

It does not license:

  • "the brain represents X here." A decoder finds whatever covaries with the label, including eye movements, muscle tone, a difference in trial count between blocks, and the stimulus's own physical properties. In this paradigm the same letters serve as both conditions, which controls the last of those — many designs do not.
  • "the effect starts at 232 ms." The onset of a decoding cluster depends on the smoothing, the filter, the fold count and the classifier, exactly as a cluster test's boundaries do (nb-6-1-corrections §7).
  • "the classifier weights show where the information is." Weights are filters, not patterns; the Level 5 lesson on CSP is the same lesson. Only patterns are interpretable as topographies.
  • "87 % is what this method achieves." Not unless the subject was not chosen by that number — see §5c.
In [15]:
print("nb-6-5-decoding -- L6.5 numbers (draft; TODO(confirm) at author review)")
print(f"Data: ds-erpcore P3, {len(sids)} subjects (CC-BY-SA-4.0, contested at source) and ds-eegbci "
      f"R04+R08+R12 (ODC-By-1.0)")
print(f"Pipeline: helpers_l6.PRESPECIFIED for the ERP CORE epochs; helpers_l6.CSP_SPEC for ds-eegbci")
print()
print(f"1. TIME-RESOLVED DECODING ({len(sids)} subjects, {len(times)} time points at "
      f"{1 / np.diff(times).mean():.0f} Hz, {FOLDS}-fold StratifiedKFold within subject, ROC AUC):")
print(f"     group peak AUC {A.mean(0)[peak_i]:.4f} at {times[peak_i] * 1000:.0f} ms")
print(f"     per-subject peak AUC: median {np.median([auc[s].max() for s in sids]):.4f}, "
      f"range {min(auc[s].max() for s in sids):.4f} to {max(auc[s].max() for s in sids):.4f}")
print()
print(f"2. ex-6-5 ANSWER PAIR -- peak decoding time and whether it is significant:")
print(f"     peak at {times[peak_i] * 1000:.0f} ms, AUC {A.mean(0)[peak_i]:.4f}")
print(f"     cluster permutation (threshold {thr:.4f}, tail 1, {len(H0)} permutations, seed {SEED}): "
      f"{len(sig_clusters)} significant cluster(s)")
for c in sig_clusters:
    print(f"        {c['t_start_ms']:.0f} to {c['t_end_ms']:.0f} ms, {c['n']} points, p = {c['p']:.4f}")
print(f"     the peak IS{'' if mask[peak_i] else ' NOT'} inside a significant cluster")
print()
print(f"3. CHANCE: AUC chance 0.500; majority-class accuracy {maj:.3f} on a median subject; binomial 95 % "
      f"band {lo:.3f}-{hi:.3f} on {n_tr} trials")
print(f"     empirical null for {s_demo} ({N_PERM_WITHIN} label shuffles): mean {null.mean():.4f}, "
      f"SD {null.std(ddof=1):.4f}; observed {obs:.4f}, p = {p_emp:.4f}")
print()
print(f"4. TEMPORAL GENERALIZATION ({s_demo}, {len(tg)} x {len(tg)} at "
      f"{1 / np.diff(tg).mean():.0f} Hz): diagonal peak {d.max():.4f} at "
      f"{tg[int(np.argmax(d))] * 1000:.0f} ms; off-diagonal/diagonal ratio after 0 ms "
      f"{(np.nanmean(off) - 0.5) / (d[post].mean() - 0.5):.3f}")
print()
print(f"5. LEAKAGE:")
print(f"     (a) pre-stimulus group mean AUC {A[:, pre].mean():.4f}, maximum {A.mean(0)[pre].max():.4f}; "
      f"t({len(sids) - 1}) = {t_pre:.3f} against 0.5, p = {p_pre:.4f}")
print(f"     (b) channel selection outside the fold: honest {np.mean([r['honest'] for r in rows]):.4f} vs "
      f"leaked {np.mean([r['leaked'] for r in rows]):.4f}, mean gap {gaps.mean():+.4f} AUC "
      f"(top {K_CHANNELS} of {len(L6.ERPCORE_30)} channels)")
print(f"     (c) CSP outside the fold, {SELECTED}: honest {lk['honest']:.4f} vs leaked {lk['leaked']:.4f}, "
      f"gap {100 * lk['inflation']:+.1f} percentage points")
print()
print(f"6. THE SELECTION DISCLOSURE -- the same pipeline on {len(survey)} of {len(CSP_CANDIDATES)} "
      f"candidates:")
print(f"     median {np.median(means):.4f}, mean {means.mean():.4f}, range {means.min():.4f} to "
      f"{means.max():.4f}")
print(f"     " + ", ".join(f"{r['subject']} {r['mean']:.4f}" for r in survey))
print(f"     {SELECTED} was selected by the rule 'lowest-numbered subject reaching 0.75'.  Its score must "
      f"never be quoted without the median and the range.")
print()
print(f"CROSS-CHECK against w-csp-explorer (csp.json, ds-eegbci S002, 8-30 Hz, 45 epochs):")
print(f"     widget:   86.7 % honest vs 97.8 % leaked, +11.1 points; folds "
      f"0.8889, 0.6667, 1.0, 1.0, 0.7778")
print(f"     notebook: {100 * lk['honest']:.1f} % honest vs {100 * lk['leaked']:.1f} % leaked, "
      f"{100 * lk['inflation']:+.1f} points; folds "
      f"{', '.join(f'{v:.4f}' for v in lk['honest_scores'])}")
print(f"     integration note site/notes/integration-phase3.md quotes a candidate 'median of 60.0 %'.  "
      f"The survey above gives median {100 * np.median(means):.1f} % and mean {100 * means.mean():.1f} % "
      f"over {len(survey)} candidates, so the published figure is the MEAN rather than the median (the "
      f"notebooks-L5 track reports the same).  The range matches exactly either way.  Which statistic the "
      f"lessons quote is a decision for the author: the median is the right summary of a distribution this "
      f"skewed, and both belong beside the selected subject's score.")
print()
print(f"Pitfall: pf-decoding-leakage.  No widget for this lesson; w-csp-explorer (L5.7) is the nearest.")
nb-6-5-decoding -- L6.5 numbers (draft; TODO(confirm) at author review)
Data: ds-erpcore P3, 9 subjects (CC-BY-SA-4.0, contested at source) and ds-eegbci R04+R08+R12 (ODC-By-1.0)
Pipeline: helpers_l6.PRESPECIFIED for the ERP CORE epochs; helpers_l6.CSP_SPEC for ds-eegbci

1. TIME-RESOLVED DECODING (9 subjects, 129 time points at 128 Hz, 5-fold StratifiedKFold within subject, ROC AUC):
     group peak AUC 0.6769 at 387 ms
     per-subject peak AUC: median 0.7222, range 0.6723 to 0.8870

2. ex-6-5 ANSWER PAIR -- peak decoding time and whether it is significant:
     peak at 387 ms, AUC 0.6769
     cluster permutation (threshold 1.8595, tail 1, 512 permutations, seed 20260918): 2 significant cluster(s)
        332 to 629 ms, 39 points, p = 0.0039
        645 to 738 ms, 13 points, p = 0.0332
     the peak IS inside a significant cluster

3. CHANCE: AUC chance 0.500; majority-class accuracy 0.800 on a median subject; binomial 95 % band 0.427-0.573 on 185 trials
     empirical null for sub-010 (200 label shuffles): mean 0.4941, SD 0.0730; observed 0.6197, p = 0.0348

4. TEMPORAL GENERALIZATION (sub-010, 65 x 65 at 64 Hz): diagonal peak 0.7222 at 520 ms; off-diagonal/diagonal ratio after 0 ms -1.538

5. LEAKAGE:
     (a) pre-stimulus group mean AUC 0.5023, maximum 0.5474; t(8) = 0.207 against 0.5, p = 0.8411
     (b) channel selection outside the fold: honest 0.6791 vs leaked 0.7160, mean gap +0.0369 AUC (top 5 of 30 channels)
     (c) CSP outside the fold, S002: honest 0.8667 vs leaked 0.9778, gap +11.1 percentage points

6. THE SELECTION DISCLOSURE -- the same pipeline on 12 of 12 candidates:
     median 0.5556, mean 0.6037, range 0.4444 to 0.9556
     S001 0.6667, S002 0.8667, S003 0.5111, S004 0.4889, S005 0.4444, S006 0.6000, S007 0.9556, S008 0.6222, S009 0.4444, S010 0.4667, S011 0.5111, S012 0.6667
     S002 was selected by the rule 'lowest-numbered subject reaching 0.75'.  Its score must never be quoted without the median and the range.

CROSS-CHECK against w-csp-explorer (csp.json, ds-eegbci S002, 8-30 Hz, 45 epochs):
     widget:   86.7 % honest vs 97.8 % leaked, +11.1 points; folds 0.8889, 0.6667, 1.0, 1.0, 0.7778
     notebook: 86.7 % honest vs 97.8 % leaked, +11.1 points; folds 0.8889, 0.6667, 1.0000, 1.0000, 0.7778
     integration note site/notes/integration-phase3.md quotes a candidate 'median of 60.0 %'.  The survey above gives median 55.6 % and mean 60.4 % over 12 candidates, so the published figure is the MEAN rather than the median (the notebooks-L5 track reports the same).  The range matches exactly either way.  Which statistic the lessons quote is a decision for the author: the median is the right summary of a distribution this skewed, and both belong beside the selected subject's score.

Pitfall: pf-decoding-leakage.  No widget for this lesson; w-csp-explorer (L5.7) is the nearest.