Download src/analysis/explore_embeddings.py from Twu31/EEG_MI: direct link, hf CLI and curl.
- Browser
- Download file 10.6 kB
-
https://huggingface.co/Twu31/EEG_MI/resolve/main/src/analysis/explore_embeddings.py
- Command line
-
hf download hf://Twu31/EEG_MI/src/analysis/explore_embeddings.py
-
curl -L -o explore_embeddings.py https://huggingface.co/Twu31/EEG_MI/resolve/main/src/analysis/explore_embeddings.py
10.6 kB
| #!/usr/bin/env python | |
| """Broad offline exploration: is there ANY separable structure in these recordings? | |
| The default pipeline (Riemannian tangent space on all channels, one hand-picked band) | |
| found hands-vs-rest but nothing for hands/feet/math. Before concluding "no signal", search | |
| much wider — different feature spaces (including foundation-model embeddings), different | |
| channel subsets (some electrodes on this cap are dead or actively noisy), and different | |
| time windows — and visualise the embedding space directly. | |
| Two methodological rules, because it is easy to fool yourself with 19–75 trials: | |
| * anything data-driven (channel ranking, band choice) is fitted INSIDE the CV fold; | |
| * the headline number is a permutation test on ONE pre-declared pipeline, and every | |
| other number is reported as exploratory. | |
| Also tests the INHIBITION HYPOTHESIS. The subject reports that during "motor imagery" the | |
| dominant mental act was suppressing the urge to actually move, not imagining movement. | |
| That predicts a specific pattern: an inhibition/effort component shared by every motor | |
| class (so hands vs feet stays at chance), absent during rest (so hands vs rest separates), | |
| and different from a purely cognitive task (so motor-pooled vs math should separate). | |
| Motor inhibition also tends to RAISE beta, whereas imagery lowers it, so the two partly | |
| cancel — which would explain weak, inconsistent beta ERD. | |
| python src/analysis/explore_embeddings.py --dataset both | |
| python src/analysis/explore_embeddings.py --dataset 3class --no-fm # skip FM encoders | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import sys | |
| import warnings | |
| from pathlib import Path | |
| import numpy as np | |
| warnings.filterwarnings("ignore") | |
| HERE = Path(__file__).resolve().parent | |
| sys.path.insert(0, str(HERE.parent)) # src/ | |
| sys.path.insert(0, str(HERE)) # src/analysis/ | |
| import load as L # noqa: E402 | |
| from common.montage import SENSORIMOTOR # noqa: E402 | |
| RESULTS = HERE.parents[1] / "results" | |
| REC = HERE.parents[1] / "recordings" | |
| DATASETS = { | |
| "hands-rest": REC / "cap32_20260725_143756_hands-rest.npz", | |
| "3class": REC / "cap32_20260725_163251_hands-feet-math.npz", | |
| } | |
| # ------------------------------------------------------------------ data | |
| def build_epochs(path, tmin=-2.0, tmax=4.0): | |
| """Recording → (Epochs, labels, names). Keeps ALL channels; drops only trials whose | |
| window runs past the end of the recording (a stalled session leaves fake trials).""" | |
| import mne | |
| mne.set_log_level("CRITICAL") | |
| z = np.load(path, allow_pickle=True) | |
| fs, N = float(z["fs"]), z["data"].shape[1] | |
| on, nm = z["trial_onset"], np.array([str(s) for s in z["trial_name"]]) | |
| ok = np.where((on >= int(-tmin * fs)) & (on + int((tmax + 0.2) * fs) < N))[0] | |
| raw, _, _ = L.read_recording(str(path)) | |
| raw, bad = L.clean_raw(raw, 1.0, 40.0, 50.0, car=True, interpolate=False) # keep bads FLAGGED | |
| names = sorted(set(nm[ok])) | |
| code = {n: i + 1 for i, n in enumerate(names)} | |
| ev = np.column_stack([on[ok], np.zeros(len(ok), int), [code[n] for n in nm[ok]]]) | |
| ep = mne.Epochs(raw, ev, code, tmin=tmin, tmax=tmax, baseline=None, preload=True, | |
| verbose=False) | |
| return ep, ep.events[:, 2] - 1, names, bad | |
| CH_SETS = { | |
| "all32": lambda ep: ep.ch_names, | |
| "drop_bad": lambda ep: [c for c in ep.ch_names if c not in ep.info["bads"]], | |
| "sensorimotor": lambda ep: [c for c in SENSORIMOTOR if c in ep.ch_names], | |
| "central+frontal": lambda ep: [c for c in ep.ch_names if c not in ep.info["bads"] | |
| and c[0] in "FC" or c in ("CZ", "FZ")], | |
| } | |
| BANDS = {"mu 8-13": (8, 13), "beta 13-30": (13, 30), "mu+beta 8-30": (8, 30), | |
| "wide 4-40": (4, 40), "theta 4-8": (4, 8)} | |
| # --------------------------------------------------------------- pipelines | |
| def make_pipelines(n_classes): | |
| from sklearn.pipeline import make_pipeline | |
| from sklearn.linear_model import LogisticRegression | |
| from sklearn.preprocessing import StandardScaler | |
| from sklearn.discriminant_analysis import LinearDiscriminantAnalysis as LDA | |
| from sklearn.svm import SVC | |
| from pyriemann.estimation import Covariances | |
| from pyriemann.tangentspace import TangentSpace | |
| from pyriemann.classification import MDM | |
| from mne.decoding import CSP | |
| lr = lambda: LogisticRegression(max_iter=2000, C=1.0) | |
| return { | |
| "TS+LR (oas)": make_pipeline(Covariances("oas"), TangentSpace(), StandardScaler(), lr()), | |
| "TS+LR (lwf)": make_pipeline(Covariances("lwf"), TangentSpace(), StandardScaler(), lr()), | |
| "TS+SVM": make_pipeline(Covariances("oas"), TangentSpace(), StandardScaler(), | |
| SVC(kernel="rbf", C=1.0)), | |
| "MDM": make_pipeline(Covariances("oas"), MDM()), | |
| "CSP+LDA": make_pipeline(CSP(n_components=4, log=True), LDA()), | |
| "logvar+LR": make_pipeline(LogVar(), StandardScaler(), lr()), | |
| } | |
| class LogVar: | |
| """Simple, robust baseline feature: log band-power per channel.""" | |
| def fit(self, X, y=None): return self | |
| def transform(self, X): return np.log(np.var(X, axis=-1) + 1e-12) | |
| def fit_transform(self, X, y=None): return self.transform(X) | |
| def get_params(self, deep=True): return {} | |
| def set_params(self, **k): return self | |
| # --------------------------------------------------------- FM embeddings | |
| def fm_embeddings(ep, chans, which="cbramod", tw=(0.5, 3.5)): | |
| """(n_trials, d) frozen foundation-model embedding per trial.""" | |
| import torch | |
| sys.path.insert(0, str(HERE.parents[1] / "src")) | |
| from foundation.encoders import CBraModEncoder, LaBraMEncoder | |
| # Both encoders want the model's native rate (CBraMod/LaBraM: 200 Hz) and µV/100 — | |
| # the ÷100 is the encoder's own `scale`, so hand it plain µV at 200 Hz. | |
| e = ep.copy().pick(chans).filter(0.3, 45.0).resample(200.0).crop(*tw) | |
| X = e.get_data(copy=False) * 1e6 # (n, ch, t) µV | |
| n_patch = (X.shape[-1] // 200) * 200 # whole 1 s patches only | |
| X = X[..., :n_patch] | |
| enc = (CBraModEncoder() if which == "cbramod" else LaBraMEncoder()) | |
| with torch.no_grad(): | |
| Z = enc.encode(X) | |
| return np.asarray(Z).reshape(len(X), -1) | |
| # --------------------------------------------------------------- evaluation | |
| def evaluate(X, y, pipes, n_rep=5, seed=0): | |
| """Repeated stratified CV. Returns {name: (mean, std)} of accuracy.""" | |
| from sklearn.model_selection import RepeatedStratifiedKFold, cross_val_score | |
| cv = RepeatedStratifiedKFold(n_splits=5, n_repeats=n_rep, random_state=seed) | |
| out = {} | |
| for name, clf in pipes.items(): | |
| try: | |
| s = cross_val_score(clf, X, y, cv=cv, scoring="accuracy", n_jobs=1) | |
| out[name] = (float(s.mean()), float(s.std())) | |
| except Exception as ex: | |
| out[name] = (float("nan"), 0.0) | |
| return out | |
| def topk_channels_cv(X, y, k=12): | |
| """Rank channels by univariate log-variance discriminability. Used INSIDE folds only.""" | |
| from sklearn.feature_selection import f_classif | |
| f = np.log(np.var(X, axis=-1) + 1e-12) | |
| F, _ = f_classif(f, y) | |
| return np.argsort(np.nan_to_num(F))[::-1][:k] | |
| def main(): | |
| ap = argparse.ArgumentParser(description=__doc__, | |
| formatter_class=argparse.RawDescriptionHelpFormatter) | |
| ap.add_argument("--dataset", default="both", choices=["both", "hands-rest", "3class"]) | |
| ap.add_argument("--no-fm", action="store_true", help="skip foundation-model embeddings") | |
| ap.add_argument("--reps", type=int, default=5) | |
| args = ap.parse_args() | |
| keys = list(DATASETS) if args.dataset == "both" else [args.dataset] | |
| for key in keys: | |
| path = DATASETS[key] | |
| if not path.exists(): | |
| print(f"missing {path}"); continue | |
| ep, y, names, bad = build_epochs(path) | |
| chance = 1.0 / len(names) | |
| print("\n" + "=" * 78) | |
| print(f" {key} {len(ep)} trials · classes {names} · chance {chance:.3f}") | |
| print(f" flagged bad channels: {bad}") | |
| print("=" * 78) | |
| # ---- feature space × channel set × band sweep (EXPLORATORY) ---- | |
| pipes = make_pipelines(len(names)) | |
| print(f"\n[A] 特征空间 × 通道集 × 频段 (准确率, 5×5-fold; 随机={chance:.3f})") | |
| rows = [] | |
| for cs_name, cs_fn in CH_SETS.items(): | |
| chans = [c for c in cs_fn(ep) if c in ep.ch_names] | |
| if len(chans) < 4: | |
| continue | |
| for b_name, (lo, hi) in BANDS.items(): | |
| e = ep.copy().pick(chans).filter(lo, hi).crop(0.5, 3.5) | |
| X = e.get_data(copy=False) * 1e6 | |
| res = evaluate(X, y, pipes, n_rep=args.reps) | |
| for p_name, (m, s) in res.items(): | |
| rows.append((m, s, cs_name, b_name, p_name, len(chans))) | |
| rows = [r for r in rows if np.isfinite(r[0])] # CSP goes rank-deficient on 32ch | |
| rows.sort(key=lambda r: -r[0]) | |
| print(f" {'acc':>7} {'std':>6} {'channels':<16}{'band':<14}{'pipeline':<14}") | |
| for m, s, cs, b, p, n in rows[:12]: | |
| print(f" {m:7.3f} {s:6.3f} {cs:<11}({n:2d}) {b:<14}{p:<14}") | |
| print(f" … {len(rows)} 组合中最好 {rows[0][0]:.3f} / 最差 {rows[-1][0]:.3f}") | |
| # ---- FM embeddings ---- | |
| if not args.no_fm: | |
| print(f"\n[B] Foundation-model embedding (frozen) + 线性分类") | |
| for which in ("cbramod", "labram"): | |
| try: | |
| chans = [c for c in ep.ch_names if c not in bad] | |
| Z = fm_embeddings(ep, chans, which) | |
| from sklearn.pipeline import make_pipeline | |
| from sklearn.preprocessing import StandardScaler | |
| from sklearn.linear_model import LogisticRegression | |
| clf = {"linear probe": make_pipeline( | |
| StandardScaler(), LogisticRegression(max_iter=3000))} | |
| r = evaluate(Z, y, clf, n_rep=args.reps) | |
| m, s = r["linear probe"] | |
| print(f" {which:9s} dim={Z.shape[1]:5d} → {m:.3f} ± {s:.3f}") | |
| np.save(f"/tmp/emb_{key}_{which}.npy", Z) | |
| except Exception as ex: | |
| print(f" {which:9s} 失败: {type(ex).__name__}: {ex}") | |
| np.save(f"/tmp/y_{key}.npy", y) | |
| with open(f"/tmp/names_{key}.txt", "w") as fh: | |
| fh.write("\n".join(names)) | |
| if __name__ == "__main__": | |
| main() | |