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.
- 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
misleep/__init__.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""MiSleep: mice sleep EEG/EMG visualization, scoring, and analysis.
|
|
3
|
+
|
|
4
|
+
MiSleep is organized into a small number of modules:
|
|
5
|
+
|
|
6
|
+
================= ======================================================
|
|
7
|
+
Module Purpose
|
|
8
|
+
================= ======================================================
|
|
9
|
+
``misleep.data`` in-memory data containers (MiData, MiAnnotation)
|
|
10
|
+
``misleep.io`` load/save signals (``.mat``, ``.edf``) and annotations
|
|
11
|
+
``misleep.preprocessing`` filtering, artifact rejection, spectral analysis
|
|
12
|
+
``misleep.analysis`` event detection and automatic sleep staging
|
|
13
|
+
``misleep.viz`` matplotlib plotting (signals, spectra, hypnograms)
|
|
14
|
+
``misleep.gui`` PySide6 desktop application (``python -m misleep``)
|
|
15
|
+
``misleep.utils`` small helpers
|
|
16
|
+
================= ======================================================
|
|
17
|
+
|
|
18
|
+
The GUI is intentionally *not* imported here so that the core package
|
|
19
|
+
works without PySide6; import ``misleep.gui`` explicitly when needed.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from importlib import import_module
|
|
23
|
+
|
|
24
|
+
__author__ = "Xueqiang Wang <swang9194@gmail.com>"
|
|
25
|
+
__version__ = "0.3.0"
|
|
26
|
+
|
|
27
|
+
# Importing ``misleep`` used to import scipy, pandas, sklearn and matplotlib
|
|
28
|
+
# immediately. The GUI necessarily imports some of those later, but making
|
|
29
|
+
# the public convenience API lazy removes a large block of duplicate startup
|
|
30
|
+
# work and keeps lightweight uses such as ``misleep.__version__`` instant.
|
|
31
|
+
_LAZY_EXPORTS = {
|
|
32
|
+
"MiData": ("misleep.data", "MiData"),
|
|
33
|
+
"MiAnnotation": ("misleep.data", "MiAnnotation"),
|
|
34
|
+
**{name: ("misleep.io", name) for name in (
|
|
35
|
+
"load_mat", "write_mat", "load_edf", "write_edf", "load_npy",
|
|
36
|
+
"load_npz", "load_csv", "load_tsv", "write_npz",
|
|
37
|
+
"load_misleep_anno", "save_misleep_anno", "load_bio_anno",
|
|
38
|
+
"transfer_result", "load_annotation", "load_json_anno",
|
|
39
|
+
"load_table_anno", "load_signal", "write_signal")},
|
|
40
|
+
**{name: ("misleep.preprocessing", name) for name in (
|
|
41
|
+
"signal_filter", "filter_power_line_noise", "z_score",
|
|
42
|
+
"reject_artifact", "spectrum", "spectrogram", "band_power",
|
|
43
|
+
"crop_state_data")},
|
|
44
|
+
**{name: ("misleep.analysis", name) for name in (
|
|
45
|
+
"SWA_detection", "spindle_detection", "artifact_detection",
|
|
46
|
+
"auto_stage_gbm", "result_constraints")},
|
|
47
|
+
**{name: ("misleep.viz", name) for name in (
|
|
48
|
+
"plot_signals", "plot_spectrum", "plot_spectrogram", "plot_hypno")},
|
|
49
|
+
"utils": ("misleep.utils", None),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def __getattr__(name):
|
|
54
|
+
"""Load public convenience exports only when first accessed."""
|
|
55
|
+
try:
|
|
56
|
+
module_name, attribute = _LAZY_EXPORTS[name]
|
|
57
|
+
except KeyError as exc:
|
|
58
|
+
raise AttributeError(f"module 'misleep' has no attribute {name!r}") from exc
|
|
59
|
+
module = import_module(module_name)
|
|
60
|
+
value = module if attribute is None else getattr(module, attribute)
|
|
61
|
+
globals()[name] = value
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def __dir__():
|
|
66
|
+
return sorted(set(globals()) | set(_LAZY_EXPORTS))
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
# data
|
|
70
|
+
"MiData",
|
|
71
|
+
"MiAnnotation",
|
|
72
|
+
# io
|
|
73
|
+
"load_mat",
|
|
74
|
+
"write_mat",
|
|
75
|
+
"load_edf",
|
|
76
|
+
"write_edf",
|
|
77
|
+
"load_npy",
|
|
78
|
+
"load_npz",
|
|
79
|
+
"load_csv",
|
|
80
|
+
"load_tsv",
|
|
81
|
+
"write_npz",
|
|
82
|
+
"load_misleep_anno",
|
|
83
|
+
"save_misleep_anno",
|
|
84
|
+
"load_bio_anno",
|
|
85
|
+
"transfer_result",
|
|
86
|
+
"load_annotation",
|
|
87
|
+
"load_json_anno",
|
|
88
|
+
"load_table_anno",
|
|
89
|
+
"load_signal",
|
|
90
|
+
"write_signal",
|
|
91
|
+
# preprocessing
|
|
92
|
+
"signal_filter",
|
|
93
|
+
"filter_power_line_noise",
|
|
94
|
+
"z_score",
|
|
95
|
+
"reject_artifact",
|
|
96
|
+
"spectrum",
|
|
97
|
+
"spectrogram",
|
|
98
|
+
"band_power",
|
|
99
|
+
"crop_state_data",
|
|
100
|
+
# analysis
|
|
101
|
+
"SWA_detection",
|
|
102
|
+
"spindle_detection",
|
|
103
|
+
"artifact_detection",
|
|
104
|
+
"auto_stage_gbm",
|
|
105
|
+
"result_constraints",
|
|
106
|
+
# viz
|
|
107
|
+
"plot_signals",
|
|
108
|
+
"plot_spectrum",
|
|
109
|
+
"plot_spectrogram",
|
|
110
|
+
"plot_hypno",
|
|
111
|
+
]
|
misleep/__main__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""Entry point for ``python -m misleep [data] [anno]`` (launches the GUI)."""
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
from misleep.gui.app import show
|
|
9
|
+
from misleep.gui.app import _parse_args
|
|
10
|
+
|
|
11
|
+
data_path, anno_path = _parse_args(sys.argv[1:])
|
|
12
|
+
show(data_path=data_path, anno_path=anno_path)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__":
|
|
16
|
+
main()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""Analysis module: event detection and automatic sleep staging.
|
|
3
|
+
|
|
4
|
+
* :mod:`misleep.analysis.detection` -- SWA / spindle / artifact detection
|
|
5
|
+
* :mod:`misleep.analysis.features` -- auto-staging feature extraction
|
|
6
|
+
* :mod:`misleep.analysis.auto_stage` -- LightGBM auto staging
|
|
7
|
+
* :mod:`misleep.analysis.transformer` -- Causal-transformer auto staging (PyTorch)
|
|
8
|
+
|
|
9
|
+
The transformer sub-module is only imported on demand because it requires
|
|
10
|
+
PyTorch, which is not available on every platform.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .detection import SWA_detection, spindle_detection, artifact_detection
|
|
14
|
+
from .auto_stage import auto_stage_gbm, result_constraints, model_path
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"SWA_detection",
|
|
18
|
+
"spindle_detection",
|
|
19
|
+
"artifact_detection",
|
|
20
|
+
"auto_stage_gbm",
|
|
21
|
+
"result_constraints",
|
|
22
|
+
"model_path",
|
|
23
|
+
]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""Automatic sleep staging with a LightGBM classifier.
|
|
3
|
+
|
|
4
|
+
The model is trained on 20-second windows (stride 5 s) of EEG and EMG
|
|
5
|
+
features; every window label is then expanded back to per-second labels.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
import warnings
|
|
10
|
+
from importlib.resources import files
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from misleep.analysis.features import get_data_features, split_window_data
|
|
16
|
+
from misleep.logger import logger
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _model_dir() -> Path:
|
|
20
|
+
"""Return the directory containing the packaged LightGBM models."""
|
|
21
|
+
ref = files("misleep.analysis.models")
|
|
22
|
+
return Path(str(ref)) if ref.is_dir() else ref
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def model_path(mouse_age="adult", EEG_channel="F") -> Path:
|
|
26
|
+
"""Return the path of a packaged LightGBM model file.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
mouse_age : {'adult', 'ado', 'P30'}
|
|
31
|
+
Age category of the model.
|
|
32
|
+
EEG_channel : {'F', 'P'}
|
|
33
|
+
EEG electrode site (frontal or parietal).
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
Path
|
|
38
|
+
"""
|
|
39
|
+
return _model_dir() / f"{mouse_age}_EEG_{EEG_channel}_lightgbm.pkl"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def result_constraints(pred_prob):
|
|
43
|
+
"""Post-process raw model probabilities into smooth state labels.
|
|
44
|
+
|
|
45
|
+
Applies the following constraints:
|
|
46
|
+
|
|
47
|
+
1. A REM probability below 0.15 is overridden.
|
|
48
|
+
2. REM directly after Wake is set to NREM.
|
|
49
|
+
3. State 4 (Init) is set to Wake.
|
|
50
|
+
4. A single epoch between two identical states takes that state.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
pred_prob : ndarray
|
|
55
|
+
Model output probabilities, shape ``(n_windows, n_classes)``.
|
|
56
|
+
|
|
57
|
+
Returns
|
|
58
|
+
-------
|
|
59
|
+
list of int
|
|
60
|
+
Per-window predicted state codes (1-indexed).
|
|
61
|
+
"""
|
|
62
|
+
pred_prob = copy.deepcopy(pred_prob)
|
|
63
|
+
pred_label = [each + 1 for each in np.argmax(pred_prob, axis=1)]
|
|
64
|
+
pred_label = [2 if each[1] > 0.15 else pred_label[idx]
|
|
65
|
+
for idx, each in enumerate(pred_prob)]
|
|
66
|
+
|
|
67
|
+
for idx in range(1, len(pred_label) - 1):
|
|
68
|
+
label_ = pred_label[idx]
|
|
69
|
+
|
|
70
|
+
if label_ == 4:
|
|
71
|
+
pred_label[idx] = 3
|
|
72
|
+
if label_ == 3 and pred_label[idx + 1] == 2: # REM after Wake
|
|
73
|
+
pred_label[idx + 1] = 1
|
|
74
|
+
if pred_label[idx - 1] == pred_label[idx + 1] and pred_label[idx] != 3:
|
|
75
|
+
pred_label[idx] = pred_label[idx - 1]
|
|
76
|
+
|
|
77
|
+
return pred_label
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def auto_stage_gbm(EEG, EMG, label, sf, EEG_channel="F", mouse_age="adult"):
|
|
81
|
+
"""Auto-stage an EEG/EMG recording with the LightGBM model.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
EEG : ndarray
|
|
86
|
+
EEG signal.
|
|
87
|
+
EMG : ndarray
|
|
88
|
+
EMG signal.
|
|
89
|
+
label : list
|
|
90
|
+
Reference labels (kept for API compatibility; not used by the model).
|
|
91
|
+
sf : float
|
|
92
|
+
Sampling frequency of both signals.
|
|
93
|
+
EEG_channel : {'F', 'P'}
|
|
94
|
+
EEG electrode site (frontal or parietal). Default is ``'F'``.
|
|
95
|
+
mouse_age : {'adult', 'ado', 'P30'}
|
|
96
|
+
Model age category: adult > P56, ``ado`` is P30~P56, ``P30`` is < P30.
|
|
97
|
+
|
|
98
|
+
Returns
|
|
99
|
+
-------
|
|
100
|
+
list of int
|
|
101
|
+
Per-second predicted state codes (1 = NREM, 2 = REM, 3 = Wake).
|
|
102
|
+
May be slightly shorter than the input for the last few seconds.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
import joblib
|
|
106
|
+
except ImportError as e: # pragma: no cover
|
|
107
|
+
raise ImportError(
|
|
108
|
+
"Auto staging requires the 'joblib' package. "
|
|
109
|
+
"Install it with: pip install joblib") from e
|
|
110
|
+
|
|
111
|
+
EEG_windows = split_window_data(EEG, sf, state=4)
|
|
112
|
+
EMG_windows = split_window_data(EMG, sf, state=4)
|
|
113
|
+
if not EEG_windows or not EMG_windows:
|
|
114
|
+
raise ValueError("Signals are too short for auto staging (need >= 20 s).")
|
|
115
|
+
|
|
116
|
+
window_feature_df = get_data_features(EEG_windows, sf, data_format="EEG")
|
|
117
|
+
emg_feature_df = get_data_features(EMG_windows, sf, data_format="EMG")
|
|
118
|
+
# Combine and keep only feature columns (drop 'label')
|
|
119
|
+
window_feature_df = window_feature_df.join(emg_feature_df, lsuffix="_eeg", rsuffix="_emg")
|
|
120
|
+
window_feature_df = window_feature_df.filter(like="E")
|
|
121
|
+
|
|
122
|
+
model_file = model_path(mouse_age=mouse_age, EEG_channel=EEG_channel)
|
|
123
|
+
if not model_file.exists():
|
|
124
|
+
raise FileNotFoundError(
|
|
125
|
+
f"Model file not found: {model_file}. "
|
|
126
|
+
f"Make sure the 'misleep' package data is installed "
|
|
127
|
+
f"(pip install misleep) or provide the model manually.")
|
|
128
|
+
|
|
129
|
+
# The bundled LightGBM estimators were trained with scikit-learn 1.3.2.
|
|
130
|
+
# Their LabelEncoder is only retained as fitted metadata and is not used
|
|
131
|
+
# by predict_proba(), but newer sklearn versions otherwise print a long
|
|
132
|
+
# compatibility warning on every run. Limit the suppression narrowly to
|
|
133
|
+
# that known packaged object; all other model-loading warnings remain.
|
|
134
|
+
try:
|
|
135
|
+
from sklearn.exceptions import InconsistentVersionWarning
|
|
136
|
+
except ImportError: # pragma: no cover - sklearn is a LightGBM dependency
|
|
137
|
+
InconsistentVersionWarning = Warning
|
|
138
|
+
with warnings.catch_warnings():
|
|
139
|
+
warnings.filterwarnings(
|
|
140
|
+
"ignore",
|
|
141
|
+
message="Setting the shape on a NumPy array has been deprecated.*",
|
|
142
|
+
category=DeprecationWarning,
|
|
143
|
+
module=r"joblib\.numpy_pickle",
|
|
144
|
+
)
|
|
145
|
+
warnings.filterwarnings(
|
|
146
|
+
"ignore",
|
|
147
|
+
message="Trying to unpickle estimator LabelEncoder from version 1.3.2.*",
|
|
148
|
+
category=InconsistentVersionWarning,
|
|
149
|
+
)
|
|
150
|
+
gbm_model = joblib.load(model_file)
|
|
151
|
+
|
|
152
|
+
pred_prob = gbm_model.predict_proba(window_feature_df,
|
|
153
|
+
num_iteration=gbm_model.best_iteration_)
|
|
154
|
+
pred_label = result_constraints(pred_prob)
|
|
155
|
+
pred_label = [item for each in pred_label for item in [each] * 5]
|
|
156
|
+
logger.info("Auto staging finished (%d windows, %d seconds)",
|
|
157
|
+
len(pred_prob), len(pred_label))
|
|
158
|
+
return pred_label
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""Event detection: slow-wave activity (SWA), sleep spindles, artifacts.
|
|
3
|
+
|
|
4
|
+
All detectors operate on 1-D signal arrays plus a sampling frequency and
|
|
5
|
+
return either a list of detections or a pandas DataFrame (``df=True``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from scipy.signal import find_peaks
|
|
11
|
+
|
|
12
|
+
from misleep.preprocessing.filtering import signal_filter
|
|
13
|
+
from misleep.preprocessing.spectral import spectrogram
|
|
14
|
+
from misleep.utils.annotation import lst2group
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def SWA_detection(signal, sf, freq_band=[0.5, 4], amp_threshold=(75,), df=False, start_time_sec=0):
|
|
18
|
+
"""Slow-wave activity (SWA) detection.
|
|
19
|
+
|
|
20
|
+
The signal is band-pass filtered to ``freq_band``; waves are detected
|
|
21
|
+
from trough to peak using relative amplitude thresholds and validated
|
|
22
|
+
by their instantaneous frequency.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
signal : ndarray
|
|
27
|
+
Signal to analyze.
|
|
28
|
+
sf : float
|
|
29
|
+
Sampling frequency.
|
|
30
|
+
freq_band : list of two floats
|
|
31
|
+
Frequency band of the slow wave, e.g. ``[0.5, 4]`` Hz.
|
|
32
|
+
amp_threshold : tuple
|
|
33
|
+
Minimum (and optionally maximum) absolute amplitude of the peaks.
|
|
34
|
+
df : bool
|
|
35
|
+
Whether to return a pandas DataFrame instead of a list.
|
|
36
|
+
start_time_sec : float
|
|
37
|
+
Offset (in seconds) added to all detection times -- useful when
|
|
38
|
+
processing segments of a longer recording.
|
|
39
|
+
|
|
40
|
+
Returns
|
|
41
|
+
-------
|
|
42
|
+
list or pandas.DataFrame or None
|
|
43
|
+
Detections (``None`` when nothing was found).
|
|
44
|
+
"""
|
|
45
|
+
band_data, _ = signal_filter(signal, sf, btype="bandpass",
|
|
46
|
+
low=freq_band[0], high=freq_band[1])
|
|
47
|
+
|
|
48
|
+
# Find peaks and zero-crossings
|
|
49
|
+
pos_peak_idx, _ = find_peaks(band_data, amp_threshold)
|
|
50
|
+
neg_peak_idx, _ = find_peaks(-1 * band_data, amp_threshold)
|
|
51
|
+
zero_crossing = np.where(np.diff(np.signbit(band_data), axis=0))[0]
|
|
52
|
+
|
|
53
|
+
# Find zero -> neg_peak -> zero -> pos_peak -> zero pattern
|
|
54
|
+
negative_peaks_hold = []
|
|
55
|
+
positive_peaks_hold = []
|
|
56
|
+
zero_crossing_hold = []
|
|
57
|
+
for neg_idx in neg_peak_idx:
|
|
58
|
+
for zero_idx in zero_crossing:
|
|
59
|
+
if zero_idx > neg_idx:
|
|
60
|
+
for pos_idx in pos_peak_idx:
|
|
61
|
+
if pos_idx > zero_idx and zero_idx not in zero_crossing_hold:
|
|
62
|
+
if True not in (band_data[zero_idx + 1: pos_idx] <= 0) and \
|
|
63
|
+
True not in (band_data[neg_idx: zero_idx] >= 0):
|
|
64
|
+
negative_peaks_hold.append(neg_idx)
|
|
65
|
+
positive_peaks_hold.append(pos_idx)
|
|
66
|
+
zero_crossing_hold.append(zero_idx)
|
|
67
|
+
break
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
if negative_peaks_hold == []:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
# zero before the negative peak
|
|
74
|
+
start_zero_cross_hold = zero_crossing[:-1][np.diff(
|
|
75
|
+
np.searchsorted(negative_peaks_hold, zero_crossing)).astype(bool)]
|
|
76
|
+
|
|
77
|
+
if zero_crossing[-1] < positive_peaks_hold[-1]:
|
|
78
|
+
zero_crossing = np.append(zero_crossing, positive_peaks_hold[-1] + 1)
|
|
79
|
+
end_zero_cross_hold = zero_crossing[np.searchsorted(zero_crossing, positive_peaks_hold)]
|
|
80
|
+
|
|
81
|
+
df_lst = []
|
|
82
|
+
for idx, start_zero in enumerate(start_zero_cross_hold):
|
|
83
|
+
start_time = start_zero / sf + start_time_sec
|
|
84
|
+
end_time = end_zero_cross_hold[idx] / sf + start_time_sec
|
|
85
|
+
total_duration = end_time - start_time
|
|
86
|
+
frequency = 1 / total_duration
|
|
87
|
+
if frequency > freq_band[1] or frequency < freq_band[0]:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
middle_cross_time = zero_crossing_hold[idx] / sf + start_time_sec
|
|
91
|
+
time_pos_peak = positive_peaks_hold[idx] / sf + start_time_sec
|
|
92
|
+
val_pos_peak = band_data[positive_peaks_hold[idx]]
|
|
93
|
+
time_neg_peak = negative_peaks_hold[idx] / sf + start_time_sec
|
|
94
|
+
val_neg_peak = band_data[negative_peaks_hold[idx]]
|
|
95
|
+
|
|
96
|
+
peak_to_peak = val_pos_peak - val_neg_peak
|
|
97
|
+
slope = peak_to_peak / (time_pos_peak - time_neg_peak)
|
|
98
|
+
|
|
99
|
+
df_lst.append([start_time, time_neg_peak, middle_cross_time, time_pos_peak,
|
|
100
|
+
end_time, total_duration, val_neg_peak, val_pos_peak,
|
|
101
|
+
peak_to_peak, slope, frequency])
|
|
102
|
+
|
|
103
|
+
if df:
|
|
104
|
+
return pd.DataFrame(df_lst, columns=["StartTime", "NegTime", "MiddleTime",
|
|
105
|
+
"PosTime", "EndTime", "Duration", "NegPeak",
|
|
106
|
+
"PosPeak", "PTP", "Slope", "Frequency"])
|
|
107
|
+
return df_lst
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def spindle_detection(signal, sf, freq_band=[10, 15], start_time_sec=0,
|
|
111
|
+
std_thresh=None, duration_thresh=None):
|
|
112
|
+
"""Sleep spindle detection based on spectrogram power.
|
|
113
|
+
|
|
114
|
+
The signal's spectrogram power within ``freq_band`` is computed; a
|
|
115
|
+
spindle is defined as a period where the power exceeds
|
|
116
|
+
``mean + std_thresh * std`` (for detection) and
|
|
117
|
+
``mean + duration_thresh * std`` (for duration) and lasts at least
|
|
118
|
+
0.5 seconds.
|
|
119
|
+
|
|
120
|
+
Parameters
|
|
121
|
+
----------
|
|
122
|
+
signal : ndarray
|
|
123
|
+
Signal to analyze.
|
|
124
|
+
sf : float
|
|
125
|
+
Sampling frequency.
|
|
126
|
+
freq_band : list of two floats
|
|
127
|
+
Frequency band of the spindle, e.g. ``[10, 15]`` Hz.
|
|
128
|
+
start_time_sec : float
|
|
129
|
+
Offset added to the detection times.
|
|
130
|
+
std_thresh : float, optional
|
|
131
|
+
Std multiplier for the detection threshold (default 2).
|
|
132
|
+
duration_thresh : float, optional
|
|
133
|
+
Std multiplier for the duration threshold (default 1.5).
|
|
134
|
+
|
|
135
|
+
Returns
|
|
136
|
+
-------
|
|
137
|
+
list or None
|
|
138
|
+
List of ``[start, end]`` (in seconds) spindle detections.
|
|
139
|
+
"""
|
|
140
|
+
if std_thresh is None:
|
|
141
|
+
std_thresh = 2
|
|
142
|
+
if duration_thresh is None:
|
|
143
|
+
duration_thresh = 1.5
|
|
144
|
+
|
|
145
|
+
f, t, Sxx = spectrogram(signal, sf, band=freq_band, step=0.2, win_sec=2, norm=False)
|
|
146
|
+
|
|
147
|
+
# Summed power over the band, then squared
|
|
148
|
+
Sxx = np.sum(Sxx, axis=0)
|
|
149
|
+
Sxx_squared = Sxx ** 2
|
|
150
|
+
|
|
151
|
+
Sxx = Sxx_squared
|
|
152
|
+
Sxx_mean = np.mean(Sxx)
|
|
153
|
+
Sxx_std = np.std(Sxx)
|
|
154
|
+
spindle_threshold = std_thresh * Sxx_std + Sxx_mean
|
|
155
|
+
duration_threshold = duration_thresh * Sxx_std + Sxx_mean
|
|
156
|
+
|
|
157
|
+
Sxx_peaks_idx, _ = find_peaks(Sxx, (spindle_threshold))
|
|
158
|
+
if Sxx_peaks_idx.shape == (0,):
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
# Find duration groups
|
|
162
|
+
duration_group = lst2group([[idx, each] for idx, each in enumerate(Sxx > duration_threshold)])
|
|
163
|
+
start_time = []
|
|
164
|
+
end_time = []
|
|
165
|
+
for each in duration_group:
|
|
166
|
+
if each[0] != 0 and each[2]:
|
|
167
|
+
if each[1] < len(t) and each[2]:
|
|
168
|
+
start_time.append(t[each[0]])
|
|
169
|
+
end_time.append(t[each[1]])
|
|
170
|
+
|
|
171
|
+
start_time = np.array(start_time)
|
|
172
|
+
end_time = np.array(end_time)
|
|
173
|
+
|
|
174
|
+
if start_time.shape != end_time.shape:
|
|
175
|
+
return None
|
|
176
|
+
if start_time.shape == (0,):
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
start_time = start_time + start_time_sec
|
|
180
|
+
end_time = end_time + start_time_sec
|
|
181
|
+
|
|
182
|
+
return [[each, end_time[idx]] for idx, each in enumerate(start_time)
|
|
183
|
+
if end_time[idx] - each >= 0.5]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def artifact_detection(signal):
|
|
187
|
+
"""Artifact detection (placeholder).
|
|
188
|
+
|
|
189
|
+
Parameters
|
|
190
|
+
----------
|
|
191
|
+
signal : ndarray
|
|
192
|
+
Signal to analyze.
|
|
193
|
+
|
|
194
|
+
Returns
|
|
195
|
+
-------
|
|
196
|
+
None
|
|
197
|
+
"""
|
|
198
|
+
return None
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""Feature extraction for automatic sleep staging.
|
|
3
|
+
|
|
4
|
+
Implements the window-based time- and frequency-domain features used by
|
|
5
|
+
the LightGBM auto-staging model (EEG and EMG).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from math import floor
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from scipy import stats
|
|
13
|
+
|
|
14
|
+
from misleep.preprocessing.spectral import spectrogram, band_power
|
|
15
|
+
from misleep.utils.entropy import hjorth_params, num_zerocross, perm_entropy
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def split_window_data(data, sf, state, window_length=20, stride_length=5):
|
|
19
|
+
"""Split a signal into sliding windows.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
data : ndarray
|
|
24
|
+
Signal to split.
|
|
25
|
+
sf : float
|
|
26
|
+
Sampling frequency.
|
|
27
|
+
state : int
|
|
28
|
+
State code attached to every window (used as the label).
|
|
29
|
+
window_length : int
|
|
30
|
+
Window length in seconds.
|
|
31
|
+
stride_length : int
|
|
32
|
+
Stride in seconds.
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
list
|
|
37
|
+
List of ``[window_array, state]`` pairs. Empty when the signal is
|
|
38
|
+
shorter than the window.
|
|
39
|
+
"""
|
|
40
|
+
if data.shape[0] / sf < window_length:
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
window_data = []
|
|
44
|
+
data_sec_length = floor(data.shape[0] / sf)
|
|
45
|
+
for i in range(0, data_sec_length - stride_length, stride_length):
|
|
46
|
+
window = data[int(i * sf): int((i + window_length) * sf)]
|
|
47
|
+
window_data.append([window, state])
|
|
48
|
+
|
|
49
|
+
return window_data
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def delta_theta_ratio_theta(data, sf):
|
|
53
|
+
"""Delta/theta ratio and theta power from the first 5 seconds.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
data : ndarray
|
|
58
|
+
Windowed signal (20 s).
|
|
59
|
+
sf : float
|
|
60
|
+
Sampling frequency.
|
|
61
|
+
|
|
62
|
+
Returns
|
|
63
|
+
-------
|
|
64
|
+
(ratio, theta_power) : tuple of float
|
|
65
|
+
"""
|
|
66
|
+
freq, t, Sxx = spectrogram(data, sf, win_sec=1)
|
|
67
|
+
band_second = np.where(t < 5)
|
|
68
|
+
psd = np.sum(np.array([each[band_second] for each in Sxx]), axis=1)
|
|
69
|
+
band_power_dict = band_power(psd, freq, bands=[[0.5, 4, "delta"], [5, 9, "theta"]], relative=True)
|
|
70
|
+
return band_power_dict["delta"] / band_power_dict["theta"], band_power_dict["theta"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def self_zscore(feature, quantile=0.95):
|
|
74
|
+
"""Quantile-clipped z-score normalization of a feature array."""
|
|
75
|
+
upper_quantile = np.quantile(feature, quantile)
|
|
76
|
+
feature = [each if each < upper_quantile else upper_quantile for each in feature]
|
|
77
|
+
return (feature - np.mean(feature)) / np.std(feature)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_data_features(data, sf, data_format="EEG"):
|
|
81
|
+
"""Extract the auto-staging feature set from windowed data.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
data : list
|
|
86
|
+
List of ``[window_array, label]`` pairs (see :func:`split_window_data`).
|
|
87
|
+
sf : float
|
|
88
|
+
Sampling frequency.
|
|
89
|
+
data_format : {'EEG', 'EMG'}
|
|
90
|
+
Which channel type the windows come from. EEG additionally gets
|
|
91
|
+
skewness, kurtosis, delta/theta ratio and theta power.
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
pandas.DataFrame
|
|
96
|
+
Feature table with a ``label`` column.
|
|
97
|
+
"""
|
|
98
|
+
window_feature_df = pd.DataFrame()
|
|
99
|
+
window_feature_df["label"] = [each[1] for each in data]
|
|
100
|
+
|
|
101
|
+
# ---- Time-domain features, both EEG and EMG ----
|
|
102
|
+
data_std = np.array([np.std(each[0][:int(5 * sf)]) for each in data])
|
|
103
|
+
window_feature_df[f"{data_format}_std_zscore"] = self_zscore(data_std)
|
|
104
|
+
|
|
105
|
+
zerocross_rate = [num_zerocross(each[0][:int(5 * sf)]) / (5 * sf) for each in data]
|
|
106
|
+
window_feature_df[f"{data_format}_zerocross_rate"] = \
|
|
107
|
+
(zerocross_rate - np.mean(zerocross_rate)) / np.std(zerocross_rate)
|
|
108
|
+
|
|
109
|
+
hjorth = [hjorth_params(each[0][:int(5 * sf)]) for each in data]
|
|
110
|
+
hjorth_M = [each[0] for each in hjorth]
|
|
111
|
+
hjorth_C = [each[1] for each in hjorth]
|
|
112
|
+
window_feature_df[f"{data_format}_Hjorth_M"] = self_zscore(hjorth_M)
|
|
113
|
+
window_feature_df[f"{data_format}_Hjorth_C"] = self_zscore(hjorth_C)
|
|
114
|
+
|
|
115
|
+
perm_entropy_ = [perm_entropy(each[0][:int(5 * sf)]) for each in data]
|
|
116
|
+
window_feature_df[f"{data_format}_perm_entropy"] = self_zscore(perm_entropy_)
|
|
117
|
+
|
|
118
|
+
# ---- EEG-only features ----
|
|
119
|
+
if data_format.startswith("EEG"):
|
|
120
|
+
data_skewness = np.array([stats.skew(each[0][:int(5 * sf)]) for each in data])
|
|
121
|
+
data_kurtosis = np.array([stats.kurtosis(each[0][:int(5 * sf)]) for each in data])
|
|
122
|
+
window_feature_df[f"{data_format}_skewness_zscore"] = self_zscore(data_skewness)
|
|
123
|
+
window_feature_df[f"{data_format}_kurtosis_zscore"] = self_zscore(data_kurtosis)
|
|
124
|
+
|
|
125
|
+
delta_theta = [delta_theta_ratio_theta(each[0], sf) for each in data]
|
|
126
|
+
delta_theta_ratio = [each[0] for each in delta_theta]
|
|
127
|
+
theta = [each[1] for each in delta_theta]
|
|
128
|
+
window_feature_df[f"{data_format}_delta_theta_ratio"] = self_zscore(delta_theta_ratio)
|
|
129
|
+
window_feature_df[f"{data_format}_theta"] = self_zscore(theta)
|
|
130
|
+
|
|
131
|
+
return window_feature_df
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|