File size: 8,048 Bytes
d37c079 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 | #!/usr/bin/env python
"""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"}
# per-embedding default preprocessing (band, resample)
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
# frozen foundation-model embedding
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()
|