File size: 10,607 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 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 | #!/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()
|