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,46 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""Causal-transformer based automatic sleep staging.
|
|
3
|
+
|
|
4
|
+
This sub-module requires PyTorch (``pip install 'misleep[transformer]'``).
|
|
5
|
+
The heavy sub-modules are imported lazily so that importing
|
|
6
|
+
``misleep.analysis.transformer`` itself never fails when PyTorch is
|
|
7
|
+
missing -- only *using* the auto-staging API does (with a helpful
|
|
8
|
+
ImportError).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
from importlib.resources import files
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
__all__ = ["AutoStageConfig", "EEGTransformerConfig", "PreprocessConfig",
|
|
16
|
+
"STFTConfig", "auto_stage_llm", "default_checkpoint_path",
|
|
17
|
+
"build_model", "load_mouse_recording", "parse_label_file"]
|
|
18
|
+
|
|
19
|
+
#: name -> sub-module that provides it
|
|
20
|
+
_LAZY_EXPORTS = {
|
|
21
|
+
"AutoStageConfig": "misleep.analysis.transformer.inference",
|
|
22
|
+
"auto_stage_llm": "misleep.analysis.transformer.inference",
|
|
23
|
+
"EEGTransformerConfig": "misleep.analysis.transformer.configs",
|
|
24
|
+
"PreprocessConfig": "misleep.analysis.transformer.configs",
|
|
25
|
+
"STFTConfig": "misleep.analysis.transformer.configs",
|
|
26
|
+
"build_model": "misleep.analysis.transformer.models",
|
|
27
|
+
"load_mouse_recording": "misleep.analysis.transformer.data",
|
|
28
|
+
"parse_label_file": "misleep.analysis.transformer.data",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def default_checkpoint_path() -> Path:
|
|
33
|
+
"""Return the path of the packaged CausalTransformer checkpoint."""
|
|
34
|
+
ref = files("misleep.analysis.transformer.checkpoints")
|
|
35
|
+
return Path(str(ref)) / "CausalTransformer_best.pt"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def __getattr__(name: str):
|
|
39
|
+
if name in _LAZY_EXPORTS:
|
|
40
|
+
module = importlib.import_module(_LAZY_EXPORTS[name])
|
|
41
|
+
return getattr(module, name)
|
|
42
|
+
raise AttributeError(f"module 'misleep.analysis.transformer' has no attribute {name!r}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def __dir__():
|
|
46
|
+
return sorted(set(globals()) | set(__all__))
|
|
Binary file
|
|
File without changes
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Tuple
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass()
|
|
8
|
+
class PreprocessConfig:
|
|
9
|
+
"""Signal preprocessing hyperparameters for the transformer models."""
|
|
10
|
+
|
|
11
|
+
sample_rate: float = 305.1758
|
|
12
|
+
epoch_seconds: float = 5.0
|
|
13
|
+
filter_order: int = 4
|
|
14
|
+
eeg_band: Tuple[float, float] = (0.5, 30.0)
|
|
15
|
+
emg_band: Tuple[float, float] = (15.0, 100.0)
|
|
16
|
+
|
|
17
|
+
def samples_per_epoch(self) -> int:
|
|
18
|
+
return int(round(self.sample_rate * self.epoch_seconds))
|
|
19
|
+
|
|
20
|
+
def hop_length_samples(self) -> int:
|
|
21
|
+
return self.samples_per_epoch()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass()
|
|
25
|
+
class STFTConfig:
|
|
26
|
+
"""STFT hyperparameters."""
|
|
27
|
+
|
|
28
|
+
sample_rate: float = 305.1758
|
|
29
|
+
n_fft: int = 256
|
|
30
|
+
hop_length: int = 128
|
|
31
|
+
win_length: int = 256
|
|
32
|
+
f_max: float = 40.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass()
|
|
36
|
+
class EEGTransformerConfig:
|
|
37
|
+
"""Light transformer architecture hyperparameters."""
|
|
38
|
+
|
|
39
|
+
num_classes: int = 3
|
|
40
|
+
patch_size: int = 32
|
|
41
|
+
d_model: int = 128
|
|
42
|
+
num_heads: int = 4
|
|
43
|
+
num_layers: int = 4
|
|
44
|
+
dim_feedforward: int = 256
|
|
45
|
+
dropout: float = 0.1
|
|
46
|
+
use_time_tokens: bool = True
|
|
47
|
+
use_spec_tokens: bool = True
|
|
48
|
+
stft: STFTConfig = field(default_factory=STFTConfig)
|
|
49
|
+
context_window: int = 11
|
|
50
|
+
conv_base_filters: int = 16
|
|
51
|
+
conv_kernel_size: int = 7
|
|
52
|
+
conv_blocks: int = 2
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import torch
|
|
9
|
+
from torch.utils.data import Dataset
|
|
10
|
+
|
|
11
|
+
from .configs import PreprocessConfig
|
|
12
|
+
from .preprocessing import (
|
|
13
|
+
apply_bandpass,
|
|
14
|
+
augment_epoch,
|
|
15
|
+
build_channel_filters,
|
|
16
|
+
epoch_signal,
|
|
17
|
+
exponential_moving_standardize,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _resolve_mat_key(mat_dict: Dict) -> str:
|
|
22
|
+
keys = [k for k in mat_dict.keys() if not k.startswith("__")]
|
|
23
|
+
if not keys:
|
|
24
|
+
raise ValueError("MAT file does not contain any usable keys.")
|
|
25
|
+
if len(keys) == 1:
|
|
26
|
+
return keys[0]
|
|
27
|
+
for candidate in keys:
|
|
28
|
+
if candidate.lower().startswith("mouse"):
|
|
29
|
+
return candidate
|
|
30
|
+
return keys[0]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _extract_sample_rate(entry: Dict, default: float) -> float:
|
|
34
|
+
for key in ("sf", "fs", "sample_rate"):
|
|
35
|
+
if key not in entry:
|
|
36
|
+
continue
|
|
37
|
+
try:
|
|
38
|
+
values = np.asarray(entry[key], dtype=np.float32).ravel()
|
|
39
|
+
except Exception:
|
|
40
|
+
continue
|
|
41
|
+
for val in values:
|
|
42
|
+
if val > 0:
|
|
43
|
+
return float(val)
|
|
44
|
+
return float(default)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_mouse_recording(
|
|
48
|
+
data_path: Path,
|
|
49
|
+
*,
|
|
50
|
+
return_sample_rate: bool = False,
|
|
51
|
+
default_sample_rate: float = 305.1758,
|
|
52
|
+
) -> np.ndarray | Tuple[np.ndarray, float]:
|
|
53
|
+
"""Load a raw ``(2, samples)`` EEG/EMG recording from ``.mat`` or ``.edf``."""
|
|
54
|
+
if str(data_path).endswith(".mat"):
|
|
55
|
+
import scipy.io as sio
|
|
56
|
+
|
|
57
|
+
data_ = sio.loadmat(data_path, simplify_cells=True)
|
|
58
|
+
key = data_path.stem if data_path.stem in data_ else _resolve_mat_key(data_)
|
|
59
|
+
entry = data_[key]
|
|
60
|
+
elif str(data_path).endswith(".edf"):
|
|
61
|
+
import misleep
|
|
62
|
+
|
|
63
|
+
midata = misleep.load_edf(data_path)
|
|
64
|
+
entry = {"EEG": midata.signals[0], "EMG_DIFF": midata.signals[1], "sf": midata.sf[0]}
|
|
65
|
+
else:
|
|
66
|
+
raise ValueError(f"Unsupported file type: {data_path}")
|
|
67
|
+
|
|
68
|
+
sample_rate = _extract_sample_rate(entry, default_sample_rate)
|
|
69
|
+
if "EEG_P" in entry:
|
|
70
|
+
eeg_raw = entry["EEG_P"]
|
|
71
|
+
elif "EEG" in entry:
|
|
72
|
+
eeg_raw = entry["EEG"]
|
|
73
|
+
else:
|
|
74
|
+
raise KeyError(f"Missing EEG_P or EEG in {data_path}")
|
|
75
|
+
eeg = np.asarray(eeg_raw, dtype=np.float32)
|
|
76
|
+
emg = np.asarray(entry["EMG_DIFF"], dtype=np.float32)
|
|
77
|
+
min_len = min(eeg.shape[-1], emg.shape[-1])
|
|
78
|
+
if min_len <= 0:
|
|
79
|
+
raise ValueError(f"Invalid recording length in {data_path}")
|
|
80
|
+
if eeg.shape[-1] != min_len:
|
|
81
|
+
eeg = eeg[..., :min_len]
|
|
82
|
+
if emg.shape[-1] != min_len:
|
|
83
|
+
emg = emg[..., :min_len]
|
|
84
|
+
stacked = np.stack([eeg, emg], axis=0)
|
|
85
|
+
if return_sample_rate:
|
|
86
|
+
return stacked, sample_rate
|
|
87
|
+
return stacked
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_label_file(txt_path: Path) -> Tuple[List[Dict], Dict]:
|
|
91
|
+
"""Parse a MiSleep annotation ``.txt`` into entries and metadata."""
|
|
92
|
+
entries: List[Dict] = []
|
|
93
|
+
base_seconds = None
|
|
94
|
+
with txt_path.open("r", encoding="utf-8", errors="ignore") as f:
|
|
95
|
+
lines = [line.strip() for line in f if line.strip()]
|
|
96
|
+
parsing = False
|
|
97
|
+
for line in lines:
|
|
98
|
+
if line.startswith("==========Sleep stage"):
|
|
99
|
+
parsing = True
|
|
100
|
+
continue
|
|
101
|
+
if not parsing:
|
|
102
|
+
continue
|
|
103
|
+
parts = [p.strip() for p in line.split(",")]
|
|
104
|
+
if len(parts) < 8:
|
|
105
|
+
continue
|
|
106
|
+
start_code, start_idx, _, end_code, end_idx, _, label_id, label_name = parts[:8]
|
|
107
|
+
entry = {
|
|
108
|
+
"start_code": start_code,
|
|
109
|
+
"end_code": end_code,
|
|
110
|
+
"start_idx": int(start_idx),
|
|
111
|
+
"end_idx": int(end_idx),
|
|
112
|
+
"label_id": int(label_id),
|
|
113
|
+
"label_name": label_name,
|
|
114
|
+
}
|
|
115
|
+
entries.append(entry)
|
|
116
|
+
if base_seconds is None:
|
|
117
|
+
day, hour, minute, second = (int(v) for v in start_code.split(":"))
|
|
118
|
+
base_seconds = ((day * 24 + hour) * 60 + minute) * 60 + second
|
|
119
|
+
meta = {"base_seconds": base_seconds or 0,
|
|
120
|
+
"duration_units": entries[-1]["end_idx"] if entries else 0}
|
|
121
|
+
return entries, meta
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def build_label_array(num_samples: int, annotations: Sequence[Dict], samples_per_unit: float) -> np.ndarray:
|
|
125
|
+
"""Build a per-sample label array from parsed annotation entries."""
|
|
126
|
+
labels = np.full(num_samples, -1, dtype=np.int16)
|
|
127
|
+
id_map = {1: 0, 2: 1, 3: 2} # NREM, REM, Wake
|
|
128
|
+
for entry in annotations:
|
|
129
|
+
start = max(0, int(round(entry["start_idx"] * samples_per_unit)))
|
|
130
|
+
end = min(num_samples, int(round(entry["end_idx"] * samples_per_unit)))
|
|
131
|
+
if end <= start:
|
|
132
|
+
continue
|
|
133
|
+
mapped = id_map.get(entry["label_id"], -1)
|
|
134
|
+
if mapped >= 0:
|
|
135
|
+
labels[start:end] = mapped
|
|
136
|
+
return labels
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def slice_epochs_with_labels(
|
|
140
|
+
signal_arr: np.ndarray,
|
|
141
|
+
labels: np.ndarray,
|
|
142
|
+
cfg: PreprocessConfig,
|
|
143
|
+
min_valid_ratio: float = 0.8,
|
|
144
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
145
|
+
"""Slice a signal into epochs and assign each epoch its majority label."""
|
|
146
|
+
samples_per_epoch = cfg.samples_per_epoch()
|
|
147
|
+
hop = cfg.hop_length_samples()
|
|
148
|
+
total = signal_arr.shape[1]
|
|
149
|
+
if total < samples_per_epoch:
|
|
150
|
+
return (np.zeros((0, signal_arr.shape[0], samples_per_epoch), dtype=np.float32),
|
|
151
|
+
np.zeros((0,), dtype=np.int64))
|
|
152
|
+
usable_epochs: List[np.ndarray] = []
|
|
153
|
+
epoch_labels: List[int] = []
|
|
154
|
+
start = 0
|
|
155
|
+
while start + samples_per_epoch <= total:
|
|
156
|
+
end = start + samples_per_epoch
|
|
157
|
+
epoch_signal_arr = signal_arr[:, start:end]
|
|
158
|
+
label_slice = labels[start:end]
|
|
159
|
+
valid = label_slice[label_slice >= 0]
|
|
160
|
+
if valid.size / samples_per_epoch < min_valid_ratio:
|
|
161
|
+
start += hop
|
|
162
|
+
continue
|
|
163
|
+
epoch_label = int(np.bincount(valid, minlength=3).argmax())
|
|
164
|
+
usable_epochs.append(epoch_signal_arr)
|
|
165
|
+
epoch_labels.append(epoch_label)
|
|
166
|
+
start += hop
|
|
167
|
+
if not usable_epochs:
|
|
168
|
+
channels = signal_arr.shape[0]
|
|
169
|
+
return (np.zeros((0, channels, samples_per_epoch), dtype=np.float32),
|
|
170
|
+
np.zeros((0,), dtype=np.int64))
|
|
171
|
+
return np.stack(usable_epochs), np.asarray(epoch_labels, dtype=np.int64)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass()
|
|
175
|
+
class SubjectEpochs:
|
|
176
|
+
"""Epochs and labels of one subject."""
|
|
177
|
+
|
|
178
|
+
subject_id: str
|
|
179
|
+
epochs: np.ndarray # shape (N, 2, T)
|
|
180
|
+
labels: np.ndarray # shape (N,)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass()
|
|
184
|
+
class SubjectInfo:
|
|
185
|
+
"""Metadata of one subject."""
|
|
186
|
+
|
|
187
|
+
subject_id: str # relative path without extension
|
|
188
|
+
data_path: Path
|
|
189
|
+
label_path: Optional[Path]
|
|
190
|
+
age_group: str
|
|
191
|
+
mouse_id: str
|
|
192
|
+
extension: str
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class SleepEpochDataset(Dataset):
|
|
196
|
+
"""PyTorch dataset of context-window sequences with labels."""
|
|
197
|
+
|
|
198
|
+
def __init__(self, subjects: Sequence[SubjectEpochs], window_size: int,
|
|
199
|
+
augment: bool = False) -> None:
|
|
200
|
+
self.window = max(1, window_size)
|
|
201
|
+
self.augment = augment
|
|
202
|
+
self.samples: List[np.ndarray] = []
|
|
203
|
+
self.labels: List[int] = []
|
|
204
|
+
for subj in subjects:
|
|
205
|
+
epochs = subj.epochs.astype(np.float32)
|
|
206
|
+
labels = subj.labels.astype(np.int64)
|
|
207
|
+
if epochs.size == 0:
|
|
208
|
+
continue
|
|
209
|
+
seqs, seq_labels = self._build_sequences(epochs, labels)
|
|
210
|
+
self.samples.append(seqs)
|
|
211
|
+
self.labels.append(seq_labels)
|
|
212
|
+
|
|
213
|
+
if self.samples:
|
|
214
|
+
lengths = [arr.shape[0] for arr in self.samples]
|
|
215
|
+
self.offsets = np.cumsum([0] + lengths)
|
|
216
|
+
self.flat_labels = np.concatenate(self.labels)
|
|
217
|
+
else:
|
|
218
|
+
self.offsets = np.array([0], dtype=np.int64)
|
|
219
|
+
self.flat_labels = np.zeros((0,), dtype=np.int64)
|
|
220
|
+
|
|
221
|
+
def _build_sequences(self, epochs: np.ndarray, labels: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
|
222
|
+
total = epochs.shape[0]
|
|
223
|
+
seqs = []
|
|
224
|
+
seq_labels = []
|
|
225
|
+
for idx in range(total):
|
|
226
|
+
start = max(0, idx - self.window + 1)
|
|
227
|
+
seq = epochs[start: idx + 1]
|
|
228
|
+
if seq.shape[0] < self.window:
|
|
229
|
+
pad = np.repeat(seq[0:1], self.window - seq.shape[0], axis=0)
|
|
230
|
+
seq = np.concatenate([pad, seq], axis=0)
|
|
231
|
+
seqs.append(seq)
|
|
232
|
+
seq_labels.append(labels[idx])
|
|
233
|
+
return np.stack(seqs, axis=0), np.asarray(seq_labels, dtype=np.int64)
|
|
234
|
+
|
|
235
|
+
def __len__(self) -> int:
|
|
236
|
+
return int(self.offsets[-1])
|
|
237
|
+
|
|
238
|
+
def _locate(self, idx: int) -> Tuple[int, int]:
|
|
239
|
+
lo, hi = 0, len(self.offsets) - 1
|
|
240
|
+
while lo < hi:
|
|
241
|
+
mid = (lo + hi) // 2
|
|
242
|
+
if idx < self.offsets[mid + 1]:
|
|
243
|
+
hi = mid
|
|
244
|
+
else:
|
|
245
|
+
lo = mid + 1
|
|
246
|
+
subj_idx = lo
|
|
247
|
+
offset = idx - self.offsets[subj_idx]
|
|
248
|
+
return subj_idx, offset
|
|
249
|
+
|
|
250
|
+
def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
|
|
251
|
+
subj_idx, offset = self._locate(idx)
|
|
252
|
+
seq = self.samples[subj_idx][offset]
|
|
253
|
+
label = self.labels[subj_idx][offset]
|
|
254
|
+
if self.augment:
|
|
255
|
+
seq = np.stack([augment_epoch(epoch) for epoch in seq], axis=0)
|
|
256
|
+
return torch.from_numpy(seq), torch.tensor(label, dtype=torch.long)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _sanitize_subject_id(subject_id: str) -> str:
|
|
260
|
+
return subject_id.replace("/", "__").replace("\\", "__")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def resolve_subject_paths(subject_id: str, data_dir: Path, data_format: str = "mat") -> Tuple[Path, Optional[Path]]:
|
|
264
|
+
base = data_dir / subject_id
|
|
265
|
+
if data_format == "mat":
|
|
266
|
+
mat_path = Path(str(base) + ".mat")
|
|
267
|
+
txt_path = Path(str(base) + ".txt")
|
|
268
|
+
if not mat_path.exists():
|
|
269
|
+
raise FileNotFoundError(f"MAT file not found for subject '{subject_id}' at {mat_path}")
|
|
270
|
+
if not txt_path.exists():
|
|
271
|
+
raise FileNotFoundError(f"TXT file not found for subject '{subject_id}' at {txt_path}")
|
|
272
|
+
return mat_path, txt_path
|
|
273
|
+
if data_format == "npy":
|
|
274
|
+
npy_path = Path(str(base) + ".npz")
|
|
275
|
+
if not npy_path.exists():
|
|
276
|
+
raise FileNotFoundError(f"NPZ file not found for subject '{subject_id}' at {npy_path}")
|
|
277
|
+
return npy_path, None
|
|
278
|
+
raise ValueError(f"Unsupported data format '{data_format}'")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def discover_subject_infos(data_dir: Path, data_format: str = "mat") -> List[SubjectInfo]:
|
|
282
|
+
"""Discover ``.mat``/``.npz`` subjects (with label files) under a directory."""
|
|
283
|
+
infos: List[SubjectInfo] = []
|
|
284
|
+
if not data_dir.exists():
|
|
285
|
+
return infos
|
|
286
|
+
pattern = "*.npz" if data_format == "npy" else "*.mat"
|
|
287
|
+
for data_file in sorted(data_dir.rglob(pattern)):
|
|
288
|
+
if not data_file.is_file():
|
|
289
|
+
continue
|
|
290
|
+
if data_format == "mat":
|
|
291
|
+
label_path = data_file.with_suffix(".txt")
|
|
292
|
+
if not label_path.exists():
|
|
293
|
+
continue
|
|
294
|
+
else:
|
|
295
|
+
label_path = None
|
|
296
|
+
rel_path = data_file.relative_to(data_dir)
|
|
297
|
+
rel_id = rel_path.with_suffix("").as_posix()
|
|
298
|
+
parts = rel_path.parts
|
|
299
|
+
if parts:
|
|
300
|
+
age_group = parts[0]
|
|
301
|
+
mouse_id = parts[-2] if len(parts) >= 2 else parts[0]
|
|
302
|
+
else:
|
|
303
|
+
age_group = "root"
|
|
304
|
+
mouse_id = "root"
|
|
305
|
+
infos.append(SubjectInfo(
|
|
306
|
+
subject_id=rel_id,
|
|
307
|
+
data_path=data_file,
|
|
308
|
+
label_path=label_path,
|
|
309
|
+
age_group=age_group,
|
|
310
|
+
mouse_id=mouse_id,
|
|
311
|
+
extension=data_file.suffix,
|
|
312
|
+
))
|
|
313
|
+
return infos
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def load_subject_epochs(
|
|
317
|
+
subject_id: str,
|
|
318
|
+
data_dir: Path,
|
|
319
|
+
cfg: PreprocessConfig,
|
|
320
|
+
cache_dir: Optional[Path] = None,
|
|
321
|
+
data_format: str = "mat",
|
|
322
|
+
) -> SubjectEpochs:
|
|
323
|
+
"""Load (and optionally cache) the epochs + labels of one subject."""
|
|
324
|
+
mat_path, txt_path = resolve_subject_paths(subject_id, data_dir, data_format=data_format)
|
|
325
|
+
cache_file = None
|
|
326
|
+
if cache_dir and data_format == "mat":
|
|
327
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
328
|
+
cache_file = cache_dir / f"{_sanitize_subject_id(subject_id)}_epochs.npz"
|
|
329
|
+
if cache_file.exists():
|
|
330
|
+
data = np.load(cache_file)
|
|
331
|
+
cached = SubjectEpochs(subject_id, data["epochs"], data["labels"])
|
|
332
|
+
expected_len = cfg.samples_per_epoch()
|
|
333
|
+
expected_hop = cfg.hop_length_samples()
|
|
334
|
+
cache_len = int(data["epoch_samples"]) if "epoch_samples" in data else cached.epochs.shape[-1]
|
|
335
|
+
cache_hop = int(data["hop_samples"]) if "hop_samples" in data else expected_hop + 1
|
|
336
|
+
standardization = data["standardization"] if "standardization" in data else None
|
|
337
|
+
if cache_len == expected_len and cache_hop == expected_hop and standardization == "exp_mov":
|
|
338
|
+
return cached
|
|
339
|
+
print(f"[Cache] {cache_file.name} mismatch detected; rebuilding with new preprocessing settings.")
|
|
340
|
+
if data_format == "npy":
|
|
341
|
+
data = np.load(mat_path)
|
|
342
|
+
return SubjectEpochs(subject_id, data["epochs"], data["labels"])
|
|
343
|
+
|
|
344
|
+
raw = load_mouse_recording(mat_path)
|
|
345
|
+
annotations, meta = parse_label_file(txt_path)
|
|
346
|
+
sos = build_channel_filters(cfg)
|
|
347
|
+
filtered = apply_bandpass(raw, sos)
|
|
348
|
+
standardized = exponential_moving_standardize(filtered, init_window=cfg.samples_per_epoch())
|
|
349
|
+
samples_per_unit = raw.shape[1] / max(meta["duration_units"], 1)
|
|
350
|
+
labels = build_label_array(raw.shape[1], annotations, samples_per_unit)
|
|
351
|
+
epochs, epoch_labels = slice_epochs_with_labels(standardized, labels, cfg)
|
|
352
|
+
|
|
353
|
+
if cache_file is not None:
|
|
354
|
+
np.savez_compressed(
|
|
355
|
+
cache_file,
|
|
356
|
+
epochs=epochs,
|
|
357
|
+
labels=epoch_labels,
|
|
358
|
+
epoch_samples=cfg.samples_per_epoch(),
|
|
359
|
+
hop_samples=cfg.hop_length_samples(),
|
|
360
|
+
standardization="exp_mov",
|
|
361
|
+
)
|
|
362
|
+
return SubjectEpochs(subject_id, epochs, epoch_labels)
|