| |
| """Which mental tasks differ in embedding space? — the core of the personal-MI scheme. |
| |
| For ONE subject (your constraint), embeds every trial, measures **cross-session** pairwise |
| separability between task classes, picks the best mutually-separable k-set (max-min clique), |
| and runs a **permutation test** so a separability value is only trusted if it beats shuffled |
| labels. Cross-session (train day-A / test day-B) is the honest test that separability is |
| neural, not cap-placement (research/MI_4class_personal_design.md). |
| |
| Embedding spaces (--embedding): |
| tangent Riemannian tangent space of the covariance (8-30 Hz, no training) [default] |
| cbramod frozen CBraMod embedding (0.5-45 Hz, 200 Hz, MPS) |
| labram frozen LaBraM embedding (0.5-45 Hz, 200 Hz, MPS) |
| |
| Demo on public 4-class MI (BCI IV-2a, one subject). Point it at your own multi-task |
| screening recording later (same MOABB/MNE epoch format). |
| |
| python src/exploration/task_separability.py --embedding tangent --permutations 200 |
| python src/exploration/task_separability.py --embedding cbramod --permutations 100 |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import os |
| import sys |
| from importlib import import_module |
| from itertools import combinations |
| from pathlib import Path |
|
|
| import numpy as np |
|
|
| ROOT = Path(__file__).resolve().parents[2] |
| os.environ.setdefault("MNE_DATA", str(ROOT / "mne_data")) |
| os.environ.setdefault("MOABB_RESULTS", str(ROOT / "mne_data")) |
| os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1") |
| (ROOT / "mne_data").mkdir(parents=True, exist_ok=True) |
|
|
| from sklearn.pipeline import make_pipeline |
| from sklearn.linear_model import LogisticRegression |
| from sklearn.preprocessing import StandardScaler |
| from sklearn.decomposition import PCA |
| from sklearn.metrics import balanced_accuracy_score |
|
|
| from pyriemann.estimation import Covariances |
| from pyriemann.tangentspace import TangentSpace |
|
|
| from moabb.paradigms import MotorImagery |
|
|
| DATASETS = {"2a": "BNCI2014_001", "2b": "BNCI2014_004"} |
|
|
| |
| PREP = { |
| "tangent": dict(fmin=8.0, fmax=32.0, resample=None), |
| "cbramod": dict(fmin=0.5, fmax=45.0, resample=200.0), |
| "labram": dict(fmin=0.5, fmax=45.0, resample=200.0), |
| } |
|
|
|
|
| def load_subject(key, subject, n_classes, fmin, fmax, resample): |
| ds = getattr(import_module("moabb.datasets"), DATASETS[key])() |
| ds.subject_list = [subject] |
| kw = dict(n_classes=n_classes, fmin=fmin, fmax=fmax) |
| if resample: |
| kw["resample"] = resample |
| X, y, meta = MotorImagery(**kw).get_data(dataset=ds) |
| return np.asarray(X), np.asarray(y), meta |
|
|
|
|
| def build_features(X, embedding): |
| """Return (feat, make_clf). tangent keeps raw epochs (cov+TS fit per fold); |
| FM precomputes frozen embeddings (fit only a linear head per fold).""" |
| if embedding == "tangent": |
| make_clf = lambda: make_pipeline(Covariances("oas"), TangentSpace(), |
| LogisticRegression(max_iter=1000)) |
| return X, make_clf |
| |
| sys.path.insert(0, str(ROOT / "src" / "foundation")) |
| from encoders import CBraModEncoder, LaBraMEncoder |
| enc = CBraModEncoder() if embedding == "cbramod" else LaBraMEncoder() |
| print(f" encoding {len(X)} trials with {enc.name} …") |
| feat = enc.encode(X) |
| make_clf = lambda: make_pipeline(StandardScaler(), |
| PCA(n_components=min(50, feat.shape[1])), |
| LogisticRegression(max_iter=1000)) |
| return feat, make_clf |
|
|
|
|
| def _split(sessions, y): |
| sess = np.unique(sessions) |
| if len(sess) >= 2: |
| return sessions == sess[0], sessions != sess[0], "cross-session" |
| cut = len(y) // 2 |
| tr = np.zeros(len(y), bool); tr[:cut] = True |
| return tr, ~tr, "within-session (only one session found)" |
|
|
|
|
| def pairwise_separability(feat, y, sessions, classes, make_clf): |
| tr_mask, te_mask, split = _split(sessions, y) |
| n = len(classes) |
| M = np.full((n, n), np.nan) |
| for a, b in combinations(range(n), 2): |
| sel = np.isin(y, [classes[a], classes[b]]) |
| tr, te = sel & tr_mask, sel & te_mask |
| if tr.sum() < 4 or te.sum() < 4: |
| continue |
| clf = make_clf() |
| clf.fit(feat[tr], y[tr]) |
| M[a, b] = M[b, a] = balanced_accuracy_score(y[te], clf.predict(feat[te])) |
| return M, split |
|
|
|
|
| def mean_sep(M): |
| iu = np.triu_indices_from(M, k=1) |
| return float(np.nanmean(M[iu])) |
|
|
|
|
| def best_kset(M, classes, k): |
| best, best_score = None, -1.0 |
| for combo in combinations(range(len(classes)), k): |
| pairs = [M[i, j] for i, j in combinations(combo, 2)] |
| if any(np.isnan(pairs)): |
| continue |
| if min(pairs) > best_score: |
| best_score, best = min(pairs), combo |
| return best, best_score |
|
|
|
|
| def permutation_test(feat, y, sessions, classes, make_clf, n_perm, obs, seed=7): |
| """Shuffle labels within each session, recompute mean pairwise separability -> null.""" |
| rng = np.random.default_rng(seed) |
| null = np.empty(n_perm) |
| for p in range(n_perm): |
| yp = y.copy() |
| for s in np.unique(sessions): |
| m = sessions == s |
| yp[m] = rng.permutation(yp[m]) |
| Mp, _ = pairwise_separability(feat, yp, sessions, classes, make_clf) |
| null[p] = mean_sep(Mp) |
| pval = (1 + int(np.sum(null >= obs))) / (n_perm + 1) |
| return null, pval |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser(description=__doc__) |
| ap.add_argument("--dataset", default="2a", choices=list(DATASETS)) |
| ap.add_argument("--subject", type=int, default=1) |
| ap.add_argument("--n-classes", type=int, default=4) |
| ap.add_argument("--k", type=int, default=4) |
| ap.add_argument("--embedding", default="tangent", choices=list(PREP)) |
| ap.add_argument("--permutations", type=int, default=0, help="0 = skip permutation test") |
| args = ap.parse_args() |
|
|
| p = PREP[args.embedding] |
| X, y, meta = load_subject(args.dataset, args.subject, args.n_classes, |
| p["fmin"], p["fmax"], p["resample"]) |
| sessions = meta["session"].to_numpy() |
| classes = sorted(set(y)) |
| print(f"subject {args.subject} | embedding={args.embedding} | X={X.shape} | " |
| f"classes={classes} | sessions={sorted(set(sessions))}\n") |
|
|
| feat, make_clf = build_features(X, args.embedding) |
| M, split = pairwise_separability(feat, y, sessions, classes, make_clf) |
|
|
| print(f"pairwise separability ({split}, balanced acc; 0.5=indistinguishable):") |
| print(" " + "".join(f"{c[:6]:>8}" for c in classes)) |
| for i, c in enumerate(classes): |
| row = "".join(f"{M[i,j]:>8.2f}" if not np.isnan(M[i, j]) else f"{'·':>8}" |
| for j in range(len(classes))) |
| print(f"{c[:7]:<8}{row}") |
|
|
| pairs = sorted([(classes[i], classes[j], M[i, j]) |
| for i, j in combinations(range(len(classes)), 2) if not np.isnan(M[i, j])], |
| key=lambda t: t[2], reverse=True) |
| print("\neasiest → hardest pairs:") |
| for a, b, s in pairs: |
| print(f" {s:.2f} {a} vs {b}") |
|
|
| obs = mean_sep(M) |
| print(f"\nmean pairwise separability = {obs:.3f}") |
| if args.k <= len(classes): |
| best, score = best_kset(M, classes, args.k) |
| if best: |
| print(f"best {args.k}-set (max-min = {score:.2f}): {[classes[i] for i in best]}") |
|
|
| if args.permutations: |
| print(f"\npermutation test ({args.permutations} shuffles, labels shuffled within session)…") |
| null, pval = permutation_test(feat, y, sessions, classes, make_clf, |
| args.permutations, obs) |
| print(f" observed mean-sep = {obs:.3f} | null mean = {null.mean():.3f} " |
| f"(95th pct {np.percentile(null,95):.3f}) | p = {pval:.4f}") |
| print(" → " + ("REAL structure (p<0.05)" if pval < 0.05 else |
| "NOT distinguishable from chance — suspect artifact / too little data")) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|