Skip to content

Commit d0e5215

Browse files
conoratoabelfodil
andauthored
Backend/metrics (#74)
* added board type & completed metadata response * added base work for tests * setup tests * moved server files not related to classification to backend package * added time passed in stage metrics * added time passed in each sleep stages tests * tests pass for time passed in stage * added latency tests * latency tests passes * added tests to backend CI * added onset tests * completed onsets * added sleep offset * added wake after sleep offset * added efficient tests * added efficiency * fixed warning * added tests * moved all metrics to single module * refactored and added stage shifts * fixed awakenings tests * return -1 timestamps in case user doesnt sleep * refactored metrics for the 2nd time ...... * fixed server errors * removed acquisition board (now we autodetect) && added tests instructions to readme * extracted stream duration * fixed tests * code review * code review Co-authored-by: Anes Belfodil <abelfodil@users.noreply.github.com>
1 parent 40895ae commit d0e5215

File tree

12 files changed

+656
-32
lines changed

12 files changed

+656
-32
lines changed

.github/workflows/backend.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ jobs:
2626
with:
2727
python-version: 3.8
2828
- run: python -m pip install -r requirements.txt -r requirements-dev.txt
29+
- run: python -m pytest
2930
- run: python -m PyInstaller --onefile app.py
3031
- uses: actions/upload-artifact@v2
3132
with:

backend/app.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
from waitress import serve
44
from http import HTTPStatus
55

6+
from backend.request import ClassificationRequest
7+
from backend.response import ClassificationResponse
8+
from backend.spectrogram_generator import SpectrogramGenerator
69
from classification.parser import get_raw_array
710
from classification.exceptions import ClassificationError
811
from classification.config.constants import Sex, ALLOWED_FILE_EXTENSIONS
912
from classification.model import SleepStagesClassifier
10-
from classification.request import ClassificationRequest
11-
from classification.response import ClassificationResponse
1213
from classification.features.preprocessing import preprocess
13-
from classification.spectrogram_generator import SpectrogramGenerator
1414

1515
app = Flask(__name__)
1616
sleep_stage_classifier = SleepStagesClassifier()

backend/assets/readme.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,25 @@
1818
"report": {
1919
"sleepOnset": 1602211380, // Time at which the subject fell asleep (time of the first non-wake epoch)
2020
"sleepOffset": 1602242425, // Time at which the subject woke up (time of the epoch after the last non-wake epoch)
21-
"wakeAfterSleepOffset": 500, // [seconds] (wakeUpTime - sleepOffset)
22-
"totalSleepTime": 31045, // Total amount of time sleeping including nocturnal awakenings (sleepOffset - sleepOnset)
23-
"efficientSleepTime": 27113, // Total amount of seconds passed in non-wake stages
24-
"sleepEffeciency": 0.8733, // Overall sense of how well the patient slept (totalSleepTime/bedTime)
25-
"totalWASO": 3932, // Total amount of time passed in nocturnal awakenings. It is the total time passed in non-wake stage from sleep Onset to sleep offset (totalSleepTime - efficientSleepTime)
26-
"sleepLatency": 1000, // Time to fall asleep [seconds] (sleepOnset - bedTime)
2721
"remOnset": 1602214232, // First REM epoch
22+
23+
"sleepLatency": 1000, // Time to fall asleep [seconds] (sleepOnset - bedTime)
2824
"remLatency": 3852, // [seconds] (remOnset- bedTime)
25+
26+
"sleepEfficiency": 0.8733, // Overall sense of how well the patient slept (totalSleepTime/bedTime)
2927
"awakenings": 7, // number of times the subject woke up between sleep onset & offset
3028
"stageShifts": 89, // number of times the subject transitionned from one stage to another between sleep onset & offset
31-
"totalWTime": 3932, // [seconds] time passed in this stage between bedTime to wakeUpTime
32-
"totalREMTime": 2370,
33-
"totalN1Time": 3402,
34-
"totalN2Time": 16032,
35-
"totalN3Time": 5309
29+
30+
31+
"wakeAfterSleepOffset": 500, // [seconds] (wakeUpTime - sleepOffset)
32+
"efficientSleepTime": 27113, // Total amount of seconds passed in non-wake stages
33+
"WASO": 3932, // Total amount of time passed in nocturnal awakenings. It is the total time passed in non-wake stage from sleep Onset to sleep offset (totalSleepTime - efficientSleepTime)
34+
"WTime": 3932, // [seconds] time passed in this stage between bedTime to wakeUpTime
35+
"SleepTime": 31045, // Total amount of time sleeping including nocturnal awakenings (sleepOffset - sleepOnset)
36+
"REMTime": 2370,
37+
"N1Time": 3402,
38+
"N2Time": 16032,
39+
"N3Time": 5309
3640
},
3741
"epochs": {
3842
"timestamps": [

backend/backend/metric.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
from collections import Counter
2+
import numpy as np
3+
4+
from classification.config.constants import SleepStage, EPOCH_DURATION
5+
6+
7+
class Metrics():
8+
def __init__(self, sleep_stages, bedtime):
9+
self.sleep_stages = sleep_stages
10+
self.bedtime = bedtime
11+
self.has_slept = len(np.unique(self.sleep_stages)) != 1 or np.unique(self.sleep_stages)[0] != SleepStage.W.name
12+
13+
self.is_sleeping_stages = self.sleep_stages != SleepStage.W.name
14+
self.sleep_indexes = np.where(self.is_sleeping_stages)[0]
15+
self.is_last_stage_sleep = self.sleep_stages[-1] != SleepStage.W.name
16+
17+
self._initialize_sleep_offset()
18+
self._initialize_sleep_latency()
19+
self._initialize_rem_latency()
20+
self._initialize_transition_based_metrics()
21+
22+
@property
23+
def report(self):
24+
report = {
25+
'sleepOffset': self._sleep_offset,
26+
'sleepLatency': self._sleep_latency,
27+
'remLatency': self._rem_latency,
28+
'awakenings': self._awakenings,
29+
'stageShifts': self._stage_shifts,
30+
'sleepTime': self._sleep_time,
31+
'WASO': self._wake_after_sleep_onset,
32+
'sleepEfficiency': self._sleep_efficiency,
33+
'efficientSleepTime': self._efficient_sleep_time,
34+
'wakeAfterSleepOffset': self._wake_after_sleep_offset,
35+
'sleepOnset': self._sleep_onset,
36+
'remOnset': self._rem_onset,
37+
**self._time_passed_in_stage,
38+
}
39+
40+
for metric in report:
41+
# json does not recognize NumPy data types
42+
if isinstance(report[metric], np.int64):
43+
report[metric] = int(report[metric])
44+
45+
return report
46+
47+
@property
48+
def _sleep_time(self):
49+
if not self.has_slept:
50+
return 0
51+
52+
return self._sleep_offset - self._sleep_onset
53+
54+
@property
55+
def _wake_after_sleep_onset(self):
56+
if not self.has_slept:
57+
return 0
58+
59+
return self._sleep_time - self._efficient_sleep_time
60+
61+
@property
62+
def _time_passed_in_stage(self):
63+
"""Calculates time passed in each stage for all of the sequence"""
64+
nb_epoch_passed_by_stage = Counter(self.sleep_stages)
65+
66+
def get_time_passed(stage):
67+
return EPOCH_DURATION * nb_epoch_passed_by_stage[stage] if stage in nb_epoch_passed_by_stage else 0
68+
69+
return {
70+
f"{stage.upper()}Time": get_time_passed(stage)
71+
for stage in SleepStage.tolist()
72+
}
73+
74+
@property
75+
def _sleep_efficiency(self):
76+
return len(self.sleep_indexes) / len(self.sleep_stages)
77+
78+
@property
79+
def _efficient_sleep_time(self):
80+
return len(self.sleep_indexes) * EPOCH_DURATION
81+
82+
@property
83+
def _wake_after_sleep_offset(self):
84+
if not self.has_slept:
85+
return 0
86+
87+
wake_after_sleep_offset_nb_epochs = (
88+
len(self.sleep_stages) - self.sleep_indexes[-1] - 1
89+
) if not self.is_last_stage_sleep else 0
90+
91+
return wake_after_sleep_offset_nb_epochs * EPOCH_DURATION
92+
93+
@property
94+
def _sleep_onset(self):
95+
if not self.has_slept:
96+
return None
97+
98+
return self._sleep_latency + self.bedtime
99+
100+
@property
101+
def _rem_onset(self):
102+
rem_latency = self._rem_latency
103+
if rem_latency is None:
104+
return None
105+
106+
return rem_latency + self.bedtime
107+
108+
def _initialize_sleep_offset(self):
109+
if not self.has_slept:
110+
sleep_offset = None
111+
else:
112+
sleep_nb_epochs = (self.sleep_indexes[-1] + 1) if len(self.sleep_indexes) else len(self.sleep_stages)
113+
sleep_offset = sleep_nb_epochs * EPOCH_DURATION + self.bedtime
114+
115+
self._sleep_offset = sleep_offset
116+
117+
def _initialize_sleep_latency(self):
118+
self._sleep_latency = self._get_latency_of_stage(self.is_sleeping_stages)
119+
120+
def _initialize_rem_latency(self):
121+
"""Time it took to enter REM stage"""
122+
self._rem_latency = self._get_latency_of_stage(self.sleep_stages == SleepStage.REM.name)
123+
124+
def _initialize_transition_based_metrics(self):
125+
consecutive_stages_occurences = Counter(zip(self.sleep_stages[:-1], self.sleep_stages[1:]))
126+
occurences_by_transition = {
127+
consecutive_stages: consecutive_stages_occurences[consecutive_stages]
128+
for consecutive_stages in consecutive_stages_occurences if consecutive_stages[0] != consecutive_stages[1]
129+
}
130+
transition_occurences = list(occurences_by_transition.values())
131+
awakenings_occurences = [
132+
occurences_by_transition[transition_stages]
133+
for transition_stages in occurences_by_transition
134+
if transition_stages[0] != SleepStage.W.name
135+
and transition_stages[1] == SleepStage.W.name
136+
]
137+
nb_stage_shifts = sum(transition_occurences)
138+
nb_awakenings = sum(awakenings_occurences)
139+
140+
if self.is_last_stage_sleep and self.has_slept:
141+
nb_stage_shifts += 1
142+
nb_awakenings += 1
143+
144+
self._stage_shifts = nb_stage_shifts
145+
self._awakenings = nb_awakenings
146+
147+
def _get_latency_of_stage(self, sequence_is_stage):
148+
epochs_of_stage_of_interest = np.where(sequence_is_stage)[0]
149+
150+
if len(epochs_of_stage_of_interest) == 0:
151+
return None
152+
153+
return epochs_of_stage_of_interest[0] * EPOCH_DURATION

backend/classification/request.py renamed to backend/backend/request.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,21 @@
1212

1313

1414
class ClassificationRequest():
15-
def __init__(self, sex, age, stream_start, bedtime, wakeup, raw_eeg):
15+
def __init__(self, sex, age, stream_start, bedtime, wakeup, raw_eeg, stream_duration=None):
1616
self.sex = sex
1717
self.age = age
1818
self.stream_start = stream_start
1919
self.bedtime = bedtime
2020
self.wakeup = wakeup
21-
22-
self.stream_duration = raw_eeg.times[-1]
2321
self.raw_eeg = raw_eeg
22+
self.stream_duration = stream_duration if stream_duration else self._get_stream_duration()
2423

2524
self._validate()
2625

26+
def _get_stream_duration(self):
27+
PERIOD_DURATION = 1 / self.raw_eeg.info['sfreq']
28+
return self.raw_eeg.times[-1] + PERIOD_DURATION
29+
2730
@property
2831
def in_bed_seconds(self):
2932
"""timespan, in seconds, from which the subject started the recording and went to bed"""
Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import numpy as np
22

3+
from backend.metric import Metrics
34
from classification.config.constants import EPOCH_DURATION, SleepStage
45

56

@@ -15,42 +16,46 @@ def __init__(self, request, predictions, spectrogram):
1516

1617
self.spectrogram = spectrogram
1718
self.predictions = predictions
19+
self.metrics = Metrics(self.sleep_stages, self.bedtime)
1820

1921
@property
2022
def sleep_stages(self):
21-
ordered_sleep_stage_names = np.array([SleepStage(stage_index).name for stage_index in range(len(SleepStage))])
23+
ordered_sleep_stage_names = np.array(SleepStage.tolist())
2224
return ordered_sleep_stage_names[self.predictions]
2325

2426
@property
25-
def epochs(self):
27+
def response(self):
28+
return {
29+
'epochs': self._epochs,
30+
'report': self._report,
31+
'metadata': self._metadata,
32+
'subject': self._subject,
33+
'spectrograms': self.spectrogram,
34+
}
35+
36+
@property
37+
def _epochs(self):
2638
timestamps = np.arange(self.n_epochs * EPOCH_DURATION, step=EPOCH_DURATION) + self.bedtime
2739
return {'timestamps': timestamps.tolist(), 'stages': self.sleep_stages.tolist()}
2840

2941
@property
30-
def metadata(self):
42+
def _metadata(self):
3143
return {
3244
"sessionStartTime": self.stream_start,
3345
"sessionEndTime": self.stream_duration + self.stream_start,
3446
"totalSessionTime": self.stream_duration,
3547
"bedTime": self.bedtime,
36-
"wakeUpTime": None,
37-
"totalBedTime": None,
48+
"wakeUpTime": self.wakeup,
49+
"totalBedTime": self.wakeup - self.bedtime,
3850
}
3951

4052
@property
41-
def subject(self):
53+
def _subject(self):
4254
return {
4355
'age': self.age,
4456
'sex': self.sex.name,
4557
}
4658

4759
@property
48-
def response(self):
49-
return {
50-
'epochs': self.epochs,
51-
'report': None,
52-
'metadata': self.metadata,
53-
'subject': self.subject,
54-
'board': None,
55-
'spectrograms': self.spectrogram,
56-
}
60+
def _report(self):
61+
return self.metrics.report

backend/classification/config/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ class SleepStage(Enum):
1515
N3 = 3
1616
REM = 4
1717

18+
@staticmethod
19+
def tolist():
20+
return [e.name for e in SleepStage]
21+
1822

1923
class HiddenMarkovModelProbability(Enum):
2024
emission = auto()

backend/readme.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ If you want to run the backend with hot reload enabled (you must have installed
4141
hupper -m waitress app:app
4242
```
4343

44+
## Run the tests
45+
46+
You can run our unit tests with the following command, after installing the development requirements:
47+
48+
```bash
49+
pytest
50+
```
51+
4452
## Profile application
4553

4654
- Run `python profiler.py`

backend/requirements-dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
hupper==1.10.2
22
pyinstaller==4.0
3+
pytest==6.1.2
34
snakeviz==2.1.0
45
Werkzeug==1.0.1

backend/tests/setup.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from unittest.mock import patch
2+
3+
from backend.request import ClassificationRequest
4+
from classification.config.constants import Sex
5+
6+
7+
def pytest_generate_tests(metafunc):
8+
# called once per each test function
9+
funcarglist = metafunc.cls.params[metafunc.function.__name__]
10+
argnames = sorted(funcarglist[0])
11+
metafunc.parametrize(
12+
argnames, [[funcargs[name] for name in argnames] for funcargs in funcarglist]
13+
)
14+
15+
16+
def get_mock_request():
17+
with patch.object(ClassificationRequest, '_validate', lambda *x, **y: None):
18+
mock_request = ClassificationRequest(
19+
sex=Sex.M,
20+
age=22,
21+
stream_start=1582418280,
22+
bedtime=1582423980,
23+
wakeup=1582452240,
24+
raw_eeg=None,
25+
stream_duration=35760,
26+
)
27+
28+
return mock_request

0 commit comments

Comments
 (0)