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.
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
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
File without changes