Spatial filters for decoding: CSP plus LDA on ds-eegbci motor imagery with cross-validation, filters against patterns, the component-count sweep, the eleven points a misplaced fold boundary buys, and the twelve-subject distribution the headline number came from

nb-5-7-csp Level 5 · Connectivity and Spatial Analysis ~20 min Used in L5.7 · Spatial filters for decoding

Downloads from 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-5-7-csp · Spatial filters for decoding (L5.7)

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

Common spatial patterns (CSP) is the spatial filter that made motor-imagery decoding work: it finds the channel weightings whose variance differs most between two conditions. This notebook derives it, decodes left against right hand imagery with CSP + LDA, and then spends most of its length on the three ways the headline number can be wrong.

Three disclosures that belong with the result, not after it

  1. The subject was chosen by accuracy. The shipped subject scores about 87 %; the median across the twelve candidates is around 56 % and the range runs from 44 % to 96 %. A lesson that quotes the winner alone teaches exactly the selection effect Level 6 exists to warn about, so the whole distribution is in section 6.
  2. More components is not monotonically better. CSP with two components scores worse than the log band power at C3 and C4 with no spatial filter at all.
  3. Moving the fold boundary by one step inflates the accuracy by about eleven points. Fitting CSP once on every epoch and cross-validating only the classifier is the single most common way to get a number that does not survive replication; section 5 measures it on the same data, the same folds and the same seed.

Data. ds-eegbci — EEG Motor Movement/Imagery Dataset (EEGMMIDB), Schalk, McFarland, Hinterberger, Birbaumer & Wolpaw (2004), BCI2000: A General-Purpose Brain-Computer Interface (BCI) System, IEEE TBME 51(6), 1034–1043; dataset DOI 10.13026/C28G6P, PhysioNet v1.0.0. Licence and access are read from data/directory.yaml in the first cell. 64 channels in a 10-10 layout, 160 Hz, no hardware filters, 60 Hz mains. Runs R04, R08 and R12 are the motor-imagery runs in which T1 marks imagined movement of the left fist and T2 the right.

Downloads. Three EDF runs per subject, about 7.7 MB, fetched from PhysioNet through helpers.load_spine and deleted as soon as that subject's epochs are in memory, so peak disk is one subject. Twelve subjects are loaded, so the notebook is download-bound on a cold cache: about 7.7 MB × 12, and PhysioNet delivered that at roughly 0.13 MB/s from the machine these outputs were produced on.

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", "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"]
    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_l5.py").exists()), None)
if _shared is None:
    raise FileNotFoundError("start the kernel in notebooks/L5/ (or notebooks/) so that _shared/helpers_l5.py is found")
sys.path.insert(0, str(_shared))
import helpers
import helpers_l5 as L5

# 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
import pooch

mne.set_log_level("WARNING")
pooch.get_logger().setLevel("WARNING")   # no download chatter: it would print local paths
plt.rcParams["figure.dpi"] = 72
print(f"MNE {mne.__version__}; helpers_l5 imported from notebooks/_shared")
print("mne-connectivity available:", L5.have_mne_connectivity())
print(L5.disk_line("disk at the start"))
MNE 1.10.2; helpers_l5 imported from notebooks/_shared
mne-connectivity available: True
disk at the start: 4.36 GB free on the working volume
In [2]:
print("Licences, from data/directory.yaml (never from memory):")
L5.print_licences("ds-eegbci", notes=True)
Licences, from data/directory.yaml (never from memory):
  ds-eegbci — EEG Motor Movement/Imagery Dataset (EEGMMIDB): licence ODC-By-1.0, access open (data/directory.yaml)
      ODC-By 1.0 on PhysioNet; CC0 on the OpenNeuro BIDS mirror (ds004362)

1 · One pipeline, twelve subjects, deleted as it goes

Every choice below is fixed before any accuracy is computed, and printed rather than described. The parameters are the ones w-csp-explorer ships, so the notebook and the widget are comparable.

In [3]:
import warnings
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import Pipeline
from mne.decoding import CSP

PIPELINE = {
    "dataset": "ds-eegbci",
    "runs": (4, 8, 12),
    "runs_note": "motor imagery of the left (T1) or right (T2) fist",
    "band_hz": (8.0, 30.0),
    "filter": "FIR, zero-phase, MNE defaults",
    "epoch_s": (-1.0, 4.0),
    "window_s": (0.5, 2.5),
    "window_note": "the analysis window, inside the epoch, from cue onset",
    "n_components": 6,
    "estimator": "Pipeline([('csp', mne.decoding.CSP(n_components=6, log=True, norm_trace=False)), "
                 "('lda', LinearDiscriminantAnalysis())])",
    "cv": "StratifiedKFold(n_splits=5, shuffle=True, random_state=20260918)",
    "seed": L5.SEED,
}
CANDIDATES = tuple(range(1, 13))
FEATURED = 2
BAND, EPOCH_S, WINDOW_S = PIPELINE["band_hz"], PIPELINE["epoch_s"], PIPELINE["window_s"]
for k, v in PIPELINE.items():
    print(f"  {k:14s}: {v}")
print()
print(f"candidate subjects: {['S%03d' % s for s in CANDIDATES]}")
print(f"featured subject (chosen by accuracy -- see section 6): S{FEATURED:03d}")
print()
print(L5.disk_line("disk before the downloads"))


def load_subject(subject, runs=PIPELINE["runs"]):
    """One subject's band-passed imagery epochs; the EDF files are deleted before this returns."""
    paths = []
    try:
        raw = helpers.load_spine("ds-eegbci", subject, list(runs), preload=True)
        paths = L5.eegbci_files(subject, runs)
        raw.filter(BAND[0], BAND[1], verbose=False)
        events, event_id = mne.events_from_annotations(raw, verbose=False)
        wanted = {k: v for k, v in event_id.items() if k in ("T1", "T2")}
        epochs = mne.Epochs(raw, events, wanted, tmin=EPOCH_S[0], tmax=EPOCH_S[1], baseline=None,
                            picks="eeg", preload=True, verbose=False)
        y = np.array([1 if e == wanted["T1"] else 2 for e in epochs.events[:, 2]])
        return epochs, y
    finally:
        L5.delete_files(paths, verbose=False)


data, survey_meta = {}, []
for s in CANDIDATES:
    ep, y = load_subject(s)
    X = ep.copy().crop(*WINDOW_S).get_data(copy=True)
    data[s] = (X, y, ep.info.copy(), ep.ch_names)
    survey_meta.append((s, len(y), int((y == 1).sum()), int((y == 2).sum())))
    print(f"  S{s:03d}: {len(y)} epochs ({int((y == 1).sum())} left, {int((y == 2).sum())} right), "
          f"{X.shape[1]} channels x {X.shape[2]} samples in the {WINDOW_S[0]:g}-{WINDOW_S[1]:g} s window")
print()
print(L5.disk_line("disk after every download was deleted"))

X_f, y_f, info_f, ch_f = data[FEATURED]
print()
print(f"featured subject S{FEATURED:03d}: {X_f.shape[0]} epochs x {X_f.shape[1]} channels x "
      f"{X_f.shape[2]} samples at {info_f['sfreq']:g} Hz")
  dataset       : ds-eegbci
  runs          : (4, 8, 12)
  runs_note     : motor imagery of the left (T1) or right (T2) fist
  band_hz       : (8.0, 30.0)
  filter        : FIR, zero-phase, MNE defaults
  epoch_s       : (-1.0, 4.0)
  window_s      : (0.5, 2.5)
  window_note   : the analysis window, inside the epoch, from cue onset
  n_components  : 6
  estimator     : Pipeline([('csp', mne.decoding.CSP(n_components=6, log=True, norm_trace=False)), ('lda', LinearDiscriminantAnalysis())])
  cv            : StratifiedKFold(n_splits=5, shuffle=True, random_state=20260918)
  seed          : 20260918

candidate subjects: ['S001', 'S002', 'S003', 'S004', 'S005', 'S006', 'S007', 'S008', 'S009', 'S010', 'S011', 'S012']
featured subject (chosen by accuracy -- see section 6): S002

disk before the downloads: 4.36 GB free on the working volume
  S001: 45 epochs (23 left, 22 right), 64 channels x 321 samples in the 0.5-2.5 s window
  S002: 45 epochs (23 left, 22 right), 64 channels x 321 samples in the 0.5-2.5 s window
  S003: 45 epochs (23 left, 22 right), 64 channels x 321 samples in the 0.5-2.5 s window
  S004: 45 epochs (23 left, 22 right), 64 channels x 321 samples in the 0.5-2.5 s window
  S005: 45 epochs (21 left, 24 right), 64 channels x 321 samples in the 0.5-2.5 s window
  S006: 45 epochs (24 left, 21 right), 64 channels x 321 samples in the 0.5-2.5 s window
  S007: 45 epochs (23 left, 22 right), 64 channels x 321 samples in the 0.5-2.5 s window
  S008: 45 epochs (22 left, 23 right), 64 channels x 321 samples in the 0.5-2.5 s window
  S009: 45 epochs (24 left, 21 right), 64 channels x 321 samples in the 0.5-2.5 s window
  S010: 45 epochs (24 left, 21 right), 64 channels x 321 samples in the 0.5-2.5 s window
  S011: 45 epochs (23 left, 22 right), 64 channels x 321 samples in the 0.5-2.5 s window
  S012: 45 epochs (21 left, 24 right), 64 channels x 321 samples in the 0.5-2.5 s window

disk after every download was deleted: 4.27 GB free on the working volume

featured subject S002: 45 epochs x 64 channels x 321 samples at 160 Hz

2 · What CSP actually solves

CSP is one generalized eigenproblem. Take the class-mean covariance matrices S₁ and S₂ of the band-passed data and solve

$$S_1 w = \lambda\,(S_1 + S_2)\,w .$$

Each eigenvector w is a spatial filter: a weighting of the channels whose variance is λ in class 1 and 1 − λ in class 2. Eigenvalues near 1 give filters that are loud in left imagery and quiet in right; near 0, the reverse; near 0.5, filters that do not separate the classes at all.

The filters below are fitted on all of this subject's epochs. That is legitimate for looking at the spatial maps and illegitimate for measuring accuracy — section 5 measures exactly how illegitimate.

In [4]:
with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    csp_display = CSP(n_components=PIPELINE["n_components"], log=True, norm_trace=False, reg=None)
    csp_display.fit(X_f, y_f)

# Recompute the eigenvalues from the stored filters rather than quoting them, so the two cannot disagree.
S1 = np.mean([np.cov(x) for x in X_f[y_f == 1]], axis=0)
S2 = np.mean([np.cov(x) for x in X_f[y_f == 2]], axis=0)
eig = []
for w in csp_display.filters_[:PIPELINE["n_components"]]:
    eig.append(float(w @ S1 @ w / (w @ (S1 + S2) @ w)))
print(f"CSP eigenvalues of the {PIPELINE['n_components']} stored filters "
      f"(lambda = w'S_left w / w'(S_left + S_right) w):")
print("  " + "  ".join(f"{e:.4f}" for e in eig))
print("  1 = all the variance is in left imagery, 0 = all in right, 0.5 = the filter does not separate them")
print()
print(f"w-csp-explorer ships {[0.176603, 0.245932, 0.263226, 0.681189, 0.319435, 0.321245]} for the same")
print("subject, runs, band and window.  CSP eigenvectors are defined up to sign and their ORDER depends on")
print("the library's sorting convention, so the two lists are compared as sets of distances from 0.5, not")
print("element by element:")
mine = sorted(abs(e - 0.5) for e in eig)
theirs = sorted(abs(e - 0.5) for e in [0.176603, 0.245932, 0.263226, 0.681189, 0.319435, 0.321245])
print(f"  this notebook : {[round(v, 4) for v in mine]}")
print(f"  the widget    : {[round(v, 4) for v in theirs]}")
print(f"  largest difference: {max(abs(a - b) for a, b in zip(mine, theirs)):.4f}")
CSP eigenvalues of the 6 stored filters (lambda = w'S_left w / w'(S_left + S_right) w):
  0.1766  0.2459  0.2632  0.6812  0.3194  0.3212
  1 = all the variance is in left imagery, 0 = all in right, 0.5 = the filter does not separate them

w-csp-explorer ships [0.176603, 0.245932, 0.263226, 0.681189, 0.319435, 0.321245] for the same
subject, runs, band and window.  CSP eigenvectors are defined up to sign and their ORDER depends on
the library's sorting convention, so the two lists are compared as sets of distances from 0.5, not
element by element:
  this notebook : [0.1788, 0.1806, 0.1812, 0.2368, 0.2541, 0.3234]
  the widget    : [0.1788, 0.1806, 0.1812, 0.2368, 0.2541, 0.3234]
  largest difference: 0.0000

3 · Filters are not patterns, and only patterns are interpretable

A CSP filter w says how to weight the sensors to recover a component. A CSP pattern a says how that component projects onto the sensors. They are different objects and they look different, and only the pattern can be read as a topography:

$$a = \frac{\Sigma_x w}{w^\top \Sigma_x w}$$

(the Haufe transformation). A large weight in a filter often marks a channel the filter is using to subtract something — noise, or a neighbouring source — so reading a filter as "where the activity is" routinely puts the activity in the wrong place.

In [5]:
n_show = 4
fig, axes = plt.subplots(2, n_show, figsize=(3.0 * n_show, 6.0))
for k in range(n_show):
    im0, _ = mne.viz.plot_topomap(csp_display.filters_[k], info_f, axes=axes[0, k], show=False,
                                  contours=4, sensors=True)
    im1, _ = mne.viz.plot_topomap(csp_display.patterns_[k], info_f, axes=axes[1, k], show=False,
                                  contours=4, sensors=True)
    axes[0, k].set_title(f"component {k + 1}\nlambda = {eig[k]:.3f}", fontsize=9)
axes[0, 0].set_ylabel("FILTER (not interpretable)", fontsize=9)
axes[1, 0].set_ylabel("PATTERN (interpretable)", fontsize=9)
cb0 = fig.colorbar(im0, ax=axes[0, :], shrink=0.8); cb0.set_label("filter weight (arbitrary units)")
cb1 = fig.colorbar(im1, ax=axes[1, :], shrink=0.8); cb1.set_label("pattern (arbitrary units)")
fig.suptitle(f"ds-eegbci S{FEATURED:03d}, {BAND[0]:g}-{BAND[1]:g} Hz: CSP filters above, patterns below -- "
             f"the same components, and they do not look alike", y=1.0, fontsize=11)
plt.show()   # render the static figure(s) of this cell inline

print("Agreement between each filter and its own pattern, as an across-channel correlation:")
for k in range(PIPELINE["n_components"]):
    r = float(np.corrcoef(csp_display.filters_[k], csp_display.patterns_[k])[0, 1])
    fp = ch_f[int(np.argmax(np.abs(csp_display.filters_[k])))]
    pp = ch_f[int(np.argmax(np.abs(csp_display.patterns_[k])))]
    print(f"  component {k + 1}: r = {r:+.3f}; largest |filter| weight at {fp:>4s}, "
          f"largest |pattern| value at {pp:>4s}")
print()
print("Where the two disagree about which channel matters most, the FILTER is the one that is not answering")
print("the question a reader will assume it answers.  TODO(confirm): whether the physiologically plausible")
print("pattern for this subject is the one over left or right sensorimotor cortex is a judgement for the")
print("author; the notebook reports which channels carry the extrema and does not label them.")
Figure 1 of notebook nb-5-7-csp, an output plot. The text around it states what it shows and the units of every axis.
Agreement between each filter and its own pattern, as an across-channel correlation:
  component 1: r = +0.631; largest |filter| weight at  FC3, largest |pattern| value at  FC5
  component 2: r = +0.564; largest |filter| weight at  CP5, largest |pattern| value at  CP5
  component 3: r = +0.141; largest |filter| weight at   C4, largest |pattern| value at   P6
  component 4: r = +0.291; largest |filter| weight at   C3, largest |pattern| value at  CP3
  component 5: r = +0.158; largest |filter| weight at   Iz, largest |pattern| value at   O2
  component 6: r = +0.292; largest |filter| weight at   Iz, largest |pattern| value at   O2

Where the two disagree about which channel matters most, the FILTER is the one that is not answering
the question a reader will assume it answers.  TODO(confirm): whether the physiologically plausible
pattern for this subject is the one over left or right sensorimotor cortex is a judgement for the
author; the notebook reports which channels carry the extrema and does not label them.

4 · Cross-validated accuracy, with the spatial filter inside the fold

The estimator is a scikit-learn Pipeline, so cross_val_score refits CSP and LDA together on each training fold. That is the whole point: CSP is a supervised transform, and a supervised transform fitted outside the fold has already seen the test labels.

In [6]:
CV = StratifiedKFold(n_splits=5, shuffle=True, random_state=L5.SEED)


def csp_lda(n_components):
    return Pipeline([("csp", CSP(n_components=n_components, log=True, norm_trace=False, reg=None)),
                     ("lda", LinearDiscriminantAnalysis())])


def cv_scores(X, y, estimator=None, n_components=PIPELINE["n_components"]):
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        return cross_val_score(estimator or csp_lda(n_components), X, y, cv=CV, n_jobs=1)


scores = cv_scores(X_f, y_f)
ACC = float(scores.mean())
print(f"ds-eegbci S{FEATURED:03d}, CSP-{PIPELINE['n_components']} + LDA, "
      f"{CV.get_n_splits()}-fold stratified cross-validation:")
print(f"  fold scores: {[f'{s:.4f}' for s in scores]}")
print(f"  mean {ACC:.4f}, SD across folds {scores.std():.4f}")
print()
n = len(y_f)
from scipy import stats
lo, hi = stats.binom.ppf([0.025, 0.975], n, 0.5) / n
print(f"  chance is 0.5 with two balanced classes, but with {n} epochs the binomial 95 % interval around")
print(f"  chance runs from {lo:.3f} to {hi:.3f}.  A single subject inside that band is not evidence of "
      f"anything.")
print()
print(f"  w-csp-explorer reports 0.8667 for the same subject, runs, band, window, folds and seed.")
print(f"  This notebook: {ACC:.4f}.  Difference: {ACC - 0.8667:+.4f}")
ds-eegbci S002, CSP-6 + LDA, 5-fold stratified cross-validation:
  fold scores: ['0.8889', '0.6667', '1.0000', '1.0000', '0.7778']
  mean 0.8667, SD across folds 0.1296

  chance is 0.5 with two balanced classes, but with 45 epochs the binomial 95 % interval around
  chance runs from 0.356 to 0.644.  A single subject inside that band is not evidence of anything.

  w-csp-explorer reports 0.8667 for the same subject, runs, band, window, folds and seed.
  This notebook: 0.8667.  Difference: -0.0000

5 · Two ways the number moves, and neither is a better analysis

More components. The same data and the same folds, with the feature set changed: the log band power at C3 and C4 with no spatial filter at all, then CSP with 2, 4, 6 and 8 components.

The fold boundary. The same data, the same folds, the same seed — and CSP fitted once on every epoch before the cross-validation starts, so the spatial filters have already seen the test folds' labels.

In [7]:
class BandPower:
    """Log variance of a fixed channel subset: the simplest possible 'spatial filter', namely none."""

    def __init__(self, picks):
        self.picks = picks

    def fit(self, X, y=None):
        return self

    def transform(self, X):
        return np.log(X[:, self.picks, :].var(axis=-1))

    def fit_transform(self, X, y=None):
        return self.transform(X)

    def get_params(self, deep=True):
        return {"picks": self.picks}

    def set_params(self, **kw):
        self.picks = kw.get("picks", self.picks)
        return self


c3c4 = [ch_f.index("C3"), ch_f.index("C4")]
stages = [("log band power at C3 and C4 only (no spatial filter)",
           Pipeline([("bp", BandPower(c3c4)), ("lda", LinearDiscriminantAnalysis())]), 2)]
for k in (2, 4, 6, 8):
    stages.append((f"CSP, {k} components -> LDA", csp_lda(k), k))

WIDGET_STAGES = {"log band power at C3 and C4 only (no spatial filter)": 0.7333,
                 "CSP, 2 components -> LDA": 0.5111, "CSP, 4 components -> LDA": 0.8667,
                 "CSP, 6 components -> LDA": 0.8667, "CSP, 8 components -> LDA": 0.8889}
print(f"{'feature set':>52s} {'features':>9s} {'mean':>7s} {'SD':>7s} {'widget':>8s} {'difference':>11s}")
stage_rows = []
for label, est, n_feat in stages:
    sc = cv_scores(X_f, y_f, estimator=est)
    w = WIDGET_STAGES[label]
    stage_rows.append((label, n_feat, float(sc.mean()), float(sc.std()), w))
    print(f"{label:>52s} {n_feat:9d} {sc.mean():7.4f} {sc.std():7.4f} {w:8.4f} {sc.mean() - w:+11.4f}")
print()
print("CSP with two components is WORSE than no spatial filter at all on this subject.  That is a real result")
print("and it is worth keeping: two components means one filter per class, and one filter per class is not")
print("enough to describe this subject's sensorimotor rhythms.  More components is not monotonically better")
print("either -- it is a hyperparameter, and choosing it by looking at the cross-validated score is itself a")
print("selection (Level 6).")
                                         feature set  features    mean      SD   widget  difference
log band power at C3 and C4 only (no spatial filter)         2  0.7333  0.1507   0.7333     +0.0000
                            CSP, 2 components -> LDA         2  0.5111  0.1805   0.5111     +0.0000
                            CSP, 4 components -> LDA         4  0.8667  0.1296   0.8667     -0.0000
                            CSP, 6 components -> LDA         6  0.8667  0.1296   0.8667     -0.0000
                            CSP, 8 components -> LDA         8  0.8889  0.0703   0.8889     -0.0000

CSP with two components is WORSE than no spatial filter at all on this subject.  That is a real result
and it is worth keeping: two components means one filter per class, and one filter per class is not
enough to describe this subject's sensorimotor rhythms.  More components is not monotonically better
either -- it is a hyperparameter, and choosing it by looking at the cross-validated score is itself a
selection (Level 6).
In [8]:
# The leak: fit CSP once on everything, cross-validate only the classifier.
with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    leaked_csp = CSP(n_components=PIPELINE["n_components"], log=True, norm_trace=False, reg=None)
    F = leaked_csp.fit_transform(X_f, y_f)           # <- the labels of every epoch are used here
    leaked = cross_val_score(LinearDiscriminantAnalysis(), F, y_f, cv=CV, n_jobs=1)
LEAKED = float(leaked.mean())
print(f"honest  (CSP refitted inside each training fold): {ACC:.4f}   folds {[f'{s:.3f}' for s in scores]}")
print(f"leaked  (CSP fitted once on all {len(y_f)} epochs) : {LEAKED:.4f}   "
      f"folds {[f'{s:.3f}' for s in leaked]}")
print(f"inflation: {LEAKED - ACC:+.4f} ({100 * (LEAKED - ACC):+.1f} percentage points)")
print()
print("Same data, same folds, same seed, same classifier.  The only difference is where the fold boundary")
print("sits relative to the spatial filter.  w-csp-explorer reports 0.8667 honest against 0.9778 leaked, an")
print(f"inflation of 0.1111; this notebook gets {ACC:.4f} against {LEAKED:.4f}, an inflation of "
      f"{LEAKED - ACC:.4f}.")
print()
print("Note what it does NOT look like: the leaked number is not absurd, it does not fail any internal check,")
print("and nothing in the output says 'leak'.  It is simply 11 points too high.")
honest  (CSP refitted inside each training fold): 0.8667   folds ['0.889', '0.667', '1.000', '1.000', '0.778']
leaked  (CSP fitted once on all 45 epochs) : 0.9778   folds ['1.000', '1.000', '1.000', '1.000', '0.889']
inflation: +0.1111 (+11.1 percentage points)

Same data, same folds, same seed, same classifier.  The only difference is where the fold boundary
sits relative to the spatial filter.  w-csp-explorer reports 0.8667 honest against 0.9778 leaked, an
inflation of 0.1111; this notebook gets 0.8667 against 0.9778, an inflation of 0.1111.

Note what it does NOT look like: the leaked number is not absurd, it does not fail any internal check,
and nothing in the output says 'leak'.  It is simply 11 points too high.

6 · The subject was selected by accuracy

The number in section 4 belongs to a subject chosen because it was high. The honest picture is the whole distribution, and it is much less encouraging.

In [9]:
survey = []
for s in CANDIDATES:
    X, y, _, _ = data[s]
    sc = cv_scores(X, y)
    survey.append((s, len(y), float(sc.mean()), float(sc.std()), [float(v) for v in sc]))
means = np.array([r[2] for r in survey])
print(f"CSP-{PIPELINE['n_components']} + LDA, identical pipeline, {len(survey)} candidate subjects:")
print(f"{'subject':>8s} {'epochs':>7s} {'mean':>7s} {'SD':>7s}  fold scores")
for s, n_ep, m, sd, sc in survey:
    mark = "  <- the featured subject" if s == FEATURED else ""
    print(f"{'S%03d' % s:>8s} {n_ep:7d} {m:7.4f} {sd:7.4f}  {[f'{v:.3f}' for v in sc]}{mark}")
print()
print(f"  median {np.median(means):.4f}   mean {means.mean():.4f}   range {means.min():.4f}-{means.max():.4f}")
print(f"  subjects above the upper edge of the chance band ({hi:.3f}): "
      f"{int((means > hi).sum())} of {len(means)}")
print()
print("A FINDING, reported rather than reconciled.  The Phase 3 integration notes record the Level 5 decoding")
print("distribution as 'a median of 60.0 %' with a range of 44.4-95.6 %.  The range matches this run and the")
print("widget's own stored survey exactly.  The median does not: the twelve subject means stored in")
print("w-csp-explorer/csp.json have a median of 0.5556 and a MEAN of 0.6037, and 60.0 % is the mean, not the")
print(f"median.  This notebook's own twelve subjects give median {np.median(means):.4f} and mean "
      f"{means.mean():.4f}.")
print("Both statistics are worth quoting and they are not interchangeable; the lesson should say which it")
print("means.  TODO(confirm) for the author.")
CSP-6 + LDA, identical pipeline, 12 candidate subjects:
 subject  epochs    mean      SD  fold scores
    S001      45  0.6667  0.1405  ['0.778', '0.556', '0.778', '0.778', '0.444']
    S002      45  0.8667  0.1296  ['0.889', '0.667', '1.000', '1.000', '0.778']  <- the featured subject
    S003      45  0.5111  0.1133  ['0.444', '0.556', '0.667', '0.333', '0.556']
    S004      45  0.4889  0.0889  ['0.556', '0.333', '0.556', '0.444', '0.556']
    S005      45  0.4444  0.0994  ['0.333', '0.556', '0.444', '0.556', '0.333']
    S006      45  0.6000  0.1507  ['0.556', '0.889', '0.444', '0.556', '0.556']
    S007      45  0.9556  0.0544  ['0.889', '0.889', '1.000', '1.000', '1.000']
    S008      45  0.6222  0.0889  ['0.667', '0.667', '0.667', '0.667', '0.444']
    S009      45  0.4444  0.0703  ['0.444', '0.444', '0.333', '0.444', '0.556']
    S010      45  0.4667  0.1296  ['0.556', '0.444', '0.333', '0.667', '0.333']
    S011      45  0.5111  0.0544  ['0.556', '0.556', '0.444', '0.444', '0.556']
    S012      45  0.6667  0.1988  ['0.333', '0.889', '0.778', '0.778', '0.556']

  median 0.5556   mean 0.6037   range 0.4444-0.9556
  subjects above the upper edge of the chance band (0.644): 4 of 12

A FINDING, reported rather than reconciled.  The Phase 3 integration notes record the Level 5 decoding
distribution as 'a median of 60.0 %' with a range of 44.4-95.6 %.  The range matches this run and the
widget's own stored survey exactly.  The median does not: the twelve subject means stored in
w-csp-explorer/csp.json have a median of 0.5556 and a MEAN of 0.6037, and 60.0 % is the mean, not the
median.  This notebook's own twelve subjects give median 0.5556 and mean 0.6037.
Both statistics are worth quoting and they are not interchangeable; the lesson should say which it
means.  TODO(confirm) for the author.
In [10]:
fig, axes = plt.subplots(1, 2, figsize=(12.5, 4.2))
o = np.argsort(means)
axes[0].bar(range(len(survey)), means[o], color=["C1" if survey[i][0] == FEATURED else "C0" for i in o])
axes[0].axhline(0.5, color="k", lw=1, ls="--")
axes[0].axhspan(lo, hi, color="grey", alpha=0.25)
axes[0].axhline(np.median(means), color="C2", lw=1.5, label=f"median {np.median(means):.3f}")
axes[0].axhline(means.mean(), color="C3", lw=1.5, ls=":", label=f"mean {means.mean():.3f}")
axes[0].set_xticks(range(len(survey)))
axes[0].set_xticklabels([f"S{survey[i][0]:03d}" for i in o], rotation=45, fontsize=7)
axes[0].set_ylabel("cross-validated accuracy (proportion correct)")
axes[0].set_title(f"twelve candidates; the grey band is the 95 % chance interval for {n} epochs", fontsize=9)
axes[0].legend(fontsize=8); axes[0].set_ylim(0, 1.05)

labels = [r[0].replace(" -> LDA", "").replace(" (no spatial filter)", "") for r in stage_rows]
axes[1].bar(range(len(stage_rows)), [r[2] for r in stage_rows],
            yerr=[r[3] for r in stage_rows], capsize=3, color="C0")
axes[1].bar([len(stage_rows)], [LEAKED], color="C3")
axes[1].set_xticks(range(len(stage_rows) + 1))
axes[1].set_xticklabels(labels + ["CSP-6 LEAKED"], rotation=30, ha="right", fontsize=7)
axes[1].axhline(0.5, color="k", lw=1, ls="--")
axes[1].axhspan(lo, hi, color="grey", alpha=0.25)
axes[1].set_ylabel("cross-validated accuracy (proportion correct)")
axes[1].set_title(f"S{FEATURED:03d}: feature set, and the cost of one misplaced fold boundary", fontsize=9)
axes[1].set_ylim(0, 1.05)
fig.suptitle("ds-eegbci motor imagery, 8-30 Hz, 0.5-2.5 s window, 5-fold stratified cross-validation",
             y=1.02, fontsize=10)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
Figure 2 of notebook nb-5-7-csp, an output plot. The text around it states what it shows and the units of every axis.

7 · The numbers

In [11]:
print("nb-5-7-csp -- L5.7 numbers (draft; TODO(confirm) at author review)")
print(f"Data: ds-eegbci runs R04+R08+R12, {BAND[0]:g}-{BAND[1]:g} Hz, epochs "
      f"{EPOCH_S[0]:g} to {EPOCH_S[1]:g} s, analysis window {WINDOW_S[0]:g}-{WINDOW_S[1]:g} s, "
      f"{X_f.shape[1]} channels at {info_f['sfreq']:g} Hz")
print(f"      {L5.licence_line('ds-eegbci')}")
print(f"Estimator: {PIPELINE['estimator']}")
print(f"Cross-validation: {PIPELINE['cv']}")
print(f"Downloads: 3 EDF runs x {len(CANDIDATES)} subjects, about 7.7 MB each, each deleted as soon as its "
      f"epochs were in memory")
print()
print(f"ANSWER KEY -- ex-5-7 (cross-validated accuracy for one subject): S{FEATURED:03d}, "
      f"{ACC:.4f} ({100 * ACC:.1f} %)")
print(f"    fold scores {[f'{s:.4f}' for s in scores]}, SD across folds {scores.std():.4f}, "
      f"{len(y_f)} epochs")
print(f"    THE SUBJECT WAS SELECTED BY ACCURACY.  Across the {len(survey)} candidates the range is "
      f"{means.min():.3f}-{means.max():.3f},")
print(f"    the median is {np.median(means):.4f} and the mean is {means.mean():.4f}.  Quoting "
      f"{100 * ACC:.1f} % on its own is the selection")
print("    effect Level 6 exists to warn about, so the key must carry the median and the range beside it.")
print(f"    Chance band for {len(y_f)} epochs: {lo:.3f} to {hi:.3f}; "
      f"{int((means > hi).sum())} of {len(means)} subjects beat it.")
print()
print("    per-subject distribution:")
for s, n_ep, m, sd, sc in survey:
    print(f"      S{s:03d}  {m:.4f}  (SD {sd:.4f})" + ("   <- featured" if s == FEATURED else ""))
print()
print("ANSWER KEY -- feature set (same subject, same folds, same seed):")
print(f"{'':6s}{'feature set':>52s} {'here':>8s} {'widget':>8s}")
for label, n_feat, m, sd, w in stage_rows:
    print(f"{'':6s}{label:>52s} {m:8.4f} {w:8.4f}")
print(f"{'':6s}CSP-2 scores {dict((r[0], r[2]) for r in stage_rows)['CSP, 2 components -> LDA']:.4f}, "
      f"which is WORSE than the "
      f"{dict((r[0], r[2]) for r in stage_rows)['log band power at C3 and C4 only (no spatial filter)']:.4f} "
      f"of two raw channels.")
print()
print(f"ANSWER KEY -- the cost of leakage (ex-5-7 / ex-6-5): honest {ACC:.4f} against leaked {LEAKED:.4f}, "
      f"an inflation of")
print(f"    {LEAKED - ACC:+.4f} ({100 * (LEAKED - ACC):+.1f} percentage points).  Same subject, same epochs, "
      f"same folds, same seed; the only")
print("    change is whether CSP is fitted inside the cross-validation or once on everything.")
print(f"    w-csp-explorer reports 0.8667 against 0.9778, an inflation of 0.1111.")
print()
print("ANSWER KEY -- filters against patterns:")
for k in range(PIPELINE["n_components"]):
    r = float(np.corrcoef(csp_display.filters_[k], csp_display.patterns_[k])[0, 1])
    print(f"      component {k + 1}: lambda {eig[k]:.4f}, filter/pattern correlation r = {r:+.3f}, "
          f"|filter| peak {ch_f[int(np.argmax(np.abs(csp_display.filters_[k])))]}, "
          f"|pattern| peak {ch_f[int(np.argmax(np.abs(csp_display.patterns_[k])))]}")
print("      Only the patterns may be read as topographies.  The filters and patterns of the same component")
print("      disagree about which channel matters most, which is exactly why reading a filter is a mistake.")
print()
print("Pitfall: pf-decoding-leakage.  Widget: w-csp-explorer (mode patterns).")
print(L5.disk_line("disk at the end"))
nb-5-7-csp -- L5.7 numbers (draft; TODO(confirm) at author review)
Data: ds-eegbci runs R04+R08+R12, 8-30 Hz, epochs -1 to 4 s, analysis window 0.5-2.5 s, 64 channels at 160 Hz
      ds-eegbci — EEG Motor Movement/Imagery Dataset (EEGMMIDB): licence ODC-By-1.0, access open (data/directory.yaml)
Estimator: Pipeline([('csp', mne.decoding.CSP(n_components=6, log=True, norm_trace=False)), ('lda', LinearDiscriminantAnalysis())])
Cross-validation: StratifiedKFold(n_splits=5, shuffle=True, random_state=20260918)
Downloads: 3 EDF runs x 12 subjects, about 7.7 MB each, each deleted as soon as its epochs were in memory

ANSWER KEY -- ex-5-7 (cross-validated accuracy for one subject): S002, 0.8667 (86.7 %)
    fold scores ['0.8889', '0.6667', '1.0000', '1.0000', '0.7778'], SD across folds 0.1296, 45 epochs
    THE SUBJECT WAS SELECTED BY ACCURACY.  Across the 12 candidates the range is 0.444-0.956,
    the median is 0.5556 and the mean is 0.6037.  Quoting 86.7 % on its own is the selection
    effect Level 6 exists to warn about, so the key must carry the median and the range beside it.
    Chance band for 45 epochs: 0.356 to 0.644; 4 of 12 subjects beat it.

    per-subject distribution:
      S001  0.6667  (SD 0.1405)
      S002  0.8667  (SD 0.1296)   <- featured
      S003  0.5111  (SD 0.1133)
      S004  0.4889  (SD 0.0889)
      S005  0.4444  (SD 0.0994)
      S006  0.6000  (SD 0.1507)
      S007  0.9556  (SD 0.0544)
      S008  0.6222  (SD 0.0889)
      S009  0.4444  (SD 0.0703)
      S010  0.4667  (SD 0.1296)
      S011  0.5111  (SD 0.0544)
      S012  0.6667  (SD 0.1988)

ANSWER KEY -- feature set (same subject, same folds, same seed):
                                               feature set     here   widget
      log band power at C3 and C4 only (no spatial filter)   0.7333   0.7333
                                  CSP, 2 components -> LDA   0.5111   0.5111
                                  CSP, 4 components -> LDA   0.8667   0.8667
                                  CSP, 6 components -> LDA   0.8667   0.8667
                                  CSP, 8 components -> LDA   0.8889   0.8889
      CSP-2 scores 0.5111, which is WORSE than the 0.7333 of two raw channels.

ANSWER KEY -- the cost of leakage (ex-5-7 / ex-6-5): honest 0.8667 against leaked 0.9778, an inflation of
    +0.1111 (+11.1 percentage points).  Same subject, same epochs, same folds, same seed; the only
    change is whether CSP is fitted inside the cross-validation or once on everything.
    w-csp-explorer reports 0.8667 against 0.9778, an inflation of 0.1111.

ANSWER KEY -- filters against patterns:
      component 1: lambda 0.1766, filter/pattern correlation r = +0.631, |filter| peak FC3, |pattern| peak FC5
      component 2: lambda 0.2459, filter/pattern correlation r = +0.564, |filter| peak CP5, |pattern| peak CP5
      component 3: lambda 0.2632, filter/pattern correlation r = +0.141, |filter| peak C4, |pattern| peak P6
      component 4: lambda 0.6812, filter/pattern correlation r = +0.291, |filter| peak C3, |pattern| peak CP3
      component 5: lambda 0.3194, filter/pattern correlation r = +0.158, |filter| peak Iz, |pattern| peak O2
      component 6: lambda 0.3212, filter/pattern correlation r = +0.292, |filter| peak Iz, |pattern| peak O2
      Only the patterns may be read as topographies.  The filters and patterns of the same component
      disagree about which channel matters most, which is exactly why reading a filter is a mistake.

Pitfall: pf-decoding-leakage.  Widget: w-csp-explorer (mode patterns).
disk at the end: 4.27 GB free on the working volume