2026-04-21 13:01:49 -05:00
{
"cells": [
{
"cell_type": "markdown",
2026-04-21 21:18:33 -05:00
"id": "d8960e56",
2026-04-21 13:01:49 -05:00
"metadata": {},
2026-04-21 21:18:33 -05:00
"source": [
"# Motor Imagery Decoder — Train OFFLINE, Evaluate ONLINE (FES vs NOFES)\n",
"\n",
"For each subject × offline-session, train a CSP + LDA classifier on the OFFLINE recording, then apply it to the two matched ONLINE sessions.\n",
"\n",
"**Pair 1:** train on `S001 OFFLINE_FES` → test on `S002 ONLINE_FES` and `S003 ONLINE_NOFES`\n",
"**Pair 2:** train on `S004 OFFLINE_NOFES` → test on `S006 ONLINE_FES` and `S005 ONLINE_NOFES`\n",
"\n",
"**Metrics reported per (subject × pair × condition):**\n",
"1. **Classification accuracy** — fraction of cued trials correctly classified\n",
"2. **Classification amplitude** — mean |LDA decision-function value|\n",
"3. **SNR** — (a) Fisher ratio of the LDA projection on online data, and (b) mu-band power ratio REST / MI over motor channels C3/Cz/C4"
]
2026-04-21 13:01:49 -05:00
},
{
"cell_type": "code",
2026-04-21 21:18:33 -05:00
"execution_count": 1,
2026-04-21 13:01:49 -05:00
"id": "578c9128",
2026-04-21 21:18:33 -05:00
"metadata": {
"execution": {
"iopub.execute_input": "2026-04-22T00:51:36.337947Z",
"iopub.status.busy": "2026-04-22T00:51:36.337765Z",
"iopub.status.idle": "2026-04-22T00:51:36.341891Z",
"shell.execute_reply": "2026-04-22T00:51:36.341238Z"
}
},
2026-04-21 13:01:49 -05:00
"outputs": [],
"source": [
"# Install dependencies if needed\n",
"# !pip install pyxdf mne scipy numpy matplotlib"
]
},
{
"cell_type": "code",
2026-04-21 21:18:33 -05:00
"execution_count": 2,
2026-04-21 13:01:49 -05:00
"id": "857b22c0",
2026-04-21 21:18:33 -05:00
"metadata": {
"execution": {
"iopub.execute_input": "2026-04-22T00:51:36.344380Z",
"iopub.status.busy": "2026-04-22T00:51:36.344206Z",
"iopub.status.idle": "2026-04-22T00:51:37.587307Z",
"shell.execute_reply": "2026-04-22T00:51:37.586902Z"
}
},
2026-04-21 13:01:49 -05:00
"outputs": [],
2026-04-21 21:18:33 -05:00
"source": [
"import os\n",
"import re\n",
"import glob\n",
"import numpy as np\n",
"import matplotlib.pyplot as plt\n",
"from matplotlib.patches import Patch\n",
"import pyxdf\n",
"from scipy.signal import welch, butter, filtfilt, iirnotch\n",
"from scipy.linalg import eigh\n",
"\n",
"plt.rcParams.update({'font.size': 11, 'figure.dpi': 120})"
]
2026-04-21 13:01:49 -05:00
},
{
"cell_type": "markdown",
"id": "fe68bf0e",
"metadata": {},
"source": [
"## Configuration"
]
},
{
"cell_type": "code",
2026-04-21 21:18:33 -05:00
"execution_count": 3,
2026-04-21 13:01:49 -05:00
"id": "dc4b2c55",
2026-04-21 21:18:33 -05:00
"metadata": {
"execution": {
"iopub.execute_input": "2026-04-22T00:51:37.589045Z",
"iopub.status.busy": "2026-04-22T00:51:37.588919Z",
"iopub.status.idle": "2026-04-22T00:51:37.591774Z",
"shell.execute_reply": "2026-04-22T00:51:37.591458Z"
}
},
2026-04-21 13:01:49 -05:00
"outputs": [],
2026-04-21 21:18:33 -05:00
"source": [
"DATA_DIR = os.path.join(os.path.dirname(os.path.abspath('__file__')), 'Group 2 - Glove')\n",
"\n",
"# Marker codes (same cue-encoding in offline and online)\n",
"MI_BEGIN, MI_END = 200, 220\n",
"REST_BEGIN, REST_END = 100, 120\n",
"TARGET_MARKERS = [100, 120, 200, 220]\n",
"\n",
"# Epoch window (t=0 at cue marker)\n",
"T_PRE = -1.0 # baseline start\n",
"T_POST = 5.0 # epoch end\n",
"\n",
"# ── Preprocessing ────────────────────────────────────────────────────────────\n",
"NOTCH_FREQ = 60.0 # US powerline; EU would be 50\n",
"NOTCH_Q = 30 # quality factor for narrow notch\n",
"BP_LO, BP_HI = 8.0, 30.0 # mu + beta (MI band)\n",
"USE_CAR = True # Common Average Reference spatial filter (Blankertz 2008; McFarland 1997)\n",
"PTP_REJECT_UV = 100.0 # epoch-wise peak-to-peak threshold on BP data (µV) — reject blinks/movement\n",
"\n",
"# CSP spatial filters (top N/2 + bottom N/2)\n",
"N_CSP = 4\n",
"\n",
"NON_EEG = {'AUX1', 'AUX2', 'AUX3', 'AUX7', 'AUX8', 'AUX9', 'TRIGGER'}\n",
"RENAME = {'FP1':'Fp1','FPZ':'Fpz','FP2':'Fp2','FZ':'Fz','CZ':'Cz',\n",
" 'PZ':'Pz','POZ':'POz','OZ':'Oz'}\n",
"\n",
"MOTOR_CH = ['C3', 'Cz', 'C4']\n",
"MU_BAND = (8, 13)\n",
"\n",
"# Design: per subject, each OFFLINE session trains a model tested on two ONLINE sessions\n",
"PAIRS = [\n",
" {'name': 'Pair1 (train=OFFLINE_FES)',\n",
" 'train': 'S001', 'online_fes': 'S002', 'online_nofes': 'S003'},\n",
" {'name': 'Pair2 (train=OFFLINE_NOFES)',\n",
" 'train': 'S004', 'online_fes': 'S006', 'online_nofes': 'S005'},\n",
"]"
]
2026-04-21 13:01:49 -05:00
},
{
"cell_type": "markdown",
"id": "21a40df3",
"metadata": {},
"source": [
"## Helper Functions"
]
},
{
"cell_type": "code",
2026-04-21 21:18:33 -05:00
"execution_count": 4,
2026-04-21 13:01:49 -05:00
"id": "e798b039",
2026-04-21 21:18:33 -05:00
"metadata": {
"execution": {
"iopub.execute_input": "2026-04-22T00:51:37.592974Z",
"iopub.status.busy": "2026-04-22T00:51:37.592890Z",
"iopub.status.idle": "2026-04-22T00:51:37.605863Z",
"shell.execute_reply": "2026-04-22T00:51:37.605402Z"
}
},
2026-04-21 13:01:49 -05:00
"outputs": [],
2026-04-21 21:18:33 -05:00
"source": [
"# ── XDF loading + session parsing ─────────────────────────────────────────────\n",
"\n",
"def get_channel_names_from_xdf(eeg_stream):\n",
" ch_desc = eeg_stream['info']['desc'][0]\n",
" channels = ch_desc.get('channels', [{}])[0].get('channel', [])\n",
" return [ch['label'][0] for ch in channels]\n",
"\n",
"\n",
"# Tolerates the 'ONOLINE' typo in subj 003 / S005\n",
"_SESSION_RE = re.compile(r'ses-(S\\d+)(O[A-Z]*LINE)_(FES|NOFES)')\n",
"_SUBJ_RE = re.compile(r'SUBJ_(\\d+)')\n",
"\n",
"def parse_session(path):\n",
" \"\"\"Return (subject, session_id, kind, stim) or None.\"\"\"\n",
" base = os.path.basename(path)\n",
" m_subj = _SUBJ_RE.search(base)\n",
" m_ses = _SESSION_RE.search(base)\n",
" if not (m_subj and m_ses):\n",
" return None\n",
" ses_id, raw_kind, stim = m_ses.group(1), m_ses.group(2), m_ses.group(3)\n",
" kind = 'OFFLINE' if 'OFF' in raw_kind else 'ONLINE'\n",
" return m_subj.group(1), ses_id, kind, stim\n",
"\n",
"\n",
"def load_xdf_file(filepath):\n",
" streams, _ = pyxdf.load_xdf(filepath)\n",
"\n",
" eeg_stream = marker_stream = None\n",
" for s in streams:\n",
" stype = s['info']['type'][0].lower()\n",
" if stype == 'eeg': eeg_stream = s\n",
" elif stype == 'markers': marker_stream = s\n",
" if eeg_stream is None or marker_stream is None:\n",
" eeg_stream = streams[0]\n",
" marker_stream = streams[1] if len(streams) > 1 else None\n",
"\n",
" eeg_timestamps = np.array(eeg_stream['time_stamps'])\n",
" eeg_data = np.array(eeg_stream['time_series']).T\n",
" channel_names = get_channel_names_from_xdf(eeg_stream)\n",
" sfreq = float(eeg_stream['info']['nominal_srate'][0])\n",
"\n",
" valid_idx = [i for i, ch in enumerate(channel_names) if ch not in NON_EEG]\n",
" channel_names = [channel_names[i] for i in valid_idx]\n",
" eeg_data = eeg_data[valid_idx, :]\n",
" channel_names = [RENAME.get(ch, ch) for ch in channel_names]\n",
"\n",
" ts_arr = np.asarray(marker_stream['time_series'], dtype=float)\n",
" marker_data = ts_arr[:, 0].astype(int)\n",
" marker_ts = ts_arr[:, 1]\n",
" keep = np.isin(marker_data, TARGET_MARKERS)\n",
" return eeg_data, eeg_timestamps, marker_data[keep], marker_ts[keep], channel_names, sfreq\n",
"\n",
"\n",
"# ── Preprocessing primitives ─────────────────────────────────────────────────\n",
"\n",
"def notch_filter(data, freq, sfreq, Q=NOTCH_Q):\n",
" \"\"\"IIR notch at `freq` Hz — removes powerline interference.\"\"\"\n",
" b, a = iirnotch(freq, Q, fs=sfreq)\n",
" return filtfilt(b, a, data, axis=-1)\n",
"\n",
"\n",
"def car(data):\n",
" \"\"\"Common Average Reference: subtract the across-channel mean at each sample.\n",
" Standard spatial preprocessing for MI-BCI (McFarland 1997; Blankertz 2008).\n",
" \"\"\"\n",
" return data - data.mean(axis=0, keepdims=True)\n",
"\n",
"\n",
"def bandpass(data, lo, hi, sfreq, order=4):\n",
" nyq = sfreq / 2.0\n",
" b, a = butter(order, [max(lo, 0.5) / nyq, min(hi, nyq - 0.1) / nyq], btype='band')\n",
" return filtfilt(b, a, data, axis=-1)\n",
"\n",
"\n",
"def reject_by_ptp(X, thresh_uv=PTP_REJECT_UV):\n",
" \"\"\"Boolean mask: True = keep (max-across-channels peak-to-peak < threshold).\n",
" Amplitude criterion for blink / movement artifact rejection (Nolan 2010).\n",
" \"\"\"\n",
" if X.size == 0:\n",
" return np.zeros(0, dtype=bool)\n",
" ptp = X.max(axis=-1) - X.min(axis=-1) # (n_trials, n_ch)\n",
" return ptp.max(axis=-1) < thresh_uv\n",
"\n",
"\n",
"def extract_epochs(eeg_data, eeg_ts, marker_data, marker_ts, sfreq, begin_code,\n",
" t_pre=T_PRE, t_post=T_POST):\n",
" \"\"\"Returns (n_epochs, n_ch, n_samp) — all epochs trimmed to the same length.\"\"\"\n",
" epochs = []\n",
" n_pre = int(abs(t_pre) * sfreq)\n",
"\n",
" for bi in np.where(marker_data == begin_code)[0]:\n",
" t_start = marker_ts[bi]\n",
" i0 = np.searchsorted(eeg_ts, t_start + t_pre)\n",
" i1 = np.searchsorted(eeg_ts, t_start + t_post)\n",
" if i0 < 0 or i1 > eeg_data.shape[1]:\n",
" continue\n",
" ep = eeg_data[:, i0:i1].copy()\n",
" if ep.shape[1] > n_pre:\n",
" ep -= ep[:, :n_pre].mean(axis=1, keepdims=True)\n",
" epochs.append(ep)\n",
"\n",
" if not epochs:\n",
" return np.empty((0, eeg_data.shape[0], 0))\n",
" min_len = min(e.shape[-1] for e in epochs)\n",
" return np.stack([e[:, :min_len] for e in epochs])\n",
"\n",
"\n",
"def load_session_epochs(filepath):\n",
" \"\"\"Full per-session preprocessing pipeline:\n",
" 1. Load XDF, drop non-EEG channels\n",
" 2. Notch at 60 Hz (powerline)\n",
" 3. Common Average Reference\n",
" 4. Bandpass 8– 30 Hz (mu + beta — the MI band)\n",
" 5. Epoch on cues, baseline-correct to [-1, 0] s\n",
" 6. Reject epochs with any-channel peak-to-peak > PTP_REJECT_UV (blinks / movement)\n",
" Returns X (n_trials, n_ch, n_samp), y (1=MI, 0=REST), ch_names, sfreq, n_rejected.\n",
" \"\"\"\n",
" eeg, eeg_ts, mk, mk_ts, ch_names, sfreq = load_xdf_file(filepath)\n",
"\n",
" eeg = notch_filter(eeg, NOTCH_FREQ, sfreq)\n",
" if USE_CAR:\n",
" eeg = car(eeg)\n",
" eeg_bp = bandpass(eeg, BP_LO, BP_HI, sfreq)\n",
"\n",
" mi = extract_epochs(eeg_bp, eeg_ts, mk, mk_ts, sfreq, MI_BEGIN)\n",
" rest = extract_epochs(eeg_bp, eeg_ts, mk, mk_ts, sfreq, REST_BEGIN)\n",
"\n",
" n_pre = int(abs(T_PRE) * sfreq)\n",
" if mi.shape[-1] > n_pre: mi = mi[..., n_pre:]\n",
" if rest.shape[-1] > n_pre: rest = rest[..., n_pre:]\n",
"\n",
" n = min(mi.shape[-1], rest.shape[-1]) if (mi.size and rest.size) else 0\n",
" mi, rest = mi[..., :n], rest[..., :n]\n",
"\n",
" n0_mi, n0_rest = len(mi), len(rest)\n",
" mi = mi[reject_by_ptp(mi)]\n",
" rest = rest[reject_by_ptp(rest)]\n",
" n_rejected = (n0_mi - len(mi)) + (n0_rest - len(rest))\n",
"\n",
" X = np.concatenate([mi, rest], axis=0) if (len(mi) or len(rest)) else np.empty((0, len(ch_names), 0))\n",
" y = np.concatenate([np.ones(len(mi), int), np.zeros(len(rest), int)])\n",
" return X, y, ch_names, sfreq, n_rejected\n",
"\n",
"\n",
"# ── CSP + LDA (2-class, numpy/scipy only) ────────────────────────────────────\n",
"\n",
"def _mean_cov(X):\n",
" \"\"\"Average trace-normalized trial covariance. X: (n_trials, n_ch, n_samp).\"\"\"\n",
" covs = np.einsum('ijk,ilk->ijl', X, X)\n",
" covs /= np.trace(covs, axis1=1, axis2=2)[:, None, None]\n",
" return covs.mean(axis=0)\n",
"\n",
"\n",
"class CSPLDA:\n",
" \"\"\"Common Spatial Patterns (log-var features) + Linear Discriminant Analysis.\n",
" Ramoser 2000; Blankertz 2008. Ledoit-Wolf style cov shrinkage so CAR\n",
" (which drops rank by 1) does not break the generalized eigenproblem.\n",
" \"\"\"\n",
"\n",
" def __init__(self, n_csp=N_CSP, cov_shrink=0.05, lda_reg=1e-4):\n",
" self.n_csp = n_csp\n",
" self.cov_shrink = cov_shrink\n",
" self.lda_reg = lda_reg\n",
"\n",
" def fit(self, X, y):\n",
" assert set(np.unique(y)) == {0, 1}, 'CSPLDA requires both classes in training set'\n",
" C1 = _mean_cov(X[y == 1])\n",
" C0 = _mean_cov(X[y == 0])\n",
" n_ch = C1.shape[0]\n",
" s = self.cov_shrink\n",
" C1 = (1 - s) * C1 + s * (np.trace(C1) / n_ch) * np.eye(n_ch)\n",
" C0 = (1 - s) * C0 + s * (np.trace(C0) / n_ch) * np.eye(n_ch)\n",
" evals, evecs = eigh(C1, C0 + C1)\n",
" order = np.argsort(evals)\n",
" k = self.n_csp // 2\n",
" self.filters_ = np.concatenate([evecs[:, order[:k]],\n",
" evecs[:, order[-k:]]], axis=1).T\n",
"\n",
" F = self._features(X)\n",
" mu1, mu0 = F[y == 1].mean(0), F[y == 0].mean(0)\n",
" Sw = np.cov(F[y == 1].T, ddof=1) + np.cov(F[y == 0].T, ddof=1)\n",
" Sw += self.lda_reg * np.eye(Sw.shape[0])\n",
" self.coef_ = np.linalg.solve(Sw, mu1 - mu0)\n",
" self.intercept_ = -self.coef_ @ ((mu1 + mu0) / 2)\n",
" return self\n",
"\n",
" def _features(self, X):\n",
" Z = np.einsum('fc,ncs->nfs', self.filters_, X)\n",
" var = Z.var(axis=-1, ddof=1)\n",
" return np.log(var / var.sum(axis=1, keepdims=True))\n",
"\n",
" def decision_function(self, X):\n",
" return self._features(X) @ self.coef_ + self.intercept_\n",
"\n",
" def predict(self, X):\n",
" return (self.decision_function(X) > 0).astype(int)\n",
"\n",
"\n",
"# ── Evaluation metrics ───────────────────────────────────────────────────────\n",
"\n",
"def evaluate(clf, X, y):\n",
" margin = clf.decision_function(X)\n",
" pred = (margin > 0).astype(int)\n",
" acc = (pred == y).mean()\n",
" amp = np.abs(margin).mean()\n",
" m1, m0 = margin[y == 1], margin[y == 0]\n",
" fisher = (m1.mean() - m0.mean()) ** 2 / (m1.var(ddof=1) + m0.var(ddof=1) + 1e-30)\n",
" return dict(acc=acc, amp=amp, fisher=fisher, margin=margin, y=y, pred=pred)\n",
"\n",
"\n",
"def spectral_snr(X, y, ch_idx, sfreq, band=MU_BAND):\n",
" \"\"\"Ratio of REST mu-power to MI mu-power averaged over selected channels.\"\"\"\n",
" def band_pwr(sig):\n",
" f, p = welch(sig, fs=sfreq,\n",
" nperseg=min(int(sfreq * 2), sig.shape[-1]),\n",
" noverlap=int(sfreq), axis=-1)\n",
" m = (f >= band[0]) & (f < band[1])\n",
" return np.trapezoid(p[..., m], f[m], axis=-1).mean()\n",
"\n",
" return band_pwr(X[y == 0][:, ch_idx, :]) / (band_pwr(X[y == 1][:, ch_idx, :]) + 1e-30)"
]
2026-04-21 13:01:49 -05:00
},
{
"cell_type": "markdown",
"id": "98d225db",
"metadata": {},
"source": [
"## Load Data"
]
},
{
"cell_type": "code",
2026-04-21 21:18:33 -05:00
"execution_count": 5,
2026-04-21 13:01:49 -05:00
"id": "d266216b",
2026-04-21 21:18:33 -05:00
"metadata": {
"execution": {
"iopub.execute_input": "2026-04-22T00:51:37.607052Z",
"iopub.status.busy": "2026-04-22T00:51:37.606969Z",
"iopub.status.idle": "2026-04-22T00:52:02.122083Z",
"shell.execute_reply": "2026-04-22T00:52:02.121262Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Found 18 XDF file(s).\n",
"Preprocessing: notch 60 Hz → CAR → bandpass 8– 30 Hz → baseline-correct → PTP-reject @ 100 µV\n",
"\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
" 003/S001 OFFLINE FES n= 89 (MI=44, REST=45) rejected=1\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
" 003/S002 ONLINE FES n= 59 (MI=29, REST=30) rejected=1\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
" 003/S003 ONLINE NOFES n= 38 (MI=17, REST=21) rejected=0\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
" 003/S004 OFFLINE NOFES n= 86 (MI=42, REST=44) rejected=4\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
" 003/S005 ONLINE NOFES n= 43 (MI=19, REST=24) rejected=17\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
" 003/S006 ONLINE FES n= 52 (MI=23, REST=29) rejected=8\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
" 005/S001 OFFLINE FES n= 90 (MI=45, REST=45) rejected=0\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
" 005/S002 ONLINE FES n= 60 (MI=30, REST=30) rejected=0\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
" 005/S003 ONLINE NOFES n= 59 (MI=29, REST=30) rejected=1\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
" 005/S004 OFFLINE NOFES n= 89 (MI=44, REST=45) rejected=1\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
" 005/S005 ONLINE NOFES n= 58 (MI=28, REST=30) rejected=2\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
" 005/S006 ONLINE FES n= 59 (MI=30, REST=29) rejected=1\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
" 009/S001 OFFLINE FES n= 57 (MI=33, REST=24) rejected=33\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
" 009/S002 ONLINE FES n= 42 (MI=21, REST=21) rejected=18\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
" 009/S003 ONLINE NOFES n= 1 (MI=1, REST=0) rejected=59\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
" 009/S004 OFFLINE NOFES n= 86 (MI=42, REST=44) rejected=4\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
" 009/S005 ONLINE NOFES n= 60 (MI=30, REST=30) rejected=0\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
" 009/S006 ONLINE FES n= 50 (MI=26, REST=24) rejected=10\n",
"\n",
"Loaded 3 subject(s): ['003', '005', '009'] | total artifact-rejected epochs: 160\n"
]
}
],
"source": [
"xdf_files = sorted(glob.glob(os.path.join(DATA_DIR, '*.xdf')))\n",
"print(f'Found {len(xdf_files)} XDF file(s).')\n",
"print(f'Preprocessing: notch {NOTCH_FREQ:.0f} Hz → '\n",
" f'{\"CAR → \" if USE_CAR else \"\"}bandpass {BP_LO:.0f}– {BP_HI:.0f} Hz → '\n",
" f'baseline-correct → PTP-reject @ {PTP_REJECT_UV:.0f} µV\\n')\n",
"\n",
"# sessions[subject][session_id] = dict(X, y, kind, stim, ch_names, sfreq, file)\n",
"sessions = {}\n",
"total_rej = 0\n",
"\n",
"for fp in xdf_files:\n",
" meta = parse_session(fp)\n",
" if meta is None:\n",
" print(f' SKIP (unparsed): {os.path.basename(fp)}')\n",
" continue\n",
" subj, ses_id, kind, stim = meta\n",
" try:\n",
" X, y, ch_names, sfreq, n_rej = load_session_epochs(fp)\n",
" except Exception as e:\n",
" print(f' ERROR {os.path.basename(fp)}: {e}')\n",
" continue\n",
"\n",
" sessions.setdefault(subj, {})[ses_id] = dict(\n",
" X=X, y=y, kind=kind, stim=stim,\n",
" ch_names=ch_names, sfreq=sfreq, file=os.path.basename(fp))\n",
" total_rej += n_rej\n",
"\n",
" print(f' {subj}/{ses_id} {kind:<7} {stim:<5} '\n",
" f'n={len(y):3d} (MI={int(y.sum())}, REST={int((1-y).sum())}) '\n",
" f'rejected={n_rej}')\n",
"\n",
"subjects = sorted(sessions.keys())\n",
"print(f'\\nLoaded {len(subjects)} subject(s): {subjects} | '\n",
" f'total artifact-rejected epochs: {total_rej}')"
]
2026-04-21 13:01:49 -05:00
},
{
"cell_type": "markdown",
"id": "7b8c8bea",
"metadata": {},
2026-04-21 21:18:33 -05:00
"source": [
"## Verify Session Layout"
]
2026-04-21 13:01:49 -05:00
},
{
"cell_type": "code",
2026-04-21 21:18:33 -05:00
"execution_count": 6,
2026-04-21 13:01:49 -05:00
"id": "611baf23",
2026-04-21 21:18:33 -05:00
"metadata": {
"execution": {
"iopub.execute_input": "2026-04-22T00:52:02.124753Z",
"iopub.status.busy": "2026-04-22T00:52:02.124540Z",
"iopub.status.idle": "2026-04-22T00:52:02.130065Z",
"shell.execute_reply": "2026-04-22T00:52:02.129699Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Channels (32): ['Fp1', 'Fpz', 'Fp2', 'F7', 'F3', 'Fz', 'F4', 'F8', 'FC5', 'FC1', 'FC2', 'FC6', 'M1', 'T7', 'C3', 'Cz', 'C4', 'T8', 'M2', 'CP5', 'CP1', 'CP2', 'CP6', 'P7', 'P3', 'Pz', 'P4', 'P8', 'POz', 'O1', 'Oz', 'O2']\n",
"Sampling rate: 512.0 Hz\n",
"Motor channels ['C3', 'Cz', 'C4'] → indices [14, 15, 16]\n"
]
}
],
"source": [
"# Verify channel layout is consistent across sessions, locate motor channels\n",
"ref_subj = subjects[0]\n",
"ref_ses = next(iter(sessions[ref_subj].values()))\n",
"channel_names_global = ref_ses['ch_names']\n",
"sfreq_global = ref_ses['sfreq']\n",
"\n",
"mismatches = [f'{subj}/{sid}' for subj in subjects for sid, s in sessions[subj].items()\n",
" if s['ch_names'] != channel_names_global]\n",
"if mismatches:\n",
" print('!! channel mismatch in:', mismatches)\n",
"\n",
"motor_idx_global = [channel_names_global.index(c) for c in MOTOR_CH\n",
" if c in channel_names_global]\n",
"\n",
"print(f'Channels ({len(channel_names_global)}): {channel_names_global}')\n",
"print(f'Sampling rate: {sfreq_global} Hz')\n",
"print(f'Motor channels {MOTOR_CH} → indices {motor_idx_global}')"
]
2026-04-21 13:01:49 -05:00
},
{
"cell_type": "markdown",
"id": "70922abb",
"metadata": {},
2026-04-21 21:18:33 -05:00
"source": [
"## Train CSP + LDA on OFFLINE, Evaluate on ONLINE"
]
2026-04-21 13:01:49 -05:00
},
{
"cell_type": "code",
2026-04-21 21:18:33 -05:00
"execution_count": 7,
2026-04-21 13:01:49 -05:00
"id": "f5e80da3",
2026-04-21 21:18:33 -05:00
"metadata": {
"execution": {
"iopub.execute_input": "2026-04-22T00:52:02.135245Z",
"iopub.status.busy": "2026-04-22T00:52:02.135163Z",
"iopub.status.idle": "2026-04-22T00:52:03.802461Z",
"shell.execute_reply": "2026-04-22T00:52:03.802016Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"[009] Pair1 (train=OFFLINE_FES) / NOFES: only 1 surviving epochs — dropping (likely electrode/noise issue)\n",
"\n",
"Subj Pair Cond n trainAcc acc |marg| Fisher muSNR\n",
"-------------------------------------------------------------------------------------------\n",
"003 Pair1 (train=OFFLINE_FES) FES 59 0.843 0.542 0.879 0.017 1.347\n",
"003 Pair1 (train=OFFLINE_FES) NOFES 38 0.843 0.737 0.910 0.683 1.219\n",
"003 Pair2 (train=OFFLINE_NOFES) FES 52 0.907 0.538 1.166 0.000 1.273\n",
"003 Pair2 (train=OFFLINE_NOFES) NOFES 43 0.907 0.605 1.071 0.029 1.296\n",
"005 Pair1 (train=OFFLINE_FES) FES 60 0.978 0.817 3.346 2.498 2.695\n",
"005 Pair1 (train=OFFLINE_FES) NOFES 59 0.978 0.932 3.620 3.029 2.399\n",
"005 Pair2 (train=OFFLINE_NOFES) FES 59 1.000 0.949 5.740 6.291 2.911\n",
"005 Pair2 (train=OFFLINE_NOFES) NOFES 58 1.000 0.897 5.261 4.325 2.287\n",
"009 Pair1 (train=OFFLINE_FES) FES 42 0.667 0.571 0.304 0.040 1.326\n",
"009 Pair2 (train=OFFLINE_NOFES) FES 50 1.000 0.520 4.673 6.192 1.594\n",
"009 Pair2 (train=OFFLINE_NOFES) NOFES 60 1.000 0.917 3.754 8.959 2.000\n"
]
}
],
"source": [
"MIN_TEST_TRIALS = 10 # skip test sessions too badly corrupted to evaluate\n",
"\n",
"results = [] # one row per (subject, pair, condition)\n",
"\n",
"for subj in subjects:\n",
" subj_ses = sessions[subj]\n",
"\n",
" for pair in PAIRS:\n",
" needed = (pair['train'], pair['online_fes'], pair['online_nofes'])\n",
" missing = [k for k in needed if k not in subj_ses]\n",
" if missing:\n",
" print(f'[{subj}] {pair[\"name\"]}: missing {missing} — skipping')\n",
" continue\n",
"\n",
" train = subj_ses[pair['train']]\n",
" if set(np.unique(train['y'])) != {0, 1}:\n",
" print(f'[{subj}] {pair[\"name\"]}: training set lacks both classes — skipping')\n",
" continue\n",
" clf = CSPLDA(n_csp=N_CSP).fit(train['X'], train['y'])\n",
" train_acc = (clf.predict(train['X']) == train['y']).mean()\n",
"\n",
" for cond_key, cond_label in [('online_fes', 'FES'), ('online_nofes', 'NOFES')]:\n",
" te = subj_ses[pair[cond_key]]\n",
" if len(te['y']) < MIN_TEST_TRIALS or set(np.unique(te['y'])) != {0, 1}:\n",
" print(f'[{subj}] {pair[\"name\"]} / {cond_label}: only {len(te[\"y\"])} '\n",
" f'surviving epochs — dropping (likely electrode/noise issue)')\n",
" continue\n",
" res = evaluate(clf, te['X'], te['y'])\n",
" snr_s = spectral_snr(te['X'], te['y'], motor_idx_global, te['sfreq'])\n",
"\n",
" results.append(dict(\n",
" subject=subj, pair=pair['name'], condition=cond_label,\n",
" train_file=train['file'], test_file=te['file'],\n",
" train_acc=train_acc, n_test=len(te['y']),\n",
" acc=res['acc'], amp=res['amp'], fisher=res['fisher'], mu_snr=snr_s,\n",
" margin=res['margin'], y_test=res['y'], pred=res['pred'],\n",
" ))\n",
"\n",
"# Results table\n",
"hdr = f'{\"Subj\":<5} {\"Pair\":<28} {\"Cond\":<6} {\"n\":>4} {\"trainAcc\":>9} {\"acc\":>7} {\"|marg|\":>8} {\"Fisher\":>8} {\"muSNR\":>8}'\n",
"print('\\n' + hdr)\n",
"print('-' * len(hdr))\n",
"for r in results:\n",
" print(f'{r[\"subject\"]:<5} {r[\"pair\"]:<28} {r[\"condition\"]:<6} {r[\"n_test\"]:>4} '\n",
" f'{r[\"train_acc\"]:>9.3f} {r[\"acc\"]:>7.3f} {r[\"amp\"]:>8.3f} '\n",
" f'{r[\"fisher\"]:>8.3f} {r[\"mu_snr\"]:>8.3f}')"
]
2026-04-21 13:01:49 -05:00
},
{
"cell_type": "markdown",
"id": "2ab81600",
"metadata": {},
2026-04-21 21:18:33 -05:00
"source": [
"---\n",
"## Figure 1 — Per-metric comparison (FES vs NOFES)"
]
2026-04-21 13:01:49 -05:00
},
{
"cell_type": "code",
2026-04-21 21:18:33 -05:00
"execution_count": 8,
2026-04-21 13:01:49 -05:00
"id": "d53e63b9",
2026-04-21 21:18:33 -05:00
"metadata": {
"execution": {
"iopub.execute_input": "2026-04-22T00:52:03.805180Z",
"iopub.status.busy": "2026-04-22T00:52:03.805014Z",
"iopub.status.idle": "2026-04-22T00:52:04.240793Z",
"shell.execute_reply": "2026-04-22T00:52:04.240314Z"
}
},
"outputs": [
{
"data": {
"image/png": "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
"text/plain": [
"<Figure size 1680x1080 with 4 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"Saved: fes_vs_nofes_metrics.png\n"
]
}
],
"source": [
"METRICS = [\n",
" ('acc', 'Classification accuracy', '0– 1'),\n",
" ('amp', 'Classification amplitude (mean |decision fn|)', 'a.u.'),\n",
" ('fisher', 'Fisher ratio on LDA projection (test-set SNR)', 'a.u.'),\n",
" ('mu_snr', 'μ-band power ratio REST / MI @ C3/Cz/C4', 'ratio'),\n",
"]\n",
"\n",
"cond_color = {'FES': '#E05C2A', 'NOFES': '#2A7BE0'}\n",
"\n",
"fig, axes = plt.subplots(2, 2, figsize=(14, 9))\n",
"fig.suptitle('Online decoding: FES vs NOFES feedback (per subject × offline-trained model)',\n",
" fontsize=13, fontweight='bold', y=1.00)\n",
"\n",
"for ax, (key, title, unit) in zip(axes.ravel(), METRICS):\n",
" labels, vals, colors = [], [], []\n",
" for subj in subjects:\n",
" for pair in PAIRS:\n",
" tag = pair['name'].split()[0] # \"Pair1\" / \"Pair2\"\n",
" for cond in ('FES', 'NOFES'):\n",
" row = next((r for r in results\n",
" if r['subject']==subj and r['pair']==pair['name']\n",
" and r['condition']==cond), None)\n",
" if row is None: continue\n",
" labels.append(f'{subj}\\n{tag}\\n{cond}')\n",
" vals.append(row[key])\n",
" colors.append(cond_color[cond])\n",
" x = np.arange(len(vals))\n",
" ax.bar(x, vals, color=colors, edgecolor='white', zorder=2)\n",
" ax.set_xticks(x); ax.set_xticklabels(labels, fontsize=7.5)\n",
" ax.set_title(f'{title} ({unit})', fontsize=11, fontweight='bold')\n",
" ax.grid(axis='y', alpha=0.3)\n",
" ax.spines[['top','right']].set_visible(False)\n",
" if key == 'acc':\n",
" ax.axhline(0.5, color='gray', linestyle='--', lw=0.8, alpha=0.6)\n",
"\n",
"fig.legend(handles=[Patch(color=cond_color['FES'], label='ONLINE_FES'),\n",
" Patch(color=cond_color['NOFES'], label='ONLINE_NOFES')],\n",
" loc='upper right', ncol=2, bbox_to_anchor=(0.98, 1.0))\n",
"plt.tight_layout()\n",
"plt.savefig('fes_vs_nofes_metrics.png', dpi=150, bbox_inches='tight')\n",
"plt.show()\n",
"print('Saved: fes_vs_nofes_metrics.png')"
]
2026-04-21 13:01:49 -05:00
},
{
"cell_type": "markdown",
"id": "248740bd",
"metadata": {},
2026-04-21 21:18:33 -05:00
"source": [
"---\n",
"## Figure 2 — LDA decision-function distributions\n",
"\n",
"Visualizes classification amplitude and separability directly: wider FES vs NOFES spread between MI and REST curves = higher Fisher ratio and larger mean |margin|."
]
2026-04-21 13:01:49 -05:00
},
{
"cell_type": "code",
2026-04-21 21:18:33 -05:00
"execution_count": 9,
2026-04-21 13:01:49 -05:00
"id": "393042a0",
2026-04-21 21:18:33 -05:00
"metadata": {
"execution": {
"iopub.execute_input": "2026-04-22T00:52:04.242359Z",
"iopub.status.busy": "2026-04-22T00:52:04.242277Z",
"iopub.status.idle": "2026-04-22T00:52:04.931529Z",
"shell.execute_reply": "2026-04-22T00:52:04.930993Z"
}
},
"outputs": [
{
"data": {
"image/png": "iVBORw0KGgoAAAANSUhEUgAABZAAAASPCAYAAACQ3tqnAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAASdAAAEnQB3mYfeAABAABJREFUeJzs3QeYHHX9x/HvluvpIRUSOtKrdIEgRUB6B+lFMIggTRAQUEEgUqRXCU2USFcBRarUoLTwpwUCISGB1Ltcv92d//P5hdnM7m2ZveRq3q/n2efudmdnZ2dm92Y/+53vL+J5nmcAAAAAAAAAAGSJZl8BAAAAAAAAAAABMgAAAAAAAAAgLyqQAQAAAAAAAAA5ESADAAAAAAAAAHIiQAYAAAAAAAAA5ESADAAAAAAAAADIiQAZAAAAAAAAAJATATIAAAAAAAAAICcCZAAAAAAAAABATgTIAAAAAAAAAICcCJABAAAAAAAAADkRIAMAUEQkEklfLr744i5fX+PGjUs/vn7vzc+lVJ999pkddthhNmrUKIvH4+llnzhxovUVzz//fMZ20d+9Ub59qyc/v962zD11uYDl2eeff57xuuxL/58AAPARIAMAlvrD0jHHHFP0PvpAFbyPLuXl5TZgwABbZZVVXDD6i1/8wj744INQy/DII4+0m9+ZZ57J1uxDmpqabI899rA///nPNnv2bEsmk9bbKJQM7qNYOn05qGFf6XneeustGz9+vG244YY2aNAg9z9r2LBhtu2229qvfvUr++qrr0IF/br861//KvjloP4PFvqfGWZf1//i4H30esn32tHl9ttvLzqPYs8r1yX7uQAAgN4v3t0LAABYfrW1tbnLokWL7IsvvrAXXnjBrrzySjvuuOPsuuuus5qamrz3vfPOO9tdd99999nll19uZWVl1pf85Cc/sT333NP9PmbMmKWa14QJE9K/b7PNNtaTTZ482T766KP031oH2223nUWjUdt8882tr1h99dUztov+7kt68vPrTa+Hnr4u+5Lm5mb72c9+ljNgnTt3rru88sor7v/V1Vdf7ULmYs455xz73//+16O+SLrooovsRz/6kVVXV3f3ovRqQ4YMyXhd9qX/TwAA+AiQAQDd4pBDDrHvfve7VldXZ++++649+eST1tra6m774x//aFOnTrV//vOfVlFR0e6+qvp66qmn2l3/zTff2BNPPGH777+/9bV1taycddZZ1lsEq+fk2muv7ZOBmb4U6E3bpbc/v0Qi4b64qqqq6lHL1RvXZV/keZ4deeSR9te//jV93YgRI+zggw+2kSNHui+1/vKXv1hLS4u7nHLKKe4++lnI22+/7b7k1Lx7ilmzZtlVV11lF154YYfuv8suu9iuu+7a7vqBAwfa8kRnUvG6BAD0dbSwAAB0i91228194Pr1r39tjz76qH3yySe2ySabpG9/8cUX7dJLL815X53K67czUF/c1VZbLX2bwueOqK2ttTPOOMMFNJWVlfad73zHrrjiChc0FaO2G6oSXnvttV3VtIIp3f/nP/+5zZw5M+/9/v73v9uBBx5oK6+8sntMfQjVPI4//nj79NNPQ/VAVpih6ddcc033uDrFWv2CVQGlZfr3v/9dUg/kKVOm2I9//GNba621XFWa5rnGGmu4x3jnnXeKnnavQOV3v/udex4K/xW4nHzyye6LgrD8U62PPvrojOu1HMFTs4v1gy203rLXgyoD99lnHxs8eLB7zltssYXbPrmkUin705/+5CqiR48e7Z6n7rfBBhu4EEnVif6yXXLJJXkf12/9Eqav7T/+8Q/3xciKK66Ybv2y8cYb2y9/+Uv7+uuv202f/dw1jfYH3V/Lq/1FFXMKvjr7dVLo+Wld3nzzzbb99tvbCius4F7PCp+0rffee2/77W9/aw0NDW5anRa/6qqrZsz72GOPzXm6vaYNruf33nvPbd+hQ4e6MxRef/31knuCK1Dcaqut3GtcFYd67QYr5MO02Ai2B/BP8++t+0op2y6snrCfP/TQQxnh8frrr+/e43VWjJbj7rvvttdeey3jDBn9Lyv0Xu9TUKv3yJ5E60dfvnaEqvb13LMvJ554Yuh5LFiwwM4//3y3nbW9tR9pf1pvvfXsiCOOsDvuuKPdffR+o+t33nln11JE+4ruozB70qRJOR/njTfesEMPPTT9/1aXlVZaybUjOf300+3NN9/s8PRhWuuoYl3V3noP07y0/6y77rqu0l29/rNlv1fof6jafOn+2rfHjh1r5557bvqL9yC1fdK60Bcfer/r37+/m4eOu7QPqiUUAAAl8wAAKNG0adP0aTx9Ofroo4ve56677sq4j/7ONn36dK+ioiI9zYABA7zW1taMaVKplLf66qunp9ltt928yy67LP13LBbzZs6cWdLzqaur8zbccMOM5fMve+21V8bfF110UcZ977jjDq+8vDznfXUZPHiw95///CfjPnpOBx54YN776PLII4+kp99hhx3S1+t334cffuj169ev4Hyyt02h53Lrrbd6ZWVleecVj8e9m266KeM+mkdwmu222y7nfXfccccO71+5Lprmueeey7hOfwflW2/Z62HLLbfMuQ2j0aj37LPPZtxv4cKFeZ+jf3nrrbfaLVuhbVPoeSSTSe+YY44pOJ8VVljBe+WVV/I+99VWW80bPXp0zvtefPHFnf46KfT8TjzxxFDbWlZeeeWi0/qC026yySZeTU1NzmUIu8zZz8+/DBo0yHvnnXfy7rvZ73Pa5v5tWsZcj9Vb9pVStl0xPWk///73v59x3yeffDLndL/4xS8ypvv1r3+dvi17O40aNSr9+5VXXplz+f39oZT/mdmC+1f2+s/eN4PLNH78+LzzCMp+Xtn/Q0rV3Nzsrb/++gW3e/Z6mTdvnrf55psXvM9hhx3m9inf888/7/5/FbpP8LmUOn2x1/2FF17oRSKRvPPS+9Ojjz6ad1sOHTrUW3fddXPeV6+boEsvvbTo6zL7fyUAAGHQwgIA0GOoqlEVMo899pj7WxU3qvLZeuut09OoT3KwOlcVPd/73vdcBZPyIFUmq0LsvPPOC/24GgxJbTR8G220kaug0+M88MADee+nKkZV66oST1SBqipHLYcqgHR/VVftt99+rsLaP6337LPPzqhwUzWjTo9W5bDuozYcYdx1111WX1/vftcAT6rGVBWWqvA0H1Vxh6XqKFXu+c9F89Gp1rFYzO655x5XoaZT/1Vhq+epdZ7LSy+95J6vKqvuv//+dBuK5557zq2vLbfcMnQ/SW17nSruU/WfKn39abJbXHSUlkuVZdqXvvzyS1ddLFoX6nG64447pqdVVbSeY3Cf3Xfffd3yfPjhh+lt5/eqVRuW4OBZwT6ZqmwsRtMHq9l0H+1j2sbaz1WJp4pnXRfcx4JU3aaKN21fVVeralQDFIr6t2q9hukb3tHXST7ad4NnDHz/+99361oVmjNmzHA9sN9///307XqNa5tfdtll7VrhFBsITfuxtq8qptUep1B/9Vy0XVVpqypXVav/7W9/c9cvXLjQVQrquo7qjftKqduuJy17Ifr/8Z///Cf9t95X9T8pF1WnqvreV+j9VlX7v/nNb9z/NJ2hccIJJ6Tfy7qL9lv9n5o2bZrddtttrqpWFdul0P+N3//+9zkrk8P0FNf/BZ31Iuptr/85eo3q/+b06dMztoXvqKOOcvuXaHtrO6jqXfub/l/ofVvvR9qHtM1F+4L+f4mq01XZrKpctcPS/hR8T+/I9IVombTtfaoE1vtWY2Nj+n+4KvX1PLQucrVpmjdvnlsneu4660XV13o9iP4/6z1Rxw+iSnmf3hv98RP0v03v3//9739DLzsAABlCxcwAAHRBBbKcc845GdM9+OCDGbcfccQR6duqq6u9RYsWueu32Wab9PVrrLFG6O3V1tbm9e/fP33ftdZay1VF+VRVlq/q6IADDkhfv9FGG3ktLS0ZVVKVlZXp26+55hp3/YIFCzKqfMeOHet98803Gcuk5/T1118XraQ97bTT0tefdNJJ7Z6bKp0///zzjOvyPZf9998/o4r7gw8+SN/28cc
"text/plain": [
"<Figure size 1440x1152 with 6 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"Saved: decision_margin_distributions.png\n"
]
}
],
"source": [
"fig, axes = plt.subplots(len(subjects), len(PAIRS),\n",
" figsize=(6 * len(PAIRS), 3.2 * len(subjects)),\n",
" sharex=True, squeeze=False)\n",
"fig.suptitle('LDA decision-function distributions on ONLINE sessions\\n'\n",
" '(class separation ↔ classification amplitude & SNR)',\n",
" fontsize=12, fontweight='bold', y=1.01)\n",
"\n",
"for i, subj in enumerate(subjects):\n",
" for j, pair in enumerate(PAIRS):\n",
" ax = axes[i][j]\n",
" for cond, color in cond_color.items():\n",
" row = next((r for r in results\n",
" if r['subject']==subj and r['pair']==pair['name']\n",
" and r['condition']==cond), None)\n",
" if row is None: continue\n",
" m_mi = row['margin'][row['y_test'] == 1]\n",
" m_rest = row['margin'][row['y_test'] == 0]\n",
" ax.hist(m_mi, bins=15, alpha=0.5, color=color,\n",
" label=f'{cond} MI', density=True)\n",
" ax.hist(m_rest, bins=15, alpha=0.25, color=color, hatch='///',\n",
" edgecolor=color, label=f'{cond} REST', density=True)\n",
" ax.axvline(0, color='k', lw=0.8)\n",
" ax.set_title(f'{subj} | {pair[\"name\"]}', fontsize=10, fontweight='bold')\n",
" if j == 0: ax.set_ylabel('Density')\n",
" if i == len(subjects) - 1: ax.set_xlabel('LDA decision function')\n",
" ax.spines[['top','right']].set_visible(False)\n",
" if i == 0 and j == 0:\n",
" ax.legend(fontsize=6.5, loc='upper left', ncol=2)\n",
"\n",
"plt.tight_layout()\n",
"plt.savefig('decision_margin_distributions.png', dpi=150, bbox_inches='tight')\n",
"plt.show()\n",
"print('Saved: decision_margin_distributions.png')"
]
2026-04-21 13:01:49 -05:00
},
{
"cell_type": "markdown",
"id": "fcb6d19d",
"metadata": {},
2026-04-21 21:18:33 -05:00
"source": [
"---\n",
"## Figure 3 — Paired Δ (FES − NOFES) per metric\n",
"\n",
"Within each (subject × pair), FES and NOFES sessions use the same offline-trained model. Positive bars mean FES > NOFES; negative means NOFES > FES. This removes the offline-model-quality confound and isolates the effect of feedback type."
]
2026-04-21 13:01:49 -05:00
},
{
"cell_type": "code",
2026-04-21 21:18:33 -05:00
"execution_count": 10,
2026-04-21 13:01:49 -05:00
"id": "75df404b",
2026-04-21 21:18:33 -05:00
"metadata": {
"execution": {
"iopub.execute_input": "2026-04-22T00:52:04.933405Z",
"iopub.status.busy": "2026-04-22T00:52:04.933304Z",
"iopub.status.idle": "2026-04-22T00:52:05.178252Z",
"shell.execute_reply": "2026-04-22T00:52:05.177815Z"
}
},
"outputs": [
{
"data": {
"image/png": "iVBORw0KGgoAAAANSUhEUgAAB3cAAAIwCAYAAACVwOH0AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAASdAAAEnQB3mYfeAAAptdJREFUeJzs3QmcXeP9OP4nkU0sESELErvaglrS4EujaFVVg0RRaqmtQWmoij22NnZC1JeoXWsJQWlL0cUW1SLaqqX2LREaIkLC/b8+5/s7939n5s7MnclMZu7M+/16ndedOfecc8++fZ7n83QpFAqFBAAAAAAAAEC71rWtZwAAAAAAAACAxgnuAgAAAAAAAFQBwV0AAAAAAACAKiC4CwAAAAAAAFAFBHcBAAAAAAAAqoDgLgAAAAAAAEAVENwFAAAAAAAAqAKCuwAAAAAAAABVQHAXAAAAAAAAoAoI7gIAAAAAAABUAcFdAAAAAAAAgCoguAsAQJu5+uqrU5cuXYrdK6+80uRplI5/6qmnVjzeiBEjiuPF3x3RfvvtV1zGVVZZpa1nByijUCikTTbZJDtOF1tssfT8889XzXpqy3M47UNs89JtGPsE/+f+++8vrpdddtnFagEAoMUI7gIA0KDf/va3NV7c3nbbbXWGGTRoUPH7pZZaKn3++ef1vuCM7o477mh0rT/00EM1xon/ab9uv/32GtsruqOPPjq1RxFAqj2v5boIjpeKAHkl45XbV6dMmZJ22mmn7Fjp0aNHdpwMGTIkDR8+PB100EHp8ssvT+1J7WU64YQTGl2P9QX2XnjhhXTMMcekTTfdNPXr1y917949+4z/Yx+J7ysJGjXUtcT2zed1zJgxaZ111klLLLFE6tmzZxo4cGBab7310qhRo9Lpp5+eXn/99dSSrrvuuvS3v/0t+3u33XZLa621VuoInMObXginoa524LvSY6P2cfnFF1+kq666Km233Xapf//+2fHYp0+ftOqqq6b/+Z//SYcddli6+eabU3tWei4udxy3l/0v1nGc50Lc9/z5z39e5PMAAEDH1K2tZwAAgPZtyy23zGqT5QHbP/7xj1kAIhe1zN55553i/3PmzMkCFZtttlmxX4yTi5esW221VfZ3DHPOOecUv1t22WXTovLDH/4wC7aFwYMHp45ojz32SOuvv372d7y8b02TJ0+u0+/6669PP//5z7PgQWd24IEH1lk/8+fPz46VCBQ+/vjj6ZZbbkmHHHJIaq8uvPDCLOi54oorVjxOBJFOOeWUdNZZZ2V/l3r//fez7sknn8ymffzxx6fx48enrl3brvzxfffdl3beeec0b968Gv3ffffdrPvnP/+ZFW4ZOnRoi50z4rx60kknFf//8Y9/nKpJS5zDS8ffYostWmzeqGvBggXZde93v/tdjf4ffvhh1kUg+OGHH8663Xff3SpsAWPHjk177bVX9nec5wR4AQBoCYK7AAA0KGoYbrzxxumJJ56oE6gt93/er77gbgQbo9ZeiNpw0bWF7373u6mj22GHHbKuOQGACD4uvvjiFQ3/1ltvZTW8a5sxY0a666670q677pras0MPPTStvvrqdfrngfFy+vbtm72oL6d0Wr///e9rBHa//OUvp2984xtZsD2Cm9OnT09/+ctfUns3d+7cdPLJJ5cN4tfn2GOPTeedd17x/6WXXjorcLDyyiunV199Nf3qV7/KAkoR+D3jjDOyoGppoK+2qAHXnOO2ku0b83DAAQcUA7sRpBw9enRWu/qzzz5LL774YnrkkUfSyy+/nFpSHB+vvfZa9nfUnNx8881TNWmJc3jU6ub/F+eVOL/U1lDge7XVVssKLJVTGnCPGrulgd2tt94663r37p1mzpyZnnrqqfToo4/aHAspzmtxvgtRYCTWb5xD41wf5/woIAIAAAulAAAAjfjJT35SiFvH6Lp06VKYNWtW8bvvfe97Wf/evXsX+vTpk/290047Fb+fO3duoWfPnsXxDz/88OJ3v/zlL4v9o3v55Zez/qX9ynUrr7xycRql/U855ZTC9OnTC7vsskuhb9++hV69ehU222yzwt13311nmb761a8Wx4u/S9We5pNPPlnYeeedC8sss0yD02xM7eV96aWXChMnTiwMHTo0W0fLL798Yb/99iu8+eabdcY9++yzCyNHjiystdZahX79+hW6detWWHLJJbNxf/zjHxdef/31OuPsu+++ZddZiP/z72K4Z555JlvGZZddNuv34IMPVrxcZ555ZnFaPXr0KKy77rrF/7/1rW8V2pvYpqXbodJlLV1ntddnfWLb5OOsvvrqhQULFtQZ5rPPPiv89re/LbQn5Y67xRZbrPDss8/Wux7z4zc88cQTNb5bYYUVCq+88kqN34j/o3/pcH/961+L38f0Sr+L/bS1tu/TTz9d0ThxfildzoX1zW9+s/ibxx57bIueM0LM61FHHVVYb731CksssUR2fA4ZMqTw3e9+t/CnP/2p7Dg33XRTYfvtty/079+/eJ6J/f0b3/hG4cQTTyy8/fbbrXoOD5MnTy7269q1a+GNN96oM58bbLBBcZhdd921xnf//Oc/C4ceemjhS1/6UnZtivN2nDtjXZSbVkuI7VJ6baxU6Xm69nHUkNJxal/D6hPXxnycESNGlB3m448/Ltx///0Vz3/t4zT2iQceeKCwzTbbZPtOdF//+tcLjz/+eNnx58yZUzjvvPMKW265ZXbd7t69e7bvxfXo97//fYPrqlwXx25T9r/w3nvvFU499dTCpptuWlh66aWzeVhxxRULe+65Z2HatGl15rn2fv/CCy8UJkyYUFh77bWzY6z29hg9enRx2COOOKLidQsAAPUR3AUAoFERyCx9kXn77bcXv1tppZWyfttuu20WyIu/Iwj6+eefZ9/HS97ScW+55ZZWCwzEC9XFF1+8zvARHIj5aE5w9ytf+Ur2sraSaTam9vLGOiu3fIMHD64TgIiAbkPrJF6K/+Mf/2hWcPfLX/5yFvhpakAsfPHFF1nQsjTIcsEFF9QICNYXeGpIY/tA7S4PCrW34O6PfvSj4jixDZ9//vlCNShdP4MGDSr+veOOO1YU3D3ggANqfHfZZZeV/Z3oXzpcjNcWwd2//e1vNcaJfTj27dY0b968LOiY/+bUqVNb9Jxx55131jmua3fHH398vQU16utK12drBXcj4LfUUksV+0fhltpB9tLx7rnnnuJ3V155Zdlzdum58i9/+UuhJeXnvDiXNjXAuyiDuxEwzceJYHdpoL65ah+ncY6I62Pt9R6FEWoHa6OwwpprrtngvlJa6KE1grtREGXAgAH1DhvXsAjcl6q932+11VYNbo8LL7yw+N0aa6yx0OscAACkZQYAoFHRRm7tdndHjhyZ/vOf/6Q33ngj6/fVr341S+P7m9/8Jv33v/9NTz/9dJaCtnba5kgB2ZhIzfrSSy+lX/ziF2VTq9bXfmz81korrZS+973vZW2Z3njjjcWUq2effXbaZpttmry1oz3Ulp5m7g9/+EPW/mGkvX7wwQeLbfHF7xxxxBFpypQpxWFjHkaMGJGltI2UndF2caz7m2++OUvv+8EHH2RpcO++++4mz8ff//73bPvGMn7pS1/KUtAuscQSFY0b6zy2VW6fffZJw4cPz1Ktxv4S3TXXXJPGjRuX2qtf//rX6a9//Wud/pECuL62VSPt5rnnnlunf+ybBx10UPH/2La5WbNmZes3UnJGiuE4PuLY2nDDDVN79vWvfz3bJ6IdznvuuSc99NBD2b7YkNrH/Z577ll2uEjTXJpO9k9/+lO90/zHP/5Rdp1HeuWG0o9Xsn3XXnvt7Pz1ySefFNu+jfaiI01ybJ/Yp/NzXEuJVPel7fsOGzasxc4ZkT46li9fnkgLu99++2X7Z6yPOHeHaA850irnbYJefPHFxd+KfTRvlzym/8wzz2RtJFeiuefwXJx/Yt+44ooriu13/+QnPyl+f8MNNxT/jtTZkeo8P18ffPDBxTae41j7zne+E4XaszTgMU9xrtxll13SCy+80GJtkUf7tJdeeml2Lt1uu+3S/fff3+w25GOZy6V
"text/plain": [
"<Figure size 1920x540 with 4 Axes>"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"Saved: fes_minus_nofes_delta.png\n"
]
}
],
"source": [
"fig, axes = plt.subplots(1, 4, figsize=(16, 4.5))\n",
"fig.suptitle('Within-pair Δ = FES − NOFES (positive → FES better)',\n",
" fontsize=12, fontweight='bold', y=1.03)\n",
"\n",
"for ax, (key, title, _) in zip(axes, METRICS):\n",
" labels, deltas = [], []\n",
" for subj in subjects:\n",
" for pair in PAIRS:\n",
" fes = next((r for r in results if r['subject']==subj and r['pair']==pair['name']\n",
" and r['condition']=='FES'), None)\n",
" nof = next((r for r in results if r['subject']==subj and r['pair']==pair['name']\n",
" and r['condition']=='NOFES'), None)\n",
" if fes is None or nof is None: continue\n",
" deltas.append(fes[key] - nof[key])\n",
" labels.append(f'{subj}\\n{pair[\"name\"].split()[0]}')\n",
"\n",
" colors = ['#E05C2A' if d > 0 else '#2A7BE0' for d in deltas]\n",
" ax.bar(np.arange(len(deltas)), deltas, color=colors, edgecolor='white', zorder=2)\n",
" ax.axhline(0, color='k', lw=0.8)\n",
" ax.set_xticks(np.arange(len(deltas)))\n",
" ax.set_xticklabels(labels, fontsize=8)\n",
" ax.set_title(f'Δ {title.split(\"(\")[0].strip()}', fontsize=10, fontweight='bold')\n",
" ax.grid(axis='y', alpha=0.3)\n",
" ax.spines[['top','right']].set_visible(False)\n",
"\n",
"plt.tight_layout()\n",
"plt.savefig('fes_minus_nofes_delta.png', dpi=150, bbox_inches='tight')\n",
"plt.show()\n",
"print('Saved: fes_minus_nofes_delta.png')"
]
2026-04-21 13:01:49 -05:00
},
{
"cell_type": "markdown",
"id": "b3db60ba",
"metadata": {},
"source": [
"---\n",
"## Summary Statistics"
]
},
{
"cell_type": "code",
2026-04-21 21:18:33 -05:00
"execution_count": 11,
2026-04-21 13:01:49 -05:00
"id": "cf55268e",
2026-04-21 21:18:33 -05:00
"metadata": {
"execution": {
"iopub.execute_input": "2026-04-22T00:52:05.179587Z",
"iopub.status.busy": "2026-04-22T00:52:05.179489Z",
"iopub.status.idle": "2026-04-22T00:52:05.183657Z",
"shell.execute_reply": "2026-04-22T00:52:05.183209Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"=== Aggregate across 5 complete (subject × pair) comparisons ===\n",
"\n",
"Metric FES (mean ± sd) NOFES (mean ± sd) paired Δ\n",
"---------------------------------------------------------------------------------------\n",
"Classification accuracy 0.673 ± 0.197 0.817 ± 0.142 -0.144\n",
"Classification amplitude 3.161 ± 2.131 2.923 ± 1.879 +0.237\n",
"Fisher ratio (test SNR) 3.000 ± 3.129 3.405 ± 3.558 -0.405\n",
"μ-band SNR (REST/MI) 1.964 ± 0.779 1.840 ± 0.552 +0.124\n",
"\n",
" acc FES > NOFES in 1/5 comparisons (NOFES > FES in 4)\n",
" |margin| FES > NOFES in 3/5 comparisons (NOFES > FES in 2)\n",
" Fisher FES > NOFES in 1/5 comparisons (NOFES > FES in 4)\n",
" μ-SNR FES > NOFES in 3/5 comparisons (NOFES > FES in 2)\n"
]
}
],
"source": [
"# Build only pairs where BOTH FES and NOFES survived evaluation\n",
"paired = []\n",
"for subj in subjects:\n",
" for pair in PAIRS:\n",
" fes = next((r for r in results if r['subject']==subj and r['pair']==pair['name']\n",
" and r['condition']=='FES'), None)\n",
" nof = next((r for r in results if r['subject']==subj and r['pair']==pair['name']\n",
" and r['condition']=='NOFES'), None)\n",
" if fes and nof:\n",
" paired.append((fes, nof))\n",
"\n",
"print(f'=== Aggregate across {len(paired)} complete (subject × pair) comparisons ===\\n')\n",
"\n",
"hdr = f'{\"Metric\":<28} {\"FES (mean ± sd)\":>22} {\"NOFES (mean ± sd)\":>22} {\"paired Δ\":>12}'\n",
"print(hdr); print('-' * len(hdr))\n",
"for k, label in [('acc', 'Classification accuracy'),\n",
" ('amp', 'Classification amplitude'),\n",
" ('fisher', 'Fisher ratio (test SNR)'),\n",
" ('mu_snr', 'μ-band SNR (REST/MI)')]:\n",
" fes_v = np.array([f[k] for f,_ in paired])\n",
" nof_v = np.array([n[k] for _,n in paired])\n",
" delta = fes_v - nof_v\n",
" sd = lambda a: a.std(ddof=1) if len(a) > 1 else 0.0\n",
" print(f'{label:<28} {fes_v.mean():>10.3f} ± {sd(fes_v):>6.3f} '\n",
" f'{nof_v.mean():>10.3f} ± {sd(nof_v):>6.3f} {delta.mean():>+12.3f}')\n",
"\n",
"# Sign test (simple)\n",
"print()\n",
"for k, label in [('acc','acc'), ('amp','|margin|'), ('fisher','Fisher'), ('mu_snr','μ-SNR')]:\n",
" d = np.array([f[k] - n[k] for f,n in paired])\n",
" n_pos = int((d > 0).sum()); n_neg = int((d < 0).sum())\n",
" print(f' {label:<10} FES > NOFES in {n_pos}/{len(d)} comparisons (NOFES > FES in {n_neg})')"
]
2026-04-21 13:01:49 -05:00
}
],
"metadata": {
"kernelspec": {
"display_name": "venv",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.7"
}
},
"nbformat": 4,
"nbformat_minor": 5
2026-04-21 21:18:33 -05:00
}