Decoding and BCI with moabb: CSP + LDA, a Riemannian tangent-space classifier and filter-bank CSP, benchmarked within session, across sessions and across subjects on motor imagery and on a dry-electrode P300, with session and EOG leakage measured

nb-7-1-moabb Level 7 · Applied Electives ~13 min Used in L7.1 · Decoding and BCI

Downloads from ds-bci-iv-2a, ds-brain-invaders 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-7-1-moabb · Decoding and BCI (L7.1)

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

Three pipelines, two paradigms, three places to put the fold boundary. The question the lesson ends on is "which pipeline wins cross-subject and by how much", and the honest answer turns out to be about the and by how much.

  1. Motor imagery on ds-bci-iv-2a — CSP + LDA, a Riemannian tangent-space classifier, and filter-bank CSP, evaluated within session, across sessions (different days) and across subjects.
  2. P300 on ds-brain-invaders — the same three families adapted to an evoked response, within subject and across subjects.
  3. Leakage, measured rather than asserted: a random split pooled over two sessions against the train-on-day-one / test-on-day-two split the data set's own design gives, and a check of whether the EOG channels the authors forbid as features actually carry the label.

Data.

  • ds-bci-iv-2a — BCI Competition IV data set 2a / BNCI Horizon 001-2014, Tangermann et al. (2012), Review of the BCI Competition IV, Frontiers in Neuroscience 6, 55, DOI 10.3389/fnins.2012.00055. No dataset DOI exists (TODO(confirm)). Licence CC BY-ND 4.0, access: open. 9 subjects, 2 sessions on different days, 22 EEG + 3 EOG at 250 Hz.
  • ds-brain-invaders — Brain Invaders bi2014a, Korczowski et al. (2019), GIPSA-lab research report HAL hal-02171575; dataset DOI 10.5281/zenodo.3266223. Licence CC BY 4.0, access: open. 16 dry electrodes at 512 Hz.

No derivatives ship from ds-bci-iv-2a. CC BY-ND is a no-derivatives licence and every snippet this site ships is a derivative, so this notebook downloads the data, computes on it and publishes tables and figures of its own results — a benchmark table is a report of findings, not a redistribution of the recording — and ships nothing cut from the signal. w-csp-explorer keeps using ds-eegbci. The licence was verified from primary material; see site/notes/data-p4-licences.md.

Two instructions from the data set's own documentation are followed and one of them is tested. The EOG channels "must not be used for classification", and the two sessions are on different days so a random pooled split leaks. Section 4 measures both rather than taking them on trust.

The compact CNN spec §6 asks for is not run. braindecode and torch are not in the pinned stack; filter-bank CSP stands in as the third pipeline family, and section 0 prints exactly what that substitution does and does not answer. No published EEGNet accuracy is quoted from memory.

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

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

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

import matplotlib.pyplot as plt
import numpy as np
import mne
import pooch
from scipy import stats
from sklearn.base import clone
from sklearn.metrics import confusion_matrix, get_scorer
from sklearn.model_selection import StratifiedKFold, cross_val_predict, cross_val_score

mne.set_log_level("WARNING")
# Silence the downloader at the source rather than scrubbing cache paths out of stored outputs
# afterwards: a re-execution would put them straight back (Phase 3 note in notebooks/README.md).
pooch.get_logger().setLevel("WARNING")
plt.rcParams["figure.dpi"] = 72

SEED = L7.SEED
MI_SUBJECTS = list(range(1, 10))          # every ds-bci-iv-2a subject
P300_SUBJECTS = [1, 2, 3, 4, 5, 6]        # a documented subset of bi2014a's 64
FOLDS = 5

print(f"MNE {mne.__version__}; helpers_l7 imported from notebooks/_shared")
print(f"moabb download provider pinned to: {L7.use_upstream_moabb()}")
print(f"seed {SEED}; {len(MI_SUBJECTS)} motor-imagery subjects, {len(P300_SUBJECTS)} P300 subjects, "
      f"{FOLDS}-fold cross-validation")
MNE 1.10.2; helpers_l7 imported from notebooks/_shared
moabb download provider pinned to: upstream
seed 20260918; 9 motor-imagery subjects, 6 P300 subjects, 5-fold cross-validation

0 · What may ship, what the data set's own rules are, and what is missing

Read before any number: a licence decides what leaves this notebook, and two of the data set's own instructions decide how the pipelines are allowed to be built.

In [2]:
for ds in ("ds-bci-iv-2a", "ds-brain-invaders"):
    print(L7.dataset_line(ds))
    print()

print("The data set's own instructions (from its description document, not from its licence):")
for k, v in L7.BCI_IV_2A_RULES.items():
    print(f"  [{k}] {v}")

print("\nThe third pipeline spec section 6 asks for:")
for k in ("asked_for", "status", "why", "substitute", "what_it_does_not_answer"):
    print(f"  [{k}] {L7.CNN_ARM[k]}")
ds-bci-iv-2a — BCI Competition IV data set 2a (BNCI Horizon 001-2014, "Graz data set A")
    licence CC-BY-ND-4.0, access open, snippets no; DOI TODO(confirm)
    CC BY-ND 4.0: NO DERIVATIVES.  This notebook may download the data and compute on it — ND restricts distributing modified copies, not analysis — and it may publish tables and figures of its OWN RESULTS, because a benchmark table is a report of findings and not a redistribution of the recording.  NOTHING derived from its signal ships: no snippet, no trace, no precomputed CSP product, no figure built from its samples.  w-csp-explorer keeps using ds-eegbci.

ds-brain-invaders — Brain Invaders bi2014a
    licence CC-BY-4.0, access open, snippets allowed; DOI 10.5281/zenodo.3266223
    CC BY 4.0, access open: class A.  Derived assets may ship with attribution.

The data set's own instructions (from its description document, not from its licence):
  [eog] "The EOG channels are provided for the subsequent application of artifact processing methods and must not be used for classification."  A pipeline that leaves the 3 EOG channels in its feature set does the thing the dataset explicitly forbids, and teaches exactly the artifact-driven decoding pf-decoding-leakage warns about.
  [sessions] "Two sessions on different days were recorded for each subject."  Session T is the labelled training session and session E the evaluation session.  A random split over the pooled sessions leaks session information across the fold boundary; train-on-T / test-on-E is a genuine cross-session evaluation.
  [causality] "All algorithms must be causal" — a competition rule rather than a licence term, and the reason L7.3 exists.
  [artifacts] Trials the experts marked as containing artifacts are flagged in the files (event type 1023 and an ArtifactSelection list), so an artifact-rejection step here can use the experts' marks or ignore them.  Which choice is made changes the accuracy and the notebook must say which it made.
  [filters] An analog 0.5-100 Hz band-pass and a 50 Hz notch were applied at acquisition, so this data is already filtered.

The third pipeline spec section 6 asks for:
  [asked_for] spec section 6 L7.1: 'benchmark CSP + LDA, Riemannian-geometry classifiers and a compact CNN (EEGNet) with moabb'.
  [status] NOT RUN.
  [why] braindecode and torch are absent from the environment these notebooks were executed in and from notebooks/requirements.txt, and CONTRACTS.md says to avoid torch.  Training EEGNet for 9 subjects x 2 sessions x 5 folds plus leave-one-subject-out on a free-tier CPU would also not fit the spec section 11 ten-minute budget.
  [substitute] Filter-bank CSP (FBCSP) stands in as the third pipeline.  It is a third FAMILY — many narrow sub-bands with supervised feature selection, rather than one broad band — so the benchmark still compares three genuinely different approaches.
  [what_it_does_not_answer] Whether a learned convolutional representation beats hand-designed spatial filters on this data, and whether it transfers across subjects better.  Those are the questions EEGNet is in the lesson for, and this notebook does not answer them.  No published EEGNet accuracy is quoted here from memory: that would be a fabricated comparison.  TODO(confirm) for the author: whether to pin braindecode + torch as optional dependencies and add the arm, or to reword the L7.1 objective.

1 · One extraction, three pipelines

The three pipelines see the same trials, the same channels, the same window and the same folds. They differ in the classifier and in nothing else, which is the only way a benchmark table means anything.

Band-pass filtering does not use the labels, so it cannot leak and is done once, outside the cross-validation loop. CSP, the filter bank's CSPs and the feature selection do use the labels and are re-fitted inside every training fold. That line is the whole of pf-decoding-leakage and section 4 measures what crossing it costs.

Each subject is downloaded, epoched and deleted before the next one is fetched, so peak disk is about 87 MB rather than the 780 MB the whole set would take.

In [3]:
for k, v in L7.MI_SPEC.items():
    print(f"  {k:24s} {v}")
  dataset                  ds-bci-iv-2a
  loader                   moabb.datasets.BNCI2014_001() through moabb.paradigms.MotorImagery
  classes                  ('left_hand', 'right_hand')
  classes4                 ('left_hand', 'right_hand', 'feet', 'tongue')
  classes_note             The data set is a FOUR-class paradigm (left hand, right hand, both feet, tongue). The headline arm uses the two hand classes, so chance is 0.5 and the numbers are directly comparable with the ds-eegbci left/right pipeline of L5.7 and L6.5; the four-class arm is run beside it, where chance is 0.25.
  extract_band_hz          (4.0, 40.0)
  extract_window_s         (0.0, 4.0)
  extract_window_note      seconds from the cue at t = 2 s in the trial; moabb's `interval` for this dataset is [2, 6] s from trial onset, so tmin/tmax are measured from the cue.
  resample_hz              128.0
  analysis_band_hz         (8.0, 32.0)
  analysis_window_s        (0.5, 3.5)
  analysis_window_note     0.5-3.5 s after the cue: the cue arrow is on screen for 1.25 s and imagery is held until the fixation cross disappears at 4 s after the cue.
  fb_bands_hz              ((4.0, 8.0), (8.0, 12.0), (12.0, 16.0), (16.0, 20.0), (20.0, 24.0), (24.0, 28.0), (28.0, 32.0))
  filter                   zero-phase 4th-order Butterworth (scipy sosfiltfilt) on the epoch array
  filter_note              Band-pass filtering does not use the labels, so it cannot leak and is done once, outside the cross-validation loop.  CSP and the feature selection DO use the labels and are re-fitted inside every training fold.
  eeg_channels             ('Fz', 'FC3', 'FC1', 'FCz', 'FC2', 'FC4', 'C5', 'C3', 'C1', 'Cz', 'C2', 'C4', 'C6', 'CP3', 'CP1', 'CPz', 'CP2', 'CP4', 'P1', 'Pz', 'P2', 'POz')
  eog_channels             ('EOG1', 'EOG2', 'EOG3')
  eog                      excluded from every classification pipeline.  moabb's paradigm returns the 22 EEG channels and drops the EOG by default; this notebook asks for all 25 on purpose, so that it can TEST the prohibition by decoding from the EOG alone, and then removes them from every pipeline that reports a score.
  artifact_trials          kept.  The expert artifact marks in the files are NOT applied: every trial the paradigm returns is scored.  This is a choice and it changes the accuracy; it is recorded here so the number is reproducible.
  seed                     20260918
  folds                    5
In [4]:
L7.disk_report("before any download",
                folders={"BNCI cache": L7.moabb_root(), "NEMAR cache": L7.nemar_root()})
already_on_disk = [f for s in MI_SUBJECTS for f in L7.bnci_subject_files(s)]
print(f"{len(already_on_disk)} of this dataset's files were already cached before this run; "
      f"they are kept, everything this run fetches is deleted")

t0 = time.time()
mi = {}
for s in MI_SUBJECTS:
    mi[s] = L7.load_bci_iv_2a_subject(s, keep=already_on_disk, four_class=(s == MI_SUBJECTS[0]),
                                      default_picks=(s == MI_SUBJECTS[0]))
print(f"\n{len(mi)} subjects in {time.time() - t0:.0f} s; "
      f"{sum(d['downloaded_mb'] for d in mi.values()):.0f} MB fetched, "
      f"{sum(d['freed_mb'] for d in mi.values()):.0f} MB deleted")
assert len(mi) == len(MI_SUBJECTS), "a subject is missing: the cohort must be complete before any mean"
L7.disk_report("after the download loop",
                folders={"BNCI cache": L7.moabb_root(), "NEMAR cache": L7.nemar_root()})
free disk before any download: 1.90 GB  (BNCI cache 86.6 MB, NEMAR cache 0.0 MB)
2 of this dataset's files were already cached before this run; they are kept, everything this run fetches is deleted
  subject 1: 288 two-class epochs (576 four-class), 22 EEG channels x 384 samples at 128 Hz; 0 MB fetched, 0 MB deleted
  subject 2: 288 two-class epochs (576 four-class), 22 EEG channels x 384 samples at 128 Hz; 87 MB fetched, 87 MB deleted
  subject 3: 288 two-class epochs (576 four-class), 22 EEG channels x 384 samples at 128 Hz; 86 MB fetched, 86 MB deleted
  subject 4: 288 two-class epochs (576 four-class), 22 EEG channels x 384 samples at 128 Hz; 79 MB fetched, 79 MB deleted
  subject 5: 288 two-class epochs (576 four-class), 22 EEG channels x 384 samples at 128 Hz; 87 MB fetched, 87 MB deleted
  subject 6: 288 two-class epochs (576 four-class), 22 EEG channels x 384 samples at 128 Hz; 88 MB fetched, 88 MB deleted
  subject 7: 288 two-class epochs (576 four-class), 22 EEG channels x 384 samples at 128 Hz; 85 MB fetched, 85 MB deleted
  subject 8: 288 two-class epochs (576 four-class), 22 EEG channels x 384 samples at 128 Hz; 91 MB fetched, 91 MB deleted
  subject 9: 288 two-class epochs (576 four-class), 22 EEG channels x 384 samples at 128 Hz; 90 MB fetched, 90 MB deleted
9 subjects in 192 s; 693 MB fetched, 693 MB deleted
free disk after the download loop: 1.81 GB  (BNCI cache 86.6 MB, NEMAR cache 0.0 MB)
Out[4]:
{'free_gb': 1.807241216,
 'folders_mb': {'BNCI cache': 86.578599, 'NEMAR cache': 0.0}}

The EOG channels are absent from the features, and that is checked rather than assumed

The data set's description document says the three EOG channels "must not be used for classification". moabb's paradigm already drops them: asked for the default picks it returns the 22 EEG channels and nothing else. This notebook asks for all 25 on purpose, so that section 4 can test what is in the EOG, and every pipeline that reports a score is fitted on the 22.

In [5]:
d0 = mi[MI_SUBJECTS[0]]
print(f"moabb's default picks (channels=None): {len(d0['default_ch_names'])} channels")
print(f"  {d0['default_ch_names']}")
print(f"EOG among them: {[c for c in d0['default_ch_names'] if c.upper().startswith('EOG')] or 'none'}")
print(f"\nwhat the pipelines are fitted on: {len(d0['ch_names'])} channels, "
      f"{[c for c in d0['ch_names'] if c.upper().startswith('EOG')] or 'no EOG'}")
print(f"what section 4 tests separately: {d0['eog_names']}")
assert not any(c.upper().startswith("EOG") for c in d0["ch_names"])
print(f"\nepochs per subject: {d0['X'].shape[0]} two-class trials, "
      f"{d0['X'].shape[1]} channels x {d0['n_times']} samples at {d0['sfreq']:.0f} Hz")
print(f"filter bank: {d0['X_fb'].shape[1]} sub-bands {L7.MI_SPEC['fb_bands_hz']}")
print(f"arrays held in memory: "
      f"{sum(v.nbytes for d in mi.values() for v in d.values() if isinstance(v, np.ndarray)) / 1e6:.0f} MB")
moabb's default picks (channels=None): 22 channels
  ['Fz', 'FC3', 'FC1', 'FCz', 'FC2', 'FC4', 'C5', 'C3', 'C1', 'Cz', 'C2', 'C4', 'C6', 'CP3', 'CP1', 'CPz', 'CP2', 'CP4', 'P1', 'Pz', 'P2', 'POz']
EOG among them: none

what the pipelines are fitted on: 22 channels, no EOG
what section 4 tests separately: ['EOG1', 'EOG2', 'EOG3']

epochs per subject: 288 two-class trials, 22 channels x 384 samples at 128 Hz
filter bank: 7 sub-bands ((4.0, 8.0), (8.0, 12.0), (12.0, 16.0), (16.0, 20.0), (20.0, 24.0), (24.0, 28.0), (28.0, 32.0))
arrays held in memory: 733 MB

2 · Three evaluations

Where the fold boundary falls changes the number more than the choice of classifier does.

In [6]:
for k, v in L7.EVALUATION_NOTES.items():
    print(f"[{k}]\n  {v}\n")
pipelines = L7.build_mi_pipelines()
for name, p in pipelines.items():
    print(f"{name:24s} {' -> '.join(s for s, _ in p.steps)}")
[within-session]
  Stratified k-fold inside ONE session of ONE subject.  The most optimistic number a BCI paper can report, and the one a calibrated single-session system would actually deliver.

[cross-session]
  Train on session T, test on session E — different days, as the data set's own documentation states.  A random split over the pooled sessions would leak day, cap placement and impedance across the fold boundary; this does not.  This is the number a system that calibrates once and is used again tomorrow gets.

[cross-subject]
  Leave one subject out: train on everyone else, test on the held-out person, who contributed nothing to the fit.  The number a calibration-free system gets, and the hardest of the three.

CSP + LDA                csp -> lda
Riemannian TS + LR       cov -> ts -> sc -> lr
FBCSP + LDA              fbcsp -> sel -> lda
In [7]:
rows = []
t0 = time.time()
rows += L7.within_session_scores(mi, pipelines, folds=FOLDS, seed=SEED, progress=False)
print(f"within-session  {time.time() - t0:5.0f} s")
t1 = time.time()
rows += L7.cross_session_scores(mi, pipelines, progress=False)
print(f"cross-session   {time.time() - t1:5.0f} s")
t1 = time.time()
rows += L7.cross_subject_scores(mi, pipelines, progress=False)
print(f"cross-subject   {time.time() - t1:5.0f} s")
mi_df = L7.benchmark_frame(rows)
print(f"total           {time.time() - t0:5.0f} s\n")
L7.print_benchmark(mi_df, chance=0.5,
                   title="ds-bci-iv-2a, left hand vs right hand, accuracy (dimensionless)")
lo, hi = L6.chance_band(int(mi[MI_SUBJECTS[0]]["X"].shape[0]), 0.5)
print(f"binomial 95 % band around chance for {mi[MI_SUBJECTS[0]]['X'].shape[0]} trials: {lo:.4f}-{hi:.4f}")
within-session     70 s
cross-session      10 s
cross-subject     135 s
total             215 s

ds-bci-iv-2a, left hand vs right hand, accuracy (dimensionless)
evaluation       pipeline                            mean      SD  median     min     max    n       s
------------------------------------------------------------------------------------------------------
within-session   CSP + LDA                         0.7948  0.1539  0.8196  0.5064  0.9862   18     7.7
within-session   FBCSP + LDA                       0.8144  0.1534  0.8714  0.5207  0.9931   18    52.5
within-session   Riemannian TS + LR                0.7959  0.1422  0.8232  0.5128  0.9791   18     9.2
cross-session    CSP + LDA                         0.7731  0.1448  0.7569  0.5417  0.9653    9     1.2
cross-session    FBCSP + LDA                       0.7940  0.1795  0.8472  0.4792  0.9722    9     7.3
cross-session    Riemannian TS + LR                0.7685  0.1454  0.7431  0.5764  0.9861    9     1.8
cross-subject    CSP + LDA                         0.6543  0.1329  0.6250  0.5243  0.9375    9    12.2
cross-subject    FBCSP + LDA                       0.6262  0.0840  0.6250  0.5035  0.7882    9   103.0
cross-subject    Riemannian TS + LR                0.6454  0.1269  0.6042  0.4931  0.8333    9    19.0

chance = 0.5000 (accuracy)
binomial 95 % band around chance for 288 trials: 0.4410-0.5590
In [8]:
fig, _ = L7.plot_benchmark(mi_df, chance=0.5, scoring="accuracy",
                           title="ds-bci-iv-2a: decoding accuracy by pipeline and evaluation "
                                 "(dimensionless, 9 subjects)")
plt.show()
Figure 1 of notebook nb-7-1-moabb, an output plot. The text around it states what it shows and the units of every axis.

Which pipeline wins, and by how much

The margin between pipelines is reported paired across subjects — every pipeline saw the same people and the same trials — because the unpaired spread across subjects is an order of magnitude larger than any difference between the pipelines, and quoting one without the other would say the opposite of what the data says.

In [9]:
mi_winners = {}
for ev in ("within-session", "cross-session", "cross-subject"):
    w = L7.winner(mi_df, ev)
    mi_winners[ev] = w
    print(f"[{ev}]")
    print(f"  ranking: " + ", ".join(f"{k} {v:.4f}" for k, v in w["ranking"]))
    print(f"  {w['winner']} beats {w['runner_up']} by {w['margin']:+.4f} "
          f"(paired mean {w['margin_mean_paired']:+.4f}, paired SD {w['margin_sd_paired']:.4f}, "
          f"t({w['n_subjects'] - 1}) = {w['t']:.2f}, p = {w['p']:.4f})")
    spread = mi_df[mi_df['evaluation'] == ev].groupby('subject', observed=True)['score'].mean().std(ddof=1)
    print(f"  between-subject SD of the mean score: {spread:.4f} "
          f"({spread / max(abs(w['margin']), 1e-9):.0f}x the winning margin)\n")
[within-session]
  ranking: FBCSP + LDA 0.8144, Riemannian TS + LR 0.7959, CSP + LDA 0.7948
  FBCSP + LDA beats Riemannian TS + LR by +0.0186 (paired mean +0.0186, paired SD 0.1122, t(8) = 0.50, p = 0.6330)
  between-subject SD of the mean score: 0.1393 (8x the winning margin)

[cross-session]
  ranking: FBCSP + LDA 0.7940, CSP + LDA 0.7731, Riemannian TS + LR 0.7685
  FBCSP + LDA beats CSP + LDA by +0.0208 (paired mean +0.0208, paired SD 0.1374, t(8) = 0.46, p = 0.6612)
  between-subject SD of the mean score: 0.1438 (7x the winning margin)

[cross-subject]
  ranking: CSP + LDA 0.6543, Riemannian TS + LR 0.6454, FBCSP + LDA 0.6262
  CSP + LDA beats Riemannian TS + LR by +0.0089 (paired mean +0.0089, paired SD 0.1046, t(8) = 0.25, p = 0.8055)
  between-subject SD of the mean score: 0.0984 (11x the winning margin)

3 · The four-class design, and why chance is not a constant

The data set is a four-class paradigm — left hand, right hand, both feet, tongue. The benchmark above uses the two hand classes, so chance is 0.5 and the numbers are directly comparable with the ds-eegbci left/right pipeline of L5.7 and L6.5. Running the same estimator on all four classes on one subject shows what "accuracy" means when the design changes under it, and where the errors go.

In [10]:
d = mi[MI_SUBJECTS[0]]
m = d["session4"] == "0train"
X4, y4 = d["X4"][m].astype(np.float64), d["y4"][m]
cv = StratifiedKFold(n_splits=FOLDS, shuffle=True, random_state=SEED)
with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    sc4 = cross_val_score(L7.make_csp_lda(8), X4, y4, cv=cv, scoring="accuracy")
    pred4 = cross_val_predict(L7.make_csp_lda(8), X4, y4, cv=cv)
classes = d["classes4"]
print(f"subject {d['subject']}, session T, {len(y4)} trials, {len(classes)} classes {classes}")
print(f"  four-class accuracy {sc4.mean():.4f} (chance 0.25)")
sc2 = mi_df[(mi_df['evaluation'] == 'within-session') & (mi_df['subject'] == d['subject'])
            & (mi_df['pipeline'] == 'CSP + LDA') & (mi_df['session'] == '0train')]['score']
print(f"  two-class accuracy on the same session {float(sc2.iloc[0]):.4f} (chance 0.50)")
cm = confusion_matrix(y4, pred4)
print("\nconfusion (rows = true, columns = predicted):")
print(f"{'':12s}" + "".join(f"{c:>12s}" for c in classes))
for i, c in enumerate(classes):
    print(f"{c:12s}" + "".join(f"{int(v):12d}" for v in cm[i]))
subject 1, session T, 288 trials, 4 classes ('feet', 'left_hand', 'right_hand', 'tongue')
  four-class accuracy 0.7672 (chance 0.25)
  two-class accuracy on the same session 0.8468 (chance 0.50)

confusion (rows = true, columns = predicted):
                    feet   left_hand  right_hand      tongue
feet                  48           2           6          16
left_hand              5          49          16           2
right_hand             0           5          66           1
tongue                14           0           0          58

4 · Leakage, measured twice

pf-decoding-leakage in two forms the data set warns about by itself.

4.1 · A random split pooled over two sessions

The two sessions were recorded on different days. Pooling them and cross-validating at random puts trials from the same day, the same cap placement and the same impedances on both sides of every fold boundary. Train on session T and test on session E and nothing crosses. The same estimator, the same trials, the same seed: only the split moves.

In [11]:
cv = StratifiedKFold(n_splits=FOLDS, shuffle=True, random_state=SEED)
acc = get_scorer("accuracy")
pooled, sessionwise = [], []
print(f"{'subject':>8s} {'pooled random':>14s} {'train T / test E':>17s} {'gap':>8s}")
for s in MI_SUBJECTS:
    d = mi[s]
    X, y, sess = d["X"].astype(np.float64), d["y"], d["session"]
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        p = float(cross_val_score(L7.make_csp_lda(8), X, y, cv=cv, scoring="accuracy").mean())
        tr, te = sess == "0train", sess == "1test"
        est = clone(L7.make_csp_lda(8)).fit(X[tr], y[tr])
        c = float(acc(est, X[te], y[te]))
    pooled.append(p)
    sessionwise.append(c)
    print(f"{s:8d} {p:14.4f} {c:17.4f} {p - c:+8.4f}")
pooled, sessionwise = np.asarray(pooled), np.asarray(sessionwise)
t, pv = stats.ttest_rel(pooled, sessionwise)
print(f"\nmean {pooled.mean():.4f} pooled against {sessionwise.mean():.4f} cross-session; "
      f"inflation {pooled.mean() - sessionwise.mean():+.4f}")
print(f"paired SD {np.std(pooled - sessionwise, ddof=1):.4f}, t({len(pooled) - 1}) = {t:.2f}, p = {pv:.4f}; "
      f"largest single-subject gap {np.max(pooled - sessionwise):+.4f}")
 subject  pooled random  train T / test E      gap
       1         0.8817            0.8819  -0.0002
       2         0.5864            0.5417  +0.0447
       3         0.9688            0.9306  +0.0382
       4         0.7570            0.7569  +0.0001
       5         0.6179            0.5833  +0.0346
       6         0.7186            0.7431  -0.0244
       7         0.8574            0.7361  +0.1213
       8         0.9792            0.9653  +0.0140
       9         0.8295            0.8194  +0.0100

mean 0.7996 pooled against 0.7731 cross-session; inflation +0.0265
paired SD 0.0418, t(8) = 1.90, p = 0.0937; largest single-subject gap +0.1213

4.2 · Are the forbidden channels actually informative?

The data set says the EOG channels must not be used for classification. That is an instruction, and it is worth knowing whether it is also a live risk on this data: a pipeline that leaves the EOG in its feature set is decoding eye movement, and pf-decoding-leakage is about exactly that kind of shortcut. The test is the same estimator on the three EOG channels alone.

In [12]:
eog_acc = []
for s in MI_SUBJECTS:
    d = mi[s]
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        v = float(cross_val_score(L7.make_csp_lda(2), d["X_eog"].astype(np.float64), d["y"],
                                  cv=cv, scoring="accuracy").mean())
    eog_acc.append(v)
eog_acc = np.asarray(eog_acc)
n_trials = int(mi[MI_SUBJECTS[0]]["X"].shape[0])
lo, hi = L6.chance_band(n_trials, 0.5)
above = eog_acc > hi
print(f"{'subject':>8s} {'EOG-only accuracy':>18s}")
for s, v in zip(MI_SUBJECTS, eog_acc):
    print(f"{s:8d} {v:18.4f}{'   above the chance band' if v > hi else ''}")
print(f"\nmean {eog_acc.mean():.4f}, range {eog_acc.min():.4f}-{eog_acc.max():.4f}")
print(f"binomial 95 % band around chance for {n_trials} trials: {lo:.4f}-{hi:.4f}")
print(f"{int(above.sum())} of {len(eog_acc)} subjects are above it — the prohibition is not a formality, "
      f"and it is also not a licence to claim the EEG result is an EOG artifact")
 subject  EOG-only accuracy
       1             0.4791
       2             0.5557
       3             0.5764   above the chance band
       4             0.5559
       5             0.5906   above the chance band
       6             0.5344
       7             0.4929
       8             0.5487
       9             0.5522

mean 0.5429, range 0.4791-0.5906
binomial 95 % band around chance for 288 trials: 0.4410-0.5590
2 of 9 subjects are above it — the prohibition is not a formality, and it is also not a licence to claim the EEG result is an EOG artifact

5 · The P300 paradigm on dry electrodes

The same three families, adapted to an evoked response: a supervised spatial filter with a linear classifier, the same spatial filtering into a Riemannian tangent space, and no spatial filter at all. moabb collapses this data set's game sessions into one, so there is a within-subject and a cross-subject evaluation and no cross-session one. The score is ROC AUC, not accuracy, because the oddball is about one target to five non-targets and accuracy would reward a classifier that always answered "non-target".

In [13]:
for k, v in L7.P300_SPEC.items():
    print(f"  {k:20s} {v}")
  dataset              ds-brain-invaders
  loader               moabb.datasets.BI2014a() through moabb.paradigms.P300
  classes              ('NonTarget', 'Target')
  extract_band_hz      (1.0, 20.0)
  extract_window_s     (0.0, 0.8)
  resample_hz          128.0
  scoring              roc_auc
  scoring_note         The oddball is about 1 target to 5 non-targets, so accuracy would reward a classifier that always answered NonTarget.  AUC is the yardstick, and chance is 0.5.
  sessions_note        moabb collapses this dataset's up-to-three game sessions into one, so a cross-session evaluation is not available here.
  channels_note        moabb labels two frontal channels F3/F4 where the site's catalog lists F5/F6 — TODO(confirm).  No analysis here depends on which of the two labellings is right.
  seed                 20260918
  folds                5
In [14]:
bi_root = helpers.download_root() / "MNE-braininvaders2014a-data"
bi_pre = [p for p in bi_root.rglob("*") if p.is_file()] if bi_root.exists() else []
L7.disk_report("before the P300 downloads", folders={"bi2014a cache": bi_root})
t0 = time.time()
p300 = {}
for s in P300_SUBJECTS:
    p300[s] = L7.load_bi2014a_subject(s, keep=bi_pre)
print(f"\n{len(p300)} subjects in {time.time() - t0:.0f} s; "
      f"{sum(d['downloaded_mb'] for d in p300.values()):.0f} MB fetched, "
      f"{sum(d['freed_mb'] for d in p300.values()):.0f} MB deleted")
assert len(p300) == len(P300_SUBJECTS)
L7.disk_report("after the P300 downloads", folders={"bi2014a cache": bi_root})
free disk before the P300 downloads: 1.95 GB  (bi2014a cache 227.7 MB)
  subject 1: 1188 epochs (198 target), 16 channels x 103 samples at 128 Hz; 0 MB fetched, 0 MB deleted
  subject 2: 492 epochs (82 target), 16 channels x 103 samples at 128 Hz; 0 MB fetched, 0 MB deleted
  subject 3: 1752 epochs (292 target), 16 channels x 103 samples at 128 Hz; 222 MB fetched, 222 MB deleted
  subject 4: 768 epochs (128 target), 16 channels x 103 samples at 128 Hz; 118 MB fetched, 118 MB deleted
  subject 5: 744 epochs (124 target), 16 channels x 103 samples at 128 Hz; 118 MB fetched, 118 MB deleted
  subject 6: 444 epochs (74 target), 16 channels x 103 samples at 128 Hz; 87 MB fetched, 87 MB deleted
6 subjects in 25 s; 545 MB fetched, 545 MB deleted
free disk after the P300 downloads: 1.94 GB  (bi2014a cache 227.7 MB)
Out[14]:
{'free_gb': 1.937096704, 'folders_mb': {'bi2014a cache': 227.711007}}
In [15]:
p300_pipelines = L7.build_p300_pipelines()
p300_inputs = {k: "X" for k in p300_pipelines}
rows = []
t0 = time.time()
rows += L7.within_session_scores(p300, p300_pipelines, inputs=p300_inputs, folds=FOLDS, seed=SEED,
                                 scoring="roc_auc", session_key="__single__", progress=False)
rows += L7.cross_subject_scores(p300, p300_pipelines, inputs=p300_inputs, scoring="roc_auc",
                                progress=False)
p300_df = L7.benchmark_frame(rows)
print(f"{time.time() - t0:.0f} s\n")
L7.print_benchmark(p300_df, scoring="roc_auc", chance=0.5,
                   title="ds-brain-invaders bi2014a, target vs non-target, ROC AUC (dimensionless)")
p300_winners = {}
for ev in p300_df["evaluation"].cat.categories:
    w = L7.winner(p300_df, ev)
    p300_winners[ev] = w
    print(f"\n[{ev}] " + ", ".join(f"{k} {v:.4f}" for k, v in w["ranking"]))
    print(f"  {w['winner']} beats {w['runner_up']} by {w['margin']:+.4f} "
          f"(paired SD {w['margin_sd_paired']:.4f}, t({w['n_subjects'] - 1}) = {w['t']:.2f}, "
          f"p = {w['p']:.4f})")
40 s

ds-brain-invaders bi2014a, target vs non-target, ROC AUC (dimensionless)
evaluation       pipeline                            mean      SD  median     min     max    n       s
------------------------------------------------------------------------------------------------------
within-session   Decimated epoch + shrinkage LDA   0.8594  0.0910  0.8643  0.7432  0.9697    6     1.9
within-session   Xdawn + LDA                       0.9117  0.0539  0.9025  0.8488  0.9873    6     9.5
within-session   XdawnCov + TS + LR                0.8750  0.0649  0.8543  0.8148  0.9749    6    11.1
cross-subject    Decimated epoch + shrinkage LDA   0.6645  0.0922  0.6326  0.5822  0.8133    6     0.9
cross-subject    Xdawn + LDA                       0.6221  0.1461  0.5914  0.4501  0.8392    6     3.4
cross-subject    XdawnCov + TS + LR                0.6765  0.1234  0.6887  0.5016  0.8719    6    13.6

chance = 0.5000 (roc_auc)

[within-session] Xdawn + LDA 0.9117, XdawnCov + TS + LR 0.8750, Decimated epoch + shrinkage LDA 0.8594
  Xdawn + LDA beats XdawnCov + TS + LR by +0.0367 (paired SD 0.0165, t(5) = 5.46, p = 0.0028)

[cross-subject] XdawnCov + TS + LR 0.6765, Decimated epoch + shrinkage LDA 0.6645, Xdawn + LDA 0.6221
  XdawnCov + TS + LR beats Decimated epoch + shrinkage LDA by +0.0120 (paired SD 0.0633, t(5) = 0.47, p = 0.6612)
In [16]:
fig, _ = L7.plot_benchmark(p300_df, chance=0.5, scoring="roc_auc",
                           title="ds-brain-invaders bi2014a: P300 decoding AUC by pipeline and "
                                 "evaluation (dimensionless, 6 subjects)")
plt.show()
Figure 2 of notebook nb-7-1-moabb, an output plot. The text around it states what it shows and the units of every axis.

6 · What the lesson's exercise asks for

"Which pipeline wins cross-subject, and by how much?" — printed below for both paradigms, with the paired margin, its spread and the between-subject spread beside it, because the by how much is the part that decides whether the ranking means anything.

In [17]:
print("=" * 96)
print("nb-7-1-moabb — the numbers the L7.1 exercise asks for")
print("=" * 96)

print("\n[1] ds-bci-iv-2a, left vs right hand, accuracy (chance 0.5), 9 subjects, "
      f"{L7.MI_SPEC['analysis_band_hz'][0]:g}-{L7.MI_SPEC['analysis_band_hz'][1]:g} Hz, "
      f"{L7.MI_SPEC['analysis_window_s'][0]:g}-{L7.MI_SPEC['analysis_window_s'][1]:g} s after the cue")
for ev in ("within-session", "cross-session", "cross-subject"):
    g = mi_df[mi_df["evaluation"] == ev]
    print(f"  {ev:15s} " + "  ".join(
        f"{p}: {g[g['pipeline'] == p]['score'].mean():.4f}" for p in L7.MI_INPUT))

w = mi_winners["cross-subject"]
print(f"\n[2] CROSS-SUBJECT WINNER (motor imagery): {w['winner']} at {w['winner_mean']:.4f}, "
      f"ahead of {w['runner_up']} at {w['runner_up_mean']:.4f}")
print(f"    margin {w['margin']:+.4f} ({100 * w['margin']:+.2f} percentage points); "
      f"paired SD across the 9 subjects {w['margin_sd_paired']:.4f}; "
      f"t({w['n_subjects'] - 1}) = {w['t']:.2f}, p = {w['p']:.4f}")
print(f"    ranking: " + ", ".join(f"{k} {v:.4f}" for k, v in w["ranking"]))
print(f"    READ THIS BEFORE QUOTING THE WINNER: the margin is "
      f"{100 * w['margin']:.1f} percentage points and the paired standard deviation is "
      f"{100 * w['margin_sd_paired']:.1f}. The three pipelines are not distinguishable "
      f"cross-subject on 9 subjects. The finding is the DROP, not the ranking.")

wi = mi_winners["within-session"]
print(f"\n[3] The drop that is the real result: within-session "
      f"{mi_df[mi_df['evaluation'] == 'within-session']['score'].mean():.4f} -> cross-session "
      f"{mi_df[mi_df['evaluation'] == 'cross-session']['score'].mean():.4f} -> cross-subject "
      f"{mi_df[mi_df['evaluation'] == 'cross-subject']['score'].mean():.4f} "
      f"(mean over the three pipelines)")

pw = p300_winners["cross-subject"]
print(f"\n[4] CROSS-SUBJECT WINNER (P300, ROC AUC): {pw['winner']} at {pw['winner_mean']:.4f}, "
      f"ahead of {pw['runner_up']} at {pw['runner_up_mean']:.4f}, margin {pw['margin']:+.4f} "
      f"(paired SD {pw['margin_sd_paired']:.4f}, p = {pw['p']:.4f})")
pwi = p300_winners["within-session"]
print(f"    within subject the order is different and the margin IS separable: {pwi['winner']} at "
      f"{pwi['winner_mean']:.4f} over {pwi['runner_up']} at {pwi['runner_up_mean']:.4f} "
      f"(margin {pwi['margin']:+.4f}, paired SD {pwi['margin_sd_paired']:.4f}, p = {pwi['p']:.4f}); "
      f"{pwi['winner']} falls to {float(p300_df[(p300_df['evaluation'] == 'cross-subject') & (p300_df['pipeline'] == pwi['winner'])]['score'].mean()):.4f} "
      f"cross-subject")

print(f"\n[5] Leakage — a pooled random split over two days: {pooled.mean():.4f} against "
      f"{sessionwise.mean():.4f} for train-on-T / test-on-E, inflation "
      f"{pooled.mean() - sessionwise.mean():+.4f} "
      f"(paired SD {np.std(pooled - sessionwise, ddof=1):.4f}, p = {pv:.4f}, "
      f"largest subject {np.max(pooled - sessionwise):+.4f})")
print(f"[6] Leakage — EOG-only decoding, the channels the data set forbids as features: mean "
      f"{eog_acc.mean():.4f}, range {eog_acc.min():.4f}-{eog_acc.max():.4f}; "
      f"{int(above.sum())} of {len(eog_acc)} subjects above the {lo:.4f}-{hi:.4f} chance band")
print(f"[7] Four classes instead of two, subject {mi[MI_SUBJECTS[0]]['subject']}, session T: "
      f"{sc4.mean():.4f} against chance 0.25 (two-class {float(sc2.iloc[0]):.4f} against chance 0.50)")
print(f"\n[8] NOT ANSWERED: {L7.CNN_ARM['what_it_does_not_answer']}")
print("=" * 96)
================================================================================================
nb-7-1-moabb — the numbers the L7.1 exercise asks for
================================================================================================

[1] ds-bci-iv-2a, left vs right hand, accuracy (chance 0.5), 9 subjects, 8-32 Hz, 0.5-3.5 s after the cue
  within-session  CSP + LDA: 0.7948  Riemannian TS + LR: 0.7959  FBCSP + LDA: 0.8144
  cross-session   CSP + LDA: 0.7731  Riemannian TS + LR: 0.7685  FBCSP + LDA: 0.7940
  cross-subject   CSP + LDA: 0.6543  Riemannian TS + LR: 0.6454  FBCSP + LDA: 0.6262

[2] CROSS-SUBJECT WINNER (motor imagery): CSP + LDA at 0.6543, ahead of Riemannian TS + LR at 0.6454
    margin +0.0089 (+0.89 percentage points); paired SD across the 9 subjects 0.1046; t(8) = 0.25, p = 0.8055
    ranking: CSP + LDA 0.6543, Riemannian TS + LR 0.6454, FBCSP + LDA 0.6262
    READ THIS BEFORE QUOTING THE WINNER: the margin is 0.9 percentage points and the paired standard deviation is 10.5. The three pipelines are not distinguishable cross-subject on 9 subjects. The finding is the DROP, not the ranking.

[3] The drop that is the real result: within-session 0.8017 -> cross-session 0.7785 -> cross-subject 0.6420 (mean over the three pipelines)

[4] CROSS-SUBJECT WINNER (P300, ROC AUC): XdawnCov + TS + LR at 0.6765, ahead of Decimated epoch + shrinkage LDA at 0.6645, margin +0.0120 (paired SD 0.0633, p = 0.6612)
    within subject the order is different and the margin IS separable: Xdawn + LDA at 0.9117 over XdawnCov + TS + LR at 0.8750 (margin +0.0367, paired SD 0.0165, p = 0.0028); Xdawn + LDA falls to 0.6221 cross-subject

[5] Leakage — a pooled random split over two days: 0.7996 against 0.7731 for train-on-T / test-on-E, inflation +0.0265 (paired SD 0.0418, p = 0.0937, largest subject +0.1213)
[6] Leakage — EOG-only decoding, the channels the data set forbids as features: mean 0.5429, range 0.4791-0.5906; 2 of 9 subjects above the 0.4410-0.5590 chance band
[7] Four classes instead of two, subject 1, session T: 0.7672 against chance 0.25 (two-class 0.8468 against chance 0.50)

[8] NOT ANSWERED: Whether a learned convolutional representation beats hand-designed spatial filters on this data, and whether it transfers across subjects better.  Those are the questions EEGNet is in the lesson for, and this notebook does not answer them.  No published EEGNet accuracy is quoted here from memory: that would be a fabricated comparison.  TODO(confirm) for the author: whether to pin braindecode + torch as optional dependencies and add the arm, or to reword the L7.1 objective.
================================================================================================
In [18]:
# Nothing this notebook downloaded is left on disk: every loader deletes its subject in a finally,
# and the counts above are the evidence.  This is the final check.
L7.disk_report("at the end",
               folders={"BNCI cache": L7.moabb_root(), "NEMAR cache": L7.nemar_root(),
                        "bi2014a cache": bi_root})
left = [f.name for s in MI_SUBJECTS for f in L7.bnci_subject_files(s)]
print(f"ds-bci-iv-2a files still on disk: {sorted(left) or 'none'} "
      f"(pre-existing before this run: {sorted(f.name for f in already_on_disk) or 'none'})")
assert set(left) <= {f.name for f in already_on_disk}, "this run left a download behind"
print("no download made by this run was left behind")
free disk at the end: 1.93 GB  (BNCI cache 86.6 MB, NEMAR cache 0.0 MB, bi2014a cache 227.7 MB)
ds-bci-iv-2a files still on disk: ['A01E.mat', 'A01T.mat'] (pre-existing before this run: ['A01E.mat', 'A01T.mat'])
no download made by this run was left behind