moecog 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- moecog/__init__.py +6 -0
- moecog/analysis/__init__.py +1 -0
- moecog/datasets/__init__.py +8 -0
- moecog/datasets/base.py +161 -0
- moecog/evaluations/__init__.py +7 -0
- moecog/evaluations/base.py +111 -0
- moecog/paradigms/__init__.py +9 -0
- moecog/paradigms/base.py +209 -0
- moecog/pipelines/__init__.py +1 -0
- moecog/pipelines/decoders/__init__.py +1 -0
- moecog/pipelines/features/__init__.py +1 -0
- moecog/utils/__init__.py +1 -0
- moecog-0.1.0.dist-info/METADATA +260 -0
- moecog-0.1.0.dist-info/RECORD +17 -0
- moecog-0.1.0.dist-info/WHEEL +5 -0
- moecog-0.1.0.dist-info/licenses/LICENSE +28 -0
- moecog-0.1.0.dist-info/top_level.txt +1 -0
moecog/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Analysis — results storage, statistics, and visualization."""
|
moecog/datasets/base.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Base class for all ECoG datasets."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ElectrodeInfo:
|
|
12
|
+
"""Patient-specific electrode metadata.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
positions : np.ndarray, shape (n_channels, 3)
|
|
17
|
+
Electrode coordinates (MNI or native space).
|
|
18
|
+
labels : list of str
|
|
19
|
+
Channel names.
|
|
20
|
+
hemisphere : list of str or None
|
|
21
|
+
Hemisphere per electrode ("L" or "R").
|
|
22
|
+
lobe : list of str or None
|
|
23
|
+
Anatomical lobe per electrode.
|
|
24
|
+
gyrus : list of str or None
|
|
25
|
+
Gyrus per electrode.
|
|
26
|
+
brodmann_area : list of int or None
|
|
27
|
+
Brodmann area per electrode.
|
|
28
|
+
grid_type : str
|
|
29
|
+
Electrode array type: "grid", "strip", or "depth".
|
|
30
|
+
spacing_mm : float
|
|
31
|
+
Inter-electrode distance in millimeters.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
positions: np.ndarray
|
|
35
|
+
labels: list[str]
|
|
36
|
+
hemisphere: list[str] | None = None
|
|
37
|
+
lobe: list[str] | None = None
|
|
38
|
+
gyrus: list[str] | None = None
|
|
39
|
+
brodmann_area: list[int] | None = None
|
|
40
|
+
grid_type: str = "grid"
|
|
41
|
+
spacing_mm: float = 10.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BaseECoGDataset(ABC):
|
|
45
|
+
"""Base class for all ECoG datasets.
|
|
46
|
+
|
|
47
|
+
Every concrete dataset must implement:
|
|
48
|
+
- ``_get_single_subject_data(subject)``
|
|
49
|
+
- ``data_path(subject)``
|
|
50
|
+
- ``get_electrode_info(subject)``
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
subjects : list of int
|
|
55
|
+
Available subject IDs.
|
|
56
|
+
sessions_per_subject : int
|
|
57
|
+
Number of recording sessions per subject.
|
|
58
|
+
events : dict or None
|
|
59
|
+
Mapping of event names to integer codes. None for pure regression datasets.
|
|
60
|
+
code : str
|
|
61
|
+
Unique dataset identifier string.
|
|
62
|
+
paradigm : str
|
|
63
|
+
Paradigm type: "motor_regression", "motor_imagery", or "naturalistic".
|
|
64
|
+
interval : list of float or None
|
|
65
|
+
Epoch window [tmin, tmax] in seconds. None for continuous data.
|
|
66
|
+
sfreq : float
|
|
67
|
+
Sampling frequency in Hz.
|
|
68
|
+
doi : str or None
|
|
69
|
+
DOI of the associated publication.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
subjects: list[int],
|
|
75
|
+
sessions_per_subject: int,
|
|
76
|
+
events: dict[str, int] | None,
|
|
77
|
+
code: str,
|
|
78
|
+
paradigm: str,
|
|
79
|
+
interval: list[float] | None,
|
|
80
|
+
sfreq: float,
|
|
81
|
+
doi: str | None = None,
|
|
82
|
+
):
|
|
83
|
+
self.subject_list = subjects
|
|
84
|
+
self.n_sessions = sessions_per_subject
|
|
85
|
+
self.event_id = events
|
|
86
|
+
self.code = code
|
|
87
|
+
self.paradigm_type = paradigm
|
|
88
|
+
self.interval = interval
|
|
89
|
+
self.sfreq = sfreq
|
|
90
|
+
self.doi = doi
|
|
91
|
+
|
|
92
|
+
def get_data(self, subjects=None):
|
|
93
|
+
"""Load data for one or more subjects.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
subjects : list of int or None
|
|
98
|
+
Subject IDs to load. If None, loads all subjects.
|
|
99
|
+
|
|
100
|
+
Returns
|
|
101
|
+
-------
|
|
102
|
+
dict
|
|
103
|
+
Nested dict: ``{subject: {session: {run: mne.io.Raw}}}``.
|
|
104
|
+
"""
|
|
105
|
+
subjects = subjects or self.subject_list
|
|
106
|
+
data = {}
|
|
107
|
+
for subject in subjects:
|
|
108
|
+
data[subject] = self._get_single_subject_data(subject)
|
|
109
|
+
return data
|
|
110
|
+
|
|
111
|
+
@abstractmethod
|
|
112
|
+
def _get_single_subject_data(self, subject):
|
|
113
|
+
"""Load all sessions and runs for a single subject.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
subject : int
|
|
118
|
+
Subject identifier.
|
|
119
|
+
|
|
120
|
+
Returns
|
|
121
|
+
-------
|
|
122
|
+
dict
|
|
123
|
+
``{session_id: {run_id: mne.io.Raw}}``.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
@abstractmethod
|
|
127
|
+
def data_path(self, subject):
|
|
128
|
+
"""Return local file paths for a subject's data, downloading if needed.
|
|
129
|
+
|
|
130
|
+
Parameters
|
|
131
|
+
----------
|
|
132
|
+
subject : int
|
|
133
|
+
Subject identifier.
|
|
134
|
+
|
|
135
|
+
Returns
|
|
136
|
+
-------
|
|
137
|
+
list of Path
|
|
138
|
+
Local paths to the data files.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
@abstractmethod
|
|
142
|
+
def get_electrode_info(self, subject):
|
|
143
|
+
"""Return electrode metadata for a subject.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
subject : int
|
|
148
|
+
Subject identifier.
|
|
149
|
+
|
|
150
|
+
Returns
|
|
151
|
+
-------
|
|
152
|
+
ElectrodeInfo
|
|
153
|
+
Electrode positions and anatomical labels.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
def __repr__(self):
|
|
157
|
+
return (
|
|
158
|
+
f"{self.__class__.__name__}(code={self.code!r}, "
|
|
159
|
+
f"subjects={len(self.subject_list)}, "
|
|
160
|
+
f"sessions={self.n_sessions})"
|
|
161
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Base evaluation class."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from sklearn.base import clone
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseEvaluation(ABC):
|
|
12
|
+
"""Base class for all evaluation strategies.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
paradigm : BaseParadigm
|
|
17
|
+
Paradigm instance defining the task.
|
|
18
|
+
datasets : list of BaseECoGDataset
|
|
19
|
+
Datasets to evaluate on (filtered for paradigm compatibility).
|
|
20
|
+
n_splits : int
|
|
21
|
+
Number of cross-validation folds.
|
|
22
|
+
random_state : int
|
|
23
|
+
Random seed for reproducibility.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, paradigm, datasets, n_splits=5, random_state=42):
|
|
27
|
+
self.paradigm = paradigm
|
|
28
|
+
self.datasets = [d for d in datasets if paradigm.is_valid(d)]
|
|
29
|
+
self.n_splits = n_splits
|
|
30
|
+
self.random_state = random_state
|
|
31
|
+
|
|
32
|
+
def process(self, pipelines):
|
|
33
|
+
"""Run all pipelines on all compatible datasets.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
pipelines : dict of str to sklearn estimator
|
|
38
|
+
Named pipelines to evaluate.
|
|
39
|
+
|
|
40
|
+
Returns
|
|
41
|
+
-------
|
|
42
|
+
pd.DataFrame
|
|
43
|
+
Results with columns: dataset, subject, session, pipeline,
|
|
44
|
+
score, metric, time, n_samples, n_channels, fold.
|
|
45
|
+
"""
|
|
46
|
+
all_results = []
|
|
47
|
+
for dataset in self.datasets:
|
|
48
|
+
X, y, metadata = self.paradigm.get_data(dataset)
|
|
49
|
+
results = self._evaluate(dataset, X, y, metadata, pipelines)
|
|
50
|
+
all_results.extend(results)
|
|
51
|
+
return pd.DataFrame(all_results)
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def _evaluate(self, dataset, X, y, metadata, pipelines):
|
|
55
|
+
"""Implement the cross-validation strategy.
|
|
56
|
+
|
|
57
|
+
Returns
|
|
58
|
+
-------
|
|
59
|
+
list of dict
|
|
60
|
+
One dict per (pipeline, fold, subject) combination.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def _score_pipeline(self, pipeline, X_train, y_train, X_test, y_test):
|
|
64
|
+
"""Fit a pipeline and compute scores."""
|
|
65
|
+
t0 = time.time()
|
|
66
|
+
clf = clone(pipeline)
|
|
67
|
+
|
|
68
|
+
if X_train.ndim == 3:
|
|
69
|
+
try:
|
|
70
|
+
clf.fit(X_train, y_train)
|
|
71
|
+
y_pred = clf.predict(X_test)
|
|
72
|
+
except ValueError:
|
|
73
|
+
X_tr = X_train.reshape(X_train.shape[0], -1)
|
|
74
|
+
X_te = X_test.reshape(X_test.shape[0], -1)
|
|
75
|
+
clf.fit(X_tr, y_train)
|
|
76
|
+
y_pred = clf.predict(X_te)
|
|
77
|
+
else:
|
|
78
|
+
clf.fit(X_train, y_train)
|
|
79
|
+
y_pred = clf.predict(X_test)
|
|
80
|
+
|
|
81
|
+
duration = time.time() - t0
|
|
82
|
+
|
|
83
|
+
scoring = self.paradigm.scoring()
|
|
84
|
+
if isinstance(scoring, str):
|
|
85
|
+
scores = {scoring: _compute_metric(scoring, y_test, y_pred)}
|
|
86
|
+
else:
|
|
87
|
+
scores = {
|
|
88
|
+
name: _compute_metric(name, y_test, y_pred) for name in scoring
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {"scores": scores, "time": duration}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _compute_metric(metric_name, y_true, y_pred):
|
|
95
|
+
"""Compute a single evaluation metric."""
|
|
96
|
+
from scipy.stats import pearsonr
|
|
97
|
+
from sklearn.metrics import accuracy_score, cohen_kappa_score, r2_score
|
|
98
|
+
|
|
99
|
+
if metric_name == "pearson_r":
|
|
100
|
+
if y_true.ndim == 1:
|
|
101
|
+
return pearsonr(y_true, y_pred)[0]
|
|
102
|
+
rs = [pearsonr(y_true[:, i], y_pred[:, i])[0] for i in range(y_true.shape[1])]
|
|
103
|
+
return float(np.mean(rs))
|
|
104
|
+
elif metric_name == "r2":
|
|
105
|
+
return r2_score(y_true, y_pred, multioutput="uniform_average")
|
|
106
|
+
elif metric_name == "accuracy":
|
|
107
|
+
return accuracy_score(y_true, y_pred)
|
|
108
|
+
elif metric_name == "kappa":
|
|
109
|
+
return cohen_kappa_score(y_true, y_pred)
|
|
110
|
+
else:
|
|
111
|
+
raise ValueError(f"Unknown metric: {metric_name}")
|
moecog/paradigms/base.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Base paradigm classes for classification and regression tasks."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
import mne
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BaseParadigm(ABC):
|
|
11
|
+
"""Base class for all paradigms.
|
|
12
|
+
|
|
13
|
+
Transforms raw ECoG data from a dataset into ``(X, y, metadata)`` arrays
|
|
14
|
+
ready for scikit-learn pipelines.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
fmin : float
|
|
19
|
+
Lower bandpass frequency in Hz.
|
|
20
|
+
fmax : float
|
|
21
|
+
Upper bandpass frequency in Hz.
|
|
22
|
+
resample : float or None
|
|
23
|
+
Target sampling rate in Hz. None keeps the original rate.
|
|
24
|
+
channels : list of str or None
|
|
25
|
+
Channel names to select. None uses all ECoG channels.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
fmin: float = 0.5,
|
|
31
|
+
fmax: float = 200.0,
|
|
32
|
+
resample: float | None = None,
|
|
33
|
+
channels: list[str] | None = None,
|
|
34
|
+
):
|
|
35
|
+
self.fmin = fmin
|
|
36
|
+
self.fmax = fmax
|
|
37
|
+
self.resample = resample
|
|
38
|
+
self.channels = channels
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def get_data(self, dataset, subjects=None):
|
|
42
|
+
"""Extract (X, y, metadata) from a dataset.
|
|
43
|
+
|
|
44
|
+
Returns
|
|
45
|
+
-------
|
|
46
|
+
X : np.ndarray
|
|
47
|
+
y : np.ndarray
|
|
48
|
+
metadata : pd.DataFrame
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def is_valid(self, dataset) -> bool:
|
|
53
|
+
"""Check if a dataset is compatible with this paradigm."""
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def scoring(self):
|
|
57
|
+
"""Return scoring metric name(s)."""
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def datasets(self) -> list:
|
|
62
|
+
"""Return list of compatible dataset classes."""
|
|
63
|
+
|
|
64
|
+
def _preprocess_raw(self, raw):
|
|
65
|
+
"""Apply bandpass filter, channel selection, and resampling."""
|
|
66
|
+
raw = raw.copy()
|
|
67
|
+
raw.filter(self.fmin, self.fmax, verbose=False)
|
|
68
|
+
if self.channels:
|
|
69
|
+
raw.pick_channels(self.channels)
|
|
70
|
+
if self.resample:
|
|
71
|
+
raw.resample(self.resample, verbose=False)
|
|
72
|
+
return raw
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class BaseClassificationParadigm(BaseParadigm):
|
|
76
|
+
"""Base paradigm for epoched classification tasks.
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
tmin : float
|
|
81
|
+
Epoch start time relative to event onset (seconds).
|
|
82
|
+
tmax : float or None
|
|
83
|
+
Epoch end time relative to event onset (seconds).
|
|
84
|
+
baseline : tuple or None
|
|
85
|
+
Baseline correction window.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, tmin=0.0, tmax=None, baseline=None, **kwargs):
|
|
89
|
+
super().__init__(**kwargs)
|
|
90
|
+
self.tmin = tmin
|
|
91
|
+
self.tmax = tmax
|
|
92
|
+
self.baseline = baseline
|
|
93
|
+
|
|
94
|
+
@abstractmethod
|
|
95
|
+
def used_events(self, dataset):
|
|
96
|
+
"""Return the event dict this paradigm uses from the dataset."""
|
|
97
|
+
|
|
98
|
+
def get_data(self, dataset, subjects=None):
|
|
99
|
+
subjects = subjects or dataset.subject_list
|
|
100
|
+
all_X, all_y, all_meta = [], [], []
|
|
101
|
+
|
|
102
|
+
for subject in subjects:
|
|
103
|
+
sub_data = dataset.get_data(subjects=[subject])
|
|
104
|
+
for session_id, runs in sub_data[subject].items():
|
|
105
|
+
for run_id, raw in runs.items():
|
|
106
|
+
raw = self._preprocess_raw(raw)
|
|
107
|
+
events, _ = mne.events_from_annotations(raw, verbose=False)
|
|
108
|
+
used = self.used_events(dataset)
|
|
109
|
+
tmax = self.tmax if self.tmax is not None else dataset.interval[1]
|
|
110
|
+
epochs = mne.Epochs(
|
|
111
|
+
raw,
|
|
112
|
+
events,
|
|
113
|
+
event_id=used,
|
|
114
|
+
tmin=self.tmin,
|
|
115
|
+
tmax=tmax,
|
|
116
|
+
baseline=self.baseline,
|
|
117
|
+
preload=True,
|
|
118
|
+
verbose=False,
|
|
119
|
+
)
|
|
120
|
+
X = epochs.get_data(copy=False)
|
|
121
|
+
y = np.array(
|
|
122
|
+
[
|
|
123
|
+
list(used.keys())[list(used.values()).index(e)]
|
|
124
|
+
for e in epochs.events[:, 2]
|
|
125
|
+
]
|
|
126
|
+
)
|
|
127
|
+
meta = pd.DataFrame(
|
|
128
|
+
{"subject": subject, "session": session_id, "run": run_id},
|
|
129
|
+
index=range(len(y)),
|
|
130
|
+
)
|
|
131
|
+
all_X.append(X)
|
|
132
|
+
all_y.append(y)
|
|
133
|
+
all_meta.append(meta)
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
np.concatenate(all_X),
|
|
137
|
+
np.concatenate(all_y),
|
|
138
|
+
pd.concat(all_meta).reset_index(drop=True),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class BaseRegressionParadigm(BaseParadigm):
|
|
143
|
+
"""Base paradigm for continuous regression tasks.
|
|
144
|
+
|
|
145
|
+
Uses causal windowing: target ``y[i]`` is the value at the **end** of
|
|
146
|
+
window ``i``, ensuring the model only sees past neural data.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
window_size : float
|
|
151
|
+
Window length in seconds.
|
|
152
|
+
window_stride : float
|
|
153
|
+
Stride between windows in seconds.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
def __init__(self, window_size=0.5, window_stride=0.05, **kwargs):
|
|
157
|
+
super().__init__(**kwargs)
|
|
158
|
+
self.window_size = window_size
|
|
159
|
+
self.window_stride = window_stride
|
|
160
|
+
|
|
161
|
+
@abstractmethod
|
|
162
|
+
def _extract_targets(self, raw, dataset):
|
|
163
|
+
"""Extract continuous target signals from a Raw object.
|
|
164
|
+
|
|
165
|
+
Returns
|
|
166
|
+
-------
|
|
167
|
+
np.ndarray, shape (n_samples, n_targets)
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def get_data(self, dataset, subjects=None):
|
|
171
|
+
subjects = subjects or dataset.subject_list
|
|
172
|
+
all_X, all_y, all_meta = [], [], []
|
|
173
|
+
|
|
174
|
+
for subject in subjects:
|
|
175
|
+
sub_data = dataset.get_data(subjects=[subject])
|
|
176
|
+
for session_id, runs in sub_data[subject].items():
|
|
177
|
+
for run_id, raw in runs.items():
|
|
178
|
+
raw = self._preprocess_raw(raw)
|
|
179
|
+
targets = self._extract_targets(raw, dataset)
|
|
180
|
+
|
|
181
|
+
sfreq = raw.info["sfreq"]
|
|
182
|
+
data = raw.pick(picks="ecog", exclude=[]).get_data()
|
|
183
|
+
win_samples = int(self.window_size * sfreq)
|
|
184
|
+
stride_samples = int(self.window_stride * sfreq)
|
|
185
|
+
|
|
186
|
+
n_windows = (data.shape[1] - win_samples) // stride_samples + 1
|
|
187
|
+
X = np.zeros((n_windows, data.shape[0], win_samples))
|
|
188
|
+
y = np.zeros((n_windows, targets.shape[1]))
|
|
189
|
+
|
|
190
|
+
for i in range(n_windows):
|
|
191
|
+
start = i * stride_samples
|
|
192
|
+
end = start + win_samples
|
|
193
|
+
X[i] = data[:, start:end]
|
|
194
|
+
# Causal: target at end of window
|
|
195
|
+
y[i] = targets[min(end, targets.shape[0] - 1)]
|
|
196
|
+
|
|
197
|
+
meta = pd.DataFrame(
|
|
198
|
+
{"subject": subject, "session": session_id, "run": run_id},
|
|
199
|
+
index=range(n_windows),
|
|
200
|
+
)
|
|
201
|
+
all_X.append(X)
|
|
202
|
+
all_y.append(y)
|
|
203
|
+
all_meta.append(meta)
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
np.concatenate(all_X),
|
|
207
|
+
np.concatenate(all_y),
|
|
208
|
+
pd.concat(all_meta).reset_index(drop=True),
|
|
209
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pipelines — feature extractors and decoders for ECoG."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Neural network decoders for ECoG signals."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Feature extraction transformers for ECoG signals."""
|
moecog/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utilities — electrode mapping, signal processing helpers."""
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moecog
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Mother of All ECoG Benchmarks — MOABB-style benchmarking for ECoG motor decoding
|
|
5
|
+
Author-email: Yifan Yu <yifanyu97@gmail.com>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Homepage, https://github.com/epyifany/MOECoG
|
|
8
|
+
Project-URL: Repository, https://github.com/epyifany/MOECoG
|
|
9
|
+
Project-URL: Issues, https://github.com/epyifany/MOECoG/issues
|
|
10
|
+
Keywords: ECoG,electrocorticography,BCI,brain-computer interface,motor decoding,benchmark,neuroscience,neural decoding
|
|
11
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: mne>=1.5
|
|
25
|
+
Requires-Dist: numpy>=1.24
|
|
26
|
+
Requires-Dist: scipy>=1.10
|
|
27
|
+
Requires-Dist: scikit-learn>=1.3
|
|
28
|
+
Requires-Dist: pandas>=2.0
|
|
29
|
+
Requires-Dist: h5py>=3.8
|
|
30
|
+
Requires-Dist: pooch>=1.7
|
|
31
|
+
Requires-Dist: tqdm>=4.65
|
|
32
|
+
Provides-Extra: deep
|
|
33
|
+
Requires-Dist: torch>=2.0; extra == "deep"
|
|
34
|
+
Requires-Dist: braindecode>=0.8; extra == "deep"
|
|
35
|
+
Provides-Extra: nwb
|
|
36
|
+
Requires-Dist: pynwb>=2.5; extra == "nwb"
|
|
37
|
+
Requires-Dist: dandi>=0.55; extra == "nwb"
|
|
38
|
+
Provides-Extra: viz
|
|
39
|
+
Requires-Dist: matplotlib>=3.7; extra == "viz"
|
|
40
|
+
Requires-Dist: seaborn>=0.12; extra == "viz"
|
|
41
|
+
Provides-Extra: anatomy
|
|
42
|
+
Requires-Dist: nilearn>=0.10; extra == "anatomy"
|
|
43
|
+
Requires-Dist: nibabel>=5.0; extra == "anatomy"
|
|
44
|
+
Provides-Extra: dev
|
|
45
|
+
Requires-Dist: moecog[anatomy,deep,nwb,viz]; extra == "dev"
|
|
46
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
47
|
+
Requires-Dist: pytest-cov>=4.1; extra == "dev"
|
|
48
|
+
Requires-Dist: ruff>=0.1; extra == "dev"
|
|
49
|
+
Provides-Extra: all
|
|
50
|
+
Requires-Dist: moecog[anatomy,deep,nwb,viz]; extra == "all"
|
|
51
|
+
Dynamic: license-file
|
|
52
|
+
|
|
53
|
+
# MOECoG — Mother of All ECoG Benchmarks
|
|
54
|
+
|
|
55
|
+
A MOABB-style benchmarking framework for electrocorticographic (ECoG) motor decoding.
|
|
56
|
+
|
|
57
|
+
> *"ECoG isn't just the future — it's the testable present."*
|
|
58
|
+
|
|
59
|
+
## Vision
|
|
60
|
+
|
|
61
|
+
[MOABB](https://github.com/NeuroTechX/moabb) transformed EEG-BCI research by making algorithm comparison reproducible and fair. **MOECoG does the same for ECoG motor decoding** — the signal modality at the critical intersection of clinical viability (long-term stability, lower surgical risk) and high-performance neural control (high-gamma access, mm-scale spatial resolution).
|
|
62
|
+
|
|
63
|
+
This project will be successful when we read in an abstract:
|
|
64
|
+
|
|
65
|
+
> *"...the proposed method obtained a correlation of 0.82 on MOECoG, outperforming the state of the art by 12%..."*
|
|
66
|
+
|
|
67
|
+
## Why ECoG Needs Its Own Benchmark
|
|
68
|
+
|
|
69
|
+
| Property | EEG (MOABB) | ECoG (MOECoG) |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| Signal type | Scalp potentials | Cortical surface potentials |
|
|
72
|
+
| Key features | mu/beta ERD/ERS | High-gamma broadband (>70 Hz) + beta suppression |
|
|
73
|
+
| Spatial resolution | ~cm | ~mm |
|
|
74
|
+
| Electrode geometry | Standard montages (10-20) | Patient-specific grids/strips |
|
|
75
|
+
| Primary tasks | Classification (L/R imagery) | Both classification AND continuous regression |
|
|
76
|
+
| Cross-subject | Standard channel alignment | Requires anatomical registration |
|
|
77
|
+
| Noise profile | EMG, EOG artifacts | Epileptiform activity, referencing |
|
|
78
|
+
|
|
79
|
+
MOABB's paradigm/dataset/evaluation/pipeline abstraction is brilliant — but its assumptions (fixed channel montages, epoched classification, standard frequency bands) break down for ECoG.
|
|
80
|
+
|
|
81
|
+
## Architecture
|
|
82
|
+
|
|
83
|
+
MOECoG follows MOABB's 4-concept design, adapted for ECoG:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
+--------------+ +---------------+ +---------------+ +---------------+
|
|
87
|
+
| Dataset |--->| Paradigm |--->| Evaluation |--->| Pipeline |
|
|
88
|
+
| | | | | | | |
|
|
89
|
+
| Raw ECoG + | | Motor Imagery | | WithinSubject | | Feature ext. |
|
|
90
|
+
| electrode | | Finger Flex | | CrossSession | | + Classifier |
|
|
91
|
+
| positions + | | Arm Reach | | Transfer | | or Regressor |
|
|
92
|
+
| anatomy | | Grasp Type | | | | |
|
|
93
|
+
+--------------+ +---------------+ +---------------+ +---------------+
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Key Differences from MOABB
|
|
97
|
+
|
|
98
|
+
- **Dual-task paradigms:** Classification (which finger?) AND regression (finger trajectory)
|
|
99
|
+
- **Anatomical electrode registration:** Patient-specific grids mapped to MNI/FreeSurfer atlas
|
|
100
|
+
- **Broadband feature extraction:** High-gamma (70-150 Hz), beta (13-30 Hz), phase-amplitude coupling
|
|
101
|
+
- **Continuous decoding metrics:** Correlation coefficient (r), R-squared, normalized MSE — not just accuracy
|
|
102
|
+
- **Naturalistic movement support:** Not just cued trials but free/spontaneous movements (AJILE12)
|
|
103
|
+
|
|
104
|
+
## Included Datasets
|
|
105
|
+
|
|
106
|
+
### Tier 1: Core Benchmark
|
|
107
|
+
|
|
108
|
+
Motor-specific, public, well-documented.
|
|
109
|
+
|
|
110
|
+
| ID | Dataset | Source | Subjects | Task | Channels | Modality |
|
|
111
|
+
|---|---|---|---|---|---|---|
|
|
112
|
+
| MillerFingerFlex | BCI Competition IV Dataset 4 | Miller & Schalk | 3 | Individual finger flexion (5-class regression) | 48-64 | ECoG grid |
|
|
113
|
+
| MillerLibrary | Stanford/Mayo ECoG Library | Miller 2019, *Nature Human Behaviour* | 34 | 16 experiments (motor, sensory, language, visual) | Varies | ECoG grid |
|
|
114
|
+
| AJILE12 | Annotated Joints in Long-term ECoG | Peterson et al. 2022, *Scientific Data* | 12 | Naturalistic wrist movements | >=64 | ECoG grid |
|
|
115
|
+
|
|
116
|
+
### Tier 2: Extended Benchmark
|
|
117
|
+
|
|
118
|
+
| ID | Dataset | Source | Subjects | Task |
|
|
119
|
+
|---|---|---|---|---|
|
|
120
|
+
| BCITetraplegia | BCI and Tetraplegia (WIMAGINE) | Benabid/Costecalde et al. | 1 (chronic) | 4-class motor imagery, 2D cursor |
|
|
121
|
+
| GraspECoG | Natural grasp types | Various (Pistohl, Bleichner) | Varies | Grasp classification |
|
|
122
|
+
| MoveAgain | Blackrock/BrainGate ECoG subsets | If released publicly | Varies | Arm/hand movement |
|
|
123
|
+
|
|
124
|
+
### Tier 3: Cross-Modality Comparison
|
|
125
|
+
|
|
126
|
+
| ID | Dataset | Why included |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| MOABB_MI | MOABB motor imagery EEG datasets | Direct EEG vs ECoG comparison on matched paradigms |
|
|
129
|
+
|
|
130
|
+
## Paradigms
|
|
131
|
+
|
|
132
|
+
### FingerFlexionRegression
|
|
133
|
+
|
|
134
|
+
- **Task:** Predict continuous finger flexion trajectories from ECoG
|
|
135
|
+
- **Metrics:** Pearson r, R-squared, NRMSE per finger
|
|
136
|
+
- **Datasets:** MillerFingerFlex, MillerLibrary (motor subset)
|
|
137
|
+
- **Baseline:** BCI Competition IV Dataset 4 leaderboard
|
|
138
|
+
|
|
139
|
+
### MotorImageryClassification
|
|
140
|
+
|
|
141
|
+
- **Task:** Classify imagined/attempted movements (L/R hand, feet, tongue, etc.)
|
|
142
|
+
- **Metrics:** Accuracy, ROC-AUC, Cohen's kappa
|
|
143
|
+
- **Datasets:** MillerLibrary (motor imagery subset), BCITetraplegia
|
|
144
|
+
|
|
145
|
+
### NaturalisticReachDecoding
|
|
146
|
+
|
|
147
|
+
- **Task:** Decode wrist movement onset and trajectory from unconstrained behavior
|
|
148
|
+
- **Metrics:** Event detection F1, trajectory r, latency
|
|
149
|
+
- **Datasets:** AJILE12
|
|
150
|
+
|
|
151
|
+
### GraspClassification
|
|
152
|
+
|
|
153
|
+
- **Task:** Classify grasp types or hand gestures from sensorimotor ECoG
|
|
154
|
+
- **Metrics:** Accuracy, confusion matrix analysis
|
|
155
|
+
- **Datasets:** GraspECoG, MillerLibrary (gesture subset)
|
|
156
|
+
|
|
157
|
+
## Evaluation Strategies
|
|
158
|
+
|
|
159
|
+
| Strategy | Description | Use Case |
|
|
160
|
+
|---|---|---|
|
|
161
|
+
| WithinSubjectCV | K-fold within single subject | Standard single-patient decoding |
|
|
162
|
+
| CrossSessionEval | Train on session A, test on session B | Stability / recalibration assessment |
|
|
163
|
+
| CrossSubjectTransfer | Leave-one-subject-out (with atlas projection) | Generalization / zero-shot transfer |
|
|
164
|
+
| TemporalStabilityEval | Chronological split (early to late) | Long-term signal stability |
|
|
165
|
+
|
|
166
|
+
## Baseline Pipelines
|
|
167
|
+
|
|
168
|
+
### Feature Extraction
|
|
169
|
+
|
|
170
|
+
- **LogBandPower** — Log power in canonical bands (mu, beta, low-gamma, high-gamma)
|
|
171
|
+
- **BroadbandChange** — Miller's broadband spectral change method
|
|
172
|
+
- **PAC** — Phase-amplitude coupling (theta/gamma, beta/high-gamma)
|
|
173
|
+
- **CSP_ECoG** — Common Spatial Patterns adapted for patient-specific grids
|
|
174
|
+
- **TimeFrequency** — Continuous wavelet / multitaper spectrograms
|
|
175
|
+
|
|
176
|
+
### Decoders
|
|
177
|
+
|
|
178
|
+
- **LDA / SVM / LogisticRegression** — Classical classifiers
|
|
179
|
+
- **Ridge / Kalman** — Linear regressors for trajectory decoding
|
|
180
|
+
- **ECoGNet** — Lightweight CNN for ECoG (braindecode-compatible)
|
|
181
|
+
- **FingerFlex** — Convolutional encoder-decoder (Lomtev et al.)
|
|
182
|
+
- **HTNet** — Transfer learning across subjects via Hilbert transform
|
|
183
|
+
|
|
184
|
+
## Quick Start
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
import moecog
|
|
188
|
+
from moecog.datasets import MillerFingerFlex
|
|
189
|
+
from moecog.paradigms import FingerFlexionRegression
|
|
190
|
+
from moecog.evaluations import WithinSubjectCV
|
|
191
|
+
from moecog.pipelines.features import LogBandPower
|
|
192
|
+
from sklearn.linear_model import Ridge
|
|
193
|
+
from sklearn.pipeline import make_pipeline
|
|
194
|
+
|
|
195
|
+
# Define pipeline
|
|
196
|
+
pipelines = {
|
|
197
|
+
"LogBandPower+Ridge": make_pipeline(LogBandPower(), Ridge(alpha=1.0))
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
# Load data
|
|
201
|
+
dataset = MillerFingerFlex()
|
|
202
|
+
paradigm = FingerFlexionRegression(fmin=1, fmax=150)
|
|
203
|
+
evaluation = WithinSubjectCV(paradigm=paradigm, datasets=[dataset], n_splits=5)
|
|
204
|
+
|
|
205
|
+
# Run benchmark
|
|
206
|
+
results = evaluation.process(pipelines)
|
|
207
|
+
print(results.groupby("pipeline")["score"].mean())
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Installation
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
pip install moecog
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Or for development:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
git clone https://github.com/epyifany/MOECoG.git
|
|
220
|
+
cd MOECoG
|
|
221
|
+
pip install -e ".[dev]"
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Dependencies
|
|
225
|
+
|
|
226
|
+
**Core:**
|
|
227
|
+
- `mne >= 1.5` — ECoG signal handling, coordinate transforms
|
|
228
|
+
- `numpy`, `scipy`, `scikit-learn` — Core ML
|
|
229
|
+
- `pandas` — Results management
|
|
230
|
+
- `h5py` — Persistent results storage
|
|
231
|
+
- `pooch` — Robust data downloading
|
|
232
|
+
|
|
233
|
+
**Optional:**
|
|
234
|
+
- `torch`, `braindecode` — Deep learning baselines (`pip install moecog[deep]`)
|
|
235
|
+
- `pynwb`, `dandi` — NWB data access for AJILE12 (`pip install moecog[nwb]`)
|
|
236
|
+
- `nilearn`, `nibabel` — Anatomical registration (`pip install moecog[anatomy]`)
|
|
237
|
+
- `matplotlib`, `seaborn` — Visualization (`pip install moecog[viz]`)
|
|
238
|
+
|
|
239
|
+
## Citation
|
|
240
|
+
|
|
241
|
+
If you use MOECoG in your research, please cite:
|
|
242
|
+
|
|
243
|
+
```bibtex
|
|
244
|
+
@software{moecog2025,
|
|
245
|
+
title = {MOECoG: Mother of All ECoG Benchmarks},
|
|
246
|
+
author = {Yu, Yifan},
|
|
247
|
+
year = {2025},
|
|
248
|
+
url = {https://github.com/epyifany/MOECoG}
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
And the foundational datasets:
|
|
253
|
+
|
|
254
|
+
- Miller, K.J. "A library of human electrocorticographic data and analyses." *Nature Human Behaviour* 3(11), 1225-1235 (2019).
|
|
255
|
+
- Peterson, S.M. et al. "AJILE12: Long-term naturalistic human intracranial neural recordings and pose." *Scientific Data* 9, 184 (2022).
|
|
256
|
+
- Schalk, G. et al. BCI Competition IV Dataset 4.
|
|
257
|
+
|
|
258
|
+
## License
|
|
259
|
+
|
|
260
|
+
BSD-3-Clause (matching MOABB)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
moecog/__init__.py,sha256=e5F7QRudYbcoibqVlP46le-BOnHcqjH8inBYduFXtIs,159
|
|
2
|
+
moecog/analysis/__init__.py,sha256=ExR9A1yjIdZMZkpF7R6nvDNJEIBy7l1oLy9M_rqZc7w,67
|
|
3
|
+
moecog/datasets/__init__.py,sha256=66_Y1EAyZq95-PqzxgnxQ8xhXVgg1v3ClMp5aRUuBZo,137
|
|
4
|
+
moecog/datasets/base.py,sha256=krZ64AwYqH8hSObZJXuyKu9WK8NO146r5RojyhCjT3I,4328
|
|
5
|
+
moecog/evaluations/__init__.py,sha256=iZOCMX5TwwM1Ufub-VXTa7pOK42fZiRJ49_NXdoF9bc,122
|
|
6
|
+
moecog/evaluations/base.py,sha256=t2pjnQC6tGwv3180WCW6CEl2oD4QN6Q9xl2QVgteyf4,3584
|
|
7
|
+
moecog/paradigms/__init__.py,sha256=prOaV6pBOm-TxmCgZHi95vBTGRWRi-xEb03ywQk65XI,250
|
|
8
|
+
moecog/paradigms/base.py,sha256=9POMm55YuVBOdAFc5am23Bt_3OEw_hsnzHX4SUizEiI,6907
|
|
9
|
+
moecog/pipelines/__init__.py,sha256=pD1TEeHjiqpM7W54OTHwfhnH1prFokliRpeGjg8mCs8,62
|
|
10
|
+
moecog/pipelines/decoders/__init__.py,sha256=7x__BdnPIPSCUVmAmsT39pzQr139zqu2pAJS_8sngHo,48
|
|
11
|
+
moecog/pipelines/features/__init__.py,sha256=oskX5GdeEWLQX5f9nOq_tdK98QbUNMBDNSUeqeuXTkY,56
|
|
12
|
+
moecog/utils/__init__.py,sha256=6qo244StRvNH_Rc8rUqLPGzU7FZc5hkITntUkC27xNc,66
|
|
13
|
+
moecog-0.1.0.dist-info/licenses/LICENSE,sha256=tjJri2nMHvBODozIWCOPxUaIkCMC-nEAAn-FKy1YQpQ,1495
|
|
14
|
+
moecog-0.1.0.dist-info/METADATA,sha256=QC6KtNGzBTJ_-to2a5sRGcyKQHDIL7Sqnk639wkVvYg,10446
|
|
15
|
+
moecog-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
16
|
+
moecog-0.1.0.dist-info/top_level.txt,sha256=vh-OEq_cEuJ4LlBwz8NC_PNJprGmFqFi5c3VVtdqtaM,7
|
|
17
|
+
moecog-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Yifan Yu
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
moecog
|