| |
| """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)) |
| sys.path.insert(0, str(HERE)) |
| import load as L |
| from common.montage import SENSORIMOTOR |
|
|
| 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", |
| } |
|
|
|
|
| |
| 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) |
| 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)} |
|
|
|
|
| |
| 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 |
|
|
|
|
| |
| 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 |
| |
| |
| e = ep.copy().pick(chans).filter(0.3, 45.0).resample(200.0).crop(*tw) |
| X = e.get_data(copy=False) * 1e6 |
| n_patch = (X.shape[-1] // 200) * 200 |
| 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) |
|
|
|
|
| |
| 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) |
|
|
| |
| 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])] |
| 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}") |
|
|
| |
| 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() |
|
|