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()