misleep 0.3.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.
Potentially problematic release.
This version of misleep might be problematic. Click here for more details.
- misleep/__init__.py +111 -0
- misleep/__main__.py +16 -0
- misleep/analysis/__init__.py +23 -0
- misleep/analysis/auto_stage.py +158 -0
- misleep/analysis/detection.py +198 -0
- misleep/analysis/features.py +131 -0
- misleep/analysis/models/P30_EEG_F_lightgbm.pkl +0 -0
- misleep/analysis/models/P30_EEG_P_lightgbm.pkl +0 -0
- misleep/analysis/models/__init__.py +0 -0
- misleep/analysis/models/ado_EEG_F_lightgbm.pkl +0 -0
- misleep/analysis/models/ado_EEG_P_lightgbm.pkl +0 -0
- misleep/analysis/models/adult_EEG_F_lightgbm.pkl +0 -0
- misleep/analysis/models/adult_EEG_P_lightgbm.pkl +0 -0
- misleep/analysis/transformer/__init__.py +46 -0
- misleep/analysis/transformer/checkpoints/CausalTransformer_best.pt +0 -0
- misleep/analysis/transformer/checkpoints/__init__.py +0 -0
- misleep/analysis/transformer/configs.py +52 -0
- misleep/analysis/transformer/data.py +362 -0
- misleep/analysis/transformer/inference.py +328 -0
- misleep/analysis/transformer/models.py +220 -0
- misleep/analysis/transformer/preprocessing.py +105 -0
- misleep/config/__init__.py +105 -0
- misleep/config/default_config.ini +38 -0
- misleep/data/__init__.py +13 -0
- misleep/data/annotation.py +114 -0
- misleep/data/midata.py +387 -0
- misleep/gui/__init__.py +23 -0
- misleep/gui/app.py +183 -0
- misleep/gui/config_dialog.py +429 -0
- misleep/gui/dialogs.py +1107 -0
- misleep/gui/event_filters.py +26 -0
- misleep/gui/main_window.py +2699 -0
- misleep/gui/qt_utils.py +277 -0
- misleep/gui/resources/__init__.py +9 -0
- misleep/gui/resources/entire_logo.png +0 -0
- misleep/gui/resources/logo.png +0 -0
- misleep/gui/resources/misleep.ico +0 -0
- misleep/gui/resources/misleep.qrc +7 -0
- misleep/gui/resources/misleep_rc.py +1324 -0
- misleep/gui/spec_window.py +187 -0
- misleep/gui/style.py +994 -0
- misleep/gui/uis/SWA_detect_dialog.ui +181 -0
- misleep/gui/uis/SWA_detect_dialog_ui.py +176 -0
- misleep/gui/uis/__init__.py +6 -0
- misleep/gui/uis/about.ui +88 -0
- misleep/gui/uis/about_ui.py +80 -0
- misleep/gui/uis/auto_stage_causalTransformer_dialog.ui +77 -0
- misleep/gui/uis/auto_stage_causalTransformer_dialog_ui.py +90 -0
- misleep/gui/uis/auto_stage_lightGBM_dialog.ui +124 -0
- misleep/gui/uis/auto_stage_lightGBM_dialog_ui.py +124 -0
- misleep/gui/uis/horizontal_line_dialog.ui +166 -0
- misleep/gui/uis/horizontal_line_dialog_ui.py +157 -0
- misleep/gui/uis/label_dialog.ui +65 -0
- misleep/gui/uis/label_dialog_ui.py +72 -0
- misleep/gui/uis/main_window.ui +871 -0
- misleep/gui/uis/main_window_ui.py +616 -0
- misleep/gui/uis/save_data_dialog.ui +95 -0
- misleep/gui/uis/save_data_dialog_ui.py +98 -0
- misleep/gui/uis/spec_window.ui +109 -0
- misleep/gui/uis/spec_window_ui.py +103 -0
- misleep/gui/uis/spindle_detect_dialog.ui +202 -0
- misleep/gui/uis/spindle_detect_dialog_ui.py +194 -0
- misleep/gui/uis/state_spectral_dialog.ui +240 -0
- misleep/gui/uis/state_spectral_dialog_ui.py +204 -0
- misleep/gui/uis/transfer_result_dialog.ui +104 -0
- misleep/gui/uis/transfer_result_dialog_ui.py +94 -0
- misleep/gui/workers.py +69 -0
- misleep/io/__init__.py +68 -0
- misleep/io/annotation.py +367 -0
- misleep/io/array.py +209 -0
- misleep/io/base.py +174 -0
- misleep/io/edf.py +109 -0
- misleep/io/mat.py +189 -0
- misleep/logger.py +79 -0
- misleep/preprocessing/__init__.py +23 -0
- misleep/preprocessing/artifacts.py +76 -0
- misleep/preprocessing/filtering.py +94 -0
- misleep/preprocessing/segment.py +59 -0
- misleep/preprocessing/spectral.py +169 -0
- misleep/utils/__init__.py +45 -0
- misleep/utils/annotation.py +130 -0
- misleep/utils/entropy.py +135 -0
- misleep/utils/misc.py +69 -0
- misleep/utils/time_utils.py +61 -0
- misleep/viz/__init__.py +12 -0
- misleep/viz/hypnogram.py +48 -0
- misleep/viz/signals.py +51 -0
- misleep/viz/spectral.py +98 -0
- misleep-0.3.0.dist-info/METADATA +249 -0
- misleep-0.3.0.dist-info/RECORD +94 -0
- misleep-0.3.0.dist-info/WHEEL +5 -0
- misleep-0.3.0.dist-info/entry_points.txt +15 -0
- misleep-0.3.0.dist-info/licenses/LICENSE +29 -0
- misleep-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Iterable, List, Optional, Sequence, Tuple
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .configs import EEGTransformerConfig, PreprocessConfig
|
|
11
|
+
from .models import build_model
|
|
12
|
+
from .preprocessing import (
|
|
13
|
+
apply_bandpass,
|
|
14
|
+
build_channel_filters,
|
|
15
|
+
epoch_signal,
|
|
16
|
+
exponential_moving_standardize,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def default_checkpoint_path() -> Path:
|
|
21
|
+
"""Return the path of the packaged CausalTransformer checkpoint."""
|
|
22
|
+
ref = files("misleep.analysis.transformer.checkpoints")
|
|
23
|
+
return Path(str(ref)) / "CausalTransformer_best.pt"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass()
|
|
27
|
+
class AutoStageConfig:
|
|
28
|
+
"""
|
|
29
|
+
Configuration for offline auto staging with the transformer models.
|
|
30
|
+
|
|
31
|
+
Most users can leave this at the defaults and only set ``model_path``
|
|
32
|
+
and ``sf``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
model_name: str = "CausalTransformer"
|
|
36
|
+
model_path: Optional[Path] = None
|
|
37
|
+
sf: float = 305.1758
|
|
38
|
+
epoch_seconds: float = 5.0
|
|
39
|
+
filter_order: int = 4
|
|
40
|
+
eeg_band: Tuple[float, float] = (0.5, 30.0)
|
|
41
|
+
emg_band: Tuple[float, float] = (15.0, 100.0)
|
|
42
|
+
context_window: Optional[int] = None
|
|
43
|
+
finetune_frac: float = 1.0
|
|
44
|
+
finetune_epochs: int = 5
|
|
45
|
+
finetune_lr: float = 5e-5
|
|
46
|
+
finetune_batch: int = 16
|
|
47
|
+
label_stride_seconds: float = 5.0
|
|
48
|
+
label_mode: str = "stage_id"
|
|
49
|
+
output_stride_seconds: float = 5.0
|
|
50
|
+
output_label_mode: str = "stage_id"
|
|
51
|
+
device: Optional[str] = None
|
|
52
|
+
override_model_sample_rate: Optional[float] = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _validate_1d(arr: np.ndarray, name: str) -> np.ndarray:
|
|
56
|
+
arr = np.asarray(arr).squeeze()
|
|
57
|
+
if arr.ndim != 1:
|
|
58
|
+
raise ValueError(f"{name} must be 1D after squeeze; got shape {arr.shape}")
|
|
59
|
+
return arr.astype(np.float32)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _map_label_value(value: int, mode: str) -> int:
|
|
63
|
+
if mode == "stage_id":
|
|
64
|
+
if value in (1, 2, 3):
|
|
65
|
+
return value - 1
|
|
66
|
+
return -1
|
|
67
|
+
if mode == "class":
|
|
68
|
+
if value in (0, 1, 2):
|
|
69
|
+
return value
|
|
70
|
+
return -1
|
|
71
|
+
raise ValueError("label_mode must be 'stage_id' or 'class'")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _labels_to_sample_labels(
|
|
75
|
+
labels: Sequence[int],
|
|
76
|
+
total_samples: int,
|
|
77
|
+
sf: float,
|
|
78
|
+
label_stride_seconds: float,
|
|
79
|
+
label_mode: str,
|
|
80
|
+
) -> np.ndarray:
|
|
81
|
+
sample_labels = np.full(total_samples, -1, dtype=np.int16)
|
|
82
|
+
if not labels:
|
|
83
|
+
return sample_labels
|
|
84
|
+
samples_per_unit = int(round(sf * label_stride_seconds))
|
|
85
|
+
if samples_per_unit <= 0:
|
|
86
|
+
return sample_labels
|
|
87
|
+
for idx, raw_label in enumerate(labels):
|
|
88
|
+
mapped = _map_label_value(int(raw_label), label_mode)
|
|
89
|
+
if mapped < 0:
|
|
90
|
+
continue
|
|
91
|
+
start = idx * samples_per_unit
|
|
92
|
+
end = min(start + samples_per_unit, total_samples)
|
|
93
|
+
if start >= total_samples:
|
|
94
|
+
break
|
|
95
|
+
sample_labels[start:end] = mapped
|
|
96
|
+
return sample_labels
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _compute_epoch_labels(
|
|
100
|
+
sample_labels: np.ndarray,
|
|
101
|
+
cfg: PreprocessConfig,
|
|
102
|
+
min_valid_ratio: float = 0.8,
|
|
103
|
+
) -> np.ndarray:
|
|
104
|
+
samples_per_epoch = cfg.samples_per_epoch()
|
|
105
|
+
hop = cfg.hop_length_samples()
|
|
106
|
+
total = sample_labels.shape[0]
|
|
107
|
+
if total < samples_per_epoch:
|
|
108
|
+
return np.zeros((0,), dtype=np.int64)
|
|
109
|
+
epoch_labels: List[int] = []
|
|
110
|
+
start = 0
|
|
111
|
+
while start + samples_per_epoch <= total:
|
|
112
|
+
end = start + samples_per_epoch
|
|
113
|
+
label_slice = sample_labels[start:end]
|
|
114
|
+
valid = label_slice[label_slice >= 0]
|
|
115
|
+
if valid.size / samples_per_epoch < min_valid_ratio:
|
|
116
|
+
epoch_labels.append(-1)
|
|
117
|
+
else:
|
|
118
|
+
epoch_labels.append(int(np.bincount(valid, minlength=3).argmax()))
|
|
119
|
+
start += hop
|
|
120
|
+
return np.asarray(epoch_labels, dtype=np.int64)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _prepare_sequences(
|
|
124
|
+
epochs: np.ndarray,
|
|
125
|
+
labels: np.ndarray,
|
|
126
|
+
context_window: int,
|
|
127
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
128
|
+
sequences: list[np.ndarray] = []
|
|
129
|
+
seq_labels: list[int] = []
|
|
130
|
+
buffer: list[np.ndarray] = []
|
|
131
|
+
epoch_shape = epochs.shape[1:] if epochs.ndim >= 3 else ()
|
|
132
|
+
for idx, epoch in enumerate(epochs):
|
|
133
|
+
buffer.append(epoch)
|
|
134
|
+
if len(buffer) > context_window:
|
|
135
|
+
buffer.pop(0)
|
|
136
|
+
seq = list(buffer)
|
|
137
|
+
if len(seq) < context_window:
|
|
138
|
+
seq = [seq[0]] * (context_window - len(seq)) + seq
|
|
139
|
+
arr = np.stack(seq, axis=0)
|
|
140
|
+
sequences.append(arr.astype(np.float32))
|
|
141
|
+
seq_labels.append(int(labels[idx]))
|
|
142
|
+
if not sequences:
|
|
143
|
+
return np.empty((0, context_window, *epoch_shape), dtype=np.float32), \
|
|
144
|
+
np.empty((0,), dtype=np.int64)
|
|
145
|
+
return np.stack(sequences, axis=0), np.asarray(seq_labels, dtype=np.int64)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _finetune_on_labeled(
|
|
149
|
+
model,
|
|
150
|
+
sequences: np.ndarray,
|
|
151
|
+
labels: np.ndarray,
|
|
152
|
+
device,
|
|
153
|
+
epochs_steps: int,
|
|
154
|
+
batch_size: int,
|
|
155
|
+
lr: float,
|
|
156
|
+
frac: float,
|
|
157
|
+
) -> None:
|
|
158
|
+
import torch
|
|
159
|
+
|
|
160
|
+
if sequences.size == 0 or labels.size == 0 or frac <= 0 or epochs_steps <= 0:
|
|
161
|
+
return
|
|
162
|
+
total = len(labels)
|
|
163
|
+
use_count = max(1, min(int(round(total * frac)), total))
|
|
164
|
+
sequences = sequences[:use_count]
|
|
165
|
+
labels = labels[:use_count]
|
|
166
|
+
dataset = torch.utils.data.TensorDataset(
|
|
167
|
+
torch.from_numpy(sequences),
|
|
168
|
+
torch.from_numpy(labels.astype(np.int64)),
|
|
169
|
+
)
|
|
170
|
+
loader = torch.utils.data.DataLoader(dataset, batch_size=batch_size, shuffle=True, drop_last=False)
|
|
171
|
+
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
|
|
172
|
+
criterion = torch.nn.CrossEntropyLoss()
|
|
173
|
+
model.train()
|
|
174
|
+
for _ in range(epochs_steps):
|
|
175
|
+
for batch_x, batch_y in loader:
|
|
176
|
+
batch_x = batch_x.to(device)
|
|
177
|
+
batch_y = batch_y.to(device)
|
|
178
|
+
optimizer.zero_grad(set_to_none=True)
|
|
179
|
+
logits = model(batch_x)
|
|
180
|
+
loss = criterion(logits, batch_y)
|
|
181
|
+
loss.backward()
|
|
182
|
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
|
183
|
+
optimizer.step()
|
|
184
|
+
model.eval()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _expand_labels(labels: Sequence[int], epoch_seconds: float, output_stride_seconds: float) -> List[int]:
|
|
188
|
+
if output_stride_seconds <= 0:
|
|
189
|
+
raise ValueError("output_stride_seconds must be > 0")
|
|
190
|
+
if abs(epoch_seconds / output_stride_seconds - round(epoch_seconds / output_stride_seconds)) > 1e-6:
|
|
191
|
+
raise ValueError("epoch_seconds must be an integer multiple of output_stride_seconds")
|
|
192
|
+
repeat = int(round(epoch_seconds / output_stride_seconds))
|
|
193
|
+
return [label for label in labels for _ in range(repeat)]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def auto_stage_llm(
|
|
197
|
+
EEG: Sequence[float],
|
|
198
|
+
EMG: Sequence[float],
|
|
199
|
+
label: Optional[Sequence[int]] = None,
|
|
200
|
+
*,
|
|
201
|
+
config: Optional[AutoStageConfig] = None,
|
|
202
|
+
) -> List[int]:
|
|
203
|
+
"""
|
|
204
|
+
Auto stage a recording using the offline transformer models.
|
|
205
|
+
|
|
206
|
+
Parameters
|
|
207
|
+
----------
|
|
208
|
+
EEG : array-like
|
|
209
|
+
EEG 1-D signal.
|
|
210
|
+
EMG : array-like
|
|
211
|
+
EMG 1-D signal.
|
|
212
|
+
label : list[int] | None
|
|
213
|
+
Optional reference labels used for on-the-fly finetuning. Use 1-3
|
|
214
|
+
for ``stage_id`` mode (NREM/REM/Wake) or 0-2 for ``class`` mode.
|
|
215
|
+
Provide per-second or per-epoch labels depending on
|
|
216
|
+
``config.label_stride_seconds``.
|
|
217
|
+
config : AutoStageConfig | None
|
|
218
|
+
Configuration for preprocessing, finetuning, and output formatting.
|
|
219
|
+
|
|
220
|
+
Returns
|
|
221
|
+
-------
|
|
222
|
+
list of int
|
|
223
|
+
Predicted state codes. Resolution is controlled by
|
|
224
|
+
``config.output_stride_seconds``.
|
|
225
|
+
|
|
226
|
+
Raises
|
|
227
|
+
------
|
|
228
|
+
ImportError
|
|
229
|
+
When PyTorch is not installed.
|
|
230
|
+
"""
|
|
231
|
+
try:
|
|
232
|
+
import torch
|
|
233
|
+
except ImportError as e: # pragma: no cover
|
|
234
|
+
raise ImportError(
|
|
235
|
+
"The transformer auto-staging requires PyTorch. "
|
|
236
|
+
"Install it with: pip install 'misleep[transformer]' "
|
|
237
|
+
"or pip install torch") from e
|
|
238
|
+
|
|
239
|
+
cfg = config or AutoStageConfig()
|
|
240
|
+
model_path = cfg.model_path or default_checkpoint_path()
|
|
241
|
+
if not Path(model_path).exists():
|
|
242
|
+
raise FileNotFoundError(f"Checkpoint not found: {model_path}")
|
|
243
|
+
|
|
244
|
+
eeg = _validate_1d(np.asarray(EEG), "EEG")
|
|
245
|
+
emg = _validate_1d(np.asarray(EMG), "EMG")
|
|
246
|
+
if eeg.shape[0] != emg.shape[0]:
|
|
247
|
+
raise ValueError("EEG and EMG must have the same length.")
|
|
248
|
+
|
|
249
|
+
device = torch.device(cfg.device or ("cuda" if torch.cuda.is_available() else "cpu"))
|
|
250
|
+
|
|
251
|
+
payload = torch.load(model_path, map_location=device, weights_only=False)
|
|
252
|
+
model_cfg: EEGTransformerConfig = payload["config"]
|
|
253
|
+
if cfg.override_model_sample_rate is not None:
|
|
254
|
+
model_cfg.stft.sample_rate = float(cfg.override_model_sample_rate)
|
|
255
|
+
|
|
256
|
+
preprocess_cfg = PreprocessConfig(
|
|
257
|
+
sample_rate=cfg.sf,
|
|
258
|
+
epoch_seconds=cfg.epoch_seconds,
|
|
259
|
+
filter_order=cfg.filter_order,
|
|
260
|
+
eeg_band=cfg.eeg_band,
|
|
261
|
+
emg_band=cfg.emg_band,
|
|
262
|
+
)
|
|
263
|
+
context_window = cfg.context_window or model_cfg.context_window
|
|
264
|
+
|
|
265
|
+
signal = np.stack([eeg, emg], axis=0)
|
|
266
|
+
sos = build_channel_filters(preprocess_cfg)
|
|
267
|
+
filtered = apply_bandpass(signal, sos)
|
|
268
|
+
standardized = exponential_moving_standardize(filtered, init_window=preprocess_cfg.samples_per_epoch())
|
|
269
|
+
epochs = epoch_signal(standardized, preprocess_cfg)
|
|
270
|
+
if epochs.size == 0:
|
|
271
|
+
return []
|
|
272
|
+
|
|
273
|
+
labels_in = label or []
|
|
274
|
+
sample_labels = _labels_to_sample_labels(
|
|
275
|
+
labels_in,
|
|
276
|
+
total_samples=signal.shape[1],
|
|
277
|
+
sf=cfg.sf,
|
|
278
|
+
label_stride_seconds=cfg.label_stride_seconds,
|
|
279
|
+
label_mode=cfg.label_mode,
|
|
280
|
+
)
|
|
281
|
+
epoch_labels = _compute_epoch_labels(sample_labels, preprocess_cfg)
|
|
282
|
+
if epochs.shape[0] != epoch_labels.shape[0]:
|
|
283
|
+
limit = min(epochs.shape[0], epoch_labels.shape[0])
|
|
284
|
+
epochs = epochs[:limit]
|
|
285
|
+
epoch_labels = epoch_labels[:limit]
|
|
286
|
+
|
|
287
|
+
sequences, seq_labels = _prepare_sequences(epochs, epoch_labels, context_window)
|
|
288
|
+
model = build_model(cfg.model_name, model_cfg).to(device)
|
|
289
|
+
model.load_state_dict(payload["model_state"])
|
|
290
|
+
model.eval()
|
|
291
|
+
|
|
292
|
+
labeled_mask = seq_labels >= 0
|
|
293
|
+
if labeled_mask.any():
|
|
294
|
+
_finetune_on_labeled(
|
|
295
|
+
model,
|
|
296
|
+
sequences[labeled_mask],
|
|
297
|
+
seq_labels[labeled_mask],
|
|
298
|
+
device,
|
|
299
|
+
epochs_steps=cfg.finetune_epochs,
|
|
300
|
+
batch_size=cfg.finetune_batch,
|
|
301
|
+
lr=cfg.finetune_lr,
|
|
302
|
+
frac=cfg.finetune_frac,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
preds: List[int] = []
|
|
306
|
+
loader = torch.utils.data.DataLoader(
|
|
307
|
+
torch.utils.data.TensorDataset(torch.from_numpy(sequences)),
|
|
308
|
+
batch_size=64,
|
|
309
|
+
shuffle=False,
|
|
310
|
+
num_workers=0,
|
|
311
|
+
drop_last=False,
|
|
312
|
+
)
|
|
313
|
+
with torch.no_grad():
|
|
314
|
+
for (batch_x,) in loader:
|
|
315
|
+
batch_x = batch_x.to(device)
|
|
316
|
+
logits = model(batch_x)
|
|
317
|
+
pred = logits.argmax(dim=-1).cpu().numpy()
|
|
318
|
+
preds.extend(pred.tolist())
|
|
319
|
+
|
|
320
|
+
if cfg.output_label_mode == "stage_id":
|
|
321
|
+
preds = [p + 1 for p in preds]
|
|
322
|
+
elif cfg.output_label_mode != "class":
|
|
323
|
+
raise ValueError("output_label_mode must be 'stage_id' or 'class'")
|
|
324
|
+
|
|
325
|
+
if cfg.output_stride_seconds != cfg.epoch_seconds:
|
|
326
|
+
preds = _expand_labels(preds, cfg.epoch_seconds, cfg.output_stride_seconds)
|
|
327
|
+
|
|
328
|
+
return preds
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Tuple
|
|
5
|
+
|
|
6
|
+
import torch
|
|
7
|
+
import torch.nn as nn
|
|
8
|
+
import torch.nn.functional as F
|
|
9
|
+
|
|
10
|
+
from .configs import EEGTransformerConfig, STFTConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConvFeatureExtractor(nn.Module):
|
|
14
|
+
def __init__(self, in_channels: int, base_filters: int, kernel_size: int,
|
|
15
|
+
conv_blocks: int, embed_dim: int) -> None:
|
|
16
|
+
super().__init__()
|
|
17
|
+
padding = kernel_size // 2
|
|
18
|
+
layers = []
|
|
19
|
+
current_channels = in_channels
|
|
20
|
+
filters = base_filters
|
|
21
|
+
for _ in range(conv_blocks):
|
|
22
|
+
layers.append(nn.Conv1d(current_channels, filters, kernel_size, padding=padding))
|
|
23
|
+
layers.append(nn.BatchNorm1d(filters))
|
|
24
|
+
layers.append(nn.ReLU(inplace=True))
|
|
25
|
+
current_channels = filters
|
|
26
|
+
filters *= 2
|
|
27
|
+
self.conv = nn.Sequential(*layers)
|
|
28
|
+
self.pool = nn.AdaptiveAvgPool1d(1)
|
|
29
|
+
self.proj = nn.Linear(current_channels, embed_dim)
|
|
30
|
+
|
|
31
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
32
|
+
features = self.conv(x)
|
|
33
|
+
pooled = self.pool(features).squeeze(-1)
|
|
34
|
+
return F.relu(self.proj(pooled))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def infer_stft_feature_dim(cfg: STFTConfig) -> int:
|
|
38
|
+
nyquist = cfg.sample_rate / 2.0
|
|
39
|
+
max_freq = min(cfg.f_max, nyquist)
|
|
40
|
+
freq_resolution = cfg.sample_rate / cfg.n_fft
|
|
41
|
+
max_bin = max(int(math.floor(max_freq / max(freq_resolution, 1e-6))), 0)
|
|
42
|
+
return min(cfg.n_fft // 2 + 1, max_bin + 1)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def compute_stft_features(x: torch.Tensor, cfg: STFTConfig) -> torch.Tensor:
|
|
46
|
+
bsz, window, channels, samples = x.shape
|
|
47
|
+
if channels == 0:
|
|
48
|
+
raise ValueError("Input tensor must contain at least one channel for STFT features.")
|
|
49
|
+
device = x.device
|
|
50
|
+
window_fn = torch.hann_window(cfg.win_length, device=device)
|
|
51
|
+
eeg = x[:, :, 0, :] # use the first channel for spectral features
|
|
52
|
+
flat = eeg.reshape(bsz * window, samples)
|
|
53
|
+
spec = torch.stft(
|
|
54
|
+
flat,
|
|
55
|
+
n_fft=cfg.n_fft,
|
|
56
|
+
hop_length=cfg.hop_length,
|
|
57
|
+
win_length=cfg.win_length,
|
|
58
|
+
window=window_fn,
|
|
59
|
+
return_complex=True,
|
|
60
|
+
onesided=True,
|
|
61
|
+
normalized=False,
|
|
62
|
+
)
|
|
63
|
+
freqs = torch.fft.rfftfreq(cfg.n_fft, d=1.0 / cfg.sample_rate).to(device)
|
|
64
|
+
mask = freqs <= cfg.f_max
|
|
65
|
+
spec = spec[:, mask, :]
|
|
66
|
+
mag = torch.log1p(spec.abs())
|
|
67
|
+
mag = mag.mean(dim=-1) # (B*W, F_keep)
|
|
68
|
+
return mag.view(bsz, window, -1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ConvEpochEncoder(nn.Module):
|
|
72
|
+
def __init__(self, in_channels: int, hidden_dim: int) -> None:
|
|
73
|
+
super().__init__()
|
|
74
|
+
layers = []
|
|
75
|
+
current = in_channels
|
|
76
|
+
for out in (hidden_dim // 2, hidden_dim):
|
|
77
|
+
layers.append(nn.Conv1d(current, out, kernel_size=7, padding=3, stride=2))
|
|
78
|
+
layers.append(nn.BatchNorm1d(out))
|
|
79
|
+
layers.append(nn.GELU())
|
|
80
|
+
current = out
|
|
81
|
+
layers.append(nn.AdaptiveAvgPool1d(1))
|
|
82
|
+
self.net = nn.Sequential(*layers)
|
|
83
|
+
|
|
84
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
85
|
+
bsz, window, channels, samples = x.shape
|
|
86
|
+
out = self.net(x.view(bsz * window, channels, samples)).squeeze(-1)
|
|
87
|
+
return out.view(bsz, window, -1)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class FeedForward(nn.Module):
|
|
91
|
+
def __init__(self, dim: int, hidden: int, dropout: float) -> None:
|
|
92
|
+
super().__init__()
|
|
93
|
+
self.net = nn.Sequential(
|
|
94
|
+
nn.Linear(dim, hidden),
|
|
95
|
+
nn.GELU(),
|
|
96
|
+
nn.Dropout(dropout),
|
|
97
|
+
nn.Linear(hidden, dim),
|
|
98
|
+
nn.Dropout(dropout),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
102
|
+
return self.net(x)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class CausalBlock(nn.Module):
|
|
106
|
+
def __init__(self, dim: int, heads: int, dropout: float) -> None:
|
|
107
|
+
super().__init__()
|
|
108
|
+
self.attn = nn.MultiheadAttention(dim, heads, dropout=dropout, batch_first=True)
|
|
109
|
+
self.ln1 = nn.LayerNorm(dim)
|
|
110
|
+
self.ln2 = nn.LayerNorm(dim)
|
|
111
|
+
self.ff = FeedForward(dim, dim * 4, dropout)
|
|
112
|
+
|
|
113
|
+
def forward(self, x: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
|
|
114
|
+
attn_out, _ = self.attn(self.ln1(x), self.ln1(x), self.ln1(x), attn_mask=mask)
|
|
115
|
+
x = x + attn_out
|
|
116
|
+
x = x + self.ff(self.ln2(x))
|
|
117
|
+
return x
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class CausalTransformerClassifier(nn.Module):
|
|
121
|
+
def __init__(self, cfg: EEGTransformerConfig) -> None:
|
|
122
|
+
super().__init__()
|
|
123
|
+
self.cfg = cfg
|
|
124
|
+
self.context_window = max(1, cfg.context_window)
|
|
125
|
+
self.local_encoder = ConvEpochEncoder(2, cfg.d_model)
|
|
126
|
+
spec_dim = infer_stft_feature_dim(cfg.stft)
|
|
127
|
+
self.spec_linear = nn.Sequential(
|
|
128
|
+
nn.Linear(spec_dim, cfg.d_model),
|
|
129
|
+
nn.GELU(),
|
|
130
|
+
nn.LayerNorm(cfg.d_model),
|
|
131
|
+
)
|
|
132
|
+
self.fuse = nn.Sequential(
|
|
133
|
+
nn.Linear(cfg.d_model * 2, cfg.d_model),
|
|
134
|
+
nn.GELU(),
|
|
135
|
+
)
|
|
136
|
+
self.pos_embed = nn.Parameter(
|
|
137
|
+
torch.randn(1, self.context_window, cfg.d_model) / math.sqrt(cfg.d_model))
|
|
138
|
+
self.blocks = nn.ModuleList(
|
|
139
|
+
[CausalBlock(cfg.d_model, cfg.num_heads, cfg.dropout) for _ in range(cfg.num_layers)])
|
|
140
|
+
self.norm = nn.LayerNorm(cfg.d_model)
|
|
141
|
+
self.head = nn.Linear(cfg.d_model, cfg.num_classes)
|
|
142
|
+
|
|
143
|
+
def forward(self, x: torch.Tensor, return_features: bool = False,
|
|
144
|
+
ablate_spec: bool = False):
|
|
145
|
+
if x.dim() == 3:
|
|
146
|
+
x = x.unsqueeze(1)
|
|
147
|
+
bsz, window, channels, samples = x.shape
|
|
148
|
+
assert window == self.context_window, f"Expected {self.context_window} epochs, got {window}"
|
|
149
|
+
local = self.local_encoder(x)
|
|
150
|
+
spec = compute_stft_features(x, self.cfg.stft)
|
|
151
|
+
if ablate_spec:
|
|
152
|
+
spec = torch.zeros_like(spec)
|
|
153
|
+
if spec.size(-1) != self.spec_linear[0].in_features:
|
|
154
|
+
target = self.spec_linear[0].in_features
|
|
155
|
+
if spec.size(-1) > target:
|
|
156
|
+
spec = spec[..., :target]
|
|
157
|
+
else:
|
|
158
|
+
spec = F.pad(spec, (0, target - spec.size(-1)))
|
|
159
|
+
spec = self.spec_linear(spec)
|
|
160
|
+
tokens = self.fuse(torch.cat([local, spec], dim=-1)) + self.pos_embed
|
|
161
|
+
mask = torch.triu(torch.ones(window, window, device=x.device), diagonal=1).bool()
|
|
162
|
+
for block in self.blocks:
|
|
163
|
+
tokens = block(tokens, mask)
|
|
164
|
+
features = self.norm(tokens[:, -1, :])
|
|
165
|
+
logits = self.head(features)
|
|
166
|
+
if return_features:
|
|
167
|
+
return logits, features
|
|
168
|
+
return logits
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class ConvTransformerClassifier(nn.Module):
|
|
172
|
+
def __init__(self, cfg: EEGTransformerConfig) -> None:
|
|
173
|
+
super().__init__()
|
|
174
|
+
self.cfg = cfg
|
|
175
|
+
self.context_window = max(1, cfg.context_window)
|
|
176
|
+
self.feature_extractor = ConvFeatureExtractor(
|
|
177
|
+
in_channels=2,
|
|
178
|
+
base_filters=cfg.conv_base_filters,
|
|
179
|
+
kernel_size=cfg.conv_kernel_size,
|
|
180
|
+
conv_blocks=cfg.conv_blocks,
|
|
181
|
+
embed_dim=cfg.d_model,
|
|
182
|
+
)
|
|
183
|
+
encoder_layer = nn.TransformerEncoderLayer(
|
|
184
|
+
d_model=cfg.d_model,
|
|
185
|
+
nhead=cfg.num_heads,
|
|
186
|
+
dim_feedforward=cfg.dim_feedforward,
|
|
187
|
+
dropout=cfg.dropout,
|
|
188
|
+
batch_first=True,
|
|
189
|
+
)
|
|
190
|
+
self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=cfg.num_layers)
|
|
191
|
+
self.pos_embed = nn.Parameter(torch.zeros(1, self.context_window, cfg.d_model))
|
|
192
|
+
self.norm = nn.LayerNorm(cfg.d_model)
|
|
193
|
+
self.head = nn.Sequential(
|
|
194
|
+
nn.Linear(cfg.d_model, cfg.dim_feedforward),
|
|
195
|
+
nn.ReLU(inplace=True),
|
|
196
|
+
nn.Dropout(cfg.dropout),
|
|
197
|
+
nn.Linear(cfg.dim_feedforward, cfg.num_classes),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def forward(self, x: torch.Tensor, return_features: bool = False):
|
|
201
|
+
if x.dim() == 3:
|
|
202
|
+
x = x.unsqueeze(1)
|
|
203
|
+
bsz, window, channels, samples = x.shape
|
|
204
|
+
assert window == self.context_window, f"Expected {self.context_window} epochs, got {window}"
|
|
205
|
+
feats = self.feature_extractor(x.view(bsz * window, channels, samples)).view(bsz, window, -1)
|
|
206
|
+
tokens = feats + self.pos_embed[:, :window, :]
|
|
207
|
+
encoded = self.transformer(tokens)
|
|
208
|
+
pooled = self.norm(encoded.mean(dim=1))
|
|
209
|
+
logits = self.head(pooled)
|
|
210
|
+
if return_features:
|
|
211
|
+
return logits, pooled
|
|
212
|
+
return logits
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def build_model(name: str, config: EEGTransformerConfig) -> nn.Module:
|
|
216
|
+
if name == "CausalTransformer":
|
|
217
|
+
return CausalTransformerClassifier(config)
|
|
218
|
+
if name == "ConvTransformer":
|
|
219
|
+
return ConvTransformerClassifier(config)
|
|
220
|
+
raise ValueError("Unknown model. Choices: CausalTransformer, ConvTransformer.")
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Sequence, Tuple
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy import signal
|
|
7
|
+
|
|
8
|
+
from .configs import PreprocessConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def design_bandpass_sos(low_cut: float, high_cut: float, sample_rate: float, order: int) -> np.ndarray:
|
|
12
|
+
return signal.butter(order, [low_cut, high_cut], btype="bandpass",
|
|
13
|
+
fs=sample_rate, output="sos")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_channel_filters(cfg: PreprocessConfig) -> Tuple[np.ndarray, np.ndarray]:
|
|
17
|
+
eeg_low, eeg_high = cfg.eeg_band
|
|
18
|
+
emg_low, emg_high = cfg.emg_band
|
|
19
|
+
eeg_sos = design_bandpass_sos(eeg_low, eeg_high, cfg.sample_rate, cfg.filter_order)
|
|
20
|
+
emg_sos = design_bandpass_sos(emg_low, emg_high, cfg.sample_rate, cfg.filter_order)
|
|
21
|
+
return eeg_sos, emg_sos
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def apply_bandpass(signal_arr: np.ndarray, sos_filters: Sequence[np.ndarray],
|
|
25
|
+
real_time: bool = False) -> np.ndarray:
|
|
26
|
+
"""
|
|
27
|
+
Band-pass filter a multi-channel signal.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
signal_arr : ndarray
|
|
32
|
+
Shape ``(channels, samples)``.
|
|
33
|
+
sos_filters : sequence of ndarray
|
|
34
|
+
One SOS filter per channel (the last filter is reused if fewer are given).
|
|
35
|
+
real_time : bool
|
|
36
|
+
When True, applies causal filtering (``sosfilt``) to emulate online
|
|
37
|
+
processing; otherwise zero-phase ``sosfiltfilt`` is used.
|
|
38
|
+
"""
|
|
39
|
+
if len(sos_filters) == 0:
|
|
40
|
+
raise ValueError("At least one SOS filter must be provided.")
|
|
41
|
+
filtered = np.empty_like(signal_arr, dtype=np.float32)
|
|
42
|
+
filter_fn = signal.sosfilt if real_time else signal.sosfiltfilt
|
|
43
|
+
for ch in range(signal_arr.shape[0]):
|
|
44
|
+
sos = sos_filters[min(ch, len(sos_filters) - 1)]
|
|
45
|
+
filtered[ch] = filter_fn(sos, signal_arr[ch].astype(np.float32))
|
|
46
|
+
return filtered
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def exponential_moving_standardize(
|
|
50
|
+
signal_arr: np.ndarray,
|
|
51
|
+
alpha: float = 0.999,
|
|
52
|
+
init_window: Optional[int] = None,
|
|
53
|
+
) -> np.ndarray:
|
|
54
|
+
"""
|
|
55
|
+
Exponential moving standardization applied per channel.
|
|
56
|
+
|
|
57
|
+
``x'_k = (x_k - mu_k) / sqrt(sigma_k^2)`` with
|
|
58
|
+
``mu_k = (1 - alpha) * x_k + alpha * mu_{k-1}`` and
|
|
59
|
+
``sigma_k^2 = (1 - alpha) * (x_k - mu_k)^2 + alpha * sigma_{k-1}^2``.
|
|
60
|
+
"""
|
|
61
|
+
if signal_arr.ndim != 2:
|
|
62
|
+
raise ValueError("Signal array must have shape (channels, samples) for standardization.")
|
|
63
|
+
channels, samples = signal_arr.shape
|
|
64
|
+
standardized = np.empty_like(signal_arr, dtype=np.float32)
|
|
65
|
+
window = samples if init_window is None else max(1, min(samples, int(init_window)))
|
|
66
|
+
init_slice = signal_arr[:, :window]
|
|
67
|
+
mu = init_slice.mean(axis=1, keepdims=True).astype(np.float32)
|
|
68
|
+
var = init_slice.var(axis=1, keepdims=True).astype(np.float32)
|
|
69
|
+
var = np.clip(var, 1e-6, None)
|
|
70
|
+
for idx in range(samples):
|
|
71
|
+
x = signal_arr[:, idx: idx + 1].astype(np.float32)
|
|
72
|
+
mu = (1.0 - alpha) * x + alpha * mu
|
|
73
|
+
var = (1.0 - alpha) * (x - mu) ** 2 + alpha * var
|
|
74
|
+
std = np.sqrt(np.maximum(var, 1e-6))
|
|
75
|
+
standardized[:, idx] = ((x - mu) / std)[:, 0]
|
|
76
|
+
return standardized
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def epoch_signal(signal_arr: np.ndarray, cfg: PreprocessConfig) -> np.ndarray:
|
|
80
|
+
"""Slice a signal into non-overlapping epochs of ``cfg.epoch_seconds``."""
|
|
81
|
+
c, total = signal_arr.shape
|
|
82
|
+
samples_per_epoch = cfg.samples_per_epoch()
|
|
83
|
+
hop = cfg.hop_length_samples()
|
|
84
|
+
if total < samples_per_epoch:
|
|
85
|
+
return np.zeros((0, c, samples_per_epoch), dtype=np.float32)
|
|
86
|
+
epochs = []
|
|
87
|
+
start = 0
|
|
88
|
+
while start + samples_per_epoch <= total:
|
|
89
|
+
epochs.append(signal_arr[:, start: start + samples_per_epoch])
|
|
90
|
+
start += hop
|
|
91
|
+
if not epochs:
|
|
92
|
+
return np.zeros((0, c, samples_per_epoch), dtype=np.float32)
|
|
93
|
+
return np.stack(epochs, axis=0).astype(np.float32)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def augment_epoch(
|
|
97
|
+
epoch: np.ndarray,
|
|
98
|
+
scale_range: Tuple[float, float] = (0.9, 1.1),
|
|
99
|
+
noise_std_ratio: float = 0.02,
|
|
100
|
+
) -> np.ndarray:
|
|
101
|
+
"""Light data augmentation (scaling + Gaussian noise) for one epoch."""
|
|
102
|
+
scaled = epoch * np.random.uniform(*scale_range)
|
|
103
|
+
noise_std = noise_std_ratio * np.std(scaled, axis=-1, keepdims=True)
|
|
104
|
+
noise = np.random.randn(*scaled.shape).astype(np.float32) * noise_std
|
|
105
|
+
return scaled + noise
|