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.

Files changed (94) hide show
  1. misleep/__init__.py +111 -0
  2. misleep/__main__.py +16 -0
  3. misleep/analysis/__init__.py +23 -0
  4. misleep/analysis/auto_stage.py +158 -0
  5. misleep/analysis/detection.py +198 -0
  6. misleep/analysis/features.py +131 -0
  7. misleep/analysis/models/P30_EEG_F_lightgbm.pkl +0 -0
  8. misleep/analysis/models/P30_EEG_P_lightgbm.pkl +0 -0
  9. misleep/analysis/models/__init__.py +0 -0
  10. misleep/analysis/models/ado_EEG_F_lightgbm.pkl +0 -0
  11. misleep/analysis/models/ado_EEG_P_lightgbm.pkl +0 -0
  12. misleep/analysis/models/adult_EEG_F_lightgbm.pkl +0 -0
  13. misleep/analysis/models/adult_EEG_P_lightgbm.pkl +0 -0
  14. misleep/analysis/transformer/__init__.py +46 -0
  15. misleep/analysis/transformer/checkpoints/CausalTransformer_best.pt +0 -0
  16. misleep/analysis/transformer/checkpoints/__init__.py +0 -0
  17. misleep/analysis/transformer/configs.py +52 -0
  18. misleep/analysis/transformer/data.py +362 -0
  19. misleep/analysis/transformer/inference.py +328 -0
  20. misleep/analysis/transformer/models.py +220 -0
  21. misleep/analysis/transformer/preprocessing.py +105 -0
  22. misleep/config/__init__.py +105 -0
  23. misleep/config/default_config.ini +38 -0
  24. misleep/data/__init__.py +13 -0
  25. misleep/data/annotation.py +114 -0
  26. misleep/data/midata.py +387 -0
  27. misleep/gui/__init__.py +23 -0
  28. misleep/gui/app.py +183 -0
  29. misleep/gui/config_dialog.py +429 -0
  30. misleep/gui/dialogs.py +1107 -0
  31. misleep/gui/event_filters.py +26 -0
  32. misleep/gui/main_window.py +2699 -0
  33. misleep/gui/qt_utils.py +277 -0
  34. misleep/gui/resources/__init__.py +9 -0
  35. misleep/gui/resources/entire_logo.png +0 -0
  36. misleep/gui/resources/logo.png +0 -0
  37. misleep/gui/resources/misleep.ico +0 -0
  38. misleep/gui/resources/misleep.qrc +7 -0
  39. misleep/gui/resources/misleep_rc.py +1324 -0
  40. misleep/gui/spec_window.py +187 -0
  41. misleep/gui/style.py +994 -0
  42. misleep/gui/uis/SWA_detect_dialog.ui +181 -0
  43. misleep/gui/uis/SWA_detect_dialog_ui.py +176 -0
  44. misleep/gui/uis/__init__.py +6 -0
  45. misleep/gui/uis/about.ui +88 -0
  46. misleep/gui/uis/about_ui.py +80 -0
  47. misleep/gui/uis/auto_stage_causalTransformer_dialog.ui +77 -0
  48. misleep/gui/uis/auto_stage_causalTransformer_dialog_ui.py +90 -0
  49. misleep/gui/uis/auto_stage_lightGBM_dialog.ui +124 -0
  50. misleep/gui/uis/auto_stage_lightGBM_dialog_ui.py +124 -0
  51. misleep/gui/uis/horizontal_line_dialog.ui +166 -0
  52. misleep/gui/uis/horizontal_line_dialog_ui.py +157 -0
  53. misleep/gui/uis/label_dialog.ui +65 -0
  54. misleep/gui/uis/label_dialog_ui.py +72 -0
  55. misleep/gui/uis/main_window.ui +871 -0
  56. misleep/gui/uis/main_window_ui.py +616 -0
  57. misleep/gui/uis/save_data_dialog.ui +95 -0
  58. misleep/gui/uis/save_data_dialog_ui.py +98 -0
  59. misleep/gui/uis/spec_window.ui +109 -0
  60. misleep/gui/uis/spec_window_ui.py +103 -0
  61. misleep/gui/uis/spindle_detect_dialog.ui +202 -0
  62. misleep/gui/uis/spindle_detect_dialog_ui.py +194 -0
  63. misleep/gui/uis/state_spectral_dialog.ui +240 -0
  64. misleep/gui/uis/state_spectral_dialog_ui.py +204 -0
  65. misleep/gui/uis/transfer_result_dialog.ui +104 -0
  66. misleep/gui/uis/transfer_result_dialog_ui.py +94 -0
  67. misleep/gui/workers.py +69 -0
  68. misleep/io/__init__.py +68 -0
  69. misleep/io/annotation.py +367 -0
  70. misleep/io/array.py +209 -0
  71. misleep/io/base.py +174 -0
  72. misleep/io/edf.py +109 -0
  73. misleep/io/mat.py +189 -0
  74. misleep/logger.py +79 -0
  75. misleep/preprocessing/__init__.py +23 -0
  76. misleep/preprocessing/artifacts.py +76 -0
  77. misleep/preprocessing/filtering.py +94 -0
  78. misleep/preprocessing/segment.py +59 -0
  79. misleep/preprocessing/spectral.py +169 -0
  80. misleep/utils/__init__.py +45 -0
  81. misleep/utils/annotation.py +130 -0
  82. misleep/utils/entropy.py +135 -0
  83. misleep/utils/misc.py +69 -0
  84. misleep/utils/time_utils.py +61 -0
  85. misleep/viz/__init__.py +12 -0
  86. misleep/viz/hypnogram.py +48 -0
  87. misleep/viz/signals.py +51 -0
  88. misleep/viz/spectral.py +98 -0
  89. misleep-0.3.0.dist-info/METADATA +249 -0
  90. misleep-0.3.0.dist-info/RECORD +94 -0
  91. misleep-0.3.0.dist-info/WHEEL +5 -0
  92. misleep-0.3.0.dist-info/entry_points.txt +15 -0
  93. misleep-0.3.0.dist-info/licenses/LICENSE +29 -0
  94. 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